feat: 接入公开币种目录并统一多币种金额与语言初始化处理

This commit is contained in:
2026-05-21 15:14:00 +08:00
parent 626914feb6
commit 6b18e25766
27 changed files with 277 additions and 94 deletions

View File

@@ -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
View 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`);
}

View File

@@ -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,

View File

@@ -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";

View File

@@ -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>

View File

@@ -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(() => {

View File

@@ -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) {

View File

@@ -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 () => {

View File

@@ -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;

View File

@@ -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 (

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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 &&

View File

@@ -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]">

View File

@@ -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 () => {

View File

@@ -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 () => {

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 () => {

View 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;
}

View File

@@ -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 → zhne-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);
}
});
}

View File

@@ -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",

View File

@@ -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;

View 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";
}

View File

@@ -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
View File

@@ -0,0 +1,10 @@
export type PublicCurrencyRow = {
code: string;
name: string;
decimal_places: number;
is_bettable: boolean;
};
export type PublicCurrencyListData = {
items: PublicCurrencyRow[];
};