feat(player): PC 侧栏布局、顶栏与页内间距优化
Some checks failed
lotteryfront CI / build (push) Has been cancelled
Some checks failed
lotteryfront CI / build (push) Has been cancelled
- 桌面端侧栏 + 全局顶栏(Logo 在侧栏顶、激活态对齐后台红底白字) - 移除各页重复页内顶栏,统一主区与卡片顶部留白 - 视口遮罩/弹层约束到主内容区,移动端保留底栏与顶栏 Logo
This commit is contained in:
@@ -49,6 +49,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--player-viewport-max: 480px;
|
||||||
|
--player-sidebar-width: 0px;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.145 0 0);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
@@ -163,9 +165,27 @@
|
|||||||
background: linear-gradient(to left, rgba(255, 255, 255, 0.95), transparent);
|
background: linear-gradient(to left, rgba(255, 255, 255, 0.95), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 玩家端侧栏:浅色底 + 与后台一致的红色激活态 */
|
||||||
|
.player-sidebar {
|
||||||
|
--sidebar: #ffffff;
|
||||||
|
--sidebar-foreground: #475569;
|
||||||
|
--sidebar-primary: #e60012;
|
||||||
|
--sidebar-primary-foreground: #ffffff;
|
||||||
|
--sidebar-accent: #f1f5f9;
|
||||||
|
--sidebar-accent-foreground: #0f172a;
|
||||||
|
--sidebar-border: #e4ebf5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 玩家端桌面壳:侧栏宽度供遮罩/弹层定位 */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.player-desktop-shell {
|
||||||
|
--player-sidebar-width: 14rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 玩家端 Toast:顶部居中、紧凑尺寸(位置见 components/ui/sonner.tsx) */
|
/* 玩家端 Toast:顶部居中、紧凑尺寸(位置见 components/ui/sonner.tsx) */
|
||||||
[data-sonner-toaster] {
|
[data-sonner-toaster] {
|
||||||
--width: min(280px, calc(100vw - 24px));
|
--width: min(280px, calc(var(--player-viewport-max, 480px) - 24px));
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-sonner-toast][data-styled="true"] {
|
[data-sonner-toast][data-styled="true"] {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
|
import { playerViewportPopoverMaxClass } from "@/lib/player-viewport";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type CurrencySwitcherProps = {
|
type CurrencySwitcherProps = {
|
||||||
@@ -109,7 +110,8 @@ export function CurrencySwitcher({
|
|||||||
side="bottom"
|
side="bottom"
|
||||||
sideOffset={6}
|
sideOffset={6}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-[200] w-auto min-w-[min(100vw-2rem,200px)] max-w-[min(100vw-2rem,280px)] p-1 text-gray-900",
|
"z-[200] w-auto min-w-[11rem] p-1 text-gray-900",
|
||||||
|
playerViewportPopoverMaxClass,
|
||||||
styles.dropdown,
|
styles.dropdown,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { normalizeLanguage, SUPPORTED_LANGUAGES, type AppLanguage } from "@/i18n";
|
import { normalizeLanguage, SUPPORTED_LANGUAGES, type AppLanguage } from "@/i18n";
|
||||||
|
import { playerViewportPopoverMaxClass } from "@/lib/player-viewport";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface LanguageSwitcherProps {
|
interface LanguageSwitcherProps {
|
||||||
@@ -104,7 +105,8 @@ export function LanguageSwitcher({
|
|||||||
side="bottom"
|
side="bottom"
|
||||||
sideOffset={6}
|
sideOffset={6}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-[200] w-auto min-w-[min(100vw-2rem,220px)] max-w-[min(100vw-2rem,280px)] p-1 text-gray-900",
|
"z-[200] w-auto min-w-[12rem] p-1 text-gray-900",
|
||||||
|
playerViewportPopoverMaxClass,
|
||||||
styles.dropdown,
|
styles.dropdown,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,48 +2,65 @@
|
|||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { NetworkStatusBanner } from "@/components/network-status-banner";
|
import { PlayerDesktopHeader } from "@/components/layout/player-desktop-header";
|
||||||
import { PlayerBottomNav } from "@/components/layout/player-bottom-nav";
|
import { PlayerBottomNav } from "@/components/layout/player-bottom-nav";
|
||||||
|
import { PlayerSidebar } from "@/components/layout/player-sidebar";
|
||||||
|
import { NetworkStatusBanner } from "@/components/network-status-banner";
|
||||||
import { PullToRefreshIndicator } from "@/components/pull-to-refresh-indicator";
|
import { PullToRefreshIndicator } from "@/components/pull-to-refresh-indicator";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||||
|
import { playerMainInset } from "@/lib/player-spacing";
|
||||||
|
|
||||||
type PlayerAppShellProps = {
|
type PlayerAppShellProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 玩家端外壳:顶栏(品牌 + 会话)+ 主体 + **底部 Tab 导航**(H5)。
|
* 玩家端外壳:
|
||||||
* 底部栏留白:{@link PlayerBottomNav} 作为 sticky 元素放在底部,main 只需要基础 padding。
|
* - 移动:主体 + 底部 Tab(H5)
|
||||||
*
|
* - 桌面:侧栏 + 顶栏 + 宽主内容区(UI 设计稿)
|
||||||
* 注意:全局离线横幅和服务器错误覆盖层已在 ErrorProvider 中处理
|
|
||||||
* 这里的 NetworkStatusBanner 仅用于 WebSocket 状态显示
|
|
||||||
*/
|
*/
|
||||||
export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
|
export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const { pullDistance, isRefreshing } = usePullToRefresh({
|
const { pullDistance, isRefreshing } = usePullToRefresh({
|
||||||
onRefresh: () => {
|
onRefresh: () => {
|
||||||
window.dispatchEvent(new CustomEvent("lottery-hall-refresh"));
|
window.dispatchEvent(new CustomEvent("lottery-hall-refresh"));
|
||||||
window.dispatchEvent(new CustomEvent("lottery-wallet-refresh"));
|
window.dispatchEvent(new CustomEvent("lottery-wallet-refresh"));
|
||||||
},
|
},
|
||||||
threshold: 70,
|
threshold: 70,
|
||||||
|
enabled: isMobile,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden bg-white text-foreground">
|
<div className="flex h-full min-h-0 flex-1 overflow-hidden bg-white text-foreground lg:flex-row">
|
||||||
<PullToRefreshIndicator
|
<PlayerSidebar />
|
||||||
pullDistance={pullDistance}
|
|
||||||
isRefreshing={isRefreshing}
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
threshold={70}
|
{isMobile ? (
|
||||||
/>
|
<PullToRefreshIndicator
|
||||||
<NetworkStatusBanner />
|
pullDistance={pullDistance}
|
||||||
<main
|
isRefreshing={isRefreshing}
|
||||||
id="player-scroll-container"
|
threshold={70}
|
||||||
className="flex w-full min-h-0 flex-1 flex-col overflow-y-auto overscroll-y-contain pb-3"
|
/>
|
||||||
>
|
) : null}
|
||||||
{children}
|
<NetworkStatusBanner />
|
||||||
</main>
|
<PlayerDesktopHeader />
|
||||||
<div className="shrink-0 w-full relative z-50">
|
|
||||||
<PlayerBottomNav />
|
<main
|
||||||
|
id="player-scroll-container"
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-0 w-full flex-1 flex-col overflow-y-auto overscroll-y-contain bg-[#f8fafc] lg:bg-[#f0f2f6]",
|
||||||
|
playerMainInset,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div className="relative z-50 w-full shrink-0 lg:hidden">
|
||||||
|
<PlayerBottomNav />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
45
src/components/layout/player-desktop-header.tsx
Normal file
45
src/components/layout/player-desktop-header.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
|
import { PlayerNotificationBell } from "@/components/layout/player-notification-bell";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type PlayerDesktopHeaderProps = {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 全局顶栏:移动显示 Logo;桌面 Logo 在侧栏顶部,此处仅语言 + 通知 */
|
||||||
|
export function PlayerDesktopHeader({ className }: PlayerDesktopHeaderProps) {
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={cn(
|
||||||
|
"flex h-12 shrink-0 items-center justify-between border-b border-[#e4ebf5] bg-white px-3 lg:h-14 lg:justify-end lg:px-6",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Link href="/hall" className="inline-flex min-w-0 items-center lg:hidden" aria-label="N lotto">
|
||||||
|
<Image
|
||||||
|
src="/logo.png"
|
||||||
|
alt="N lotto"
|
||||||
|
width={243}
|
||||||
|
height={84}
|
||||||
|
className="h-7 w-auto max-w-[160px] object-contain object-left"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<LanguageSwitcher
|
||||||
|
variant="minimal"
|
||||||
|
menuAlign="end"
|
||||||
|
showFlag={false}
|
||||||
|
className="rounded-full border border-[#e4eaf4] bg-[#f8fafc] [&_button]:h-8 [&_button]:gap-1 [&_button]:px-2 [&_button]:text-xs lg:[&_button]:h-9 lg:[&_button]:gap-1.5 lg:[&_button]:px-3 lg:[&_button]:text-sm"
|
||||||
|
/>
|
||||||
|
<PlayerNotificationBell className="inline-flex size-9 items-center justify-center rounded-full border border-[#e4eaf4] bg-[#f8fafc] text-[#0b3f96]" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@ import type { ReactNode } from "react";
|
|||||||
import { OfflineBanner } from "@/components/offline-banner";
|
import { OfflineBanner } from "@/components/offline-banner";
|
||||||
import {
|
import {
|
||||||
playerSafeAreaTopClass,
|
playerSafeAreaTopClass,
|
||||||
playerViewportColumnClass,
|
playerViewportOuterClass,
|
||||||
|
playerViewportRootClass,
|
||||||
} from "@/lib/player-viewport";
|
} from "@/lib/player-viewport";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -13,25 +14,21 @@ type PlayerMobileViewportProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 玩家端根容器:真机全宽;桌面浏览器居中为固定宽度的 H5 画布,避免 PC 打开时 UI 横向拉满。
|
* 玩家端根容器:移动居中 480px 画布;桌面全宽并配合侧栏壳层(见 UI 设计稿)。
|
||||||
*/
|
*/
|
||||||
export function PlayerMobileViewport({
|
export function PlayerMobileViewport({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
}: PlayerMobileViewportProps): ReactNode {
|
}: PlayerMobileViewportProps): ReactNode {
|
||||||
return (
|
return (
|
||||||
<div className="h-[100dvh] bg-[#e8ecf3] overflow-hidden">
|
<div className={playerViewportOuterClass}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(playerViewportRootClass, playerSafeAreaTopClass, className)}
|
||||||
playerViewportColumnClass,
|
style={{ "--player-viewport-max": "480px" } as React.CSSProperties}
|
||||||
playerSafeAreaTopClass,
|
|
||||||
"relative flex h-full min-h-0 flex-col bg-white shadow-none sm:shadow-[0_0_48px_rgba(15,23,42,0.1)]",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<OfflineBanner />
|
<OfflineBanner />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,30 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Bell } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { type ReactElement } from "react";
|
import { type ReactElement } from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type PlayerNotificationBellProps = {
|
type PlayerNotificationBellProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 顶栏铃铛:暂隐藏,待接入通用通知中心后再开放 */
|
/** 顶栏铃铛:跳转通知页(完整通知中心待后续接入) */
|
||||||
export function PlayerNotificationBell(_props: PlayerNotificationBellProps): ReactElement | null {
|
export function PlayerNotificationBell({
|
||||||
return null;
|
className,
|
||||||
}
|
}: PlayerNotificationBellProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href="/notifications"
|
||||||
|
className={cn("relative text-[#0b3f96]", className)}
|
||||||
|
aria-label="Notifications"
|
||||||
|
>
|
||||||
|
<Bell className="size-[1.125rem]" aria-hidden />
|
||||||
|
<span
|
||||||
|
className="absolute right-1 top-1 size-2 rounded-full bg-[#f10b32] ring-2 ring-white"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,21 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { ChevronLeft } from "lucide-react";
|
import { playerPageShellPadding } from "@/lib/player-spacing";
|
||||||
import { useTranslation } from "react-i18next";
|
import { playerContentColumnClass } from "@/lib/player-viewport";
|
||||||
|
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
|
||||||
import { usePlayerStickyTopStyle } from "@/hooks/use-player-sticky-top-style";
|
|
||||||
import {
|
|
||||||
playerHeaderControl,
|
|
||||||
playerPageHeader,
|
|
||||||
} from "@/lib/player-spacing";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type PlayerPanelProps = {
|
type PlayerPanelProps = {
|
||||||
title: string;
|
/** 保留供调用方语义标注,不再渲染页内顶栏(导航由外壳侧栏/顶栏承担) */
|
||||||
|
title?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
backHref?: string;
|
backHref?: string;
|
||||||
backLabel?: string;
|
backLabel?: string;
|
||||||
@@ -24,65 +17,21 @@ type PlayerPanelProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function PlayerPanel({
|
export function PlayerPanel({
|
||||||
title,
|
|
||||||
children,
|
children,
|
||||||
backHref = "/hall",
|
|
||||||
backLabel,
|
|
||||||
className,
|
className,
|
||||||
containerClassName,
|
containerClassName,
|
||||||
}: PlayerPanelProps) {
|
}: PlayerPanelProps) {
|
||||||
const { t: tp } = useTranslation("player");
|
|
||||||
const resolvedBackLabel = backLabel ?? tp("panel.home");
|
|
||||||
const stickyTopStyle = usePlayerStickyTopStyle();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("mx-auto w-full max-w-[480px]", containerClassName)}>
|
<div className={cn(playerContentColumnClass, containerClassName)}>
|
||||||
<section
|
<section
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-white text-slate-900",
|
"bg-white text-slate-900 lg:rounded-2xl lg:border lg:border-[#e4ebf5] lg:shadow-sm",
|
||||||
"px-3 pb-6",
|
playerPageShellPadding,
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<header
|
|
||||||
className={cn(
|
|
||||||
playerPageHeader,
|
|
||||||
"sticky z-50 grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 bg-white/95 pt-2 pb-2 -mx-3 px-3 backdrop-blur",
|
|
||||||
)}
|
|
||||||
style={stickyTopStyle}
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 justify-start">
|
|
||||||
<Link
|
|
||||||
href={backHref}
|
|
||||||
className={cn(
|
|
||||||
playerHeaderControl,
|
|
||||||
"gap-0.5 rounded-full border border-[#e4eaf4] bg-[#f8fafc] px-2 text-xs font-bold text-[#0b3f96] hover:bg-[#f1f6ff]",
|
|
||||||
)}
|
|
||||||
aria-label={resolvedBackLabel}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="size-4 shrink-0" aria-hidden />
|
|
||||||
<span className="max-w-[5.5rem] truncate">{resolvedBackLabel}</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="min-w-0 truncate px-1 text-center text-base font-black leading-tight text-[#0b3f96]">
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="flex min-w-0 items-center justify-end gap-0.5">
|
|
||||||
<LanguageSwitcher
|
|
||||||
variant="minimal"
|
|
||||||
menuAlign="end"
|
|
||||||
showFlag={false}
|
|
||||||
className={cn(
|
|
||||||
playerHeaderControl,
|
|
||||||
"rounded-full border border-[#e4eaf4] bg-[#f8fafc] [&_button]:h-8 [&_button]:gap-1 [&_button]:px-2 [&_button]:py-0 [&_button]:text-xs",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
118
src/components/layout/player-sidebar.tsx
Normal file
118
src/components/layout/player-sidebar.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
BookOpen,
|
||||||
|
ClipboardList,
|
||||||
|
Home,
|
||||||
|
Wallet,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{
|
||||||
|
href: "/hall",
|
||||||
|
labelKey: "nav.home",
|
||||||
|
labelDefault: "首页",
|
||||||
|
icon: Home,
|
||||||
|
match: (p: string) => p === "/hall",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/results",
|
||||||
|
labelKey: "nav.results",
|
||||||
|
labelDefault: "开奖结果",
|
||||||
|
icon: BarChart3,
|
||||||
|
match: (p: string) => p === "/results" || p.startsWith("/results/"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/orders",
|
||||||
|
labelKey: "nav.orders",
|
||||||
|
labelDefault: "我的注单",
|
||||||
|
icon: ClipboardList,
|
||||||
|
match: (p: string) => p === "/orders" || p.startsWith("/orders/"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/wallet",
|
||||||
|
labelKey: "nav.wallet",
|
||||||
|
creditLabelKey: "nav.credit",
|
||||||
|
labelDefault: "钱包",
|
||||||
|
creditLabelDefault: "信用",
|
||||||
|
icon: Wallet,
|
||||||
|
match: (p: string) => p === "/wallet" || p.startsWith("/wallet/"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/rules",
|
||||||
|
labelKey: "nav.rules",
|
||||||
|
labelDefault: "规则",
|
||||||
|
icon: BookOpen,
|
||||||
|
match: (p: string) => p === "/rules",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 与 lotteryadmin `NAV_BTN` / `NAV_ACTIVE` 一致 */
|
||||||
|
const playerNavLinkClass =
|
||||||
|
"flex h-8 w-full items-center gap-2 rounded-md px-2.5 text-[13px] leading-snug font-normal text-sidebar-foreground/90 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0";
|
||||||
|
|
||||||
|
const playerNavLinkActiveClass =
|
||||||
|
"bg-sidebar-primary/90 font-medium text-sidebar-primary-foreground shadow-sm hover:bg-sidebar-primary/90 hover:text-sidebar-primary-foreground";
|
||||||
|
|
||||||
|
/** 桌面端左侧导航(激活态对齐后台侧栏) */
|
||||||
|
export function PlayerSidebar() {
|
||||||
|
const pathname = usePathname() ?? "";
|
||||||
|
const { t } = useTranslation("player");
|
||||||
|
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className="player-sidebar hidden h-full w-56 shrink-0 flex-col border-r border-[#e4ebf5] bg-white lg:flex"
|
||||||
|
aria-label={t("nav.aria")}
|
||||||
|
>
|
||||||
|
<div className="shrink-0 border-b border-[#e4ebf5] px-2 py-2">
|
||||||
|
<Link href="/hall" className="flex h-10 items-center px-1" aria-label="N lotto">
|
||||||
|
<Image
|
||||||
|
src="/logo.png"
|
||||||
|
alt="N lotto"
|
||||||
|
width={243}
|
||||||
|
height={84}
|
||||||
|
className="h-auto max-h-10 w-full object-contain object-left"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto px-1.5 py-1.5">
|
||||||
|
{navItems.map(({ href, labelKey, labelDefault, icon: Icon, match, ...item }) => {
|
||||||
|
const active = match(pathname);
|
||||||
|
const creditLabelKey = "creditLabelKey" in item ? item.creditLabelKey : undefined;
|
||||||
|
const creditLabelDefault =
|
||||||
|
"creditLabelDefault" in item ? item.creditLabelDefault : undefined;
|
||||||
|
const label =
|
||||||
|
creditMode && creditLabelKey
|
||||||
|
? t(creditLabelKey, { defaultValue: creditLabelDefault ?? labelDefault })
|
||||||
|
: t(labelKey, { defaultValue: labelDefault });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
prefetch
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
className={cn(playerNavLinkClass, active && playerNavLinkActiveClass)}
|
||||||
|
>
|
||||||
|
<Icon aria-hidden />
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { usePlayerBannerHeightRef } from "@/hooks/use-player-banner-height-ref";
|
import { usePlayerBannerHeightRef } from "@/hooks/use-player-banner-height-ref";
|
||||||
import { useWebSocketManager } from "@/hooks/use-websocket-manager";
|
import { useWebSocketManager } from "@/hooks/use-websocket-manager";
|
||||||
import { playerViewportColumnClass } from "@/lib/player-viewport";
|
import { playerContentColumnClass } from "@/lib/player-viewport";
|
||||||
import { useErrorStore } from "@/stores/error-store";
|
import { useErrorStore } from "@/stores/error-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ export function NetworkStatusBanner(): React.ReactElement | null {
|
|||||||
<div ref={bannerRef} className="sticky top-0 z-50 w-full">
|
<div ref={bannerRef} className="sticky top-0 z-50 w-full">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
playerViewportColumnClass,
|
playerContentColumnClass,
|
||||||
"flex items-center justify-center gap-2 px-4 py-2 text-sm",
|
"flex items-center justify-center gap-2 px-4 py-2 text-sm",
|
||||||
"animate-in slide-in-from-top-2 duration-200",
|
"animate-in slide-in-from-top-2 duration-200",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useCallback } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { usePlayerBannerHeightRef } from "@/hooks/use-player-banner-height-ref";
|
import { usePlayerBannerHeightRef } from "@/hooks/use-player-banner-height-ref";
|
||||||
import { playerViewportColumnClass } from "@/lib/player-viewport";
|
import { playerContentColumnClass } from "@/lib/player-viewport";
|
||||||
import { useErrorStore, ERROR_COLORS } from "@/stores/error-store";
|
import { useErrorStore, ERROR_COLORS } from "@/stores/error-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ export function OfflineBanner(): React.ReactElement | null {
|
|||||||
<div ref={bannerRef} className="sticky top-0 z-[60] w-full">
|
<div ref={bannerRef} className="sticky top-0 z-[60] w-full">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
playerViewportColumnClass,
|
playerContentColumnClass,
|
||||||
"flex items-center justify-center gap-2 px-4 py-3 text-sm",
|
"flex items-center justify-center gap-2 px-4 py-3 text-sm",
|
||||||
"animate-in slide-in-from-top-full duration-300 ease-out",
|
"animate-in slide-in-from-top-full duration-300 ease-out",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { Loader2, RefreshCw } from "lucide-react";
|
import { Loader2, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
|
import { playerViewportFixedBarClass } from "@/lib/player-viewport";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface PullToRefreshIndicatorProps {
|
interface PullToRefreshIndicatorProps {
|
||||||
@@ -25,7 +26,10 @@ export function PullToRefreshIndicator({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none fixed left-1/2 z-[60] flex w-full max-w-[480px] -translate-x-1/2 items-center justify-center overflow-hidden transition-[height] duration-100 ease-out"
|
className={cn(
|
||||||
|
playerViewportFixedBarClass,
|
||||||
|
"pointer-events-none z-[60] flex items-center justify-center overflow-hidden transition-[height] duration-100 ease-out",
|
||||||
|
)}
|
||||||
style={{
|
style={{
|
||||||
top: "env(safe-area-inset-top, 0px)",
|
top: "env(safe-area-inset-top, 0px)",
|
||||||
height: isRefreshing ? 40 : pullDistance,
|
height: isRefreshing ? 40 : pullDistance,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { useErrorStore, ERROR_COLORS } from "@/stores/error-store";
|
import { useErrorStore, ERROR_COLORS } from "@/stores/error-store";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { playerViewportOverlayClass } from "@/lib/player-viewport";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,7 +33,12 @@ export function ServerError(): React.ReactElement | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-background p-4">
|
<div
|
||||||
|
className={cn(
|
||||||
|
playerViewportOverlayClass,
|
||||||
|
"z-[100] flex items-center justify-center bg-background p-4",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="flex w-full max-w-md flex-col items-center text-center">
|
<div className="flex w-full max-w-md flex-col items-center text-center">
|
||||||
{/* 错误图标 */}
|
{/* 错误图标 */}
|
||||||
<div
|
<div
|
||||||
@@ -109,7 +115,12 @@ export function ServerErrorModal(): React.ReactElement | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
<div
|
||||||
|
className={cn(
|
||||||
|
playerViewportOverlayClass,
|
||||||
|
"z-[100] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="w-full max-w-sm rounded-xl bg-card p-6 shadow-lg">
|
<div className="w-full max-w-sm rounded-xl bg-card p-6 shadow-lg">
|
||||||
<div className="flex flex-col items-center text-center">
|
<div className="flex flex-col items-center text-center">
|
||||||
{/* 错误图标 */}
|
{/* 错误图标 */}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import * as React from "react"
|
|||||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
playerViewportModalClass,
|
||||||
|
playerViewportOverlayClass,
|
||||||
|
} from "@/lib/player-viewport"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
@@ -31,7 +35,8 @@ function DialogOverlay({
|
|||||||
<DialogPrimitive.Backdrop
|
<DialogPrimitive.Backdrop
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
playerViewportOverlayClass,
|
||||||
|
"isolate bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -53,7 +58,8 @@ function DialogContent({
|
|||||||
<DialogPrimitive.Popup
|
<DialogPrimitive.Popup
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
playerViewportModalClass,
|
||||||
|
"grid w-full max-w-[calc(100%-2rem)] gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as React from "react"
|
|||||||
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { playerViewportOverlayClass } from "@/lib/player-viewport"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
@@ -28,7 +29,8 @@ function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
|||||||
<SheetPrimitive.Backdrop
|
<SheetPrimitive.Backdrop
|
||||||
data-slot="sheet-overlay"
|
data-slot="sheet-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
|
playerViewportOverlayClass,
|
||||||
|
"bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -53,7 +55,7 @@ function SheetContent({
|
|||||||
data-slot="sheet-content"
|
data-slot="sheet-content"
|
||||||
data-side={side}
|
data-side={side}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:bottom-0 data-[side=bottom]:left-[var(--player-sidebar-width)] data-[side=bottom]:right-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-[var(--player-sidebar-width)] data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:left-[var(--player-sidebar-width)] data-[side=top]:right-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -33,13 +33,13 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||||||
"--normal-text": "var(--popover-foreground)",
|
"--normal-text": "var(--popover-foreground)",
|
||||||
"--normal-border": "var(--border)",
|
"--normal-border": "var(--border)",
|
||||||
"--border-radius": "calc(var(--radius) * 0.8)",
|
"--border-radius": "calc(var(--radius) * 0.8)",
|
||||||
"--width": "min(280px, calc(100vw - 24px))",
|
"--width": "min(280px, calc(var(--player-viewport-max, 480px) - 24px))",
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
classNames: {
|
classNames: {
|
||||||
toast:
|
toast:
|
||||||
"cn-toast !min-h-0 !w-full !max-w-[min(280px,calc(100vw-24px))] !items-center !gap-2 !rounded-lg !border !px-3 !py-2 !text-xs !shadow-md",
|
"cn-toast !min-h-0 !w-full !max-w-[min(280px,calc(var(--player-viewport-max,480px)-24px))] !items-center !gap-2 !rounded-lg !border !px-3 !py-2 !text-xs !shadow-md",
|
||||||
title: "!text-xs !font-semibold !leading-snug",
|
title: "!text-xs !font-semibold !leading-snug",
|
||||||
description: "!text-[11px] !leading-snug !text-muted-foreground",
|
description: "!text-[11px] !leading-snug !text-muted-foreground",
|
||||||
icon: "!mr-0 !size-3.5",
|
icon: "!mr-0 !size-3.5",
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BookOpen } from "lucide-react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
|
||||||
import { HallBettingGrid } from "@/features/hall/hall-betting-grid";
|
import { HallBettingGrid } from "@/features/hall/hall-betting-grid";
|
||||||
import { usePlayerStickyTopStyle } from "@/hooks/use-player-sticky-top-style";
|
|
||||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
import { HallDrawPanel } from "@/features/hall/hall-draw-panel";
|
import { HallDrawPanel } from "@/features/hall/hall-draw-panel";
|
||||||
import { HallWalletStrip } from "@/features/hall/hall-wallet-strip";
|
import { HallWalletStrip } from "@/features/hall/hall-wallet-strip";
|
||||||
import { JackpotBurstOverlay } from "@/features/hall/jackpot-burst-overlay";
|
import { JackpotBurstOverlay } from "@/features/hall/jackpot-burst-overlay";
|
||||||
import { useHallDrawLive } from "@/features/hall/use-hall-draw-live";
|
import { useHallDrawLive } from "@/features/hall/use-hall-draw-live";
|
||||||
import { useJackpotBurstLive } from "@/features/hall/use-jackpot-burst-live";
|
import { useJackpotBurstLive } from "@/features/hall/use-jackpot-burst-live";
|
||||||
import { playerHeaderControl, playerPageHeader, playerPageInset } from "@/lib/player-spacing";
|
import { playerPageInset } from "@/lib/player-spacing";
|
||||||
import { playerViewportColumnClass } from "@/lib/player-viewport";
|
import { playerContentColumnClass } from "@/lib/player-viewport";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,52 +21,15 @@ export function HallScreen() {
|
|||||||
const drawLive = useHallDrawLive();
|
const drawLive = useHallDrawLive();
|
||||||
const { activeCurrency } = useActivePlayerCurrency();
|
const { activeCurrency } = useActivePlayerCurrency();
|
||||||
const { burstEvent, clearBurstEvent } = useJackpotBurstLive(tp);
|
const { burstEvent, clearBurstEvent } = useJackpotBurstLive(tp);
|
||||||
const stickyTopStyle = usePlayerStickyTopStyle();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={playerViewportColumnClass}>
|
<div className={playerContentColumnClass}>
|
||||||
<section className={cn("bg-white text-slate-900", playerPageInset)}>
|
<section
|
||||||
<header
|
className={cn(
|
||||||
className={cn(
|
"bg-white text-slate-900 lg:rounded-2xl lg:border lg:border-[#e4ebf5] lg:shadow-sm",
|
||||||
playerPageHeader,
|
playerPageInset,
|
||||||
"sticky z-20 mb-2 flex min-h-9 items-center gap-2 overflow-visible bg-white pt-2 pb-2 -mx-3 px-3",
|
)}
|
||||||
)}
|
>
|
||||||
style={stickyTopStyle}
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 flex-1 items-center">
|
|
||||||
<Image
|
|
||||||
src="/logo.png"
|
|
||||||
alt="Nlotto"
|
|
||||||
width={243}
|
|
||||||
height={84}
|
|
||||||
className="h-7 w-auto max-w-[min(100%,160px)] object-contain object-left"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
|
||||||
<LanguageSwitcher
|
|
||||||
variant="minimal"
|
|
||||||
menuAlign="end"
|
|
||||||
showFlag={false}
|
|
||||||
className={cn(
|
|
||||||
playerHeaderControl,
|
|
||||||
"rounded-full border border-[#e4eaf4] bg-[#f8fafc] [&_button]:h-8 [&_button]:gap-1 [&_button]:px-2 [&_button]:py-0 [&_button]:text-xs",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Link
|
|
||||||
href="/rules"
|
|
||||||
className={cn(
|
|
||||||
playerHeaderControl,
|
|
||||||
"rounded-full border border-[#e4eaf4] bg-[#f8fafc] px-2.5 text-xs font-bold text-[#0b3f96] hover:bg-[#f1f6ff]",
|
|
||||||
)}
|
|
||||||
aria-label={tp("nav.rules")}
|
|
||||||
>
|
|
||||||
<BookOpen className="size-3.5 shrink-0" aria-hidden />
|
|
||||||
<span>{tp("nav.rules")}</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<HallDrawPanel drawLive={drawLive} />
|
<HallDrawPanel drawLive={drawLive} />
|
||||||
|
|
||||||
<HallWalletStrip />
|
<HallWalletStrip />
|
||||||
@@ -81,4 +39,4 @@ export function HallScreen() {
|
|||||||
<JackpotBurstOverlay event={burstEvent} onClose={clearBurstEvent} />
|
<JackpotBurstOverlay event={burstEvent} onClose={clearBurstEvent} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { getDrawResultByNo } from "@/api/draw";
|
import { getDrawResultByNo } from "@/api/draw";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
|
import { playerViewportOverlayClass } from "@/lib/player-viewport";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type JackpotBurstEvent = {
|
export type JackpotBurstEvent = {
|
||||||
@@ -214,7 +215,10 @@ function JackpotBurstOverlayContent({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[90] flex items-center justify-center bg-white/88 px-4 py-6 text-slate-900 backdrop-blur-[2px] animate-[jackpot-backdrop-in_0.35s_ease-out]"
|
className={cn(
|
||||||
|
playerViewportOverlayClass,
|
||||||
|
"z-[90] flex items-center justify-center bg-white/88 px-4 py-6 text-slate-900 backdrop-blur-[2px] animate-[jackpot-backdrop-in_0.35s_ease-out]",
|
||||||
|
)}
|
||||||
style={{
|
style={{
|
||||||
backgroundImage:
|
backgroundImage:
|
||||||
"radial-gradient(ellipse 75% 55% at 50% 38%, rgba(245,197,66,0.14) 0%, transparent 60%)",
|
"radial-gradient(ellipse 75% 55% at 50% 38%, rgba(245,197,66,0.14) 0%, transparent 60%)",
|
||||||
|
|||||||
@@ -450,7 +450,7 @@ export function EntryGate() {
|
|||||||
|
|
||||||
<div className="flex flex-1 flex-col px-3 py-5">
|
<div className="flex flex-1 flex-col px-3 py-5">
|
||||||
{phase === "loading" || waitingForEmbeddedToken ? (
|
{phase === "loading" || waitingForEmbeddedToken ? (
|
||||||
<div className="mx-auto w-full max-w-md">
|
<div className="mx-auto w-full max-w-md lg:max-w-lg">
|
||||||
<div className="mb-6 flex items-center gap-2">
|
<div className="mb-6 flex items-center gap-2">
|
||||||
<div className="flex size-8 items-center justify-center rounded bg-red-600 text-white">
|
<div className="flex size-8 items-center justify-center rounded bg-red-600 text-white">
|
||||||
<Globe className="size-5" aria-hidden />
|
<Globe className="size-5" aria-hidden />
|
||||||
@@ -525,7 +525,7 @@ export function EntryGate() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{phase === "failed" ? (
|
{phase === "failed" ? (
|
||||||
<div className="mx-auto w-full max-w-md">
|
<div className="mx-auto w-full max-w-md lg:max-w-lg">
|
||||||
<div className="mb-6 text-center">
|
<div className="mb-6 text-center">
|
||||||
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-red-100 text-red-600">
|
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-red-100 text-red-600">
|
||||||
<AlertTriangle className="size-8" aria-hidden />
|
<AlertTriangle className="size-8" aria-hidden />
|
||||||
@@ -601,7 +601,7 @@ export function EntryGate() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{phase === "success" ? (
|
{phase === "success" ? (
|
||||||
<div className="mx-auto w-full max-w-md text-center">
|
<div className="mx-auto w-full max-w-md text-center lg:max-w-lg">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-green-100 text-green-600">
|
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-green-100 text-green-600">
|
||||||
<CheckCircle2 className="size-8" aria-hidden />
|
<CheckCircle2 className="size-8" aria-hidden />
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export function PlayerLoginScreen(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto w-full max-w-md flex-1 px-4 py-8">
|
<div className="mx-auto w-full max-w-md flex-1 px-4 py-8 lg:max-w-lg lg:px-8">
|
||||||
<h1 className="text-xl font-bold text-gray-900">
|
<h1 className="text-xl font-bold text-gray-900">
|
||||||
{t("login.title")}
|
{t("login.title")}
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -117,7 +117,8 @@ export function DrawResultsListScreen() {
|
|||||||
<p className="mb-2 text-xs font-bold text-[#32518d]">
|
<p className="mb-2 text-xs font-bold text-[#32518d]">
|
||||||
{t("results.businessDate")}
|
{t("results.businessDate")}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex min-w-0 gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
<Popover open={datePickerOpen} onOpenChange={setDatePickerOpen}>
|
<Popover open={datePickerOpen} onOpenChange={setDatePickerOpen}>
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
render={
|
render={
|
||||||
@@ -186,6 +187,7 @@ export function DrawResultsListScreen() {
|
|||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
</div>
|
||||||
{date ? (
|
{date ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -201,7 +203,7 @@ export function DrawResultsListScreen() {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-10 rounded-lg bg-[#07459f] px-4 text-white hover:bg-[#063b88]"
|
className="h-10 shrink-0 rounded-lg bg-[#07459f] px-4 text-white hover:bg-[#063b88]"
|
||||||
onClick={() => void fetchList(1, false)}
|
onClick={() => void fetchList(1, false)}
|
||||||
>
|
>
|
||||||
{t("actions.apply")}
|
{t("actions.apply")}
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { PLAYER_DESKTOP_BREAKPOINT_PX } from "@/lib/player-viewport";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 玩家端 H5 内容列最大 480px,交互一律按紧凑布局处理。
|
* 是否按移动端紧凑布局渲染(< lg / 1024px)。
|
||||||
* 宽屏浏览器中虽 viewport 更宽,画布仍按手机宽度展示,因此恒为 `true`。
|
* 桌面端启用侧栏 + 顶栏布局(见 UI 设计稿)。
|
||||||
*/
|
*/
|
||||||
export function useIsMobile(): boolean {
|
export function useIsMobile(): boolean {
|
||||||
return true;
|
const [isMobile, setIsMobile] = useState(true);
|
||||||
}
|
|
||||||
|
useEffect(() => {
|
||||||
|
const query = `(max-width: ${PLAYER_DESKTOP_BREAKPOINT_PX - 1}px)`;
|
||||||
|
const mq = window.matchMedia(query);
|
||||||
|
const sync = () => setIsMobile(mq.matches);
|
||||||
|
sync();
|
||||||
|
mq.addEventListener("change", sync);
|
||||||
|
return () => mq.removeEventListener("change", sync);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMobile;
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
/** 玩家端统一紧凑间距(页面壳、区块堆叠、区块间距) */
|
/** 主滚动区外边距(灰底区域与顶栏/底栏留白) */
|
||||||
export const playerPageInset = "px-3 pb-6 pt-2";
|
export const playerMainInset = "px-3 pt-3 pb-3 lg:px-8 lg:pt-6 lg:pb-8";
|
||||||
|
|
||||||
|
/** 页内白卡片内边距 */
|
||||||
|
export const playerPageShellPadding = "px-3 pt-3 pb-6 lg:px-8 lg:pt-8 lg:pb-8";
|
||||||
|
|
||||||
|
/** 大厅等直接写在 section 上的内边距 */
|
||||||
|
export const playerPageInset = playerPageShellPadding;
|
||||||
/** 顶栏:三列 grid 布局,标题居中;左右控件统一高度 */
|
/** 顶栏:三列 grid 布局,标题居中;左右控件统一高度 */
|
||||||
export const playerPageHeader =
|
export const playerPageHeader =
|
||||||
"relative mb-2 flex min-h-9 items-center justify-between gap-2";
|
"relative mb-2 flex min-h-9 items-center justify-between gap-2";
|
||||||
|
|||||||
@@ -1,12 +1,49 @@
|
|||||||
/** H5 玩家端逻辑宽度(px),桌面浏览器居中展示为「手机画布」 */
|
/** H5 玩家端逻辑宽度(px),窄屏/移动浏览器居中展示为「手机画布」 */
|
||||||
export const PLAYER_VIEWPORT_MAX_PX = 480;
|
export const PLAYER_VIEWPORT_MAX_PX = 480;
|
||||||
|
|
||||||
/** 内容列:全宽但不超过手机逻辑宽度,水平居中 */
|
/** 桌面侧栏布局断点(与 Tailwind `lg` 一致) */
|
||||||
export const playerViewportColumnClass = "mx-auto w-full max-w-[480px]" as const;
|
export const PLAYER_DESKTOP_BREAKPOINT_PX = 1024;
|
||||||
|
|
||||||
/** 贴底/贴顶固定栏:与内容列同宽并居中(桌面不铺满整屏) */
|
/** 桌面侧栏宽度,与 PlayerSidebar `w-56` 对齐 */
|
||||||
|
export const PLAYER_SIDEBAR_WIDTH = "14rem";
|
||||||
|
|
||||||
|
/** 外层视口:移动居中灰底;桌面全宽浅灰底 */
|
||||||
|
export const playerViewportOuterClass =
|
||||||
|
"h-[100dvh] overflow-hidden bg-[#e8ecf3] lg:bg-[#f0f2f6]" as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内层根容器:移动 480px 居中;桌面铺满并启用侧栏布局变量。
|
||||||
|
* 配合 globals.css `.player-desktop-shell` 约束弹层/遮罩范围。
|
||||||
|
*/
|
||||||
|
export const playerViewportRootClass =
|
||||||
|
"player-desktop-shell relative mx-auto flex h-full min-h-0 w-full max-w-[480px] flex-col bg-white shadow-none lg:mx-0 lg:max-w-none sm:shadow-[0_0_48px_rgba(15,23,42,0.1)] lg:shadow-none" as const;
|
||||||
|
|
||||||
|
/** 页面内容列:移动限宽;桌面随主内容区伸展 */
|
||||||
|
export const playerContentColumnClass =
|
||||||
|
"mx-auto w-full max-w-[480px] lg:mx-0 lg:max-w-none" as const;
|
||||||
|
|
||||||
|
/** @deprecated 使用 playerContentColumnClass */
|
||||||
|
export const playerViewportColumnClass = playerContentColumnClass;
|
||||||
|
|
||||||
|
/** 贴底/贴顶固定栏:移动与内容列同宽;桌面贴主内容区顶部 */
|
||||||
export const playerViewportFixedBarClass =
|
export const playerViewportFixedBarClass =
|
||||||
"fixed left-1/2 z-50 w-full max-w-[480px] -translate-x-1/2" as const;
|
"fixed left-1/2 z-50 w-full max-w-[480px] -translate-x-1/2 lg:left-[var(--player-sidebar-width)] lg:max-w-none lg:translate-x-0" as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全屏遮罩:桌面仅覆盖主内容区(不盖住左侧导航)。
|
||||||
|
*/
|
||||||
|
export const playerViewportOverlayClass =
|
||||||
|
"fixed inset-0 z-50 lg:left-[var(--player-sidebar-width)]" as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 居中弹窗:移动按视口居中;桌面按「主内容区」水平居中。
|
||||||
|
*/
|
||||||
|
export const playerViewportModalClass =
|
||||||
|
"fixed top-1/2 left-1/2 z-50 -translate-x-1/2 -translate-y-1/2 lg:left-[calc(var(--player-sidebar-width)+(100vw-var(--player-sidebar-width))/2)]" as const;
|
||||||
|
|
||||||
/** 刘海屏 / 全屏 WebView 顶部安全区 */
|
/** 刘海屏 / 全屏 WebView 顶部安全区 */
|
||||||
export const playerSafeAreaTopClass = "pt-[env(safe-area-inset-top,0px)]" as const;
|
export const playerSafeAreaTopClass = "pt-[env(safe-area-inset-top,0px)]" as const;
|
||||||
|
|
||||||
|
/** Popover / Toast 等内容宽度上限(相对画布,避免桌面 100vw 溢出) */
|
||||||
|
export const playerViewportPopoverMaxClass =
|
||||||
|
"max-w-[min(calc(var(--player-viewport-max,480px)-2rem),280px)]" as const;
|
||||||
Reference in New Issue
Block a user