diff --git a/src/app/(player)/(main)/orders/group/[groupKey]/page.tsx b/src/app/(player)/(main)/orders/group/[groupKey]/page.tsx new file mode 100644 index 0000000..2b6f02a --- /dev/null +++ b/src/app/(player)/(main)/orders/group/[groupKey]/page.tsx @@ -0,0 +1,10 @@ +import { TicketOrderGroupScreen } from "@/features/orders/ticket-order-group-screen"; + +type PageProps = { + params: Promise<{ groupKey: string }>; +}; + +export default async function OrderGroupPage({ params }: PageProps) { + const { groupKey } = await params; + return ; +} diff --git a/src/features/orders/group-ticket-items.ts b/src/features/orders/group-ticket-items.ts new file mode 100644 index 0000000..7b31093 --- /dev/null +++ b/src/features/orders/group-ticket-items.ts @@ -0,0 +1,131 @@ +import type { TicketItemListRow } from "@/types/api/ticket-items"; + +export type TicketItemGroup = { + key: string; + order_no: string | null; + draw_no: string | null; + placed_at: string | null; + currency_code: string | null; + status: string; + items: TicketItemListRow[]; + total_bet_amount: number; + actual_deduct_amount: number; + win_amount: number; + jackpot_win_amount: number; +}; + +export const ORDER_GROUP_STORAGE_PREFIX = "lottery:order-group:v1:"; + +/** 分组键:有 order_no 则按订单;否则 draw_no + placed_at + currency + status 兜底 */ +export function getTicketItemGroupKey(row: TicketItemListRow): string { + const orderNo = (row.order_no ?? "").trim(); + if (orderNo) { + return `order:${orderNo}`; + } + const drawNo = row.draw_no ?? ""; + const placedAt = row.placed_at ?? ""; + const currency = row.currency_code ?? ""; + const status = row.status ?? ""; + return `fallback:${drawNo}|${placedAt}|${currency}|${status}`; +} + +export function sortTicketGroupItems(items: TicketItemListRow[]): TicketItemListRow[] { + return [...items].sort((a, b) => { + const byPlay = a.play_code.localeCompare(b.play_code); + if (byPlay !== 0) return byPlay; + return (a.original_number ?? "").localeCompare(b.original_number ?? ""); + }); +} + +/** 保持 API 返回顺序下,按分组键首次出现顺序输出合并组 */ +export function groupTicketItems(items: TicketItemListRow[]): TicketItemGroup[] { + const map = new Map(); + const order: string[] = []; + + for (const row of items) { + const key = getTicketItemGroupKey(row); + let group = map.get(key); + if (!group) { + const orderNo = (row.order_no ?? "").trim(); + group = { + key, + order_no: orderNo || null, + draw_no: row.draw_no ?? null, + placed_at: row.placed_at ?? null, + currency_code: row.currency_code ?? null, + status: row.status, + items: [], + total_bet_amount: 0, + actual_deduct_amount: 0, + win_amount: 0, + jackpot_win_amount: 0, + }; + map.set(key, group); + order.push(key); + } + group.items.push(row); + group.total_bet_amount += row.total_bet_amount; + group.actual_deduct_amount += row.actual_deduct_amount; + group.win_amount += row.win_amount; + group.jackpot_win_amount += row.jackpot_win_amount; + } + + return order.map((key) => { + const group = map.get(key)!; + return { ...group, items: sortTicketGroupItems(group.items) }; + }); +} + +export function persistOrderGroup(group: TicketItemGroup): void { + try { + sessionStorage.setItem( + ORDER_GROUP_STORAGE_PREFIX + group.key, + JSON.stringify(group), + ); + } catch { + /* quota / private mode */ + } +} + +export function loadPersistedOrderGroup(groupKey: string): TicketItemGroup | null { + try { + const raw = sessionStorage.getItem(ORDER_GROUP_STORAGE_PREFIX + groupKey); + if (!raw) return null; + const parsed = JSON.parse(raw) as TicketItemGroup; + if (!parsed?.key || !Array.isArray(parsed.items)) return null; + return { ...parsed, items: sortTicketGroupItems(parsed.items) }; + } catch { + return null; + } +} + +export function orderGroupPath(groupKey: string, ticketNos?: string[]): string { + const base = `/orders/group/${encodeURIComponent(groupKey)}`; + if (!ticketNos?.length) return base; + return `${base}?tickets=${encodeURIComponent(ticketNos.join(","))}`; +} + +export function orderGroupHref(group: TicketItemGroup): string { + if (group.items.length === 1) { + return `/orders/${encodeURIComponent(group.items[0].ticket_no)}`; + } + return orderGroupPath( + group.key, + group.items.map((i) => i.ticket_no), + ); +} + +/** 注项详情;来自订单组时带上 fromGroup,便于返回订单详情 */ +export function ticketDetailHref( + ticketNo: string, + fromGroup?: TicketItemGroup | null, +): string { + const base = `/orders/${encodeURIComponent(ticketNo)}`; + const key = (fromGroup?.key ?? "").trim(); + if (!key) return base; + const params = new URLSearchParams({ fromGroup: key }); + if (fromGroup && fromGroup.items.length > 1) { + params.set("tickets", fromGroup.items.map((i) => i.ticket_no).join(",")); + } + return `${base}?${params.toString()}`; +} diff --git a/src/features/orders/order-meta-line.tsx b/src/features/orders/order-meta-line.tsx new file mode 100644 index 0000000..f21fe94 --- /dev/null +++ b/src/features/orders/order-meta-line.tsx @@ -0,0 +1,27 @@ +"use client"; + +import type { TFunction } from "i18next"; + +import { formatLotteryInstant } from "@/lib/player-datetime"; + +type OrderMetaLineProps = { + orderNo: string | null | undefined; + placedAt: string | null | undefined; + t: TFunction<"player">; + className?: string; +}; + +/** 订单号 + 下单时间(与 hall.result.orderNo 文案一致) */ +export function OrderMetaLine({ orderNo, placedAt, t, className }: OrderMetaLineProps) { + const trimmed = (orderNo ?? "").trim(); + const displayNo = trimmed || t("orders.noOrderNo"); + + return ( +

+ {t("orders.orderNoLabel")}{" "} + {displayNo} + · + {formatLotteryInstant(placedAt ?? null)} +

+ ); +} diff --git a/src/features/orders/ticket-item-status.tsx b/src/features/orders/ticket-item-status.tsx index 34c3b57..75ec375 100644 --- a/src/features/orders/ticket-item-status.tsx +++ b/src/features/orders/ticket-item-status.tsx @@ -19,13 +19,16 @@ export function ticketStatusDisplay( if (status === "settled_win" && total > 0) { return { label: t?.("ticketStatus.settled_win") ?? status, dotClass: "bg-emerald-500" }; } - if (status === "settled_lose" || status === "settled_win") { + if (status === "settled_lose" || (status === "settled_win" && total <= 0)) { return { label: t?.("ticketStatus.settled_lose") ?? status, dotClass: "bg-background", ring: true, }; } + if (status === "failed") { + return { label: t?.("ticketStatus.failed") ?? status, dotClass: "bg-red-500" }; + } return { label: t?.("ticketStatus.unknown", { status, defaultValue: status }) ?? status, dotClass: "bg-red-500", diff --git a/src/features/orders/ticket-order-detail-screen.tsx b/src/features/orders/ticket-order-detail-screen.tsx index bd70043..c10c737 100644 --- a/src/features/orders/ticket-order-detail-screen.tsx +++ b/src/features/orders/ticket-order-detail-screen.tsx @@ -1,7 +1,8 @@ "use client"; import Link from "next/link"; -import { useCallback, useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { getTicketItemDetail } from "@/api/ticket-items"; @@ -17,6 +18,7 @@ import { import { Skeleton } from "@/components/ui/skeleton"; import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid"; import { useCurrencyCatalog } from "@/hooks/use-currency-catalog"; +import { orderGroupPath } from "@/features/orders/group-ticket-items"; import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status"; import { formatLotteryInstant } from "@/lib/player-datetime"; import { formatMinorAsCurrency } from "@/lib/money"; @@ -67,6 +69,7 @@ type TicketItemDetailWithExtras = TicketItemDetailPayload & { /** 界面文档 §4.8 注单详情 */ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) { + const searchParams = useSearchParams(); const { t } = useTranslation("player"); const { activeCurrency } = useActivePlayerCurrency(); useCurrencyCatalog(); @@ -74,6 +77,27 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) { const [error, setError] = useState(null); const [loading, setLoading] = useState(true); + const fromGroupKey = useMemo( + () => (searchParams.get("fromGroup") ?? "").trim(), + [searchParams], + ); + const groupTickets = useMemo( + () => (searchParams.get("tickets") ?? "").trim(), + [searchParams], + ); + const backNav = useMemo(() => { + if (!fromGroupKey) { + return { href: "/orders", label: t("orders.title") }; + } + const ticketNos = groupTickets + ? groupTickets.split(",").map((s) => s.trim()).filter(Boolean) + : undefined; + return { + href: orderGroupPath(fromGroupKey, ticketNos), + label: t("orders.groupDetail"), + }; + }, [fromGroupKey, groupTickets, t]); + const load = useCallback(async () => { setLoading(true); setError(null); @@ -98,8 +122,8 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) { return (
@@ -113,8 +137,8 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) { return (

{error ?? t("orders.noData")}

@@ -174,8 +198,8 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) { return (
@@ -368,12 +392,12 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) { ) : null} - {t("orders.backToOrders")} + {fromGroupKey ? t("orders.backToGroup") : t("orders.backToOrders")}
diff --git a/src/features/orders/ticket-order-group-screen.tsx b/src/features/orders/ticket-order-group-screen.tsx new file mode 100644 index 0000000..687886e --- /dev/null +++ b/src/features/orders/ticket-order-group-screen.tsx @@ -0,0 +1,167 @@ +"use client"; + +import Link from "next/link"; +import { useMemo } from "react"; +import { ChevronRight } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { PlayerPanel } from "@/components/layout/player-panel"; +import { OrderMetaLine } from "@/features/orders/order-meta-line"; +import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status"; +import { + loadPersistedOrderGroup, + ticketDetailHref, + type TicketItemGroup, +} from "@/features/orders/group-ticket-items"; +import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; +import { useCurrencyCatalog } from "@/hooks/use-currency-catalog"; +import { formatMinorAsCurrency } from "@/lib/money"; +import { playLabel } from "@/lib/play-labels"; +import { cn } from "@/lib/utils"; + +type TicketOrderGroupScreenProps = { + groupKey: string; +}; + +export function TicketOrderGroupScreen({ groupKey }: TicketOrderGroupScreenProps) { + const { t } = useTranslation("player"); + const { activeCurrency } = useActivePlayerCurrency(); + useCurrencyCatalog(); + + const decodedKey = decodeURIComponent(groupKey); + + const group = useMemo( + (): TicketItemGroup | null => loadPersistedOrderGroup(decodedKey), + [decodedKey], + ); + + if (!group) { + return ( + +
+

{t("orders.groupNotFound")}

+ + {t("orders.backToOrders")} + +
+
+ ); + } + + const cur = group.currency_code ?? activeCurrency; + const st = ticketStatusDisplay( + group.status, + group.win_amount, + group.jackpot_win_amount, + t, + ); + const totalWin = group.win_amount + group.jackpot_win_amount; + return ( + +
+
+
+

+ {group.draw_no ?? "—"} +

+ +
+ +

+ {t("orders.itemCount", { count: group.items.length })} +

+
+
+

{t("orders.stake")}

+

+ {formatMinorAsCurrency(group.total_bet_amount, cur)} +

+
+
+

{t("orders.deduction")}

+

+ {formatMinorAsCurrency(group.actual_deduct_amount, cur)} +

+
+
+ {totalWin > 0 && group.status === "settled_win" ? ( +

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

+ ) : null} +
+ +

{t("orders.betItems")}

+
+ {group.items.map((row, index) => { + const lineCur = row.currency_code ?? cur; + const lineSt = ticketStatusDisplay( + row.status, + row.win_amount, + row.jackpot_win_amount, + t, + ); + const lineWin = row.win_amount + row.jackpot_win_amount; + return ( + + + {index + 1} + +
+

+ {playLabel(row.play_code, t)} · {row.original_number ?? row.play_code} +

+

+ {t("orders.ticketNo", { ticketNo: row.ticket_no })} +

+
+ + {t("orders.stake")}{" "} + + {formatMinorAsCurrency(row.total_bet_amount, lineCur)} + + + + {t("orders.deduction")}{" "} + + {formatMinorAsCurrency(row.actual_deduct_amount, lineCur)} + + +
+ {lineWin > 0 && row.status === "settled_win" ? ( +

+ {t("orders.win", { amount: formatMinorAsCurrency(lineWin, lineCur) })} +

+ ) : null} +
+
+ + +
+ + ); + })} +
+
+
+ ); +} diff --git a/src/features/orders/ticket-orders-list-screen.tsx b/src/features/orders/ticket-orders-list-screen.tsx index 3c55b85..532efcc 100644 --- a/src/features/orders/ticket-orders-list-screen.tsx +++ b/src/features/orders/ticket-orders-list-screen.tsx @@ -14,11 +14,16 @@ import { Input } from "@/components/ui/input"; import { PlayerPanel } from "@/components/layout/player-panel"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Skeleton } from "@/components/ui/skeleton"; +import { + groupTicketItems, + orderGroupHref, + persistOrderGroup, +} from "@/features/orders/group-ticket-items"; +import { 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 { formatMinorAsCurrency } from "@/lib/money"; -import { formatLotteryInstant } from "@/lib/player-datetime"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { playLabel } from "@/lib/play-labels"; import { cn } from "@/lib/utils"; @@ -92,6 +97,7 @@ export function TicketOrdersListScreen() { return `${fromDate ? formatCompactDate(fromDate) : "..." } ~ ${toDate ? formatCompactDate(toDate) : "..."}`; }, [formatCompactDate, fromDate, t, toDate]); const visiblePages = useMemo(() => buildPageWindow(page, lastPage), [lastPage, page]); + const orderGroups = useMemo(() => groupTicketItems(items), [items]); const fetchPage = useCallback( async (nextPage: number, append: boolean) => { @@ -354,49 +360,62 @@ export function TicketOrdersListScreen() { ) : ( <>
- {items.map((row) => { - const cur = row.currency_code ?? activeCurrency; - const st = ticketStatusDisplay(row.status, row.win_amount, row.jackpot_win_amount, t); - const totalWin = row.win_amount + row.jackpot_win_amount; + {orderGroups.map((group) => { + const cur = group.currency_code ?? activeCurrency; + const st = ticketStatusDisplay( + group.status, + group.win_amount, + group.jackpot_win_amount, + t, + ); + const totalWin = group.win_amount + group.jackpot_win_amount; return ( persistOrderGroup(group)} className="block rounded-xl border border-[#e5edf8] bg-white p-3 shadow-[0_8px_24px_rgba(15,23,42,0.05)] transition-colors hover:border-[#b9ccf6]" >
-
-

- {row.draw_no ?? "—"} -

-

+

+ {group.draw_no ?? "—"} +

+ +
+ +
+ {group.items.map((row) => ( +

{playLabel(row.play_code, t)} · {row.original_number ?? row.play_code}

-
- + ))}

{t("orders.stake")}

- {formatMinorAsCurrency(row.total_bet_amount, cur)} + {formatMinorAsCurrency(group.total_bet_amount, cur)}

{t("orders.deduction")}

- {formatMinorAsCurrency(row.actual_deduct_amount, cur)} + {formatMinorAsCurrency(group.actual_deduct_amount, cur)}

- {totalWin > 0 && row.status === "settled_win" ? ( + {totalWin > 0 && group.status === "settled_win" ? (

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

) : null} -

- {formatLotteryInstant(row.placed_at ?? null)} -

); })} diff --git a/src/i18n/locales/en/player.json b/src/i18n/locales/en/player.json index 31f21cd..3cedf18 100644 --- a/src/i18n/locales/en/player.json +++ b/src/i18n/locales/en/player.json @@ -396,6 +396,14 @@ "detailTitle": "Ticket detail", "ticketNo": "Ticket {{ticketNo}}", "orderNo": "Order {{orderNo}}", + "orderNoLabel": "Order No.", + "orderNoAt": "Order No. {{orderNo}} · {{time}}", + "noOrderNo": "—", + "groupDetail": "Order detail", + "groupNotFound": "Could not load this order. Please open it again from My Bets.", + "betItems": "Bet lines", + "itemCount": "{{count}} bet line(s)", + "viewBetLine": "View bet line detail", "drawNo": "Issue", "placedAt": "Placed at", "number": "Number", @@ -421,6 +429,7 @@ "settledAt": "Settled at {{time}}", "viewDraw": "View this draw", "backToOrders": "Back to My Bets", + "backToGroup": "Back to order detail", "notFound": "Ticket does not exist or cannot be viewed.", "noData": "No data", "loadFailed": "Failed to load" @@ -555,6 +564,7 @@ "pending_payout": "Won, pending payout", "settled_win": "Paid", "settled_lose": "Not won", + "failed": "Failed", "unknown": "{{status}}" }, "prizeTier": { diff --git a/src/i18n/locales/ne/player.json b/src/i18n/locales/ne/player.json index 261d677..f5e4588 100644 --- a/src/i18n/locales/ne/player.json +++ b/src/i18n/locales/ne/player.json @@ -396,6 +396,14 @@ "detailTitle": "टिकट विवरण", "ticketNo": "टिकट {{ticketNo}}", "orderNo": "अर्डर {{orderNo}}", + "orderNoLabel": "अर्डर नं.", + "orderNoAt": "अर्डर नं. {{orderNo}} · {{time}}", + "noOrderNo": "—", + "groupDetail": "अर्डर विवरण", + "groupNotFound": "यो अर्डर लोड हुन सकेन। कृपया मेरा बेटबाट फेरि खोल्नुहोस्।", + "betItems": "बेट लाइन विवरण", + "itemCount": "जम्मा {{count}} बेट लाइन", + "viewBetLine": "बेट लाइन विवरण हेर्नुहोस्", "drawNo": "इश्यू", "placedAt": "राखेको समय", "number": "नम्बर", @@ -413,9 +421,15 @@ "jackpotAmount": "Jackpot {{amount}}", "payoutTotal": "कुल भुक्तानी {{amount}}", "matchLose": "मिलान नतिजा: जितेन", + "matchResult": "मिलान नतिजा", + "drawPendingMatch": "यस इश्यूका ड्र नम्बर प्रकाशित भएका छैनन्। जित-नजित अझै निर्धारण गर्न मिल्दैन।", + "matchPendingDraw": "ड्र पर्खँदैछ। जित-नजित अझै निर्धारण गर्न मिल्दैन।", + "matchPendingSettlement": "ड्र नतिजा प्रकाशित भयो। सेटल पछि जित-नजित देखाइनेछ।", + "timeline": "समयरेखा", "settledAt": "सेटल समय {{time}}", "viewDraw": "यो ड्र हेर्नुहोस्", "backToOrders": "मेरा बेटमा फर्कनुहोस्", + "backToGroup": "अर्डर विवरणमा फर्कनुहोस्", "notFound": "टिकट छैन वा हेर्न अनुमति छैन।", "noData": "डेटा छैन", "loadFailed": "लोड असफल" @@ -550,6 +564,7 @@ "pending_payout": "जितेको, भुक्तानी बाँकी", "settled_win": "भुक्तानी भयो", "settled_lose": "जितेन", + "failed": "असफल", "unknown": "{{status}}" }, "prizeTier": { diff --git a/src/i18n/locales/zh/player.json b/src/i18n/locales/zh/player.json index 8e2ca2b..1dd229a 100644 --- a/src/i18n/locales/zh/player.json +++ b/src/i18n/locales/zh/player.json @@ -396,6 +396,14 @@ "detailTitle": "注单详情", "ticketNo": "注单号 {{ticketNo}}", "orderNo": "订单 {{orderNo}}", + "orderNoLabel": "订单号", + "orderNoAt": "订单号 {{orderNo}} · {{time}}", + "noOrderNo": "—", + "groupDetail": "订单详情", + "groupNotFound": "无法加载该订单,请从注单列表重新进入。", + "betItems": "注项明细", + "itemCount": "共 {{count}} 条注项", + "viewBetLine": "查看注项详情", "drawNo": "期号", "placedAt": "下单时间", "number": "号码", @@ -421,6 +429,7 @@ "settledAt": "结算时间 {{time}}", "viewDraw": "查看本期开奖", "backToOrders": "返回我的注单", + "backToGroup": "返回订单详情", "notFound": "注单不存在或无权查看", "noData": "无数据", "loadFailed": "加载失败" @@ -555,6 +564,7 @@ "pending_payout": "已中奖待派彩", "settled_win": "已派彩", "settled_lose": "未中奖", + "failed": "失败", "unknown": "{{status}}" }, "prizeTier": {