diff --git a/src/api/ticket-items.ts b/src/api/ticket-items.ts index cc5889d..dbbf24b 100644 --- a/src/api/ticket-items.ts +++ b/src/api/ticket-items.ts @@ -10,6 +10,10 @@ export type GetTicketItemsParams = { page?: number; per_page?: number; draw_no?: string; + number?: string; + status?: string[]; + start_date?: string; + end_date?: string; }; /** `GET /api/v1/ticket/items`(需登录) */ @@ -23,6 +27,10 @@ export function getTicketItems( page: params?.page, per_page: params?.per_page, draw_no: params?.draw_no, + number: params?.number, + status: params?.status, + start_date: params?.start_date, + end_date: params?.end_date, }, }, ); diff --git a/src/app/(player)/(main)/rules/page.tsx b/src/app/(player)/(main)/rules/page.tsx new file mode 100644 index 0000000..3eb1970 --- /dev/null +++ b/src/app/(player)/(main)/rules/page.tsx @@ -0,0 +1,5 @@ +import { PlayRulesScreen } from "@/features/rules/play-rules-screen"; + +export default function RulesPage() { + return ; +} diff --git a/src/components/layout/player-bottom-nav.tsx b/src/components/layout/player-bottom-nav.tsx index 27f791a..41ecab7 100644 --- a/src/components/layout/player-bottom-nav.tsx +++ b/src/components/layout/player-bottom-nav.tsx @@ -3,28 +3,44 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { BarChart3, ClipboardList, Home, Wallet } from "lucide-react"; +import { BarChart3, BookOpen, ClipboardList, Home, Wallet } from "lucide-react"; import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; const tabs = [ - { href: "/hall", labelKey: "nav.home", icon: Home, match: (p: string) => p === "/hall" }, + { + href: "/hall", + labelKey: "nav.home", + labelDefault: "首页", + icon: Home, + match: (p: string) => p === "/hall", + }, { href: "/results", labelKey: "nav.results", + labelDefault: "开奖结果", icon: BarChart3, match: (p: string) => p === "/results" || p.startsWith("/results/"), }, { href: "/orders", labelKey: "nav.orders", + labelDefault: "我的注单", icon: ClipboardList, match: (p: string) => p === "/orders" || p.startsWith("/orders/"), }, + { + href: "/rules", + labelKey: "nav.rules", + labelDefault: "规则", + icon: BookOpen, + match: (p: string) => p === "/rules", + }, { href: "/wallet", labelKey: "nav.wallet", + labelDefault: "钱包", icon: Wallet, match: (p: string) => p === "/wallet" || p.startsWith("/wallet/"), }, @@ -42,10 +58,10 @@ export function PlayerBottomNav() { className="fixed bottom-0 left-0 right-0 z-50 border-t border-[#e4ebf5] bg-white/96 pb-[env(safe-area-inset-bottom,0px)] shadow-[0_-10px_30px_rgba(15,23,42,0.08)] backdrop-blur-md" aria-label={t("nav.aria")} > -
- {tabs.map(({ href, labelKey, icon: Icon, match }) => { +
+ {tabs.map(({ href, labelKey, labelDefault, icon: Icon, match }) => { const active = match(pathname); - const label = t(labelKey); + const label = t(labelKey, { defaultValue: labelDefault }); return ( +
- - {t("hall.preview.warningsTitle")} - -

+ + + {t("hall.preview.warningsTitle")} + +

{t("hall.preview.warningsDescription")}

-
    +
      {warnings.map((w, i) => (
    • - {w.number_4d} — {w.message} + {w.number_4d} · {w.message}
    • ))}
    @@ -52,6 +57,38 @@ function WarningsBlock({ warnings }: { warnings: TicketPreviewWarning[] }) { ); } +function SubmittingPanel() { + const { t } = useTranslation("player"); + + return ( + +
    +
    + N +
    +
    +
    +
    + + + {t("hall.preview.processingTitle", { defaultValue: "正在提交下注" })} + + + {t("hall.preview.processingDescription", { defaultValue: "请勿关闭页面或返回上一页。" })} + + +
    + + {t("hall.preview.processingProgress", { defaultValue: "正在处理注单..." })} +
    +
    + + ); +} + /** * 预览弹窗 + 提交确认(产品文档 §10.1.2:预览不下单,确认后 place)。 */ @@ -68,15 +105,40 @@ export function HallBetPreviewDialog({ const summary = data?.summary; const lines = data?.lines ?? []; + if (placing) { + return ( + {}}> + + + ); + } + return ( - -
    - - - {t("hall.preview.title")} - - + +
    + + +
    + + + + + {t("hall.preview.title")} + +
    + {t("hall.preview.description")}
    @@ -91,109 +153,122 @@ export function HallBetPreviewDialog({ ) : null}
    - -
    +
    +
    {!data ? (

    {t("hall.preview.empty")}

    ) : ( <> -
    -

    - {t("hall.preview.draw")}{" "} - {data.draw.draw_id} ·{" "} - {t("hall.preview.status")}{" "} - {data.draw.status} -

    - {summary ? ( -
      -
    • - {t("hall.preview.amount")}{" "} - - {formatMinorAsCurrency(summary.total_bet_amount, currencyCode)} - -
    • -
    • - {t("hall.preview.rebate")}{" "} - - {formatMinorAsCurrency(summary.total_rebate_amount, currencyCode)} - -
    • -
    • - {t("hall.preview.actual")}{" "} - - {formatMinorAsCurrency(summary.total_actual_deduct, currencyCode)} - -
    • -
    • - {t("hall.preview.total")}{" "} - - {formatMinorAsCurrency(summary.total_actual_deduct, currencyCode)} - -
    • -
    - ) : null} +
    + {t("hall.preview.draw")}: + {data.draw.draw_id} + + {data.draw.status} +
    - - -
    -

    - {t("hall.preview.lines")} -

    -
      - {lines.map((ln) => ( -
    • -
      - - #{ln.client_line_no} - - +
      + + + + + + + + + + + + + {lines.map((ln) => ( + + + + + + + + + ))} + +
      No. + {t("hall.result.number", { defaultValue: "号码" })} + + {t("orders.play", { defaultValue: "玩法" })} + + {t("hall.preview.amount")} + + {t("hall.preview.rebate")} + + {t("hall.preview.actual")} +
      + + {ln.client_line_no} + + + {ln.number} + {playLabel(ln.play_code, t)} + {ln.play_code} - - -

      {ln.number}

      - -
      - - {t("hall.preview.normalizedNumber")} - - {ln.normalized_number} - - {t("hall.preview.combinationCount")} - - {ln.combination_count} - - {t("hall.preview.actual")} - - +
      + {formatMinorAsCurrency(ln.total_bet_amount, currencyCode)} + + -{formatMinorAsCurrency(ln.rebate_amount, currencyCode).replace(`${currencyCode} `, "")} + {formatMinorAsCurrency(ln.actual_deduct_amount, currencyCode)} - - - {t("hall.preview.estimatedMax")} - - - {formatMinorAsCurrency(ln.estimated_max_payout, currencyCode)} - - - - ))} - +
      + + {summary ? ( +
      +
      +

      {t("hall.preview.totalBet")}

      +

      + {formatMinorAsCurrency(summary.total_bet_amount, currencyCode)} +

      +
      +
      +

      {t("hall.preview.rebate")}

      +

      + - + {formatMinorAsCurrency(summary.total_rebate_amount, currencyCode).replace( + `${currencyCode} `, + "", + )} +

      +
      +
      +

      {t("hall.preview.actualDeduct")}

      +

      + {formatMinorAsCurrency(summary.total_actual_deduct, currencyCode)} +

      +
      +
      + ) : null} + + {data.warnings.length > 0 ? ( + + ) : ( +
      + + {t("hall.preview.noWarnings", { defaultValue: "当前预览未发现明显风险。" })} +
      + )} )}
      - +
    -
    +
    @@ -201,7 +276,7 @@ export function HallBetPreviewDialog({ type="button" onClick={onConfirmPlace} disabled={!data || placing || !allowSubmit} - className="h-11 rounded-xl border-0 bg-[#e5002c] text-sm font-bold text-white shadow-[0_8px_20px_rgba(229,0,44,0.26)] hover:bg-[#d10028] disabled:bg-slate-300 disabled:shadow-none" + className="h-12 rounded-lg border-0 bg-[#e5002c] text-base font-black text-white shadow-[0_10px_24px_rgba(229,0,44,0.28)] hover:bg-[#d10028] disabled:bg-slate-300 disabled:shadow-none" > {placing ? t("hall.preview.submitting") diff --git a/src/features/hall/hall-bet-result-dialog.tsx b/src/features/hall/hall-bet-result-dialog.tsx index 014b46c..dc2be72 100644 --- a/src/features/hall/hall-bet-result-dialog.tsx +++ b/src/features/hall/hall-bet-result-dialog.tsx @@ -1,6 +1,7 @@ "use client"; -import { CheckCircle2, ChevronRight } from "lucide-react"; +import Link from "next/link"; +import { CheckCircle2, ClipboardList, Ticket } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; @@ -11,8 +12,6 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Separator } from "@/components/ui/separator"; import { formatMinorAsCurrency } from "@/lib/money"; import { playLabel } from "@/lib/play-labels"; import type { TicketPlaceData } from "@/types/api/ticket"; @@ -38,163 +37,155 @@ export function HallBetResultDialog({ return ( - -
    - - + +
    +
    + +
    + + {t("hall.result.title", { defaultValue: "下注结果" })} - - {t("hall.result.description", { - defaultValue: "本次提交已完成,以下为本次结果明细。", - })} - + {data ? ( + + {t("hall.result.draw", { defaultValue: "期号" })}{" "} + {data.draw.draw_id} + + ) : null}
    - -
    +
    +
    {!data ? (

    {t("hall.result.empty", { defaultValue: "暂无结果。" })}

    ) : ( <> -
    -
    - - - {t("hall.result.orderNo", { - defaultValue: "订单号", - })}{" "} - {data.order_no} +
    +
    +

    + {t("hall.result.successCount", { defaultValue: "成功注项" })} +

    +

    {totalSuccess}

    +
    +
    +

    + {t("hall.result.failureCount", { defaultValue: "失败注项" })} +

    +

    {totalFailure}

    +
    +
    + +
    +
    + + + + + {t("hall.result.actual", { defaultValue: "实扣金额" })}
    -

    - {t("hall.result.draw", { defaultValue: "期号" })}{" "} - {data.draw.draw_id} + + {formatMinorAsCurrency(data.summary.total_actual_deduct, currencyCode)} + +

    + +
    +

    + {t("hall.result.orderNo", { defaultValue: "订单号" })}:{" "} + {data.order_no}

    -

    - {t("hall.result.status", { defaultValue: "注单状态" })}{" "} - - {t("ticketStatus.success", { defaultValue: "待开奖" })} +

    + {t("hall.result.balanceAfter", { defaultValue: "剩余余额" })}:{" "} + + {formatMinorAsCurrency(data.balance_after, currencyCode)}

    - -
      -
    • - {t("hall.result.successCount", { defaultValue: "成功数" })}{" "} - {totalSuccess} -
    • -
    • - {t("hall.result.failureCount", { defaultValue: "失败数" })}{" "} - {totalFailure} -
    • -
    • - {t("hall.result.amount", { defaultValue: "金额" })}{" "} - - {formatMinorAsCurrency(data.summary.total_bet_amount, currencyCode)} - -
    • -
    • - {t("hall.result.rebate", { defaultValue: "回水" })}{" "} - - {formatMinorAsCurrency(data.summary.total_rebate_amount, currencyCode)} - -
    • -
    • - {t("hall.result.actual", { defaultValue: "实扣" })}{" "} - - {formatMinorAsCurrency(data.summary.total_actual_deduct, currencyCode)} - -
    • -
    • - {t("hall.result.total", { defaultValue: "合计" })}{" "} - - {formatMinorAsCurrency(data.summary.total_actual_deduct, currencyCode)} - -
    • -
    • - {t("hall.result.balanceAfter", { defaultValue: "剩余余额" })}{" "} - - {formatMinorAsCurrency(data.balance_after, currencyCode)} - -
    • -
    -

    +

    {t("hall.result.items", { - defaultValue: "每一注成功/失败详情", + defaultValue: "成功注项明细", })}

    -
      - {data.items.map((item, index) => ( -
    • -
      - - #{index + 1} - - - {t("hall.result.success", { defaultValue: "成功" })} - -
      -
      - {playLabel(item.play_code, t)} - - {item.ticket_no} - -
      -
      - +
      + + + + + + + + + + + {data.items.map((item, index) => ( + + + + + + + ))} + +
      No. {t("hall.result.number", { defaultValue: "号码" })} - - {item.number} - - {t("hall.result.comboCount", { defaultValue: "组合数" })} - - {item.combination_count} - + + {t("orders.play", { defaultValue: "玩法" })} + {t("hall.result.actualDeduct", { defaultValue: "实扣" })} - - - {formatMinorAsCurrency(item.actual_deduct_amount, currencyCode)} - - - {t("hall.result.estimatedMax", { defaultValue: "最坏赔付" })} - - - {formatMinorAsCurrency(item.estimated_max_payout, currencyCode)} - - - {item.combination_count > 1 ? ( -
      - - {t("hall.result.comboHint", { - defaultValue: "已按展开组合分摊", - })} -
      - ) : null} - - ))} - +
      + {index + 1} + + + {item.number} + + + {item.ticket_no} + + + {playLabel(item.play_code, t)} + + {formatMinorAsCurrency(item.actual_deduct_amount, currencyCode)} +
      +
      + {totalFailure === 0 ? ( +
      + {t("hall.result.noFailures", { defaultValue: "本次提交没有失败注项。" })} +
      + ) : null}
      )}
    - +
    -
    +
    +
    diff --git a/src/features/hall/hall-screen.tsx b/src/features/hall/hall-screen.tsx index 11874c0..db453fb 100644 --- a/src/features/hall/hall-screen.tsx +++ b/src/features/hall/hall-screen.tsx @@ -2,6 +2,7 @@ import { Bell } from "lucide-react"; import Image from "next/image"; +import Link from "next/link"; import { useTranslation } from "react-i18next"; import { LanguageSwitcher } from "@/components/language-switcher"; @@ -15,6 +16,7 @@ import { useHallDrawLive } from "@/features/hall/use-hall-draw-live"; */ export function HallScreen() { const { t } = useTranslation("common"); + const { t: tp } = useTranslation("player"); const drawLive = useHallDrawLive(); return ( @@ -38,6 +40,12 @@ export function HallScreen() { showFlag={false} className="shrink-0 rounded-full border border-[#e4eaf4] bg-[#f8fafc]" /> + + {tp("nav.rules", { defaultValue: "规则" })} +
    - {pub?.results ? ( -
    -

    {t("orders.drawNumbers")}

    - - {first ? ( -

    - {t("orders.firstPrize")}{" "} - {first} - {comboHits.length > 0 ? ( - - {" "} - ← {t("orders.hit")} - - ) : null} + {pub?.results ? ( +

    +

    {t("orders.drawNumbers")}

    + + {first ? ( +

    + {t("orders.firstPrize")}{" "} + {first} + {comboHits.length > 0 ? ( + + {" "} + ← {t("orders.hit")} + + ) : null} +

    + ) : null} + {!hasSettlement ? ( +

    + {t("orders.matchPendingSettlement", { defaultValue: "已开奖,等待系统结算后显示中奖结果。" })} +

    + ) : null} +
    + ) : ( +
    +

    {t("orders.drawNumbers")}

    +

    + {t("orders.drawPendingMatch", { defaultValue: "本期开奖号码尚未发布,暂不能判断是否中奖。" })}

    - ) : null} -
    - ) : ( -

    - {t("orders.notPublished")} -

    - )} +
    + )} - {data.settlement && tierLabel ? ( -
    -

    - {t("orders.matchWin", { tier: tierLabel })} -

    + {data.settlement && tierLabel ? ( +
    +

    + {t("orders.matchWin", { tier: tierLabel })} +

    {t("orders.winAmount", { amount: formatMinorAsCurrency(data.settlement.win_amount_minor, cur), @@ -269,10 +302,53 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {

    {t("orders.payoutTotal", { amount: formatMinorAsCurrency(totalWin, cur) })}

    -
    - ) : data.status === "settled_lose" ? ( -

    {t("orders.matchLose")}

    - ) : null} +
    + ) : hasSettlement ? ( +

    {t("orders.matchLose")}

    + ) : null} + + {matchResult && hasSettlement ? ( +
    +

    + {t("orders.matchResult", { defaultValue: "匹配结果" })} +

    +

    + {matchResult.matched + ? t("orders.matchWin", { tier: tierLabel ?? (matchResult.matched_prize_tier ?? "—") }) + : t("orders.matchLose")} +

    + {Array.isArray(matchResult.lines) && matchResult.lines.length > 0 ? ( +
    + {matchResult.lines.map((line, idx) => ( +

    + {line.number_4d ?? "—"} · {line.matched_tier ?? "—"} · {formatMinorAsCurrency(line.payout ?? 0, cur)} +

    + ))} +
    + ) : null} +
    + ) : null} + + {timeline.length > 0 ? ( +
    +

    + {t("orders.timeline", { defaultValue: "时间线" })} +

    +
    + {timeline.map((row) => ( +
    +
    +

    {row.label}

    +

    {row.code}

    +
    +

    + {formatLotteryInstant(row.time)} +

    +
    + ))} +
    +
    + ) : null} {data.settled_at ? (

    diff --git a/src/features/orders/ticket-orders-list-screen.tsx b/src/features/orders/ticket-orders-list-screen.tsx index 45e37bf..8333548 100644 --- a/src/features/orders/ticket-orders-list-screen.tsx +++ b/src/features/orders/ticket-orders-list-screen.tsx @@ -3,27 +3,46 @@ import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { CalendarRange, ChevronDown, Search } from "lucide-react"; import { useTranslation } from "react-i18next"; import { getTicketItems } from "@/api/ticket-items"; import { Button } from "@/components/ui/button"; -import { Skeleton } from "@/components/ui/skeleton"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Calendar } from "@/components/ui/calendar"; +import { Input } from "@/components/ui/input"; import { PlayerPanel } from "@/components/layout/player-panel"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Skeleton } from "@/components/ui/skeleton"; import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status"; +import { useIsMobile } from "@/hooks/use-mobile"; import { formatMinorAsCurrency } from "@/lib/money"; import { formatLotteryInstant } from "@/lib/player-datetime"; import { playLabel } from "@/lib/play-labels"; +import { cn } from "@/lib/utils"; import type { TicketItemListRow } from "@/types/api/ticket-items"; -const ORDERS_PAGE_SIZE = 10; +const ORDERS_PAGE_SIZE = 20; +const STATUS_OPTIONS = ["success", "settled_win", "settled_lose", "failed"] as const; + +function parseYmd(value: string): Date | undefined { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!m) return undefined; + const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); + return Number.isNaN(d.getTime()) ? undefined : d; +} + +function formatYmd(value: Date): string { + const y = value.getFullYear(); + const m = String(value.getMonth() + 1).padStart(2, "0"); + const d = String(value.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} export function TicketOrdersListScreen() { const searchParams = useSearchParams(); const { t } = useTranslation("player"); - const drawNoFilter = useMemo( - () => (searchParams.get("draw_no") ?? "").trim(), - [searchParams], - ); + const drawNoFilter = useMemo(() => (searchParams.get("draw_no") ?? "").trim(), [searchParams]); const [items, setItems] = useState([]); const [page, setPage] = useState(1); @@ -32,7 +51,39 @@ export function TicketOrdersListScreen() { const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); + const [queryDrawNo, setQueryDrawNo] = useState(drawNoFilter); + const [queryNumber, setQueryNumber] = useState(""); + const [queryStatuses, setQueryStatuses] = useState([]); + const [fromDate, setFromDate] = useState(""); + const [toDate, setToDate] = useState(""); + const [rangeOpen, setRangeOpen] = useState(false); + const [statusOpen, setStatusOpen] = useState(false); + const [calendarMonth, setCalendarMonth] = useState(() => new Date()); const loadMoreRef = useRef(null); + const isMobile = useIsMobile(); + const initialLoadDone = useRef(false); + + const selectedRange = useMemo(() => { + const from = parseYmd(fromDate); + const to = parseYmd(toDate); + if (!from && !to) return undefined; + if (from && to) return { from, to }; + if (from) return { from }; + return to ? { from: to } : undefined; + }, [fromDate, toDate]); + + const formatCompactDate = useCallback((value: string) => { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!m) return value; + return `${m[2]}-${m[3]}`; + }, []); + + const dateLabel = useMemo(() => { + if (!fromDate && !toDate) return t("orders.dateRange", { defaultValue: "日期范围" }); + if (fromDate && toDate) return `${formatCompactDate(fromDate)} ~ ${formatCompactDate(toDate)}`; + return `${fromDate ? formatCompactDate(fromDate) : "..." } ~ ${toDate ? formatCompactDate(toDate) : "..."}`; + }, [formatCompactDate, fromDate, t, toDate]); + const visiblePages = useMemo(() => buildPageWindow(page, lastPage), [lastPage, page]); const fetchPage = useCallback( async (nextPage: number, append: boolean) => { @@ -43,7 +94,11 @@ export function TicketOrdersListScreen() { const res = await getTicketItems({ page: nextPage, per_page: ORDERS_PAGE_SIZE, - draw_no: drawNoFilter || undefined, + draw_no: queryDrawNo || drawNoFilter || undefined, + number: queryNumber || undefined, + status: queryStatuses.length ? queryStatuses : undefined, + start_date: fromDate || undefined, + end_date: toDate || undefined, }); setItems((prev) => (append ? [...prev, ...res.items] : res.items)); setPage(res.page); @@ -57,19 +112,20 @@ export function TicketOrdersListScreen() { setLoadingMore(false); } }, - [drawNoFilter, t], + [drawNoFilter, fromDate, queryDrawNo, queryNumber, queryStatuses, t, toDate], ); useEffect(() => { - queueMicrotask(() => { + if (!initialLoadDone.current) { + initialLoadDone.current = true; void fetchPage(1, false); - }); - }, [fetchPage]); - - const loadMore = useCallback(() => { - if (page >= lastPage || loadingMore) return; - void fetchPage(page + 1, true); - }, [fetchPage, lastPage, loadingMore, page]); + return; + } + setItems([]); + setPage(1); + setLastPage(1); + void fetchPage(1, false); + }, [fetchPage, queryDrawNo, queryNumber, queryStatuses, fromDate, toDate]); useEffect(() => { const target = loadMoreRef.current; @@ -78,7 +134,7 @@ export function TicketOrdersListScreen() { const observer = new IntersectionObserver( ([entry]) => { if (entry?.isIntersecting) { - loadMore(); + void fetchPage(page + 1, true); } }, { rootMargin: "160px" }, @@ -86,14 +142,19 @@ export function TicketOrdersListScreen() { observer.observe(target); return () => observer.disconnect(); - }, [lastPage, loadMore, loading, loadingMore, page]); + }, [fetchPage, lastPage, loading, loadingMore, page]); return ( - +

    -
    -
    -
    +
    +
    +

    {drawNoFilter ? t("orders.filteredIssue") : t("orders.totalRecords")}

    @@ -101,21 +162,158 @@ export function TicketOrdersListScreen() { {drawNoFilter || total}

    - {drawNoFilter ? ( - - {t("actions.clear")} - - ) : ( +
    {t("orders.betNow")} - )} + {(queryDrawNo || queryNumber || fromDate || toDate || queryStatuses.length > 0) ? ( + + ) : null} +
    +
    + +
    +
    + setQueryDrawNo(e.target.value)} + placeholder={t("orders.drawNo")} + aria-label={t("orders.drawNo")} + className="h-8 border-0 bg-transparent px-0 text-sm shadow-none focus-visible:ring-0" + /> +
    + +
    + + setQueryNumber(e.target.value)} + placeholder={t("orders.number")} + aria-label={t("orders.number")} + className="h-8 border-0 bg-transparent px-0 text-sm shadow-none focus-visible:ring-0" + /> +
    + + + + + {dateLabel} + + } + /> + + { + if (!range?.from && !range?.to) { + setFromDate(""); + setToDate(""); + return; + } + setFromDate(range?.from ? formatYmd(range.from) : ""); + setToDate(range?.to ? formatYmd(range.to) : ""); + }} + /> +
    + + +
    +
    +
    + + + + + 状态 + {queryStatuses.length ? ( + + {queryStatuses.length} + + ) : null} + + + + + + } + /> + +
    +

    + {t("orders.statusFilter", { defaultValue: "状态筛选" })} +

    + {STATUS_OPTIONS.map((status) => { + const checked = queryStatuses.includes(status); + return ( + + ); + })} +
    +
    +
    @@ -152,12 +350,7 @@ export function TicketOrdersListScreen() {
    {items.map((row) => { const cur = row.currency_code ?? "NPR"; - const st = ticketStatusDisplay( - row.status, - row.win_amount, - row.jackpot_win_amount, - t, - ); + const st = ticketStatusDisplay(row.status, row.win_amount, row.jackpot_win_amount, t); const totalWin = row.win_amount + row.jackpot_win_amount; return (
    -

    - {t("orders.stake")} -

    +

    {t("orders.stake")}

    {formatMinorAsCurrency(row.total_bet_amount, cur)}

    -

    - {t("orders.deduction")} -

    +

    {t("orders.deduction")}

    {formatMinorAsCurrency(row.actual_deduct_amount, cur)}

    @@ -206,19 +395,58 @@ export function TicketOrdersListScreen() { ); })}
    -
    - {page < lastPage ? ( - + {isMobile ?
    : null} + {isMobile && page < lastPage ? ( + + ) : !isMobile && lastPage > 1 ? ( +
    + + {visiblePages.map((p) => ( + + ))} + +
    ) : (

    {t("orders.noMore", { defaultValue: "没有更多注单" })} @@ -230,3 +458,12 @@ export function TicketOrdersListScreen() { ); } + +function buildPageWindow(current: number, last: number): number[] { + if (last <= 5) { + return Array.from({ length: last }, (_, index) => index + 1); + } + + const start = Math.max(1, Math.min(current - 2, last - 4)); + return Array.from({ length: 5 }, (_, index) => start + index); +} diff --git a/src/features/results/draw-results-list-screen.tsx b/src/features/results/draw-results-list-screen.tsx index c8947a5..2aea7d4 100644 --- a/src/features/results/draw-results-list-screen.tsx +++ b/src/features/results/draw-results-list-screen.tsx @@ -19,6 +19,7 @@ import { } from "@/components/ui/select"; import { PlayerPanel } from "@/components/layout/player-panel"; import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip"; +import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid"; import { formatLotteryInstant } from "@/lib/player-datetime"; import type { DrawResultListItem } from "@/types/api/draw-results"; @@ -44,6 +45,8 @@ export function DrawResultsListScreen() { const selectedDate = useMemo(() => parseBusinessDate(date), [date]); const businessDate = /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : undefined; const quickYears = useMemo(() => buildYearOptions(calendarMonth), [calendarMonth]); + const featured = items?.[0] ?? null; + const olderDraw = items?.[1] ?? null; const fetchList = useCallback(async (targetPage = 1, append = false) => { setError(null); @@ -221,6 +224,59 @@ export function DrawResultsListScreen() {

    ) : (
    + {featured ? ( +
    +
    + +
    +

    + {featured.draw_no} +

    +

    + {t("results.drawTime", { + time: formatLotteryInstant(featured.draw_time_iso ?? featured.draw_time ?? null), + })} +

    +
    + {olderDraw ? ( + + {t("results.previous")} + + ) : ( + + )} +
    +
    + + + {t("results.viewMyWinning")} + +
    +
    + ) : null} + {items?.map((row) => ( +
    + + +
    + + + +
    + + {t("rules.quick.title", { defaultValue: "开奖结构" })} + +

    + {t("rules.quick.description", { + defaultValue: "每期开奖 23 组 4 位号码,结算以下注时锁定的赔率快照为准。", + })} +

    +
    +
    +
    +
    +

    23

    +

    + {t("rules.quick.totalPrizes", { defaultValue: "奖项组数" })} +

    +
    +
    +

    15

    +

    + {t("rules.quick.cooldown", { defaultValue: "冷静期分钟" })} +

    +
    +
    +

    1

    +

    + {t("rules.quick.snapshot", { defaultValue: "赔率快照" })} +

    +
    +
    +
    + +
    + {prizeRows.map((row) => ( +
    + + {t(row.label, { defaultValue: row.defaultValue })} + + {row.count} +
    + ))} +
    +
    +
    + +
    + {playSections.map((section) => ( + + + + {t(section.title, { defaultValue: section.titleDefault })} + + + +
      + {section.items.map(([item, defaultValue]) => ( +
    • + {t(item, { defaultValue })} +
    • + ))} +
    +
    +
    + ))} +
    + + + +
    + +

    + {t("rules.footer.config", { + defaultValue: "下注大厅的玩法列、启用状态、限额和赔率来自后台生效玩法配置。", + })} +

    +
    +
    + +

    + {t("rules.footer.phaseTwo", { + defaultValue: "5D / 6D 属于二期扩展,一期前后端均不提供可用入口。", + })} +

    +
    + + {t("rules.footer.backBet", { defaultValue: "返回下注大厅" })} + +
    +
    +
    + + ); +} diff --git a/src/hooks/use-mobile.ts b/src/hooks/use-mobile.ts new file mode 100644 index 0000000..3c6da11 --- /dev/null +++ b/src/hooks/use-mobile.ts @@ -0,0 +1,17 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export function useIsMobile(breakpoint = 768): boolean { + const [isMobile, setIsMobile] = useState(false); + + useEffect(() => { + const query = window.matchMedia(`(max-width: ${breakpoint - 1}px)`); + const update = () => setIsMobile(query.matches); + update(); + query.addEventListener("change", update); + return () => query.removeEventListener("change", update); + }, [breakpoint]); + + return isMobile; +} diff --git a/src/i18n/locales/en/player.json b/src/i18n/locales/en/player.json index c5033ee..492eabb 100644 --- a/src/i18n/locales/en/player.json +++ b/src/i18n/locales/en/player.json @@ -19,6 +19,7 @@ "home": "Home", "results": "Results", "orders": "My Bets", + "rules": "Rules", "wallet": "Wallet" }, "panel": { @@ -158,6 +159,10 @@ "backEdit": "Back to edit", "submitting": "Submitting...", "confirmSubmit": "Confirm submit", + "processingTitle": "Submitting your bets", + "processingDescription": "Please do not close the page or go back.", + "processingProgress": "Processing tickets...", + "noWarnings": "No obvious risk was found in this preview.", "warningsTitle": "Payout pool warning", "warningsDescription": "The following numbers have high payout pool usage for this issue. Betting is still allowed, but the order may be rejected as sold out if capacity is insufficient." }, @@ -299,6 +304,8 @@ "totalRecords": "Total Records", "betNow": "Bet Now", "empty": "No bet records yet.", + "dateRange": "Date range", + "statusFilter": "Status filter", "submitBet": "Submit Bet", "stake": "Stake", "deduction": "Deduction", @@ -323,6 +330,11 @@ "jackpotAmount": "Jackpot {{amount}}", "payoutTotal": "Payout total {{amount}}", "matchLose": "Match result: not won", + "matchResult": "Match result", + "drawPendingMatch": "Draw numbers are not published yet. Winning status cannot be determined.", + "matchPendingDraw": "Waiting for draw results. Winning status cannot be determined yet.", + "matchPendingSettlement": "Draw results are published. Winning status will show after settlement.", + "timeline": "Timeline", "settledAt": "Settled at {{time}}", "viewDraw": "View this draw", "backToOrders": "Back to My Bets", @@ -365,8 +377,73 @@ "consolation": "Consolation" } }, + "rules": { + "title": "Play Rules", + "subtitle": "2D / 3D / 4D, box plays, rebate, closing and sold-out rules", + "quick": { + "title": "Prize Structure", + "description": "Each draw publishes 23 four-digit numbers. Settlement uses the odds snapshot locked when the bet was placed.", + "totalPrizes": "Prize groups", + "cooldown": "Cooldown mins", + "snapshot": "Odds snapshot" + }, + "prizes": { + "first": "First prize", + "second": "Second prize", + "third": "Third prize", + "starter": "Starter", + "consolation": "Consolation" + }, + "sections": { + "dimensions": { + "title": "What are 2D, 3D and 4D", + "d4": "4D: match the full four-digit number, for example 1234.", + "d3": "3D: match your last three digits against the result's last three digits, for example 234 can hit 1234.", + "d2": "2D: match your last two digits against the result's last two digits, for example 34 can hit 1234." + }, + "bigSmall": { + "title": "Big vs Small", + "big": "Big: covers all 23 prizes. Any hit in first, second, third, starter or consolation wins.", + "small": "Small: only covers first, second and third prizes. Starter or consolation hits do not win." + }, + "positions": { + "title": "Position Plays", + "d4": "4A / 4B / 4C map to first, second and third prize. 4D hits any starter row. 4E hits any consolation row.", + "d3": "3A / 3B / 3C match the last three digits of first, second and third prize. 3ABC hits any of those three.", + "d2": "2A / 2B / 2C match the last two digits of first, second and third prize. 2ABC hits any of those three." + }, + "box": { + "title": "Box Plays", + "straight": "Straight: only the exact same order wins.", + "box": "Box: expands unique permutations and settles the winning expanded number.", + "ibox": "iBox: every permutation is staked by the unit amount. Total deduction = unit amount × combinations × (1 - rebate rate).", + "mbox": "mBox: input amount is shared across combinations. Any indivisible remainder is not deducted and is treated as returned to wallet.", + "roll": "Roll: R marks a rolling digit covering 0-9. Combination count = 10 to the power of R count.", + "half": "Half Box: reserved for phase one data structure only and not open on the player side." + }, + "attributes": { + "title": "Head / Tail, Odd / Even and Digit Size", + "headTail": "Head / Tail only checks the first digit of the first prize. 5-9 is Head, 0-4 is Tail.", + "oddEven": "Odd / Even checks the last digit. 1/3/5/7/9 is odd and 0/2/4/6/8 is even. It can be combined with 2D / 3D / 4D dimensions.", + "digitSize": "Big Digit / Small Digit checks a selected digit. 5-9 is big and 0-4 is small. Multiple selected digits settle independently." + }, + "wallet": { + "title": "Jackpot, Rebate, Closing and Sold Out", + "rebate": "Rebate / commission is locked into the ticket odds snapshot and is not changed by later configuration updates.", + "jackpot": "Jackpot depends on backend pool configuration. Burst, contribution and payout follow system records.", + "close": "Closing: after close time, the hall cannot edit or submit bets.", + "soldOut": "Sold out: when payout pool capacity is insufficient, preview may warn and final submission can reject the whole order." + } + }, + "footer": { + "config": "Hall play columns, enabled state, limits and odds come from the active backend play configuration.", + "phaseTwo": "5D / 6D are phase-two extensions and have no available entry in phase one.", + "backBet": "Back to betting hall" + } + }, "ticketStatus": { "success": "Awaiting draw", + "pending_payout": "Won, pending payout", "settled_win": "Paid", "settled_lose": "Not won", "unknown": "{{status}}" diff --git a/src/i18n/locales/ne/player.json b/src/i18n/locales/ne/player.json index 6aaef7b..33624c6 100644 --- a/src/i18n/locales/ne/player.json +++ b/src/i18n/locales/ne/player.json @@ -19,6 +19,7 @@ "home": "गृह", "results": "नतिजा", "orders": "मेरा बेट", + "rules": "नियम", "wallet": "वालेट" }, "panel": { @@ -365,6 +366,70 @@ "consolation": "Consolation" } }, + "rules": { + "title": "प्ले नियम", + "subtitle": "2D / 3D / 4D, box play, rebate, closing र sold-out नियम", + "quick": { + "title": "पुरस्कार संरचना", + "description": "हरेक ड्रमा 23 वटा 4 अंकका नम्बर प्रकाशित हुन्छन्। सेटलमेन्ट बेट राख्दा लक भएको odds snapshot अनुसार हुन्छ।", + "totalPrizes": "पुरस्कार समूह", + "cooldown": "Cooldown मिनेट", + "snapshot": "Odds snapshot" + }, + "prizes": { + "first": "पहिलो पुरस्कार", + "second": "दोस्रो पुरस्कार", + "third": "तेस्रो पुरस्कार", + "starter": "Starter", + "consolation": "Consolation" + }, + "sections": { + "dimensions": { + "title": "2D, 3D र 4D के हो", + "d4": "4D: पूरा 4 अंकको नम्बर मिल्नुपर्छ, जस्तै 1234।", + "d3": "3D: तपाईंको पछिल्ला 3 अंकलाई नतिजाको पछिल्ला 3 अंकसँग मिलाइन्छ, जस्तै 234 ले 1234 हिट गर्न सक्छ।", + "d2": "2D: तपाईंको पछिल्ला 2 अंकलाई नतिजाको पछिल्ला 2 अंकसँग मिलाइन्छ, जस्तै 34 ले 1234 हिट गर्न सक्छ।" + }, + "bigSmall": { + "title": "Big vs Small", + "big": "Big: सबै 23 पुरस्कार समेट्छ। first, second, third, starter वा consolation मध्ये कुनै पनि हिट भए जित्छ।", + "small": "Small: first, second र third मात्र समेट्छ। starter वा consolation हिट भए जित्दैन।" + }, + "positions": { + "title": "Position Plays", + "d4": "4A / 4B / 4C क्रमशः first, second र third prize हुन्। 4D ले कुनै starter row हिट गर्छ। 4E ले कुनै consolation row हिट गर्छ।", + "d3": "3A / 3B / 3C ले first, second र third prize का पछिल्ला 3 अंक मिलाउँछ। 3ABC ले यी तीनमध्ये कुनै पनि मिलाउँछ।", + "d2": "2A / 2B / 2C ले first, second र third prize का पछिल्ला 2 अंक मिलाउँछ। 2ABC ले यी तीनमध्ये कुनै पनि मिलाउँछ।" + }, + "box": { + "title": "Box Plays", + "straight": "Straight: ठीक उही क्रम मिलेमात्र जित्छ।", + "box": "Box: unique permutation हरू विस्तार हुन्छन् र हिट भएको expanded नम्बर सेटल हुन्छ।", + "ibox": "iBox: हरेक permutation मा unit amount बेट हुन्छ। Total deduction = unit amount × combinations × (1 - rebate rate)।", + "mbox": "mBox: input amount सबै combinations मा बाँडिन्छ। बाँकी indivisible रकम deduct हुँदैन र wallet मा फर्किएको मानिन्छ।", + "roll": "Roll: R ले rolling digit जनाउँछ र 0-9 समेट्छ। Combination count = 10 को R count power।", + "half": "Half Box: phase one मा data structure मात्र reserve छ, player side मा खुला छैन।" + }, + "attributes": { + "title": "Head / Tail, Odd / Even र Digit Size", + "headTail": "Head / Tail ले first prize को पहिलो अंक मात्र हेर्छ। 5-9 Head, 0-4 Tail।", + "oddEven": "Odd / Even ले अन्तिम अंक हेर्छ। 1/3/5/7/9 odd, 0/2/4/6/8 even। यो 2D / 3D / 4D dimension सँग प्रयोग गर्न सकिन्छ।", + "digitSize": "Big Digit / Small Digit ले चयन गरिएको digit हेर्छ। 5-9 big, 0-4 small। धेरै digit छुट्टाछुट्टै सेटल हुन्छन्।" + }, + "wallet": { + "title": "Jackpot, Rebate, Closing र Sold Out", + "rebate": "Rebate / commission टिकटको odds snapshot मा lock हुन्छ र पछि config बदलिए पनि बदलिँदैन।", + "jackpot": "Jackpot backend pool configuration मा निर्भर हुन्छ। Burst, contribution र payout system records अनुसार हुन्छ।", + "close": "Closing: close time पछि hall मा bet edit वा submit गर्न सकिँदैन।", + "soldOut": "Sold out: payout pool capacity अपुग भए preview ले warning दिन सक्छ र final submission ले whole order reject गर्न सक्छ।" + } + }, + "footer": { + "config": "Hall play columns, enabled state, limits र odds active backend play configuration बाट आउँछन्।", + "phaseTwo": "5D / 6D phase-two extensions हुन् र phase one मा available entry छैन।", + "backBet": "Betting hall मा फर्कनुहोस्" + } + }, "ticketStatus": { "success": "ड्र पर्खँदै", "settled_win": "भुक्तानी भयो", diff --git a/src/i18n/locales/zh/player.json b/src/i18n/locales/zh/player.json index 5a1338f..bac3ae1 100644 --- a/src/i18n/locales/zh/player.json +++ b/src/i18n/locales/zh/player.json @@ -19,6 +19,7 @@ "home": "首页", "results": "开奖结果", "orders": "我的注单", + "rules": "规则", "wallet": "钱包" }, "panel": { @@ -158,6 +159,10 @@ "backEdit": "返回修改", "submitting": "提交中...", "confirmSubmit": "确认提交", + "processingTitle": "正在提交下注", + "processingDescription": "请勿关闭页面或返回上一页。", + "processingProgress": "正在处理注单...", + "noWarnings": "当前预览未发现明显风险。", "warningsTitle": "赔付池预警", "warningsDescription": "以下号码本期赔付池占用较高,仍允许下注;若实际占用不足将售罄拒单。" }, @@ -299,6 +304,8 @@ "totalRecords": "总记录数", "betNow": "立即下注", "empty": "暂无下注记录。", + "dateRange": "日期范围", + "statusFilter": "状态筛选", "submitBet": "提交下注", "stake": "下注", "deduction": "实扣", @@ -323,6 +330,11 @@ "jackpotAmount": "Jackpot {{amount}}", "payoutTotal": "派彩合计 {{amount}}", "matchLose": "匹配结果:未中奖", + "matchResult": "匹配结果", + "drawPendingMatch": "本期开奖号码尚未发布,暂不能判断是否中奖。", + "matchPendingDraw": "待开奖,暂不能判断是否中奖。", + "matchPendingSettlement": "已开奖,等待系统结算后显示中奖结果。", + "timeline": "时间线", "settledAt": "结算时间 {{time}}", "viewDraw": "查看本期开奖", "backToOrders": "返回我的注单", @@ -365,8 +377,73 @@ "consolation": "安慰奖" } }, + "rules": { + "title": "玩法规则", + "subtitle": "2D / 3D / 4D、包号、回水、封盘与售罄说明", + "quick": { + "title": "开奖结构", + "description": "每期开奖 23 组 4 位号码,结算以下注时锁定的赔率快照为准。", + "totalPrizes": "奖项组数", + "cooldown": "冷静期分钟", + "snapshot": "赔率快照" + }, + "prizes": { + "first": "头奖", + "second": "二奖", + "third": "三奖", + "starter": "特别奖", + "consolation": "安慰奖" + }, + "sections": { + "dimensions": { + "title": "什么是 2D、3D、4D", + "d4": "4D:按完整 4 位号码判断,例如 1234。", + "d3": "3D:按下注后三位与开奖号码后三位匹配,例如 234 可命中 1234。", + "d2": "2D:按下注后二位与开奖号码后二位匹配,例如 34 可命中 1234。" + }, + "bigSmall": { + "title": "Big vs Small", + "big": "Big:覆盖全部 23 个奖项,命中头奖、二奖、三奖、特别奖或安慰奖任一组即中奖。", + "small": "Small:只覆盖头奖、二奖、三奖;命中特别奖或安慰奖不中奖。" + }, + "positions": { + "title": "位置类玩法", + "d4": "4A / 4B / 4C 分别对应头奖、二奖、三奖;4D 命中特别奖任一组;4E 命中安慰奖任一组。", + "d3": "3A / 3B / 3C 分别匹配头奖、二奖、三奖后三位;3ABC 命中头/二/三任一后三位。", + "d2": "2A / 2B / 2C 分别匹配头奖、二奖、三奖后二位;2ABC 命中头/二/三任一后二位。" + }, + "box": { + "title": "包号类玩法", + "straight": "Straight:顺序完全一致才中奖。", + "box": "Box:系统展开不重复排列组合,中奖按命中的展开号码结算。", + "ibox": "iBox:每个排列都按单注金额下注,总扣款 = 单注金额 × 组合数 × (1 - 回水率)。", + "mbox": "mBox:总输入金额平摊到所有组合,不能整除的尾差不扣除,相当于退回玩家钱包。", + "roll": "Roll:用 R 表示滚动位,每个 R 覆盖 0-9;组合数 = 10 的滚动位数次方。", + "half": "Half Box:一期仅预留数据结构,前台不开放下注。" + }, + "attributes": { + "title": "Head / Tail、单双、数字大小", + "headTail": "Head / Tail:只看头奖首位数字;5-9 为 Head,0-4 为 Tail。", + "oddEven": "单双:按号码末位判断,1/3/5/7/9 为单,0/2/4/6/8 为双,可叠加 2D / 3D / 4D 维度。", + "digitSize": "Big Digit / Small Digit:按指定位数字判断,5-9 为大,0-4 为小;多位分别下注时独立结算。" + }, + "wallet": { + "title": "Jackpot、回水、封盘与售罄", + "rebate": "回水 / 佣金:下注时锁定到注单赔率快照,结算不会受之后配置变化影响。", + "jackpot": "Jackpot:命中规则与后台奖池配置相关;爆池、蓄水和派彩以系统记录为准。", + "close": "封盘:到达封盘时间后大厅不可继续编辑或提交注单。", + "soldOut": "售罄:号码赔付池额度不足时,预览会提示风险,提交时额度不足会整单拒绝。" + } + }, + "footer": { + "config": "下注大厅的玩法列、启用状态、限额和赔率来自后台生效玩法配置。", + "phaseTwo": "5D / 6D 属于二期扩展,一期前后端均不提供可用入口。", + "backBet": "返回下注大厅" + } + }, "ticketStatus": { "success": "待开奖", + "pending_payout": "已中奖待派彩", "settled_win": "已派彩", "settled_lose": "未中奖", "unknown": "{{status}}"