Some checks failed
lotteryfront CI / build (push) Has been cancelled
- Introduced D4 play groups (big/small, position, combo, roll/straight, head/tail/odd/even, digit big/small) with tab-based filtering to improve betting grid usability - Updated PlayerPanel header grid layout from percentage-based to auto/1fr/auto columns with increased gap for better responsiveness - Removed "placed" status from ticket orders list filter options - Added D4 group translations in English, Nepali, and Chinese - Refactored play columns
569 lines
23 KiB
TypeScript
569 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useSearchParams } from "next/navigation";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { CalendarRange, Check, ChevronDown, Search } from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import { getDrawCurrent } from "@/api/draw";
|
|
import { getTicketItems } from "@/api/ticket-items";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Calendar } from "@/components/ui/calendar";
|
|
import { Input } from "@/components/ui/input";
|
|
import { PlayerPanel } from "@/components/layout/player-panel";
|
|
import { 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 { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone";
|
|
import { formatMinorAsCurrency } from "@/lib/money";
|
|
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
|
import {
|
|
formatSchedulePickerYmd,
|
|
getTimeZoneShortLabel,
|
|
parseSchedulePickerYmd,
|
|
scheduleTodayYmd,
|
|
} from "@/lib/player-datetime";
|
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
|
import { playLabel } from "@/lib/play-labels";
|
|
import { cn } from "@/lib/utils";
|
|
import type { TicketItemListRow } from "@/types/api/ticket-items";
|
|
|
|
const ORDERS_PAGE_SIZE = 20;
|
|
const STATUS_OPTIONS = [
|
|
"pending_confirm",
|
|
"partial_pending_confirm",
|
|
"pending_draw",
|
|
"partial_failed",
|
|
"pending_payout",
|
|
"settled_win",
|
|
"settled_lose",
|
|
"failed",
|
|
"refunded",
|
|
] as const;
|
|
|
|
export function TicketOrdersListScreen() {
|
|
const searchParams = useSearchParams();
|
|
const { t } = useTranslation("player");
|
|
const { activeCurrency } = useActivePlayerCurrency();
|
|
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
|
|
useCurrencyCatalog();
|
|
const drawNoFilter = useMemo(() => (searchParams.get("draw_no") ?? "").trim(), [searchParams]);
|
|
const statusFilter = useMemo(
|
|
() => searchParams.getAll("status").map((s) => s.trim()).filter(Boolean),
|
|
[searchParams],
|
|
);
|
|
|
|
const [items, setItems] = useState<TicketItemListRow[]>([]);
|
|
const [page, setPage] = useState(1);
|
|
const [lastPage, setLastPage] = useState(1);
|
|
const [total, setTotal] = useState(0);
|
|
const [loading, setLoading] = useState(true);
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [queryDrawNoState, setQueryDrawNoState] = useState(() => ({
|
|
base: drawNoFilter,
|
|
draft: drawNoFilter,
|
|
}));
|
|
const [queryNumber, setQueryNumber] = useState("");
|
|
const [queryStatuses, setQueryStatuses] = useState<string[]>(statusFilter);
|
|
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 [scheduleTimezone, setScheduleTimezone] = useState(LOTTERY_SCHEDULE_TIMEZONE);
|
|
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
|
const isMobile = useIsMobile();
|
|
const initialLoadDone = useRef(false);
|
|
|
|
const queryDrawNoInput =
|
|
queryDrawNoState.base === drawNoFilter ? queryDrawNoState.draft : drawNoFilter;
|
|
const queryDrawNo = queryDrawNoInput || drawNoFilter;
|
|
|
|
const selectedRange = useMemo(() => {
|
|
const from = parseSchedulePickerYmd(fromDate);
|
|
const to = parseSchedulePickerYmd(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");
|
|
if (fromDate && toDate) return `${formatCompactDate(fromDate)} ~ ${formatCompactDate(toDate)}`;
|
|
return `${fromDate ? formatCompactDate(fromDate) : "..." } ~ ${toDate ? formatCompactDate(toDate) : "..."}`;
|
|
}, [formatCompactDate, fromDate, t, toDate]);
|
|
const visiblePages = useMemo(() => buildPageWindow(page, lastPage), [lastPage, page]);
|
|
const orderGroups = useMemo(() => groupTicketItems(items), [items]);
|
|
|
|
const fetchPage = useCallback(
|
|
async (nextPage: number, append: boolean) => {
|
|
if (append) setLoadingMore(true);
|
|
else setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const res = await getTicketItems({
|
|
page: nextPage,
|
|
per_page: ORDERS_PAGE_SIZE,
|
|
draw_no: queryDrawNo || 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);
|
|
setLastPage(res.last_page);
|
|
setTotal(res.total);
|
|
} catch {
|
|
setError(t("orders.loadFailed"));
|
|
if (!append) setItems([]);
|
|
} finally {
|
|
setLoading(false);
|
|
setLoadingMore(false);
|
|
}
|
|
},
|
|
[fromDate, queryDrawNo, queryNumber, queryStatuses, t, toDate],
|
|
);
|
|
|
|
useEffect(() => {
|
|
void getDrawCurrent()
|
|
.then((res) => {
|
|
const tz = res.data?.schedule_timezone?.trim();
|
|
if (tz) setScheduleTimezone(tz);
|
|
})
|
|
.catch(() => {
|
|
/* 保留默认排期时区 */
|
|
});
|
|
}, []);
|
|
|
|
const scheduleTzLabel = useMemo(
|
|
() => getTimeZoneShortLabel(scheduleTimezone),
|
|
[scheduleTimezone],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!initialLoadDone.current) {
|
|
initialLoadDone.current = true;
|
|
void fetchPage(1, false);
|
|
return;
|
|
}
|
|
setItems([]);
|
|
setPage(1);
|
|
setLastPage(1);
|
|
void fetchPage(1, false);
|
|
}, [fetchPage, queryDrawNo, queryNumber, queryStatuses, fromDate, toDate]);
|
|
|
|
useEffect(() => {
|
|
const refreshFromEvents = () => {
|
|
void fetchPage(1, false);
|
|
};
|
|
window.addEventListener("lottery-hall-refresh", refreshFromEvents);
|
|
window.addEventListener("lottery-wallet-refresh", refreshFromEvents);
|
|
return () => {
|
|
window.removeEventListener("lottery-hall-refresh", refreshFromEvents);
|
|
window.removeEventListener("lottery-wallet-refresh", refreshFromEvents);
|
|
};
|
|
}, [fetchPage]);
|
|
|
|
useEffect(() => {
|
|
const target = loadMoreRef.current;
|
|
if (!target || loading || loadingMore || page >= lastPage) return;
|
|
|
|
const observer = new IntersectionObserver(
|
|
([entry]) => {
|
|
if (entry?.isIntersecting) {
|
|
void fetchPage(page + 1, true);
|
|
}
|
|
},
|
|
{ rootMargin: "160px" },
|
|
);
|
|
|
|
observer.observe(target);
|
|
return () => observer.disconnect();
|
|
}, [fetchPage, lastPage, loading, loadingMore, page]);
|
|
|
|
return (
|
|
<PlayerPanel title={t("orders.title")}>
|
|
<div className="space-y-3">
|
|
<div className="rounded-2xl border border-[#dfe9f8] bg-white p-3 shadow-[0_10px_28px_rgba(15,23,42,0.05)] sm:p-4">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-[11px] font-black uppercase tracking-wide text-[#6f86ad]">
|
|
{drawNoFilter ? t("orders.filteredIssue") : t("orders.totalRecords")}
|
|
</p>
|
|
<p className="mt-0.5 truncate font-mono text-2xl font-black leading-none text-[#0b3f96]">
|
|
{drawNoFilter || total}
|
|
</p>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<Link
|
|
href="/hall"
|
|
className="inline-flex h-9 shrink-0 items-center rounded-full bg-[#e5002c] px-5 text-sm font-black text-white shadow-[0_8px_18px_rgba(229,0,44,0.22)]"
|
|
>
|
|
{t("orders.betNow")}
|
|
</Link>
|
|
{(queryDrawNoInput || queryNumber || fromDate || toDate || queryStatuses.length > 0) ? (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="h-9 rounded-full border-[#dce7f7] bg-white px-3 text-xs font-bold text-[#32518d] hover:bg-[#f8fbff]"
|
|
onClick={() => {
|
|
setQueryDrawNoState({ base: drawNoFilter, draft: drawNoFilter });
|
|
setQueryNumber("");
|
|
setFromDate("");
|
|
setToDate("");
|
|
setQueryStatuses(statusFilter);
|
|
setRangeOpen(false);
|
|
setStatusOpen(false);
|
|
}}
|
|
>
|
|
{t("actions.clear")}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-3 grid grid-cols-2 gap-2 lg:grid-cols-4">
|
|
<div className="flex h-9 min-w-0 items-center rounded-full border border-[#dce7f7] bg-[#fbfdff] px-3">
|
|
<Input
|
|
value={queryDrawNoInput}
|
|
onChange={(e) =>
|
|
setQueryDrawNoState({ base: drawNoFilter, draft: e.target.value })
|
|
}
|
|
placeholder={t("orders.drawNo")}
|
|
aria-label={t("orders.drawNo")}
|
|
className="h-7 border-0 bg-transparent px-0 text-sm shadow-none focus-visible:ring-0"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex h-9 min-w-0 items-center gap-2 rounded-full border border-[#dce7f7] bg-[#fbfdff] px-3">
|
|
<Search className="size-3.5 shrink-0 text-slate-400" />
|
|
<Input
|
|
value={queryNumber}
|
|
onChange={(e) => setQueryNumber(e.target.value)}
|
|
placeholder={t("orders.number")}
|
|
aria-label={t("orders.number")}
|
|
className="h-7 border-0 bg-transparent px-0 text-sm shadow-none focus-visible:ring-0"
|
|
/>
|
|
</div>
|
|
|
|
<Popover open={rangeOpen} onOpenChange={setRangeOpen}>
|
|
<PopoverTrigger
|
|
render={
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="h-9 w-full justify-start gap-2 rounded-full border-[#dce7f7] bg-[#fbfdff] px-3 text-left text-sm font-bold text-[#32518d] hover:bg-white"
|
|
>
|
|
<CalendarRange className="size-4 text-[#7890b8]" />
|
|
<span className="truncate">{dateLabel}</span>
|
|
</Button>
|
|
}
|
|
/>
|
|
<PopoverContent align="start" className="w-auto border-[#dce7f7] p-2 shadow-[0_16px_40px_rgba(15,23,42,0.14)]">
|
|
<p className="mb-2 px-1 text-[11px] leading-snug text-muted-foreground">
|
|
{t("orders.dateRangeHint", { tz: scheduleTzLabel })}
|
|
</p>
|
|
<Calendar
|
|
mode="range"
|
|
month={calendarMonth}
|
|
onMonthChange={setCalendarMonth}
|
|
numberOfMonths={isMobile ? 1 : 2}
|
|
selected={selectedRange}
|
|
onSelect={(range) => {
|
|
if (!range?.from && !range?.to) {
|
|
setFromDate("");
|
|
setToDate("");
|
|
return;
|
|
}
|
|
setFromDate(range?.from ? formatSchedulePickerYmd(range.from) : "");
|
|
setToDate(range?.to ? formatSchedulePickerYmd(range.to) : "");
|
|
}}
|
|
/>
|
|
<div className="flex items-center justify-between gap-2 border-t px-2 py-1.5">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="xs"
|
|
onClick={() => {
|
|
const today = scheduleTodayYmd(scheduleTimezone);
|
|
setFromDate(today);
|
|
setToDate(today);
|
|
}}
|
|
>
|
|
{t("orders.scheduleToday")}
|
|
</Button>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="xs"
|
|
onClick={() => {
|
|
setFromDate("");
|
|
setToDate("");
|
|
}}
|
|
>
|
|
{t("actions.clear")}
|
|
</Button>
|
|
<Button type="button" variant="secondary" size="xs" onClick={() => setRangeOpen(false)}>
|
|
{t("actions.done")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
|
|
<PopoverTrigger
|
|
render={
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="h-9 w-full justify-between gap-2 rounded-full border-[#dce7f7] bg-[#fbfdff] px-3 text-sm font-bold text-[#32518d] hover:bg-white"
|
|
>
|
|
<span className="flex min-w-0 items-center gap-1.5">
|
|
<span className="shrink-0">{t("orders.status")}</span>
|
|
{queryStatuses.length ? (
|
|
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-[#0b56b7] px-1 text-[10px] font-black text-white">
|
|
{queryStatuses.length}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
<span className="flex items-center gap-1 text-[#7890b8]">
|
|
<ChevronDown className="size-3.5" />
|
|
</span>
|
|
</Button>
|
|
}
|
|
/>
|
|
<PopoverContent align="start" className="w-56 border-[#dce7f7] p-2 shadow-[0_16px_40px_rgba(15,23,42,0.14)]">
|
|
<div className="space-y-1">
|
|
<p className="px-1 pb-1 text-[11px] font-bold text-[#32518d]">
|
|
{t("orders.statusFilter")}
|
|
</p>
|
|
{STATUS_OPTIONS.map((status) => {
|
|
const checked = queryStatuses.includes(status);
|
|
return (
|
|
<button
|
|
key={status}
|
|
type="button"
|
|
className={cn(
|
|
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs font-semibold transition-colors",
|
|
checked ? "bg-[#eaf2ff] text-[#0b56b7]" : "text-[#32518d] hover:bg-[#f8fbff]",
|
|
)}
|
|
onClick={() => {
|
|
setQueryStatuses((current) =>
|
|
current.includes(status)
|
|
? current.filter((s) => s !== status)
|
|
: [...current, status],
|
|
);
|
|
}}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"flex size-3.5 shrink-0 items-center justify-center rounded border",
|
|
checked
|
|
? "border-[#0b56b7] bg-[#0b56b7] text-white"
|
|
: "border-slate-300 bg-white",
|
|
)}
|
|
aria-hidden
|
|
>
|
|
{checked ? <Check className="size-2.5" strokeWidth={3} /> : null}
|
|
</span>
|
|
<span className="truncate">{t(`ticketStatus.${status}`, { defaultValue: status })}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="space-y-3">
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-28 w-full rounded-xl" />
|
|
))}
|
|
</div>
|
|
) : error ? (
|
|
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
|
|
<p>{error}</p>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
|
|
onClick={() => void fetchPage(1, false)}
|
|
>
|
|
{t("actions.retry")}
|
|
</Button>
|
|
</div>
|
|
) : items.length === 0 ? (
|
|
<div className="rounded-xl border border-dashed border-[#dce7f7] bg-[#f8fbff] px-3 py-8 text-center">
|
|
<p className="text-sm font-bold text-slate-700">{t("orders.empty")}</p>
|
|
<Link
|
|
href="/hall"
|
|
className="mt-4 inline-flex h-9 items-center rounded-lg bg-[#e5002c] px-4 text-sm font-bold text-white"
|
|
>
|
|
{t("orders.submitBet")}
|
|
</Link>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="space-y-3">
|
|
{orderGroups.map((group) => {
|
|
const cur = group.currency_code ?? activeCurrency;
|
|
const st = ticketStatusDisplay(
|
|
group.status,
|
|
group.win_amount,
|
|
group.jackpot_win_amount,
|
|
t,
|
|
creditMode,
|
|
);
|
|
const totalWin = group.win_amount + group.jackpot_win_amount;
|
|
return (
|
|
<Link
|
|
key={group.key}
|
|
href={orderGroupHref(group)}
|
|
onClick={() => persistOrderGroup(group)}
|
|
className="block rounded-xl border border-[#e5edf8] bg-white p-3 shadow-[0_8px_24px_rgba(15,23,42,0.05)] transition-colors hover:border-[#b9ccf6]"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<p className="min-w-0 truncate font-mono text-sm font-black text-[#0b3f96]">
|
|
{group.draw_no ?? "—"}
|
|
</p>
|
|
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
|
</div>
|
|
<OrderMetaLine
|
|
orderNo={group.order_no}
|
|
placedAt={group.placed_at}
|
|
t={t}
|
|
/>
|
|
<div className="mt-2.5 space-y-1">
|
|
{group.items.map((row) => (
|
|
<p
|
|
key={row.ticket_no}
|
|
className="text-sm font-semibold text-[#32518d]"
|
|
>
|
|
{playLabel(row.play_code, t)} · {row.original_number ?? row.play_code}
|
|
</p>
|
|
))}
|
|
</div>
|
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
|
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
|
<p className="text-[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>
|
|
{group.status === "partial_failed" ? (
|
|
<p className="mt-2 text-xs font-bold text-amber-700">
|
|
{t("orders.partialFailedHint")}
|
|
</p>
|
|
) : null}
|
|
{totalWin > 0 && group.status === "settled_win" ? (
|
|
<p className="mt-2 text-xs font-bold text-emerald-600">
|
|
{t("orders.win", { amount: formatMinorAsCurrency(totalWin, cur) })}
|
|
</p>
|
|
) : null}
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
{isMobile ? <div ref={loadMoreRef} className="min-h-1" /> : null}
|
|
{isMobile && page < lastPage ? (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="h-10 w-full rounded-xl border-[#dce7f7] bg-white text-sm font-bold text-[#32518d] hover:bg-[#f8fbff]"
|
|
disabled={loadingMore}
|
|
onClick={() => void fetchPage(page + 1, true)}
|
|
>
|
|
{loadingMore ? t("actions.loading") : t("actions.loadMore")}
|
|
</Button>
|
|
) : !isMobile && lastPage > 1 ? (
|
|
<div className="flex flex-wrap items-center justify-center gap-2 rounded-xl border border-[#e6edf8] bg-white px-3 py-3">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="rounded-full border-[#dce7f7] bg-white text-[#32518d]"
|
|
disabled={loading || page <= 1}
|
|
onClick={() => void fetchPage(Math.max(1, page - 1), false)}
|
|
>
|
|
{t("actions.previous")}
|
|
</Button>
|
|
{visiblePages.map((p) => (
|
|
<Button
|
|
key={p}
|
|
type="button"
|
|
variant={p === page ? "default" : "outline"}
|
|
size="sm"
|
|
className={cn(
|
|
"min-w-8 rounded-full",
|
|
p === page
|
|
? "bg-[#07459f] text-white hover:bg-[#063b88]"
|
|
: "border-[#dce7f7] bg-white text-[#32518d]",
|
|
)}
|
|
disabled={loading}
|
|
onClick={() => void fetchPage(p, false)}
|
|
>
|
|
{p}
|
|
</Button>
|
|
))}
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="rounded-full border-[#dce7f7] bg-white text-[#32518d]"
|
|
disabled={loading || page >= lastPage}
|
|
onClick={() => void fetchPage(Math.min(lastPage, page + 1), false)}
|
|
>
|
|
{t("actions.next")}
|
|
</Button>
|
|
</div>
|
|
) : lastPage > 1 ? (
|
|
<p className="py-2 text-center text-xs text-slate-400">
|
|
{t("orders.noMore")}
|
|
</p>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</div>
|
|
</PlayerPanel>
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|