feat: implement initial language resolution for SSR and local storage support
- Added a function to determine the initial language based on server-side rendering and local storage. - Updated i18n initialization to use the resolved initial language instead of the default.
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
|
import { PlayerMobileViewport } from "@/components/layout/player-mobile-viewport";
|
||||||
|
|
||||||
export default function PlayerRootLayout({
|
export default function PlayerRootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return children;
|
return <PlayerMobileViewport>{children}</PlayerMobileViewport>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ export const metadata: Metadata = {
|
|||||||
description: "Lottery player",
|
description: "Lottery player",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-dvh bg-white text-foreground">
|
<div className="min-h-dvh bg-white text-foreground">
|
||||||
<NetworkStatusBanner />
|
<NetworkStatusBanner />
|
||||||
<main className="mx-auto flex w-full max-w-lg flex-col pb-[calc(3.5rem+env(safe-area-inset-bottom,0px)+0.75rem)]">
|
<main className="flex w-full flex-col pb-[calc(3.5rem+env(safe-area-inset-bottom,0px)+0.75rem)]">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
<PlayerBottomNav />
|
<PlayerBottomNav />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { usePathname } from "next/navigation";
|
|||||||
import { BarChart3, ClipboardList, Home, Wallet } from "lucide-react";
|
import { BarChart3, ClipboardList, Home, Wallet } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { playerViewportFixedBarClass } from "@/lib/player-viewport";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
@@ -48,10 +49,13 @@ export function PlayerBottomNav() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
className="fixed bottom-0 left-0 right-0 z-50 border-t border-[#e4ebf5] bg-white/96 pb-[env(safe-area-inset-bottom,0px)] shadow-[0_-10px_30px_rgba(15,23,42,0.08)] backdrop-blur-md"
|
className={cn(
|
||||||
|
playerViewportFixedBarClass,
|
||||||
|
"bottom-0 border-t border-[#e4ebf5] bg-white/96 pb-[env(safe-area-inset-bottom,0px)] shadow-[0_-10px_30px_rgba(15,23,42,0.08)] backdrop-blur-md",
|
||||||
|
)}
|
||||||
aria-label={t("nav.aria")}
|
aria-label={t("nav.aria")}
|
||||||
>
|
>
|
||||||
<div className="mx-auto grid h-14 w-full max-w-lg grid-rows-1 grid-cols-4">
|
<div className="grid h-14 w-full grid-cols-4 grid-rows-1">
|
||||||
{tabs.map(({ href, labelKey, labelDefault, icon: Icon, match }) => {
|
{tabs.map(({ href, labelKey, labelDefault, icon: Icon, match }) => {
|
||||||
const active = match(pathname);
|
const active = match(pathname);
|
||||||
const label = t(labelKey, { defaultValue: labelDefault });
|
const label = t(labelKey, { defaultValue: labelDefault });
|
||||||
|
|||||||
31
src/components/layout/player-mobile-viewport.tsx
Normal file
31
src/components/layout/player-mobile-viewport.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { playerViewportColumnClass } from "@/lib/player-viewport";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type PlayerMobileViewportProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 玩家端根容器:真机全宽;桌面浏览器居中为固定宽度的 H5 画布,避免 PC 打开时 UI 横向拉满。
|
||||||
|
*/
|
||||||
|
export function PlayerMobileViewport({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: PlayerMobileViewportProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="min-h-dvh bg-[#e8ecf3]">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
playerViewportColumnClass,
|
||||||
|
"relative min-h-dvh bg-white shadow-none sm:shadow-[0_0_48px_rgba(15,23,42,0.1)]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1159,7 +1159,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
<th key={column.key} className="min-w-16 px-1 py-2 text-center font-bold">
|
<th key={column.key} className="min-w-16 px-1 py-2 text-center font-bold">
|
||||||
<span className="block truncate">
|
<span className="block truncate">
|
||||||
{pickDisplayName(column.play)}
|
{pickDisplayName(column.play)}
|
||||||
{column.digitSlot !== undefined
|
{column.digitSlot !== undefined && activeCategory !== "JACKPOT"
|
||||||
? `-${digitSlotLabel(activeCategory, column.digitSlot)}`
|
? `-${digitSlotLabel(activeCategory, column.digitSlot)}`
|
||||||
: ""}
|
: ""}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -53,6 +53,17 @@ const resources = {
|
|||||||
Record<(typeof namespaces)[number], Record<string, unknown>>
|
Record<(typeof namespaces)[number], Record<string, unknown>>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
function resolveInitialLanguage(): AppLanguage {
|
||||||
|
// SSR 始终使用默认语言,避免首屏渲染依赖浏览器状态
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return DEFAULT_LANGUAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = window.localStorage.getItem("i18nextLng");
|
||||||
|
const preferred = normalizeLanguage(stored ?? window.navigator.language);
|
||||||
|
return preferred;
|
||||||
|
}
|
||||||
|
|
||||||
export function syncDocumentLanguage(lang: AppLanguage): void {
|
export function syncDocumentLanguage(lang: AppLanguage): void {
|
||||||
if (typeof document === "undefined") return;
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
@@ -72,6 +83,8 @@ export function syncPreferredLanguage(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!i18n.isInitialized) {
|
if (!i18n.isInitialized) {
|
||||||
|
const initialLng = resolveInitialLanguage();
|
||||||
|
|
||||||
void i18n.use(initReactI18next).init({
|
void i18n.use(initReactI18next).init({
|
||||||
resources,
|
resources,
|
||||||
fallbackLng: DEFAULT_LANGUAGE,
|
fallbackLng: DEFAULT_LANGUAGE,
|
||||||
@@ -80,8 +93,7 @@ if (!i18n.isInitialized) {
|
|||||||
ns: [...namespaces],
|
ns: [...namespaces],
|
||||||
/** 始终先用默认语言完成 SSR / 首次 hydration,避免首屏文本不一致 */
|
/** 始终先用默认语言完成 SSR / 首次 hydration,避免首屏文本不一致 */
|
||||||
load: "languageOnly",
|
load: "languageOnly",
|
||||||
lng: DEFAULT_LANGUAGE,
|
lng: initialLng,
|
||||||
initImmediate: false,
|
|
||||||
|
|
||||||
interpolation: {
|
interpolation: {
|
||||||
escapeValue: false,
|
escapeValue: false,
|
||||||
|
|||||||
9
src/lib/player-viewport.ts
Normal file
9
src/lib/player-viewport.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/** H5 玩家端逻辑宽度(px),桌面浏览器居中展示为「手机画布」 */
|
||||||
|
export const PLAYER_VIEWPORT_MAX_PX = 480;
|
||||||
|
|
||||||
|
/** 内容列:全宽但不超过手机逻辑宽度,水平居中 */
|
||||||
|
export const playerViewportColumnClass = "mx-auto w-full max-w-[480px]" as const;
|
||||||
|
|
||||||
|
/** 贴底/贴顶固定栏:与内容列同宽并居中(桌面不铺满整屏) */
|
||||||
|
export const playerViewportFixedBarClass =
|
||||||
|
"fixed left-1/2 z-50 w-full max-w-[480px] -translate-x-1/2" as const;
|
||||||
Reference in New Issue
Block a user