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:
@@ -121,11 +121,14 @@
|
|||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
body {
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
}
|
|
||||||
html {
|
html {
|
||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
}
|
}
|
||||||
html:lang(ne) {
|
html:lang(ne) {
|
||||||
font-family:
|
font-family:
|
||||||
@@ -134,6 +137,9 @@
|
|||||||
system-ui,
|
system-ui,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
}
|
}
|
||||||
|
button, a, [role="button"] {
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 玩家端 Toast:顶部居中、紧凑尺寸(位置见 components/ui/sonner.tsx) */
|
/* 玩家端 Toast:顶部居中、紧凑尺寸(位置见 components/ui/sonner.tsx) */
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export const metadata: Metadata = {
|
|||||||
export const viewport = {
|
export const viewport = {
|
||||||
width: "device-width",
|
width: "device-width",
|
||||||
initialScale: 1,
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
|
userScalable: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -39,9 +41,9 @@ export default function RootLayout({
|
|||||||
<html
|
<html
|
||||||
lang={DEFAULT_LANGUAGE}
|
lang={DEFAULT_LANGUAGE}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
className={`${geistSans.variable} ${geistMono.variable} ${notoSansDevanagari.variable} h-full antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} ${notoSansDevanagari.variable} min-h-full antialiased`}
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col">
|
<body className="min-h-full">
|
||||||
<Providers>{children}</Providers>
|
<Providers>{children}</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type { ReactNode } from "react";
|
|||||||
|
|
||||||
import { NetworkStatusBanner } from "@/components/network-status-banner";
|
import { NetworkStatusBanner } from "@/components/network-status-banner";
|
||||||
import { PlayerBottomNav } from "@/components/layout/player-bottom-nav";
|
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 = {
|
type PlayerAppShellProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -11,19 +13,34 @@ type PlayerAppShellProps = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 玩家端外壳:顶栏(品牌 + 会话)+ 主体 + **底部 Tab 导航**(H5)。
|
* 玩家端外壳:顶栏(品牌 + 会话)+ 主体 + **底部 Tab 导航**(H5)。
|
||||||
* 底部栏留白:{@link PlayerBottomNav} 对应 `padding-bottom`.
|
* 底部栏留白:{@link PlayerBottomNav} 作为 sticky 元素放在底部,main 只需要基础 padding。
|
||||||
*
|
*
|
||||||
* 注意:全局离线横幅和服务器错误覆盖层已在 ErrorProvider 中处理
|
* 注意:全局离线横幅和服务器错误覆盖层已在 ErrorProvider 中处理
|
||||||
* 这里的 NetworkStatusBanner 仅用于 WebSocket 状态显示
|
* 这里的 NetworkStatusBanner 仅用于 WebSocket 状态显示
|
||||||
*/
|
*/
|
||||||
export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
|
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 (
|
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 />
|
<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}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
<div className="shrink-0 w-full relative z-50">
|
||||||
<PlayerBottomNav />
|
<PlayerBottomNav />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ 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 { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
@@ -55,8 +54,7 @@ export function PlayerBottomNav() {
|
|||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
className={cn(
|
className={cn(
|
||||||
playerViewportFixedBarClass,
|
"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",
|
||||||
"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")}
|
||||||
>
|
>
|
||||||
@@ -75,7 +73,7 @@ export function PlayerBottomNav() {
|
|||||||
prefetch
|
prefetch
|
||||||
aria-current={active ? "page" : undefined}
|
aria-current={active ? "page" : undefined}
|
||||||
className={cn(
|
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
|
active
|
||||||
? "text-[#f10b32]"
|
? "text-[#f10b32]"
|
||||||
: "text-[#6b7280] hover:text-[#1f2937] active:text-[#1f2937]",
|
: "text-[#6b7280] hover:text-[#1f2937] active:text-[#1f2937]",
|
||||||
@@ -83,7 +81,7 @@ export function PlayerBottomNav() {
|
|||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
aria-hidden
|
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>
|
<span className="w-full truncate">{label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ export function PlayerMobileViewport({
|
|||||||
className,
|
className,
|
||||||
}: PlayerMobileViewportProps): ReactNode {
|
}: PlayerMobileViewportProps): ReactNode {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh bg-[#e8ecf3]">
|
<div className="h-[100dvh] bg-[#e8ecf3] overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
playerViewportColumnClass,
|
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,
|
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={{
|
classNames={{
|
||||||
root: cn("w-fit", defaultClassNames.root),
|
root: cn("w-fit", defaultClassNames.root),
|
||||||
months: cn(
|
months: cn(
|
||||||
"relative flex flex-col gap-4 md:flex-row",
|
"relative flex flex-col gap-4",
|
||||||
defaultClassNames.months
|
defaultClassNames.months
|
||||||
),
|
),
|
||||||
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ export function HallBetPreviewDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{summary ? (
|
{summary ? (
|
||||||
<div className="grid grid-cols-2 overflow-hidden rounded-xl border border-[#dfe8f6] bg-[#f8fbff] text-center text-xs sm:grid-cols-4">
|
<div className="grid grid-cols-2 overflow-hidden rounded-xl border border-[#dfe8f6] bg-[#f8fbff] text-center text-xs">
|
||||||
<div className="border-r border-[#dfe8f6] px-2 py-3">
|
<div className="border-r border-[#dfe8f6] px-2 py-3">
|
||||||
<p className="font-bold text-[#304f86]">{t("hall.preview.totalBet")}</p>
|
<p className="font-bold text-[#304f86]">{t("hall.preview.totalBet")}</p>
|
||||||
<p className="mt-1 font-black tabular-nums text-[#0b3f96]">
|
<p className="mt-1 font-black tabular-nums text-[#0b3f96]">
|
||||||
@@ -319,7 +319,7 @@ export function HallBetPreviewDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid shrink-0 grid-cols-2 gap-2.5 border-t border-[#e8eef7] bg-white p-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:p-4">
|
<div className="grid shrink-0 grid-cols-2 gap-2.5 border-t border-[#e8eef7] bg-white p-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))]">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ export function HallBetResultDialog({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2 rounded-lg border border-[#e8eef7] bg-white px-3 py-2.5 text-xs text-slate-600 sm:grid-cols-2">
|
<div className="grid grid-cols-2 gap-2 rounded-lg border border-[#e8eef7] bg-white px-3 py-2.5 text-xs text-slate-600">
|
||||||
<p>
|
<p>
|
||||||
{t("hall.result.orderNo")}:{" "}
|
{t("hall.result.orderNo")}:{" "}
|
||||||
<span className="font-mono font-black text-[#0b3f96]">{data.order_no}</span>
|
<span className="font-mono font-black text-[#0b3f96]">{data.order_no}</span>
|
||||||
@@ -232,7 +232,7 @@ export function HallBetResultDialog({
|
|||||||
<span className="block font-mono text-base font-black text-slate-950">
|
<span className="block font-mono text-base font-black text-slate-950">
|
||||||
{item.number}
|
{item.number}
|
||||||
</span>
|
</span>
|
||||||
<span className="block truncate text-[10px] text-slate-400">
|
<span className="block truncate text-[11px] text-slate-400">
|
||||||
{item.ticket_no}
|
{item.ticket_no}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -287,7 +287,7 @@ export function HallBetResultDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid shrink-0 grid-cols-2 gap-2.5 border-t border-[#e8eef7] bg-white p-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:p-4">
|
<div className="grid shrink-0 grid-cols-2 gap-2.5 border-t border-[#e8eef7] bg-white p-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))]">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
nativeButton={false}
|
nativeButton={false}
|
||||||
|
|||||||
@@ -1301,7 +1301,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
<button
|
<button
|
||||||
key={`fav-${number}`}
|
key={`fav-${number}`}
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex h-7 touch-manipulation items-center gap-1 rounded-full border border-[#ffd7db] bg-[#fff3f5] px-2.5 text-xs font-semibold text-[#d81435] transition-colors hover:bg-[#ffe9ed]"
|
className="inline-flex h-8 touch-manipulation items-center gap-1 rounded-full border border-[#ffd7db] bg-[#fff3f5] px-2.5 text-xs font-semibold text-[#d81435] transition-colors hover:bg-[#ffe9ed]"
|
||||||
onPointerDown={() => {
|
onPointerDown={() => {
|
||||||
const current = holdFavoriteRef.current;
|
const current = holdFavoriteRef.current;
|
||||||
current.number = number;
|
current.number = number;
|
||||||
@@ -1333,7 +1333,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{number}
|
{number}
|
||||||
<span className="text-[10px] font-medium opacity-70">
|
<span className="text-[11px] font-medium opacity-70">
|
||||||
{t("hall.quickFill.tapHold")}
|
{t("hall.quickFill.tapHold")}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -1350,14 +1350,14 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
<button
|
<button
|
||||||
key={`his-${number}`}
|
key={`his-${number}`}
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex h-7 min-w-10 items-center justify-center rounded-full border border-[#d7e5f8] bg-[#f8fbff] px-3 text-xs font-bold text-[#07459f] transition-colors hover:border-[#b9d0f3] hover:bg-[#eef6ff]"
|
className="inline-flex h-8 min-w-10 items-center justify-center rounded-full border border-[#d7e5f8] bg-[#f8fbff] px-3 text-xs font-bold text-[#07459f] transition-colors hover:border-[#b9d0f3] hover:bg-[#eef6ff]"
|
||||||
onClick={() => fillCurrentRow(number)}
|
onClick={() => fillCurrentRow(number)}
|
||||||
>
|
>
|
||||||
{number}
|
{number}
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-flex h-7 items-center rounded-full border border-dashed border-slate-200 bg-slate-50 px-3 text-xs text-slate-400">
|
<span className="inline-flex h-8 items-center rounded-full border border-dashed border-slate-200 bg-slate-50 px-3 text-xs text-slate-400">
|
||||||
{t("hall.quickFill.emptyHistory")}
|
{t("hall.quickFill.emptyHistory")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -1405,7 +1405,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="block text-xs">{t("hall.table.number", { defaultValue: "Number" })}</span>
|
<span className="block text-xs">{t("hall.table.number", { defaultValue: "Number" })}</span>
|
||||||
<span className="mt-0.5 block font-mono text-[10px] font-medium text-[#9aa8bd]">
|
<span className="mt-0.5 block font-mono text-[11px] font-medium text-[#9aa8bd]">
|
||||||
{numberPlaceholder}
|
{numberPlaceholder}
|
||||||
</span>
|
</span>
|
||||||
</th>
|
</th>
|
||||||
@@ -1414,7 +1414,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
key={column.key}
|
key={column.key}
|
||||||
className="w-[4.5rem] min-w-[4.5rem] max-w-[4.5rem] px-0.5 py-2 text-center font-bold"
|
className="w-[4.5rem] min-w-[4.5rem] max-w-[4.5rem] px-0.5 py-2 text-center font-bold"
|
||||||
>
|
>
|
||||||
<span className="block whitespace-nowrap text-[10px] leading-tight">
|
<span className="block whitespace-nowrap text-[11px] leading-tight">
|
||||||
{playColumnHeaderLabel(
|
{playColumnHeaderLabel(
|
||||||
column.play,
|
column.play,
|
||||||
activeCategory as Exclude<HallCategory, "JACKPOT">,
|
activeCategory as Exclude<HallCategory, "JACKPOT">,
|
||||||
@@ -1422,7 +1422,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
t,
|
t,
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-0.5 block text-[9px] font-medium text-[#9aa8bd]">
|
<span className="mt-0.5 block text-[10px] font-medium text-[#9aa8bd]">
|
||||||
{t("hall.table.amountPlaceholder")}
|
{t("hall.table.amountPlaceholder")}
|
||||||
</span>
|
</span>
|
||||||
</th>
|
</th>
|
||||||
@@ -1519,11 +1519,11 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{status === "sold_out" ? (
|
{status === "sold_out" ? (
|
||||||
<p className="mt-0.5 text-center text-[9px] font-bold text-slate-500">
|
<p className="mt-0.5 text-center text-[10px] font-bold text-slate-500">
|
||||||
{t("hall.table.soldOut")}
|
{t("hall.table.soldOut")}
|
||||||
</p>
|
</p>
|
||||||
) : status === "warning" ? (
|
) : status === "warning" ? (
|
||||||
<p className="mt-0.5 text-center text-[9px] font-bold text-amber-700">
|
<p className="mt-0.5 text-center text-[10px] font-bold text-amber-700">
|
||||||
{t("hall.table.warning")}
|
{t("hall.table.warning")}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1535,7 +1535,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={tableDisabled || rows.length <= 1}
|
disabled={tableDisabled || rows.length <= 1}
|
||||||
onClick={() => removeRow(row.id)}
|
onClick={() => removeRow(row.id)}
|
||||||
className="inline-flex size-6 items-center justify-center rounded-full text-[#e5002c] hover:bg-red-50 disabled:text-slate-300 disabled:hover:bg-transparent"
|
className="inline-flex size-8 items-center justify-center rounded-full text-[#e5002c] hover:bg-red-50 disabled:text-slate-300 disabled:hover:bg-transparent"
|
||||||
aria-label={t("actions.deleteRow", { row: index + 1 })}
|
aria-label={t("actions.deleteRow", { row: index + 1 })}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-3.5" aria-hidden />
|
<Trash2 className="size-3.5" aria-hidden />
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ function ScheduleAnchorTime({ payload }: { payload: DrawCurrentPayload }) {
|
|||||||
<>
|
<>
|
||||||
<span className="text-lg font-black tabular-nums text-[#0b3f96]">{time}</span>
|
<span className="text-lg font-black tabular-nums text-[#0b3f96]">{time}</span>
|
||||||
<span className="mt-1 text-[11px] text-slate-500">{date}</span>
|
<span className="mt-1 text-[11px] text-slate-500">{date}</span>
|
||||||
<span className="mt-0.5 text-[10px] text-slate-400">{t(labelKey)}</span>
|
<span className="mt-0.5 text-[11px] text-slate-400">{t(labelKey)}</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -186,11 +186,11 @@ export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot })
|
|||||||
)}
|
)}
|
||||||
aria-label={t("draw.currentIssue")}
|
aria-label={t("draw.currentIssue")}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 divide-y divide-[#e7edf6] sm:grid-cols-[1fr_1.05fr_1fr] sm:divide-x sm:divide-y-0">
|
<div className="grid grid-cols-[1fr_1.05fr_1fr] divide-x divide-[#e7edf6]">
|
||||||
<div className="flex min-w-0 flex-col items-center justify-center px-2 py-3 text-center">
|
<div className="flex min-w-0 flex-col items-center justify-center px-2 py-3 text-center">
|
||||||
<div className="min-w-0 max-w-full overflow-x-auto">
|
<div className="min-w-0 max-w-full overflow-x-auto">
|
||||||
<p className="text-[11px] font-semibold text-slate-500">{t("draw.issueNo")}</p>
|
<p className="text-[11px] font-semibold text-slate-500">{t("draw.issueNo")}</p>
|
||||||
<p className="whitespace-nowrap text-xs font-black tabular-nums text-[#ff143d] sm:text-sm">
|
<p className="whitespace-nowrap text-sm font-black tabular-nums text-[#ff143d]">
|
||||||
{display.draw_no}
|
{display.draw_no}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -215,7 +215,7 @@ export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot })
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{display.schedule_now ? (
|
{display.schedule_now ? (
|
||||||
<div className="border-t border-[#eef3f9] bg-[#fbfdff] px-3 py-1.5 text-center text-[10px] text-slate-500">
|
<div className="border-t border-[#eef3f9] bg-[#fbfdff] px-3 py-1.5 text-center text-[11px] text-slate-500">
|
||||||
{t("draw.scheduleNow", {
|
{t("draw.scheduleNow", {
|
||||||
now: formatLotteryScheduleClock(
|
now: formatLotteryScheduleClock(
|
||||||
display.schedule_now,
|
display.schedule_now,
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ export function HallPlayCatalogPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
<CardHeader className="flex flex-col gap-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<CardTitle className="text-base">{t("hall.playCatalog.title")}</CardTitle>
|
<CardTitle className="text-base">{t("hall.playCatalog.title")}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ export function HallScreen() {
|
|||||||
alt="Nlotto"
|
alt="Nlotto"
|
||||||
width={243}
|
width={243}
|
||||||
height={84}
|
height={84}
|
||||||
className="h-7 w-auto max-w-[min(100%,160px)] object-contain object-left sm:h-8 sm:max-w-[min(100%,200px)]"
|
className="h-7 w-auto max-w-[min(100%,160px)] object-contain object-left"
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-0.5 sm:gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<CurrencySwitcher
|
<CurrencySwitcher
|
||||||
variant="minimal"
|
variant="minimal"
|
||||||
menuAlign="end"
|
menuAlign="end"
|
||||||
@@ -76,8 +76,8 @@ export function HallScreen() {
|
|||||||
)}
|
)}
|
||||||
aria-label={tp("nav.rules")}
|
aria-label={tp("nav.rules")}
|
||||||
>
|
>
|
||||||
<BookOpen className="size-3.5 shrink-0 sm:hidden" aria-hidden />
|
<BookOpen className="size-3.5 shrink-0" aria-hidden />
|
||||||
<span className="hidden sm:inline">{tp("nav.rules")}</span>
|
<span>{tp("nav.rules")}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ function RollingDigit({
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex min-w-[2.1rem] items-center justify-center rounded-lg border border-[#e8c96a] bg-gradient-to-b from-[#fff9e8] to-[#fff3d4] px-1 py-1 font-mono text-[2rem] font-black leading-none text-[#b8860b] shadow-[inset_0_1px_0_rgba(255,255,255,0.9),0_2px_8px_rgba(201,162,39,0.12)] sm:min-w-[2.5rem] sm:text-[2.35rem]",
|
"inline-flex min-w-[2.1rem] items-center justify-center rounded-lg border border-[#e8c96a] bg-gradient-to-b from-[#fff9e8] to-[#fff3d4] px-1 py-1 font-mono text-[2rem] font-black leading-none text-[#b8860b] shadow-[inset_0_1px_0_rgba(255,255,255,0.9),0_2px_8px_rgba(201,162,39,0.12)]",
|
||||||
popping && "animate-[jackpot-digit-pop_0.32s_ease-out]",
|
popping && "animate-[jackpot-digit-pop_0.32s_ease-out]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -289,7 +289,7 @@ function JackpotBurstOverlayContent({
|
|||||||
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[#e8c96a]/70 to-transparent"
|
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[#e8c96a]/70 to-transparent"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-center gap-2 sm:gap-2.5">
|
<div className="flex items-center justify-center gap-2">
|
||||||
{display.map((char, i) => (
|
{display.map((char, i) => (
|
||||||
<RollingDigit key={i} char={char} popping={poppedIndex === i} />
|
<RollingDigit key={i} char={char} popping={poppedIndex === i} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -87,13 +87,13 @@ export function TicketOrderGroupScreen({ groupKey }: TicketOrderGroupScreenProps
|
|||||||
</p>
|
</p>
|
||||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
||||||
<p className="text-[10px] font-bold uppercase text-[#7890b8]">{t("orders.stake")}</p>
|
<p className="text-[11px] font-bold uppercase text-[#7890b8]">{t("orders.stake")}</p>
|
||||||
<p className="mt-1 text-sm font-black text-slate-900">
|
<p className="mt-1 text-sm font-black text-slate-900">
|
||||||
{formatMinorAsCurrency(group.total_bet_amount, cur)}
|
{formatMinorAsCurrency(group.total_bet_amount, cur)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
||||||
<p className="text-[10px] font-bold uppercase text-[#7890b8]">{t("orders.deduction")}</p>
|
<p className="text-[11px] font-bold uppercase text-[#7890b8]">{t("orders.deduction")}</p>
|
||||||
<p className="mt-1 text-sm font-black text-[#0b3f96]">
|
<p className="mt-1 text-sm font-black text-[#0b3f96]">
|
||||||
{formatMinorAsCurrency(group.actual_deduct_amount, cur)}
|
{formatMinorAsCurrency(group.actual_deduct_amount, cur)}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ export function TicketOrdersListScreen() {
|
|||||||
return (
|
return (
|
||||||
<PlayerPanel title={t("orders.title")}>
|
<PlayerPanel title={t("orders.title")}>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="rounded-2xl border border-[#dfe9f8] bg-white p-3 shadow-[0_10px_28px_rgba(15,23,42,0.05)] sm:p-4">
|
<div className="rounded-2xl border border-[#dfe9f8] bg-white p-3 shadow-[0_10px_28px_rgba(15,23,42,0.05)]">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-[11px] font-black uppercase tracking-wide text-[#6f86ad]">
|
<p className="text-[11px] font-black uppercase tracking-wide text-[#6f86ad]">
|
||||||
@@ -241,7 +241,7 @@ export function TicketOrdersListScreen() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 grid grid-cols-2 gap-2 lg:grid-cols-4">
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
<div className="flex h-9 min-w-0 items-center rounded-full border border-[#dce7f7] bg-[#fbfdff] px-3">
|
<div className="flex h-9 min-w-0 items-center rounded-full border border-[#dce7f7] bg-[#fbfdff] px-3">
|
||||||
<Input
|
<Input
|
||||||
value={queryDrawNoInput}
|
value={queryDrawNoInput}
|
||||||
@@ -342,7 +342,7 @@ export function TicketOrdersListScreen() {
|
|||||||
<span className="flex min-w-0 items-center gap-1.5">
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
<span className="shrink-0">{t("orders.status")}</span>
|
<span className="shrink-0">{t("orders.status")}</span>
|
||||||
{queryStatuses.length ? (
|
{queryStatuses.length ? (
|
||||||
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-[#0b56b7] px-1 text-[10px] font-black text-white">
|
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-[#0b56b7] px-1 text-[11px] font-black text-white">
|
||||||
{queryStatuses.length}
|
{queryStatuses.length}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -468,13 +468,13 @@ export function TicketOrdersListScreen() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
||||||
<p className="text-[10px] font-bold uppercase text-[#7890b8]">{t("orders.stake")}</p>
|
<p className="text-[11px] font-bold uppercase text-[#7890b8]">{t("orders.stake")}</p>
|
||||||
<p className="mt-1 text-sm font-black text-slate-900">
|
<p className="mt-1 text-sm font-black text-slate-900">
|
||||||
{formatMinorAsCurrency(group.total_bet_amount, cur)}
|
{formatMinorAsCurrency(group.total_bet_amount, cur)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
||||||
<p className="text-[10px] font-bold uppercase text-[#7890b8]">{t("orders.deduction")}</p>
|
<p className="text-[11px] font-bold uppercase text-[#7890b8]">{t("orders.deduction")}</p>
|
||||||
<p className="mt-1 text-sm font-black text-[#0b3f96]">
|
<p className="mt-1 text-sm font-black text-[#0b3f96]">
|
||||||
{formatMinorAsCurrency(group.actual_deduct_amount, cur)}
|
{formatMinorAsCurrency(group.actual_deduct_amount, cur)}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ export function EntryGate() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-dvh flex-col bg-white">
|
<div className="relative flex min-h-dvh flex-col bg-white">
|
||||||
<div className={cn("relative h-[45vh] min-h-[200px] sm:min-h-[320px]", phase === "success" ? "bg-white" : "bg-red-600")}>
|
<div className={cn("relative h-[45vh] min-h-[200px]", phase === "success" ? "bg-white" : "bg-red-600")}>
|
||||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||||
<Image
|
<Image
|
||||||
src={phase === "success" ? "/entry/image4.png" : "/entry/image1.png"}
|
src={phase === "success" ? "/entry/image4.png" : "/entry/image1.png"}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export function NotificationsScreen() {
|
|||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-[10px] font-semibold",
|
"text-[11px] font-semibold",
|
||||||
cardRead ? "text-slate-400" : "text-amber-700",
|
cardRead ? "text-slate-400" : "text-amber-700",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ export function PlayerLoginScreen(): React.ReactElement {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-dvh flex-col bg-white">
|
<div className="relative flex min-h-dvh flex-col bg-white">
|
||||||
<div className="relative h-[45vh] min-h-[200px] shrink-0 overflow-hidden bg-red-600 sm:min-h-[320px]">
|
<div className="relative h-[45vh] min-h-[200px] shrink-0 overflow-hidden bg-red-600">
|
||||||
<div className="pointer-events-none absolute inset-0">
|
<div className="pointer-events-none absolute inset-0">
|
||||||
<Image src="/entry/image1.png" alt="" fill className="object-cover object-center" priority />
|
<Image src="/entry/image1.png" alt="" fill className="object-cover object-center" priority />
|
||||||
</div>
|
</div>
|
||||||
@@ -202,7 +202,7 @@ export function PlayerLoginScreen(): React.ReactElement {
|
|||||||
placeholder={t("login.captchaPlaceholder")}
|
placeholder={t("login.captchaPlaceholder")}
|
||||||
maxLength={32}
|
maxLength={32}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="min-w-0 flex-1 sm:max-w-[12rem]"
|
className="min-w-0 flex-1"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function PlayerSessionBar({ className }: { className?: string }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-w-0 max-w-[55%] items-center gap-1.5 sm:max-w-[60%]",
|
"flex min-w-0 max-w-[55%] items-center gap-1.5",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -35,7 +35,7 @@ export function PlayerSessionBar({ className }: { className?: string }) {
|
|||||||
{label ?? "…"}
|
{label ?? "…"}
|
||||||
</p>
|
</p>
|
||||||
{activeCurrency ? (
|
{activeCurrency ? (
|
||||||
<p className="truncate text-[10px] text-muted-foreground">
|
<p className="truncate text-[11px] text-muted-foreground">
|
||||||
{activeCurrency}
|
{activeCurrency}
|
||||||
{profile?.locale ? (
|
{profile?.locale ? (
|
||||||
<span className="text-muted-foreground/80">
|
<span className="text-muted-foreground/80">
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
|||||||
key={tier}
|
key={tier}
|
||||||
className="rounded-lg border border-[#edf2f8] bg-white py-2 shadow-[0_4px_12px_rgba(15,23,42,0.03)]"
|
className="rounded-lg border border-[#edf2f8] bg-white py-2 shadow-[0_4px_12px_rgba(15,23,42,0.03)]"
|
||||||
>
|
>
|
||||||
<p className="text-[10px] font-bold text-[#7890b8]">
|
<p className="text-[11px] font-bold text-[#7890b8]">
|
||||||
{t(resultsPrizeLabelKey(tier))}
|
{t(resultsPrizeLabelKey(tier))}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 font-mono text-lg font-black tabular-nums text-[#e5002c]">
|
<p className="mt-1 font-mono text-lg font-black tabular-nums text-[#e5002c]">
|
||||||
@@ -276,7 +276,7 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
|||||||
href="/results/check"
|
href="/results/check"
|
||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({ variant: "default", size: "sm" }),
|
buttonVariants({ variant: "default", size: "sm" }),
|
||||||
"mt-3 h-10 w-full rounded-xl bg-[#e5002c] text-white hover:bg-[#d10028] sm:w-auto",
|
"mt-3 h-10 w-full rounded-xl bg-[#e5002c] text-white hover:bg-[#d10028]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{t("results.viewMyWinning")}
|
{t("results.viewMyWinning")}
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ export function DrawResultsListScreen() {
|
|||||||
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
|
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
|
||||||
{RESULTS_TOP_PRIZE_KEYS.map((tier) => (
|
{RESULTS_TOP_PRIZE_KEYS.map((tier) => (
|
||||||
<div key={tier} className="rounded-lg border border-[#edf2f8] bg-[#f8fbff] py-2">
|
<div key={tier} className="rounded-lg border border-[#edf2f8] bg-[#f8fbff] py-2">
|
||||||
<p className="text-[10px] font-bold text-[#7890b8]">
|
<p className="text-[11px] font-bold text-[#7890b8]">
|
||||||
{t(resultsPrizeLabelKey(tier))}
|
{t(resultsPrizeLabelKey(tier))}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 font-mono text-lg font-black tabular-nums text-[#e5002c]">
|
<p className="mt-1 font-mono text-lg font-black tabular-nums text-[#e5002c]">
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function PlayRulesScreen() {
|
|||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Card className="border-[#dbe7fb] bg-white shadow-sm overflow-hidden min-h-[300px]">
|
<Card className="border-[#dbe7fb] bg-white shadow-sm overflow-hidden min-h-[300px]">
|
||||||
<CardContent className="p-4 sm:p-6">
|
<CardContent className="p-4">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-[200px] items-center justify-center">
|
<div className="flex h-[200px] items-center justify-center">
|
||||||
<Loader2 className="size-6 animate-spin text-slate-400" />
|
<Loader2 className="size-6 animate-spin text-slate-400" />
|
||||||
|
|||||||
120
src/hooks/use-pull-to-refresh.ts
Normal file
120
src/hooks/use-pull-to-refresh.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface PullToRefreshOptions {
|
||||||
|
onRefresh: () => void | Promise<void>;
|
||||||
|
threshold?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PullToRefreshResult {
|
||||||
|
pullDistance: number;
|
||||||
|
isRefreshing: boolean;
|
||||||
|
pullPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull-to-refresh hook for mobile H5 pages.
|
||||||
|
* Attaches touch listeners to the document body and triggers `onRefresh`
|
||||||
|
* when the user pulls down past `threshold` while scrolled to top.
|
||||||
|
*/
|
||||||
|
export function usePullToRefresh({
|
||||||
|
onRefresh,
|
||||||
|
threshold = 70,
|
||||||
|
enabled = true,
|
||||||
|
}: PullToRefreshOptions): PullToRefreshResult {
|
||||||
|
const [pullDistance, setPullDistance] = useState(0);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const startYRef = useRef(0);
|
||||||
|
const pullingRef = useRef(false);
|
||||||
|
const pullDistanceRef = useRef(0);
|
||||||
|
const onRefreshRef = useRef(onRefresh);
|
||||||
|
|
||||||
|
onRefreshRef.current = onRefresh;
|
||||||
|
|
||||||
|
const handleTouchStart = useCallback(
|
||||||
|
(e: TouchEvent) => {
|
||||||
|
if (!enabled || isRefreshing) return;
|
||||||
|
|
||||||
|
// Check if the scroll container is scrolled down
|
||||||
|
const scrollContainer = document.getElementById("player-scroll-container");
|
||||||
|
const currentScrollY = scrollContainer ? scrollContainer.scrollTop : window.scrollY;
|
||||||
|
|
||||||
|
if (currentScrollY > 0) return;
|
||||||
|
startYRef.current = e.touches[0].clientY;
|
||||||
|
pullingRef.current = true;
|
||||||
|
},
|
||||||
|
[enabled, isRefreshing],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTouchMove = useCallback(
|
||||||
|
(e: TouchEvent) => {
|
||||||
|
if (!pullingRef.current || isRefreshing) return;
|
||||||
|
const delta = e.touches[0].clientY - startYRef.current;
|
||||||
|
if (delta <= 0) {
|
||||||
|
// User is scrolling down — abort pull-to-refresh entirely
|
||||||
|
pullingRef.current = false;
|
||||||
|
if (pullDistanceRef.current !== 0) {
|
||||||
|
pullDistanceRef.current = 0;
|
||||||
|
setPullDistance(0);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Apply rubber-band resistance
|
||||||
|
const resisted = Math.min(delta * 0.4, threshold * 1.5);
|
||||||
|
if (resisted !== pullDistanceRef.current) {
|
||||||
|
pullDistanceRef.current = resisted;
|
||||||
|
setPullDistance(resisted);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isRefreshing, threshold],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTouchEnd = useCallback(async () => {
|
||||||
|
if (!pullingRef.current) {
|
||||||
|
if (pullDistanceRef.current !== 0) {
|
||||||
|
pullDistanceRef.current = 0;
|
||||||
|
setPullDistance(0);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pullingRef.current = false;
|
||||||
|
|
||||||
|
if (pullDistanceRef.current >= threshold) {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
pullDistanceRef.current = threshold * 0.5;
|
||||||
|
setPullDistance(threshold * 0.5);
|
||||||
|
try {
|
||||||
|
await onRefreshRef.current();
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
pullDistanceRef.current = 0;
|
||||||
|
setPullDistance(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pullDistanceRef.current = 0;
|
||||||
|
setPullDistance(0);
|
||||||
|
}
|
||||||
|
}, [threshold]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
document.addEventListener("touchstart", handleTouchStart, { passive: true });
|
||||||
|
document.addEventListener("touchmove", handleTouchMove, { passive: true });
|
||||||
|
document.addEventListener("touchend", handleTouchEnd, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("touchstart", handleTouchStart);
|
||||||
|
document.removeEventListener("touchmove", handleTouchMove);
|
||||||
|
document.removeEventListener("touchend", handleTouchEnd);
|
||||||
|
};
|
||||||
|
}, [enabled, handleTouchStart, handleTouchMove, handleTouchEnd]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pullDistance,
|
||||||
|
isRefreshing,
|
||||||
|
pullPercent: Math.min(pullDistance / threshold, 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user