feat: implement global pull-to-refresh functionality and refine UI typography and layout scaling for improved mobile UX.
Some checks failed
lotteryfront CI / build (push) Has been cancelled

This commit is contained in:
2026-06-24 11:46:49 +08:00
parent f46ccaac6e
commit cff6470031
24 changed files with 253 additions and 60 deletions

View File

@@ -4,6 +4,8 @@ import type { ReactNode } from "react";
import { NetworkStatusBanner } from "@/components/network-status-banner";
import { PlayerBottomNav } from "@/components/layout/player-bottom-nav";
import { PullToRefreshIndicator } from "@/components/pull-to-refresh-indicator";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
type PlayerAppShellProps = {
children: ReactNode;
@@ -11,19 +13,34 @@ type PlayerAppShellProps = {
/**
* 玩家端外壳:顶栏(品牌 + 会话)+ 主体 + **底部 Tab 导航**H5
* 底部栏留白:{@link PlayerBottomNav} 对应 `padding-bottom`.
* 底部栏留白:{@link PlayerBottomNav} 作为 sticky 元素放在底部main 只需要基础 padding。
*
* 注意:全局离线横幅和服务器错误覆盖层已在 ErrorProvider 中处理
* 这里的 NetworkStatusBanner 仅用于 WebSocket 状态显示
*/
export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
const { pullDistance, isRefreshing } = usePullToRefresh({
onRefresh: () => {
window.dispatchEvent(new CustomEvent("lottery-hall-refresh"));
window.dispatchEvent(new CustomEvent("lottery-wallet-refresh"));
},
threshold: 70,
});
return (
<div className="min-h-dvh bg-white text-foreground">
<div className="flex h-full flex-col bg-white text-foreground overflow-hidden">
<PullToRefreshIndicator
pullDistance={pullDistance}
isRefreshing={isRefreshing}
threshold={70}
/>
<NetworkStatusBanner />
<main className="flex w-full flex-col pb-[calc(3.5rem+env(safe-area-inset-bottom,0px)+0.75rem)]">
<main id="player-scroll-container" className="flex w-full flex-1 flex-col pb-1 overflow-y-auto overscroll-y-contain">
{children}
</main>
<PlayerBottomNav />
<div className="shrink-0 w-full relative z-50">
<PlayerBottomNav />
</div>
</div>
);
}

View File

@@ -6,7 +6,6 @@ import { usePathname } from "next/navigation";
import { BarChart3, ClipboardList, Home, Wallet } from "lucide-react";
import { useTranslation } from "react-i18next";
import { playerViewportFixedBarClass } from "@/lib/player-viewport";
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
import { cn } from "@/lib/utils";
import { usePlayerSessionStore } from "@/stores/player-session-store";
@@ -55,8 +54,7 @@ export function PlayerBottomNav() {
return (
<nav
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",
"w-full 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")}
>
@@ -75,7 +73,7 @@ export function PlayerBottomNav() {
prefetch
aria-current={active ? "page" : undefined}
className={cn(
"relative flex min-h-0 min-w-0 max-w-full flex-col items-center justify-center gap-1 px-0.5 text-center text-[10px] font-semibold leading-tight transition-colors sm:text-[11px]",
"relative flex min-h-0 min-w-0 max-w-full flex-col items-center justify-center gap-1 px-0.5 text-center text-[11px] font-semibold leading-tight transition-colors",
active
? "text-[#f10b32]"
: "text-[#6b7280] hover:text-[#1f2937] active:text-[#1f2937]",
@@ -83,7 +81,7 @@ export function PlayerBottomNav() {
>
<Icon
aria-hidden
className={cn("size-5 shrink-0 sm:size-[22px]", active && "stroke-[2.5px]")}
className={cn("size-5 shrink-0", active && "stroke-[2.5px]")}
/>
<span className="w-full truncate">{label}</span>
</Link>

View File

@@ -16,11 +16,11 @@ export function PlayerMobileViewport({
className,
}: PlayerMobileViewportProps): ReactNode {
return (
<div className="min-h-dvh bg-[#e8ecf3]">
<div className="h-[100dvh] bg-[#e8ecf3] overflow-hidden">
<div
className={cn(
playerViewportColumnClass,
"relative min-h-dvh bg-white shadow-none sm:shadow-[0_0_48px_rgba(15,23,42,0.1)]",
"relative h-full bg-white shadow-none sm:shadow-[0_0_48px_rgba(15,23,42,0.1)] flex flex-col",
className,
)}
>

View File

@@ -0,0 +1,50 @@
"use client";
import { Loader2, RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils";
interface PullToRefreshIndicatorProps {
pullDistance: number;
isRefreshing: boolean;
threshold: number;
}
/**
* Visual indicator for pull-to-refresh.
* Renders a fixed bar at the top that grows as the user pulls down.
*/
export function PullToRefreshIndicator({
pullDistance,
isRefreshing,
threshold,
}: PullToRefreshIndicatorProps) {
if (pullDistance === 0 && !isRefreshing) return null;
const percent = Math.min(pullDistance / threshold, 1);
return (
<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"
style={{ height: isRefreshing ? 40 : pullDistance }}
>
<div
className={cn(
"flex items-center gap-1.5 text-xs font-bold text-[#32518d] transition-opacity",
pullDistance > 5 || isRefreshing ? "opacity-100" : "opacity-0",
)}
>
{isRefreshing ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<RefreshCw
className="size-4 transition-transform"
style={{ transform: `rotate(${percent * 360}deg)` }}
aria-hidden
/>
)}
<span>{isRefreshing ? "…" : ""}</span>
</div>
</div>
);
}

View File

@@ -46,7 +46,7 @@ function Calendar({
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
"relative flex flex-col gap-4",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),