feat: enhance ticket order detail and status display
- Updated ticket status display logic to handle "failed" status and improve "settled_win" condition. - Refactored TicketOrderDetailScreen to utilize search parameters for dynamic navigation and grouping of tickets. - Enhanced TicketOrdersListScreen to group ticket items and improve rendering of order details. - Added new translations for order-related terms in multiple languages to support enhanced user experience.
This commit is contained in:
10
src/app/(player)/(main)/orders/group/[groupKey]/page.tsx
Normal file
10
src/app/(player)/(main)/orders/group/[groupKey]/page.tsx
Normal file
@@ -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 <TicketOrderGroupScreen groupKey={groupKey} />;
|
||||||
|
}
|
||||||
131
src/features/orders/group-ticket-items.ts
Normal file
131
src/features/orders/group-ticket-items.ts
Normal file
@@ -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<string, TicketItemGroup>();
|
||||||
|
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()}`;
|
||||||
|
}
|
||||||
27
src/features/orders/order-meta-line.tsx
Normal file
27
src/features/orders/order-meta-line.tsx
Normal file
@@ -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 (
|
||||||
|
<p className={className ?? "mt-1 truncate text-xs text-slate-500"}>
|
||||||
|
<span>{t("orders.orderNoLabel")}</span>{" "}
|
||||||
|
<span className="font-mono font-semibold text-slate-600">{displayNo}</span>
|
||||||
|
<span aria-hidden> · </span>
|
||||||
|
<span>{formatLotteryInstant(placedAt ?? null)}</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,13 +19,16 @@ export function ticketStatusDisplay(
|
|||||||
if (status === "settled_win" && total > 0) {
|
if (status === "settled_win" && total > 0) {
|
||||||
return { label: t?.("ticketStatus.settled_win") ?? status, dotClass: "bg-emerald-500" };
|
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 {
|
return {
|
||||||
label: t?.("ticketStatus.settled_lose") ?? status,
|
label: t?.("ticketStatus.settled_lose") ?? status,
|
||||||
dotClass: "bg-background",
|
dotClass: "bg-background",
|
||||||
ring: true,
|
ring: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (status === "failed") {
|
||||||
|
return { label: t?.("ticketStatus.failed") ?? status, dotClass: "bg-red-500" };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
label: t?.("ticketStatus.unknown", { status, defaultValue: status }) ?? status,
|
label: t?.("ticketStatus.unknown", { status, defaultValue: status }) ?? status,
|
||||||
dotClass: "bg-red-500",
|
dotClass: "bg-red-500",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
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 { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { getTicketItemDetail } from "@/api/ticket-items";
|
import { getTicketItemDetail } from "@/api/ticket-items";
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
|
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
|
||||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||||
|
import { orderGroupPath } from "@/features/orders/group-ticket-items";
|
||||||
import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
|
import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
|
||||||
import { formatLotteryInstant } from "@/lib/player-datetime";
|
import { formatLotteryInstant } from "@/lib/player-datetime";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
@@ -67,6 +69,7 @@ type TicketItemDetailWithExtras = TicketItemDetailPayload & {
|
|||||||
|
|
||||||
/** 界面文档 §4.8 注单详情 */
|
/** 界面文档 §4.8 注单详情 */
|
||||||
export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
const { activeCurrency } = useActivePlayerCurrency();
|
const { activeCurrency } = useActivePlayerCurrency();
|
||||||
useCurrencyCatalog();
|
useCurrencyCatalog();
|
||||||
@@ -74,6 +77,27 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const 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 () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -98,8 +122,8 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
|||||||
return (
|
return (
|
||||||
<PlayerPanel
|
<PlayerPanel
|
||||||
title={t("orders.betDetail")}
|
title={t("orders.betDetail")}
|
||||||
backHref="/orders"
|
backHref={backNav.href}
|
||||||
backLabel={t("orders.title")}
|
backLabel={backNav.label}
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Skeleton className="h-12 rounded-xl" />
|
<Skeleton className="h-12 rounded-xl" />
|
||||||
@@ -113,8 +137,8 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
|||||||
return (
|
return (
|
||||||
<PlayerPanel
|
<PlayerPanel
|
||||||
title={t("orders.betDetail")}
|
title={t("orders.betDetail")}
|
||||||
backHref="/orders"
|
backHref={backNav.href}
|
||||||
backLabel={t("orders.title")}
|
backLabel={backNav.label}
|
||||||
>
|
>
|
||||||
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
|
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
|
||||||
<p>{error ?? t("orders.noData")}</p>
|
<p>{error ?? t("orders.noData")}</p>
|
||||||
@@ -174,8 +198,8 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
|||||||
return (
|
return (
|
||||||
<PlayerPanel
|
<PlayerPanel
|
||||||
title={t("orders.betDetail")}
|
title={t("orders.betDetail")}
|
||||||
backHref="/orders"
|
backHref={backNav.href}
|
||||||
backLabel={t("orders.title")}
|
backLabel={backNav.label}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Card className="ring-0 border border-[#e8eef7] bg-white shadow-[0_8px_28px_rgba(15,23,42,0.05)]">
|
<Card className="ring-0 border border-[#e8eef7] bg-white shadow-[0_8px_28px_rgba(15,23,42,0.05)]">
|
||||||
@@ -368,12 +392,12 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
|||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
<Link
|
<Link
|
||||||
href="/orders"
|
href={backNav.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-11 min-w-[140px] flex-1 items-center justify-center rounded-xl border border-[#dce7f7] bg-white px-4 text-sm font-semibold text-[#07459f] transition-colors hover:bg-[#f1f6ff]",
|
"inline-flex h-11 min-w-[140px] flex-1 items-center justify-center rounded-xl border border-[#dce7f7] bg-white px-4 text-sm font-semibold text-[#07459f] transition-colors hover:bg-[#f1f6ff]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{t("orders.backToOrders")}
|
{fromGroupKey ? t("orders.backToGroup") : t("orders.backToOrders")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
167
src/features/orders/ticket-order-group-screen.tsx
Normal file
167
src/features/orders/ticket-order-group-screen.tsx
Normal file
@@ -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 (
|
||||||
|
<PlayerPanel
|
||||||
|
title={t("orders.groupDetail")}
|
||||||
|
backHref="/orders"
|
||||||
|
backLabel={t("orders.title")}
|
||||||
|
>
|
||||||
|
<div className="rounded-xl border border-dashed border-[#dce7f7] bg-[#f8fbff] px-3 py-8 text-center">
|
||||||
|
<p className="text-sm font-bold text-slate-700">{t("orders.groupNotFound")}</p>
|
||||||
|
<Link
|
||||||
|
href="/orders"
|
||||||
|
className="mt-4 inline-flex h-9 items-center rounded-lg bg-[#07459f] px-4 text-sm font-bold text-white"
|
||||||
|
>
|
||||||
|
{t("orders.backToOrders")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</PlayerPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cur = group.currency_code ?? activeCurrency;
|
||||||
|
const st = ticketStatusDisplay(
|
||||||
|
group.status,
|
||||||
|
group.win_amount,
|
||||||
|
group.jackpot_win_amount,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
const totalWin = group.win_amount + group.jackpot_win_amount;
|
||||||
|
return (
|
||||||
|
<PlayerPanel
|
||||||
|
title={t("orders.groupDetail")}
|
||||||
|
backHref="/orders"
|
||||||
|
backLabel={t("orders.title")}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-xl border border-[#e5edf8] bg-white p-3 shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<p className="truncate font-mono text-lg font-black text-[#0b3f96]">
|
||||||
|
{group.draw_no ?? "—"}
|
||||||
|
</p>
|
||||||
|
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
||||||
|
</div>
|
||||||
|
<OrderMetaLine orderNo={group.order_no} placedAt={group.placed_at} t={t} />
|
||||||
|
<p className="mt-2 text-[11px] font-bold uppercase tracking-wide text-[#7890b8]">
|
||||||
|
{t("orders.itemCount", { count: group.items.length })}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
|
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
||||||
|
<p className="text-[10px] 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-[10px] 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>
|
||||||
|
{totalWin > 0 && group.status === "settled_win" ? (
|
||||||
|
<p className="mt-2 text-xs font-bold text-emerald-600">
|
||||||
|
{t("orders.win", { amount: formatMinorAsCurrency(totalWin, cur) })}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="px-0.5 text-sm font-black text-[#0b3f96]">{t("orders.betItems")}</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{group.items.map((row, index) => {
|
||||||
|
const lineCur = row.currency_code ?? cur;
|
||||||
|
const lineSt = ticketStatusDisplay(
|
||||||
|
row.status,
|
||||||
|
row.win_amount,
|
||||||
|
row.jackpot_win_amount,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
const lineWin = row.win_amount + row.jackpot_win_amount;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={row.ticket_no}
|
||||||
|
href={ticketDetailHref(row.ticket_no, group)}
|
||||||
|
aria-label={t("orders.viewBetLine")}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-xl border border-[#e5edf8] bg-white px-3 py-3",
|
||||||
|
"shadow-[0_6px_18px_rgba(15,23,42,0.04)] transition-colors hover:border-[#b9ccf6]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[#eaf2ff] text-xs font-black text-[#0b56b7]">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-black text-[#0b3f96]">
|
||||||
|
{playLabel(row.play_code, t)} · {row.original_number ?? row.play_code}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 truncate font-mono text-[11px] text-slate-500">
|
||||||
|
{t("orders.ticketNo", { ticketNo: row.ticket_no })}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-slate-600">
|
||||||
|
<span>
|
||||||
|
{t("orders.stake")}{" "}
|
||||||
|
<span className="font-bold tabular-nums text-slate-900">
|
||||||
|
{formatMinorAsCurrency(row.total_bet_amount, lineCur)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t("orders.deduction")}{" "}
|
||||||
|
<span className="font-bold tabular-nums text-[#0b3f96]">
|
||||||
|
{formatMinorAsCurrency(row.actual_deduct_amount, lineCur)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{lineWin > 0 && row.status === "settled_win" ? (
|
||||||
|
<p className="mt-1 text-xs font-bold text-emerald-600">
|
||||||
|
{t("orders.win", { amount: formatMinorAsCurrency(lineWin, lineCur) })}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||||
|
<StatusDot label={lineSt.label} dotClass={lineSt.dotClass} ring={lineSt.ring} />
|
||||||
|
<ChevronRight className="size-4 text-[#7890b8]" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PlayerPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,11 +14,16 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
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 { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
|
||||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
import { formatLotteryInstant } from "@/lib/player-datetime";
|
|
||||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
import { playLabel } from "@/lib/play-labels";
|
import { playLabel } from "@/lib/play-labels";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -92,6 +97,7 @@ export function TicketOrdersListScreen() {
|
|||||||
return `${fromDate ? formatCompactDate(fromDate) : "..." } ~ ${toDate ? formatCompactDate(toDate) : "..."}`;
|
return `${fromDate ? formatCompactDate(fromDate) : "..." } ~ ${toDate ? formatCompactDate(toDate) : "..."}`;
|
||||||
}, [formatCompactDate, fromDate, t, toDate]);
|
}, [formatCompactDate, fromDate, t, toDate]);
|
||||||
const visiblePages = useMemo(() => buildPageWindow(page, lastPage), [lastPage, page]);
|
const visiblePages = useMemo(() => buildPageWindow(page, lastPage), [lastPage, page]);
|
||||||
|
const orderGroups = useMemo(() => groupTicketItems(items), [items]);
|
||||||
|
|
||||||
const fetchPage = useCallback(
|
const fetchPage = useCallback(
|
||||||
async (nextPage: number, append: boolean) => {
|
async (nextPage: number, append: boolean) => {
|
||||||
@@ -354,49 +360,62 @@ export function TicketOrdersListScreen() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{items.map((row) => {
|
{orderGroups.map((group) => {
|
||||||
const cur = row.currency_code ?? activeCurrency;
|
const cur = group.currency_code ?? activeCurrency;
|
||||||
const st = ticketStatusDisplay(row.status, row.win_amount, row.jackpot_win_amount, t);
|
const st = ticketStatusDisplay(
|
||||||
const totalWin = row.win_amount + row.jackpot_win_amount;
|
group.status,
|
||||||
|
group.win_amount,
|
||||||
|
group.jackpot_win_amount,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
const totalWin = group.win_amount + group.jackpot_win_amount;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={row.ticket_no}
|
key={group.key}
|
||||||
href={`/orders/${encodeURIComponent(row.ticket_no)}`}
|
href={orderGroupHref(group)}
|
||||||
|
onClick={() => persistOrderGroup(group)}
|
||||||
className="block rounded-xl border border-[#e5edf8] bg-white p-3 shadow-[0_8px_24px_rgba(15,23,42,0.05)] transition-colors hover:border-[#b9ccf6]"
|
className="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="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<p className="min-w-0 truncate font-mono text-sm font-black text-[#0b3f96]">
|
||||||
<p className="truncate font-mono text-sm font-black text-[#0b3f96]">
|
{group.draw_no ?? "—"}
|
||||||
{row.draw_no ?? "—"}
|
</p>
|
||||||
</p>
|
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
||||||
<p className="mt-1 truncate text-xs text-slate-500">
|
</div>
|
||||||
|
<OrderMetaLine
|
||||||
|
orderNo={group.order_no}
|
||||||
|
placedAt={group.placed_at}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<div className="mt-2.5 space-y-1">
|
||||||
|
{group.items.map((row) => (
|
||||||
|
<p
|
||||||
|
key={row.ticket_no}
|
||||||
|
className="text-sm font-semibold text-[#32518d]"
|
||||||
|
>
|
||||||
{playLabel(row.play_code, t)} · {row.original_number ?? row.play_code}
|
{playLabel(row.play_code, t)} · {row.original_number ?? row.play_code}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
))}
|
||||||
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
||||||
<p className="text-[10px] font-bold uppercase text-[#7890b8]">{t("orders.stake")}</p>
|
<p className="text-[10px] font-bold uppercase text-[#7890b8]">{t("orders.stake")}</p>
|
||||||
<p className="mt-1 text-sm font-black text-slate-900">
|
<p className="mt-1 text-sm font-black text-slate-900">
|
||||||
{formatMinorAsCurrency(row.total_bet_amount, cur)}
|
{formatMinorAsCurrency(group.total_bet_amount, cur)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
||||||
<p className="text-[10px] font-bold uppercase text-[#7890b8]">{t("orders.deduction")}</p>
|
<p className="text-[10px] font-bold uppercase text-[#7890b8]">{t("orders.deduction")}</p>
|
||||||
<p className="mt-1 text-sm font-black text-[#0b3f96]">
|
<p className="mt-1 text-sm font-black text-[#0b3f96]">
|
||||||
{formatMinorAsCurrency(row.actual_deduct_amount, cur)}
|
{formatMinorAsCurrency(group.actual_deduct_amount, cur)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{totalWin > 0 && row.status === "settled_win" ? (
|
{totalWin > 0 && group.status === "settled_win" ? (
|
||||||
<p className="mt-2 text-xs font-bold text-emerald-600">
|
<p className="mt-2 text-xs font-bold text-emerald-600">
|
||||||
{t("orders.win", { amount: formatMinorAsCurrency(totalWin, cur) })}
|
{t("orders.win", { amount: formatMinorAsCurrency(totalWin, cur) })}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="mt-2 text-[11px] text-slate-500">
|
|
||||||
{formatLotteryInstant(row.placed_at ?? null)}
|
|
||||||
</p>
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -396,6 +396,14 @@
|
|||||||
"detailTitle": "Ticket detail",
|
"detailTitle": "Ticket detail",
|
||||||
"ticketNo": "Ticket {{ticketNo}}",
|
"ticketNo": "Ticket {{ticketNo}}",
|
||||||
"orderNo": "Order {{orderNo}}",
|
"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",
|
"drawNo": "Issue",
|
||||||
"placedAt": "Placed at",
|
"placedAt": "Placed at",
|
||||||
"number": "Number",
|
"number": "Number",
|
||||||
@@ -421,6 +429,7 @@
|
|||||||
"settledAt": "Settled at {{time}}",
|
"settledAt": "Settled at {{time}}",
|
||||||
"viewDraw": "View this draw",
|
"viewDraw": "View this draw",
|
||||||
"backToOrders": "Back to My Bets",
|
"backToOrders": "Back to My Bets",
|
||||||
|
"backToGroup": "Back to order detail",
|
||||||
"notFound": "Ticket does not exist or cannot be viewed.",
|
"notFound": "Ticket does not exist or cannot be viewed.",
|
||||||
"noData": "No data",
|
"noData": "No data",
|
||||||
"loadFailed": "Failed to load"
|
"loadFailed": "Failed to load"
|
||||||
@@ -555,6 +564,7 @@
|
|||||||
"pending_payout": "Won, pending payout",
|
"pending_payout": "Won, pending payout",
|
||||||
"settled_win": "Paid",
|
"settled_win": "Paid",
|
||||||
"settled_lose": "Not won",
|
"settled_lose": "Not won",
|
||||||
|
"failed": "Failed",
|
||||||
"unknown": "{{status}}"
|
"unknown": "{{status}}"
|
||||||
},
|
},
|
||||||
"prizeTier": {
|
"prizeTier": {
|
||||||
|
|||||||
@@ -396,6 +396,14 @@
|
|||||||
"detailTitle": "टिकट विवरण",
|
"detailTitle": "टिकट विवरण",
|
||||||
"ticketNo": "टिकट {{ticketNo}}",
|
"ticketNo": "टिकट {{ticketNo}}",
|
||||||
"orderNo": "अर्डर {{orderNo}}",
|
"orderNo": "अर्डर {{orderNo}}",
|
||||||
|
"orderNoLabel": "अर्डर नं.",
|
||||||
|
"orderNoAt": "अर्डर नं. {{orderNo}} · {{time}}",
|
||||||
|
"noOrderNo": "—",
|
||||||
|
"groupDetail": "अर्डर विवरण",
|
||||||
|
"groupNotFound": "यो अर्डर लोड हुन सकेन। कृपया मेरा बेटबाट फेरि खोल्नुहोस्।",
|
||||||
|
"betItems": "बेट लाइन विवरण",
|
||||||
|
"itemCount": "जम्मा {{count}} बेट लाइन",
|
||||||
|
"viewBetLine": "बेट लाइन विवरण हेर्नुहोस्",
|
||||||
"drawNo": "इश्यू",
|
"drawNo": "इश्यू",
|
||||||
"placedAt": "राखेको समय",
|
"placedAt": "राखेको समय",
|
||||||
"number": "नम्बर",
|
"number": "नम्बर",
|
||||||
@@ -413,9 +421,15 @@
|
|||||||
"jackpotAmount": "Jackpot {{amount}}",
|
"jackpotAmount": "Jackpot {{amount}}",
|
||||||
"payoutTotal": "कुल भुक्तानी {{amount}}",
|
"payoutTotal": "कुल भुक्तानी {{amount}}",
|
||||||
"matchLose": "मिलान नतिजा: जितेन",
|
"matchLose": "मिलान नतिजा: जितेन",
|
||||||
|
"matchResult": "मिलान नतिजा",
|
||||||
|
"drawPendingMatch": "यस इश्यूका ड्र नम्बर प्रकाशित भएका छैनन्। जित-नजित अझै निर्धारण गर्न मिल्दैन।",
|
||||||
|
"matchPendingDraw": "ड्र पर्खँदैछ। जित-नजित अझै निर्धारण गर्न मिल्दैन।",
|
||||||
|
"matchPendingSettlement": "ड्र नतिजा प्रकाशित भयो। सेटल पछि जित-नजित देखाइनेछ।",
|
||||||
|
"timeline": "समयरेखा",
|
||||||
"settledAt": "सेटल समय {{time}}",
|
"settledAt": "सेटल समय {{time}}",
|
||||||
"viewDraw": "यो ड्र हेर्नुहोस्",
|
"viewDraw": "यो ड्र हेर्नुहोस्",
|
||||||
"backToOrders": "मेरा बेटमा फर्कनुहोस्",
|
"backToOrders": "मेरा बेटमा फर्कनुहोस्",
|
||||||
|
"backToGroup": "अर्डर विवरणमा फर्कनुहोस्",
|
||||||
"notFound": "टिकट छैन वा हेर्न अनुमति छैन।",
|
"notFound": "टिकट छैन वा हेर्न अनुमति छैन।",
|
||||||
"noData": "डेटा छैन",
|
"noData": "डेटा छैन",
|
||||||
"loadFailed": "लोड असफल"
|
"loadFailed": "लोड असफल"
|
||||||
@@ -550,6 +564,7 @@
|
|||||||
"pending_payout": "जितेको, भुक्तानी बाँकी",
|
"pending_payout": "जितेको, भुक्तानी बाँकी",
|
||||||
"settled_win": "भुक्तानी भयो",
|
"settled_win": "भुक्तानी भयो",
|
||||||
"settled_lose": "जितेन",
|
"settled_lose": "जितेन",
|
||||||
|
"failed": "असफल",
|
||||||
"unknown": "{{status}}"
|
"unknown": "{{status}}"
|
||||||
},
|
},
|
||||||
"prizeTier": {
|
"prizeTier": {
|
||||||
|
|||||||
@@ -396,6 +396,14 @@
|
|||||||
"detailTitle": "注单详情",
|
"detailTitle": "注单详情",
|
||||||
"ticketNo": "注单号 {{ticketNo}}",
|
"ticketNo": "注单号 {{ticketNo}}",
|
||||||
"orderNo": "订单 {{orderNo}}",
|
"orderNo": "订单 {{orderNo}}",
|
||||||
|
"orderNoLabel": "订单号",
|
||||||
|
"orderNoAt": "订单号 {{orderNo}} · {{time}}",
|
||||||
|
"noOrderNo": "—",
|
||||||
|
"groupDetail": "订单详情",
|
||||||
|
"groupNotFound": "无法加载该订单,请从注单列表重新进入。",
|
||||||
|
"betItems": "注项明细",
|
||||||
|
"itemCount": "共 {{count}} 条注项",
|
||||||
|
"viewBetLine": "查看注项详情",
|
||||||
"drawNo": "期号",
|
"drawNo": "期号",
|
||||||
"placedAt": "下单时间",
|
"placedAt": "下单时间",
|
||||||
"number": "号码",
|
"number": "号码",
|
||||||
@@ -421,6 +429,7 @@
|
|||||||
"settledAt": "结算时间 {{time}}",
|
"settledAt": "结算时间 {{time}}",
|
||||||
"viewDraw": "查看本期开奖",
|
"viewDraw": "查看本期开奖",
|
||||||
"backToOrders": "返回我的注单",
|
"backToOrders": "返回我的注单",
|
||||||
|
"backToGroup": "返回订单详情",
|
||||||
"notFound": "注单不存在或无权查看",
|
"notFound": "注单不存在或无权查看",
|
||||||
"noData": "无数据",
|
"noData": "无数据",
|
||||||
"loadFailed": "加载失败"
|
"loadFailed": "加载失败"
|
||||||
@@ -555,6 +564,7 @@
|
|||||||
"pending_payout": "已中奖待派彩",
|
"pending_payout": "已中奖待派彩",
|
||||||
"settled_win": "已派彩",
|
"settled_win": "已派彩",
|
||||||
"settled_lose": "未中奖",
|
"settled_lose": "未中奖",
|
||||||
|
"failed": "失败",
|
||||||
"unknown": "{{status}}"
|
"unknown": "{{status}}"
|
||||||
},
|
},
|
||||||
"prizeTier": {
|
"prizeTier": {
|
||||||
|
|||||||
Reference in New Issue
Block a user