injectManifest, a new typed SW source, runtime caching, and a non‑blocking update toast with an action button. The
guest shell now links a dedicated manifest and theme color, and background upload sync is managed in a single
PwaManager component.
Key changes (where/why)
- vite.config.ts: added VitePWA injectManifest config, guest manifest, and output to /public so the SW can control /
scope.
- resources/js/guest/guest-sw.ts: new Workbox SW (precache + runtime caching for guest navigation, GET /api/v1/*,
images, fonts) and preserves push/sync/notification logic.
- resources/js/guest/components/PwaManager.tsx: registers SW, shows update/offline toasts, and processes the upload
queue on sync/online.
- resources/js/guest/components/ToastHost.tsx: action-capable toasts so update prompts can include a CTA.
- resources/js/guest/i18n/messages.ts: added common.updateAvailable, common.updateAction, common.offlineReady.
- resources/views/guest.blade.php: manifest + theme color + apple touch icon.
- .gitignore: ignore generated public/guest-sw.js and public/guest.webmanifest; public/guest-sw.js removed since it’s
now build output.
150 lines
4.4 KiB
TypeScript
150 lines
4.4 KiB
TypeScript
import React from 'react';
|
|
import { ArrowDown, Loader2 } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
const MAX_PULL = 96;
|
|
const TRIGGER_PULL = 72;
|
|
const DAMPING = 0.55;
|
|
|
|
type PullToRefreshProps = {
|
|
onRefresh: () => Promise<void> | void;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
pullLabel?: string;
|
|
releaseLabel?: string;
|
|
refreshingLabel?: string;
|
|
children: React.ReactNode;
|
|
};
|
|
|
|
export default function PullToRefresh({
|
|
onRefresh,
|
|
disabled = false,
|
|
className,
|
|
pullLabel = 'Pull to refresh',
|
|
releaseLabel = 'Release to refresh',
|
|
refreshingLabel = 'Refreshing…',
|
|
children,
|
|
}: PullToRefreshProps) {
|
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
|
const startYRef = React.useRef<number | null>(null);
|
|
const pullDistanceRef = React.useRef(0);
|
|
const [pullDistance, setPullDistance] = React.useState(0);
|
|
const [dragging, setDragging] = React.useState(false);
|
|
const [refreshing, setRefreshing] = React.useState(false);
|
|
|
|
const updatePull = React.useCallback((value: number) => {
|
|
pullDistanceRef.current = value;
|
|
setPullDistance(value);
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container || disabled) {
|
|
return;
|
|
}
|
|
|
|
const handleStart = (event: TouchEvent) => {
|
|
if (refreshing || window.scrollY > 0) {
|
|
return;
|
|
}
|
|
startYRef.current = event.touches[0]?.clientY ?? null;
|
|
setDragging(true);
|
|
};
|
|
|
|
const handleMove = (event: TouchEvent) => {
|
|
if (refreshing || startYRef.current === null) {
|
|
return;
|
|
}
|
|
if (window.scrollY > 0) {
|
|
startYRef.current = null;
|
|
updatePull(0);
|
|
setDragging(false);
|
|
return;
|
|
}
|
|
const currentY = event.touches[0]?.clientY ?? 0;
|
|
const delta = currentY - startYRef.current;
|
|
if (delta <= 0) {
|
|
updatePull(0);
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
const next = Math.min(MAX_PULL, delta * DAMPING);
|
|
updatePull(next);
|
|
};
|
|
|
|
const handleEnd = async () => {
|
|
if (startYRef.current === null) {
|
|
return;
|
|
}
|
|
startYRef.current = null;
|
|
setDragging(false);
|
|
|
|
if (pullDistanceRef.current >= TRIGGER_PULL) {
|
|
setRefreshing(true);
|
|
updatePull(TRIGGER_PULL);
|
|
try {
|
|
await onRefresh();
|
|
} finally {
|
|
setRefreshing(false);
|
|
updatePull(0);
|
|
}
|
|
return;
|
|
}
|
|
|
|
updatePull(0);
|
|
};
|
|
|
|
container.addEventListener('touchstart', handleStart, { passive: true });
|
|
container.addEventListener('touchmove', handleMove, { passive: false });
|
|
container.addEventListener('touchend', handleEnd);
|
|
container.addEventListener('touchcancel', handleEnd);
|
|
|
|
return () => {
|
|
container.removeEventListener('touchstart', handleStart);
|
|
container.removeEventListener('touchmove', handleMove);
|
|
container.removeEventListener('touchend', handleEnd);
|
|
container.removeEventListener('touchcancel', handleEnd);
|
|
};
|
|
}, [disabled, onRefresh, refreshing, updatePull]);
|
|
|
|
const progress = Math.min(pullDistance / TRIGGER_PULL, 1);
|
|
const ready = pullDistance >= TRIGGER_PULL;
|
|
const indicatorLabel = refreshing
|
|
? refreshingLabel
|
|
: ready
|
|
? releaseLabel
|
|
: pullLabel;
|
|
|
|
return (
|
|
<div ref={containerRef} className={cn('relative', className)}>
|
|
<div
|
|
className="pointer-events-none absolute left-0 right-0 top-2 flex h-10 items-center justify-center"
|
|
style={{
|
|
opacity: progress,
|
|
transform: `translateY(${Math.min(pullDistance, TRIGGER_PULL) - 48}px)`,
|
|
transition: dragging ? 'none' : 'transform 200ms ease-out, opacity 200ms ease-out',
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-2 rounded-full border border-white/30 bg-white/90 px-3 py-1 text-xs font-semibold text-slate-700 shadow-sm dark:border-white/10 dark:bg-slate-900/80 dark:text-slate-100">
|
|
{refreshing ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
|
) : (
|
|
<ArrowDown
|
|
className={cn('h-4 w-4 transition-transform duration-200', ready && 'rotate-180')}
|
|
aria-hidden
|
|
/>
|
|
)}
|
|
<span>{indicatorLabel}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className={cn('will-change-transform', !dragging && 'transition-transform duration-200 ease-out')}
|
|
style={{ transform: `translateY(${pullDistance}px)` }}
|
|
>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|