feat(player): PC 端列表/布局适配与分页
玩家端桌面:全宽居中 Logo、圆角侧栏与主内容面板;开奖结果/注单改 shadcn Table+分页(移动仍无限滚动),注单组默认折叠;信用/钱包筛选与分页;详情页与奖号网格密度优化。
This commit is contained in:
@@ -165,10 +165,10 @@
|
||||
--sidebar-border: #e4ebf5;
|
||||
}
|
||||
|
||||
/* 玩家端桌面壳:侧栏宽度供遮罩/弹层定位 */
|
||||
/* 玩家端桌面壳:侧栏宽度供遮罩/弹层定位(含外侧留白) */
|
||||
@media (min-width: 1024px) {
|
||||
.player-desktop-shell {
|
||||
--player-sidebar-width: 14rem;
|
||||
--player-sidebar-width: 15rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ type PlayerAppShellProps = {
|
||||
|
||||
/**
|
||||
* 玩家端外壳:
|
||||
* - 移动:主体 + 底部 Tab(H5)
|
||||
* - 桌面:侧栏 + 顶栏 + 宽主内容区(UI 设计稿)
|
||||
* - 移动:顶栏 + 主体 + 底部 Tab
|
||||
* - 桌面:全宽顶栏(Logo 整页居中)+ 侧栏 + 主内容
|
||||
*/
|
||||
export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
|
||||
const isMobile = useIsMobile();
|
||||
@@ -33,34 +33,37 @@ export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-1 overflow-hidden bg-white text-foreground lg:flex-row">
|
||||
<PlayerSidebar />
|
||||
<div className="player-desktop-shell flex h-full min-h-0 flex-1 flex-col overflow-hidden bg-white text-foreground lg:bg-[#f0f2f6]">
|
||||
<PlayerDesktopHeader />
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{isMobile ? (
|
||||
<PullToRefreshIndicator
|
||||
pullDistance={pullDistance}
|
||||
isRefreshing={isRefreshing}
|
||||
threshold={70}
|
||||
/>
|
||||
) : null}
|
||||
<NetworkStatusBanner />
|
||||
<PlayerDesktopHeader />
|
||||
<div className="flex min-h-0 min-w-0 flex-1 overflow-hidden lg:flex-row lg:gap-0 lg:px-0">
|
||||
<PlayerSidebar />
|
||||
|
||||
<main
|
||||
id="player-scroll-container"
|
||||
className={cn(
|
||||
"flex min-h-0 w-full flex-1 flex-col overflow-y-auto overscroll-y-contain bg-[#f8fafc] lg:bg-[#f0f2f6]",
|
||||
playerMainInset,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden lg:py-4 lg:pr-4">
|
||||
{isMobile ? (
|
||||
<PullToRefreshIndicator
|
||||
pullDistance={pullDistance}
|
||||
isRefreshing={isRefreshing}
|
||||
threshold={70}
|
||||
/>
|
||||
) : null}
|
||||
<NetworkStatusBanner />
|
||||
|
||||
<div className="relative z-50 w-full shrink-0 lg:hidden">
|
||||
<PlayerBottomNav />
|
||||
<main
|
||||
id="player-scroll-container"
|
||||
className={cn(
|
||||
"flex min-h-0 w-full flex-1 flex-col overflow-y-auto overscroll-y-contain bg-[#f8fafc] lg:rounded-2xl lg:border lg:border-[#e4ebf5] lg:bg-white lg:shadow-[0_8px_24px_rgba(15,23,42,0.04)]",
|
||||
playerMainInset,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<div className="relative z-50 w-full shrink-0 lg:hidden">
|
||||
<PlayerBottomNav />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,15 +10,16 @@ type PlayerDesktopHeaderProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** 全局顶栏:移动显示 Logo;桌面 Logo 在侧栏顶部,此处仅语言切换 */
|
||||
/** 全局顶栏:移动 Logo 左对齐;桌面 Logo 整页水平居中,语言切换靠右 */
|
||||
export function PlayerDesktopHeader({ className }: PlayerDesktopHeaderProps) {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"flex h-12 shrink-0 items-center justify-between border-b border-[#e4ebf5] bg-white px-3 lg:h-14 lg:justify-end lg:px-6",
|
||||
"relative z-20 flex h-12 shrink-0 items-center justify-between border-b border-[#e4ebf5] bg-white px-3 lg:h-14 lg:px-6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* 移动:左 Logo */}
|
||||
<Link href="/hall" className="inline-flex min-w-0 items-center lg:hidden" aria-label="N lotto">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
@@ -30,7 +31,23 @@ export function PlayerDesktopHeader({ className }: PlayerDesktopHeaderProps) {
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* 桌面:整页水平居中 Logo */}
|
||||
<Link
|
||||
href="/hall"
|
||||
className="pointer-events-auto absolute left-1/2 top-1/2 hidden -translate-x-1/2 -translate-y-1/2 lg:inline-flex"
|
||||
aria-label="N lotto"
|
||||
>
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="N lotto"
|
||||
width={243}
|
||||
height={84}
|
||||
className="h-8 w-auto max-w-[180px] object-contain"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
<LanguageSwitcher
|
||||
variant="minimal"
|
||||
menuAlign="end"
|
||||
|
||||
157
src/components/layout/player-list-pagination.tsx
Normal file
157
src/components/layout/player-list-pagination.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** 页码切片:首尾 + 当前邻页,中间用省略号 */
|
||||
export function playerListPageSlices(
|
||||
current: number,
|
||||
last: number,
|
||||
delta = 1,
|
||||
): (number | "ellipsis")[] {
|
||||
if (last <= 1) {
|
||||
return last === 1 ? [1] : [];
|
||||
}
|
||||
const cap = delta * 2 + 3;
|
||||
if (last <= cap) {
|
||||
return Array.from({ length: last }, (_, i) => i + 1);
|
||||
}
|
||||
const set = new Set<number>([1, last]);
|
||||
for (let p = current - delta; p <= current + delta; p++) {
|
||||
if (p >= 1 && p <= last) set.add(p);
|
||||
}
|
||||
const sorted = [...set].sort((a, b) => a - b);
|
||||
const out: (number | "ellipsis")[] = [];
|
||||
let prev = 0;
|
||||
for (const p of sorted) {
|
||||
if (prev && p - prev > 1) out.push("ellipsis");
|
||||
out.push(p);
|
||||
prev = p;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type PlayerListPaginationProps = {
|
||||
page: number;
|
||||
lastPage: number;
|
||||
total?: number;
|
||||
loading?: boolean;
|
||||
onPageChange: (page: number) => void;
|
||||
className?: string;
|
||||
/** 隐藏「共 x 条」摘要 */
|
||||
hideSummary?: boolean;
|
||||
};
|
||||
|
||||
/** PC 列表底部分页(移动端仍用无限滚动,勿挂载本组件) */
|
||||
export function PlayerListPagination({
|
||||
page,
|
||||
lastPage,
|
||||
total,
|
||||
loading = false,
|
||||
onPageChange,
|
||||
className,
|
||||
hideSummary = false,
|
||||
}: PlayerListPaginationProps) {
|
||||
const { t } = useTranslation("player");
|
||||
|
||||
if (lastPage <= 1 && (total == null || total <= 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"hidden items-center justify-between gap-3 border-t border-[#e9eff8] bg-[#fbfdff] px-4 py-3 lg:flex",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!hideSummary ? (
|
||||
<p className="text-xs font-semibold tabular-nums text-[#59739f]">
|
||||
{total != null
|
||||
? t("pagination.summary", {
|
||||
defaultValue: "共 {{total}} 条,第 {{page}} / {{lastPage}} 页",
|
||||
total,
|
||||
page,
|
||||
lastPage: Math.max(1, lastPage),
|
||||
})
|
||||
: t("pagination.pageOf", {
|
||||
defaultValue: "第 {{page}} / {{lastPage}} 页",
|
||||
page,
|
||||
lastPage: Math.max(1, lastPage),
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
|
||||
{lastPage > 1 ? (
|
||||
<Pagination className="mx-0 w-auto justify-end">
|
||||
<PaginationContent className="flex-wrap justify-end">
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
type="button"
|
||||
text={t("pagination.previous", { defaultValue: "上一页" })}
|
||||
disabled={page <= 1 || loading}
|
||||
className="h-8 rounded-lg border border-[#dce7f7] bg-white text-[#32518d] hover:bg-[#f8fbff]"
|
||||
onClick={() => {
|
||||
if (page <= 1 || loading) return;
|
||||
onPageChange(Math.max(1, page - 1));
|
||||
}}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{playerListPageSlices(page, lastPage).map((item, idx) =>
|
||||
item === "ellipsis" ? (
|
||||
<PaginationItem key={`e-${idx}`}>
|
||||
<PaginationEllipsis className="text-[#7890b8]" />
|
||||
</PaginationItem>
|
||||
) : (
|
||||
<PaginationItem key={item}>
|
||||
<PaginationLink
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
isActive={item === page}
|
||||
disabled={loading}
|
||||
className={cn(
|
||||
"min-w-8 tabular-nums",
|
||||
item === page
|
||||
? "border-[#0b3f96] bg-white font-black text-[#0b3f96]"
|
||||
: "text-[#32518d] hover:bg-[#f8fbff]",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (loading || item === page) return;
|
||||
onPageChange(item);
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
),
|
||||
)}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
type="button"
|
||||
text={t("pagination.next", { defaultValue: "下一页" })}
|
||||
disabled={page >= lastPage || loading}
|
||||
className="h-8 rounded-lg border border-[#dce7f7] bg-white text-[#32518d] hover:bg-[#f8fbff]"
|
||||
onClick={() => {
|
||||
if (page >= lastPage || loading) return;
|
||||
onPageChange(Math.min(lastPage, page + 1));
|
||||
}}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
@@ -57,14 +56,13 @@ const navItems = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** 与 lotteryadmin `NAV_BTN` / `NAV_ACTIVE` 一致 */
|
||||
const playerNavLinkClass =
|
||||
"flex h-8 w-full items-center gap-2 rounded-md px-2.5 text-[13px] leading-snug font-normal text-sidebar-foreground/90 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0";
|
||||
"flex h-10 w-full items-center gap-2 rounded-xl px-3 text-sm leading-none font-medium text-sidebar-foreground/90 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0";
|
||||
|
||||
const playerNavLinkActiveClass =
|
||||
"bg-sidebar-primary/90 font-medium text-sidebar-primary-foreground shadow-sm hover:bg-sidebar-primary/90 hover:text-sidebar-primary-foreground";
|
||||
"bg-sidebar-primary/90 font-semibold text-sidebar-primary-foreground shadow-sm hover:bg-sidebar-primary/90 hover:text-sidebar-primary-foreground";
|
||||
|
||||
/** 桌面端左侧导航(激活态对齐后台侧栏) */
|
||||
/** 桌面端左侧导航:与内容区同高的圆角面板,不贴边 */
|
||||
export function PlayerSidebar() {
|
||||
const pathname = usePathname() ?? "";
|
||||
const { t } = useTranslation("player");
|
||||
@@ -72,23 +70,10 @@ export function PlayerSidebar() {
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="player-sidebar hidden h-full w-56 shrink-0 flex-col border-r border-[#e4ebf5] bg-white lg:flex"
|
||||
className="player-sidebar hidden w-[13.5rem] shrink-0 flex-col py-4 pl-4 pr-0 lg:flex"
|
||||
aria-label={t("nav.aria")}
|
||||
>
|
||||
<div className="shrink-0 border-b border-[#e4ebf5] px-2 py-2">
|
||||
<Link href="/hall" className="flex h-10 items-center px-1" aria-label="N lotto">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="N lotto"
|
||||
width={243}
|
||||
height={84}
|
||||
className="h-auto max-h-10 w-full object-contain object-left"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto px-1.5 py-1.5">
|
||||
<nav className="flex h-full min-h-0 flex-col gap-1 overflow-y-auto rounded-2xl border border-[#e4ebf5] bg-white p-2 shadow-[0_8px_24px_rgba(15,23,42,0.04)]">
|
||||
{navItems.map(({ href, labelKey, labelDefault, icon: Icon, match, ...item }) => {
|
||||
const active = match(pathname);
|
||||
const creditLabelKey = "creditLabelKey" in item ? item.creditLabelKey : undefined;
|
||||
@@ -115,4 +100,4 @@ export function PlayerSidebar() {
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
127
src/components/ui/pagination.tsx
Normal file
127
src/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import * as React from "react";
|
||||
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={cn("flex items-center gap-0.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="pagination-item" {...props} />;
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean;
|
||||
disabled?: boolean;
|
||||
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||
Omit<React.ComponentProps<typeof Button>, "size" | "variant">;
|
||||
|
||||
function PaginationLink({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
disabled,
|
||||
...props
|
||||
}: PaginationLinkProps) {
|
||||
return (
|
||||
<Button
|
||||
variant={isActive ? "outline" : "ghost"}
|
||||
size={size}
|
||||
disabled={disabled}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive || undefined}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationPrevious({
|
||||
className,
|
||||
text = "Previous",
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 pl-2!", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon data-icon="inline-start" />
|
||||
<span className="hidden sm:block">{text}</span>
|
||||
</PaginationLink>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationNext({
|
||||
className,
|
||||
text = "Next",
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 pr-2!", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="hidden sm:block">{text}</span>
|
||||
<ChevronRightIcon data-icon="inline-end" />
|
||||
</PaginationLink>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="pagination-ellipsis"
|
||||
className={cn(
|
||||
"flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
};
|
||||
@@ -11,6 +11,14 @@ import { getWalletBalance } from "@/api/wallet";
|
||||
import { postTicketPlace, postTicketPreview } from "@/api/ticket";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { isHallSealedCountdownUi } from "@/features/draw/draw-status-meta";
|
||||
@@ -25,6 +33,13 @@ import {
|
||||
ticketNumberSpec,
|
||||
type DraftLineIssueReason,
|
||||
} from "@/features/hall/hall-bet-rules";
|
||||
import {
|
||||
isHighCostSelectionType,
|
||||
resolveSelectionTotalBet,
|
||||
selectionCombinationCount,
|
||||
selectionTypesForCategory,
|
||||
type SelectionType,
|
||||
} from "@/features/hall/selection-type";
|
||||
import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live";
|
||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||
import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling";
|
||||
@@ -64,9 +79,13 @@ type DraftRow = {
|
||||
number: string;
|
||||
amounts: Record<string, string>;
|
||||
providerCodes: string[];
|
||||
selectionType: "straight" | "reverse" | "full_cover" | "full_play" | "half_play";
|
||||
selectionType: SelectionType;
|
||||
};
|
||||
|
||||
type PendingSelectionChange =
|
||||
| { mode: "row"; rowId: string; next: SelectionType; prev: SelectionType }
|
||||
| { mode: "all"; next: SelectionType };
|
||||
|
||||
type DraftEntry = {
|
||||
rowId: string;
|
||||
rowNo: number;
|
||||
@@ -174,9 +193,24 @@ const D4_PLAY_ORDER = [
|
||||
"digit_big",
|
||||
"digit_small",
|
||||
] as const;
|
||||
/** 按大厅 Tab 对应的玩法顺序;汇总条默认以当前 Tab 玩法开头 */
|
||||
const PLAY_ORDER_BY_CATEGORY: Record<PlayHallCategory, readonly string[]> = {
|
||||
D2: D2_PLAY_ORDER,
|
||||
D3: D3_PLAY_ORDER,
|
||||
D4: D4_PLAY_ORDER,
|
||||
};
|
||||
const CATEGORY_ORDER: readonly PlayHallCategory[] = ["D4", "D3", "D2"];
|
||||
const MOBILE_QUICK_AMOUNT_PRESETS = ["10", "50", "100"] as const;
|
||||
const DEFAULT_DRAFT_ROW_COUNT = 20;
|
||||
|
||||
function playOrderForActiveCategory(activeCategory: PlayHallCategory): readonly string[] {
|
||||
const rest = CATEGORY_ORDER.filter((category) => category !== activeCategory);
|
||||
return [
|
||||
...PLAY_ORDER_BY_CATEGORY[activeCategory],
|
||||
...rest.flatMap((category) => PLAY_ORDER_BY_CATEGORY[category]),
|
||||
];
|
||||
}
|
||||
|
||||
function newDraftRows(count = DEFAULT_DRAFT_ROW_COUNT): DraftRow[] {
|
||||
return Array.from({ length: count }, newDraftRow);
|
||||
}
|
||||
@@ -290,7 +324,7 @@ function lineForPlay(
|
||||
displayNumber: string,
|
||||
amountMinor: number,
|
||||
digitSlot?: number,
|
||||
selectionType: DraftRow["selectionType"] = "straight",
|
||||
selectionType: SelectionType = "straight",
|
||||
): TicketLineInput | null {
|
||||
const number = normalizeNumberForPlay(displayNumber, play.play_code);
|
||||
if (draftLineIssueReason(play.play_code, displayNumber, digitSlot) !== null) {
|
||||
@@ -504,6 +538,9 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
const [liveSoldOutNumbers, setLiveSoldOutNumbers] = useState<Set<string>>(() => new Set());
|
||||
const [liveWarningNumbers, setLiveWarningNumbers] = useState<Set<string>>(() => new Set());
|
||||
const [debouncedSummary, setDebouncedSummary] = useState({ bet: 0, rebate: 0, actual: 0 });
|
||||
const [pendingSelectionChange, setPendingSelectionChange] = useState<PendingSelectionChange | null>(
|
||||
null,
|
||||
);
|
||||
const holdFavoriteRef = useRef<{ timer: number | null; number: string | null; longPress: boolean }>({
|
||||
timer: null,
|
||||
number: null,
|
||||
@@ -511,6 +548,8 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
});
|
||||
/** 单次预览→确认共用,重试 place 复用,避免重复扣款 */
|
||||
const placeTraceIdRef = useRef<string | null>(null);
|
||||
/** 玩法汇总条:切 Tab 后滚回左侧,保证当前维度玩法从开头可见 */
|
||||
const playSummaryScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const newPlaceTraceId = (): string =>
|
||||
typeof crypto !== "undefined" && crypto.randomUUID
|
||||
@@ -607,7 +646,8 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
|
||||
const openPlays = useMemo(() => {
|
||||
if (catalogState.kind !== "ok") return [];
|
||||
const order: readonly string[] = [...D2_PLAY_ORDER, ...D3_PLAY_ORDER, ...D4_PLAY_ORDER];
|
||||
// 4D / 3D / 2D 汇总条与列顺序以当前 Tab 玩法为先,避免切到 4D 仍从 2A 起显示
|
||||
const order = playOrderForActiveCategory(activeCategory);
|
||||
const orderSet = new Set(order);
|
||||
return sortByPlayOrder(
|
||||
catalogState.data.plays
|
||||
@@ -615,7 +655,12 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
.filter((p) => orderSet.has(p.play_code)),
|
||||
order,
|
||||
);
|
||||
}, [catalogState]);
|
||||
}, [activeCategory, catalogState]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = playSummaryScrollRef.current;
|
||||
if (el) el.scrollLeft = 0;
|
||||
}, [activeCategory]);
|
||||
|
||||
const activeCategoryPlays = useMemo(
|
||||
() => openPlays.filter((play) => TRADITIONAL_PLAY_CODES[activeCategory].includes(play.play_code)),
|
||||
@@ -712,14 +757,57 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
setActiveRowId(id);
|
||||
}, [activeCategory]);
|
||||
|
||||
const updateRowSelectionType = useCallback((id: string, selectionType: DraftRow["selectionType"]) => {
|
||||
setRows((current) => current.map((row) => row.id === id ? { ...row, selectionType } : row));
|
||||
const applyRowSelectionType = useCallback((id: string, selectionType: SelectionType) => {
|
||||
setRows((current) => current.map((row) => (row.id === id ? { ...row, selectionType } : row)));
|
||||
}, []);
|
||||
|
||||
const updateAllSelectionTypes = useCallback((selectionType: DraftRow["selectionType"]) => {
|
||||
const applyAllSelectionTypes = useCallback((selectionType: SelectionType) => {
|
||||
setRows((current) => current.map((row) => ({ ...row, selectionType })));
|
||||
}, []);
|
||||
|
||||
const requestRowSelectionType = useCallback(
|
||||
(id: string, selectionType: SelectionType) => {
|
||||
const row = rows.find((item) => item.id === id);
|
||||
if (!row || row.selectionType === selectionType) return;
|
||||
if (isHighCostSelectionType(selectionType) && !isHighCostSelectionType(row.selectionType)) {
|
||||
setPendingSelectionChange({
|
||||
mode: "row",
|
||||
rowId: id,
|
||||
next: selectionType,
|
||||
prev: row.selectionType,
|
||||
});
|
||||
return;
|
||||
}
|
||||
applyRowSelectionType(id, selectionType);
|
||||
},
|
||||
[applyRowSelectionType, rows],
|
||||
);
|
||||
|
||||
const requestAllSelectionTypes = useCallback(
|
||||
(selectionType: SelectionType) => {
|
||||
if (isHighCostSelectionType(selectionType)) {
|
||||
setPendingSelectionChange({ mode: "all", next: selectionType });
|
||||
return;
|
||||
}
|
||||
applyAllSelectionTypes(selectionType);
|
||||
},
|
||||
[applyAllSelectionTypes],
|
||||
);
|
||||
|
||||
const confirmPendingSelectionChange = useCallback(() => {
|
||||
if (!pendingSelectionChange) return;
|
||||
if (pendingSelectionChange.mode === "row") {
|
||||
applyRowSelectionType(pendingSelectionChange.rowId, pendingSelectionChange.next);
|
||||
} else {
|
||||
applyAllSelectionTypes(pendingSelectionChange.next);
|
||||
}
|
||||
setPendingSelectionChange(null);
|
||||
}, [applyAllSelectionTypes, applyRowSelectionType, pendingSelectionChange]);
|
||||
|
||||
const cancelPendingSelectionChange = useCallback(() => {
|
||||
setPendingSelectionChange(null);
|
||||
}, []);
|
||||
|
||||
const updateAmount = useCallback((rowId: string, playCode: string, value: string) => {
|
||||
const amount = sanitizeAmount(value);
|
||||
const column = allPlayColumns.find((item) => item.key === playCode);
|
||||
@@ -1076,18 +1164,84 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
const draftSummary = useMemo(() => {
|
||||
return draftEntries.reduce(
|
||||
(acc, entry) => {
|
||||
const selectionType = entry.line.selection_type ?? "straight";
|
||||
const comboCount = selectionCombinationCount(entry.number, selectionType);
|
||||
const totalBet = resolveSelectionTotalBet(entry.amountMinor, selectionType, comboCount);
|
||||
const rebateRate = parseRebateRate(entry.play.odds?.rebate_rate);
|
||||
const rebate = Math.round(entry.amountMinor * rebateRate);
|
||||
const rebate = Math.round(totalBet * rebateRate);
|
||||
const providerCount = entry.line.provider_codes?.length ?? 0;
|
||||
acc.bet += entry.amountMinor * providerCount;
|
||||
acc.bet += totalBet * providerCount;
|
||||
acc.rebate += rebate * providerCount;
|
||||
acc.actual += Math.max(0, entry.amountMinor - rebate) * providerCount;
|
||||
acc.actual += Math.max(0, totalBet - rebate) * providerCount;
|
||||
return acc;
|
||||
},
|
||||
{ bet: 0, rebate: 0, actual: 0 },
|
||||
);
|
||||
}, [draftEntries]);
|
||||
|
||||
const selectionTypeOptions = useMemo(
|
||||
() => selectionTypesForCategory(activeCategory),
|
||||
[activeCategory],
|
||||
);
|
||||
|
||||
const pendingSelectionPreview = useMemo(() => {
|
||||
if (!pendingSelectionChange) return null;
|
||||
const next = pendingSelectionChange.next;
|
||||
|
||||
const rowStake = (
|
||||
row: DraftRow,
|
||||
selectionType: SelectionType,
|
||||
mode: "resolved" | "raw",
|
||||
): number => {
|
||||
const comboCount = selectionCombinationCount(row.number, selectionType);
|
||||
const providerCount = Math.max(1, row.providerCodes.length);
|
||||
const stake = playColumns.reduce((total, column) => {
|
||||
if (draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) !== null) {
|
||||
return total;
|
||||
}
|
||||
const amount = parseDecimalInputToMinor(row.amounts[column.key] ?? "", currencyCode) ?? 0;
|
||||
if (amount <= 0) return total;
|
||||
if (mode === "raw") return total + amount;
|
||||
return total + resolveSelectionTotalBet(amount, selectionType, comboCount);
|
||||
}, 0);
|
||||
return stake * providerCount;
|
||||
};
|
||||
|
||||
if (pendingSelectionChange.mode === "row") {
|
||||
const row = rows.find((item) => item.id === pendingSelectionChange.rowId);
|
||||
if (!row) return null;
|
||||
const comboCount = selectionCombinationCount(row.number, next);
|
||||
const fromMinor = rowStake(row, pendingSelectionChange.prev, "resolved");
|
||||
const toMinor =
|
||||
next === "full_cover" ? rowStake(row, next, "raw") : rowStake(row, next, "resolved");
|
||||
return {
|
||||
next,
|
||||
comboCount,
|
||||
number: row.number,
|
||||
fromMinor,
|
||||
toMinor,
|
||||
scope: "row" as const,
|
||||
};
|
||||
}
|
||||
|
||||
let fromMinor = 0;
|
||||
let toMinor = 0;
|
||||
let maxCombo = 1;
|
||||
rows.forEach((row) => {
|
||||
maxCombo = Math.max(maxCombo, selectionCombinationCount(row.number, next));
|
||||
fromMinor += rowStake(row, row.selectionType, "resolved");
|
||||
toMinor += next === "full_cover" ? rowStake(row, next, "raw") : rowStake(row, next, "resolved");
|
||||
});
|
||||
return {
|
||||
next,
|
||||
comboCount: maxCombo,
|
||||
number: "",
|
||||
fromMinor,
|
||||
toMinor,
|
||||
scope: "all" as const,
|
||||
};
|
||||
}, [currencyCode, pendingSelectionChange, playColumns, rows]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => {
|
||||
setDebouncedSummary(draftSummary);
|
||||
@@ -1377,30 +1531,105 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"bg-white",
|
||||
isMobile
|
||||
? "rounded-lg border border-[#e8eef7] px-3 py-2.5"
|
||||
: "rounded-xl border border-[#e3ebf7] px-3 py-3 shadow-[0_8px_24px_rgba(15,23,42,0.045)]",
|
||||
!isMobile &&
|
||||
"flex items-center gap-2 rounded-xl border border-[#e3ebf7] bg-white px-2 py-2 shadow-[0_6px_18px_rgba(15,23,42,0.04)]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-bold leading-5 text-slate-950">
|
||||
{t("hall.quickFill.title")}
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-white",
|
||||
isMobile
|
||||
? "rounded-lg border border-[#e8eef7] px-3 py-2.5"
|
||||
: "min-w-0 flex-1 px-2 py-0.5",
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex gap-3", isMobile ? "items-start justify-between" : "items-center justify-between")}>
|
||||
<div className={cn("min-w-0", !isMobile && "flex flex-1 items-center gap-4")}>
|
||||
<div className={cn("shrink-0", !isMobile && "min-w-[5.5rem]")}>
|
||||
<p className="text-sm font-bold leading-5 text-slate-950">
|
||||
{t("hall.quickFill.title")}
|
||||
</p>
|
||||
{isMobile ? (
|
||||
<p className="mt-0.5 text-[11px] leading-5 text-slate-500">
|
||||
{t("hall.mobile.quickFillSummary", {
|
||||
defaultValue: "收藏 {{favorites}} 个,历史 {{history}} 个",
|
||||
favorites: favorites.length,
|
||||
history: historyNumbers.length,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!isMobile ? (
|
||||
<p className="mt-0.5 text-xs leading-5 text-slate-500">
|
||||
{t("hall.quickFill.description")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-0.5 text-[11px] leading-5 text-slate-500">
|
||||
{t("hall.mobile.quickFillSummary", {
|
||||
defaultValue: "收藏 {{favorites}} 个,历史 {{history}} 个",
|
||||
favorites: favorites.length,
|
||||
history: historyNumbers.length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-4 gap-y-1.5">
|
||||
{favoriteChips.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="mr-0.5 text-[11px] font-semibold text-[#d81435]">
|
||||
{t("hall.quickFill.favorites")}
|
||||
</span>
|
||||
{favoriteChips.map((number) => (
|
||||
<button
|
||||
key={`fav-${number}`}
|
||||
type="button"
|
||||
className="inline-flex h-7 touch-manipulation items-center gap-1 rounded-full border border-[#ffd7db] bg-[#fff3f5] px-2.5 text-xs font-semibold text-[#d81435] transition-colors hover:bg-[#ffe9ed]"
|
||||
onPointerDown={() => {
|
||||
const current = holdFavoriteRef.current;
|
||||
current.number = number;
|
||||
current.longPress = false;
|
||||
if (current.timer) window.clearTimeout(current.timer);
|
||||
current.timer = window.setTimeout(() => {
|
||||
current.longPress = true;
|
||||
toggleFavoriteNumber(number);
|
||||
}, 500);
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
const current = holdFavoriteRef.current;
|
||||
if (current.timer) {
|
||||
window.clearTimeout(current.timer);
|
||||
current.timer = null;
|
||||
}
|
||||
if (!current.longPress) {
|
||||
fillCurrentRow(number);
|
||||
}
|
||||
current.longPress = false;
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
const current = holdFavoriteRef.current;
|
||||
if (current.timer) {
|
||||
window.clearTimeout(current.timer);
|
||||
current.timer = null;
|
||||
}
|
||||
current.longPress = false;
|
||||
}}
|
||||
>
|
||||
{number}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<span className="mr-0.5 text-[11px] font-semibold text-slate-400">
|
||||
{t("hall.quickFill.history")}
|
||||
</span>
|
||||
{historyChips.length > 0 ? (
|
||||
historyChips.map((number) => (
|
||||
<button
|
||||
key={`his-${number}`}
|
||||
type="button"
|
||||
className="inline-flex h-7 min-w-9 items-center justify-center rounded-full border border-[#d7e5f8] bg-[#f8fbff] px-2.5 text-xs font-bold text-[#07459f] transition-colors hover:border-[#b9d0f3] hover:bg-[#eef6ff]"
|
||||
onClick={() => fillCurrentRow(number)}
|
||||
>
|
||||
{number}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<span className="inline-flex h-7 items-center rounded-full border border-dashed border-slate-200 bg-slate-50 px-2.5 text-xs text-slate-400">
|
||||
{t("hall.quickFill.emptyHistory")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{isMobile ? (
|
||||
@@ -1425,167 +1654,176 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
</Button>
|
||||
) : null}
|
||||
{activeRow?.number ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={
|
||||
favorites.includes(activeRow.number)
|
||||
? t("hall.quickFill.unfavorite")
|
||||
: t("hall.quickFill.favorite")
|
||||
}
|
||||
aria-pressed={favorites.includes(activeRow.number)}
|
||||
title={
|
||||
favorites.includes(activeRow.number)
|
||||
? t("hall.quickFill.unfavorite")
|
||||
: t("hall.quickFill.favorite")
|
||||
}
|
||||
className={cn(
|
||||
"size-8 rounded-full border border-[#ffd7db] bg-[#fff7f8] text-[#d81435] hover:bg-[#fff1f3] hover:text-[#b80f2b]",
|
||||
favorites.includes(activeRow.number) &&
|
||||
"border-[#d81435] bg-[#d81435] text-white hover:bg-[#c51230] hover:text-white",
|
||||
)}
|
||||
onClick={() => toggleFavoriteNumber(activeRow.number)}
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
"size-4",
|
||||
favorites.includes(activeRow.number) && "fill-current",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("hall.quickFill.clearAll")}
|
||||
title={t("hall.quickFill.clearAll")}
|
||||
className="size-8 rounded-full border border-[#dfe6f0] bg-[#f8fafc] text-slate-500 hover:bg-slate-100 hover:text-slate-900"
|
||||
onClick={clearAllRows}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={
|
||||
favorites.includes(activeRow.number)
|
||||
? t("hall.quickFill.unfavorite")
|
||||
: t("hall.quickFill.favorite")
|
||||
}
|
||||
aria-pressed={favorites.includes(activeRow.number)}
|
||||
title={
|
||||
favorites.includes(activeRow.number)
|
||||
? t("hall.quickFill.unfavorite")
|
||||
: t("hall.quickFill.favorite")
|
||||
}
|
||||
className={cn(
|
||||
"size-8 rounded-full border border-[#ffd7db] bg-[#fff7f8] text-[#d81435] hover:bg-[#fff1f3] hover:text-[#b80f2b]",
|
||||
favorites.includes(activeRow.number) &&
|
||||
"border-[#d81435] bg-[#d81435] text-white hover:bg-[#c51230] hover:text-white",
|
||||
)}
|
||||
onClick={() => toggleFavoriteNumber(activeRow.number)}
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
"size-4",
|
||||
favorites.includes(activeRow.number) && "fill-current",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("hall.quickFill.clearAll")}
|
||||
title={t("hall.quickFill.clearAll")}
|
||||
className="size-8 rounded-full border border-[#dfe6f0] bg-[#f8fafc] text-slate-500 hover:bg-slate-100 hover:text-slate-900"
|
||||
onClick={clearAllRows}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn("space-y-2", isMobile ? "mt-2" : "mt-3")}>
|
||||
{isMobile && quickFillExpanded ? (
|
||||
<div className="rounded-xl border border-[#e7eef8] bg-[#f8fbff] px-3 py-2.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[11px] font-semibold text-[#5b6f95]">
|
||||
{t("hall.quickFill.batchTitle", { defaultValue: "Quick amount" })}
|
||||
</span>
|
||||
{MOBILE_QUICK_AMOUNT_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
disabled={tableDisabled}
|
||||
onClick={() => applyQuickAmountToActiveRow(preset)}
|
||||
className="inline-flex h-8 min-w-12 items-center justify-center rounded-full border border-[#cfe0fb] bg-white px-3 text-xs font-bold text-[#0b4ab3] transition-colors hover:border-[#aac7f5] hover:bg-[#edf5ff] disabled:opacity-40"
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
disabled={tableDisabled || !activeRow?.number}
|
||||
onClick={copyPreviousRowToActiveRow}
|
||||
className="inline-flex h-8 items-center justify-center rounded-full border border-[#dfe6f0] bg-white px-3 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-40"
|
||||
>
|
||||
{t("hall.quickFill.copyPrev", { defaultValue: "Copy prev row" })}
|
||||
</button>
|
||||
{isMobile ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
{quickFillExpanded ? (
|
||||
<div className="rounded-xl border border-[#e7eef8] bg-[#f8fbff] px-3 py-2.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[11px] font-semibold text-[#5b6f95]">
|
||||
{t("hall.quickFill.batchTitle", { defaultValue: "Quick amount" })}
|
||||
</span>
|
||||
{MOBILE_QUICK_AMOUNT_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
disabled={tableDisabled}
|
||||
onClick={clearActiveRowAmounts}
|
||||
className="inline-flex h-8 items-center justify-center rounded-full border border-[#ffe0e5] bg-white px-3 text-xs font-semibold text-[#d81435] transition-colors hover:bg-[#fff4f6] disabled:opacity-40"
|
||||
onClick={() => applyQuickAmountToActiveRow(preset)}
|
||||
className="inline-flex h-8 min-w-12 items-center justify-center rounded-full border border-[#cfe0fb] bg-white px-3 text-xs font-bold text-[#0b4ab3] transition-colors hover:border-[#aac7f5] hover:bg-[#edf5ff] disabled:opacity-40"
|
||||
>
|
||||
{t("hall.quickFill.clearRow", { defaultValue: "Clear row amounts" })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isMobile || quickFillExpanded ? (
|
||||
<>
|
||||
{favoriteChips.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="mr-0.5 text-[11px] font-semibold text-[#d81435]">
|
||||
{t("hall.quickFill.favorites")}
|
||||
</span>
|
||||
{favoriteChips.map((number) => (
|
||||
<button
|
||||
key={`fav-${number}`}
|
||||
type="button"
|
||||
className="inline-flex h-8 touch-manipulation items-center gap-1 rounded-full border border-[#ffd7db] bg-[#fff3f5] px-2.5 text-xs font-semibold text-[#d81435] transition-colors hover:bg-[#ffe9ed]"
|
||||
onPointerDown={() => {
|
||||
const current = holdFavoriteRef.current;
|
||||
current.number = number;
|
||||
current.longPress = false;
|
||||
if (current.timer) window.clearTimeout(current.timer);
|
||||
current.timer = window.setTimeout(() => {
|
||||
current.longPress = true;
|
||||
toggleFavoriteNumber(number);
|
||||
}, 500);
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
const current = holdFavoriteRef.current;
|
||||
if (current.timer) {
|
||||
window.clearTimeout(current.timer);
|
||||
current.timer = null;
|
||||
}
|
||||
if (!current.longPress) {
|
||||
fillCurrentRow(number);
|
||||
}
|
||||
current.longPress = false;
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
const current = holdFavoriteRef.current;
|
||||
if (current.timer) {
|
||||
window.clearTimeout(current.timer);
|
||||
current.timer = null;
|
||||
}
|
||||
current.longPress = false;
|
||||
}}
|
||||
>
|
||||
{number}
|
||||
<span className="text-[11px] font-medium opacity-70">
|
||||
{t("hall.quickFill.tapHold")}
|
||||
</span>
|
||||
{preset}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
disabled={tableDisabled || !activeRow?.number}
|
||||
onClick={copyPreviousRowToActiveRow}
|
||||
className="inline-flex h-8 items-center justify-center rounded-full border border-[#dfe6f0] bg-white px-3 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-40"
|
||||
>
|
||||
{t("hall.quickFill.copyPrev", { defaultValue: "Copy prev row" })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={tableDisabled}
|
||||
onClick={clearActiveRowAmounts}
|
||||
className="inline-flex h-8 items-center justify-center rounded-full border border-[#ffe0e5] bg-white px-3 text-xs font-semibold text-[#d81435] transition-colors hover:bg-[#fff4f6] disabled:opacity-40"
|
||||
>
|
||||
{t("hall.quickFill.clearRow", { defaultValue: "Clear row amounts" })}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="mr-0.5 text-[11px] font-semibold text-slate-400">
|
||||
{t("hall.quickFill.history")}
|
||||
</span>
|
||||
{historyChips.length > 0 ? (
|
||||
historyChips.map((number) => (
|
||||
<button
|
||||
key={`his-${number}`}
|
||||
type="button"
|
||||
className="inline-flex h-8 min-w-10 items-center justify-center rounded-full border border-[#d7e5f8] bg-[#f8fbff] px-3 text-xs font-bold text-[#07459f] transition-colors hover:border-[#b9d0f3] hover:bg-[#eef6ff]"
|
||||
onClick={() => fillCurrentRow(number)}
|
||||
>
|
||||
{number}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<span className="inline-flex h-8 items-center rounded-full border border-dashed border-slate-200 bg-slate-50 px-3 text-xs text-slate-400">
|
||||
{t("hall.quickFill.emptyHistory")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={cn("overflow-x-auto pb-1", isMobile ? "-mx-1 flex gap-1.5 px-1" : "flex items-end gap-2")}>
|
||||
{quickFillExpanded ? (
|
||||
<>
|
||||
{favoriteChips.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="mr-0.5 text-[11px] font-semibold text-[#d81435]">
|
||||
{t("hall.quickFill.favorites")}
|
||||
</span>
|
||||
{favoriteChips.map((number) => (
|
||||
<button
|
||||
key={`fav-${number}`}
|
||||
type="button"
|
||||
className="inline-flex h-8 touch-manipulation items-center gap-1 rounded-full border border-[#ffd7db] bg-[#fff3f5] px-2.5 text-xs font-semibold text-[#d81435] transition-colors hover:bg-[#ffe9ed]"
|
||||
onPointerDown={() => {
|
||||
const current = holdFavoriteRef.current;
|
||||
current.number = number;
|
||||
current.longPress = false;
|
||||
if (current.timer) window.clearTimeout(current.timer);
|
||||
current.timer = window.setTimeout(() => {
|
||||
current.longPress = true;
|
||||
toggleFavoriteNumber(number);
|
||||
}, 500);
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
const current = holdFavoriteRef.current;
|
||||
if (current.timer) {
|
||||
window.clearTimeout(current.timer);
|
||||
current.timer = null;
|
||||
}
|
||||
if (!current.longPress) {
|
||||
fillCurrentRow(number);
|
||||
}
|
||||
current.longPress = false;
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
const current = holdFavoriteRef.current;
|
||||
if (current.timer) {
|
||||
window.clearTimeout(current.timer);
|
||||
current.timer = null;
|
||||
}
|
||||
current.longPress = false;
|
||||
}}
|
||||
>
|
||||
{number}
|
||||
<span className="text-[11px] font-medium opacity-70">
|
||||
{t("hall.quickFill.tapHold")}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="mr-0.5 text-[11px] font-semibold text-slate-400">
|
||||
{t("hall.quickFill.history")}
|
||||
</span>
|
||||
{historyChips.length > 0 ? (
|
||||
historyChips.map((number) => (
|
||||
<button
|
||||
key={`his-${number}`}
|
||||
type="button"
|
||||
className="inline-flex h-8 min-w-10 items-center justify-center rounded-full border border-[#d7e5f8] bg-[#f8fbff] px-3 text-xs font-bold text-[#07459f] transition-colors hover:border-[#b9d0f3] hover:bg-[#eef6ff]"
|
||||
onClick={() => fillCurrentRow(number)}
|
||||
>
|
||||
{number}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<span className="inline-flex h-8 items-center rounded-full border border-dashed border-slate-200 bg-slate-50 px-3 text-xs text-slate-400">
|
||||
{t("hall.quickFill.emptyHistory")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-x-auto",
|
||||
isMobile
|
||||
? "-mx-1 flex gap-1.5 px-1 pb-1"
|
||||
: "order-first inline-flex w-fit max-w-full shrink-0 items-center gap-1 rounded-lg bg-[#f3f6fb] p-1",
|
||||
)}
|
||||
>
|
||||
{categoryTabs.map((tab) => {
|
||||
const hasPlays = openPlays.some(
|
||||
(play) => playCategory(play.play_code) === tab.value,
|
||||
@@ -1599,18 +1837,22 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
disabled={!hasPlays}
|
||||
onClick={() => setActiveCategory(tab.value)}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center border font-bold transition-colors",
|
||||
"inline-flex items-center justify-center font-bold transition-colors",
|
||||
isMobile
|
||||
? "min-w-[5.25rem] rounded-t-xl border-b-0 px-4 py-3 text-sm"
|
||||
: "min-w-[6rem] rounded-t-xl px-4 py-3 text-sm",
|
||||
active
|
||||
? "border-[#d7e1f3] border-b-white bg-white text-[#21335b] shadow-[0_-1px_0_rgba(255,255,255,0.8)]"
|
||||
: "border-transparent bg-transparent text-[#6d8fd6] hover:text-[#2d63e2]",
|
||||
? "min-w-[5.25rem] rounded-t-xl border border-b-0 px-4 py-3 text-sm"
|
||||
: "min-w-[4.5rem] rounded-lg px-3.5 py-2 text-sm",
|
||||
isMobile
|
||||
? active
|
||||
? "border-[#d7e1f3] border-b-white bg-white text-[#21335b] shadow-[0_-1px_0_rgba(255,255,255,0.8)]"
|
||||
: "border-transparent bg-transparent text-[#6d8fd6] hover:text-[#2d63e2]"
|
||||
: active
|
||||
? "bg-[#2d63e2] text-white shadow-[0_4px_12px_rgba(45,99,226,0.28)]"
|
||||
: "bg-transparent text-[#5b7fbf] hover:bg-[#f3f7ff] hover:text-[#2d63e2]",
|
||||
!hasPlays && "cursor-not-allowed opacity-40",
|
||||
)}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<span className="mr-2 text-[13px]" aria-hidden>
|
||||
<span className={cn("mr-2 text-[13px]", !isMobile && active && "text-white/90")} aria-hidden>
|
||||
▦
|
||||
</span>
|
||||
{tab.label}
|
||||
@@ -1618,6 +1860,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeCategoryPlays.length === 0 ? (
|
||||
<div
|
||||
@@ -1637,7 +1880,10 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
showWideTableHint && "player-table-scroll-wrap",
|
||||
)}
|
||||
>
|
||||
<div className="mb-3 overflow-x-auto rounded border border-[#dae3f3] shadow-sm">
|
||||
<div
|
||||
ref={playSummaryScrollRef}
|
||||
className="mb-3 overflow-x-auto rounded border border-[#dae3f3] shadow-sm"
|
||||
>
|
||||
<table className="w-full border-collapse text-center text-[11px] tabular-nums">
|
||||
<thead>
|
||||
<tr className="bg-[#2d63e2] text-white">
|
||||
@@ -1686,9 +1932,9 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="player-table-scroll flex overflow-x-auto overscroll-x-contain">
|
||||
<div className="player-table-scroll overflow-x-auto overscroll-x-contain">
|
||||
<table
|
||||
className="w-max border-collapse text-[11px]"
|
||||
className="mx-auto w-max border-collapse text-[11px]"
|
||||
style={{ width: tableWidthPx }}
|
||||
>
|
||||
<thead>
|
||||
@@ -1745,8 +1991,8 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
defaultValue=""
|
||||
disabled={tableDisabled}
|
||||
onChange={(event) => {
|
||||
const selectionType = event.target.value as DraftRow["selectionType"] | "";
|
||||
if (selectionType) updateAllSelectionTypes(selectionType);
|
||||
const selectionType = event.target.value as SelectionType | "";
|
||||
if (selectionType) requestAllSelectionTypes(selectionType);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
className="mx-auto mt-1 h-5 w-full rounded border border-[#d7e1f3] bg-white px-0.5 text-[10px] font-semibold text-[#304f86]"
|
||||
@@ -1754,11 +2000,11 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
title={t("hall.table.setAllTypes")}
|
||||
>
|
||||
<option value="">{t("hall.table.selectAll")}</option>
|
||||
<option value="straight">{t("hall.table.selectionTypes.straight")}</option>
|
||||
<option value="reverse">{t("hall.table.selectionTypes.reverse")}</option>
|
||||
<option value="full_cover">{t("hall.table.selectionTypes.full_cover")}</option>
|
||||
<option value="full_play">{t("hall.table.selectionTypes.full_play")}</option>
|
||||
{activeCategory === "D4" ? <option value="half_play">{t("hall.table.selectionTypes.half_play")}</option> : null}
|
||||
{selectionTypeOptions.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{t(`hall.table.selectionTypes.${type}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</th>
|
||||
) : null}
|
||||
@@ -1803,7 +2049,8 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
liveSoldOutNumbers={liveSoldOutNumbers}
|
||||
liveWarningNumbers={liveWarningNumbers}
|
||||
updateRowNumber={updateRowNumber}
|
||||
updateRowSelectionType={updateRowSelectionType}
|
||||
updateRowSelectionType={requestRowSelectionType}
|
||||
selectionTypeOptions={selectionTypeOptions}
|
||||
updateAmount={updateAmount}
|
||||
toggleRowProvider={toggleRowProvider}
|
||||
setActiveRowId={setActiveRowId}
|
||||
@@ -1826,20 +2073,20 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
</div>
|
||||
|
||||
{!isMobile ? (
|
||||
<aside className="sticky top-3 z-20 w-64 shrink-0 self-start rounded-xl border border-[#e6edf8] bg-[#f8fbff] px-4 py-4 text-[#304f86] shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
||||
<aside className="sticky top-3 z-20 w-52 shrink-0 self-start rounded-xl border border-[#e6edf8] bg-[#f8fbff] px-3.5 py-3.5 text-[#304f86] shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
||||
<p className="text-xs font-black">{t("hall.table.orderSummary", { defaultValue: "Order summary" })}</p>
|
||||
<dl className="mt-5 space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<dl className="mt-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<dt className="text-xs text-slate-500">{t("hall.table.lineCount", { defaultValue: "Lines" })}</dt>
|
||||
<dd className="font-mono text-base font-black tabular-nums">{draftEntries.length}</dd>
|
||||
<dd className="font-mono text-sm font-black tabular-nums">{draftEntries.length}</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<dt className="text-xs text-slate-500">{t("hall.table.providerCount", { defaultValue: "Providers" })}</dt>
|
||||
<dd className="font-mono text-base font-black tabular-nums">{selectedProviderCount}</dd>
|
||||
<dd className="font-mono text-sm font-black tabular-nums">{selectedProviderCount}</dd>
|
||||
</div>
|
||||
<div className="border-t border-[#dce6f5] pt-4">
|
||||
<div className="border-t border-[#dce6f5] pt-3">
|
||||
<dt className="text-xs text-slate-500">{t("hall.table.actualTotal")}</dt>
|
||||
<dd className="mt-1 text-lg font-black tabular-nums text-[#0b3f96]">
|
||||
<dd className="mt-1 text-base font-black tabular-nums text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(submitActualMinor, currencyCode)}
|
||||
</dd>
|
||||
</div>
|
||||
@@ -1849,7 +2096,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
disabled={!canSubmit || previewLoading}
|
||||
onClick={() => void handlePreview()}
|
||||
className={cn(
|
||||
"mt-6 h-11 w-full gap-2 rounded-lg border-0 text-sm font-bold text-white",
|
||||
"mt-4 h-10 w-full gap-2 rounded-lg border-0 text-sm font-bold text-white",
|
||||
!isBettable
|
||||
? "bg-slate-500 shadow-none hover:bg-slate-500 disabled:opacity-100"
|
||||
: "bg-[#e5002c] shadow-[0_8px_20px_rgba(229,0,44,0.26)] hover:bg-[#d10028]",
|
||||
@@ -1956,6 +2203,79 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
jackpotEnabled={Boolean(jackpot?.enabled)}
|
||||
creditMode={creditMode}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={pendingSelectionChange !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) cancelPendingSelectionChange();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-[380px] rounded-2xl border border-[#dfe8f6] bg-white p-0 shadow-[0_24px_70px_rgba(15,23,42,0.18)]">
|
||||
<DialogHeader className="gap-2 border-b border-[#eef2f8] px-5 py-4 text-left">
|
||||
<DialogTitle className="text-base font-black text-[#0b3f96]">
|
||||
{t("hall.table.selectionConfirm.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm leading-relaxed text-slate-600">
|
||||
{pendingSelectionPreview
|
||||
? pendingSelectionPreview.next === "full_cover"
|
||||
? t("hall.table.selectionConfirm.fullCoverBody", {
|
||||
type: t(`hall.table.selectionTypes.${pendingSelectionPreview.next}`),
|
||||
count: pendingSelectionPreview.comboCount,
|
||||
amount: formatMinorAsCurrency(pendingSelectionPreview.toMinor || pendingSelectionPreview.fromMinor, currencyCode),
|
||||
})
|
||||
: pendingSelectionPreview.fromMinor > 0 &&
|
||||
pendingSelectionPreview.toMinor > pendingSelectionPreview.fromMinor
|
||||
? t("hall.table.selectionConfirm.increaseBody", {
|
||||
type: t(`hall.table.selectionTypes.${pendingSelectionPreview.next}`),
|
||||
count: pendingSelectionPreview.comboCount,
|
||||
from: formatMinorAsCurrency(pendingSelectionPreview.fromMinor, currencyCode),
|
||||
to: formatMinorAsCurrency(pendingSelectionPreview.toMinor, currencyCode),
|
||||
})
|
||||
: t("hall.table.selectionConfirm.genericBody", {
|
||||
type: t(`hall.table.selectionTypes.${pendingSelectionPreview.next}`),
|
||||
count: pendingSelectionPreview.comboCount,
|
||||
})
|
||||
: t("hall.table.selectionConfirm.genericBody", {
|
||||
type: "",
|
||||
count: 1,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{pendingSelectionPreview && pendingSelectionPreview.comboCount > 1 ? (
|
||||
<div className="space-y-1 px-5 py-3 text-xs text-slate-600">
|
||||
<p className="font-bold text-[#304f86]">
|
||||
{t("hall.table.selectionConfirm.comboHint", {
|
||||
count: pendingSelectionPreview.comboCount,
|
||||
})}
|
||||
</p>
|
||||
{pendingSelectionPreview.number ? (
|
||||
<p className="font-mono text-[11px] text-slate-500">
|
||||
{t("hall.table.selectionConfirm.numberHint", {
|
||||
number: pendingSelectionPreview.number,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<DialogFooter className="gap-2 border-t border-[#eef2f8] px-5 py-4 sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-lg"
|
||||
onClick={cancelPendingSelectionChange}
|
||||
>
|
||||
{t("hall.table.selectionConfirm.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="rounded-lg bg-[#e5002c] text-white hover:bg-[#d10028]"
|
||||
onClick={confirmPendingSelectionChange}
|
||||
>
|
||||
{t("hall.table.selectionConfirm.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1975,6 +2295,7 @@ const DraftRowItem = memo(function DraftRowItem({
|
||||
liveWarningNumbers,
|
||||
updateRowNumber,
|
||||
updateRowSelectionType,
|
||||
selectionTypeOptions,
|
||||
updateAmount,
|
||||
toggleRowProvider,
|
||||
setActiveRowId,
|
||||
@@ -1983,45 +2304,51 @@ const DraftRowItem = memo(function DraftRowItem({
|
||||
indexColClass,
|
||||
numberColClass,
|
||||
stickyNumberLeftClass,
|
||||
selectionTypeColClass,
|
||||
showSelectionTypeColumn,
|
||||
providerColClass,
|
||||
rowTotalColClass,
|
||||
currencyCode,
|
||||
}: {
|
||||
row: DraftRow;
|
||||
index: number;
|
||||
rowActive: boolean;
|
||||
tableDisabled: boolean;
|
||||
numberPlaceholder: string;
|
||||
activeCategory: PlayHallCategory;
|
||||
numberMaxChars: number;
|
||||
betProviders: BetProviderRow[];
|
||||
playColumns: PlayColumn[];
|
||||
alertRows: DrawCurrentRiskPoolAlert[];
|
||||
liveSoldOutNumbers: Set<string>;
|
||||
liveWarningNumbers: Set<string>;
|
||||
selectionTypeColClass,
|
||||
showSelectionTypeColumn,
|
||||
providerColClass,
|
||||
rowTotalColClass,
|
||||
currencyCode,
|
||||
}: {
|
||||
row: DraftRow;
|
||||
index: number;
|
||||
rowActive: boolean;
|
||||
tableDisabled: boolean;
|
||||
numberPlaceholder: string;
|
||||
activeCategory: PlayHallCategory;
|
||||
numberMaxChars: number;
|
||||
betProviders: BetProviderRow[];
|
||||
playColumns: PlayColumn[];
|
||||
alertRows: DrawCurrentRiskPoolAlert[];
|
||||
liveSoldOutNumbers: Set<string>;
|
||||
liveWarningNumbers: Set<string>;
|
||||
updateRowNumber: (id: string, value: string) => void;
|
||||
updateRowSelectionType: (id: string, value: DraftRow["selectionType"]) => void;
|
||||
updateRowSelectionType: (id: string, value: SelectionType) => void;
|
||||
selectionTypeOptions: SelectionType[];
|
||||
updateAmount: (rowId: string, playCode: string, value: string) => void;
|
||||
toggleRowProvider: (rowId: string, code: string) => void;
|
||||
setActiveRowId: (id: string) => void;
|
||||
t: HallTranslate;
|
||||
isMobile: boolean;
|
||||
indexColClass: string;
|
||||
numberColClass: string;
|
||||
stickyNumberLeftClass: string;
|
||||
selectionTypeColClass: string;
|
||||
showSelectionTypeColumn: boolean;
|
||||
providerColClass: string;
|
||||
rowTotalColClass: string;
|
||||
currencyCode: string;
|
||||
}) {
|
||||
const displayNumber = sanitizeNumber(row.number, activeCategory);
|
||||
const rowTotalMinor = playColumns.reduce((total, column) => {
|
||||
if (draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) !== null) return total;
|
||||
return total + (parseDecimalInputToMinor(row.amounts[column.key] ?? "", currencyCode) ?? 0);
|
||||
}, 0) * row.providerCodes.length;
|
||||
numberColClass: string;
|
||||
stickyNumberLeftClass: string;
|
||||
selectionTypeColClass: string;
|
||||
showSelectionTypeColumn: boolean;
|
||||
providerColClass: string;
|
||||
rowTotalColClass: string;
|
||||
currencyCode: string;
|
||||
}) {
|
||||
const displayNumber = sanitizeNumber(row.number, activeCategory);
|
||||
const comboCount = selectionCombinationCount(displayNumber, row.selectionType);
|
||||
const rowTotalMinor =
|
||||
playColumns.reduce((total, column) => {
|
||||
if (draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) !== null) {
|
||||
return total;
|
||||
}
|
||||
const amount = parseDecimalInputToMinor(row.amounts[column.key] ?? "", currencyCode) ?? 0;
|
||||
return total + resolveSelectionTotalBet(amount, row.selectionType, comboCount);
|
||||
}, 0) * row.providerCodes.length;
|
||||
|
||||
return (
|
||||
<tr
|
||||
@@ -2132,16 +2459,21 @@ const DraftRowItem = memo(function DraftRowItem({
|
||||
<select
|
||||
value={row.selectionType}
|
||||
disabled={tableDisabled}
|
||||
onChange={(event) => updateRowSelectionType(row.id, event.target.value as DraftRow["selectionType"])}
|
||||
onChange={(event) => updateRowSelectionType(row.id, event.target.value as SelectionType)}
|
||||
className="h-8 w-full rounded-md border border-[#e1e8f3] bg-white px-1 text-[10px] font-semibold text-[#304f86]"
|
||||
aria-label={t("hall.table.selectionTypeForRow", { row: index + 1 })}
|
||||
>
|
||||
<option value="straight">{t("hall.table.selectionTypes.straight")}</option>
|
||||
<option value="reverse">{t("hall.table.selectionTypes.reverse")}</option>
|
||||
<option value="full_cover">{t("hall.table.selectionTypes.full_cover")}</option>
|
||||
<option value="full_play">{t("hall.table.selectionTypes.full_play")}</option>
|
||||
{activeCategory === "D4" ? <option value="half_play">{t("hall.table.selectionTypes.half_play")}</option> : null}
|
||||
{selectionTypeOptions.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{t(`hall.table.selectionTypes.${type}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{comboCount > 1 && displayNumber.length >= 2 ? (
|
||||
<p className="mt-0.5 text-[9px] font-bold leading-none text-[#0b56b7]">
|
||||
{t("hall.table.comboCount", { count: comboCount })}
|
||||
</p>
|
||||
) : null}
|
||||
</td>
|
||||
) : null}
|
||||
{betProviders.map((provider, providerIndex) => (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Hourglass, Landmark, TimerReset } from "lucide-react";
|
||||
import { Hourglass, Landmark } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -8,7 +8,6 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
drawStatusHud,
|
||||
isHallAwaitingDrawProcessing,
|
||||
isHallBlockedForBetting,
|
||||
isHallSealedCountdownUi,
|
||||
} from "@/features/draw/draw-status-meta";
|
||||
import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live";
|
||||
@@ -17,7 +16,13 @@ import { formatPlayerInstant } from "@/lib/player-datetime";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DrawCurrentPayload } from "@/types/api/draw-current";
|
||||
|
||||
function ScheduleAnchorTime({ payload }: { payload: DrawCurrentPayload }) {
|
||||
function ScheduleAnchorTime({
|
||||
payload,
|
||||
layout,
|
||||
}: {
|
||||
payload: DrawCurrentPayload;
|
||||
layout: "mobile" | "desktop";
|
||||
}) {
|
||||
const { t } = useTranslation("player");
|
||||
const isPending = payload.status === "pending";
|
||||
const isOpen = payload.status === "open";
|
||||
@@ -30,6 +35,30 @@ function ScheduleAnchorTime({ payload }: { payload: DrawCurrentPayload }) {
|
||||
? "draw.scheduledEnd"
|
||||
: "draw.scheduledClose";
|
||||
const formatted = source ? formatPlayerInstant(source) : null;
|
||||
|
||||
if (layout === "desktop") {
|
||||
if (!formatted) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-slate-500">{t(labelKey)}</span>
|
||||
<span className="font-mono text-base font-black tabular-nums text-[#0b3f96]">--:--:--</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const parts = formatted.split(" ");
|
||||
const date = parts.slice(0, -1).join(" ");
|
||||
const time = parts.at(-1);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-slate-500">{t(labelKey)}</span>
|
||||
<span className="font-mono text-base font-black tabular-nums tracking-tight text-[#0b3f96]">
|
||||
{time}
|
||||
</span>
|
||||
{date ? <span className="text-sm text-slate-400">{date}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!formatted) {
|
||||
return (
|
||||
<>
|
||||
@@ -56,10 +85,12 @@ function CloseTime({
|
||||
nowMs,
|
||||
hud,
|
||||
payload,
|
||||
layout,
|
||||
}: {
|
||||
nowMs: number;
|
||||
hud: ReturnType<typeof drawStatusHud>;
|
||||
payload: DrawCurrentPayload;
|
||||
layout: "mobile" | "desktop";
|
||||
}) {
|
||||
const { t } = useTranslation("player");
|
||||
const sealedCountdown = isHallSealedCountdownUi(payload.status);
|
||||
@@ -119,11 +150,31 @@ function CloseTime({
|
||||
label = t("draw.coolDown");
|
||||
}
|
||||
|
||||
const clock = showClock ? formatSecondsClock(seconds) : "--:--";
|
||||
|
||||
if (layout === "desktop") {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-slate-500">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex min-w-[3.75rem] items-center justify-center rounded-md px-2 py-0.5 font-mono text-base font-black tabular-nums tracking-tight",
|
||||
sealedCountdown ? "bg-[#fff1f3] text-[#ff143d]" : "bg-[#fff5f6] text-[#e5002c]",
|
||||
)}
|
||||
>
|
||||
{clock}
|
||||
</span>
|
||||
<Hourglass
|
||||
className={cn("size-3.5 shrink-0", sealedCountdown ? "text-[#ff143d]" : "text-red-300")}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="text-lg font-black tabular-nums text-[#ff143d]">
|
||||
{showClock ? formatSecondsClock(seconds) : "--:--"}
|
||||
</span>
|
||||
<span className="text-lg font-black tabular-nums text-[#ff143d]">{clock}</span>
|
||||
<span className="mt-1 text-[11px] text-slate-500">{label}</span>
|
||||
</>
|
||||
);
|
||||
@@ -135,7 +186,7 @@ export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot })
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="mb-3 rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
|
||||
<section className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
|
||||
<p>{t(error, { defaultValue: error })}</p>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -152,19 +203,23 @@ export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot })
|
||||
|
||||
if (raw === undefined || display === undefined) {
|
||||
return (
|
||||
<section className="mb-3 rounded-xl border border-[#e3ebf6] bg-white p-3 shadow-sm">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<section className="rounded-xl border border-[#e3ebf6] bg-white p-3 shadow-sm lg:h-[5.5rem] lg:px-4 lg:py-3">
|
||||
<div className="grid grid-cols-3 gap-2 lg:hidden">
|
||||
<Skeleton className="h-14 rounded-lg" />
|
||||
<Skeleton className="h-14 rounded-lg" />
|
||||
<Skeleton className="h-14 rounded-lg" />
|
||||
</div>
|
||||
<div className="hidden h-full items-center justify-between gap-4 lg:flex">
|
||||
<Skeleton className="h-7 w-56 rounded-md" />
|
||||
<Skeleton className="h-7 w-64 rounded-md" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (raw === null || display === null) {
|
||||
return (
|
||||
<section className="mb-3 rounded-xl border border-[#e3ebf6] bg-white px-3 py-3 text-center text-sm text-slate-500 shadow-sm">
|
||||
<section className="flex items-center justify-center rounded-xl border border-[#e3ebf6] bg-white px-3 py-3 text-center text-sm text-slate-500 shadow-sm lg:h-[5.5rem]">
|
||||
{t("draw.noIssue")}
|
||||
</section>
|
||||
);
|
||||
@@ -172,16 +227,17 @@ export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot })
|
||||
|
||||
const hud = drawStatusHud(display.status);
|
||||
const sealedUi = isHallSealedCountdownUi(display.status);
|
||||
const blockedUi = isHallBlockedForBetting(display.status);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"mb-3 overflow-hidden rounded-xl border border-[#e1e9f5] bg-white shadow-[0_6px_20px_rgba(15,23,42,0.06)]",
|
||||
"flex h-full flex-col overflow-hidden rounded-xl border border-[#e1e9f5] bg-white shadow-[0_4px_16px_rgba(15,23,42,0.04)] lg:h-[5.5rem]",
|
||||
sealedUi && "border-red-200 bg-red-50/30",
|
||||
)}
|
||||
aria-label={t("draw.currentIssue")}
|
||||
>
|
||||
<div className="grid grid-cols-[1fr_1.05fr_1fr] divide-x divide-[#e7edf6]">
|
||||
{/* Mobile: 三列卡片 */}
|
||||
<div className="grid grid-cols-[1fr_1.05fr_1fr] divide-x divide-[#e7edf6] lg:hidden">
|
||||
<div className="flex min-w-0 flex-col items-center justify-center px-2 py-3 text-center">
|
||||
<div className="min-w-0 max-w-full overflow-x-auto">
|
||||
<p className="text-[11px] font-semibold text-slate-500">{t("draw.issueNo")}</p>
|
||||
@@ -191,7 +247,7 @@ export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot })
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col items-center justify-center px-2 py-3 text-center">
|
||||
<ScheduleAnchorTime payload={display} />
|
||||
<ScheduleAnchorTime payload={display} layout="mobile" />
|
||||
</div>
|
||||
<div className="relative flex min-w-0 flex-col items-center justify-center px-2 py-3 text-center">
|
||||
<CloseTime
|
||||
@@ -199,6 +255,7 @@ export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot })
|
||||
nowMs={drawLive.nowMs}
|
||||
hud={hud}
|
||||
payload={display}
|
||||
layout="mobile"
|
||||
/>
|
||||
<Hourglass
|
||||
className={cn(
|
||||
@@ -209,27 +266,44 @@ export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot })
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{blockedUi ? (
|
||||
<div className="flex items-center gap-2 border-t border-red-100 bg-red-50 px-3 py-2 text-xs font-medium text-red-600">
|
||||
<TimerReset className="size-4 shrink-0" aria-hidden />
|
||||
{display.status === "review"
|
||||
? t("draw.reviewNotice")
|
||||
: sealedUi
|
||||
? t("draw.sealedNotice")
|
||||
: t("draw.notBettableNotice")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between border-t border-[#eef3f9] bg-[#fbfdff] px-3 py-1.5 text-[11px] text-slate-500">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className={cn("size-2 rounded-full", hud.dotClass)} />
|
||||
|
||||
{/* Desktop: 单行工具条 — 左期号状态,右时间/倒计时,与右侧钱包卡同高 */}
|
||||
<div className="hidden h-full items-center justify-between gap-6 px-4 py-2.5 lg:flex">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-slate-500">{t("draw.issueNo")}</span>
|
||||
<span className="inline-flex items-center rounded-md bg-[#eef3ff] px-2 py-0.5 font-mono text-sm font-black tabular-nums text-[#0b4ab3]">
|
||||
{display.draw_no}
|
||||
</span>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-slate-500">
|
||||
<span className={cn("size-1.5 rounded-full", hud.dotClass)} />
|
||||
{t(hud.labelKey, { defaultValue: hud.labelKey })}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Landmark className="size-3.5" aria-hidden />
|
||||
{t("draw.hall")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-x-5 gap-y-1">
|
||||
<ScheduleAnchorTime payload={display} layout="desktop" />
|
||||
<CloseTime
|
||||
key={`desk-${display.draw_no}-${display.status}-${display.seconds_to_draw ?? ""}-${display.seconds_remaining_in_cooldown ?? ""}`}
|
||||
nowMs={drawLive.nowMs}
|
||||
hud={hud}
|
||||
payload={display}
|
||||
layout="desktop"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile only: 底部状态条(阻断提示已上移为首页顶栏横幅) */}
|
||||
<div className="flex items-center justify-between border-t border-[#eef3f9] bg-[#fbfdff] px-3 py-1.5 text-[11px] text-slate-500 lg:hidden">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className={cn("size-2 rounded-full", hud.dotClass)} />
|
||||
{t(hud.labelKey, { defaultValue: hud.labelKey })}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Landmark className="size-3.5" aria-hidden />
|
||||
{t("draw.hall")}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { TimerReset } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import {
|
||||
isHallBlockedForBetting,
|
||||
isHallSealedCountdownUi,
|
||||
} from "@/features/draw/draw-status-meta";
|
||||
import { HallBettingGrid } from "@/features/hall/hall-betting-grid";
|
||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||
import { HallDrawPanel } from "@/features/hall/hall-draw-panel";
|
||||
import { HallWalletStrip } from "@/features/hall/hall-wallet-strip";
|
||||
import { JackpotBurstOverlay } from "@/features/hall/jackpot-burst-overlay";
|
||||
import { useHallDrawLive } from "@/features/hall/use-hall-draw-live";
|
||||
import { useJackpotBurstLive } from "@/features/hall/use-jackpot-burst-live";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||
|
||||
/**
|
||||
* 下注大厅:钱包条 §4 + 当期期号 §4.2(封盘置灰 / 倒计时错误色 / WS+轮询);玩法目录 §12.3;下注表格 §13.3。
|
||||
* PC:状态工具条与资金摘要并列,下注工具与表格独占工作区宽度。
|
||||
* 不可下注提示为首页顶部全宽横幅,避免撑高左侧期号卡导致与钱包卡高度不一致。
|
||||
*/
|
||||
export function HallScreen() {
|
||||
const { t: tp } = useTranslation("player");
|
||||
@@ -20,14 +27,41 @@ export function HallScreen() {
|
||||
const { activeCurrency } = useActivePlayerCurrency();
|
||||
const { burstEvent, clearBurstEvent } = useJackpotBurstLive(tp);
|
||||
|
||||
const display = drawLive.display;
|
||||
const blockedUi = display != null && isHallBlockedForBetting(display.status);
|
||||
const sealedUi = display != null && isHallSealedCountdownUi(display.status);
|
||||
const blockedNotice =
|
||||
display == null
|
||||
? null
|
||||
: display.status === "review"
|
||||
? tp("draw.reviewNotice")
|
||||
: sealedUi
|
||||
? tp("draw.sealedNotice")
|
||||
: tp("draw.notBettableNotice");
|
||||
|
||||
return (
|
||||
<>
|
||||
<PlayerPanel>
|
||||
<HallDrawPanel drawLive={drawLive} />
|
||||
<HallWalletStrip />
|
||||
<HallBettingGrid key={activeCurrency} drawLive={drawLive} />
|
||||
<div className="space-y-3">
|
||||
{blockedUi && blockedNotice ? (
|
||||
<div
|
||||
role="status"
|
||||
className="flex items-center gap-2 rounded-xl border border-red-100 bg-red-50 px-3 py-2.5 text-sm font-medium text-red-600 shadow-[0_2px_8px_rgba(229,0,44,0.06)] lg:px-4"
|
||||
>
|
||||
<TimerReset className="size-4 shrink-0" aria-hidden />
|
||||
<span>{blockedNotice}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="lg:grid lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-stretch lg:gap-4 xl:grid-cols-[minmax(0,1fr)_22rem]">
|
||||
<HallDrawPanel drawLive={drawLive} />
|
||||
<aside className="min-w-0">
|
||||
<HallWalletStrip />
|
||||
</aside>
|
||||
</div>
|
||||
<HallBettingGrid key={activeCurrency} drawLive={drawLive} />
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
<JackpotBurstOverlay event={burstEvent} onClose={clearBurstEvent} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,12 +40,10 @@ export function HallWalletStrip() {
|
||||
BALANCE_KEY(currency),
|
||||
() => getWalletBalance({ currency }),
|
||||
{
|
||||
// 降级模式下 60 秒自动刷新,SWR 内置定时器代替手动 setInterval
|
||||
refreshInterval: isDegraded ? 60_000 : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
// 全局事件触发刷新(下注后、币种切换等场景)
|
||||
useEffect(() => {
|
||||
const onRefresh = () => void mutate(BALANCE_KEY(currency));
|
||||
window.addEventListener("lottery-wallet-refresh", onRefresh);
|
||||
@@ -56,7 +54,6 @@ export function HallWalletStrip() {
|
||||
};
|
||||
}, [mutate, currency]);
|
||||
|
||||
// 非降级模式时清理遗留计时器(refreshInterval 已由 SWR 管理)
|
||||
useEffect(() => {
|
||||
if (!isDegraded && degradedWalletPollRef.current !== null) {
|
||||
window.clearInterval(degradedWalletPollRef.current);
|
||||
@@ -75,57 +72,75 @@ export function HallWalletStrip() {
|
||||
? null
|
||||
: Number(balance.main_balance);
|
||||
|
||||
const label = isCreditPlayer
|
||||
? t("wallet.creditAvailable", { defaultValue: "可用信用" })
|
||||
: t("wallet.balance");
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn("mb-3", isMobile ? "space-y-2" : "space-y-2.5")}
|
||||
aria-label={
|
||||
isCreditPlayer
|
||||
? t("wallet.creditAvailable", { defaultValue: "可用信用" })
|
||||
: t("wallet.balance")
|
||||
}
|
||||
className={cn("h-full", isMobile ? "space-y-2" : "space-y-2.5")}
|
||||
aria-label={label}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-xl bg-[#e5002c] text-white shadow-[0_10px_28px_rgba(229,0,44,0.25)]",
|
||||
isMobile ? "px-3 py-2.5" : "px-3 py-3",
|
||||
"relative overflow-hidden rounded-xl bg-[#e5002c] text-white shadow-[0_8px_24px_rgba(229,0,44,0.22)]",
|
||||
isMobile ? "px-3 py-2.5" : "px-5 py-3 lg:h-[5.5rem] lg:px-4",
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src="/entry/image5.png"
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, 768px"
|
||||
sizes="(max-width: 1023px) 100vw, 1400px"
|
||||
className="pointer-events-none object-cover object-center"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className={cn("relative flex items-center", isMobile ? "gap-2.5" : "gap-3")}>
|
||||
<div className={cn("relative flex items-center", isMobile ? "gap-2.5" : "h-full gap-4")}>
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-full bg-white text-[#d81435] shadow-sm",
|
||||
isMobile
|
||||
? "flex size-11 items-center justify-center"
|
||||
: "flex size-14 items-center justify-center",
|
||||
: "flex size-11 items-center justify-center lg:size-10",
|
||||
)}
|
||||
>
|
||||
<Wallet className={cn(isMobile ? "size-5.5" : "size-7")} aria-hidden />
|
||||
<Wallet className={cn(isMobile ? "size-5.5" : "size-5.5")} aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={cn("font-semibold text-white/90", isMobile ? "text-[13px]" : "text-sm")}>
|
||||
{isCreditPlayer
|
||||
? t("wallet.creditAvailable", { defaultValue: "可用信用" })
|
||||
: t("wallet.balance")}
|
||||
</p>
|
||||
{loading ? (
|
||||
<Skeleton className={cn("rounded-md bg-white/25", isMobile ? "mt-1.5 h-7 w-36" : "mt-2 h-8 w-44")} />
|
||||
) : (
|
||||
<PlayerMoneyDisplay
|
||||
amountMinor={headlineMinor}
|
||||
currency={currency}
|
||||
className={cn("text-white", isMobile ? "mt-0.5" : "mt-1")}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 flex-1",
|
||||
!isMobile && "flex items-center gap-3 overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<div className={cn("min-w-0", !isMobile && "flex shrink-0 items-baseline gap-2")}>
|
||||
<p className={cn("font-semibold text-white/90", isMobile ? "text-[13px]" : "text-sm")}>
|
||||
{label}
|
||||
</p>
|
||||
{loading ? (
|
||||
<Skeleton
|
||||
className={cn(
|
||||
"rounded-md bg-white/25",
|
||||
isMobile ? "mt-1.5 h-7 w-36" : "h-6 w-32",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<PlayerMoneyDisplay
|
||||
amountMinor={headlineMinor}
|
||||
currency={currency}
|
||||
className={cn(
|
||||
"text-white",
|
||||
isMobile ? "mt-0.5" : "text-xl",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isCreditPlayer && !loading && balance ? (
|
||||
<p className={cn("text-white/75", isMobile ? "mt-1 text-[11px]" : "mt-2 text-xs")}>
|
||||
<p
|
||||
className={cn(
|
||||
"text-white/80",
|
||||
isMobile ? "mt-1 text-[11px]" : "truncate text-xs",
|
||||
)}
|
||||
>
|
||||
{t("wallet.creditSummary", {
|
||||
defaultValue: "授信 {{limit}} · 已用 {{used}}",
|
||||
limit: formatMinorAsCurrency(balance.credit_limit ?? 0, currency),
|
||||
@@ -138,31 +153,29 @@ export function HallWalletStrip() {
|
||||
</div>
|
||||
|
||||
{isCreditPlayer ? null : (
|
||||
<div className={cn("grid grid-cols-2", isMobile ? "gap-2" : "gap-2.5")}>
|
||||
<div className={cn("grid grid-cols-2", isMobile ? "gap-2" : "max-w-sm gap-2.5")}>
|
||||
<TransferInDialog
|
||||
idPrefix="hall-"
|
||||
triggerVariant="hall"
|
||||
triggerLabel={t("wallet.transferIn")}
|
||||
triggerClassName={cn(
|
||||
"rounded-lg font-bold",
|
||||
isMobile ? "h-10 text-sm" : "h-12 text-base",
|
||||
)}
|
||||
triggerClassName={cn("rounded-lg font-bold", isMobile ? "h-10 text-sm" : "h-10 text-sm")}
|
||||
currency={currency}
|
||||
lotteryMinor={transferInLotteryMinor}
|
||||
mainMinor={mainMinor}
|
||||
onSuccess={async () => { await mutate(BALANCE_KEY(currency)); }}
|
||||
onSuccess={async () => {
|
||||
await mutate(BALANCE_KEY(currency));
|
||||
}}
|
||||
/>
|
||||
<TransferOutDialog
|
||||
idPrefix="hall-"
|
||||
triggerVariant="hall"
|
||||
triggerLabel={t("wallet.transferOut")}
|
||||
triggerClassName={cn(
|
||||
"rounded-lg font-bold",
|
||||
isMobile ? "h-10 text-sm" : "h-12 text-base",
|
||||
)}
|
||||
triggerClassName={cn("rounded-lg font-bold", isMobile ? "h-10 text-sm" : "h-10 text-sm")}
|
||||
currency={currency}
|
||||
availableMinor={availableMinor}
|
||||
onSuccess={async () => { await mutate(BALANCE_KEY(currency)); }}
|
||||
onSuccess={async () => {
|
||||
await mutate(BALANCE_KEY(currency));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
141
src/features/hall/selection-type.ts
Normal file
141
src/features/hall/selection-type.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/** 录单「种类」:正字 / 来回 / 半打 / 全保 / 全打 */
|
||||
|
||||
export type SelectionType = "straight" | "reverse" | "full_cover" | "full_play" | "half_play";
|
||||
|
||||
/** 下拉顺序:风险从低到高 */
|
||||
export const SELECTION_TYPE_ORDER: readonly SelectionType[] = [
|
||||
"straight",
|
||||
"reverse",
|
||||
"half_play",
|
||||
"full_cover",
|
||||
"full_play",
|
||||
] as const;
|
||||
|
||||
/** 切换到这些种类时需要二次确认(组合数可能 ×N) */
|
||||
export const HIGH_COST_SELECTION_TYPES: ReadonlySet<SelectionType> = new Set([
|
||||
"full_cover",
|
||||
"full_play",
|
||||
]);
|
||||
|
||||
export function selectionTypesForCategory(category: "D2" | "D3" | "D4"): SelectionType[] {
|
||||
if (category === "D2") return ["straight"];
|
||||
if (category === "D3") {
|
||||
return SELECTION_TYPE_ORDER.filter((type) => type !== "half_play");
|
||||
}
|
||||
return [...SELECTION_TYPE_ORDER];
|
||||
}
|
||||
|
||||
function uniquePermutations(digits: string): string[] {
|
||||
const results = new Set<string>();
|
||||
const chars = digits.split("");
|
||||
|
||||
function permute(start: number): void {
|
||||
if (start === chars.length) {
|
||||
results.add(chars.join(""));
|
||||
return;
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
for (let i = start; i < chars.length; i += 1) {
|
||||
const ch = chars[i]!;
|
||||
if (seen.has(ch)) continue;
|
||||
seen.add(ch);
|
||||
[chars[start], chars[i]] = [chars[i]!, chars[start]!];
|
||||
permute(start + 1);
|
||||
[chars[start], chars[i]] = [chars[i]!, chars[start]!];
|
||||
}
|
||||
}
|
||||
|
||||
permute(0);
|
||||
return Array.from(results).sort();
|
||||
}
|
||||
|
||||
/** 展开种类对应的号码组合(与后端 PlayRuleEngine::expandSelection 对齐)。 */
|
||||
export function expandSelectionCombinations(
|
||||
number: string,
|
||||
selectionType: SelectionType,
|
||||
): string[] {
|
||||
const digits = number.replace(/\D/g, "");
|
||||
if (!digits) return [];
|
||||
if (digits.length < 2) return [digits];
|
||||
|
||||
const original = digits;
|
||||
if (selectionType === "straight") return [original];
|
||||
if (selectionType === "reverse") {
|
||||
const rev = original.split("").reverse().join("");
|
||||
return Array.from(new Set([original, rev]));
|
||||
}
|
||||
|
||||
const all = uniquePermutations(original);
|
||||
if (selectionType === "full_cover" || selectionType === "full_play") return all;
|
||||
|
||||
if (selectionType === "half_play") {
|
||||
const first = digits[0]!;
|
||||
const second = digits[1]!;
|
||||
if (first === second) return all;
|
||||
return all.filter((value) => {
|
||||
const firstIndex = value.indexOf(first);
|
||||
const secondIndex = value.indexOf(second);
|
||||
return firstIndex !== -1 && secondIndex !== -1 && firstIndex < secondIndex;
|
||||
});
|
||||
}
|
||||
|
||||
return [original];
|
||||
}
|
||||
|
||||
export function selectionCombinationCount(
|
||||
number: string,
|
||||
selectionType: SelectionType,
|
||||
): number {
|
||||
const cleaned = number.replace(/\D/g, "");
|
||||
if (!cleaned) return 1;
|
||||
if (cleaned.length < 2) return 1;
|
||||
const combos = expandSelectionCombinations(cleaned, selectionType);
|
||||
return Math.max(1, combos.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将「单注输入金额」换算为该种类下的总投注(最小货币单位)。
|
||||
* - full_cover(全保):输入为总金额,平摊到各组合(mbox)
|
||||
* - reverse / half_play / full_play:输入为单注,× 组合数(ibox)
|
||||
* - straight:原样
|
||||
*/
|
||||
export function resolveSelectionTotalBet(
|
||||
amountMinor: number,
|
||||
selectionType: SelectionType,
|
||||
combinationCount: number,
|
||||
): number {
|
||||
if (!Number.isFinite(amountMinor) || amountMinor <= 0) return 0;
|
||||
const count = Math.max(1, combinationCount);
|
||||
|
||||
if (selectionType === "full_cover") {
|
||||
const unit = Math.floor(amountMinor / count);
|
||||
return unit * count;
|
||||
}
|
||||
|
||||
if (
|
||||
selectionType === "full_play" ||
|
||||
selectionType === "reverse" ||
|
||||
selectionType === "half_play"
|
||||
) {
|
||||
return amountMinor * count;
|
||||
}
|
||||
|
||||
return amountMinor;
|
||||
}
|
||||
|
||||
export function resolveSelectionUnitBet(
|
||||
amountMinor: number,
|
||||
selectionType: SelectionType,
|
||||
combinationCount: number,
|
||||
): number {
|
||||
if (!Number.isFinite(amountMinor) || amountMinor <= 0) return 0;
|
||||
const count = Math.max(1, combinationCount);
|
||||
if (selectionType === "full_cover") {
|
||||
return Math.floor(amountMinor / count);
|
||||
}
|
||||
return amountMinor;
|
||||
}
|
||||
|
||||
export function isHighCostSelectionType(selectionType: SelectionType): boolean {
|
||||
return HIGH_COST_SELECTION_TYPES.has(selectionType);
|
||||
}
|
||||
@@ -261,11 +261,11 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
backHref={backHref}
|
||||
backLabel={backLabel}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 lg:gap-4">
|
||||
{siblingItems.length > 0 ? (
|
||||
<div className="rounded-xl border border-[#e5edf8] bg-white p-3 shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
||||
<div className="rounded-xl border border-[#e5edf8] bg-white p-3 shadow-[0_8px_24px_rgba(15,23,42,0.05)] lg:px-5 lg:py-4">
|
||||
<p className="text-sm font-black text-[#0b3f96]">{t("orders.sameOrderItems")}</p>
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="mt-2 space-y-2 lg:mt-3 lg:grid lg:grid-cols-2 lg:gap-3 lg:space-y-0">
|
||||
{siblingItems.map((row) => {
|
||||
const lineSt = ticketStatusDisplay(
|
||||
row.status,
|
||||
@@ -311,96 +311,182 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
</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">
|
||||
<CardTitle className="text-base font-black text-[#0b3f96]">
|
||||
{t("orders.detailTitle")}
|
||||
</CardTitle>
|
||||
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
||||
</div>
|
||||
<CardDescription className="font-mono text-[11px] leading-relaxed text-slate-500">
|
||||
{t("orders.ticketNo", { ticketNo: data.ticket_no })} ·{" "}
|
||||
{t("orders.orderNo", { orderNo: data.order_no ?? "—" })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
{isPartialFailedOrder ? (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||
<p className="font-bold">{t("orders.partialFailedOrderTitle")}</p>
|
||||
<p className="mt-1 leading-relaxed text-amber-800/90">
|
||||
{t("orders.partialFailedOrderBody")}
|
||||
<div className="lg:grid lg:grid-cols-[minmax(0,0.95fr)_minmax(22rem,1.05fr)] lg:items-start lg:gap-4">
|
||||
<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 lg:px-5 lg:pb-4 lg:pt-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<CardTitle className="text-base font-black text-[#0b3f96] lg:text-lg">
|
||||
{t("orders.detailTitle")}
|
||||
</CardTitle>
|
||||
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
||||
</div>
|
||||
<CardDescription className="font-mono text-[11px] leading-relaxed text-slate-500 lg:text-xs">
|
||||
{t("orders.ticketNo", { ticketNo: data.ticket_no })} ·{" "}
|
||||
{t("orders.orderNo", { orderNo: data.order_no ?? "—" })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm lg:space-y-4 lg:px-5 lg:pb-5">
|
||||
{isPartialFailedOrder ? (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||
<p className="font-bold">{t("orders.partialFailedOrderTitle")}</p>
|
||||
<p className="mt-1 leading-relaxed text-amber-800/90">
|
||||
{t("orders.partialFailedOrderBody")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{isLineFailed && failReason ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-800">
|
||||
<p className="font-bold">{t("orders.lineFailedTitle")}</p>
|
||||
<p className="mt-1 leading-relaxed">{failReason}</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-2.5 text-xs lg:grid lg:grid-cols-2 lg:gap-x-6 lg:gap-y-3 lg:space-y-0 lg:text-sm">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="shrink-0 text-slate-500">{t("orders.drawNo")}</span>
|
||||
<span className="text-right font-mono font-black text-[#0b3f96]">
|
||||
{data.draw_no ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="shrink-0 text-slate-500">{t("orders.placedAt")}</span>
|
||||
<span className="text-right font-medium text-slate-800">
|
||||
{formatPlayerInstant(data.placed_at ?? null)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="shrink-0 text-slate-500">{t("orders.number")}</span>
|
||||
<span className="text-right font-mono text-base font-black text-[#0b3f96]">
|
||||
{data.original_number ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="shrink-0 text-slate-500">{t("orders.play")}</span>
|
||||
<span className="text-right font-semibold text-[#32518d]">
|
||||
{playLabel(data.play_code, t)} ({data.dimension ?? "—"}D)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="shrink-0 text-slate-500">
|
||||
{t("hall.providers.title")}
|
||||
</span>
|
||||
<span className="text-right font-black text-[#0b56b7]">
|
||||
{providerLabel(data.provider_name, data.provider_code)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="shrink-0 text-slate-500">{t("orders.amount")}</span>
|
||||
<span className="text-right font-black tabular-nums text-[#d81435]">
|
||||
{formatMinorAsCurrency(data.total_bet_amount, cur)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="shrink-0 text-slate-500">{t("orders.rebateRate")}</span>
|
||||
<span className="text-right font-semibold tabular-nums text-emerald-600">
|
||||
{(Number(data.rebate_rate_snapshot) * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="shrink-0 text-slate-500">{t("orders.actualDeduct")}</span>
|
||||
<span className="text-right font-black tabular-nums text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(data.actual_deduct_amount, cur)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-[#c8daf6] bg-[#f0f6ff] px-3 py-2.5 text-xs lg:px-4 lg:py-3">
|
||||
<p className="font-bold text-[#0b3f96]">{t("orders.oddsSnapshot")}</p>
|
||||
<p className="mt-1 leading-relaxed text-[#32518d]">
|
||||
{formatOddsSnapshot(data.odds_snapshot_json, t)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{isLineFailed && failReason ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-800">
|
||||
<p className="font-bold">{t("orders.lineFailedTitle")}</p>
|
||||
<p className="mt-1 leading-relaxed">{failReason}</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-2.5 text-xs">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="shrink-0 text-slate-500">{t("orders.drawNo")}</span>
|
||||
<span className="text-right font-mono font-black text-[#0b3f96]">
|
||||
{data.draw_no ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="shrink-0 text-slate-500">{t("orders.placedAt")}</span>
|
||||
<span className="text-right font-medium text-slate-800">
|
||||
{formatPlayerInstant(data.placed_at ?? null)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="shrink-0 text-slate-500">{t("orders.number")}</span>
|
||||
<span className="text-right font-mono text-base font-black text-[#0b3f96]">
|
||||
{data.original_number ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="shrink-0 text-slate-500">{t("orders.play")}</span>
|
||||
<span className="text-right font-semibold text-[#32518d]">
|
||||
{playLabel(data.play_code, t)} ({data.dimension ?? "—"}D)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="shrink-0 text-slate-500">
|
||||
{t("hall.providers.title")}
|
||||
</span>
|
||||
<span className="text-right font-black text-[#0b56b7]">
|
||||
{providerLabel(data.provider_name, data.provider_code)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="shrink-0 text-slate-500">{t("orders.amount")}</span>
|
||||
<span className="text-right font-black tabular-nums text-[#d81435]">
|
||||
{formatMinorAsCurrency(data.total_bet_amount, cur)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="shrink-0 text-slate-500">{t("orders.rebateRate")}</span>
|
||||
<span className="text-right font-semibold tabular-nums text-emerald-600">
|
||||
{(Number(data.rebate_rate_snapshot) * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="shrink-0 text-slate-500">{t("orders.actualDeduct")}</span>
|
||||
<span className="text-right font-black tabular-nums text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(data.actual_deduct_amount, cur)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-[#c8daf6] bg-[#f0f6ff] px-3 py-2.5 text-xs">
|
||||
<p className="font-bold text-[#0b3f96]">{t("orders.oddsSnapshot")}</p>
|
||||
<p className="mt-1 leading-relaxed text-[#32518d]">
|
||||
{formatOddsSnapshot(data.odds_snapshot_json, t)}
|
||||
</p>
|
||||
</div>
|
||||
{data.settlement && tierLabel ? (
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs lg:px-4 lg:py-3">
|
||||
<p className="font-bold text-emerald-900">
|
||||
{t("orders.matchWin", { tier: tierLabel })}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-emerald-800/90">
|
||||
{t("orders.winAmount", {
|
||||
amount: formatMinorAsCurrency(data.settlement.win_amount_minor, cur),
|
||||
})}
|
||||
{data.settlement.jackpot_allocation_minor > 0 ? (
|
||||
<>
|
||||
{" "}
|
||||
·{" "}
|
||||
{t("orders.jackpotAmount", {
|
||||
amount: formatMinorAsCurrency(
|
||||
data.settlement.jackpot_allocation_minor,
|
||||
cur,
|
||||
),
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="mt-1 font-mono font-semibold text-emerald-900">
|
||||
{t(creditMode ? "orders.creditWinTotal" : "orders.payoutTotal", {
|
||||
amount: formatMinorAsCurrency(totalWin, cur),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : hasSettlement ? (
|
||||
<p className="text-xs text-slate-500">{t("orders.matchLose")}</p>
|
||||
) : null}
|
||||
|
||||
{matchResult && hasSettlement ? (
|
||||
<div className="rounded-lg border border-[#dce7f7] bg-[#f8fbff] px-3 py-3 text-xs lg:px-4">
|
||||
<p className="font-bold text-[#0b3f96]">
|
||||
{t("orders.matchResult")}
|
||||
</p>
|
||||
<p className="mt-1 text-slate-600">
|
||||
{matchResult.matched
|
||||
? t("orders.matchWin", { tier: tierLabel ?? (matchResult.matched_prize_tier ?? "—") })
|
||||
: t("orders.matchLose")}
|
||||
</p>
|
||||
{Array.isArray(matchResult.lines) && matchResult.lines.length > 0 ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
{matchResult.lines.map((line, idx) => (
|
||||
<p key={`${line.number_4d ?? "line"}-${idx}`} className="font-mono text-[11px] text-slate-500">
|
||||
{line.number_4d ?? "—"} · {line.matched_tier ?? "—"} · {formatMinorAsCurrency(line.payout ?? 0, cur)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{data.settled_at ? (
|
||||
<p className="text-[11px] text-slate-500">
|
||||
{t("orders.settledAt", { time: formatPlayerInstant(data.settled_at) })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-3 pt-1 lg:hidden">
|
||||
{data.draw_no ? (
|
||||
<Link
|
||||
href={`/results/${encodeURIComponent(data.draw_no)}`}
|
||||
prefetch={false}
|
||||
className={cn(
|
||||
"inline-flex h-11 min-w-[140px] flex-1 items-center justify-center rounded-xl bg-[#07459f] px-4 text-sm font-bold text-white shadow-sm transition-colors hover:bg-[#063b88]",
|
||||
)}
|
||||
>
|
||||
{t("orders.viewDraw")}
|
||||
</Link>
|
||||
) : null}
|
||||
<Link
|
||||
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]",
|
||||
)}
|
||||
>
|
||||
{t("orders.backToOrders")}
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-col gap-3 lg:gap-4">
|
||||
{pub?.results ? (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2 rounded-xl border border-[#e8eef7] bg-white p-3 shadow-[0_8px_28px_rgba(15,23,42,0.05)] lg:p-5">
|
||||
<p className="text-sm font-bold text-[#0b3f96]">{t("orders.drawNumbers")}</p>
|
||||
<TwentyThreeResultsGrid numbers={pub.results} highlighted4d={highlight} />
|
||||
{first ? (
|
||||
@@ -422,7 +508,7 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-[#dce7f7] bg-[#f8fbff] px-3 py-3 text-xs">
|
||||
<div className="rounded-xl border border-[#dce7f7] bg-white px-3 py-3 text-xs shadow-[0_8px_28px_rgba(15,23,42,0.05)] lg:px-5 lg:py-4">
|
||||
<p className="font-bold text-[#0b3f96]">{t("orders.drawNumbers")}</p>
|
||||
<p className="mt-1 text-[#32518d]">
|
||||
{t("orders.drawPendingMatch")}
|
||||
@@ -430,68 +516,14 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.settlement && tierLabel ? (
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs">
|
||||
<p className="font-bold text-emerald-900">
|
||||
{t("orders.matchWin", { tier: tierLabel })}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-emerald-800/90">
|
||||
{t("orders.winAmount", {
|
||||
amount: formatMinorAsCurrency(data.settlement.win_amount_minor, cur),
|
||||
})}
|
||||
{data.settlement.jackpot_allocation_minor > 0 ? (
|
||||
<>
|
||||
{" "}
|
||||
·{" "}
|
||||
{t("orders.jackpotAmount", {
|
||||
amount: formatMinorAsCurrency(
|
||||
data.settlement.jackpot_allocation_minor,
|
||||
cur,
|
||||
),
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="mt-1 font-mono font-semibold text-emerald-900">
|
||||
{t(creditMode ? "orders.creditWinTotal" : "orders.payoutTotal", {
|
||||
amount: formatMinorAsCurrency(totalWin, cur),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : hasSettlement ? (
|
||||
<p className="text-xs text-slate-500">{t("orders.matchLose")}</p>
|
||||
) : null}
|
||||
|
||||
{matchResult && hasSettlement ? (
|
||||
<div className="rounded-lg border border-[#dce7f7] bg-[#f8fbff] px-3 py-3 text-xs">
|
||||
<p className="font-bold text-[#0b3f96]">
|
||||
{t("orders.matchResult")}
|
||||
</p>
|
||||
<p className="mt-1 text-slate-600">
|
||||
{matchResult.matched
|
||||
? t("orders.matchWin", { tier: tierLabel ?? (matchResult.matched_prize_tier ?? "—") })
|
||||
: t("orders.matchLose")}
|
||||
</p>
|
||||
{Array.isArray(matchResult.lines) && matchResult.lines.length > 0 ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
{matchResult.lines.map((line, idx) => (
|
||||
<p key={`${line.number_4d ?? "line"}-${idx}`} className="font-mono text-[11px] text-slate-500">
|
||||
{line.number_4d ?? "—"} · {line.matched_tier ?? "—"} · {formatMinorAsCurrency(line.payout ?? 0, cur)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{timeline.length > 0 ? (
|
||||
<div className="rounded-xl border border-[#e8eef7] bg-[#f8fbff] px-3 py-3">
|
||||
<div className="rounded-xl border border-[#e8eef7] bg-white px-3 py-3 shadow-[0_8px_28px_rgba(15,23,42,0.05)] lg:px-5 lg:py-4">
|
||||
<p className="text-sm font-bold text-[#0b3f96]">
|
||||
{t("orders.timeline")}
|
||||
</p>
|
||||
<div className="mt-2 space-y-2">
|
||||
{timeline.map((row) => (
|
||||
<div key={row.code} className="flex items-start justify-between gap-3 rounded-lg bg-white px-3 py-2">
|
||||
<div key={row.code} className="flex items-start justify-between gap-3 rounded-lg bg-[#f8fbff] px-3 py-2">
|
||||
<p className="min-w-0 text-xs font-bold text-[#32518d]">
|
||||
{t(`orders.timelineEvent.${row.code}`, { defaultValue: row.label })}
|
||||
</p>
|
||||
@@ -504,34 +536,28 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{data.settled_at ? (
|
||||
<p className="text-[11px] text-slate-500">
|
||||
{t("orders.settledAt", { time: formatPlayerInstant(data.settled_at) })}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{data.draw_no ? (
|
||||
<Link
|
||||
href={`/results/${encodeURIComponent(data.draw_no)}`}
|
||||
prefetch={false}
|
||||
className={cn(
|
||||
"inline-flex h-11 min-w-[140px] flex-1 items-center justify-center rounded-xl bg-[#07459f] px-4 text-sm font-bold text-white shadow-sm transition-colors hover:bg-[#063b88]",
|
||||
)}
|
||||
>
|
||||
{t("orders.viewDraw")}
|
||||
</Link>
|
||||
) : null}
|
||||
<Link
|
||||
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]",
|
||||
)}
|
||||
>
|
||||
{t("orders.backToOrders")}
|
||||
</Link>
|
||||
<div className="hidden flex-wrap gap-3 lg:flex">
|
||||
{data.draw_no ? (
|
||||
<Link
|
||||
href={`/results/${encodeURIComponent(data.draw_no)}`}
|
||||
prefetch={false}
|
||||
className={cn(
|
||||
"inline-flex h-11 min-w-[140px] flex-1 items-center justify-center rounded-xl bg-[#07459f] px-4 text-sm font-bold text-white shadow-sm transition-colors hover:bg-[#063b88]",
|
||||
)}
|
||||
>
|
||||
{t("orders.viewDraw")}
|
||||
</Link>
|
||||
) : null}
|
||||
<Link
|
||||
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]",
|
||||
)}
|
||||
>
|
||||
{t("orders.backToOrders")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CalendarRange, ChevronDown, ChevronRight, Search, SlidersHorizontal } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -12,12 +12,22 @@ import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import { PlayerListPagination } from "@/components/layout/player-list-pagination";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
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";
|
||||
@@ -57,6 +67,7 @@ export function TicketOrdersListScreen() {
|
||||
const { t } = useTranslation("player");
|
||||
const { activeCurrency } = useActivePlayerCurrency();
|
||||
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
|
||||
const isMobile = useIsMobile();
|
||||
useCurrencyCatalog();
|
||||
const drawNoFilter = useMemo(() => (searchParams.get("draw_no") ?? "").trim(), [searchParams]);
|
||||
const numberFilter = useMemo(() => (searchParams.get("number") ?? "").trim(), [searchParams]);
|
||||
@@ -89,9 +100,20 @@ export function TicketOrdersListScreen() {
|
||||
const [statusOpen, setStatusOpen] = useState(false);
|
||||
const [calendarMonth, setCalendarMonth] = useState(() => new Date());
|
||||
const [scheduleTimezone, setScheduleTimezone] = useState(LOTTERY_SCHEDULE_TIMEZONE);
|
||||
/** 注单组默认折叠,点击展开明细 */
|
||||
const [expandedGroupKeys, setExpandedGroupKeys] = useState<Set<string>>(() => new Set());
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
const initialLoadDone = useRef(false);
|
||||
|
||||
const toggleGroupExpanded = useCallback((key: string) => {
|
||||
setExpandedGroupKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const queryDrawNoInput =
|
||||
queryDrawNoState.base === drawNoFilter ? queryDrawNoState.draft : drawNoFilter;
|
||||
const queryDrawNo = queryDrawNoInput || drawNoFilter;
|
||||
@@ -189,6 +211,8 @@ export function TicketOrdersListScreen() {
|
||||
}, [fetchPage]);
|
||||
|
||||
useEffect(() => {
|
||||
// 仅移动端无限滚动;PC 用分页
|
||||
if (!isMobile) return;
|
||||
const target = loadMoreRef.current;
|
||||
if (!target || loading || loadingMore || page >= lastPage) return;
|
||||
|
||||
@@ -203,13 +227,207 @@ export function TicketOrdersListScreen() {
|
||||
|
||||
observer.observe(target);
|
||||
return () => observer.disconnect();
|
||||
}, [fetchPage, lastPage, loading, loadingMore, page]);
|
||||
}, [fetchPage, isMobile, lastPage, loading, loadingMore, page]);
|
||||
|
||||
const clearFilters = () => {
|
||||
setQueryDrawNoState({ base: "", draft: "" });
|
||||
setQueryNumberDraft("");
|
||||
setFromDate("");
|
||||
setToDate("");
|
||||
setQueryStatuses([]);
|
||||
setFiltersOpen(false);
|
||||
setFiltersCollapsed(false);
|
||||
setRangeOpen(false);
|
||||
setStatusOpen(false);
|
||||
if (hasUrlFilters) {
|
||||
router.replace("/orders");
|
||||
}
|
||||
};
|
||||
|
||||
const hasActiveFilters = Boolean(
|
||||
queryDrawNoInput || queryNumber || fromDate || toDate || queryStatuses.length > 0,
|
||||
);
|
||||
|
||||
const filterFields = (
|
||||
<>
|
||||
<div className="flex h-9 min-w-0 items-center rounded-full border border-[#dce7f7] bg-[#fbfdff] px-3 lg:h-9 lg:rounded-lg lg:bg-white">
|
||||
<Input
|
||||
value={queryDrawNoInput}
|
||||
onChange={(e) =>
|
||||
setQueryDrawNoState({ base: drawNoFilter, draft: e.target.value })
|
||||
}
|
||||
placeholder={t("orders.drawNo")}
|
||||
aria-label={t("orders.drawNo")}
|
||||
className="h-8 border-0 bg-transparent px-0 text-base shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex h-9 min-w-0 items-center gap-2 rounded-full border border-[#dce7f7] bg-[#fbfdff] px-3 lg:h-9 lg:rounded-lg lg:bg-white">
|
||||
<Search className="size-3.5 shrink-0 text-slate-400" />
|
||||
<Input
|
||||
value={queryNumber}
|
||||
onChange={(e) => setQueryNumberDraft(e.target.value)}
|
||||
placeholder={t("orders.number")}
|
||||
aria-label={t("orders.number")}
|
||||
className="h-8 border-0 bg-transparent px-0 text-base shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Popover open={rangeOpen} onOpenChange={setRangeOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 w-full justify-start gap-2 rounded-full border-[#dce7f7] bg-[#fbfdff] px-3 text-left text-sm font-bold text-[#32518d] hover:bg-white lg:rounded-lg lg:bg-white"
|
||||
>
|
||||
<CalendarRange className="size-4 text-[#7890b8]" />
|
||||
<span className="truncate">{dateLabel}</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-auto border-[#dce7f7] p-2 shadow-[0_16px_40px_rgba(15,23,42,0.14)]">
|
||||
<p className="mb-2 px-1 text-[11px] leading-snug text-muted-foreground">
|
||||
{t("orders.dateRangeHint", { tz: scheduleTzLabel })}
|
||||
</p>
|
||||
<Calendar
|
||||
mode="range"
|
||||
month={calendarMonth}
|
||||
onMonthChange={setCalendarMonth}
|
||||
numberOfMonths={1}
|
||||
selected={selectedRange}
|
||||
onSelect={(range) => {
|
||||
if (!range?.from && !range?.to) {
|
||||
setFromDate("");
|
||||
setToDate("");
|
||||
return;
|
||||
}
|
||||
setFromDate(range?.from ? formatSchedulePickerYmd(range.from) : "");
|
||||
setToDate(range?.to ? formatSchedulePickerYmd(range.to) : "");
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2 border-t px-2 py-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const today = scheduleTodayYmd(scheduleTimezone);
|
||||
setFromDate(today);
|
||||
setToDate(today);
|
||||
}}
|
||||
>
|
||||
{t("orders.scheduleToday")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setFromDate("");
|
||||
setToDate("");
|
||||
}}
|
||||
>
|
||||
{t("actions.clear")}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="xs" onClick={() => setRangeOpen(false)}>
|
||||
{t("actions.done")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 w-full justify-between gap-2 rounded-full border-[#dce7f7] bg-[#fbfdff] px-3 text-sm font-bold text-[#32518d] hover:bg-white lg:rounded-lg lg:bg-white"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="shrink-0">{t("orders.status")}</span>
|
||||
{queryStatuses.length ? (
|
||||
<span className="truncate text-[#0b56b7]">
|
||||
{t(`ticketStatus.${queryStatuses[0]}`, { defaultValue: queryStatuses[0] })}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[#7890b8]">
|
||||
<ChevronDown className="size-3.5" />
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-56 border-[#dce7f7] p-1.5 shadow-[0_16px_40px_rgba(15,23,42,0.14)] lg:w-[28rem] lg:p-3"
|
||||
>
|
||||
<div className="mb-2 hidden items-center justify-between gap-2 px-1 lg:flex">
|
||||
<p className="text-xs font-black text-[#0b3f96]">
|
||||
{t("orders.status")}
|
||||
</p>
|
||||
{queryStatuses.length ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs font-bold text-[#7890b8] transition-colors hover:text-[#0b56b7]"
|
||||
onClick={() => setQueryStatuses([])}
|
||||
>
|
||||
{t("actions.clear")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-0.5 lg:grid lg:grid-cols-2 lg:gap-1.5 lg:space-y-0">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center rounded-md px-2 py-1.5 text-left text-[13px] font-semibold transition-colors lg:rounded-xl lg:border lg:px-3 lg:py-2.5",
|
||||
queryStatuses.length === 0
|
||||
? "bg-[#eaf2ff] text-[#0b56b7] lg:border-[#b9ccf6]"
|
||||
: "text-[#32518d] hover:bg-[#f8fbff] lg:border-[#e5edf8] lg:bg-[#fbfdff]",
|
||||
)}
|
||||
onClick={() => {
|
||||
setQueryStatuses([]);
|
||||
setStatusOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("actions.all", { defaultValue: "全部" })}
|
||||
</button>
|
||||
{STATUS_OPTIONS.map((status) => {
|
||||
const checked = queryStatuses[0] === status;
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center rounded-md px-2 py-1.5 text-left text-[13px] font-semibold transition-colors lg:rounded-xl lg:border lg:px-3 lg:py-2.5",
|
||||
checked
|
||||
? "bg-[#eaf2ff] text-[#0b56b7] lg:border-[#b9ccf6]"
|
||||
: "text-[#32518d] hover:bg-[#f8fbff] lg:border-[#e5edf8] lg:bg-[#fbfdff]",
|
||||
)}
|
||||
onClick={() => {
|
||||
setQueryStatuses(checked ? [] : [status]);
|
||||
setStatusOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{t(`ticketStatus.${status}`, { defaultValue: status })}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<PlayerPanel title={t("orders.title")}>
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-2xl border border-[#dfe9f8] bg-white p-3 shadow-[0_10px_28px_rgba(15,23,42,0.05)]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-3 lg:space-y-4">
|
||||
{/* 移动端筛选卡 */}
|
||||
<div className="rounded-2xl border border-[#dfe9f8] bg-white p-3 shadow-[0_10px_28px_rgba(15,23,42,0.05)] lg:hidden">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] font-black uppercase tracking-wide text-[#6f86ad]">
|
||||
{drawNoFilter ? t("orders.filteredIssue") : t("orders.totalRecords")}
|
||||
@@ -218,7 +436,7 @@ export function TicketOrdersListScreen() {
|
||||
{drawNoFilter || total}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -243,339 +461,441 @@ export function TicketOrdersListScreen() {
|
||||
>
|
||||
{t("orders.betNow")}
|
||||
</Link>
|
||||
{(queryDrawNoInput || queryNumber || fromDate || toDate || queryStatuses.length > 0) ? (
|
||||
{hasActiveFilters ? (
|
||||
<Button
|
||||
type="button"
|
||||
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: "", draft: "" });
|
||||
setQueryNumberDraft("");
|
||||
setFromDate("");
|
||||
setToDate("");
|
||||
setQueryStatuses([]);
|
||||
setFiltersOpen(false);
|
||||
setFiltersCollapsed(false);
|
||||
setRangeOpen(false);
|
||||
setStatusOpen(false);
|
||||
if (hasUrlFilters) {
|
||||
router.replace("/orders");
|
||||
}
|
||||
}}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
{t("actions.clear")}
|
||||
</Button>
|
||||
) : null}
|
||||
</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
|
||||
value={queryDrawNoInput}
|
||||
onChange={(e) =>
|
||||
setQueryDrawNoState({ base: drawNoFilter, draft: e.target.value })
|
||||
}
|
||||
placeholder={t("orders.drawNo")}
|
||||
aria-label={t("orders.drawNo")}
|
||||
className="h-8 border-0 bg-transparent px-0 text-base shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex h-9 min-w-0 items-center gap-2 rounded-full border border-[#dce7f7] bg-[#fbfdff] px-3">
|
||||
<Search className="size-3.5 shrink-0 text-slate-400" />
|
||||
<Input
|
||||
value={queryNumber}
|
||||
onChange={(e) => setQueryNumberDraft(e.target.value)}
|
||||
placeholder={t("orders.number")}
|
||||
aria-label={t("orders.number")}
|
||||
className="h-8 border-0 bg-transparent px-0 text-base shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Popover open={rangeOpen} onOpenChange={setRangeOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 w-full justify-start gap-2 rounded-full border-[#dce7f7] bg-[#fbfdff] px-3 text-left text-sm font-bold text-[#32518d] hover:bg-white"
|
||||
>
|
||||
<CalendarRange className="size-4 text-[#7890b8]" />
|
||||
<span className="truncate">{dateLabel}</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-auto border-[#dce7f7] p-2 shadow-[0_16px_40px_rgba(15,23,42,0.14)]">
|
||||
<p className="mb-2 px-1 text-[11px] leading-snug text-muted-foreground">
|
||||
{t("orders.dateRangeHint", { tz: scheduleTzLabel })}
|
||||
</p>
|
||||
<Calendar
|
||||
mode="range"
|
||||
month={calendarMonth}
|
||||
onMonthChange={setCalendarMonth}
|
||||
numberOfMonths={1}
|
||||
selected={selectedRange}
|
||||
onSelect={(range) => {
|
||||
if (!range?.from && !range?.to) {
|
||||
setFromDate("");
|
||||
setToDate("");
|
||||
return;
|
||||
}
|
||||
setFromDate(range?.from ? formatSchedulePickerYmd(range.from) : "");
|
||||
setToDate(range?.to ? formatSchedulePickerYmd(range.to) : "");
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2 border-t px-2 py-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const today = scheduleTodayYmd(scheduleTimezone);
|
||||
setFromDate(today);
|
||||
setToDate(today);
|
||||
}}
|
||||
>
|
||||
{t("orders.scheduleToday")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setFromDate("");
|
||||
setToDate("");
|
||||
}}
|
||||
>
|
||||
{t("actions.clear")}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="xs" onClick={() => setRangeOpen(false)}>
|
||||
{t("actions.done")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 w-full justify-between gap-2 rounded-full border-[#dce7f7] bg-[#fbfdff] px-3 text-sm font-bold text-[#32518d] hover:bg-white"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="shrink-0">{t("orders.status")}</span>
|
||||
{queryStatuses.length ? (
|
||||
<span className="truncate text-[#0b56b7]">
|
||||
{t(`ticketStatus.${queryStatuses[0]}`, { defaultValue: queryStatuses[0] })}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[#7890b8]">
|
||||
<ChevronDown className="size-3.5" />
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-56 border-[#dce7f7] p-1.5 shadow-[0_16px_40px_rgba(15,23,42,0.14)]">
|
||||
<div className="space-y-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center rounded-md px-2 py-1.5 text-left text-[13px] font-semibold transition-colors",
|
||||
queryStatuses.length === 0 ? "bg-[#eaf2ff] text-[#0b56b7]" : "text-[#32518d] hover:bg-[#f8fbff]",
|
||||
)}
|
||||
onClick={() => setQueryStatuses([])}
|
||||
>
|
||||
{t("actions.all", { defaultValue: "全部" })}
|
||||
</button>
|
||||
{STATUS_OPTIONS.map((status) => {
|
||||
const checked = queryStatuses[0] === status;
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center rounded-md px-2 py-1.5 text-left text-[13px] font-semibold transition-colors",
|
||||
checked ? "bg-[#eaf2ff] text-[#0b56b7]" : "text-[#32518d] hover:bg-[#f8fbff]",
|
||||
)}
|
||||
onClick={() => {
|
||||
setQueryStatuses(checked ? [] : [status]);
|
||||
setStatusOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{t(`ticketStatus.${status}`, { defaultValue: status })}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">{filterFields}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-28 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : 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"
|
||||
size="sm"
|
||||
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
|
||||
onClick={() => void fetchPage(1, false)}
|
||||
>
|
||||
{t("actions.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<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.empty")}</p>
|
||||
<Link
|
||||
href="/hall"
|
||||
className="mt-4 inline-flex h-9 items-center rounded-lg bg-[#e5002c] px-4 text-sm font-bold text-white"
|
||||
>
|
||||
{t("orders.submitBet")}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
{orderGroups.map((group) => {
|
||||
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 (
|
||||
<div
|
||||
key={group.key}
|
||||
className="rounded-xl border border-[#e5edf8] bg-white shadow-[0_8px_24px_rgba(15,23,42,0.05)]"
|
||||
<div className="space-y-3 lg:overflow-hidden lg:rounded-xl lg:border lg:border-[#e5edf8] lg:bg-white">
|
||||
{/* PC 顶栏:统计 + 筛选 + 操作 */}
|
||||
<div className="hidden lg:block lg:border-b lg:border-[#eef3fa] lg:bg-white lg:px-5 lg:py-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="min-w-0 shrink-0">
|
||||
<p className="text-[11px] font-bold text-[#7890b8]">
|
||||
{drawNoFilter ? t("orders.filteredIssue") : t("orders.totalRecords")}
|
||||
</p>
|
||||
<p className="mt-0.5 font-mono text-lg font-black leading-none text-[#0b3f96]">
|
||||
{drawNoFilter || total}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid min-w-0 flex-1 grid-cols-4 gap-2">
|
||||
{filterFields}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{hasActiveFilters ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 rounded-lg border-[#dce7f7] bg-white px-3 text-xs font-bold text-[#32518d] hover:bg-[#f8fbff]"
|
||||
onClick={clearFilters}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div className="space-y-2 border-t border-[#edf2f8] px-3 py-3">
|
||||
<p className="text-[11px] font-bold uppercase tracking-wide text-[#7890b8]">
|
||||
{t("orders.betItems")}
|
||||
</p>
|
||||
{group.items.map((row, index) => {
|
||||
const lineSt = ticketStatusDisplay(
|
||||
row.status,
|
||||
row.win_amount,
|
||||
row.jackpot_win_amount,
|
||||
t,
|
||||
creditMode,
|
||||
);
|
||||
const lineCur = row.currency_code ?? cur;
|
||||
return (
|
||||
<Link
|
||||
key={row.ticket_no}
|
||||
href={ticketDetailHref(row.ticket_no)}
|
||||
aria-label={t("orders.viewBetLine")}
|
||||
className="flex items-center gap-3 rounded-xl border border-[#e5edf8] bg-[#fbfdff] px-3 py-2.5 transition-colors hover:border-[#b9ccf6] hover:bg-white"
|
||||
>
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[#eaf2ff] text-xs font-black text-[#0b56b7]">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-black text-[#0b3f96]">
|
||||
{playLabel(row.play_code, t)} · {row.original_number ?? row.play_code}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500">
|
||||
<span className="font-bold text-[#0b56b7]">
|
||||
{providerLabel(row.provider_name, row.provider_code)}
|
||||
</span>
|
||||
{" · "}
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
{t("actions.clear")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Link
|
||||
href="/hall"
|
||||
className="inline-flex h-9 shrink-0 items-center rounded-lg bg-[#e5002c] px-4 text-sm font-black text-white"
|
||||
>
|
||||
{t("orders.betNow")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={loadMoreRef} className="min-h-1" />
|
||||
{page < lastPage ? (
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3 lg:space-y-0">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-28 w-full rounded-xl lg:h-12 lg:rounded-none" />
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700 lg:rounded-none lg:border-0 lg:px-5 lg:py-6">
|
||||
<p>{error}</p>
|
||||
<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)}
|
||||
size="sm"
|
||||
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
|
||||
onClick={() => void fetchPage(1, false)}
|
||||
>
|
||||
{loadingMore ? t("actions.loading") : t("actions.loadMore")}
|
||||
{t("actions.retry")}
|
||||
</Button>
|
||||
) : lastPage > 1 ? (
|
||||
<p className="py-2 text-center text-xs text-slate-400">
|
||||
{t("orders.noMore")}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-[#dce7f7] bg-[#f8fbff] px-3 py-8 text-center lg:rounded-none lg:border-0 lg:bg-white lg:px-5 lg:py-12">
|
||||
<p className="text-sm font-bold text-slate-700">{t("orders.empty")}</p>
|
||||
<Link
|
||||
href="/hall"
|
||||
className="mt-4 inline-flex h-9 items-center rounded-lg bg-[#e5002c] px-4 text-sm font-bold text-white"
|
||||
>
|
||||
{t("orders.submitBet")}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 移动端:卡片分组(默认折叠) */}
|
||||
<div className="space-y-3 lg:hidden">
|
||||
{orderGroups.map((group) => {
|
||||
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;
|
||||
const expanded = expandedGroupKeys.has(group.key);
|
||||
return (
|
||||
<div
|
||||
key={group.key}
|
||||
className="overflow-hidden rounded-xl border border-[#e5edf8] bg-white shadow-[0_8px_24px_rgba(15,23,42,0.05)]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleGroupExpanded(group.key)}
|
||||
aria-expanded={expanded}
|
||||
className="w-full p-3 pb-2 text-left"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="min-w-0 truncate font-mono text-sm font-black text-[#0b3f96]">
|
||||
{group.draw_no ?? "—"}
|
||||
</p>
|
||||
<OrderMetaLine
|
||||
orderNo={group.order_no}
|
||||
placedAt={group.placed_at}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-4 text-[#7890b8] transition-transform",
|
||||
expanded && "rotate-180",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
</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)}
|
||||
</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>
|
||||
<p className="mt-2 text-xs font-semibold text-[#7890b8]">
|
||||
{t("orders.betItems")} · {group.items.length}
|
||||
</p>
|
||||
{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}
|
||||
</button>
|
||||
|
||||
{expanded ? (
|
||||
<div className="border-t border-[#edf2f8] px-3 py-3">
|
||||
<p className="text-[11px] font-bold uppercase tracking-wide text-[#7890b8]">
|
||||
{t("orders.betItems")}
|
||||
</p>
|
||||
<div className="mt-2 space-y-2">
|
||||
{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="truncate 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">
|
||||
<span className="font-bold text-[#0b56b7]">
|
||||
{providerLabel(row.provider_name, row.provider_code)}
|
||||
</span>
|
||||
{" · "}
|
||||
{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>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* PC:后台风格表格(默认折叠) */}
|
||||
<div className="hidden lg:block">
|
||||
<Table className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow className="border-[#eef3fa] hover:bg-transparent">
|
||||
<TableHead className="h-11 w-[22%] bg-white px-5 text-xs font-bold text-[#59739f]">
|
||||
{t("orders.drawNo")}
|
||||
</TableHead>
|
||||
<TableHead className="h-11 w-[24%] bg-white px-3 text-xs font-bold text-[#59739f]">
|
||||
{t("orders.betItems")}
|
||||
</TableHead>
|
||||
<TableHead className="h-11 w-[12%] bg-white px-3 text-xs font-bold text-[#59739f]">
|
||||
{t("hall.providers.title")}
|
||||
</TableHead>
|
||||
<TableHead className="h-11 w-[12%] bg-white px-3 text-right text-xs font-bold text-[#59739f]">
|
||||
{t("orders.stake")}
|
||||
</TableHead>
|
||||
<TableHead className="h-11 w-[12%] bg-white px-3 text-right text-xs font-bold text-[#59739f]">
|
||||
{t("orders.deduction")}
|
||||
</TableHead>
|
||||
<TableHead className="h-11 w-[14%] bg-white px-3 text-right text-xs font-bold text-[#59739f]">
|
||||
{t("orders.status")}
|
||||
</TableHead>
|
||||
<TableHead className="h-11 w-10 bg-white px-3" aria-hidden />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{orderGroups.map((group) => {
|
||||
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;
|
||||
const expanded = expandedGroupKeys.has(group.key);
|
||||
return (
|
||||
<Fragment key={group.key}>
|
||||
<TableRow
|
||||
className="cursor-pointer border-[#eef3fa] bg-white hover:bg-[#f8fafc]"
|
||||
onClick={() => toggleGroupExpanded(group.key)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<TableCell className="px-5 py-2.5 align-middle">
|
||||
<p className="truncate font-mono text-sm font-black text-[#0b3f96]">
|
||||
{group.draw_no ?? "—"}
|
||||
</p>
|
||||
<OrderMetaLine
|
||||
orderNo={group.order_no}
|
||||
placedAt={group.placed_at}
|
||||
t={t}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-2.5 align-middle">
|
||||
<p className="truncate text-xs font-semibold text-[#7890b8]">
|
||||
{t("orders.betItems")} · {group.items.length}
|
||||
{totalWin > 0 && group.status === "settled_win" ? (
|
||||
<span className="ml-2 text-emerald-600">
|
||||
{t("orders.win", {
|
||||
amount: formatMinorAsCurrency(totalWin, cur),
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
{group.status === "partial_failed" ? (
|
||||
<span className="ml-2 text-amber-700">
|
||||
{t("orders.partialFailedHint")}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-2.5" />
|
||||
<TableCell className="px-3 py-2.5 text-right align-middle">
|
||||
<p className="text-sm font-black tabular-nums text-slate-900">
|
||||
{formatMinorAsCurrency(group.total_bet_amount, cur)}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right align-middle">
|
||||
<p className="text-sm font-black tabular-nums text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(group.actual_deduct_amount, cur)}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right align-middle">
|
||||
<div className="flex justify-end">
|
||||
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right align-middle">
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"ml-auto size-4 text-[#7890b8] transition-transform",
|
||||
expanded && "rotate-180",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expanded
|
||||
? 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 (
|
||||
<TableRow
|
||||
key={row.ticket_no}
|
||||
className="border-[#f1f5fb] hover:bg-[#f8fafc]"
|
||||
>
|
||||
<TableCell className="px-5 py-2.5 align-middle">
|
||||
<Link
|
||||
href={ticketDetailHref(row.ticket_no)}
|
||||
aria-label={t("orders.viewBetLine")}
|
||||
className="font-mono text-xs text-slate-400"
|
||||
>
|
||||
#{index + 1}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-2.5 align-middle">
|
||||
<Link
|
||||
href={ticketDetailHref(row.ticket_no)}
|
||||
className="block min-w-0 truncate text-sm font-bold text-[#0b3f96]"
|
||||
>
|
||||
{playLabel(row.play_code, t)} ·{" "}
|
||||
{row.original_number ?? row.play_code}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-2.5 align-middle">
|
||||
<Link
|
||||
href={ticketDetailHref(row.ticket_no)}
|
||||
className="block truncate text-sm font-semibold text-[#0b56b7]"
|
||||
>
|
||||
{providerLabel(row.provider_name, row.provider_code)}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right align-middle">
|
||||
<Link
|
||||
href={ticketDetailHref(row.ticket_no)}
|
||||
className="text-sm tabular-nums text-slate-500"
|
||||
>
|
||||
{formatMinorAsCurrency(row.total_bet_amount, lineCur)}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right align-middle">
|
||||
<Link
|
||||
href={ticketDetailHref(row.ticket_no)}
|
||||
className="text-sm font-bold tabular-nums text-[#0b3f96]"
|
||||
>
|
||||
{formatMinorAsCurrency(row.actual_deduct_amount, lineCur)}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right align-middle">
|
||||
<Link
|
||||
href={ticketDetailHref(row.ticket_no)}
|
||||
className="flex justify-end"
|
||||
>
|
||||
<StatusDot
|
||||
label={lineSt.label}
|
||||
dotClass={lineSt.dotClass}
|
||||
ring={lineSt.ring}
|
||||
/>
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right align-middle">
|
||||
<Link
|
||||
href={ticketDetailHref(row.ticket_no)}
|
||||
className="inline-flex justify-end text-[#7890b8]"
|
||||
aria-hidden
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="lg:hidden">
|
||||
<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")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<PlayerListPagination
|
||||
page={page}
|
||||
lastPage={lastPage}
|
||||
total={total}
|
||||
loading={loading || loadingMore}
|
||||
onPageChange={(nextPage) => {
|
||||
void fetchPage(nextPage, false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
);
|
||||
|
||||
@@ -27,7 +27,6 @@ import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
||||
import { norm4d } from "@/lib/norm-4d";
|
||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||
import { resultsPrizeLabelKey, RESULTS_TOP_PRIZE_KEYS } from "@/lib/results-prize-labels";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import type { DrawResultDetailPayload } from "@/types/api/draw-results";
|
||||
@@ -120,9 +119,9 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
||||
backHref="/results"
|
||||
backLabel={t("results.title")}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-12 rounded-xl" />
|
||||
<Skeleton className="h-56 rounded-xl" />
|
||||
<div className="space-y-3 lg:space-y-4">
|
||||
<Skeleton className="h-12 rounded-xl lg:h-14" />
|
||||
<Skeleton className="h-56 rounded-xl lg:h-72" />
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
);
|
||||
@@ -135,9 +134,9 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
||||
backHref="/results"
|
||||
backLabel={t("results.title")}
|
||||
>
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700 lg:px-5 lg:py-5">
|
||||
<p>{error ?? t("results.noData")}</p>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
||||
<Button type="button" size="sm" variant="secondary" className="mt-3" onClick={() => void load()}>
|
||||
{t("actions.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -170,33 +169,44 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
||||
backHref="/results"
|
||||
backLabel={t("results.title")}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<JackpotResultsStrip currencyCode={currency} />
|
||||
<div className="flex flex-col gap-3 lg:gap-5">
|
||||
<div className="lg:hidden">
|
||||
<JackpotResultsStrip currencyCode={currency} />
|
||||
</div>
|
||||
<div className="hidden lg:block">
|
||||
<JackpotResultsStrip currencyCode={currency} compact />
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden border-[#e5edf8] bg-white shadow-[0_10px_28px_rgba(15,23,42,0.06)]">
|
||||
<CardHeader className="space-y-3 border-b border-[#edf2f9] bg-[#f8fbff] pb-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Card className="overflow-hidden border-[#e5edf8] bg-white shadow-[0_10px_28px_rgba(15,23,42,0.06)] lg:shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
||||
<CardHeader className="space-y-3 border-b border-[#edf2f9] bg-[#f8fbff] pb-3 lg:px-6 lg:py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 lg:gap-4">
|
||||
{data.previous_draw_no ? (
|
||||
<Link
|
||||
href={`/results/${encodeURIComponent(data.previous_draw_no)}`}
|
||||
prefetch={false}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"min-w-[5rem] rounded-full border-[#dce7f7] bg-white text-[#0b56b7] hover:bg-[#f1f6ff]",
|
||||
"min-w-[5rem] rounded-full border-[#dce7f7] bg-white text-[#0b56b7] hover:bg-[#f1f6ff] lg:min-w-[6rem] lg:rounded-lg",
|
||||
)}
|
||||
>
|
||||
{t("results.previous")}
|
||||
</Link>
|
||||
) : (
|
||||
<Button type="button" variant="outline" size="sm" className="min-w-[5rem] rounded-full border-[#e6edf8] bg-white text-slate-400" disabled>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-w-[5rem] rounded-full border-[#e6edf8] bg-white text-slate-400 lg:min-w-[6rem] lg:rounded-lg"
|
||||
disabled
|
||||
>
|
||||
{t("results.previous")}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 flex-col items-center text-center">
|
||||
<CardTitle className="truncate font-mono text-lg font-black text-[#0b3f96]">
|
||||
<CardTitle className="truncate font-mono text-lg font-black text-[#0b3f96] lg:text-xl">
|
||||
{data.draw_no}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1 font-mono text-xs text-slate-500">
|
||||
<CardDescription className="mt-1 font-mono text-xs text-slate-500 lg:text-sm">
|
||||
{t("results.drawTime", {
|
||||
time: formatPlayerInstant(data.draw_time_iso ?? data.draw_time ?? null),
|
||||
})}
|
||||
@@ -208,98 +218,83 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
||||
prefetch={false}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"min-w-[5rem] rounded-full border-[#dce7f7] bg-white text-[#0b56b7] hover:bg-[#f1f6ff]",
|
||||
"min-w-[5rem] rounded-full border-[#dce7f7] bg-white text-[#0b56b7] hover:bg-[#f1f6ff] lg:min-w-[6rem] lg:rounded-lg",
|
||||
)}
|
||||
>
|
||||
{t("results.next")}
|
||||
</Link>
|
||||
) : (
|
||||
<Button type="button" variant="outline" size="sm" className="min-w-[5rem] rounded-full border-[#e6edf8] bg-white text-slate-400" disabled>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-w-[5rem] rounded-full border-[#e6edf8] bg-white text-slate-400 lg:min-w-[6rem] lg:rounded-lg"
|
||||
disabled
|
||||
>
|
||||
{t("results.next")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 pt-3">
|
||||
<div className="rounded-xl border border-[#e8eef7] bg-[#f8fbff] p-3 shadow-[0_4px_14px_rgba(15,23,42,0.04)]">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-black text-[#0b3f96]">
|
||||
{t("results.detailTitle")}
|
||||
</p>
|
||||
<span className="rounded-full bg-[#f2f6ff] px-2.5 py-1 text-xs font-bold text-[#0b56b7]">
|
||||
{t("results.detail")}
|
||||
</span>
|
||||
<CardContent className="space-y-3 pt-3 lg:space-y-5 lg:px-6 lg:pb-6 lg:pt-5">
|
||||
{providerResults.length > 1 ? (
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5 lg:gap-2">
|
||||
{providerResults.map((row) => {
|
||||
const code = String(row.provider_code ?? "");
|
||||
const active = code === String(activeProviderResult?.provider_code ?? "");
|
||||
return (
|
||||
<button
|
||||
key={code || row.provider_name || row.result_version}
|
||||
type="button"
|
||||
onClick={() => setActiveProviderCode(code)}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1 text-xs font-bold transition-colors lg:rounded-lg lg:px-3.5 lg:py-1.5 lg:text-sm",
|
||||
active
|
||||
? "border-[#0b56b7] bg-[#0b56b7] text-white"
|
||||
: "border-[#dce7f7] bg-white text-[#0b56b7] hover:bg-[#f1f6ff]",
|
||||
)}
|
||||
>
|
||||
{row.provider_name || row.provider_code || "Provider"}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{providerResults.length > 1 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{providerResults.map((row) => {
|
||||
const code = String(row.provider_code ?? "");
|
||||
const active = code === String(activeProviderResult?.provider_code ?? "");
|
||||
return (
|
||||
<button
|
||||
key={code || row.provider_name || row.result_version}
|
||||
type="button"
|
||||
onClick={() => setActiveProviderCode(code)}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1 text-xs font-bold transition-colors",
|
||||
active
|
||||
? "border-[#0b56b7] bg-[#0b56b7] text-white"
|
||||
: "border-[#dce7f7] bg-white text-[#0b56b7] hover:bg-[#f1f6ff]",
|
||||
)}
|
||||
>
|
||||
{row.provider_name || row.provider_code || "Provider"}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<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-white py-2 shadow-[0_4px_12px_rgba(15,23,42,0.03)]"
|
||||
>
|
||||
<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]">
|
||||
{activeResults[tier]}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<TwentyThreeResultsGrid
|
||||
numbers={activeResults}
|
||||
highlighted4d={highlightSet ?? undefined}
|
||||
/>
|
||||
|
||||
{showMyPayout && myTotals ? (
|
||||
<div className="rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-3 text-sm shadow-[0_4px_14px_rgba(15,23,42,0.03)]">
|
||||
<p className="font-bold text-emerald-900">
|
||||
{t(creditMode ? "results.creditMyWin" : "results.myPayout")}
|
||||
</p>
|
||||
<p className="mt-2 font-mono text-xs tabular-nums text-emerald-900/80">
|
||||
{t("results.regular", {
|
||||
amount: formatMinorAsCurrency(myTotals.win, currency),
|
||||
})}
|
||||
{myTotals.jackpot > 0 ? (
|
||||
<>
|
||||
{" "}
|
||||
·{" "}
|
||||
{t("results.jackpot", {
|
||||
amount: formatMinorAsCurrency(myTotals.jackpot, currency),
|
||||
{(showMyPayout && myTotals) || showHitOnly ? (
|
||||
<div className="grid gap-3 lg:grid-cols-2 lg:gap-4">
|
||||
{showMyPayout && myTotals ? (
|
||||
<div className="rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-3 text-sm shadow-[0_4px_14px_rgba(15,23,42,0.03)] lg:px-4 lg:py-4">
|
||||
<p className="font-bold text-emerald-900 lg:text-base">
|
||||
{t(creditMode ? "results.creditMyWin" : "results.myPayout")}
|
||||
</p>
|
||||
<p className="mt-2 font-mono text-xs tabular-nums text-emerald-900/80 lg:text-sm">
|
||||
{t("results.regular", {
|
||||
amount: formatMinorAsCurrency(myTotals.win, currency),
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{myTotals.jackpot > 0 ? (
|
||||
<>
|
||||
{" "}
|
||||
·{" "}
|
||||
{t("results.jackpot", {
|
||||
amount: formatMinorAsCurrency(myTotals.jackpot, currency),
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showHitOnly ? (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-3 text-xs text-amber-950">
|
||||
{t(creditMode ? "results.creditHitPending" : "results.hitPending")}
|
||||
{showHitOnly ? (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-3 text-xs text-amber-950 lg:px-4 lg:py-4 lg:text-sm">
|
||||
{t(creditMode ? "results.creditHitPending" : "results.hitPending")}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { CalendarIcon, XIcon } from "lucide-react";
|
||||
import { CalendarIcon, ChevronRight, XIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -17,12 +17,23 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import { PlayerListPagination } from "@/components/layout/player-list-pagination";
|
||||
import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip";
|
||||
|
||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { formatPlayerInstant } from "@/lib/player-datetime";
|
||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resultsPrizeLabelKey, RESULTS_TOP_PRIZE_KEYS } from "@/lib/results-prize-labels";
|
||||
import type { DrawResultListItem } from "@/types/api/draw-results";
|
||||
|
||||
@@ -36,6 +47,7 @@ const MONTH_OPTIONS = Array.from({ length: 12 }, (_, value) => ({
|
||||
export function DrawResultsListScreen() {
|
||||
const { t } = useTranslation("player");
|
||||
useCurrencyCatalog();
|
||||
const isMobile = useIsMobile();
|
||||
const [items, setItems] = useState<DrawResultListItem[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [date, setDate] = useState("");
|
||||
@@ -43,6 +55,7 @@ export function DrawResultsListScreen() {
|
||||
const [calendarMonth, setCalendarMonth] = useState(() => new Date());
|
||||
const [page, setPage] = useState(1);
|
||||
const [lastPage, setLastPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -71,6 +84,7 @@ export function DrawResultsListScreen() {
|
||||
setItems((current) => (append && current ? [...current, ...res.items] : res.items));
|
||||
setPage(res.page);
|
||||
setLastPage(res.last_page);
|
||||
setTotal(res.total);
|
||||
} catch {
|
||||
setError(t("results.loadFailed"));
|
||||
if (!append) {
|
||||
@@ -92,6 +106,8 @@ export function DrawResultsListScreen() {
|
||||
}, [fetchList]);
|
||||
|
||||
useEffect(() => {
|
||||
// 仅移动端无限滚动;PC 用分页
|
||||
if (!isMobile) return;
|
||||
const target = loadMoreRef.current;
|
||||
if (!target || loading || loadingMore || page >= lastPage) return;
|
||||
|
||||
@@ -106,241 +122,350 @@ export function DrawResultsListScreen() {
|
||||
|
||||
observer.observe(target);
|
||||
return () => observer.disconnect();
|
||||
}, [fetchList, lastPage, loading, loadingMore, page]);
|
||||
}, [fetchList, isMobile, lastPage, loading, loadingMore, page]);
|
||||
|
||||
const listRows = items ?? [];
|
||||
|
||||
return (
|
||||
<PlayerPanel title={t("results.title")}>
|
||||
<div className="space-y-3">
|
||||
<JackpotResultsStrip currencyCode={jackpotCurrency} />
|
||||
<div className="space-y-3 lg:space-y-4">
|
||||
<div className="space-y-3 lg:overflow-hidden lg:rounded-xl lg:border lg:border-[#e5edf8] lg:bg-white lg:shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
||||
<div className="lg:border-b lg:border-[#eef3fa] lg:bg-[#fbfdff] lg:px-5 lg:py-3">
|
||||
<div className="space-y-3 lg:flex lg:items-center lg:justify-between lg:gap-4 lg:space-y-0">
|
||||
<div className="min-w-0 lg:flex-1">
|
||||
<JackpotResultsStrip currencyCode={jackpotCurrency} compact />
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-[#e6edf8] bg-[#f8fbff] p-3">
|
||||
<p className="mb-2 text-xs font-bold text-[#32518d]">
|
||||
{t("results.businessDate")}
|
||||
</p>
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Popover open={datePickerOpen} onOpenChange={setDatePickerOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<div className="rounded-xl border border-[#e6edf8] bg-[#f8fbff] p-3 lg:rounded-none lg:border-0 lg:bg-transparent lg:p-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="hidden shrink-0 text-xs font-bold text-[#59739f] lg:block">
|
||||
{t("results.businessDate")}
|
||||
</p>
|
||||
<div className="min-w-0 flex-1 lg:w-44 lg:flex-none">
|
||||
<Popover open={datePickerOpen} onOpenChange={setDatePickerOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-10 w-full justify-start rounded-lg border-[#dce7f7] bg-white px-3 text-left text-sm font-semibold text-[#32518d] hover:bg-[#f8fbff] lg:h-9"
|
||||
>
|
||||
<CalendarIcon className="mr-2 size-4 text-[#7890b8]" />
|
||||
<span className="truncate">
|
||||
{date
|
||||
|| t("results.selectBusinessDate", {
|
||||
defaultValue: "选择日期",
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-auto border-[#dce7f7] p-2 shadow-[0_16px_40px_rgba(15,23,42,0.14)]">
|
||||
<div className="mb-2 grid grid-cols-2 gap-2">
|
||||
<Select
|
||||
value={String(calendarMonth.getMonth())}
|
||||
onValueChange={(value) => {
|
||||
setCalendarMonth((current) => new Date(current.getFullYear(), Number(value), 1));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full border-[#dce7f7] bg-white text-xs font-semibold text-[#32518d]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-64 min-w-24">
|
||||
{MONTH_OPTIONS.map((month) => (
|
||||
<SelectItem key={month.value} value={String(month.value)}>
|
||||
{t(month.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={String(calendarMonth.getFullYear())}
|
||||
onValueChange={(value) => {
|
||||
setCalendarMonth((current) => new Date(Number(value), current.getMonth(), 1));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full border-[#dce7f7] bg-white text-xs font-semibold text-[#32518d]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-64 min-w-24">
|
||||
{quickYears.map((year) => (
|
||||
<SelectItem key={year} value={String(year)}>
|
||||
{year}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Calendar
|
||||
mode="single"
|
||||
month={calendarMonth}
|
||||
onMonthChange={setCalendarMonth}
|
||||
selected={selectedDate}
|
||||
onSelect={(value) => {
|
||||
if (!value) return;
|
||||
setDate(formatBusinessDate(value));
|
||||
setCalendarMonth(value);
|
||||
setDatePickerOpen(false);
|
||||
}}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
{date ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-10 w-10 rounded-lg border-[#dce7f7] bg-white text-[#7890b8] hover:bg-[#f8fbff] hover:text-[#32518d] lg:h-9 lg:w-9"
|
||||
aria-label={t("actions.clear")}
|
||||
onClick={() => setDate("")}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-10 shrink-0 rounded-lg bg-[#07459f] px-4 text-white hover:bg-[#063b88] lg:h-9"
|
||||
onClick={() => void fetchList(1, false)}
|
||||
>
|
||||
{t("actions.apply")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3 lg:space-y-0 lg:px-0">
|
||||
<Skeleton className="h-28 rounded-xl lg:h-12 lg:rounded-none" />
|
||||
<Skeleton className="h-28 rounded-xl lg:h-12 lg:rounded-none" />
|
||||
<Skeleton className="h-28 rounded-xl lg:h-12 lg:rounded-none" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700 lg:rounded-none lg:border-0 lg:px-5 lg:py-6">
|
||||
<p>{error}</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
|
||||
onClick={() => void fetchList(1, false)}
|
||||
>
|
||||
{t("actions.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : listRows.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-[#dce7f7] bg-[#f8fbff] px-3 py-8 text-center text-sm text-slate-500 lg:rounded-none lg:border-0 lg:bg-white lg:py-12">
|
||||
{t("results.empty")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 移动端:首条高亮卡 */}
|
||||
{featured ? (
|
||||
<div className="rounded-xl border border-[#dce7f7] bg-white p-3 shadow-[0_10px_28px_rgba(15,23,42,0.06)] lg:hidden">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-[#edf2f9] pb-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-black uppercase tracking-normal text-[#0b56b7]">
|
||||
{t("results.latest")}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-lg font-black text-[#0b3f96]">
|
||||
{featured.draw_no}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-xs text-slate-500">
|
||||
{t("results.drawTime", {
|
||||
time: formatPlayerInstant(
|
||||
featured.draw_time_iso ?? featured.draw_time ?? null,
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
{(featured.provider_results?.length ?? 0) > 1 ? (
|
||||
<p className="mt-1 text-[11px] font-semibold text-[#0b56b7]">
|
||||
{featured.provider_results?.map((row) => row.provider_name || row.provider_code).filter(Boolean).join(" / ")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Link
|
||||
href={`/results/${encodeURIComponent(featured.draw_no)}`}
|
||||
prefetch={false}
|
||||
className="inline-flex h-8 shrink-0 items-center justify-center rounded-full border border-[#dce7f7] bg-white px-3 text-sm font-semibold text-[#0b56b7] transition-colors hover:bg-[#f1f6ff]"
|
||||
>
|
||||
{t("results.openDetail", { defaultValue: "查看详情" })}
|
||||
</Link>
|
||||
</div>
|
||||
<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}
|
||||
|
||||
{/* 移动端:卡片列表(首条已高亮展示时跳过) */}
|
||||
<div className="space-y-3 lg:hidden">
|
||||
{listRows.slice(1).map((row) => (
|
||||
<Link
|
||||
key={row.draw_no}
|
||||
href={`/results/${encodeURIComponent(row.draw_no)}`}
|
||||
prefetch={false}
|
||||
className="block rounded-xl border border-[#e5edf8] bg-white p-3 shadow-[0_8px_24px_rgba(15,23,42,0.05)] transition-colors hover:border-[#b9ccf6]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-mono text-sm font-black text-[#0b3f96]">
|
||||
{row.draw_no}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-slate-500">
|
||||
{formatPlayerInstant(row.draw_time_iso ?? row.draw_time ?? null)}
|
||||
</p>
|
||||
{(row.provider_results?.length ?? 0) > 1 ? (
|
||||
<p className="mt-1 text-[11px] font-semibold text-[#0b56b7]">
|
||||
{row.provider_results?.map((item) => item.provider_name || item.provider_code).filter(Boolean).join(" / ")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-[#f2f6ff] px-2.5 py-1 text-xs font-bold text-[#0b56b7]">
|
||||
{t("results.detail")}
|
||||
</span>
|
||||
</div>
|
||||
<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]">
|
||||
{row.results[tier]}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* PC:后台风格表格 */}
|
||||
<div className="hidden lg:block">
|
||||
<Table className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow className="border-[#eef3fa] hover:bg-transparent">
|
||||
<TableHead className="h-11 w-[30%] bg-[#f8fbff] px-5 text-xs font-bold text-[#59739f]">
|
||||
{t("results.detailTitle")}
|
||||
</TableHead>
|
||||
{RESULTS_TOP_PRIZE_KEYS.map((tier) => (
|
||||
<TableHead
|
||||
key={tier}
|
||||
className="h-11 bg-[#f8fbff] px-3 text-center text-xs font-bold text-[#59739f]"
|
||||
>
|
||||
{t(resultsPrizeLabelKey(tier))}
|
||||
</TableHead>
|
||||
))}
|
||||
<TableHead className="h-11 w-12 bg-[#f8fbff] px-3" aria-hidden />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{listRows.map((row, index) => {
|
||||
const isLatest = page === 1 && index === 0;
|
||||
const href = `/results/${encodeURIComponent(row.draw_no)}`;
|
||||
return (
|
||||
<TableRow
|
||||
key={row.draw_no}
|
||||
className="border-[#eef3fa] hover:bg-[#f8fbff]"
|
||||
>
|
||||
<TableCell className="px-5 py-3 align-middle">
|
||||
<Link href={href} prefetch={false} className="block min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="truncate font-mono text-sm font-bold text-[#0b3f96]">
|
||||
{row.draw_no}
|
||||
</p>
|
||||
{isLatest ? (
|
||||
<span className="shrink-0 rounded-full bg-[#eaf2ff] px-2 py-0.5 text-[10px] font-black text-[#0b56b7]">
|
||||
{t("results.latest")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-0.5 text-[11px] text-slate-500">
|
||||
{formatPlayerInstant(row.draw_time_iso ?? row.draw_time ?? null)}
|
||||
</p>
|
||||
{(row.provider_results?.length ?? 0) > 1 ? (
|
||||
<p className="mt-0.5 truncate text-[11px] font-semibold text-[#0b56b7]">
|
||||
{row.provider_results
|
||||
?.map((item) => item.provider_name || item.provider_code)
|
||||
.filter(Boolean)
|
||||
.join(" / ")}
|
||||
</p>
|
||||
) : null}
|
||||
</Link>
|
||||
</TableCell>
|
||||
{RESULTS_TOP_PRIZE_KEYS.map((tier) => (
|
||||
<TableCell key={tier} className="px-3 py-3 text-center align-middle">
|
||||
<Link
|
||||
href={href}
|
||||
prefetch={false}
|
||||
className="font-mono text-base font-bold tabular-nums text-[#e5002c]"
|
||||
>
|
||||
{row.results[tier]}
|
||||
</Link>
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell className="px-3 py-3 text-right align-middle">
|
||||
<Link
|
||||
href={href}
|
||||
prefetch={false}
|
||||
aria-label={t("results.detail")}
|
||||
className="inline-flex size-8 items-center justify-center rounded-lg text-[#7890b8] transition-colors hover:bg-[#eaf2ff] hover:text-[#0b56b7]"
|
||||
>
|
||||
<ChevronRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<PlayerListPagination
|
||||
page={page}
|
||||
lastPage={lastPage}
|
||||
total={total}
|
||||
loading={loading || loadingMore}
|
||||
onPageChange={(nextPage) => {
|
||||
void fetchList(nextPage, false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="lg:hidden">
|
||||
<div ref={loadMoreRef} className="min-h-1" />
|
||||
{page < lastPage ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-10 flex-1 justify-start rounded-lg border-[#dce7f7] bg-white px-3 text-left text-sm font-semibold text-[#32518d] hover:bg-[#f8fbff]"
|
||||
className="h-10 w-full rounded-xl border-[#dce7f7] bg-white text-sm font-bold text-[#32518d] hover:bg-[#f8fbff]"
|
||||
disabled={loadingMore}
|
||||
onClick={() => void fetchList(page + 1, true)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 size-4 text-[#7890b8]" />
|
||||
{date ||
|
||||
t("results.selectBusinessDate", {
|
||||
defaultValue: "选择日期",
|
||||
})}
|
||||
{loadingMore
|
||||
? t("actions.loading")
|
||||
: t("actions.loadMore")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-auto border-[#dce7f7] p-2 shadow-[0_16px_40px_rgba(15,23,42,0.14)]">
|
||||
<div className="mb-2 grid grid-cols-2 gap-2">
|
||||
<Select
|
||||
value={String(calendarMonth.getMonth())}
|
||||
onValueChange={(value) => {
|
||||
setCalendarMonth((current) => new Date(current.getFullYear(), Number(value), 1));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full border-[#dce7f7] bg-white text-xs font-semibold text-[#32518d]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-64 min-w-24">
|
||||
{MONTH_OPTIONS.map((month) => (
|
||||
<SelectItem key={month.value} value={String(month.value)}>
|
||||
{t(month.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={String(calendarMonth.getFullYear())}
|
||||
onValueChange={(value) => {
|
||||
setCalendarMonth((current) => new Date(Number(value), current.getMonth(), 1));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full border-[#dce7f7] bg-white text-xs font-semibold text-[#32518d]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-64 min-w-24">
|
||||
{quickYears.map((year) => (
|
||||
<SelectItem key={year} value={String(year)}>
|
||||
{year}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Calendar
|
||||
mode="single"
|
||||
month={calendarMonth}
|
||||
onMonthChange={setCalendarMonth}
|
||||
selected={selectedDate}
|
||||
onSelect={(value) => {
|
||||
if (!value) return;
|
||||
setDate(formatBusinessDate(value));
|
||||
setCalendarMonth(value);
|
||||
setDatePickerOpen(false);
|
||||
}}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
{date ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-10 w-10 rounded-lg border-[#dce7f7] bg-white text-[#7890b8] hover:bg-[#f8fbff] hover:text-[#32518d]"
|
||||
aria-label={t("actions.clear")}
|
||||
onClick={() => setDate("")}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-10 shrink-0 rounded-lg bg-[#07459f] px-4 text-white hover:bg-[#063b88]"
|
||||
onClick={() => void fetchList(1, false)}
|
||||
>
|
||||
{t("actions.apply")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-28 rounded-xl" />
|
||||
<Skeleton className="h-28 rounded-xl" />
|
||||
<Skeleton className="h-28 rounded-xl" />
|
||||
</div>
|
||||
) : 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"
|
||||
size="sm"
|
||||
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
|
||||
onClick={() => void fetchList(1, false)}
|
||||
>
|
||||
{t("actions.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : items && items.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-[#dce7f7] bg-[#f8fbff] px-3 py-8 text-center text-sm text-slate-500">
|
||||
{t("results.empty")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{featured ? (
|
||||
<div className="rounded-xl border border-[#e5edf8] bg-white p-3 shadow-[0_10px_28px_rgba(15,23,42,0.06)]">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-[#edf2f9] pb-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-black uppercase tracking-normal text-[#0b56b7]">
|
||||
{t("results.detailTitle")}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-lg font-black text-[#0b3f96]">
|
||||
{featured.draw_no}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-xs text-slate-500">
|
||||
{t("results.drawTime", {
|
||||
time: formatPlayerInstant(
|
||||
featured.draw_time_iso ?? featured.draw_time ?? null,
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
{(featured.provider_results?.length ?? 0) > 1 ? (
|
||||
<p className="mt-1 text-[11px] font-semibold text-[#0b56b7]">
|
||||
{featured.provider_results?.map((row) => row.provider_name || row.provider_code).filter(Boolean).join(" / ")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Link
|
||||
href={`/results/${encodeURIComponent(featured.draw_no)}`}
|
||||
prefetch={false}
|
||||
className="inline-flex h-8 shrink-0 items-center justify-center rounded-full border border-[#dce7f7] bg-white px-3 text-sm font-semibold text-[#0b56b7] transition-colors hover:bg-[#f1f6ff]"
|
||||
>
|
||||
{t("results.openDetail", { defaultValue: "查看详情" })}
|
||||
</Link>
|
||||
</div>
|
||||
<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>
|
||||
) : listRows.length > 0 ? (
|
||||
<p className="py-2 text-center text-xs text-slate-400">
|
||||
{t("results.noMore")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{items?.slice(1).map((row) => (
|
||||
<Link
|
||||
key={row.draw_no}
|
||||
href={`/results/${encodeURIComponent(row.draw_no)}`}
|
||||
prefetch={false}
|
||||
className="block rounded-xl border border-[#e5edf8] bg-white p-3 shadow-[0_8px_24px_rgba(15,23,42,0.05)] transition-colors hover:border-[#b9ccf6]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-mono text-sm font-black text-[#0b3f96]">
|
||||
{row.draw_no}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-slate-500">
|
||||
{formatPlayerInstant(row.draw_time_iso ?? row.draw_time ?? null)}
|
||||
</p>
|
||||
{(row.provider_results?.length ?? 0) > 1 ? (
|
||||
<p className="mt-1 text-[11px] font-semibold text-[#0b56b7]">
|
||||
{row.provider_results?.map((item) => item.provider_name || item.provider_code).filter(Boolean).join(" / ")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-[#f2f6ff] px-2.5 py-1 text-xs font-bold text-[#0b56b7]">
|
||||
{t("results.detail")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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]">
|
||||
{row.results[tier]}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
<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 fetchList(page + 1, true)}
|
||||
>
|
||||
{loadingMore
|
||||
? t("actions.loading")
|
||||
: t("actions.loadMore")}
|
||||
</Button>
|
||||
) : items && items.length > 0 ? (
|
||||
<p className="py-2 text-center text-xs text-slate-400">
|
||||
{t("results.noMore")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
);
|
||||
|
||||
@@ -5,14 +5,18 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getJackpotSummary } from "@/api/jackpot";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type JackpotResultsStripProps = {
|
||||
currencyCode?: string;
|
||||
/** PC 工具栏内嵌时用更扁的一行布局 */
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
/** 开奖模块顶部:Jackpot 当前池(公开接口) */
|
||||
export function JackpotResultsStrip({
|
||||
currencyCode = "NPR",
|
||||
compact = false,
|
||||
}: JackpotResultsStripProps) {
|
||||
const { t } = useTranslation("player");
|
||||
const [minor, setMinor] = useState<number | null>(null);
|
||||
@@ -47,18 +51,46 @@ export function JackpotResultsStrip({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-200 bg-gradient-to-r from-amber-100 via-white to-[#f8fbff] px-3 py-3 shadow-[0_8px_20px_rgba(180,83,9,0.08)]">
|
||||
<p className="text-[11px] font-black uppercase tracking-normal text-amber-700">
|
||||
{t("results.jackpotLabel")}
|
||||
</p>
|
||||
<p className="font-mono text-lg font-black tabular-nums text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(minor, currencyCode.toUpperCase())}
|
||||
</p>
|
||||
{gap !== null ? (
|
||||
<p className="mt-1 text-xs font-semibold text-amber-800">
|
||||
{t("results.jackpotGap", { count: gap })}
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border border-amber-200 bg-gradient-to-r from-amber-100 via-white to-[#f8fbff] shadow-[0_8px_20px_rgba(180,83,9,0.08)]",
|
||||
compact
|
||||
? "px-3 py-2 lg:rounded-lg lg:border-amber-100 lg:shadow-none"
|
||||
: "px-3 py-3",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
compact && "flex flex-wrap items-center gap-x-3 gap-y-1",
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[11px] font-black uppercase tracking-normal text-amber-700",
|
||||
compact && "lg:text-[10px]",
|
||||
)}
|
||||
>
|
||||
{t("results.jackpotLabel")}
|
||||
</p>
|
||||
) : null}
|
||||
<p
|
||||
className={cn(
|
||||
"font-mono font-black tabular-nums text-[#0b3f96]",
|
||||
compact ? "text-base lg:text-sm" : "text-lg",
|
||||
)}
|
||||
>
|
||||
{formatMinorAsCurrency(minor, currencyCode.toUpperCase())}
|
||||
</p>
|
||||
{gap !== null ? (
|
||||
<p
|
||||
className={cn(
|
||||
"font-semibold text-amber-800",
|
||||
compact ? "text-[11px] lg:text-[10px]" : "mt-1 text-xs",
|
||||
)}
|
||||
>
|
||||
{t("results.jackpotGap", { count: gap })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export function TwentyThreeResultsGrid({
|
||||
|
||||
const smallCellTone = (raw: string, tone: "red" | "blue") =>
|
||||
cn(
|
||||
"grid min-h-[3.875rem] grid-rows-[auto_1fr] rounded-lg border bg-white px-1.5 py-2 text-center shadow-[0_6px_16px_rgba(15,23,42,0.04)]",
|
||||
"grid min-h-[3.25rem] grid-rows-[auto_1fr] rounded-lg border bg-white px-1 py-1.5 text-center shadow-[0_6px_16px_rgba(15,23,42,0.04)] lg:min-h-[3.5rem]",
|
||||
tone === "red" ? "border-red-100 text-[#e5002c]" : "border-blue-100 text-[#0b56b7]",
|
||||
isHit(raw) && "border-amber-400 bg-amber-50 text-amber-700 shadow-[0_8px_18px_rgba(245,158,11,0.16)]",
|
||||
);
|
||||
@@ -72,58 +72,60 @@ export function TwentyThreeResultsGrid({
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="flex flex-col gap-3 lg:gap-4">
|
||||
<div className="grid grid-cols-3 gap-2 lg:gap-3">
|
||||
{prizeCards.map((card) => (
|
||||
<div
|
||||
key={card.key}
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-xl border bg-gradient-to-b px-2 py-4 text-center shadow-[0_10px_24px_rgba(15,23,42,0.06)]",
|
||||
"relative overflow-hidden rounded-xl border bg-gradient-to-b px-2 py-4 text-center shadow-[0_10px_24px_rgba(15,23,42,0.06)] lg:px-3 lg:py-5",
|
||||
card.border,
|
||||
card.wash,
|
||||
isHit(card.value) && "ring-2 ring-amber-300",
|
||||
)}
|
||||
>
|
||||
<div className={cn("mx-auto flex size-9 items-center justify-center rounded-full text-white", card.tone === "red" ? "bg-[#e5002c]" : card.tone === "blue" ? "bg-[#0b56b7]" : "bg-[#0a8f3e]")}>
|
||||
<Trophy className="size-5" />
|
||||
<div className={cn("mx-auto flex size-9 items-center justify-center rounded-full text-white lg:size-10", card.tone === "red" ? "bg-[#e5002c]" : card.tone === "blue" ? "bg-[#0b56b7]" : "bg-[#0a8f3e]")}>
|
||||
<Trophy className="size-5 lg:size-5" />
|
||||
</div>
|
||||
<p className={cn("mt-3 text-xs font-black", card.text)}>{card.label}</p>
|
||||
<p className={cn("mt-2 font-mono text-3xl font-black tabular-nums", card.text)}>{card.value}</p>
|
||||
<p className={cn("mt-3 text-xs font-black lg:text-sm", card.text)}>{card.label}</p>
|
||||
<p className={cn("mt-2 font-mono text-3xl font-black tabular-nums lg:text-4xl", card.text)}>{card.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-red-100 bg-white p-3 shadow-[0_8px_22px_rgba(15,23,42,0.05)]">
|
||||
<p className="mb-3 flex items-center gap-2 text-sm font-black text-[#e5002c]">
|
||||
<Trophy className="size-4" />
|
||||
{t("results.grid.starter")}
|
||||
</p>
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div key={`s-${i}`} className={smallCellTone(starters[i] ?? "—", "red")}>
|
||||
<span className="text-[11px] font-black">{i + 1}</span>
|
||||
<span className="self-center font-mono text-xs font-semibold tabular-nums text-slate-700">
|
||||
{starters[i] ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="grid gap-3 lg:grid-cols-1 xl:grid-cols-2 lg:gap-4">
|
||||
<div className="rounded-xl border border-red-100 bg-white p-3 shadow-[0_8px_22px_rgba(15,23,42,0.05)] lg:p-4">
|
||||
<p className="mb-3 flex items-center gap-2 text-sm font-black text-[#e5002c] lg:text-base">
|
||||
<Trophy className="size-4" />
|
||||
{t("results.grid.starter")}
|
||||
</p>
|
||||
<div className="grid grid-cols-5 gap-1.5 lg:gap-2">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div key={`s-${i}`} className={smallCellTone(starters[i] ?? "—", "red")}>
|
||||
<span className="text-[11px] font-black">{i + 1}</span>
|
||||
<span className="self-center font-mono text-[11px] font-semibold tabular-nums text-slate-700 sm:text-xs lg:text-sm">
|
||||
{starters[i] ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-blue-100 bg-white p-3 shadow-[0_8px_22px_rgba(15,23,42,0.05)]">
|
||||
<p className="mb-3 flex items-center gap-2 text-sm font-black text-[#0b56b7]">
|
||||
<Trophy className="size-4" />
|
||||
{t("results.grid.consolation")}
|
||||
</p>
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div key={`c-${i}`} className={smallCellTone(consos[i] ?? "—", "blue")}>
|
||||
<span className="text-[11px] font-black">{i + 1}</span>
|
||||
<span className="self-center font-mono text-xs font-semibold tabular-nums text-slate-700">
|
||||
{consos[i] ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="rounded-xl border border-blue-100 bg-white p-3 shadow-[0_8px_22px_rgba(15,23,42,0.05)] lg:p-4">
|
||||
<p className="mb-3 flex items-center gap-2 text-sm font-black text-[#0b56b7] lg:text-base">
|
||||
<Trophy className="size-4" />
|
||||
{t("results.grid.consolation")}
|
||||
</p>
|
||||
<div className="grid grid-cols-5 gap-1.5 lg:gap-2">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div key={`c-${i}`} className={smallCellTone(consos[i] ?? "—", "blue")}>
|
||||
<span className="text-[11px] font-black">{i + 1}</span>
|
||||
<span className="self-center font-mono text-[11px] font-semibold tabular-nums text-slate-700 sm:text-xs lg:text-sm">
|
||||
{consos[i] ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import { useMemo, type Ref } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { PlayerListPagination } from "@/components/layout/player-list-pagination";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { formatLotteryInstantInTimeZone, formatPlayerInstant } from "@/lib/player-datetime";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import type { WalletLogItem, WalletLogsData } from "@/types/api/wallet-logs";
|
||||
import { getWalletLogsLastPage } from "@/types/api/wallet-logs";
|
||||
|
||||
/** 与 §4.9 筛选一致;接口 `type` 查询参数 */
|
||||
export const WALLET_FLOW_FILTERS: { value: string; labelKey: string }[] = [
|
||||
@@ -101,6 +103,8 @@ type WalletLogsBlockProps = {
|
||||
hasMore?: boolean;
|
||||
onLoadMore?: () => void;
|
||||
loadMoreRef?: Ref<HTMLDivElement>;
|
||||
/** PC:翻页(替换无限滚动) */
|
||||
onPageChange?: (page: number) => void;
|
||||
filter: string;
|
||||
onFilterChange: (value: string) => void;
|
||||
currency: string;
|
||||
@@ -119,6 +123,7 @@ export function WalletLogsBlock({
|
||||
hasMore = false,
|
||||
onLoadMore,
|
||||
loadMoreRef,
|
||||
onPageChange,
|
||||
filter,
|
||||
onFilterChange,
|
||||
currency,
|
||||
@@ -142,29 +147,32 @@ export function WalletLogsBlock({
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<section className="space-y-3 lg:space-y-4">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between lg:gap-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-black text-[#0b3f96]">{resolvedTitle}</h2>
|
||||
<h2 className="text-sm font-black text-[#0b3f96] lg:text-base">{resolvedTitle}</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{filters.map((f) => (
|
||||
<Button
|
||||
key={f.value || "all"}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={filter === f.value ? "default" : "outline"}
|
||||
className={
|
||||
filter === f.value
|
||||
? "h-8 rounded-full bg-[#07459f] px-3 text-xs font-bold text-white hover:bg-[#063b88]"
|
||||
: "h-8 rounded-full border-[#dce7f7] bg-white px-3 text-xs font-bold text-[#32518d] hover:bg-[#f8fbff]"
|
||||
}
|
||||
disabled={logsLoading && filter === f.value}
|
||||
onClick={() => onFilterChange(f.value)}
|
||||
>
|
||||
{f.label}
|
||||
</Button>
|
||||
))}
|
||||
{/* 移动端:小胶囊;PC:大厅 4D/3D 同款分段控件 */}
|
||||
<div className="flex flex-wrap gap-1.5 lg:inline-flex lg:max-w-full lg:flex-nowrap lg:items-center lg:gap-1 lg:overflow-x-auto lg:rounded-lg lg:bg-[#f3f6fb] lg:p-1">
|
||||
{filters.map((f) => {
|
||||
const active = filter === f.value;
|
||||
return (
|
||||
<button
|
||||
key={f.value || "all"}
|
||||
type="button"
|
||||
disabled={logsLoading && active}
|
||||
onClick={() => onFilterChange(f.value)}
|
||||
aria-pressed={active}
|
||||
className={
|
||||
active
|
||||
? "inline-flex h-8 items-center justify-center rounded-full bg-[#07459f] px-3 text-xs font-bold text-white transition-colors hover:bg-[#063b88] disabled:opacity-60 lg:h-auto lg:min-w-[4.5rem] lg:rounded-lg lg:bg-[#2d63e2] lg:px-3.5 lg:py-2 lg:text-sm lg:shadow-[0_4px_12px_rgba(45,99,226,0.28)] lg:hover:bg-[#2556c7]"
|
||||
: "inline-flex h-8 items-center justify-center rounded-full border border-[#dce7f7] bg-white px-3 text-xs font-bold text-[#32518d] transition-colors hover:bg-[#f8fbff] lg:h-auto lg:min-w-[4.5rem] lg:rounded-lg lg:border-0 lg:bg-transparent lg:px-3.5 lg:py-2 lg:text-sm lg:text-[#5b7fbf] lg:hover:bg-[#f3f7ff] lg:hover:text-[#2d63e2]"
|
||||
}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -174,30 +182,68 @@ export function WalletLogsBlock({
|
||||
|
||||
{logs ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{/* PC 分页底栏已含总数,避免重复 */}
|
||||
<p className="text-xs text-muted-foreground lg:hidden">
|
||||
{t("wallet.totalRecords", { total: logs.total })}
|
||||
</p>
|
||||
<ul className={logsLoading ? "space-y-2 opacity-60" : "space-y-2"}>
|
||||
<div className={logsLoading ? "opacity-60" : undefined}>
|
||||
{logs.items.length === 0 ? (
|
||||
<li className="rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground">
|
||||
<div className="rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground lg:py-12">
|
||||
{t(creditMode ? "wallet.emptyCreditLogs" : "wallet.emptyLogs", {
|
||||
defaultValue: creditMode ? "暂无信用流水" : "暂无流水",
|
||||
})}
|
||||
</li>
|
||||
</div>
|
||||
) : (
|
||||
logs.items.map((row) => (
|
||||
<LogRow
|
||||
key={row.log_id}
|
||||
item={row}
|
||||
currency={currency}
|
||||
creditMode={creditMode}
|
||||
settlementTimeZone={settlementTimeZone}
|
||||
/>
|
||||
))
|
||||
<>
|
||||
<div className="lg:overflow-hidden lg:rounded-xl lg:border lg:border-[#e5edf8] lg:bg-white lg:shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
||||
<div className="hidden lg:grid lg:grid-cols-[minmax(10rem,1.2fr)_minmax(9rem,1fr)_minmax(8rem,0.9fr)_minmax(7rem,0.8fr)_minmax(7rem,0.8fr)_minmax(6rem,0.7fr)] lg:items-center lg:gap-3 lg:border-b lg:border-[#e9eff8] lg:bg-[#f8fbff] lg:px-5 lg:py-3">
|
||||
<p className="text-xs font-bold text-[#59739f]">
|
||||
{t("wallet.logType", { defaultValue: "类型" })}
|
||||
</p>
|
||||
<p className="text-xs font-bold text-[#59739f]">
|
||||
{t("wallet.logTime", { defaultValue: "时间" })}
|
||||
</p>
|
||||
<p className="text-xs font-bold text-[#59739f]">
|
||||
{t("wallet.logRef", { defaultValue: "关联单号" })}
|
||||
</p>
|
||||
<p className="text-right text-xs font-bold text-[#59739f]">
|
||||
{t("wallet.logAmount", { defaultValue: "金额" })}
|
||||
</p>
|
||||
<p className="text-right text-xs font-bold text-[#59739f]">
|
||||
{creditMode
|
||||
? t("wallet.creditAvailableAfter")
|
||||
: t("wallet.balanceAfter")}
|
||||
</p>
|
||||
<p className="text-right text-xs font-bold text-[#59739f]">
|
||||
{t("orders.status")}
|
||||
</p>
|
||||
</div>
|
||||
<ul className="space-y-2 lg:space-y-0">
|
||||
{logs.items.map((row) => (
|
||||
<LogRow
|
||||
key={row.log_id}
|
||||
item={row}
|
||||
currency={currency}
|
||||
creditMode={creditMode}
|
||||
settlementTimeZone={settlementTimeZone}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
{onPageChange ? (
|
||||
<PlayerListPagination
|
||||
page={logs.page}
|
||||
lastPage={getWalletLogsLastPage(logs)}
|
||||
total={logs.total}
|
||||
loading={logsLoading}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
{logs.items.length > 0 ? (
|
||||
<>
|
||||
<div className="lg:hidden">
|
||||
<div ref={loadMoreRef} className="min-h-1" />
|
||||
{hasMore ? (
|
||||
<Button
|
||||
@@ -216,7 +262,7 @@ export function WalletLogsBlock({
|
||||
{t("wallet.noMoreLogs")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
@@ -258,36 +304,52 @@ export function LogRow({
|
||||
? "border-slate-200 bg-slate-50 text-slate-600"
|
||||
: "border-blue-200 bg-blue-50 text-blue-700";
|
||||
|
||||
const timeLabel = creditMode && settlementTimeZone
|
||||
? formatLotteryInstantInTimeZone(item.created_at, settlementTimeZone)
|
||||
: formatPlayerInstant(item.created_at);
|
||||
|
||||
const amountLabel = isCreditSettlementConfirm
|
||||
? t("wallet.creditReleasedAmount", {
|
||||
defaultValue: "释额 {{amount}}",
|
||||
amount: formatMinorAsCurrency(item.amount_abs, ccy),
|
||||
})
|
||||
: `${isIn ? "+" : "−"}${formatMinorAsCurrency(item.amount_abs, ccy)}`;
|
||||
|
||||
const balanceLabel = item.balance_after != null
|
||||
? formatMinorAsCurrency(item.balance_after, ccy)
|
||||
: creditMode && item.affects_available_credit === false
|
||||
? t("wallet.noCreditChange", { defaultValue: "不改变可用信用" })
|
||||
: "—";
|
||||
|
||||
const balanceCaption = creditMode
|
||||
? item.affects_available_credit === false
|
||||
? t("wallet.creditBillOnly", { defaultValue: "账期记录" })
|
||||
: t("wallet.creditAvailableAfter")
|
||||
: t("wallet.balanceAfter");
|
||||
|
||||
return (
|
||||
<li className="rounded-2xl border border-[#e1eaf6] bg-white px-3 py-3 text-sm shadow-[0_10px_28px_rgba(15,23,42,0.06)]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<li className="rounded-2xl border border-[#e1eaf6] bg-white px-3 py-3 text-sm shadow-[0_10px_28px_rgba(15,23,42,0.06)] lg:grid lg:grid-cols-[minmax(10rem,1.2fr)_minmax(9rem,1fr)_minmax(8rem,0.9fr)_minmax(7rem,0.8fr)_minmax(7rem,0.8fr)_minmax(6rem,0.7fr)] lg:items-center lg:gap-3 lg:rounded-none lg:border-x-0 lg:border-t-0 lg:border-b-[#edf2f8] lg:px-5 lg:py-4 lg:shadow-none lg:last:border-b-0 lg:hover:bg-[#fbfdff]">
|
||||
<div className="flex items-start justify-between gap-3 lg:contents">
|
||||
<div className="min-w-0 flex-1 lg:min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`size-2.5 rounded-full ${dotTone}`} aria-hidden />
|
||||
<p className="truncate text-base font-black leading-tight text-[#101a33]">
|
||||
<span className={`size-2.5 shrink-0 rounded-full ${dotTone}`} aria-hidden />
|
||||
<p className="truncate text-base font-black leading-tight text-[#101a33] lg:text-sm">
|
||||
{logTypeLabel(item.type, t, creditMode, item.biz_type)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs font-medium text-slate-500">
|
||||
{creditMode && settlementTimeZone
|
||||
? formatLotteryInstantInTimeZone(item.created_at, settlementTimeZone)
|
||||
: formatPlayerInstant(item.created_at)}
|
||||
<p className="mt-1.5 text-xs font-medium text-slate-500 lg:hidden">
|
||||
{timeLabel}
|
||||
</p>
|
||||
{item.ref_id ? (
|
||||
<p className="mt-1 truncate font-mono text-[11px] text-slate-400">
|
||||
<p className="mt-1 truncate font-mono text-[11px] text-slate-400 lg:hidden">
|
||||
{item.ref_id}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end gap-2 text-right">
|
||||
<div className="flex shrink-0 flex-col items-end gap-2 text-right lg:hidden">
|
||||
<p className={`text-lg font-black tabular-nums ${amountTone}`}>
|
||||
{isCreditSettlementConfirm
|
||||
? t("wallet.creditReleasedAmount", {
|
||||
defaultValue: "释额 {{amount}}",
|
||||
amount: formatMinorAsCurrency(item.amount_abs, ccy),
|
||||
})
|
||||
: `${isIn ? "+" : "−"}${formatMinorAsCurrency(item.amount_abs, ccy)}`}
|
||||
{amountLabel}
|
||||
</p>
|
||||
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-black ${statusTone}`}>
|
||||
{txnStatusLabel(item.status, t)}
|
||||
@@ -295,20 +357,28 @@ export function LogRow({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between rounded-xl bg-[#f8fbff] px-3 py-2 text-xs">
|
||||
<p className="hidden text-sm text-slate-600 lg:block">{timeLabel}</p>
|
||||
<p className="hidden truncate font-mono text-xs text-slate-400 lg:block">
|
||||
{item.ref_id || "—"}
|
||||
</p>
|
||||
<p className={`hidden text-right text-sm font-black tabular-nums lg:block ${amountTone}`}>
|
||||
{amountLabel}
|
||||
</p>
|
||||
<p className="hidden text-right font-mono text-sm font-black tabular-nums text-[#32518d] lg:block">
|
||||
{balanceLabel}
|
||||
</p>
|
||||
<div className="hidden justify-end lg:flex">
|
||||
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-black ${statusTone}`}>
|
||||
{txnStatusLabel(item.status, t)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between rounded-xl bg-[#f8fbff] px-3 py-2 text-xs lg:hidden">
|
||||
<span className="font-semibold text-slate-500">
|
||||
{creditMode
|
||||
? item.affects_available_credit === false
|
||||
? t("wallet.creditBillOnly", { defaultValue: "账期记录" })
|
||||
: t("wallet.creditAvailableAfter")
|
||||
: t("wallet.balanceAfter")}
|
||||
{balanceCaption}
|
||||
</span>
|
||||
<span className="font-mono font-black tabular-nums text-[#32518d]">
|
||||
{item.balance_after != null
|
||||
? formatMinorAsCurrency(item.balance_after, ccy)
|
||||
: creditMode && item.affects_available_credit === false
|
||||
? t("wallet.noCreditChange", { defaultValue: "不改变可用信用" })
|
||||
: "—"}
|
||||
{balanceLabel}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { PlayerMoneyDisplay } from "@/components/player-money-display";
|
||||
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
|
||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { formatLotteryInstantInTimeZone, formatPlayerInstant } from "@/lib/player-datetime";
|
||||
@@ -102,22 +103,22 @@ function CreditSettlementBillsBlock({
|
||||
const netDirection = netPending >= 0 ? "receivable" : "payable";
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-[#dce7f7] bg-white px-3 py-3 shadow-[0_10px_28px_rgba(15,23,42,0.06)]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-black text-[#0b3f96]">
|
||||
<section className="flex h-full flex-col rounded-2xl border border-[#dce7f7] bg-white px-3 py-3 shadow-[0_10px_28px_rgba(15,23,42,0.06)] lg:px-4 lg:py-4">
|
||||
<div className="flex shrink-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-black text-[#0b3f96] lg:text-base">
|
||||
{t("wallet.settlementTitle", { defaultValue: "我的账期账单" })}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
<p className="mt-1 text-xs text-slate-500 lg:line-clamp-2">
|
||||
{t("wallet.settlementHint", { defaultValue: "只显示未结清账单,中奖超出授信的部分在这里结算。" })}
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-[#f1f6ff] px-2.5 py-1 text-xs font-black text-[#32518d]">
|
||||
<span className="shrink-0 rounded-full bg-[#f1f6ff] px-2.5 py-1 text-xs font-black text-[#32518d]">
|
||||
{t("wallet.settlementPendingCount", { defaultValue: "{{count}}笔", count: pendingCount })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<div className="mt-3 grid shrink-0 grid-cols-2 gap-2">
|
||||
<div className="rounded-xl bg-emerald-50 px-3 py-2">
|
||||
<p className="text-xs font-bold text-emerald-700">
|
||||
{t("wallet.pendingReceivable", { defaultValue: "待收" })}
|
||||
@@ -134,27 +135,26 @@ function CreditSettlementBillsBlock({
|
||||
{formatMinorAsCurrency(summary?.pending_payable ?? 0, currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 rounded-xl bg-[#f8fbff] px-3 py-2 text-xs">
|
||||
<span className="font-semibold text-slate-500">
|
||||
{netDirection === "receivable"
|
||||
? t("wallet.netReceivable", { defaultValue: "净待收" })
|
||||
: t("wallet.netPayable", { defaultValue: "净待付" })}
|
||||
</span>
|
||||
<span className={netDirection === "receivable" ? "ml-2 font-mono font-black text-emerald-700" : "ml-2 font-mono font-black text-red-700"}>
|
||||
{formatMinorAsCurrency(Math.abs(netPending), currency)}
|
||||
</span>
|
||||
<div className="col-span-2 rounded-xl bg-[#f8fbff] px-3 py-2 text-xs">
|
||||
<span className="font-semibold text-slate-500">
|
||||
{netDirection === "receivable"
|
||||
? t("wallet.netReceivable", { defaultValue: "净待收" })
|
||||
: t("wallet.netPayable", { defaultValue: "净待付" })}
|
||||
</span>
|
||||
<span className={netDirection === "receivable" ? "ml-2 font-mono font-black text-emerald-700" : "ml-2 font-mono font-black text-red-700"}>
|
||||
{formatMinorAsCurrency(Math.abs(netPending), currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data === null ? (
|
||||
<Skeleton className="mt-3 h-20 w-full rounded-xl" />
|
||||
<Skeleton className="mt-3 min-h-20 w-full flex-1 rounded-xl" />
|
||||
) : data.items.length === 0 ? (
|
||||
<p className="mt-3 rounded-xl border border-dashed border-[#dce7f7] py-5 text-center text-sm text-slate-500">
|
||||
<p className="mt-3 flex min-h-20 flex-1 items-center justify-center rounded-xl border border-dashed border-[#dce7f7] px-3 text-center text-sm text-slate-500">
|
||||
{t("wallet.noSettlementBills", { defaultValue: "暂无待结算账单" })}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-2">
|
||||
<ul className="mt-3 min-h-0 max-h-[16rem] flex-1 space-y-2 overflow-y-auto lg:max-h-none">
|
||||
{data.items.map((item) => {
|
||||
const receivable = item.direction === "receivable";
|
||||
return (
|
||||
@@ -218,6 +218,7 @@ export function WalletScreen() {
|
||||
const fundingModeView = resolveFundingModeView(balance, profile);
|
||||
const fundingModeKnown = fundingModeView !== "unknown";
|
||||
const isCreditPlayer = fundingModeView === "credit";
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
useEffect(() => {
|
||||
if (actionDeepLinkHandledRef.current || loading) return;
|
||||
@@ -414,7 +415,22 @@ export function WalletScreen() {
|
||||
});
|
||||
}, [hasMore, loadLogs, loadingMore, logs, t]);
|
||||
|
||||
const handleLogsPageChange = useCallback((nextPage: number) => {
|
||||
if (!logs || nextPage === logs.page || logsLoading) return;
|
||||
setError(null);
|
||||
setLogsLoading(true);
|
||||
void loadLogs(nextPage, false)
|
||||
.catch((e) => {
|
||||
setError(formatWalletClientError(e, t));
|
||||
})
|
||||
.finally(() => {
|
||||
setLogsLoading(false);
|
||||
});
|
||||
}, [loadLogs, logs, logsLoading, t]);
|
||||
|
||||
useEffect(() => {
|
||||
// 仅移动端无限滚动;PC 用分页
|
||||
if (!isMobile) return;
|
||||
const target = loadMoreRef.current;
|
||||
if (!target || loading || logsLoading || loadingMore || !hasMore) return;
|
||||
|
||||
@@ -429,13 +445,13 @@ export function WalletScreen() {
|
||||
|
||||
observer.observe(target);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, loadMore, loading, loadingMore, logsLoading]);
|
||||
}, [hasMore, isMobile, loadMore, loading, loadingMore, logsLoading]);
|
||||
|
||||
return (
|
||||
<PlayerPanel title={panelTitle}>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 lg:space-y-4">
|
||||
{error ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700 lg:px-5 lg:py-4">
|
||||
<p>{error}</p>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -447,98 +463,152 @@ export function WalletScreen() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="relative overflow-hidden rounded-xl bg-[#e5002c] px-3 py-4 text-white shadow-[0_10px_28px_rgba(229,0,44,0.25)]">
|
||||
<Image
|
||||
src="/entry/image5.png"
|
||||
alt=""
|
||||
fill
|
||||
className="pointer-events-none object-cover object-center"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="relative flex items-center gap-3">
|
||||
<div className="flex size-14 shrink-0 items-center justify-center rounded-full bg-white text-[#d81435] shadow-sm">
|
||||
<Wallet className="size-7" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-white/90">
|
||||
{fundingModeKnown && isCreditPlayer
|
||||
? t("wallet.creditAvailable", { defaultValue: "可用信用" })
|
||||
: fundingModeKnown
|
||||
? t("wallet.balance")
|
||||
: t("wallet.loadingBalance", { defaultValue: "正在加载" })}
|
||||
</p>
|
||||
{loading || !fundingModeKnown ? (
|
||||
<Skeleton className="mt-2 h-8 w-44 rounded-md bg-white/25" />
|
||||
) : (
|
||||
<PlayerMoneyDisplay
|
||||
amountMinor={displayMinor}
|
||||
currency={currency}
|
||||
className="mt-1 text-white"
|
||||
/>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-white/75">
|
||||
{!fundingModeKnown
|
||||
? t("wallet.loadingAccountMode", { defaultValue: "正在确认资金模式…" })
|
||||
: isCreditPlayer
|
||||
? t("wallet.creditSummary", {
|
||||
defaultValue: "授信 {{limit}} · 已用 {{used}}",
|
||||
limit: formatMinorAsCurrency(balance?.credit_limit ?? 0, currency),
|
||||
used: formatMinorAsCurrency(balance?.used_credit ?? 0, currency),
|
||||
})
|
||||
: t("wallet.available", {
|
||||
amount: formatMinorAsCurrency(balance?.available_balance ?? 0, currency),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{fundingModeKnown && isCreditPlayer ? (
|
||||
<CreditSettlementBillsBlock
|
||||
data={settlementBills}
|
||||
currency={currency}
|
||||
settlementTimeZone={settlementTimeZone}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{fundingModeKnown && !isCreditPlayer ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TransferInDialog
|
||||
idPrefix="wallet-"
|
||||
currency={currency}
|
||||
lotteryMinor={Number(balance?.balance ?? 0)}
|
||||
mainMinor={
|
||||
balance?.main_balance === null || balance?.main_balance === undefined
|
||||
? null
|
||||
: Number(balance.main_balance)
|
||||
}
|
||||
onSuccess={refreshAll}
|
||||
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-"
|
||||
currency={currency}
|
||||
availableMinor={Number(balance?.available_balance ?? 0)}
|
||||
onSuccess={refreshAll}
|
||||
triggerVariant="hall"
|
||||
triggerLabel={t("wallet.transferOut", { defaultValue: "Transfer Out" })}
|
||||
triggerClassName="h-14 rounded-2xl text-base font-black"
|
||||
open={transferOutOpen}
|
||||
onOpenChange={setTransferOutOpen}
|
||||
/>
|
||||
<div className="lg:grid lg:grid-cols-[minmax(0,1.2fr)_minmax(16rem,0.8fr)] lg:items-stretch lg:gap-4">
|
||||
<section className="relative overflow-hidden rounded-xl bg-[#e5002c] px-3 py-4 text-white shadow-[0_10px_28px_rgba(229,0,44,0.25)] lg:px-6 lg:py-6">
|
||||
<Image
|
||||
src="/entry/image5.png"
|
||||
alt=""
|
||||
fill
|
||||
className="pointer-events-none object-cover object-center"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="relative flex items-center gap-3 lg:gap-5">
|
||||
<div className="flex size-14 shrink-0 items-center justify-center rounded-full bg-white text-[#d81435] shadow-sm lg:size-16">
|
||||
<Wallet className="size-7 lg:size-8" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-white/90 lg:text-base">
|
||||
{t("wallet.balance")}
|
||||
</p>
|
||||
{loading ? (
|
||||
<Skeleton className="mt-2 h-8 w-44 rounded-md bg-white/25 lg:h-10 lg:w-56" />
|
||||
) : (
|
||||
<PlayerMoneyDisplay
|
||||
amountMinor={displayMinor}
|
||||
currency={currency}
|
||||
className="mt-1 text-white lg:text-[1.75rem]"
|
||||
/>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-white/75 lg:text-sm">
|
||||
{t("wallet.available", {
|
||||
amount: formatMinorAsCurrency(balance?.available_balance ?? 0, currency),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 lg:mt-0 lg:grid-cols-1 lg:gap-3">
|
||||
<TransferInDialog
|
||||
idPrefix="wallet-"
|
||||
currency={currency}
|
||||
lotteryMinor={Number(balance?.balance ?? 0)}
|
||||
mainMinor={
|
||||
balance?.main_balance === null || balance?.main_balance === undefined
|
||||
? null
|
||||
: Number(balance.main_balance)
|
||||
}
|
||||
onSuccess={refreshAll}
|
||||
triggerVariant="hall"
|
||||
triggerLabel={t("wallet.transferIn", { defaultValue: "Transfer In" })}
|
||||
triggerClassName="h-14 rounded-2xl text-base font-black lg:h-full lg:min-h-[4.5rem]"
|
||||
open={transferInOpen}
|
||||
onOpenChange={setTransferInOpen}
|
||||
/>
|
||||
<TransferOutDialog
|
||||
idPrefix="wallet-"
|
||||
currency={currency}
|
||||
availableMinor={Number(balance?.available_balance ?? 0)}
|
||||
onSuccess={refreshAll}
|
||||
triggerVariant="hall"
|
||||
triggerLabel={t("wallet.transferOut", { defaultValue: "Transfer Out" })}
|
||||
triggerClassName="h-14 rounded-2xl text-base font-black lg:h-full lg:min-h-[4.5rem]"
|
||||
open={transferOutOpen}
|
||||
onOpenChange={setTransferOutOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
) : (
|
||||
<div className="space-y-3 lg:grid lg:grid-cols-[minmax(0,1.1fr)_minmax(20rem,0.9fr)] lg:items-stretch lg:gap-4 lg:space-y-0">
|
||||
<section className="relative flex h-full flex-col overflow-hidden rounded-xl bg-[#e5002c] px-3 py-4 text-white shadow-[0_10px_28px_rgba(229,0,44,0.25)] lg:px-6 lg:py-5">
|
||||
<Image
|
||||
src="/entry/image5.png"
|
||||
alt=""
|
||||
fill
|
||||
className="pointer-events-none object-cover object-center"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="relative flex flex-1 items-center gap-3 lg:gap-5">
|
||||
<div className="flex size-14 shrink-0 items-center justify-center rounded-full bg-white text-[#d81435] shadow-sm lg:size-16">
|
||||
<Wallet className="size-7 lg:size-8" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-white/90 lg:text-base">
|
||||
{fundingModeKnown
|
||||
? t("wallet.creditAvailable", { defaultValue: "可用信用" })
|
||||
: t("wallet.loadingBalance", { defaultValue: "正在加载" })}
|
||||
</p>
|
||||
{loading || !fundingModeKnown ? (
|
||||
<Skeleton className="mt-2 h-8 w-44 rounded-md bg-white/25 lg:h-10 lg:w-56" />
|
||||
) : (
|
||||
<PlayerMoneyDisplay
|
||||
amountMinor={displayMinor}
|
||||
currency={currency}
|
||||
className="mt-1 text-white lg:text-[1.75rem]"
|
||||
/>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-white/75 lg:text-sm">
|
||||
{!fundingModeKnown
|
||||
? t("wallet.loadingAccountMode", { defaultValue: "正在确认资金模式…" })
|
||||
: t("wallet.creditSummary", {
|
||||
defaultValue: "授信 {{limit}} · 已用 {{used}}",
|
||||
limit: formatMinorAsCurrency(balance?.credit_limit ?? 0, currency),
|
||||
used: formatMinorAsCurrency(balance?.used_credit ?? 0, currency),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fundingModeKnown ? (
|
||||
<div className="relative mt-4 grid shrink-0 grid-cols-2 gap-2 lg:mt-auto lg:gap-3">
|
||||
<div className="rounded-xl bg-white/15 px-3 py-2.5 backdrop-blur-[2px]">
|
||||
<p className="text-[11px] font-bold uppercase tracking-wide text-white/75">
|
||||
{t("wallet.creditLimit", { defaultValue: "授信额度" })}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-sm font-black tabular-nums text-white">
|
||||
{formatMinorAsCurrency(balance?.credit_limit ?? 0, currency)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white/15 px-3 py-2.5 backdrop-blur-[2px]">
|
||||
<p className="text-[11px] font-bold uppercase tracking-wide text-white/75">
|
||||
{t("wallet.usedCredit", { defaultValue: "已用信用" })}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-sm font-black tabular-nums text-white">
|
||||
{formatMinorAsCurrency(balance?.used_credit ?? 0, currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{fundingModeKnown ? (
|
||||
<CreditSettlementBillsBlock
|
||||
data={settlementBills}
|
||||
currency={currency}
|
||||
settlementTimeZone={settlementTimeZone}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="h-full min-h-40 w-full rounded-2xl" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fundingModeKnown && !isCreditPlayer && (logs?.pending_reconcile?.length ?? 0) > 0 ? (
|
||||
<section
|
||||
id="wallet-pending"
|
||||
className="scroll-mt-3 rounded-2xl border border-amber-200 bg-amber-50 px-3 py-3 text-sm text-amber-900"
|
||||
className="scroll-mt-3 rounded-2xl border border-amber-200 bg-amber-50 px-3 py-3 text-sm text-amber-900 lg:px-5 lg:py-4"
|
||||
>
|
||||
<h2 className="text-sm font-black text-amber-800">
|
||||
<h2 className="text-sm font-black text-amber-800 lg:text-base">
|
||||
{t("wallet.pendingSectionTitle", { defaultValue: "待对账划转" })}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-amber-700">
|
||||
@@ -546,11 +616,11 @@ export function WalletScreen() {
|
||||
defaultValue: "以下划转仍在与主站对账,请稍后刷新查看结果。",
|
||||
})}
|
||||
</p>
|
||||
<ul className="mt-3 space-y-2">
|
||||
<ul className="mt-3 grid gap-2 lg:grid-cols-2 lg:gap-3">
|
||||
{logs?.pending_reconcile.map((item) => (
|
||||
<li
|
||||
key={item.transfer_no}
|
||||
className="rounded-xl border border-amber-200 bg-white px-3 py-2"
|
||||
className="rounded-xl border border-amber-200 bg-white px-3 py-2 lg:px-4 lg:py-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-semibold">
|
||||
@@ -579,6 +649,7 @@ export function WalletScreen() {
|
||||
hasMore={hasMore}
|
||||
onLoadMore={loadMore}
|
||||
loadMoreRef={loadMoreRef}
|
||||
onPageChange={handleLogsPageChange}
|
||||
filter={filter}
|
||||
onFilterChange={handleFilterChange}
|
||||
currency={currency}
|
||||
|
||||
@@ -249,6 +249,17 @@
|
||||
"full_play": "Full play",
|
||||
"half_play": "Half play"
|
||||
},
|
||||
"comboCount": "{{count}} combos",
|
||||
"selectionConfirm": {
|
||||
"title": "Confirm type change",
|
||||
"increaseBody": "This play ({{type}}) expands to about {{count}} combinations. Stake will increase from {{from}} to {{to}}. Continue?",
|
||||
"fullCoverBody": "This play ({{type}}) covers about {{count}} combinations. Total stake {{amount}} will be split across them, with shared prize payout. Continue?",
|
||||
"genericBody": "This play ({{type}}) covers about {{count}} combinations and may raise the stake significantly. Continue?",
|
||||
"comboHint": "About {{count}} number permutations",
|
||||
"numberHint": "Number: {{number}}",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm"
|
||||
},
|
||||
"stake": "Stake Amount",
|
||||
"rebate": "Commission / Rebate",
|
||||
"actual": "Actual Deduction",
|
||||
@@ -659,6 +670,7 @@
|
||||
"results": {
|
||||
"title": "Results",
|
||||
"subtitle": "Latest draw history",
|
||||
"latest": "Latest draw",
|
||||
"detailTitle": "Result Detail",
|
||||
"businessDate": "Business Date",
|
||||
"selectBusinessDate": "Select date",
|
||||
@@ -838,5 +850,11 @@
|
||||
"even": "Even",
|
||||
"digit_big": "Big Digit",
|
||||
"digit_small": "Small Digit"
|
||||
},
|
||||
"pagination": {
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"summary": "{{total}} total, page {{page}} / {{lastPage}}",
|
||||
"pageOf": "Page {{page}} / {{lastPage}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +249,17 @@
|
||||
"full_play": "पूर्ण खेल",
|
||||
"half_play": "आधा खेल"
|
||||
},
|
||||
"comboCount": "{{count}} संयोजन",
|
||||
"selectionConfirm": {
|
||||
"title": "प्रकार परिवर्तन पुष्टि गर्नुहोस्",
|
||||
"increaseBody": "यो प्ले ({{type}}) ले करिब {{count}} संयोजन बनाउँछ। रकम {{from}} बाट {{to}} मा बढ्नेछ। जारी राख्ने?",
|
||||
"fullCoverBody": "यो प्ले ({{type}}) ले करिब {{count}} संयोजन समेट्छ। कुल रकम {{amount}} समूहमा बाँडिनेछ। जारी राख्ने?",
|
||||
"genericBody": "यो प्ले ({{type}}) ले करिब {{count}} संयोजन समेट्छ र रकम धेरै बढ्न सक्छ। जारी राख्ने?",
|
||||
"comboHint": "करिब {{count}} नम्बर संयोजन",
|
||||
"numberHint": "नम्बर: {{number}}",
|
||||
"cancel": "रद्द",
|
||||
"confirm": "पुष्टि"
|
||||
},
|
||||
"stake": "बेट रकम",
|
||||
"rebate": "कमिशन / रिबेट",
|
||||
"actual": "वास्तविक कट्टा",
|
||||
@@ -659,6 +670,7 @@
|
||||
"results": {
|
||||
"title": "नतिजा",
|
||||
"subtitle": "हालका ड्र इतिहास",
|
||||
"latest": "पछिल्लो ड्र",
|
||||
"detailTitle": "नतिजा विवरण",
|
||||
"businessDate": "व्यावसायिक मिति",
|
||||
"selectBusinessDate": "मिति छान्नुहोस्",
|
||||
@@ -838,5 +850,11 @@
|
||||
"even": "जोड",
|
||||
"digit_big": "ठुलो अंक",
|
||||
"digit_small": "सानो अंक"
|
||||
},
|
||||
"pagination": {
|
||||
"previous": "अघिल्लो",
|
||||
"next": "अर्को",
|
||||
"summary": "जम्मा {{total}}, पृष्ठ {{page}} / {{lastPage}}",
|
||||
"pageOf": "पृष्ठ {{page}} / {{lastPage}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +248,17 @@
|
||||
"full_play": "全打",
|
||||
"half_play": "半打"
|
||||
},
|
||||
"comboCount": "共{{count}}组",
|
||||
"selectionConfirm": {
|
||||
"title": "确认切换种类",
|
||||
"increaseBody": "此玩法({{type}})会增加投注组合数量(约 {{count}} 组),投注金额将从 {{from}} 增加至 {{to}},是否继续?",
|
||||
"fullCoverBody": "此玩法({{type}})将覆盖约 {{count}} 组号码,总投注 {{amount}} 将平摊到各组,中奖派彩按组合分摊。是否继续?",
|
||||
"genericBody": "此玩法({{type}})将覆盖约 {{count}} 组号码,投注金额可能显著增加,是否继续?",
|
||||
"comboHint": "包含约 {{count}} 组号码排列",
|
||||
"numberHint": "号码:{{number}}",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认"
|
||||
},
|
||||
"stake": "下注金额",
|
||||
"rebate": "佣金 / 回水",
|
||||
"actual": "实扣金额",
|
||||
@@ -659,6 +670,7 @@
|
||||
"results": {
|
||||
"title": "开奖结果",
|
||||
"subtitle": "最新开奖历史",
|
||||
"latest": "最新开奖",
|
||||
"detailTitle": "开奖详情",
|
||||
"businessDate": "业务日期",
|
||||
"selectBusinessDate": "选择日期",
|
||||
@@ -838,5 +850,11 @@
|
||||
"even": "双",
|
||||
"digit_big": "位数大",
|
||||
"digit_small": "位数小"
|
||||
},
|
||||
"pagination": {
|
||||
"previous": "上一页",
|
||||
"next": "下一页",
|
||||
"summary": "共 {{total}} 条,第 {{page}} / {{lastPage}} 页",
|
||||
"pageOf": "第 {{page}} / {{lastPage}} 页"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/** 主滚动区外边距(灰底区域与顶栏/底栏留白) */
|
||||
export const playerMainInset = "px-2 pt-2 pb-2 lg:px-6 lg:pt-4 lg:pb-6";
|
||||
export const playerMainInset = "px-2 pt-2 pb-2 lg:px-5 lg:pt-5 lg:pb-5";
|
||||
|
||||
/** 页内白卡片内边距 */
|
||||
export const playerPageShellPadding = "px-2 pt-2 pb-4 lg:px-6 lg:pt-6 lg:pb-6";
|
||||
/** 页内白卡片内边距(桌面主内容已是白卡片,减少二次 padding) */
|
||||
export const playerPageShellPadding = "px-2 pt-2 pb-4 lg:px-1 lg:pt-1 lg:pb-2";
|
||||
|
||||
/** 大厅等直接写在 section 上的内边距 */
|
||||
export const playerPageInset = playerPageShellPadding;
|
||||
|
||||
Reference in New Issue
Block a user