Added Phase‑1 continuation work across deep links, offline moderation queue, and admin push.

resources/js/admin/mobile/lib.
  - Admin push is end‑to‑end: new backend model/migration/service/job + API endpoints, admin runtime config, push‑aware
    service worker, and a settings toggle via useAdminPushSubscription. Notifications now auto‑refresh on push.
  - New PHP/JS tests: admin push API feature test and queue/haptics unit tests
  Added admin-specific PWA icon assets and wired them into the admin manifest, service worker, and admin shell, plus a
  new “Device & permissions” card in mobile Settings with a persistent storage action and translations.
  Details: public/manifest.json, public/admin-sw.js, resources/views/admin.blade.php, new icons in public/; new hook
  resources/js/admin/mobile/hooks/useDevicePermissions.ts, helpers/tests in resources/js/admin/mobile/lib/
  devicePermissions.ts + resources/js/admin/mobile/lib/devicePermissions.test.ts, and Settings UI updates in resources/
  js/admin/mobile/SettingsPage.tsx with copy in resources/js/admin/i18n/locales/en/management.json and resources/js/
admin/i18n/locales/de/management.json.
This commit is contained in:
Codex Agent
2025-12-28 15:00:47 +01:00
parent 4ce409e918
commit b780d82d62
42 changed files with 2258 additions and 121 deletions

View File

@@ -1,10 +1,11 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Bell, RefreshCcw } from 'lucide-react';
import { Bell, Check, ChevronRight, RefreshCcw } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { motion, useAnimationControls, type PanInfo } from 'framer-motion';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, PillBadge, SkeletonCard, CTAButton } from './components/Primitives';
import { MobileSelect } from './components/FormControls';
@@ -15,6 +16,8 @@ import toast from 'react-hot-toast';
import { MobileSheet } from './components/Sheet';
import { getEvents, TenantEvent } from '../api';
import { useTheme } from '@tamagui/core';
import { triggerHaptic } from './lib/haptics';
import { adminPath } from '../constants';
type NotificationItem = {
id: string;
@@ -27,6 +30,94 @@ type NotificationItem = {
scope: 'photos' | 'guests' | 'gallery' | 'events' | 'package' | 'general';
};
type NotificationSwipeRowProps = {
item: NotificationItem;
onOpen: (item: NotificationItem) => void;
onMarkRead: (item: NotificationItem) => void;
children: React.ReactNode;
};
function NotificationSwipeRow({ item, onOpen, onMarkRead, children }: NotificationSwipeRowProps) {
const { t } = useTranslation('management');
const theme = useTheme();
const controls = useAnimationControls();
const dragged = React.useRef(false);
const markBg = String(theme.green3?.val ?? '#dcfce7');
const markText = String(theme.green10?.val ?? '#166534');
const detailBg = String(theme.blue3?.val ?? '#dbeafe');
const detailText = String(theme.blue10?.val ?? '#1d4ed8');
const handleDrag = (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
dragged.current = Math.abs(info.offset.x) > 6;
};
const handleDragEnd = (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
const swipeThreshold = 64;
const offsetX = info.offset.x;
if (offsetX > swipeThreshold && !item.is_read) {
void onMarkRead(item);
} else if (offsetX < -swipeThreshold) {
onOpen(item);
}
dragged.current = false;
void controls.start({ x: 0, transition: { type: 'spring', stiffness: 320, damping: 26 } });
};
const handlePress = () => {
if (dragged.current) {
dragged.current = false;
return;
}
onOpen(item);
};
return (
<div style={{ position: 'relative' }}>
<XStack
alignItems="center"
justifyContent="space-between"
paddingHorizontal="$3"
borderRadius="$4"
pointerEvents="none"
style={{ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0 }}
>
<XStack alignItems="center" space="$2">
<Check size={16} color={markText} />
<Text fontSize="$xs" fontWeight="700" color={markText}>
{item.is_read ? t('notificationLogs.read', 'Read') : t('notificationLogs.markRead', 'Mark read')}
</Text>
</XStack>
<XStack alignItems="center" space="$2">
<Text fontSize="$xs" fontWeight="700" color={detailText}>
Details
</Text>
<ChevronRight size={16} color={detailText} />
</XStack>
</XStack>
<XStack
borderRadius="$4"
overflow="hidden"
pointerEvents="none"
backgroundColor={item.is_read ? detailBg : markBg}
opacity={0.5}
style={{ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0 }}
/>
<motion.div
drag="x"
dragElastic={0.2}
dragConstraints={{ left: -96, right: 96 }}
onDrag={handleDrag}
onDragEnd={handleDragEnd}
animate={controls}
initial={{ x: 0 }}
style={{ touchAction: 'pan-y', position: 'relative', zIndex: 1 }}
>
<Pressable onPress={handlePress}>{children}</Pressable>
</motion.div>
</div>
);
}
function formatLog(
log: NotificationLogEntry,
t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string,
@@ -207,6 +298,8 @@ async function loadNotifications(
export default function MobileNotificationsPage() {
const navigate = useNavigate();
const location = useLocation();
const { notificationId } = useParams<{ notificationId?: string }>();
const { t } = useTranslation('management');
const search = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '');
const slug = search.get('event') ?? undefined;
@@ -251,6 +344,20 @@ export default function MobileNotificationsPage() {
void reload();
}, [reload]);
React.useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.data?.type === 'admin-notification-refresh') {
void reload();
}
};
navigator.serviceWorker?.addEventListener('message', handleMessage);
return () => {
navigator.serviceWorker?.removeEventListener('message', handleMessage);
};
}, [reload]);
React.useEffect(() => {
(async () => {
try {
@@ -287,19 +394,58 @@ export default function MobileNotificationsPage() {
const showFilterNotice = Boolean(slug) && filtered.length === 0 && notifications.length > 0;
const markSelectedRead = async () => {
const markNotificationRead = React.useCallback(
async (item: NotificationItem, options?: { close?: boolean }) => {
const id = Number(item.id);
if (!Number.isFinite(id)) return;
try {
await markNotificationLogs([id], 'read');
await reload();
triggerHaptic('success');
if (options?.close) {
setDetailOpen(false);
setSelectedNotification(null);
}
} catch {
toast.error(t('notificationLogs.markFailed', 'Could not update notifications.'));
}
},
[reload, t],
);
const markSelectedRead = React.useCallback(async () => {
if (!selectedNotification) return;
const id = Number(selectedNotification.id);
if (!Number.isFinite(id)) return;
try {
await markNotificationLogs([id], 'read');
await reload();
setDetailOpen(false);
setSelectedNotification(null);
} catch {
toast.error(t('notificationLogs.markFailed', 'Could not update notifications.'));
await markNotificationRead(selectedNotification, { close: true });
}, [markNotificationRead, selectedNotification]);
const notificationListPath = adminPath('/mobile/notifications');
const openNotification = React.useCallback(
(item: NotificationItem) => {
setSelectedNotification(item);
setDetailOpen(true);
if (notificationId !== String(item.id)) {
navigate(`${notificationListPath}/${item.id}${location.search}`, { replace: false });
}
triggerHaptic('light');
},
[location.search, navigate, notificationId, notificationListPath],
);
React.useEffect(() => {
if (!notificationId || loading) {
return;
}
};
const targetId = Number(notificationId);
if (!Number.isFinite(targetId)) {
return;
}
const target = notifications.find((item) => Number(item.id) === targetId);
if (target) {
setSelectedNotification(target);
setDetailOpen(true);
}
}, [notificationId, notifications, loading]);
return (
<MobileShell
@@ -327,7 +473,7 @@ export default function MobileNotificationsPage() {
</Text>
<Pressable
onPress={() => {
navigate('/admin/mobile/notifications', { replace: true });
navigate(notificationListPath, { replace: true });
}}
>
<Text fontSize="$sm" color={primary} fontWeight="700">
@@ -340,7 +486,15 @@ export default function MobileNotificationsPage() {
<XStack space="$2" marginBottom="$2">
<MobileSelect
value={statusParam}
onChange={(e) => navigate(`/admin/mobile/notifications?${new URLSearchParams({ status: e.target.value, scope: scopeParam, event: slug ?? '' }).toString()}`)}
onChange={(e) =>
navigate(
`${notificationListPath}?${new URLSearchParams({
status: e.target.value,
scope: scopeParam,
event: slug ?? '',
}).toString()}`,
)
}
compact
style={{ minWidth: 120 }}
>
@@ -350,7 +504,15 @@ export default function MobileNotificationsPage() {
</MobileSelect>
<MobileSelect
value={scopeParam}
onChange={(e) => navigate(`/admin/mobile/notifications?${new URLSearchParams({ scope: e.target.value, status: statusParam, event: slug ?? '' }).toString()}`)}
onChange={(e) =>
navigate(
`${notificationListPath}?${new URLSearchParams({
scope: e.target.value,
status: statusParam,
event: slug ?? '',
}).toString()}`,
)
}
compact
style={{ minWidth: 140 }}
>
@@ -369,6 +531,7 @@ export default function MobileNotificationsPage() {
try {
await markNotificationLogs(unreadIds, 'read');
void reload();
triggerHaptic('success');
} catch {
toast.error(t('notificationLogs.markFailed', 'Could not update notifications.'));
}
@@ -401,12 +564,11 @@ export default function MobileNotificationsPage() {
</Pressable>
) : null}
{statusFiltered.map((item) => (
<Pressable
<NotificationSwipeRow
key={item.id}
onPress={() => {
setSelectedNotification(item);
setDetailOpen(true);
}}
item={item}
onOpen={openNotification}
onMarkRead={markNotificationRead}
>
<MobileCard space="$2" borderColor={item.is_read ? border : primary}>
<XStack alignItems="center" space="$2">
@@ -432,7 +594,7 @@ export default function MobileNotificationsPage() {
<PillBadge tone={item.tone === 'warning' ? 'warning' : 'muted'}>{item.time}</PillBadge>
</XStack>
</MobileCard>
</Pressable>
</NotificationSwipeRow>
))}
</YStack>
)}
@@ -442,6 +604,9 @@ export default function MobileNotificationsPage() {
onClose={() => {
setDetailOpen(false);
setSelectedNotification(null);
if (notificationId) {
navigate(`${notificationListPath}${location.search}`, { replace: true });
}
}}
title={selectedNotification?.title ?? t('mobileNotifications.title', 'Notifications')}
footer={
@@ -489,7 +654,7 @@ export default function MobileNotificationsPage() {
onPress={() => {
setShowEventPicker(false);
if (ev.slug) {
navigate(`/admin/mobile/notifications?event=${ev.slug}`);
navigate(`${notificationListPath}?event=${ev.slug}`);
}
}}
>