feat: enhance wallet strip for mobile responsiveness
Some checks failed
lotteryfront CI / build (push) Has been cancelled
Some checks failed
lotteryfront CI / build (push) Has been cancelled
- Added mobile-specific styles to the HallWalletStrip component for better usability on smaller screens. - Adjusted padding, font sizes, and spacing based on device type. - Introduced a new hook `useIsMobile` to determine the device type. feat: sort ticket items by provider code - Updated the sorting logic in `sortTicketGroupItems` to include provider code for better organization of ticket items. feat: display provider information in ticket order details - Added a `providerLabel` function to format provider information. - Updated `TicketOrderDetailScreen` and `TicketOrdersListScreen` to display provider name and code. feat: enhance ticket item types with provider information - Extended `TicketItemListRow` and `TicketItemDetailPayload` types to include optional provider code and name. feat: add API for fetching bet providers - Created a new API endpoint to retrieve bet providers. - Defined types for bet provider data in `bet-provider.ts`. chore: update player spacing constants - Adjusted padding values in `player-spacing.ts` for improved layout consistency.
This commit is contained in:
6
src/api/bet-providers.ts
Normal file
6
src/api/bet-providers.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { lotteryRequest } from "@/lib/lottery-http";
|
||||
import type { BetProvidersData } from "@/types/api/bet-provider";
|
||||
|
||||
export function getBetProviders(): Promise<BetProvidersData> {
|
||||
return lotteryRequest.get<BetProvidersData>("/bet-providers");
|
||||
}
|
||||
@@ -144,7 +144,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 下注表格横向滚动:右侧渐变提示可继续滑动 */
|
||||
/* 下注表格横向滚动 */
|
||||
.player-table-scroll {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: thin;
|
||||
@@ -154,17 +154,6 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.player-table-scroll-wrap::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 1.25rem;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(to left, rgba(255, 255, 255, 0.95), transparent);
|
||||
}
|
||||
|
||||
/* 玩家端侧栏:浅色底 + 与后台一致的红色激活态 */
|
||||
.player-sidebar {
|
||||
--sidebar: #ffffff;
|
||||
@@ -330,4 +319,4 @@
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export function PlayerPanel({
|
||||
<div className={cn(playerContentColumnClass, containerClassName)}>
|
||||
<section
|
||||
className={cn(
|
||||
"bg-white text-slate-900 lg:rounded-2xl lg:border lg:border-[#e4ebf5] lg:shadow-sm",
|
||||
"bg-white text-slate-900 lg:bg-transparent",
|
||||
playerPageShellPadding,
|
||||
className,
|
||||
)}
|
||||
@@ -34,4 +34,4 @@ export function PlayerPanel({
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,10 @@ function WarningsBlock({ warnings }: { warnings: TicketPreviewWarning[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function providerLabel(providerName?: string | null, providerCode?: string | null): string {
|
||||
return providerName || providerCode || "-";
|
||||
}
|
||||
|
||||
function SubmittingPanel() {
|
||||
const { t } = useTranslation("player");
|
||||
|
||||
@@ -206,7 +210,7 @@ export function HallBetPreviewDialog({
|
||||
) : null}
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-[#dfe8f6]">
|
||||
<table className="min-w-[640px] w-full border-collapse text-xs">
|
||||
<table className="min-w-[700px] w-full border-collapse text-xs">
|
||||
<thead className="bg-[#f4f7fd] text-[#304f86]">
|
||||
<tr>
|
||||
<th className="w-10 border-r border-[#dfe8f6] px-2 py-3 text-center font-black">No.</th>
|
||||
@@ -216,6 +220,9 @@ export function HallBetPreviewDialog({
|
||||
<th className="border-r border-[#dfe8f6] px-2 py-3 text-center font-black">
|
||||
{t("orders.play")}
|
||||
</th>
|
||||
<th className="border-r border-[#dfe8f6] px-2 py-3 text-center font-black">
|
||||
{t("hall.providers.title", { defaultValue: "开注商" })}
|
||||
</th>
|
||||
<th className="border-r border-[#dfe8f6] px-2 py-3 text-center font-black">
|
||||
{t("hall.preview.amount")}
|
||||
</th>
|
||||
@@ -245,6 +252,11 @@ export function HallBetPreviewDialog({
|
||||
<td className="border-r border-[#e8eef7] px-2 py-3 text-center font-semibold text-slate-700">
|
||||
{ln.play_code}
|
||||
</td>
|
||||
<td className="border-r border-[#e8eef7] px-2 py-3 text-center">
|
||||
<span className="inline-flex rounded-full bg-[#eef5ff] px-2 py-0.5 font-black text-[#0b56b7]">
|
||||
{providerLabel(ln.provider_name, ln.provider_code)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="border-r border-[#e8eef7] px-2 py-3 text-center font-semibold tabular-nums">
|
||||
{formatMinorAsCurrency(ln.total_bet_amount, currencyCode)}
|
||||
</td>
|
||||
|
||||
@@ -29,6 +29,10 @@ type HallBetResultDialogProps = {
|
||||
const SUCCESS_ITEM_STATUSES = new Set(["pending_draw", "placed"]);
|
||||
const FAILURE_ITEM_STATUSES = new Set(["failed", "refunded"]);
|
||||
|
||||
function providerLabel(providerName?: string | null, providerCode?: string | null): string {
|
||||
return providerName || providerCode || "-";
|
||||
}
|
||||
|
||||
export function HallBetResultDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -202,7 +206,7 @@ export function HallBetResultDialog({
|
||||
{t("hall.result.items")}
|
||||
</p>
|
||||
<div className="overflow-x-auto rounded-xl border border-[#dfe8f6]">
|
||||
<table className="min-w-[430px] w-full border-collapse text-xs">
|
||||
<table className="min-w-[520px] w-full border-collapse text-xs">
|
||||
<thead className="bg-[#f4f7fd] text-[#304f86]">
|
||||
<tr>
|
||||
<th className="w-10 border-r border-[#dfe8f6] px-2 py-2.5 text-center font-black">
|
||||
@@ -214,6 +218,9 @@ export function HallBetResultDialog({
|
||||
<th className="border-r border-[#dfe8f6] px-2 py-2.5 text-center font-black">
|
||||
{t("orders.play")}
|
||||
</th>
|
||||
<th className="border-r border-[#dfe8f6] px-2 py-2.5 text-center font-black">
|
||||
{t("hall.providers.title", { defaultValue: "开注商" })}
|
||||
</th>
|
||||
<th className="px-2 py-2.5 text-center font-black">
|
||||
{t("hall.result.actualDeduct")}
|
||||
</th>
|
||||
@@ -239,6 +246,11 @@ export function HallBetResultDialog({
|
||||
<td className="border-r border-[#e8eef7] px-2 py-3 text-center font-semibold text-[#0755c7]">
|
||||
{playLabel(item.play_code, t)}
|
||||
</td>
|
||||
<td className="border-r border-[#e8eef7] px-2 py-3 text-center">
|
||||
<span className="inline-flex rounded-full bg-[#eef5ff] px-2 py-0.5 font-black text-[#0b56b7]">
|
||||
{providerLabel(item.provider_name, item.provider_code)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-3 text-center font-black tabular-nums text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(item.actual_deduct_amount, currencyCode)}
|
||||
</td>
|
||||
@@ -265,7 +277,7 @@ export function HallBetResultDialog({
|
||||
>
|
||||
<span className="min-w-0 truncate font-semibold text-slate-700">
|
||||
<span className="font-mono font-black text-slate-950">{item.number}</span>{" "}
|
||||
{playLabel(item.play_code, t)}
|
||||
{playLabel(item.play_code, t)} · {providerLabel(item.provider_name, item.provider_code)}
|
||||
</span>
|
||||
<span className="shrink-0 font-bold text-[#e5002c]">
|
||||
{item.fail_reason_text ?? item.fail_reason_code ?? t("hall.result.failed")}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { CirclePlus, Lock, Ticket, Trash2, Star } from "lucide-react";
|
||||
import { CirclePlus, ChevronDown, ChevronUp, Lock, Ticket, Trash2, Star } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, memo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getBetProviders } from "@/api/bet-providers";
|
||||
import { getPlayEffective } from "@/api/play";
|
||||
import { getWalletBalance } from "@/api/wallet";
|
||||
import { postTicketPlace, postTicketPreview } from "@/api/ticket";
|
||||
@@ -30,6 +31,7 @@ import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import { getLotteryEcho } from "@/lib/lottery-echo";
|
||||
import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money";
|
||||
import { playLabel } from "@/lib/play-labels";
|
||||
import { playerViewportFixedBarClass } from "@/lib/player-viewport";
|
||||
import {
|
||||
PLAY_CATALOG_REFRESH_EVENT,
|
||||
type PlayCatalogRefreshSource,
|
||||
@@ -41,6 +43,7 @@ import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { PlayEffectivePayload, PlayEffectivePlayRow } from "@/types/api/play-effective";
|
||||
import type { TicketLineInput, TicketPlaceData, TicketPreviewData } from "@/types/api/ticket";
|
||||
import type { DrawCurrentRiskPoolAlert } from "@/types/api/draw-current";
|
||||
import type { BetProviderRow } from "@/types/api/bet-provider";
|
||||
|
||||
const MAX_ROWS = 50;
|
||||
|
||||
@@ -113,10 +116,16 @@ type RiskWarningWsEvent = {
|
||||
type CellRiskState = "open" | "warning" | "sold_out";
|
||||
type QuickFillState = Record<HallCategory, { favorites: string[]; history: string[] }>;
|
||||
|
||||
const FALLBACK_BET_PROVIDERS: BetProviderRow[] = [
|
||||
{ code: "SG", name: "Singapore", sort_order: 10, is_default: true },
|
||||
{ code: "MY", name: "Malaysia", sort_order: 20, is_default: false },
|
||||
{ code: "TH", name: "Thailand", sort_order: 30, is_default: false },
|
||||
];
|
||||
|
||||
const categoryTabs: { value: HallCategory; label: string }[] = [
|
||||
{ value: "D2", label: "2D" },
|
||||
{ value: "D3", label: "3D" },
|
||||
{ value: "D4", label: "4D" },
|
||||
{ value: "D3", label: "3D" },
|
||||
{ value: "D2", label: "2D" },
|
||||
];
|
||||
|
||||
const D2_PLAY_ORDER = ["pos_2a", "pos_2b", "pos_2c", "pos_2abc"] as const;
|
||||
@@ -141,6 +150,7 @@ const D4_PLAY_ORDER = [
|
||||
"digit_big",
|
||||
"digit_small",
|
||||
] as const;
|
||||
const MOBILE_QUICK_AMOUNT_PRESETS = ["10", "50", "100"] as const;
|
||||
|
||||
function newDraftRow(): DraftRow {
|
||||
const id =
|
||||
@@ -445,7 +455,11 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
const [resultOpen, setResultOpen] = useState(false);
|
||||
const [resultData, setResultData] = useState<TicketPlaceData | null>(null);
|
||||
const [activeCategory, setActiveCategory] = useState<Exclude<HallCategory, "JACKPOT">>("D4");
|
||||
const [betProviders, setBetProviders] = useState<BetProviderRow[]>(FALLBACK_BET_PROVIDERS);
|
||||
const [selectedProviderCodes, setSelectedProviderCodes] = useState<string[]>([]);
|
||||
const [quickFillState, setQuickFillState] = useState<QuickFillState>(() => loadQuickFillState());
|
||||
const [quickFillExpanded, setQuickFillExpanded] = useState(false);
|
||||
const [providersExpanded, setProvidersExpanded] = useState(false);
|
||||
const [riskStateDrawNo, setRiskStateDrawNo] = useState<string | null>(display?.draw_no ?? null);
|
||||
const [liveSoldOutNumbers, setLiveSoldOutNumbers] = useState<Set<string>>(() => new Set());
|
||||
const [liveWarningNumbers, setLiveWarningNumbers] = useState<Set<string>>(() => new Set());
|
||||
@@ -501,6 +515,27 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
});
|
||||
}, [loadCatalog, refreshWallet]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void getBetProviders()
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
const items = data.items.length > 0 ? data.items : FALLBACK_BET_PROVIDERS;
|
||||
setBetProviders(items);
|
||||
setSelectedProviderCodes((current) => {
|
||||
const available = new Set(items.map((item) => item.code));
|
||||
return current.filter((code) => available.has(code));
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setBetProviders(FALLBACK_BET_PROVIDERS);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onCatalogRefresh = (ev: Event) => {
|
||||
void loadCatalog();
|
||||
@@ -554,13 +589,20 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
return playColumnsForCategory(activeCategoryPlays, activeCategory);
|
||||
}, [activeCategory, activeCategoryPlays]);
|
||||
|
||||
const mobileIndexColClass = "w-[1.75rem] min-w-[1.75rem]";
|
||||
const mobileNumberColClass = "w-[4.25rem] min-w-[4.25rem]";
|
||||
const desktopIndexColClass = "w-[2.15rem] min-w-[2.15rem]";
|
||||
const desktopNumberColClass = "w-[5.5rem] min-w-[5.5rem]";
|
||||
const indexColClass = isMobile ? mobileIndexColClass : desktopIndexColClass;
|
||||
const numberColClass = isMobile ? mobileNumberColClass : desktopNumberColClass;
|
||||
const stickyNumberLeftClass = isMobile ? "left-[1.75rem]" : "left-[2.15rem]";
|
||||
const amountColClass = !isMobile
|
||||
? "w-[3.9rem] min-w-[3.9rem]"
|
||||
: "w-[4rem] min-w-[4rem]";
|
||||
|
||||
const tableMinWidthPx = useMemo(() => {
|
||||
const indexCol = 34;
|
||||
const numberCol = 88;
|
||||
const indexCol = !isMobile ? 34 : 28;
|
||||
const numberCol = !isMobile ? 88 : 68;
|
||||
const amountCol = !isMobile ? 62 : 64;
|
||||
const deleteCol = 32;
|
||||
return indexCol + numberCol + playColumns.length * amountCol + deleteCol;
|
||||
@@ -581,7 +623,10 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
setLiveWarningNumbers(new Set());
|
||||
}
|
||||
|
||||
const alertRows = display?.risk_pool_alerts ?? [];
|
||||
const alertRows = useMemo(
|
||||
() => display?.risk_pool_alerts ?? [],
|
||||
[display?.risk_pool_alerts],
|
||||
);
|
||||
const jackpot = display?.jackpot;
|
||||
const currentQuickFill = quickFillState[activeCategory] ?? { favorites: [], history: [] };
|
||||
const favorites = currentQuickFill.favorites;
|
||||
@@ -638,6 +683,21 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
setActiveRowId(next[0].id);
|
||||
};
|
||||
|
||||
const clearActiveRowAmounts = useCallback(() => {
|
||||
const targetId = activeRowId ?? rows[0]?.id;
|
||||
if (!targetId || tableDisabled) return;
|
||||
setRows((current) =>
|
||||
current.map((row) => {
|
||||
if (row.id !== targetId) return row;
|
||||
const nextAmounts = { ...row.amounts };
|
||||
playColumns.forEach((column) => {
|
||||
nextAmounts[column.key] = "";
|
||||
});
|
||||
return { ...row, amounts: nextAmounts };
|
||||
}),
|
||||
);
|
||||
}, [activeRowId, playColumns, rows, tableDisabled]);
|
||||
|
||||
const fillCurrentRow = (number: string) => {
|
||||
if (tableDisabled) return;
|
||||
const targetId = activeRowId ?? rows[0]?.id;
|
||||
@@ -645,6 +705,78 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
updateRowNumber(targetId, number);
|
||||
};
|
||||
|
||||
const applyQuickAmountToActiveRow = useCallback(
|
||||
(amount: string) => {
|
||||
const targetId = activeRowId ?? rows[0]?.id;
|
||||
if (!targetId || tableDisabled) return;
|
||||
setRows((current) =>
|
||||
current.map((row) => {
|
||||
if (row.id !== targetId) return row;
|
||||
if (
|
||||
row.number.trim().length === 0 ||
|
||||
playColumns.every(
|
||||
(column) =>
|
||||
draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) !== null,
|
||||
)
|
||||
) {
|
||||
return row;
|
||||
}
|
||||
|
||||
const nextAmounts = { ...row.amounts };
|
||||
playColumns.forEach((column) => {
|
||||
const isInputValid =
|
||||
draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) === null;
|
||||
const status = cellRiskState(
|
||||
column.play,
|
||||
row.number,
|
||||
playCategory(column.play.play_code),
|
||||
alertRows,
|
||||
liveSoldOutNumbers,
|
||||
liveWarningNumbers,
|
||||
column.digitSlot,
|
||||
);
|
||||
if (!isInputValid || status === "sold_out") return;
|
||||
nextAmounts[column.key] = amount;
|
||||
});
|
||||
return { ...row, amounts: nextAmounts };
|
||||
}),
|
||||
);
|
||||
},
|
||||
[
|
||||
activeRowId,
|
||||
alertRows,
|
||||
liveSoldOutNumbers,
|
||||
liveWarningNumbers,
|
||||
playColumns,
|
||||
rows,
|
||||
tableDisabled,
|
||||
],
|
||||
);
|
||||
|
||||
const copyPreviousRowToActiveRow = useCallback(() => {
|
||||
const targetId = activeRowId ?? rows[0]?.id;
|
||||
if (!targetId || tableDisabled) return;
|
||||
const targetIndex = rows.findIndex((row) => row.id === targetId);
|
||||
if (targetIndex <= 0) return;
|
||||
const previousRow = rows[targetIndex - 1];
|
||||
if (!previousRow) return;
|
||||
|
||||
setRows((current) =>
|
||||
current.map((row, index) => {
|
||||
if (index !== targetIndex) return row;
|
||||
const nextAmounts = { ...row.amounts };
|
||||
playColumns.forEach((column) => {
|
||||
nextAmounts[column.key] = previousRow.amounts[column.key] ?? "";
|
||||
});
|
||||
return {
|
||||
...row,
|
||||
number: previousRow.number,
|
||||
amounts: nextAmounts,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}, [activeRowId, playColumns, rows, tableDisabled]);
|
||||
|
||||
const toggleFavoriteNumber = (number: string) => {
|
||||
const keys = quickFillKeys(activeCategory);
|
||||
setQuickFillState((current) => {
|
||||
@@ -680,6 +812,15 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
});
|
||||
};
|
||||
|
||||
const toggleProvider = (code: string) => {
|
||||
setSelectedProviderCodes((current) => {
|
||||
if (current.includes(code)) {
|
||||
return current.filter((item) => item !== code);
|
||||
}
|
||||
return [...current, code];
|
||||
});
|
||||
};
|
||||
|
||||
const hasAmountsForPlay = useCallback(
|
||||
(playCode: string): boolean =>
|
||||
rows.some((row) =>
|
||||
@@ -835,8 +976,9 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
}, [allPlayColumns, currencyCode, rows]);
|
||||
|
||||
const draftEntries = collectEntries();
|
||||
const effectiveProviderCount = selectedProviderCodes.length > 0 ? selectedProviderCodes.length : 1;
|
||||
const draftSummary = useMemo(() => {
|
||||
return draftEntries.reduce(
|
||||
const base = draftEntries.reduce(
|
||||
(acc, entry) => {
|
||||
const rebateRate = parseRebateRate(entry.play.odds?.rebate_rate);
|
||||
const rebate = Math.round(entry.amountMinor * rebateRate);
|
||||
@@ -847,7 +989,12 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
},
|
||||
{ bet: 0, rebate: 0, actual: 0 },
|
||||
);
|
||||
}, [draftEntries]);
|
||||
return {
|
||||
bet: base.bet * effectiveProviderCount,
|
||||
rebate: base.rebate * effectiveProviderCount,
|
||||
actual: base.actual * effectiveProviderCount,
|
||||
};
|
||||
}, [draftEntries, effectiveProviderCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => {
|
||||
@@ -929,6 +1076,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
draw_id: display.draw_no,
|
||||
currency_code: currencyCode,
|
||||
client_trace_id: placeTraceIdRef.current,
|
||||
provider_codes: selectedProviderCodes.length > 0 ? selectedProviderCodes : undefined,
|
||||
lines,
|
||||
});
|
||||
setPreviewData(data);
|
||||
@@ -996,6 +1144,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
draw_id: drawIdForBet,
|
||||
currency_code: currencyCode,
|
||||
client_trace_id: traceId,
|
||||
provider_codes: selectedProviderCodes.length > 0 ? selectedProviderCodes : undefined,
|
||||
lines,
|
||||
expected_config_versions: previewData.config_versions,
|
||||
});
|
||||
@@ -1065,7 +1214,10 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
|
||||
if (catalogState.kind === "loading") {
|
||||
return (
|
||||
<section className="space-y-3" aria-label={t("hall.aria")}>
|
||||
<section
|
||||
className={cn("space-y-3", isMobile && "pb-44")}
|
||||
aria-label={t("hall.aria")}
|
||||
>
|
||||
<Skeleton className="h-12 rounded-xl" />
|
||||
<Skeleton className="h-72 rounded-xl" />
|
||||
<Skeleton className="h-14 rounded-xl" />
|
||||
@@ -1096,6 +1248,23 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
!tableDisabled && draftEntries.length > 0 && availableMinor >= submitActualMinor;
|
||||
const favoriteChips = favorites.slice(0, 10);
|
||||
const historyChips = historyNumbers.slice(0, 20);
|
||||
const selectedProviderNames = betProviders
|
||||
.filter((provider) => selectedProviderCodes.includes(provider.code))
|
||||
.map((provider) => provider.name);
|
||||
const implicitDefaultProvider =
|
||||
betProviders.find((provider) => provider.is_default) ?? betProviders[0] ?? null;
|
||||
const providerSummaryCount = selectedProviderCodes.length > 0 ? selectedProviderCodes.length : 1;
|
||||
const providerSummaryText =
|
||||
selectedProviderCodes.length > 0
|
||||
? t("hall.providers.summary", {
|
||||
defaultValue: "已选 {{count}} 家,金额按每家公司计算",
|
||||
count: providerSummaryCount,
|
||||
})
|
||||
: t("hall.providers.platformDefaultSummary", {
|
||||
defaultValue: implicitDefaultProvider
|
||||
? `未选择时走平台默认(${implicitDefaultProvider.name})`
|
||||
: "未选择时走平台默认",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -1123,17 +1292,55 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-xl border border-[#e3ebf7] bg-white px-3 py-3 shadow-[0_8px_24px_rgba(15,23,42,0.045)]">
|
||||
<div
|
||||
className={cn(
|
||||
"bg-white",
|
||||
isMobile
|
||||
? "rounded-lg border border-[#e8eef7] px-3 py-2.5"
|
||||
: "rounded-xl border border-[#e3ebf7] px-3 py-3 shadow-[0_8px_24px_rgba(15,23,42,0.045)]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-bold leading-5 text-slate-950">
|
||||
{t("hall.quickFill.title")}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs leading-5 text-slate-500">
|
||||
{t("hall.quickFill.description")}
|
||||
</p>
|
||||
{!isMobile ? (
|
||||
<p className="mt-0.5 text-xs leading-5 text-slate-500">
|
||||
{t("hall.quickFill.description")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-0.5 text-[11px] leading-5 text-slate-500">
|
||||
{t("hall.mobile.quickFillSummary", {
|
||||
defaultValue: "收藏 {{favorites}} 个,历史 {{history}} 个",
|
||||
favorites: favorites.length,
|
||||
history: historyNumbers.length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{isMobile ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={
|
||||
quickFillExpanded
|
||||
? t("hall.mobile.collapseQuickFill", { defaultValue: "收起快捷填单" })
|
||||
: t("hall.mobile.expandQuickFill", { defaultValue: "展开快捷填单" })
|
||||
}
|
||||
aria-pressed={quickFillExpanded}
|
||||
className="size-8 rounded-full border border-[#dfe6f0] bg-[#f8fafc] text-slate-500 hover:bg-slate-100 hover:text-slate-900"
|
||||
onClick={() => setQuickFillExpanded((current) => !current)}
|
||||
>
|
||||
{quickFillExpanded ? (
|
||||
<ChevronUp className="size-4" aria-hidden />
|
||||
) : (
|
||||
<ChevronDown className="size-4" aria-hidden />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{activeRow?.number ? (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1180,7 +1387,46 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className={cn("space-y-2", isMobile ? "mt-2" : "mt-3")}>
|
||||
{isMobile && quickFillExpanded ? (
|
||||
<div className="rounded-xl border border-[#e7eef8] bg-[#f8fbff] px-3 py-2.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[11px] font-semibold text-[#5b6f95]">
|
||||
{t("hall.quickFill.batchTitle", { defaultValue: "Quick amount" })}
|
||||
</span>
|
||||
{MOBILE_QUICK_AMOUNT_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
disabled={tableDisabled}
|
||||
onClick={() => applyQuickAmountToActiveRow(preset)}
|
||||
className="inline-flex h-8 min-w-12 items-center justify-center rounded-full border border-[#cfe0fb] bg-white px-3 text-xs font-bold text-[#0b4ab3] transition-colors hover:border-[#aac7f5] hover:bg-[#edf5ff] disabled:opacity-40"
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
disabled={tableDisabled || !activeRow?.number}
|
||||
onClick={copyPreviousRowToActiveRow}
|
||||
className="inline-flex h-8 items-center justify-center rounded-full border border-[#dfe6f0] bg-white px-3 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-40"
|
||||
>
|
||||
{t("hall.quickFill.copyPrev", { defaultValue: "Copy prev row" })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={tableDisabled}
|
||||
onClick={clearActiveRowAmounts}
|
||||
className="inline-flex h-8 items-center justify-center rounded-full border border-[#ffe0e5] bg-white px-3 text-xs font-semibold text-[#d81435] transition-colors hover:bg-[#fff4f6] disabled:opacity-40"
|
||||
>
|
||||
{t("hall.quickFill.clearRow", { defaultValue: "Clear row amounts" })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isMobile || quickFillExpanded ? (
|
||||
<>
|
||||
{favoriteChips.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="mr-0.5 text-[11px] font-semibold text-[#d81435]">
|
||||
@@ -1251,10 +1497,115 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-2 overflow-x-auto pb-1">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-[#dfe8f6] bg-[#f8fbff]",
|
||||
isMobile ? "px-3 py-2.5" : "px-2.5 py-2",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="mr-1 text-[11px] font-black text-[#304f86]">
|
||||
{t("hall.providers.title", { defaultValue: "开注商" })}
|
||||
</span>
|
||||
{!isMobile ? betProviders.map((provider) => {
|
||||
const checked = selectedProviderCodes.includes(provider.code);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={provider.code}
|
||||
type="button"
|
||||
disabled={tableDisabled}
|
||||
aria-pressed={checked}
|
||||
onClick={() => toggleProvider(provider.code)}
|
||||
className={cn(
|
||||
"inline-flex h-8 items-center gap-1 rounded-md border px-2 text-xs font-bold transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
checked
|
||||
? "border-[#2d63e2] bg-[#2d63e2] text-white"
|
||||
: "border-[#cfdbee] bg-white text-[#304f86] hover:border-[#8fb0eb]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"size-3 rounded-[3px] border",
|
||||
checked ? "border-white bg-white/25" : "border-[#9aa8bd]",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
{provider.name}
|
||||
</button>
|
||||
);
|
||||
}) : (
|
||||
<span className="text-xs font-semibold text-[#58709d]">
|
||||
{selectedProviderNames.length > 0
|
||||
? selectedProviderNames.join(" / ")
|
||||
: t("hall.providers.platformDefault", {
|
||||
defaultValue: implicitDefaultProvider
|
||||
? `平台默认(${implicitDefaultProvider.name})`
|
||||
: "平台默认",
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs font-semibold text-[#58709d]">{providerSummaryText}</p>
|
||||
{isMobile ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={tableDisabled}
|
||||
aria-pressed={providersExpanded}
|
||||
onClick={() => setProvidersExpanded((current) => !current)}
|
||||
className="inline-flex h-7 items-center justify-center rounded-full border border-[#d4dff1] bg-white px-2 text-[#58709d] transition-colors hover:bg-[#f3f7ff] disabled:opacity-40"
|
||||
>
|
||||
{providersExpanded ? (
|
||||
<ChevronUp className="size-3.5" aria-hidden />
|
||||
) : (
|
||||
<ChevronDown className="size-3.5" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{isMobile && providersExpanded ? (
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
|
||||
{betProviders.map((provider) => {
|
||||
const checked = selectedProviderCodes.includes(provider.code);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={provider.code}
|
||||
type="button"
|
||||
disabled={tableDisabled}
|
||||
aria-pressed={checked}
|
||||
onClick={() => toggleProvider(provider.code)}
|
||||
className={cn(
|
||||
"inline-flex h-8 items-center gap-1 rounded-md border px-2 text-xs font-bold transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
checked
|
||||
? "border-[#2d63e2] bg-[#2d63e2] text-white"
|
||||
: "border-[#cfdbee] bg-white text-[#304f86] hover:border-[#8fb0eb]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"size-3 rounded-[3px] border",
|
||||
checked ? "border-white bg-white/25" : "border-[#9aa8bd]",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
{provider.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={cn("overflow-x-auto pb-1", isMobile ? "-mx-1 flex gap-1.5 px-1" : "flex items-end gap-2")}>
|
||||
{categoryTabs.map((tab) => {
|
||||
const hasPlays = openPlays.some(
|
||||
(play) => playCategory(play.play_code) === tab.value,
|
||||
@@ -1268,7 +1619,10 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
disabled={!hasPlays}
|
||||
onClick={() => setActiveCategory(tab.value)}
|
||||
className={cn(
|
||||
"inline-flex min-w-[6rem] items-center justify-center rounded-t-xl border px-4 py-3 text-sm font-bold transition-colors",
|
||||
"inline-flex items-center justify-center border font-bold transition-colors",
|
||||
isMobile
|
||||
? "min-w-[5.25rem] rounded-t-xl border-b-0 px-4 py-3 text-sm"
|
||||
: "min-w-[6rem] rounded-t-xl px-4 py-3 text-sm",
|
||||
active
|
||||
? "border-[#d7e1f3] border-b-white bg-white text-[#21335b] shadow-[0_-1px_0_rgba(255,255,255,0.8)]"
|
||||
: "border-transparent bg-transparent text-[#6d8fd6] hover:text-[#2d63e2]",
|
||||
@@ -1294,13 +1648,10 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showWideTableHint ? (
|
||||
<p className="mb-2 text-xs leading-5 text-[#58709d]">{t("hall.table.scrollHint")}</p>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border border-[#e6edf8] bg-white shadow-[0_8px_24px_rgba(15,23,42,0.05)] transition-opacity",
|
||||
"overflow-hidden border border-[#e6edf8] bg-white transition-opacity",
|
||||
isMobile ? "rounded-lg" : "rounded-xl shadow-[0_8px_24px_rgba(15,23,42,0.05)]",
|
||||
tableDisabled && "opacity-55",
|
||||
showWideTableHint && "player-table-scroll-wrap",
|
||||
)}
|
||||
@@ -1361,13 +1712,14 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
>
|
||||
<thead>
|
||||
<tr className="border-b border-[#edf2f8] bg-[#f8fafd] text-[#58709d]">
|
||||
<th className="sticky left-0 z-30 w-[2.15rem] min-w-[2.15rem] bg-[#f8fafd] px-0.5 py-1.5 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]">
|
||||
<th className={cn("sticky left-0 z-30 bg-[#f8fafd] px-0.5 py-1.5 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]", indexColClass)}>
|
||||
{t("hall.table.no", { defaultValue: "No." })}
|
||||
</th>
|
||||
<th
|
||||
className={cn(
|
||||
"sticky left-[2.15rem] z-30 bg-[#f8fafd] px-1 py-1.5 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
|
||||
"w-[5.5rem] min-w-[5.5rem]",
|
||||
"sticky z-30 bg-[#f8fafd] px-1 py-1.5 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
|
||||
stickyNumberLeftClass,
|
||||
numberColClass,
|
||||
)}
|
||||
>
|
||||
<span className="block text-xs">{t("hall.table.number", { defaultValue: "Number" })}</span>
|
||||
@@ -1416,31 +1768,27 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
t={t}
|
||||
isMobile={isMobile}
|
||||
removable={rows.length > 1}
|
||||
indexColClass={indexColClass}
|
||||
numberColClass={numberColClass}
|
||||
stickyNumberLeftClass={stickyNumberLeftClass}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={tableDisabled || rows.length >= MAX_ROWS}
|
||||
onClick={addRow}
|
||||
className="flex h-10 w-full items-center justify-center gap-1.5 border-t border-[#edf2f9] bg-white text-xs font-bold text-[#1d57b7] hover:bg-[#f7faff] disabled:text-slate-300"
|
||||
>
|
||||
<CirclePlus className="size-4" aria-hidden />
|
||||
{t("hall.table.addRow")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-xl border border-[#e9eef7] bg-[#f8fbff] px-3 py-2.5 text-sm shadow-[0_6px_20px_rgba(15,23,42,0.04)]">
|
||||
<span className="text-xs font-medium text-slate-500">
|
||||
{t("hall.table.draftTotal")}
|
||||
</span>
|
||||
<span className="text-base font-black tabular-nums text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(debouncedSummary.actual, currencyCode)}
|
||||
</span>
|
||||
</div>
|
||||
{!isMobile ? (
|
||||
<div className="flex items-center justify-between rounded-xl border border-[#e9eef7] bg-[#f8fbff] px-3 py-2.5 text-sm shadow-[0_6px_20px_rgba(15,23,42,0.04)]">
|
||||
<span className="text-xs font-medium text-slate-500">
|
||||
{t("hall.table.draftTotal")}
|
||||
</span>
|
||||
<span className="text-base font-black tabular-nums text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(debouncedSummary.actual, currencyCode)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sealedBetUi ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-600">
|
||||
@@ -1448,32 +1796,80 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canSubmit || previewLoading}
|
||||
onClick={() => void handlePreview()}
|
||||
className={cn(
|
||||
"h-12 w-full gap-2 rounded-xl border-0 text-base font-bold text-white",
|
||||
!isBettable
|
||||
? "bg-slate-500 shadow-none hover:bg-slate-500 disabled:opacity-100"
|
||||
: "bg-[#e5002c] shadow-[0_8px_20px_rgba(229,0,44,0.26)] hover:bg-[#d10028]",
|
||||
)}
|
||||
>
|
||||
{!isBettable ? (
|
||||
<Lock className="size-5 shrink-0" aria-hidden />
|
||||
) : (
|
||||
<Ticket className="size-5 shrink-0" aria-hidden />
|
||||
)}
|
||||
{previewLoading
|
||||
? t("hall.table.previewing")
|
||||
: !isBettable
|
||||
? t("hall.closed.title")
|
||||
: availableMinor < submitActualMinor
|
||||
? t("hall.table.insufficientBalance")
|
||||
: t("hall.table.submitBet")}
|
||||
</Button>
|
||||
{!isMobile ? (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canSubmit || previewLoading}
|
||||
onClick={() => void handlePreview()}
|
||||
className={cn(
|
||||
"h-12 w-full gap-2 rounded-xl border-0 text-base font-bold text-white",
|
||||
!isBettable
|
||||
? "bg-slate-500 shadow-none hover:bg-slate-500 disabled:opacity-100"
|
||||
: "bg-[#e5002c] shadow-[0_8px_20px_rgba(229,0,44,0.26)] hover:bg-[#d10028]",
|
||||
)}
|
||||
>
|
||||
{!isBettable ? (
|
||||
<Lock className="size-5 shrink-0" aria-hidden />
|
||||
) : (
|
||||
<Ticket className="size-5 shrink-0" aria-hidden />
|
||||
)}
|
||||
{previewLoading
|
||||
? t("hall.table.previewing")
|
||||
: !isBettable
|
||||
? t("hall.closed.title")
|
||||
: availableMinor < submitActualMinor
|
||||
? t("hall.table.insufficientBalance")
|
||||
: t("hall.table.submitBet")}
|
||||
</Button>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{isMobile ? (
|
||||
<div
|
||||
className={cn(
|
||||
playerViewportFixedBarClass,
|
||||
"bottom-[calc(3.25rem+env(safe-area-inset-bottom,0px))] z-40",
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto w-full border-t border-[#dfe7f3] bg-white">
|
||||
<div className="flex items-center gap-2 px-3 pt-2 pb-2.5">
|
||||
<div className="min-w-0 flex flex-1 items-center justify-between gap-2">
|
||||
<p className="shrink-0 text-[11px] font-semibold text-slate-500">
|
||||
{t("hall.table.draftTotal")}
|
||||
</p>
|
||||
<p className="truncate text-[19px] font-black tabular-nums text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(debouncedSummary.actual, currencyCode)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canSubmit || previewLoading}
|
||||
onClick={() => void handlePreview()}
|
||||
className={cn(
|
||||
"h-9 min-w-[7.2rem] shrink-0 gap-1.5 rounded-lg border-0 px-3.5 text-[14px] font-bold text-white",
|
||||
!isBettable
|
||||
? "bg-slate-500 shadow-none hover:bg-slate-500 disabled:opacity-100"
|
||||
: "bg-[#e5002c] shadow-none hover:bg-[#d10028]",
|
||||
)}
|
||||
>
|
||||
{!isBettable ? (
|
||||
<Lock className="size-4.5 shrink-0" aria-hidden />
|
||||
) : (
|
||||
<Ticket className="size-4.5 shrink-0" aria-hidden />
|
||||
)}
|
||||
{previewLoading
|
||||
? t("hall.table.previewing")
|
||||
: !isBettable
|
||||
? t("hall.closed.title")
|
||||
: availableMinor < submitActualMinor
|
||||
? t("hall.table.insufficientBalance")
|
||||
: t("hall.table.submitBet")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<HallBetPreviewDialog
|
||||
open={previewOpen}
|
||||
onOpenChange={(open) => {
|
||||
@@ -1524,7 +1920,10 @@ const DraftRowItem = memo(function DraftRowItem({
|
||||
removeRow,
|
||||
t,
|
||||
isMobile,
|
||||
removable
|
||||
removable,
|
||||
indexColClass,
|
||||
numberColClass,
|
||||
stickyNumberLeftClass,
|
||||
}: {
|
||||
row: DraftRow;
|
||||
index: number;
|
||||
@@ -1542,6 +1941,9 @@ const DraftRowItem = memo(function DraftRowItem({
|
||||
t: HallTranslate;
|
||||
isMobile: boolean;
|
||||
removable: boolean;
|
||||
indexColClass: string;
|
||||
numberColClass: string;
|
||||
stickyNumberLeftClass: string;
|
||||
}) {
|
||||
return (
|
||||
<tr
|
||||
@@ -1552,7 +1954,8 @@ const DraftRowItem = memo(function DraftRowItem({
|
||||
>
|
||||
<td
|
||||
className={cn(
|
||||
"sticky left-0 z-20 w-[2.15rem] min-w-[2.15rem] px-0.5 py-1.5 text-center font-black text-[#17408d] shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
|
||||
"sticky left-0 z-20 px-0.5 py-1.5 text-center font-black text-[#17408d] shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
|
||||
indexColClass,
|
||||
rowActive ? "bg-[#f5f9ff]" : "bg-white",
|
||||
)}
|
||||
>
|
||||
@@ -1560,15 +1963,18 @@ const DraftRowItem = memo(function DraftRowItem({
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
"sticky left-[2.15rem] z-20 px-1 py-1.5 shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
|
||||
"w-[5.5rem] min-w-[5.5rem]",
|
||||
"sticky z-20 px-1 py-1.5 shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
|
||||
stickyNumberLeftClass,
|
||||
numberColClass,
|
||||
rowActive ? "bg-[#f5f9ff]" : "bg-white",
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
value={row.number}
|
||||
disabled={tableDisabled}
|
||||
inputMode="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9Rr]*"
|
||||
autoComplete="off"
|
||||
placeholder={numberPlaceholder}
|
||||
onFocus={() => setActiveRowId(row.id)}
|
||||
onClick={() => setActiveRowId(row.id)}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@/features/wallet/wallet-transfer-dialogs";
|
||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||
import { useApiQuery } from "@/hooks/use-api-query";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
||||
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
||||
@@ -30,6 +31,7 @@ export function HallWalletStrip() {
|
||||
const { activeCurrency } = useActivePlayerCurrency();
|
||||
const { mutate } = useSWRConfig();
|
||||
const degradedWalletPollRef = useRef<number | null>(null);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const currency = activeCurrency;
|
||||
const isDegraded = mode === "polling" || mode === "offline";
|
||||
@@ -75,7 +77,7 @@ export function HallWalletStrip() {
|
||||
|
||||
return (
|
||||
<section
|
||||
className="mb-3 space-y-2.5"
|
||||
className={cn("mb-3", isMobile ? "space-y-2" : "space-y-2.5")}
|
||||
aria-label={
|
||||
isCreditPlayer
|
||||
? t("wallet.creditAvailable", { defaultValue: "可用信用" })
|
||||
@@ -84,7 +86,8 @@ export function HallWalletStrip() {
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-xl bg-[#e5002c] px-3 py-3 text-white shadow-[0_10px_28px_rgba(229,0,44,0.25)]",
|
||||
"relative overflow-hidden rounded-xl bg-[#e5002c] text-white shadow-[0_10px_28px_rgba(229,0,44,0.25)]",
|
||||
isMobile ? "px-3 py-2.5" : "px-3 py-3",
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
@@ -95,27 +98,34 @@ export function HallWalletStrip() {
|
||||
className="pointer-events-none object-cover object-center"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="relative flex items-center gap-3">
|
||||
<div className="flex size-14 shrink-0 items-center justify-center rounded-full bg-white text-[#d81435] shadow-sm">
|
||||
<Wallet className="size-7" aria-hidden />
|
||||
<div className={cn("relative flex items-center", isMobile ? "gap-2.5" : "gap-3")}>
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-full bg-white text-[#d81435] shadow-sm",
|
||||
isMobile
|
||||
? "flex size-11 items-center justify-center"
|
||||
: "flex size-14 items-center justify-center",
|
||||
)}
|
||||
>
|
||||
<Wallet className={cn(isMobile ? "size-5.5" : "size-7")} aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-white/90">
|
||||
<p className={cn("font-semibold text-white/90", isMobile ? "text-[13px]" : "text-sm")}>
|
||||
{isCreditPlayer
|
||||
? t("wallet.creditAvailable", { defaultValue: "可用信用" })
|
||||
: t("wallet.balance")}
|
||||
</p>
|
||||
{loading ? (
|
||||
<Skeleton className="mt-2 h-8 w-44 rounded-md bg-white/25" />
|
||||
<Skeleton className={cn("rounded-md bg-white/25", isMobile ? "mt-1.5 h-7 w-36" : "mt-2 h-8 w-44")} />
|
||||
) : (
|
||||
<PlayerMoneyDisplay
|
||||
amountMinor={headlineMinor}
|
||||
currency={currency}
|
||||
className="mt-1 text-white"
|
||||
className={cn("text-white", isMobile ? "mt-0.5" : "mt-1")}
|
||||
/>
|
||||
)}
|
||||
{isCreditPlayer && !loading && balance ? (
|
||||
<p className="mt-2 text-xs text-white/75">
|
||||
<p className={cn("text-white/75", isMobile ? "mt-1 text-[11px]" : "mt-2 text-xs")}>
|
||||
{t("wallet.creditSummary", {
|
||||
defaultValue: "授信 {{limit}} · 已用 {{used}}",
|
||||
limit: formatMinorAsCurrency(balance.credit_limit ?? 0, currency),
|
||||
@@ -128,12 +138,15 @@ export function HallWalletStrip() {
|
||||
</div>
|
||||
|
||||
{isCreditPlayer ? null : (
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<div className={cn("grid grid-cols-2", isMobile ? "gap-2" : "gap-2.5")}>
|
||||
<TransferInDialog
|
||||
idPrefix="hall-"
|
||||
triggerVariant="hall"
|
||||
triggerLabel={t("wallet.transferIn")}
|
||||
triggerClassName="h-12 rounded-lg text-base font-bold"
|
||||
triggerClassName={cn(
|
||||
"rounded-lg font-bold",
|
||||
isMobile ? "h-10 text-sm" : "h-12 text-base",
|
||||
)}
|
||||
currency={currency}
|
||||
lotteryMinor={transferInLotteryMinor}
|
||||
mainMinor={mainMinor}
|
||||
@@ -143,7 +156,10 @@ export function HallWalletStrip() {
|
||||
idPrefix="hall-"
|
||||
triggerVariant="hall"
|
||||
triggerLabel={t("wallet.transferOut")}
|
||||
triggerClassName="h-12 rounded-lg text-base font-bold"
|
||||
triggerClassName={cn(
|
||||
"rounded-lg font-bold",
|
||||
isMobile ? "h-10 text-sm" : "h-12 text-base",
|
||||
)}
|
||||
currency={currency}
|
||||
availableMinor={availableMinor}
|
||||
onSuccess={async () => { await mutate(BALANCE_KEY(currency)); }}
|
||||
|
||||
@@ -83,6 +83,8 @@ export function sortTicketGroupItems(items: TicketItemListRow[]): TicketItemList
|
||||
return [...items].sort((a, b) => {
|
||||
const byPlay = a.play_code.localeCompare(b.play_code);
|
||||
if (byPlay !== 0) return byPlay;
|
||||
const byProvider = (a.provider_code ?? "").localeCompare(b.provider_code ?? "");
|
||||
if (byProvider !== 0) return byProvider;
|
||||
return (a.original_number ?? "").localeCompare(b.original_number ?? "");
|
||||
});
|
||||
}
|
||||
@@ -135,4 +137,4 @@ export function groupTicketItems(items: TicketItemListRow[]): TicketItemGroup[]
|
||||
|
||||
export function ticketDetailHref(ticketNo: string): string {
|
||||
return `/orders/${encodeURIComponent(ticketNo)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,10 @@ type TicketItemDetailWithExtras = TicketItemDetailPayload & {
|
||||
};
|
||||
};
|
||||
|
||||
function providerLabel(providerName?: string | null, providerCode?: string | null): string {
|
||||
return providerName || providerCode || "-";
|
||||
}
|
||||
|
||||
/** 界面文档 §4.8 注单详情 */
|
||||
export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
const { t } = useTranslation("player");
|
||||
@@ -282,6 +286,10 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
{playLabel(row.play_code, t)} · {row.original_number ?? row.play_code}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500">
|
||||
<span className="font-bold text-[#0b56b7]">
|
||||
{providerLabel(row.provider_name, row.provider_code)}
|
||||
</span>
|
||||
{" · "}
|
||||
{t("orders.deduction")}{" "}
|
||||
<span className="font-bold tabular-nums text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(row.actual_deduct_amount, lineCur)}
|
||||
@@ -356,6 +364,14 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
{playLabel(data.play_code, t)} ({data.dimension ?? "—"}D)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="shrink-0 text-slate-500">
|
||||
{t("hall.providers.title", { defaultValue: "开注商" })}
|
||||
</span>
|
||||
<span className="text-right font-black text-[#0b56b7]">
|
||||
{providerLabel(data.provider_name, data.provider_code)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="shrink-0 text-slate-500">{t("orders.amount")}</span>
|
||||
<span className="text-right font-black tabular-nums text-[#d81435]">
|
||||
|
||||
@@ -47,6 +47,10 @@ const STATUS_OPTIONS = [
|
||||
"refunded",
|
||||
] as const;
|
||||
|
||||
function providerLabel(providerName?: string | null, providerCode?: string | null): string {
|
||||
return providerName || providerCode || "-";
|
||||
}
|
||||
|
||||
export function TicketOrdersListScreen() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -528,6 +532,10 @@ export function TicketOrdersListScreen() {
|
||||
{playLabel(row.play_code, t)} · {row.original_number ?? row.play_code}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500">
|
||||
<span className="font-bold text-[#0b56b7]">
|
||||
{providerLabel(row.provider_name, row.provider_code)}
|
||||
</span>
|
||||
{" · "}
|
||||
{t("orders.deduction")}{" "}
|
||||
<span className="font-bold tabular-nums text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(row.actual_deduct_amount, lineCur)}
|
||||
@@ -573,4 +581,3 @@ export function TicketOrdersListScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -255,7 +255,10 @@
|
||||
"quickFill": {
|
||||
"title": "Quick fill",
|
||||
"description": "Fill the current row with favorite or recent numbers in one tap.",
|
||||
"batchTitle": "Quick amount",
|
||||
"clearAll": "Clear all",
|
||||
"clearRow": "Clear row amounts",
|
||||
"copyPrev": "Copy prev row",
|
||||
"favorite": "Favorite number",
|
||||
"unfavorite": "Remove favorite",
|
||||
"favorites": "Favorites",
|
||||
@@ -264,6 +267,13 @@
|
||||
"history": "Recent 20 numbers",
|
||||
"emptyHistory": "No recent numbers"
|
||||
},
|
||||
"mobile": {
|
||||
"availableBalance": "Available {{amount}}",
|
||||
"quickFillSummary": "{{favorites}} favorites, {{history}} recent",
|
||||
"collapseQuickFill": "Collapse quick fill",
|
||||
"expandQuickFill": "Expand quick fill",
|
||||
"noProvidersSelected": "None selected"
|
||||
},
|
||||
"preview": {
|
||||
"title": "Confirm bet",
|
||||
"description": "Check the number, play type, and actual deduction. After confirmation, the lottery wallet will be debited and the ticket cannot be cancelled.",
|
||||
|
||||
@@ -255,7 +255,10 @@
|
||||
"quickFill": {
|
||||
"title": "छिटो भर्ने",
|
||||
"description": "मनपर्ने वा पछिल्ला नम्बरहरू एक ट्यापमा हालको पंक्तिमा भर्नुहोस्।",
|
||||
"batchTitle": "छिटो रकम",
|
||||
"clearAll": "सबै हटाउनुहोस्",
|
||||
"clearRow": "यो पङ्क्तिको रकम हटाउनुहोस्",
|
||||
"copyPrev": "अघिल्लो पङ्क्ति कपी गर्नुहोस्",
|
||||
"favorite": "नम्बर मनपर्नेमा राख्नुहोस्",
|
||||
"unfavorite": "मनपर्नेबाट हटाउनुहोस्",
|
||||
"favorites": "मनपर्ने",
|
||||
@@ -264,6 +267,13 @@
|
||||
"history": "पछिल्ला 20 नम्बर",
|
||||
"emptyHistory": "इतिहास नम्बर छैन"
|
||||
},
|
||||
"mobile": {
|
||||
"availableBalance": "उपलब्ध मौज्दात {{amount}}",
|
||||
"quickFillSummary": "{{favorites}} मनपर्ने, {{history}} इतिहास",
|
||||
"collapseQuickFill": "छिटो भर्ने खुम्च्याउनुहोस्",
|
||||
"expandQuickFill": "छिटो भर्ने विस्तार गर्नुहोस्",
|
||||
"noProvidersSelected": "छानिएको छैन"
|
||||
},
|
||||
"preview": {
|
||||
"title": "बेट पुष्टि गर्नुहोस्",
|
||||
"description": "नम्बर, प्ले प्रकार र वास्तविक कट्टा जाँच गर्नुहोस्। पुष्टि गरेपछि वालेटबाट रकम कट्टा हुनेछ र टिकट रद्द गर्न सकिँदैन।",
|
||||
|
||||
@@ -254,7 +254,10 @@
|
||||
"quickFill": {
|
||||
"title": "快速填单",
|
||||
"description": "收藏号码、最近号码可一键填入当前行。",
|
||||
"batchTitle": "快捷金额",
|
||||
"clearAll": "批量清空",
|
||||
"clearRow": "清空本行金额",
|
||||
"copyPrev": "复制上一行",
|
||||
"favorite": "收藏号码",
|
||||
"unfavorite": "取消收藏",
|
||||
"favorites": "收藏",
|
||||
@@ -263,6 +266,13 @@
|
||||
"history": "最近 20 个历史号码",
|
||||
"emptyHistory": "暂无历史号码"
|
||||
},
|
||||
"mobile": {
|
||||
"availableBalance": "可用余额 {{amount}}",
|
||||
"quickFillSummary": "收藏 {{favorites}} 个,历史 {{history}} 个",
|
||||
"collapseQuickFill": "收起快捷填单",
|
||||
"expandQuickFill": "展开快捷填单",
|
||||
"noProvidersSelected": "未选择"
|
||||
},
|
||||
"preview": {
|
||||
"title": "确认下注",
|
||||
"description": "请核对号码、玩法与实扣金额;确认后将扣减彩票钱包且不可撤单。",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/** 主滚动区外边距(灰底区域与顶栏/底栏留白) */
|
||||
export const playerMainInset = "px-3 pt-3 pb-3 lg:px-8 lg:pt-6 lg:pb-8";
|
||||
export const playerMainInset = "px-2 pt-2 pb-2 lg:px-6 lg:pt-4 lg:pb-6";
|
||||
|
||||
/** 页内白卡片内边距 */
|
||||
export const playerPageShellPadding = "px-3 pt-3 pb-6 lg:px-8 lg:pt-8 lg:pb-8";
|
||||
export const playerPageShellPadding = "px-2 pt-2 pb-4 lg:px-6 lg:pt-6 lg:pb-6";
|
||||
|
||||
/** 大厅等直接写在 section 上的内边距 */
|
||||
export const playerPageInset = playerPageShellPadding;
|
||||
|
||||
10
src/types/api/bet-provider.ts
Normal file
10
src/types/api/bet-provider.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export type BetProviderRow = {
|
||||
code: string;
|
||||
name: string;
|
||||
sort_order: number;
|
||||
is_default: boolean;
|
||||
};
|
||||
|
||||
export type BetProvidersData = {
|
||||
items: BetProviderRow[];
|
||||
};
|
||||
@@ -6,6 +6,8 @@ export type TicketItemListRow = {
|
||||
order_status?: string | null;
|
||||
draw_no: string | null | undefined;
|
||||
currency_code: string | null | undefined;
|
||||
provider_code?: string | null;
|
||||
provider_name?: string | null;
|
||||
play_code: string;
|
||||
original_number: string | null;
|
||||
total_bet_amount: number;
|
||||
@@ -38,6 +40,8 @@ export type TicketItemDetailPayload = {
|
||||
order_status?: string | null;
|
||||
draw_no: string | null | undefined;
|
||||
currency_code: string | null | undefined;
|
||||
provider_code?: string | null;
|
||||
provider_name?: string | null;
|
||||
play_code: string;
|
||||
dimension: number | null;
|
||||
digit_slot: number | null;
|
||||
|
||||
@@ -17,6 +17,7 @@ export type TicketPreviewPayload = {
|
||||
draw_id: string;
|
||||
currency_code: string;
|
||||
client_trace_id?: string | null;
|
||||
provider_codes?: string[];
|
||||
lines: TicketLineInput[];
|
||||
};
|
||||
|
||||
@@ -26,6 +27,8 @@ export type TicketPlacePayload = TicketPreviewPayload & {
|
||||
|
||||
export type TicketPreviewLine = {
|
||||
client_line_no: number;
|
||||
provider_code?: string;
|
||||
provider_name?: string;
|
||||
number: string;
|
||||
play_code: string;
|
||||
normalized_number: string;
|
||||
@@ -61,6 +64,8 @@ export type TicketPreviewData = {
|
||||
|
||||
export type TicketPlaceItem = {
|
||||
ticket_no: string;
|
||||
provider_code?: string;
|
||||
provider_name?: string;
|
||||
play_code: string;
|
||||
number: string;
|
||||
total_bet_amount: number;
|
||||
|
||||
Reference in New Issue
Block a user