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