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
Some checks failed
lotteryfront CI / build (push) Has been cancelled
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
50
src/components/pull-to-refresh-indicator.tsx
Normal file
50
src/components/pull-to-refresh-indicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user