feat: 接入公开币种目录并统一多币种金额与语言初始化处理
This commit is contained in:
@@ -6,6 +6,7 @@ const lotteryApiProxyTarget =
|
||||
process.env.LOTTERY_API_PROXY_TARGET?.trim() || "http://127.0.0.1:8000";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
allowedDevOrigins: ["192.168.0.101"],
|
||||
reactCompiler: true,
|
||||
|
||||
// 安全头配置 - 支持 iframe 嵌入
|
||||
|
||||
8
src/api/currency.ts
Normal file
8
src/api/currency.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { API_V1_PREFIX } from "@/api/paths";
|
||||
import { lotteryRequest } from "@/lib/lottery-http";
|
||||
import type { PublicCurrencyListData } from "@/types/api/currency";
|
||||
|
||||
/** `GET /api/v1/currencies`(公开) */
|
||||
export function getPublicCurrencies(): Promise<PublicCurrencyListData> {
|
||||
return lotteryRequest.get<PublicCurrencyListData>(`${API_V1_PREFIX}/currencies`);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export { API_V1_PREFIX } from "@/api/paths";
|
||||
export { getHealth } from "@/api/health";
|
||||
export { getPublicCurrencies } from "@/api/currency";
|
||||
export { getPlayerPing, getPlayerMe } from "@/api/player";
|
||||
export {
|
||||
getDrawCurrent,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import "@/i18n";
|
||||
|
||||
import { ChevronDown, Globe } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { ErrorProvider } from "@/components/error-provider";
|
||||
import { IframeBridge } from "@/components/iframe-bridge";
|
||||
import { TokenRefreshIndicator } from "@/components/token-refresh-indicator";
|
||||
import "@/i18n";
|
||||
import { syncPreferredLanguage } from "@/i18n";
|
||||
|
||||
type ProvidersProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function Providers({ children }: ProvidersProps): ReactNode {
|
||||
useEffect(() => {
|
||||
syncPreferredLanguage();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ErrorProvider>
|
||||
|
||||
@@ -21,11 +21,14 @@ import {
|
||||
ticketNumberSpec,
|
||||
} from "@/features/hall/hall-bet-rules";
|
||||
import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live";
|
||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||
import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling";
|
||||
import { getLotteryEcho } from "@/lib/lottery-echo";
|
||||
import { getLotteryRequestLocale } from "@/lib/lottery-locale";
|
||||
import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money";
|
||||
import { resolvePlayerCurrency } from "@/lib/player-currency";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { PlayEffectivePayload, PlayEffectivePlayRow } from "@/types/api/play-effective";
|
||||
import type { TicketLineInput, TicketPlaceData, TicketPreviewData } from "@/types/api/ticket";
|
||||
@@ -391,6 +394,7 @@ function quickFillKeys(category: HallCategory): { favorites: string; history: st
|
||||
export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }) {
|
||||
const { display, isBettable, reload: reloadDraw } = drawLive;
|
||||
const { t } = useTranslation("player");
|
||||
const profile = usePlayerSessionStore((s) => s.profile);
|
||||
|
||||
const [activeCategory, setActiveCategory] = useState<HallCategory>("D2");
|
||||
const [rows, setRows] = useState<DraftRow[]>(() => [newDraftRow()]);
|
||||
@@ -414,18 +418,14 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
number: null,
|
||||
longPress: false,
|
||||
});
|
||||
useCurrencyCatalog();
|
||||
|
||||
const currencyParam = useMemo(() => {
|
||||
const fromEnv = process.env.NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY?.trim();
|
||||
return fromEnv !== undefined && fromEnv !== "" ? fromEnv : undefined;
|
||||
}, []);
|
||||
const currencyParam = useMemo(() => resolvePlayerCurrency(profile), [profile]);
|
||||
|
||||
const loadCatalog = useCallback(async () => {
|
||||
setCatalogState((s) => (s.kind === "ok" ? s : { kind: "loading" }));
|
||||
try {
|
||||
const data = await getPlayEffective(
|
||||
currencyParam !== undefined ? { currency: currencyParam } : undefined,
|
||||
);
|
||||
const data = await getPlayEffective({ currency: currencyParam });
|
||||
setCatalogState({ kind: "ok", data });
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("hall.loadingError");
|
||||
@@ -435,9 +435,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
|
||||
const refreshWallet = useCallback(async () => {
|
||||
try {
|
||||
const wallet = await getWalletBalance(
|
||||
currencyParam !== undefined ? { currency: currencyParam } : undefined,
|
||||
);
|
||||
const wallet = await getWalletBalance({ currency: currencyParam });
|
||||
setAvailableMinor(Number(wallet.available_balance ?? 0));
|
||||
} catch {
|
||||
setAvailableMinor(0);
|
||||
@@ -470,7 +468,8 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
);
|
||||
}, [activeCategory, catalogState]);
|
||||
|
||||
const currencyCode = catalogState.kind === "ok" ? catalogState.data.currency_code : "NPR";
|
||||
const currencyCode =
|
||||
catalogState.kind === "ok" ? catalogState.data.currency_code : currencyParam;
|
||||
|
||||
const categoryPlays = useMemo(() => {
|
||||
if (catalogState.kind !== "ok") return [];
|
||||
@@ -656,7 +655,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
const entries: DraftEntry[] = [];
|
||||
rows.forEach((row, rowIndex) => {
|
||||
playColumns.forEach((column) => {
|
||||
const amount = parseDecimalInputToMinor(row.amounts[column.key] ?? "");
|
||||
const amount = parseDecimalInputToMinor(row.amounts[column.key] ?? "", currencyCode);
|
||||
if (amount === null || amount <= 0) return;
|
||||
const line = lineForPlay(activeCategory, column.play, row.number, amount, column.digitSlot);
|
||||
if (!line) return;
|
||||
@@ -673,7 +672,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
});
|
||||
});
|
||||
return entries;
|
||||
}, [activeCategory, playColumns, rows]);
|
||||
}, [activeCategory, currencyCode, playColumns, rows]);
|
||||
|
||||
const draftEntries = collectEntries();
|
||||
const draftSummary = useMemo(() => {
|
||||
|
||||
@@ -22,7 +22,9 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { getLotteryRequestLocale } from "@/lib/lottery-locale";
|
||||
import { resolvePlayerCurrency } from "@/lib/player-currency";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type {
|
||||
PlayEffectivePayload,
|
||||
@@ -77,19 +79,15 @@ function formatMoneyAmount(n: number): string {
|
||||
}
|
||||
|
||||
export function HallPlayCatalogPanel() {
|
||||
const profile = usePlayerSessionStore((s) => s.profile);
|
||||
const { t } = useTranslation("player");
|
||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
||||
const currencyParam = useMemo(() => {
|
||||
const fromEnv = process.env.NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY?.trim();
|
||||
return fromEnv !== undefined && fromEnv !== "" ? fromEnv : undefined;
|
||||
}, []);
|
||||
const currencyParam = useMemo(() => resolvePlayerCurrency(profile), [profile]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setState((s) => (s.kind === "ok" ? s : { kind: "loading" }));
|
||||
try {
|
||||
const data = await getPlayEffective(
|
||||
currencyParam !== undefined ? { currency: currencyParam } : undefined,
|
||||
);
|
||||
const data = await getPlayEffective({ currency: currencyParam });
|
||||
setState({ kind: "ok", data });
|
||||
} catch (e) {
|
||||
if (e instanceof LotteryApiBizError && e.code === 9004) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
TransferOutDialog,
|
||||
} from "@/features/wallet/wallet-transfer-dialogs";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { resolvePlayerCurrency } from "@/lib/player-currency";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useNetworkConnectionStore } from "@/stores/network-connection-store";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
@@ -26,9 +27,8 @@ export function HallWalletStrip() {
|
||||
const degradedWalletPollRef = useRef<number | null>(null);
|
||||
|
||||
const currency = useMemo(
|
||||
() =>
|
||||
(balance?.currency_code ?? profile?.default_currency ?? "NPR").toUpperCase(),
|
||||
[balance?.currency_code, profile?.default_currency],
|
||||
() => resolvePlayerCurrency(profile, balance),
|
||||
[balance, profile],
|
||||
);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
|
||||
@@ -16,12 +16,15 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
|
||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||
import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
|
||||
import { formatLotteryInstant } from "@/lib/player-datetime";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { norm4d } from "@/lib/norm-4d";
|
||||
import { resolvePlayerCurrency } from "@/lib/player-currency";
|
||||
import { playLabel } from "@/lib/play-labels";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import type { TicketItemDetailPayload } from "@/types/api/ticket-items";
|
||||
|
||||
type OddsSnapRow = { prize_scope?: string; odds_value?: number };
|
||||
@@ -66,6 +69,8 @@ type TicketItemDetailWithExtras = TicketItemDetailPayload & {
|
||||
/** 界面文档 §4.8 注单详情 */
|
||||
export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
const { t } = useTranslation("player");
|
||||
const profile = usePlayerSessionStore((state) => state.profile);
|
||||
useCurrencyCatalog();
|
||||
const [data, setData] = useState<TicketItemDetailPayload | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -131,7 +136,7 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const cur = data.currency_code ?? "NPR";
|
||||
const cur = data.currency_code ?? resolvePlayerCurrency(profile);
|
||||
const st = ticketStatusDisplay(data.status, data.win_amount, data.jackpot_win_amount, t);
|
||||
const totalWin = data.win_amount + data.jackpot_win_amount;
|
||||
const pub = data.published_draw_results;
|
||||
|
||||
@@ -15,11 +15,14 @@ import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
|
||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { formatLotteryInstant } from "@/lib/player-datetime";
|
||||
import { resolvePlayerCurrency } from "@/lib/player-currency";
|
||||
import { playLabel } from "@/lib/play-labels";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import type { TicketItemListRow } from "@/types/api/ticket-items";
|
||||
|
||||
const ORDERS_PAGE_SIZE = 20;
|
||||
@@ -42,6 +45,8 @@ function formatYmd(value: Date): string {
|
||||
export function TicketOrdersListScreen() {
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useTranslation("player");
|
||||
const profile = 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),
|
||||
@@ -353,7 +358,7 @@ export function TicketOrdersListScreen() {
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
{items.map((row) => {
|
||||
const cur = row.currency_code ?? "NPR";
|
||||
const cur = row.currency_code ?? resolvePlayerCurrency(profile);
|
||||
const st = ticketStatusDisplay(row.status, row.win_amount, row.jackpot_win_amount, t);
|
||||
const totalWin = row.win_amount + row.jackpot_win_amount;
|
||||
return (
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getPublicCurrencies } from "@/api/currency";
|
||||
import { getPlayerMe, getPlayerPing } from "@/api/player";
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
@@ -93,7 +94,7 @@ export function EntryGate() {
|
||||
|
||||
const tokenFromUrl = searchParams.get("token") ?? "";
|
||||
|
||||
const { bearerToken, setBearerToken, setProfile, clearBearerToken } =
|
||||
const { bearerToken, setBearerToken, setProfile, setCurrencies, clearBearerToken } =
|
||||
usePlayerSessionStore();
|
||||
|
||||
const [phase, setPhase] = useState<Phase>("loading");
|
||||
@@ -141,6 +142,13 @@ export function EntryGate() {
|
||||
try {
|
||||
const [me] = await Promise.all([getPlayerMe(), sleep(300)]);
|
||||
|
||||
try {
|
||||
const currencies = await getPublicCurrencies();
|
||||
setCurrencies(currencies.items);
|
||||
} catch {
|
||||
// 不阻断进场主流程;金额精度会退回默认 2 位兜底。
|
||||
}
|
||||
|
||||
updateStep("token", "done");
|
||||
updateStep("account", "done");
|
||||
|
||||
@@ -216,6 +224,7 @@ export function EntryGate() {
|
||||
tokenFromUrl,
|
||||
setBearerToken,
|
||||
setProfile,
|
||||
setCurrencies,
|
||||
clearBearerToken,
|
||||
router,
|
||||
updateStep,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { getPublicCurrencies } from "@/api/currency";
|
||||
import { getPlayerMe } from "@/api/player";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
|
||||
@@ -14,20 +15,31 @@ export function HydratePlayerAuth(): null {
|
||||
(state) => state.restoreBearerToken,
|
||||
);
|
||||
const setProfile = usePlayerSessionStore((state) => state.setProfile);
|
||||
const setCurrencies = usePlayerSessionStore((state) => state.setCurrencies);
|
||||
|
||||
useEffect(() => {
|
||||
const token = restoreBearerToken();
|
||||
if (!token) return;
|
||||
if (usePlayerSessionStore.getState().profile !== null) return;
|
||||
void (async () => {
|
||||
try {
|
||||
if (usePlayerSessionStore.getState().currencies.length === 0) {
|
||||
try {
|
||||
const currencies = await getPublicCurrencies();
|
||||
setCurrencies(currencies.items);
|
||||
} catch {
|
||||
// 币种元数据失败时不影响登录态恢复。
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) return;
|
||||
if (usePlayerSessionStore.getState().profile !== null) return;
|
||||
|
||||
const me = await getPlayerMe();
|
||||
setProfile(me);
|
||||
} catch {
|
||||
/* 401 由 lottery-http 拦截跳转 */
|
||||
}
|
||||
})();
|
||||
}, [restoreBearerToken, setProfile]);
|
||||
}, [restoreBearerToken, setCurrencies, setProfile]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -17,9 +17,12 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { formatLotteryInstant } from "@/lib/player-datetime";
|
||||
import { playLabel } from "@/lib/play-labels";
|
||||
import { resolvePlayerCurrency } from "@/lib/player-currency";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import type { DrawResultListItem } from "@/types/api/draw-results";
|
||||
import type { TicketDrawMyMatchPayload, TicketItemListRow } from "@/types/api/ticket-items";
|
||||
|
||||
@@ -31,6 +34,7 @@ type WinningCheckResult = {
|
||||
|
||||
export function CheckWinningScreen() {
|
||||
const { t } = useTranslation("player");
|
||||
useCurrencyCatalog();
|
||||
const [ticketNo, setTicketNo] = useState("");
|
||||
const [latestDraw, setLatestDraw] = useState<DrawResultListItem | null>(null);
|
||||
const [recent, setRecent] = useState<string[]>([]);
|
||||
@@ -197,9 +201,11 @@ function WinningResultDialog({
|
||||
onCheckAnother: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("player");
|
||||
const profile = usePlayerSessionStore((state) => state.profile);
|
||||
const totalWin = (data?.match.total_win_minor ?? 0) + (data?.match.total_jackpot_win_minor ?? 0);
|
||||
const isWon = totalWin > 0 || (data?.match.winning_ticket_count ?? 0) > 0;
|
||||
const firstTicket = useMemo(() => data?.tickets[0] ?? null, [data]);
|
||||
const currency = firstTicket?.currency_code ?? resolvePlayerCurrency(profile);
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
@@ -250,7 +256,7 @@ function WinningResultDialog({
|
||||
{t("results.check.amount")}
|
||||
</p>
|
||||
<p className="mt-2 font-mono text-lg font-black text-[#0a8f3e]">
|
||||
{formatMinorAsCurrency(totalWin, firstTicket?.currency_code ?? "NPR")}
|
||||
{formatMinorAsCurrency(totalWin, currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,11 +18,14 @@ import {
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip";
|
||||
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
|
||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||
import { getPlayerBearerTokenPayload } from "@/lib/lottery-auth";
|
||||
import { formatLotteryInstant } from "@/lib/player-datetime";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { norm4d } from "@/lib/norm-4d";
|
||||
import { resolvePlayerCurrency } from "@/lib/player-currency";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import type { DrawResultDetailPayload } from "@/types/api/draw-results";
|
||||
|
||||
type DrawResultDetailScreenProps = {
|
||||
@@ -32,6 +35,8 @@ type DrawResultDetailScreenProps = {
|
||||
/** §4.6 开奖结果详情:23 分区 + [< >] 切换 + 本人命中高亮 + Jackpot */
|
||||
export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) {
|
||||
const { t } = useTranslation("player");
|
||||
const profile = usePlayerSessionStore((state) => state.profile);
|
||||
useCurrencyCatalog();
|
||||
const [data, setData] = useState<DrawResultDetailPayload | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -135,7 +140,7 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
||||
);
|
||||
}
|
||||
|
||||
const currency = "NPR";
|
||||
const currency = data.jackpot?.currency_code ?? resolvePlayerCurrency(profile);
|
||||
const showMyPayout =
|
||||
myTotals &&
|
||||
myTotals.hasBets &&
|
||||
|
||||
@@ -20,7 +20,10 @@ import {
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip";
|
||||
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
|
||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||
import { formatLotteryInstant } from "@/lib/player-datetime";
|
||||
import { resolvePlayerCurrency } from "@/lib/player-currency";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import type { DrawResultListItem } from "@/types/api/draw-results";
|
||||
|
||||
const RESULTS_PAGE_SIZE = 10;
|
||||
@@ -32,6 +35,8 @@ const MONTH_OPTIONS = Array.from({ length: 12 }, (_, value) => ({
|
||||
|
||||
export function DrawResultsListScreen() {
|
||||
const { t } = useTranslation("player");
|
||||
const profile = usePlayerSessionStore((state) => state.profile);
|
||||
useCurrencyCatalog();
|
||||
const [items, setItems] = useState<DrawResultListItem[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [date, setDate] = useState("");
|
||||
@@ -46,6 +51,7 @@ export function DrawResultsListScreen() {
|
||||
const businessDate = /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : undefined;
|
||||
const quickYears = useMemo(() => buildYearOptions(calendarMonth), [calendarMonth]);
|
||||
const featured = items?.[0] ?? null;
|
||||
const jackpotCurrency = featured?.jackpot?.currency_code ?? resolvePlayerCurrency(profile);
|
||||
|
||||
const fetchList = useCallback(async (targetPage = 1, append = false) => {
|
||||
setError(null);
|
||||
@@ -104,7 +110,7 @@ export function DrawResultsListScreen() {
|
||||
return (
|
||||
<PlayerPanel title={t("results.title")} subtitle={t("results.subtitle")} eyebrow={t("brand.name")}>
|
||||
<div className="space-y-4">
|
||||
<JackpotResultsStrip currencyCode="NPR" />
|
||||
<JackpotResultsStrip currencyCode={jackpotCurrency} />
|
||||
|
||||
<div className="rounded-xl border border-[#e6edf8] bg-[#f8fbff] p-3">
|
||||
<p className="mb-2 text-xs font-bold text-[#32518d]">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { getWalletBalance } from "@/api/wallet";
|
||||
import { TransferInPage } from "@/features/wallet/wallet-transfer-forms";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { resolvePlayerCurrency } from "@/lib/player-currency";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
||||
|
||||
@@ -17,9 +18,8 @@ export function TransferInScreen() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const currency = useMemo(
|
||||
() =>
|
||||
(balance?.currency_code ?? profile?.default_currency ?? "NPR").toUpperCase(),
|
||||
[balance?.currency_code, profile?.default_currency],
|
||||
() => resolvePlayerCurrency(profile, balance),
|
||||
[balance, profile],
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { getWalletBalance } from "@/api/wallet";
|
||||
import { TransferOutPage } from "@/features/wallet/wallet-transfer-forms";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { resolvePlayerCurrency } from "@/lib/player-currency";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
||||
|
||||
@@ -17,9 +18,8 @@ export function TransferOutScreen() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const currency = useMemo(
|
||||
() =>
|
||||
(balance?.currency_code ?? profile?.default_currency ?? "NPR").toUpperCase(),
|
||||
[balance?.currency_code, profile?.default_currency],
|
||||
() => resolvePlayerCurrency(profile, balance),
|
||||
[balance, profile],
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getWalletLogs } from "@/api/wallet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
|
||||
import { resolvePlayerCurrency } from "@/lib/player-currency";
|
||||
import { formatWalletClientError } from "@/lib/wallet-api-error";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import { getWalletLogsLastPage, type WalletLogsData } from "@/types/api/wallet-logs";
|
||||
@@ -25,8 +26,8 @@ export function WalletLogsScreen() {
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const currency = useMemo(
|
||||
() => (profile?.default_currency ?? "NPR").toUpperCase(),
|
||||
[profile?.default_currency],
|
||||
() => resolvePlayerCurrency(profile),
|
||||
[profile],
|
||||
);
|
||||
|
||||
const fetchPassRef = useRef(true);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@/features/wallet/wallet-transfer-dialogs";
|
||||
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { resolvePlayerCurrency } from "@/lib/player-currency";
|
||||
import { formatWalletClientError } from "@/lib/wallet-api-error";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
||||
@@ -34,13 +35,10 @@ export function WalletScreen() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const currency = useMemo(() => {
|
||||
return (
|
||||
balance?.currency_code ??
|
||||
profile?.default_currency ??
|
||||
"NPR"
|
||||
).toUpperCase();
|
||||
}, [balance?.currency_code, profile?.default_currency]);
|
||||
const currency = useMemo(
|
||||
() => resolvePlayerCurrency(profile, balance),
|
||||
[balance, profile],
|
||||
);
|
||||
|
||||
const fetchPassRef = useRef(true);
|
||||
|
||||
|
||||
@@ -18,7 +18,12 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money";
|
||||
import {
|
||||
formatMinorAsCurrency,
|
||||
getCurrencyDecimalPlaces,
|
||||
parseDecimalInputToMinor,
|
||||
} from "@/lib/money";
|
||||
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
|
||||
import { formatWalletClientError } from "@/lib/wallet-api-error";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
@@ -64,14 +69,15 @@ export function TransferInPanel({
|
||||
variant?: PanelVariant;
|
||||
}) {
|
||||
const { t } = useTranslation("player");
|
||||
useCurrencyCatalog();
|
||||
const [amountText, setAmountText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const tid = `${idPrefix}in-amount`;
|
||||
|
||||
const parsedMinor = useMemo(
|
||||
() => parseDecimalInputToMinor(amountText),
|
||||
[amountText],
|
||||
() => parseDecimalInputToMinor(amountText, currency),
|
||||
[amountText, currency],
|
||||
);
|
||||
const previewAfter =
|
||||
parsedMinor != null ? lotteryMinor + parsedMinor : lotteryMinor;
|
||||
@@ -204,14 +210,15 @@ export function TransferOutPanel({
|
||||
variant?: PanelVariant;
|
||||
}) {
|
||||
const { t } = useTranslation("player");
|
||||
useCurrencyCatalog();
|
||||
const [amountText, setAmountText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const tid = `${idPrefix}out-amount`;
|
||||
|
||||
const parsedMinor = useMemo(
|
||||
() => parseDecimalInputToMinor(amountText),
|
||||
[amountText],
|
||||
() => parseDecimalInputToMinor(amountText, currency),
|
||||
[amountText, currency],
|
||||
);
|
||||
const previewAfter =
|
||||
parsedMinor != null
|
||||
@@ -219,8 +226,9 @@ export function TransferOutPanel({
|
||||
: availableMinor;
|
||||
|
||||
const fillAll = () => {
|
||||
const major = availableMinor / 100;
|
||||
setAmountText(major.toFixed(2));
|
||||
const decimals = getCurrencyDecimalPlaces(currency);
|
||||
const major = availableMinor / 10 ** decimals;
|
||||
setAmountText(major.toFixed(decimals));
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
|
||||
32
src/hooks/use-currency-catalog.ts
Normal file
32
src/hooks/use-currency-catalog.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { getPublicCurrencies } from "@/api/currency";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
|
||||
let inflightCurrencyLoad: Promise<void> | null = null;
|
||||
|
||||
export function useCurrencyCatalog() {
|
||||
const currencies = usePlayerSessionStore((state) => state.currencies);
|
||||
const setCurrencies = usePlayerSessionStore((state) => state.setCurrencies);
|
||||
|
||||
useEffect(() => {
|
||||
if (currencies.length > 0 || inflightCurrencyLoad !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
inflightCurrencyLoad = getPublicCurrencies()
|
||||
.then((data) => {
|
||||
setCurrencies(data.items);
|
||||
})
|
||||
.catch(() => {
|
||||
// 币种元数据失败时退回默认 2 位小数,不打断主流程。
|
||||
})
|
||||
.finally(() => {
|
||||
inflightCurrencyLoad = null;
|
||||
});
|
||||
}, [currencies.length, setCurrencies]);
|
||||
|
||||
return currencies;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import i18n from "i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
import enCommon from "./locales/en/common.json";
|
||||
@@ -60,38 +59,48 @@ export function syncDocumentLanguage(lang: AppLanguage): void {
|
||||
document.documentElement.lang = lang;
|
||||
}
|
||||
|
||||
export function syncPreferredLanguage(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const stored = window.localStorage.getItem("i18nextLng");
|
||||
const preferred = normalizeLanguage(stored ?? window.navigator.language);
|
||||
const current = normalizeLanguage(i18n.resolvedLanguage ?? i18n.language);
|
||||
|
||||
if (preferred !== current) {
|
||||
void i18n.changeLanguage(preferred);
|
||||
}
|
||||
}
|
||||
|
||||
if (!i18n.isInitialized) {
|
||||
void i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources,
|
||||
fallbackLng: DEFAULT_LANGUAGE,
|
||||
supportedLngs: ["en", "ne", "zh"],
|
||||
defaultNS: "common",
|
||||
ns: [...namespaces],
|
||||
/** zh-CN → zh,ne-NP → ne,未匹配时用 fallbackLng */
|
||||
load: "languageOnly",
|
||||
void i18n.use(initReactI18next).init({
|
||||
resources,
|
||||
fallbackLng: DEFAULT_LANGUAGE,
|
||||
supportedLngs: ["en", "ne", "zh"],
|
||||
defaultNS: "common",
|
||||
ns: [...namespaces],
|
||||
/** 始终先用默认语言完成 SSR / 首次 hydration,避免首屏文本不一致 */
|
||||
load: "languageOnly",
|
||||
lng: DEFAULT_LANGUAGE,
|
||||
initImmediate: false,
|
||||
|
||||
detection: {
|
||||
order: ["localStorage", "navigator"],
|
||||
caches: ["localStorage"],
|
||||
lookupLocalStorage: "i18nextLng",
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
|
||||
react: {
|
||||
useSuspense: false,
|
||||
},
|
||||
});
|
||||
react: {
|
||||
useSuspense: false,
|
||||
},
|
||||
});
|
||||
|
||||
syncDocumentLanguage(normalizeLanguage(i18n.resolvedLanguage ?? i18n.language));
|
||||
|
||||
i18n.on("languageChanged", (lang) => {
|
||||
syncDocumentLanguage(normalizeLanguage(lang));
|
||||
const next = normalizeLanguage(lang);
|
||||
syncDocumentLanguage(next);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem("i18nextLng", next);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,12 @@ const ALLOWED_PARENT_ORIGINS: string[] = [
|
||||
process.env.NEXT_PUBLIC_MAIN_SITE_URL,
|
||||
process.env.NEXT_PUBLIC_PARENT_ORIGIN,
|
||||
// 开发环境
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://192.168.0.101:5173",
|
||||
"http://localhost:3801",
|
||||
"http://127.0.0.1:3801",
|
||||
"http://192.168.0.101:3801",
|
||||
// 生产环境应从环境变量读取
|
||||
].filter((o): o is string => Boolean(o));
|
||||
|
||||
@@ -58,8 +62,6 @@ export function generateCSP(): string {
|
||||
// 表单提交允许同源
|
||||
"form-action": ["'self'"],
|
||||
|
||||
// 不升级 HTTPS
|
||||
"upgrade-insecure-requests": [],
|
||||
};
|
||||
|
||||
// 构建 CSP 字符串
|
||||
@@ -90,10 +92,6 @@ export const securityHeaders = [
|
||||
key: "Content-Security-Policy",
|
||||
value: generateCSP(),
|
||||
},
|
||||
{
|
||||
key: "X-Frame-Options",
|
||||
value: "SAMEORIGIN", // 允许同源,通过 CSP frame-ancestors 控制跨域
|
||||
},
|
||||
{
|
||||
key: "X-Content-Type-Options",
|
||||
value: "nosniff",
|
||||
|
||||
@@ -2,20 +2,40 @@
|
||||
* 与后端约定:金额存最小货币单位(如 NPR 2 位小数 → 分);展示时除以 10^decimals。
|
||||
*/
|
||||
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
|
||||
const DEFAULT_DECIMAL_PLACES = 2;
|
||||
|
||||
export function getCurrencyDecimalPlaces(currencyCode: string): number {
|
||||
const code = currencyCode.trim().toUpperCase();
|
||||
const row = usePlayerSessionStore
|
||||
.getState()
|
||||
.currencies.find((item) => item.code === code);
|
||||
|
||||
const decimals = row?.decimal_places;
|
||||
if (typeof decimals === "number" && Number.isFinite(decimals) && decimals >= 0) {
|
||||
return decimals;
|
||||
}
|
||||
|
||||
return DEFAULT_DECIMAL_PLACES;
|
||||
}
|
||||
|
||||
export function formatMinorAsCurrency(
|
||||
minor: number | string,
|
||||
currencyCode: string,
|
||||
decimalPlaces = DEFAULT_DECIMAL_PLACES,
|
||||
decimalPlaces?: number,
|
||||
): string {
|
||||
const resolvedDecimalPlaces =
|
||||
typeof decimalPlaces === "number" && Number.isFinite(decimalPlaces) && decimalPlaces >= 0
|
||||
? decimalPlaces
|
||||
: getCurrencyDecimalPlaces(currencyCode);
|
||||
const n = typeof minor === "string" ? Number(minor) : minor;
|
||||
if (!Number.isFinite(n)) return `${currencyCode} —`;
|
||||
const divisor = 10 ** decimalPlaces;
|
||||
const divisor = 10 ** resolvedDecimalPlaces;
|
||||
const major = n / divisor;
|
||||
return `${currencyCode} ${major.toLocaleString(undefined, {
|
||||
minimumFractionDigits: decimalPlaces,
|
||||
maximumFractionDigits: decimalPlaces,
|
||||
minimumFractionDigits: resolvedDecimalPlaces,
|
||||
maximumFractionDigits: resolvedDecimalPlaces,
|
||||
})}`;
|
||||
}
|
||||
|
||||
@@ -24,13 +44,22 @@ export function formatMinorAsCurrency(
|
||||
*/
|
||||
export function parseDecimalInputToMinor(
|
||||
raw: string,
|
||||
decimalPlaces = DEFAULT_DECIMAL_PLACES,
|
||||
decimalPlacesOrCurrencyCode?: number | string,
|
||||
): number | null {
|
||||
const decimalPlaces =
|
||||
typeof decimalPlacesOrCurrencyCode === "string"
|
||||
? getCurrencyDecimalPlaces(decimalPlacesOrCurrencyCode)
|
||||
: typeof decimalPlacesOrCurrencyCode === "number" &&
|
||||
Number.isFinite(decimalPlacesOrCurrencyCode) &&
|
||||
decimalPlacesOrCurrencyCode >= 0
|
||||
? decimalPlacesOrCurrencyCode
|
||||
: decimalPlacesOrCurrencyCode;
|
||||
const resolvedDecimalPlaces = decimalPlaces ?? DEFAULT_DECIMAL_PLACES;
|
||||
const cleaned = raw.replace(/,/g, "").trim();
|
||||
if (cleaned === "") return null;
|
||||
const n = Number(cleaned);
|
||||
if (!Number.isFinite(n) || n < 0) return null;
|
||||
const factor = 10 ** decimalPlaces;
|
||||
const factor = 10 ** resolvedDecimalPlaces;
|
||||
const minor = Math.round(n * factor);
|
||||
if (!Number.isSafeInteger(minor)) return null;
|
||||
return minor;
|
||||
|
||||
31
src/lib/player-currency.ts
Normal file
31
src/lib/player-currency.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { PlayerMeData } from "@/types/api/player-me";
|
||||
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
||||
|
||||
type CurrencyProfile = Pick<PlayerMeData, "default_currency"> | null | undefined;
|
||||
type CurrencyBalance = Pick<WalletBalanceData, "currency_code"> | null | undefined;
|
||||
|
||||
/**
|
||||
* 玩家端当前业务币种:
|
||||
* 优先钱包接口返回币种,再回退玩家默认币种,环境变量仅作联调兜底。
|
||||
*/
|
||||
export function resolvePlayerCurrency(
|
||||
profile: CurrencyProfile,
|
||||
balance?: CurrencyBalance,
|
||||
): string {
|
||||
const fromBalance = balance?.currency_code?.trim();
|
||||
if (fromBalance) {
|
||||
return fromBalance.toUpperCase();
|
||||
}
|
||||
|
||||
const fromProfile = profile?.default_currency?.trim();
|
||||
if (fromProfile) {
|
||||
return fromProfile.toUpperCase();
|
||||
}
|
||||
|
||||
const fromEnv = process.env.NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY?.trim();
|
||||
if (fromEnv) {
|
||||
return fromEnv.toUpperCase();
|
||||
}
|
||||
|
||||
return "NPR";
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
persistPlayerBearerToken,
|
||||
readPersistedPlayerBearerToken,
|
||||
} from "@/lib/player-session";
|
||||
import type { PublicCurrencyRow } from "@/types/api/currency";
|
||||
import type { PlayerMeData } from "@/types/api/player-me";
|
||||
|
||||
export type PlayerEntryPhase = "loading" | "error" | "success";
|
||||
@@ -21,6 +22,7 @@ export type PlayerEntryStep = {
|
||||
type PlayerSessionState = {
|
||||
bearerToken: string | null;
|
||||
profile: PlayerMeData | null;
|
||||
currencies: PublicCurrencyRow[];
|
||||
phase: PlayerEntryPhase;
|
||||
progress: number;
|
||||
errorMessage: string | null;
|
||||
@@ -29,6 +31,7 @@ type PlayerSessionState = {
|
||||
restoreBearerToken: () => string | null;
|
||||
clearBearerToken: () => void;
|
||||
setProfile: (profile: PlayerMeData | null) => void;
|
||||
setCurrencies: (currencies: PublicCurrencyRow[]) => void;
|
||||
setPhase: (phase: PlayerEntryPhase) => void;
|
||||
setProgress: (progress: number) => void;
|
||||
setErrorMessage: (message: string | null) => void;
|
||||
@@ -47,6 +50,7 @@ function initialSteps(): PlayerEntryStep[] {
|
||||
export const usePlayerSessionStore = create<PlayerSessionState>((set) => ({
|
||||
bearerToken: null,
|
||||
profile: null,
|
||||
currencies: [],
|
||||
phase: "loading",
|
||||
progress: 0,
|
||||
errorMessage: null,
|
||||
@@ -83,6 +87,7 @@ export const usePlayerSessionStore = create<PlayerSessionState>((set) => ({
|
||||
},
|
||||
|
||||
setProfile: (profile) => set({ profile }),
|
||||
setCurrencies: (currencies) => set({ currencies }),
|
||||
setPhase: (phase) => set({ phase }),
|
||||
setProgress: (progress) => set({ progress: Math.max(0, Math.min(100, progress)) }),
|
||||
setErrorMessage: (errorMessage) => set({ errorMessage }),
|
||||
@@ -99,4 +104,4 @@ export const usePlayerSessionStore = create<PlayerSessionState>((set) => ({
|
||||
errorMessage: null,
|
||||
steps: initialSteps(),
|
||||
}),
|
||||
}));
|
||||
}));
|
||||
|
||||
10
src/types/api/currency.ts
Normal file
10
src/types/api/currency.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export type PublicCurrencyRow = {
|
||||
code: string;
|
||||
name: string;
|
||||
decimal_places: number;
|
||||
is_bettable: boolean;
|
||||
};
|
||||
|
||||
export type PublicCurrencyListData = {
|
||||
items: PublicCurrencyRow[];
|
||||
};
|
||||
Reference in New Issue
Block a user