Implemented guest-only PWA using vite-plugin-pwa (the actual published package; @vite-pwa/plugin isn’t on npm) with
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.
This commit is contained in:
149
resources/js/guest/components/PullToRefresh.tsx
Normal file
149
resources/js/guest/components/PullToRefresh.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
77
resources/js/guest/components/PwaManager.tsx
Normal file
77
resources/js/guest/components/PwaManager.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
import { useToast } from './ToastHost';
|
||||
|
||||
export default function PwaManager() {
|
||||
const toast = useToast();
|
||||
const { t } = useTranslation();
|
||||
const toastRef = React.useRef(toast);
|
||||
const tRef = React.useRef(t);
|
||||
const updatePromptedRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
toastRef.current = toast;
|
||||
}, [toast]);
|
||||
|
||||
React.useEffect(() => {
|
||||
tRef.current = t;
|
||||
}, [t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateSW = registerSW({
|
||||
immediate: true,
|
||||
onNeedRefresh() {
|
||||
if (updatePromptedRef.current) {
|
||||
return;
|
||||
}
|
||||
updatePromptedRef.current = true;
|
||||
toastRef.current.push({
|
||||
text: tRef.current('common.updateAvailable'),
|
||||
type: 'info',
|
||||
durationMs: 0,
|
||||
action: {
|
||||
label: tRef.current('common.updateAction'),
|
||||
onClick: () => updateSW(true),
|
||||
},
|
||||
});
|
||||
},
|
||||
onOfflineReady() {
|
||||
toastRef.current.push({
|
||||
text: tRef.current('common.offlineReady'),
|
||||
type: 'success',
|
||||
});
|
||||
},
|
||||
onRegisterError(error) {
|
||||
console.warn('Guest PWA registration failed', error);
|
||||
},
|
||||
});
|
||||
|
||||
const runQueue = () => {
|
||||
void import('../queue/queue')
|
||||
.then((m) => m.processQueue().catch(() => {}))
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.data?.type === 'sync-queue') {
|
||||
runQueue();
|
||||
}
|
||||
};
|
||||
|
||||
navigator.serviceWorker.addEventListener('message', handleMessage);
|
||||
window.addEventListener('online', runQueue);
|
||||
runQueue();
|
||||
|
||||
return () => {
|
||||
navigator.serviceWorker.removeEventListener('message', handleMessage);
|
||||
window.removeEventListener('online', runQueue);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
100
resources/js/guest/components/RouteTransition.tsx
Normal file
100
resources/js/guest/components/RouteTransition.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
|
||||
import { Outlet, useLocation, useNavigationType } from 'react-router-dom';
|
||||
|
||||
const TAB_SECTIONS = new Set(['home', 'tasks', 'achievements', 'gallery']);
|
||||
|
||||
export function getTabKey(pathname: string): string | null {
|
||||
const match = pathname.match(/^\/e\/[^/]+(?:\/([^/]+))?$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const section = match[1];
|
||||
if (!section) {
|
||||
return 'home';
|
||||
}
|
||||
|
||||
return TAB_SECTIONS.has(section) ? section : null;
|
||||
}
|
||||
|
||||
export function getTransitionKind(prevPath: string, nextPath: string): 'tab' | 'stack' {
|
||||
const prevTab = getTabKey(prevPath);
|
||||
const nextTab = getTabKey(nextPath);
|
||||
|
||||
if (prevTab && nextTab && prevTab !== nextTab) {
|
||||
return 'tab';
|
||||
}
|
||||
|
||||
return 'stack';
|
||||
}
|
||||
|
||||
export function isTransitionDisabled(pathname: string): boolean {
|
||||
if (pathname.startsWith('/share/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /^\/e\/[^/]+\/upload(?:\/|$)/.test(pathname);
|
||||
}
|
||||
|
||||
export default function RouteTransition({ children }: { children?: React.ReactNode }) {
|
||||
const location = useLocation();
|
||||
const navigationType = useNavigationType();
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const prevPathRef = React.useRef(location.pathname);
|
||||
const prevPath = prevPathRef.current;
|
||||
const direction = navigationType === 'POP' ? 'back' : 'forward';
|
||||
const kind = getTransitionKind(prevPath, location.pathname);
|
||||
const disableTransitions = prefersReducedMotion
|
||||
|| isTransitionDisabled(prevPath)
|
||||
|| isTransitionDisabled(location.pathname);
|
||||
|
||||
React.useEffect(() => {
|
||||
prevPathRef.current = location.pathname;
|
||||
}, [location.pathname]);
|
||||
|
||||
const content = children ?? <Outlet />;
|
||||
|
||||
if (disableTransitions) {
|
||||
return <>{content}</>;
|
||||
}
|
||||
|
||||
const stackVariants = {
|
||||
enter: ({ direction }: { direction: 'forward' | 'back' }) => ({
|
||||
x: direction === 'back' ? -28 : 28,
|
||||
opacity: 0,
|
||||
}),
|
||||
center: { x: 0, opacity: 1 },
|
||||
exit: ({ direction }: { direction: 'forward' | 'back' }) => ({
|
||||
x: direction === 'back' ? 28 : -28,
|
||||
opacity: 0,
|
||||
}),
|
||||
};
|
||||
|
||||
const tabVariants = {
|
||||
enter: { opacity: 0, scale: 0.985 },
|
||||
center: { opacity: 1, scale: 1 },
|
||||
exit: { opacity: 0, scale: 0.985 },
|
||||
};
|
||||
|
||||
const transition = kind === 'tab'
|
||||
? { duration: 0.18, ease: [0.22, 0.61, 0.36, 1] }
|
||||
: { duration: 0.24, ease: [0.25, 0.8, 0.25, 1] };
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
<motion.div
|
||||
key={location.pathname}
|
||||
custom={{ direction }}
|
||||
variants={kind === 'tab' ? tabVariants : stackVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={transition}
|
||||
style={{ willChange: 'transform, opacity' }}
|
||||
>
|
||||
{content}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,69 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
|
||||
type Toast = { id: number; text: string; type?: 'success'|'error' };
|
||||
type ToastAction = { label: string; onClick: () => void };
|
||||
type Toast = {
|
||||
id: number;
|
||||
text: string;
|
||||
type?: 'success' | 'error' | 'info';
|
||||
action?: ToastAction;
|
||||
durationMs?: number;
|
||||
};
|
||||
const Ctx = React.createContext<{ push: (t: Omit<Toast,'id'>) => void } | null>(null);
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [list, setList] = React.useState<Toast[]>([]);
|
||||
const push = React.useCallback((t: Omit<Toast,'id'>) => {
|
||||
const id = Date.now() + Math.random();
|
||||
setList((arr) => [...arr, { id, ...t }]);
|
||||
setTimeout(() => setList((arr) => arr.filter((x) => x.id !== id)), 3000);
|
||||
const durationMs = t.durationMs ?? 3000;
|
||||
setList((arr) => [...arr, { id, ...t, durationMs }]);
|
||||
if (durationMs > 0) {
|
||||
setTimeout(() => setList((arr) => arr.filter((x) => x.id !== id)), durationMs);
|
||||
}
|
||||
}, []);
|
||||
const dismiss = React.useCallback((id: number) => {
|
||||
setList((arr) => arr.filter((x) => x.id !== id));
|
||||
}, []);
|
||||
const contextValue = React.useMemo(() => ({ push }), [push]);
|
||||
React.useEffect(() => {
|
||||
const onEvt = (e: CustomEvent<Omit<Toast, 'id'>>) => push(e.detail);
|
||||
window.addEventListener('guest-toast', onEvt);
|
||||
return () => window.removeEventListener('guest-toast', onEvt);
|
||||
}, [push]);
|
||||
return (
|
||||
<Ctx.Provider value={{ push }}>
|
||||
<Ctx.Provider value={contextValue}>
|
||||
{children}
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex justify-center px-4">
|
||||
<div className="flex w-full max-w-sm flex-col gap-2">
|
||||
{list.map((t) => (
|
||||
<div key={t.id} className={`pointer-events-auto rounded-md border p-3 shadow-sm ${t.type==='error'?'border-red-300 bg-red-50 text-red-700':'border-green-300 bg-green-50 text-green-700'}`}>
|
||||
{t.text}
|
||||
<div
|
||||
key={t.id}
|
||||
className={`pointer-events-auto rounded-md border p-3 shadow-sm ${
|
||||
t.type === 'error'
|
||||
? 'border-red-300 bg-red-50 text-red-700'
|
||||
: t.type === 'info'
|
||||
? 'border-blue-300 bg-blue-50 text-blue-700'
|
||||
: 'border-green-300 bg-green-50 text-green-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<span className="text-sm">{t.text}</span>
|
||||
{t.action ? (
|
||||
<button
|
||||
type="button"
|
||||
className="pointer-events-auto rounded-full border border-current/30 px-3 py-1 text-xs font-semibold uppercase tracking-wide transition hover:border-current"
|
||||
onClick={() => {
|
||||
try {
|
||||
t.action?.onClick();
|
||||
} finally {
|
||||
dismiss(t.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t.action.label}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import PullToRefresh from '../PullToRefresh';
|
||||
|
||||
describe('PullToRefresh', () => {
|
||||
it('renders children and labels', () => {
|
||||
render(
|
||||
<PullToRefresh
|
||||
onRefresh={vi.fn()}
|
||||
pullLabel="Pull"
|
||||
releaseLabel="Release"
|
||||
refreshingLabel="Refreshing"
|
||||
>
|
||||
<div>Content</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Content')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pull')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getTabKey, getTransitionKind, isTransitionDisabled } from '../RouteTransition';
|
||||
|
||||
describe('RouteTransition helpers', () => {
|
||||
it('detects top-level tabs', () => {
|
||||
expect(getTabKey('/e/demo')).toBe('home');
|
||||
expect(getTabKey('/e/demo/tasks')).toBe('tasks');
|
||||
expect(getTabKey('/e/demo/achievements')).toBe('achievements');
|
||||
expect(getTabKey('/e/demo/gallery')).toBe('gallery');
|
||||
expect(getTabKey('/e/demo/tasks/123')).toBeNull();
|
||||
});
|
||||
|
||||
it('detects tab vs stack transitions', () => {
|
||||
expect(getTransitionKind('/e/demo', '/e/demo/gallery')).toBe('tab');
|
||||
expect(getTransitionKind('/e/demo/tasks', '/e/demo/tasks/1')).toBe('stack');
|
||||
});
|
||||
|
||||
it('disables transitions for excluded routes', () => {
|
||||
expect(isTransitionDisabled('/e/demo/upload')).toBe(true);
|
||||
expect(isTransitionDisabled('/share/demo-photo')).toBe(true);
|
||||
expect(isTransitionDisabled('/e/demo/gallery')).toBe(false);
|
||||
});
|
||||
});
|
||||
42
resources/js/guest/components/__tests__/ToastHost.test.tsx
Normal file
42
resources/js/guest/components/__tests__/ToastHost.test.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { vi } from 'vitest';
|
||||
import { ToastProvider, useToast } from '../ToastHost';
|
||||
|
||||
function ToastTestHarness({ onAction }: { onAction: () => void }) {
|
||||
const toast = useToast();
|
||||
|
||||
React.useEffect(() => {
|
||||
toast.push({
|
||||
text: 'Update ready',
|
||||
type: 'info',
|
||||
durationMs: 0,
|
||||
action: {
|
||||
label: 'Reload',
|
||||
onClick: onAction,
|
||||
},
|
||||
});
|
||||
}, [toast, onAction]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('ToastHost', () => {
|
||||
it('renders action toasts and dismisses after action click', async () => {
|
||||
const onAction = vi.fn();
|
||||
|
||||
render(
|
||||
<ToastProvider>
|
||||
<ToastTestHarness onAction={onAction} />
|
||||
</ToastProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Update ready')).toBeInTheDocument();
|
||||
const button = screen.getByRole('button', { name: 'Reload' });
|
||||
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(onAction).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByText('Update ready')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user