diff --git a/next.config.ts b/next.config.ts index 224128a..04a8486 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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 嵌入 diff --git a/src/api/currency.ts b/src/api/currency.ts new file mode 100644 index 0000000..d512560 --- /dev/null +++ b/src/api/currency.ts @@ -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 { + return lotteryRequest.get(`${API_V1_PREFIX}/currencies`); +} diff --git a/src/api/index.ts b/src/api/index.ts index 46c6de8..91dbad6 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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, diff --git a/src/components/language-switcher.tsx b/src/components/language-switcher.tsx index 7feeb38..ef9b272 100644 --- a/src/components/language-switcher.tsx +++ b/src/components/language-switcher.tsx @@ -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"; diff --git a/src/components/providers.tsx b/src/components/providers.tsx index 38c9cc3..96e07a1 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -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 ( <> diff --git a/src/features/hall/hall-betting-grid.tsx b/src/features/hall/hall-betting-grid.tsx index f7ba13a..15a2f3d 100644 --- a/src/features/hall/hall-betting-grid.tsx +++ b/src/features/hall/hall-betting-grid.tsx @@ -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("D2"); const [rows, setRows] = useState(() => [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(() => { diff --git a/src/features/hall/hall-play-catalog-panel.tsx b/src/features/hall/hall-play-catalog-panel.tsx index 2d21ee7..6553bdd 100644 --- a/src/features/hall/hall-play-catalog-panel.tsx +++ b/src/features/hall/hall-play-catalog-panel.tsx @@ -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({ 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) { diff --git a/src/features/hall/hall-wallet-strip.tsx b/src/features/hall/hall-wallet-strip.tsx index 6cb3edb..d067cfe 100644 --- a/src/features/hall/hall-wallet-strip.tsx +++ b/src/features/hall/hall-wallet-strip.tsx @@ -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(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 () => { diff --git a/src/features/orders/ticket-order-detail-screen.tsx b/src/features/orders/ticket-order-detail-screen.tsx index 720ca49..2cdc1bf 100644 --- a/src/features/orders/ticket-order-detail-screen.tsx +++ b/src/features/orders/ticket-order-detail-screen.tsx @@ -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(null); const [error, setError] = useState(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; diff --git a/src/features/orders/ticket-orders-list-screen.tsx b/src/features/orders/ticket-orders-list-screen.tsx index 9fcea7b..2bf9bb7 100644 --- a/src/features/orders/ticket-orders-list-screen.tsx +++ b/src/features/orders/ticket-orders-list-screen.tsx @@ -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() { <>
{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 ( diff --git a/src/features/player/entry-gate.tsx b/src/features/player/entry-gate.tsx index 107ba4c..63c8a54 100644 --- a/src/features/player/entry-gate.tsx +++ b/src/features/player/entry-gate.tsx @@ -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("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, diff --git a/src/features/player/hydrate-player-auth.tsx b/src/features/player/hydrate-player-auth.tsx index 37babde..4a05279 100644 --- a/src/features/player/hydrate-player-auth.tsx +++ b/src/features/player/hydrate-player-auth.tsx @@ -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; } diff --git a/src/features/results/check-winning-screen.tsx b/src/features/results/check-winning-screen.tsx index 447d82b..b451df4 100644 --- a/src/features/results/check-winning-screen.tsx +++ b/src/features/results/check-winning-screen.tsx @@ -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(null); const [recent, setRecent] = useState([]); @@ -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")}

- {formatMinorAsCurrency(totalWin, firstTicket?.currency_code ?? "NPR")} + {formatMinorAsCurrency(totalWin, currency)}

diff --git a/src/features/results/draw-result-detail-screen.tsx b/src/features/results/draw-result-detail-screen.tsx index c8f0e74..b13fd22 100644 --- a/src/features/results/draw-result-detail-screen.tsx +++ b/src/features/results/draw-result-detail-screen.tsx @@ -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(null); const [error, setError] = useState(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 && diff --git a/src/features/results/draw-results-list-screen.tsx b/src/features/results/draw-results-list-screen.tsx index 7d06a2c..f2b1f5b 100644 --- a/src/features/results/draw-results-list-screen.tsx +++ b/src/features/results/draw-results-list-screen.tsx @@ -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(null); const [error, setError] = useState(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 (
- +

diff --git a/src/features/wallet/transfer-in-screen.tsx b/src/features/wallet/transfer-in-screen.tsx index 6cbb1fb..26fac36 100644 --- a/src/features/wallet/transfer-in-screen.tsx +++ b/src/features/wallet/transfer-in-screen.tsx @@ -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 () => { diff --git a/src/features/wallet/transfer-out-screen.tsx b/src/features/wallet/transfer-out-screen.tsx index 03b1754..cd5f301 100644 --- a/src/features/wallet/transfer-out-screen.tsx +++ b/src/features/wallet/transfer-out-screen.tsx @@ -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 () => { diff --git a/src/features/wallet/wallet-logs-screen.tsx b/src/features/wallet/wallet-logs-screen.tsx index b04ee31..bfa4e11 100644 --- a/src/features/wallet/wallet-logs-screen.tsx +++ b/src/features/wallet/wallet-logs-screen.tsx @@ -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(null); const currency = useMemo( - () => (profile?.default_currency ?? "NPR").toUpperCase(), - [profile?.default_currency], + () => resolvePlayerCurrency(profile), + [profile], ); const fetchPassRef = useRef(true); diff --git a/src/features/wallet/wallet-screen.tsx b/src/features/wallet/wallet-screen.tsx index c3a00b5..453d05a 100644 --- a/src/features/wallet/wallet-screen.tsx +++ b/src/features/wallet/wallet-screen.tsx @@ -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(null); const loadMoreRef = useRef(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); diff --git a/src/features/wallet/wallet-transfer-forms.tsx b/src/features/wallet/wallet-transfer-forms.tsx index cf6a99d..94896d8 100644 --- a/src/features/wallet/wallet-transfer-forms.tsx +++ b/src/features/wallet/wallet-transfer-forms.tsx @@ -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(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(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 () => { diff --git a/src/hooks/use-currency-catalog.ts b/src/hooks/use-currency-catalog.ts new file mode 100644 index 0000000..4ddeb34 --- /dev/null +++ b/src/hooks/use-currency-catalog.ts @@ -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 | 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; +} diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 7864a0e..a222a7b 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -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); + } }); } diff --git a/src/lib/csp-config.ts b/src/lib/csp-config.ts index 7e2f902..3a4917b 100644 --- a/src/lib/csp-config.ts +++ b/src/lib/csp-config.ts @@ -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", diff --git a/src/lib/money.ts b/src/lib/money.ts index 7e011aa..a36591e 100644 --- a/src/lib/money.ts +++ b/src/lib/money.ts @@ -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; diff --git a/src/lib/player-currency.ts b/src/lib/player-currency.ts new file mode 100644 index 0000000..12d0e15 --- /dev/null +++ b/src/lib/player-currency.ts @@ -0,0 +1,31 @@ +import type { PlayerMeData } from "@/types/api/player-me"; +import type { WalletBalanceData } from "@/types/api/wallet-balance"; + +type CurrencyProfile = Pick | null | undefined; +type CurrencyBalance = Pick | 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"; +} diff --git a/src/stores/player-session-store.ts b/src/stores/player-session-store.ts index af9e61e..beef9e2 100644 --- a/src/stores/player-session-store.ts +++ b/src/stores/player-session-store.ts @@ -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((set) => ({ bearerToken: null, profile: null, + currencies: [], phase: "loading", progress: 0, errorMessage: null, @@ -83,6 +87,7 @@ export const usePlayerSessionStore = create((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((set) => ({ errorMessage: null, steps: initialSteps(), }), -})); \ No newline at end of file +})); diff --git a/src/types/api/currency.ts b/src/types/api/currency.ts new file mode 100644 index 0000000..11eaf8d --- /dev/null +++ b/src/types/api/currency.ts @@ -0,0 +1,10 @@ +export type PublicCurrencyRow = { + code: string; + name: string; + decimal_places: number; + is_bettable: boolean; +}; + +export type PublicCurrencyListData = { + items: PublicCurrencyRow[]; +};