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:
@@ -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}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user