Completed the full mobile app polish pass: navigation feel, safe‑area consistency, input styling, list rows, FAB

patterns, skeleton loading, photo selection/bulk actions with shared‑element transitions, notification detail sheet,
  offline banner, maskable manifest icons, and route prefetching.

  Key changes

  - Navigation/shell: press feedback on all header actions, glassy sticky header and tab bar, safer bottom spacing
    (resources/js/admin/mobile/components/MobileShell.tsx, resources/js/admin/mobile/components/BottomNav.tsx).
  - Forms + lists: shared mobile form controls, list‑style rows in settings/profile, consistent inputs across core
    flows (resources/js/admin/mobile/components/FormControls.tsx, resources/js/admin/mobile/SettingsPage.tsx,
    resources/js/admin/mobile/ProfilePage.tsx, resources/js/admin/mobile/EventFormPage.tsx, resources/js/admin/mobile/
    EventMembersPage.tsx, resources/js/admin/mobile/EventTasksPage.tsx, resources/js/admin/mobile/
    EventGuestNotificationsPage.tsx, resources/js/admin/mobile/NotificationsPage.tsx, resources/js/admin/mobile/
    EventPhotosPage.tsx, resources/js/admin/mobile/EventsPage.tsx).
  - Media workflows: shared‑element photo transitions, selection mode + bulk actions bar (resources/js/admin/mobile/
    EventPhotosPage.tsx).
  - Loading UX: shimmering skeletons (resources/css/app.css, resources/js/admin/mobile/components/Primitives.tsx).
  - PWA polish + perf: maskable icons, offline banner hook, and route prefetch (public/manifest.json, resources/js/
    admin/mobile/hooks/useOnlineStatus.tsx, resources/js/admin/mobile/prefetch.ts, resources/js/admin/main.tsx).
This commit is contained in:
Codex Agent
2025-12-27 23:55:48 +01:00
parent a8b54b75ea
commit 4ce409e918
36 changed files with 1288 additions and 579 deletions

View File

@@ -7,8 +7,9 @@ import { Pressable } from '@tamagui/react-native-web-lite';
import { useTheme } from '@tamagui/core';
import { RefreshCcw, Users, User } from 'lucide-react';
import toast from 'react-hot-toast';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
import { useEventContext } from '../context/EventContext';
import {
GuestNotificationSummary,
@@ -58,21 +59,6 @@ export default function MobileEventGuestNotificationsPage() {
priority: '1',
});
const inputStyle = React.useMemo<React.CSSProperties>(() => {
const border = String(theme.borderColor?.val ?? '#e5e7eb');
const surface = String(theme.surface?.val ?? 'white');
const text = String(theme.color?.val ?? '#111827');
return {
width: '100%',
borderRadius: 10,
border: `1px solid ${border}`,
padding: '10px 12px',
fontSize: 13,
background: surface,
color: text,
};
}, [theme]);
React.useEffect(() => {
if (slugParam && activeEvent?.slug !== slugParam) {
selectEvent(slugParam);
@@ -201,9 +187,9 @@ export default function MobileEventGuestNotificationsPage() {
subtitle={t('guestMessages.subtitle', 'Send push messages to guests')}
onBack={() => navigate(-1)}
headerActions={
<Pressable onPress={() => loadHistory()}>
<HeaderActionButton onPress={() => loadHistory()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color={String(theme.color?.val ?? '#111827')} />
</Pressable>
</HeaderActionButton>
}
>
{error ? (
@@ -219,90 +205,82 @@ export default function MobileEventGuestNotificationsPage() {
{t('guestMessages.composeTitle', 'Send a message')}
</Text>
<YStack space="$2">
<Field label={t('guestMessages.form.title', 'Title')}>
<input
<MobileField label={t('guestMessages.form.title', 'Title')}>
<MobileInput
type="text"
value={form.title}
onChange={(e) => setForm((prev) => ({ ...prev, title: e.target.value }))}
placeholder={t('guestMessages.form.titlePlaceholder', 'Gallery reminder, upload nudge, ...')}
style={{ ...inputStyle, height: 40 }}
/>
</Field>
<Field label={t('guestMessages.form.message', 'Message')}>
<textarea
</MobileField>
<MobileField label={t('guestMessages.form.message', 'Message')}>
<MobileTextArea
value={form.message}
onChange={(e) => setForm((prev) => ({ ...prev, message: e.target.value }))}
placeholder={t('guestMessages.form.messagePlaceholder', 'Write a short note for your guests.')}
style={{ ...inputStyle, minHeight: 96, resize: 'vertical' }}
/>
</Field>
<Field label={t('guestMessages.form.audience', 'Audience')}>
<select
</MobileField>
<MobileField label={t('guestMessages.form.audience', 'Audience')}>
<MobileSelect
value={form.audience}
onChange={(e) => setForm((prev) => ({ ...prev, audience: e.target.value as FormState['audience'] }))}
style={{ ...inputStyle, height: 42 }}
>
<option value="all">{t('guestMessages.form.audienceAll', 'All guests')}</option>
<option value="guest">{t('guestMessages.form.audienceGuest', 'Specific guest (name or device)')}</option>
</select>
</Field>
</MobileSelect>
</MobileField>
{form.audience === 'guest' ? (
<Field label={t('guestMessages.form.guestIdentifier', 'Guest name or device ID')}>
<input
<MobileField label={t('guestMessages.form.guestIdentifier', 'Guest name or device ID')}>
<MobileInput
type="text"
value={form.guest_identifier}
onChange={(e) => setForm((prev) => ({ ...prev, guest_identifier: e.target.value }))}
placeholder={t('guestMessages.form.guestPlaceholder', 'e.g., Alex or device token')}
style={{ ...inputStyle, height: 40 }}
/>
</Field>
</MobileField>
) : null}
<Field label={t('guestMessages.form.cta', 'CTA (optional)')}>
<MobileField label={t('guestMessages.form.cta', 'CTA (optional)')}>
<YStack space="$1.5">
<input
<MobileInput
type="text"
value={form.cta_label}
onChange={(e) => setForm((prev) => ({ ...prev, cta_label: e.target.value }))}
placeholder={t('guestMessages.form.ctaLabel', 'Button label')}
style={{ ...inputStyle, height: 40 }}
/>
<input
<MobileInput
type="url"
value={form.cta_url}
onChange={(e) => setForm((prev) => ({ ...prev, cta_url: e.target.value }))}
placeholder={t('guestMessages.form.ctaUrl', 'https://your-link.com')}
style={{ ...inputStyle, height: 40 }}
/>
<Text fontSize="$xs" color={mutedText}>
{t('guestMessages.form.ctaHint', 'Both fields are required to add a button.')}
</Text>
</YStack>
</Field>
</MobileField>
<XStack space="$2">
<Field label={t('guestMessages.form.expiresIn', 'Expires in (minutes)')}>
<input
<MobileField label={t('guestMessages.form.expiresIn', 'Expires in (minutes)')}>
<MobileInput
type="number"
min={5}
max={2880}
value={form.expires_in_minutes}
onChange={(e) => setForm((prev) => ({ ...prev, expires_in_minutes: e.target.value }))}
placeholder="60"
style={{ ...inputStyle, height: 40 }}
/>
</Field>
<Field label={t('guestMessages.form.priority', 'Priority')}>
<select
</MobileField>
<MobileField label={t('guestMessages.form.priority', 'Priority')}>
<MobileSelect
value={form.priority}
onChange={(e) => setForm((prev) => ({ ...prev, priority: e.target.value }))}
style={{ ...inputStyle, height: 40 }}
>
{[0, 1, 2, 3, 4, 5].map((value) => (
<option key={value} value={value}>
{t('guestMessages.form.priorityValue', 'Priority {{value}}', { value })}
</option>
))}
</select>
</Field>
</MobileSelect>
</MobileField>
</XStack>
<CTAButton
label={sending ? t('common.processing', 'Processing…') : t('guestMessages.form.send', 'Send notification')}
@@ -331,7 +309,7 @@ export default function MobileEventGuestNotificationsPage() {
{loading ? (
<YStack space="$2">
{Array.from({ length: 3 }).map((_, idx) => (
<MobileCard key={`s-${idx}`} height={72} opacity={0.6} />
<SkeletonCard key={`s-${idx}`} height={72} />
))}
</YStack>
) : history.length === 0 ? (
@@ -397,14 +375,3 @@ export default function MobileEventGuestNotificationsPage() {
</MobileShell>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<YStack space="$1.5">
<Text fontSize="$sm" fontWeight="800" color="#111827">
{label}
</Text>
{children}
</YStack>
);
}