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

@@ -18,11 +18,23 @@
"type": "image/svg+xml", "type": "image/svg+xml",
"purpose": "any" "purpose": "any"
}, },
{
"src": "/favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "maskable"
},
{ {
"src": "/apple-touch-icon.png", "src": "/apple-touch-icon.png",
"sizes": "180x180", "sizes": "180x180",
"type": "image/png", "type": "image/png",
"purpose": "any" "purpose": "any"
},
{
"src": "/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png",
"purpose": "maskable"
} }
], ],
"shortcuts": [ "shortcuts": [

View File

@@ -511,6 +511,27 @@ h4,
--sidebar-ring: oklch(0.439 0 0); --sidebar-ring: oklch(0.439 0 0);
} }
@keyframes mobile-shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.mobile-skeleton {
border-color: transparent !important;
background: linear-gradient(
90deg,
rgba(148, 163, 184, 0.12),
rgba(148, 163, 184, 0.28),
rgba(148, 163, 184, 0.12)
);
background-size: 200% 100%;
animation: mobile-shimmer 1.4s ease-in-out infinite;
}
@layer base { @layer base {
* { * {
@apply border-border; @apply border-border;

View File

@@ -17,6 +17,7 @@ import MatomoTracker from '@/components/analytics/MatomoTracker';
import { ConsentProvider } from '@/contexts/consent'; import { ConsentProvider } from '@/contexts/consent';
import CookieBanner from '@/components/consent/CookieBanner'; import CookieBanner from '@/components/consent/CookieBanner';
import { Sentry, initSentry } from '@/lib/sentry'; import { Sentry, initSentry } from '@/lib/sentry';
import { prefetchMobileRoutes } from './mobile/prefetch';
const DevTenantSwitcher = React.lazy(() => import('./DevTenantSwitcher')); const DevTenantSwitcher = React.lazy(() => import('./DevTenantSwitcher'));
@@ -65,6 +66,10 @@ function AdminApp() {
const { resolved } = useAppearance(); const { resolved } = useAppearance();
const themeName = resolved ?? 'light'; const themeName = resolved ?? 'light';
React.useEffect(() => {
prefetchMobileRoutes();
}, []);
return ( return (
<TamaguiProvider config={tamaguiConfig} defaultTheme={themeName} themeClassNameOnRoot> <TamaguiProvider config={tamaguiConfig} defaultTheme={themeName} themeClassNameOnRoot>
<Theme name={themeName}> <Theme name={themeName}>

View File

@@ -10,6 +10,10 @@ export default function AuthCallbackPage(): React.ReactElement {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation('auth'); const { t } = useTranslation('auth');
const [redirected, setRedirected] = React.useState(false); const [redirected, setRedirected] = React.useState(false);
const safeAreaStyle: React.CSSProperties = {
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
};
const searchParams = React.useMemo(() => new URLSearchParams(window.location.search), []); const searchParams = React.useMemo(() => new URLSearchParams(window.location.search), []);
const rawReturnTo = searchParams.get('return_to'); const rawReturnTo = searchParams.get('return_to');
@@ -36,7 +40,10 @@ export default function AuthCallbackPage(): React.ReactElement {
}, [destination, navigate, redirected, status]); }, [destination, navigate, redirected, status]);
return ( return (
<div className="flex min-h-screen flex-col items-center justify-center gap-3 p-6 text-center text-sm text-muted-foreground"> <div
className="flex min-h-screen flex-col items-center justify-center gap-3 p-6 text-center text-sm text-muted-foreground"
style={safeAreaStyle}
>
<span className="text-base font-medium text-foreground">{t('processing.title', 'Signing you in …')}</span> <span className="text-base font-medium text-foreground">{t('processing.title', 'Signing you in …')}</span>
<p className="max-w-xs text-xs text-muted-foreground">{t('processing.copy', 'One moment please while we prepare your dashboard.')}</p> <p className="max-w-xs text-xs text-muted-foreground">{t('processing.copy', 'One moment please while we prepare your dashboard.')}</p>
</div> </div>

View File

@@ -6,7 +6,7 @@ import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { MobileShell } from './components/MobileShell'; import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives'; import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import { import {
createTenantBillingPortalSession, createTenantBillingPortalSession,
@@ -104,9 +104,9 @@ export default function MobileBillingPage() {
title={t('billing.title', 'Billing & Packages')} title={t('billing.title', 'Billing & Packages')}
onBack={() => navigate(-1)} onBack={() => navigate(-1)}
headerActions={ headerActions={
<Pressable onPress={() => load()}> <HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color="#0f172a" /> <RefreshCcw size={18} color="#0f172a" />
</Pressable> </HeaderActionButton>
} }
> >
{error ? ( {error ? (

View File

@@ -5,8 +5,8 @@ import { Image as ImageIcon, RefreshCcw, UploadCloud, Trash2, ChevronDown, Save,
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell } from './components/MobileShell'; import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives'; import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
import { TenantEvent, getEvent, updateEvent, getTenantFonts, TenantFont, WatermarkSettings } from '../api'; import { TenantEvent, getEvent, updateEvent, getTenantFonts, TenantFont, WatermarkSettings } from '../api';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { ApiError, getApiErrorMessage } from '../lib/apiError'; import { ApiError, getApiErrorMessage } from '../lib/apiError';
@@ -403,9 +403,9 @@ export default function MobileBrandingPage() {
title={t('events.branding.titleShort', 'Branding')} title={t('events.branding.titleShort', 'Branding')}
onBack={() => navigate(-1)} onBack={() => navigate(-1)}
headerActions={ headerActions={
<Pressable disabled={saving} onPress={() => handleSave()}> <HeaderActionButton onPress={() => handleSave()} ariaLabel={t('common.save', 'Save')}>
<Save size={18} color="#007AFF" /> <Save size={18} color="#007AFF" />
</Pressable> </HeaderActionButton>
} }
> >
{error ? ( {error ? (
@@ -618,7 +618,7 @@ export default function MobileBrandingPage() {
> >
<YStack space="$2"> <YStack space="$2">
{fontsLoading ? ( {fontsLoading ? (
Array.from({ length: 4 }).map((_, idx) => <MobileCard key={`font-sk-${idx}`} height={48} opacity={0.6} />) Array.from({ length: 4 }).map((_, idx) => <SkeletonCard key={`font-sk-${idx}`} height={48} />)
) : fonts.length === 0 ? ( ) : fonts.length === 0 ? (
<Text fontSize="$sm" color="#4b5563"> <Text fontSize="$sm" color="#4b5563">
{t('events.branding.noFonts', 'Keine Schriftarten gefunden.')} {t('events.branding.noFonts', 'Keine Schriftarten gefunden.')}

View File

@@ -7,7 +7,7 @@ import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell, renderEventLocation } from './components/MobileShell'; import { MobileShell, renderEventLocation } from './components/MobileShell';
import { MobileCard, CTAButton, KpiTile, ActionTile, PillBadge } from './components/Primitives'; import { MobileCard, CTAButton, KpiTile, ActionTile, PillBadge, SkeletonCard } from './components/Primitives';
import { adminPath } from '../constants'; import { adminPath } from '../constants';
import { useEventContext } from '../context/EventContext'; import { useEventContext } from '../context/EventContext';
import { getEventStats, EventStats, TenantEvent, getEvents } from '../api'; import { getEventStats, EventStats, TenantEvent, getEvents } from '../api';
@@ -73,13 +73,13 @@ export default function MobileDashboardPage() {
if (isLoading || fallbackLoading) { if (isLoading || fallbackLoading) {
return ( return (
<MobileShell activeTab="home" title={t('mobileDashboard.title', 'Dashboard')}> <MobileShell activeTab="home" title={t('mobileDashboard.title', 'Dashboard')}>
<YStack space="$2"> <YStack space="$2">
{Array.from({ length: 3 }).map((_, idx) => ( {Array.from({ length: 3 }).map((_, idx) => (
<MobileCard key={`sk-${idx}`} height={110} opacity={0.6} /> <SkeletonCard key={`sk-${idx}`} height={110} />
))} ))}
</YStack> </YStack>
</MobileShell> </MobileShell>
); );
} }
if (!effectiveHasEvents) { if (!effectiveHasEvents) {

View File

@@ -5,7 +5,7 @@ import { CalendarDays, MapPin, Settings, Users, Camera, Sparkles, QrCode, Image,
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell } from './components/MobileShell'; import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, PillBadge, KpiTile, ActionTile } from './components/Primitives'; import { MobileCard, PillBadge, KpiTile, ActionTile } from './components/Primitives';
import { TenantEvent, EventStats, EventToolkit, getEvent, getEventStats, getEventToolkit, getEvents } from '../api'; import { TenantEvent, EventStats, EventToolkit, getEvent, getEventStats, getEventToolkit, getEvents } from '../api';
import { adminPath, ADMIN_EVENT_BRANDING_PATH, ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH, ADMIN_EVENT_INVITES_PATH, ADMIN_EVENT_MEMBERS_PATH, ADMIN_EVENT_PHOTOS_PATH, ADMIN_EVENT_TASKS_PATH } from '../constants'; import { adminPath, ADMIN_EVENT_BRANDING_PATH, ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH, ADMIN_EVENT_INVITES_PATH, ADMIN_EVENT_MEMBERS_PATH, ADMIN_EVENT_PHOTOS_PATH, ADMIN_EVENT_TASKS_PATH } from '../constants';
@@ -102,12 +102,12 @@ export default function MobileEventDetailPage() {
onBack={() => navigate(-1)} onBack={() => navigate(-1)}
headerActions={ headerActions={
<XStack space="$3" alignItems="center"> <XStack space="$3" alignItems="center">
<Pressable onPress={() => navigate(adminPath('/mobile/settings'))}> <HeaderActionButton onPress={() => navigate(adminPath('/mobile/settings'))} ariaLabel={t('mobileSettings.title', 'Settings')}>
<Settings size={18} color="#0f172a" /> <Settings size={18} color="#0f172a" />
</Pressable> </HeaderActionButton>
<Pressable onPress={() => navigate(0)}> <HeaderActionButton onPress={() => navigate(0)} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color="#0f172a" /> <RefreshCcw size={18} color="#0f172a" />
</Pressable> </HeaderActionButton>
</XStack> </XStack>
} }
> >

View File

@@ -7,6 +7,7 @@ import { SizableText as Text } from '@tamagui/text';
import { Switch } from '@tamagui/switch'; import { Switch } from '@tamagui/switch';
import { MobileShell } from './components/MobileShell'; import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives'; import { MobileCard, CTAButton } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
import { LegalConsentSheet } from './components/LegalConsentSheet'; import { LegalConsentSheet } from './components/LegalConsentSheet';
import { createEvent, getEvent, updateEvent, getEventTypes, TenantEvent, TenantEventType } from '../api'; import { createEvent, getEvent, updateEvent, getEventTypes, TenantEvent, TenantEventType } from '../api';
import { resolveEventSlugAfterUpdate } from './eventFormNavigation'; import { resolveEventSlugAfterUpdate } from './eventFormNavigation';
@@ -206,38 +207,36 @@ export default function MobileEventFormPage() {
) : null} ) : null}
<MobileCard space="$3"> <MobileCard space="$3">
<Field label={t('eventForm.fields.name.label', 'Event name')}> <MobileField label={t('eventForm.fields.name.label', 'Event name')}>
<input <MobileInput
type="text" type="text"
value={form.name} value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder={t('eventForm.fields.name.placeholder', 'e.g. Summer Party 2025')} placeholder={t('eventForm.fields.name.placeholder', 'e.g. Summer Party 2025')}
style={inputStyle}
/> />
</Field> </MobileField>
<Field label={t('eventForm.fields.date.label', 'Date & time')}> <MobileField label={t('eventForm.fields.date.label', 'Date & time')}>
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
<input <MobileInput
type="datetime-local" type="datetime-local"
value={form.date} value={form.date}
onChange={(e) => setForm((prev) => ({ ...prev, date: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, date: e.target.value }))}
style={{ ...inputStyle, flex: 1 }} style={{ flex: 1 }}
/> />
<CalendarDays size={16} color="#9ca3af" /> <CalendarDays size={16} color="#9ca3af" />
</XStack> </XStack>
</Field> </MobileField>
<Field label={t('eventForm.fields.type.label', 'Event type')}> <MobileField label={t('eventForm.fields.type.label', 'Event type')}>
{typesLoading ? ( {typesLoading ? (
<Text fontSize="$sm" color="#6b7280">{t('eventForm.fields.type.loading', 'Loading event types…')}</Text> <Text fontSize="$sm" color="#6b7280">{t('eventForm.fields.type.loading', 'Loading event types…')}</Text>
) : eventTypes.length === 0 ? ( ) : eventTypes.length === 0 ? (
<Text fontSize="$sm" color="#6b7280">{t('eventForm.fields.type.empty', 'No event types available yet. Please add one in the admin area.')}</Text> <Text fontSize="$sm" color="#6b7280">{t('eventForm.fields.type.empty', 'No event types available yet. Please add one in the admin area.')}</Text>
) : ( ) : (
<select <MobileSelect
value={form.eventTypeId ?? ''} value={form.eventTypeId ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, eventTypeId: Number(e.target.value) }))} onChange={(e) => setForm((prev) => ({ ...prev, eventTypeId: Number(e.target.value) }))}
style={{ ...inputStyle, height: 44 }}
> >
<option value="">{t('eventForm.fields.type.placeholder', 'Select event type')}</option> <option value="">{t('eventForm.fields.type.placeholder', 'Select event type')}</option>
{eventTypes.map((type) => ( {eventTypes.map((type) => (
@@ -245,33 +244,32 @@ export default function MobileEventFormPage() {
{renderName(type.name as any) || type.slug} {renderName(type.name as any) || type.slug}
</option> </option>
))} ))}
</select> </MobileSelect>
)} )}
</Field> </MobileField>
<Field label={t('eventForm.fields.description.label', 'Optional details')}> <MobileField label={t('eventForm.fields.description.label', 'Optional details')}>
<textarea <MobileTextArea
value={form.description} value={form.description}
onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
placeholder={t('eventForm.fields.description.placeholder', 'Description')} placeholder={t('eventForm.fields.description.placeholder', 'Description')}
style={{ ...inputStyle, minHeight: 96 }}
/> />
</Field> </MobileField>
<Field label={t('eventForm.fields.location.label', 'Location')}> <MobileField label={t('eventForm.fields.location.label', 'Location')}>
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
<input <MobileInput
type="text" type="text"
value={form.location} value={form.location}
onChange={(e) => setForm((prev) => ({ ...prev, location: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, location: e.target.value }))}
placeholder={t('eventForm.fields.location.placeholder', 'Location')} placeholder={t('eventForm.fields.location.placeholder', 'Location')}
style={{ ...inputStyle, flex: 1 }} style={{ flex: 1 }}
/> />
<MapPin size={16} color="#9ca3af" /> <MapPin size={16} color="#9ca3af" />
</XStack> </XStack>
</Field> </MobileField>
<Field label={t('eventForm.fields.publish.label', 'Publish immediately')}> <MobileField label={t('eventForm.fields.publish.label', 'Publish immediately')}>
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
<Switch <Switch
checked={form.published} checked={form.published}
@@ -288,9 +286,9 @@ export default function MobileEventFormPage() {
</Text> </Text>
</XStack> </XStack>
<Text fontSize="$xs" color="#6b7280">{t('eventForm.fields.publish.help', 'Enable if guests should see the event right away. You can change the status later.')}</Text> <Text fontSize="$xs" color="#6b7280">{t('eventForm.fields.publish.help', 'Enable if guests should see the event right away. You can change the status later.')}</Text>
</Field> </MobileField>
<Field label={t('eventForm.fields.tasksMode.label', 'Tasks & challenges')}> <MobileField label={t('eventForm.fields.tasksMode.label', 'Tasks & challenges')}>
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
<Switch <Switch
checked={form.tasksEnabled} checked={form.tasksEnabled}
@@ -319,9 +317,9 @@ export default function MobileEventFormPage() {
'Task mode is off: guests only see the photo feed.', 'Task mode is off: guests only see the photo feed.',
)} )}
</Text> </Text>
</Field> </MobileField>
<Field label={t('eventForm.fields.uploadVisibility.label', 'Uploads visible immediately')}> <MobileField label={t('eventForm.fields.uploadVisibility.label', 'Uploads visible immediately')}>
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
<Switch <Switch
checked={form.autoApproveUploads} checked={form.autoApproveUploads}
@@ -350,7 +348,7 @@ export default function MobileEventFormPage() {
'Uploads werden zunächst geprüft und erscheinen nach Freigabe.', 'Uploads werden zunächst geprüft und erscheinen nach Freigabe.',
)} )}
</Text> </Text>
</Field> </MobileField>
</MobileCard> </MobileCard>
<YStack space="$2"> <YStack space="$2">
@@ -416,27 +414,6 @@ export default function MobileEventFormPage() {
); );
} }
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>
);
}
const inputStyle: React.CSSProperties = {
width: '100%',
height: 44,
borderRadius: 10,
border: '1px solid #e5e7eb',
padding: '0 12px',
fontSize: 14,
background: 'white',
};
function renderName(name: TenantEvent['name']): string { function renderName(name: TenantEvent['name']): string {
if (typeof name === 'string') return name; if (typeof name === 'string') return name;
if (name && typeof name === 'object') { if (name && typeof name === 'object') {

View File

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

View File

@@ -5,8 +5,9 @@ import { UserPlus, Trash2, Copy, RefreshCcw } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell } from './components/MobileShell'; import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives'; import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect } from './components/FormControls';
import { EventMember, getEventMembers, inviteEventMember, removeEventMember } from '../api'; import { EventMember, getEventMembers, inviteEventMember, removeEventMember } from '../api';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError'; import { getApiErrorMessage } from '../lib/apiError';
@@ -97,9 +98,9 @@ export default function MobileEventMembersPage() {
title={t('events.members.title', 'Guest Management')} title={t('events.members.title', 'Guest Management')}
onBack={() => navigate(-1)} onBack={() => navigate(-1)}
headerActions={ headerActions={
<Pressable onPress={() => load()}> <HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color="#0f172a" /> <RefreshCcw size={18} color="#0f172a" />
</Pressable> </HeaderActionButton>
} }
> >
{error ? ( {error ? (
@@ -115,34 +116,31 @@ export default function MobileEventMembersPage() {
{t('events.members.inviteTitle', 'Invite Member')} {t('events.members.inviteTitle', 'Invite Member')}
</Text> </Text>
<YStack space="$2"> <YStack space="$2">
<Field label={t('events.members.name', 'Name')}> <MobileField label={t('events.members.name', 'Name')}>
<input <MobileInput
type="text" type="text"
value={invite.name} value={invite.name}
onChange={(e) => setInvite((prev) => ({ ...prev, name: e.target.value }))} onChange={(e) => setInvite((prev) => ({ ...prev, name: e.target.value }))}
placeholder="Alex Example" placeholder="Alex Example"
style={inputStyle}
/> />
</Field> </MobileField>
<Field label={t('events.members.email', 'Email')}> <MobileField label={t('events.members.email', 'Email')}>
<input <MobileInput
type="email" type="email"
value={invite.email} value={invite.email}
onChange={(e) => setInvite((prev) => ({ ...prev, email: e.target.value }))} onChange={(e) => setInvite((prev) => ({ ...prev, email: e.target.value }))}
placeholder="alex@example.com" placeholder="alex@example.com"
style={inputStyle}
/> />
</Field> </MobileField>
<Field label={t('events.members.role', 'Role')}> <MobileField label={t('events.members.role', 'Role')}>
<select <MobileSelect
value={invite.role} value={invite.role}
onChange={(e) => setInvite((prev) => ({ ...prev, role: e.target.value as EventMember['role'] }))} onChange={(e) => setInvite((prev) => ({ ...prev, role: e.target.value as EventMember['role'] }))}
style={{ ...inputStyle, height: 44 }}
> >
<option value="member">{t('events.members.roleMember', 'Member')}</option> <option value="member">{t('events.members.roleMember', 'Member')}</option>
<option value="tenant_admin">{t('events.members.roleAdmin', 'Admin')}</option> <option value="tenant_admin">{t('events.members.roleAdmin', 'Admin')}</option>
</select> </MobileSelect>
</Field> </MobileField>
<CTAButton label={saving ? t('common.saving', 'Saving...') : t('events.members.invite', 'Send Invite')} onPress={() => handleInvite()} /> <CTAButton label={saving ? t('common.saving', 'Saving...') : t('events.members.invite', 'Send Invite')} onPress={() => handleInvite()} />
{saving ? ( {saving ? (
<Text fontSize="$xs" color="#4b5563"> <Text fontSize="$xs" color="#4b5563">
@@ -152,20 +150,12 @@ export default function MobileEventMembersPage() {
</YStack> </YStack>
</MobileCard> </MobileCard>
<input <MobileInput
type="search" type="search"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder={t('events.members.search', 'Search members')} placeholder={t('events.members.search', 'Search members')}
style={{ compact
width: '100%',
height: 38,
borderRadius: 10,
border: '1px solid #e5e7eb',
padding: '0 12px',
fontSize: 13,
background: 'white',
}}
/> />
<MobileCard space="$3"> <MobileCard space="$3">
@@ -194,7 +184,7 @@ export default function MobileEventMembersPage() {
{loading ? ( {loading ? (
<YStack space="$2"> <YStack space="$2">
{Array.from({ length: 4 }).map((_, idx) => ( {Array.from({ length: 4 }).map((_, idx) => (
<MobileCard key={`m-${idx}`} height={70} opacity={0.6} /> <SkeletonCard key={`m-${idx}`} height={70} />
))} ))}
</YStack> </YStack>
) : members.length === 0 ? ( ) : members.length === 0 ? (
@@ -286,24 +276,3 @@ export default function MobileEventMembersPage() {
</MobileShell> </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>
);
}
const inputStyle: React.CSSProperties = {
width: '100%',
height: 42,
borderRadius: 10,
border: '1px solid #e5e7eb',
padding: '0 12px',
fontSize: 14,
background: 'white',
};

View File

@@ -7,8 +7,8 @@ import { SizableText as Text } from '@tamagui/text';
import { Switch } from '@tamagui/switch'; import { Switch } from '@tamagui/switch';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import { useTheme } from '@tamagui/core'; import { useTheme } from '@tamagui/core';
import { MobileShell } from './components/MobileShell'; import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives'; import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
import { import {
getEvent, getEvent,
getEventPhotoboothStatus, getEventPhotoboothStatus,
@@ -167,9 +167,9 @@ export default function MobileEventPhotoboothPage() {
subtitle={subtitle ?? undefined} subtitle={subtitle ?? undefined}
onBack={() => navigate(-1)} onBack={() => navigate(-1)}
headerActions={ headerActions={
<Pressable onPress={() => load()}> <HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color={text} /> <RefreshCcw size={18} color={text} />
</Pressable> </HeaderActionButton>
} }
> >
{error ? ( {error ? (
@@ -183,7 +183,7 @@ export default function MobileEventPhotoboothPage() {
{loading ? ( {loading ? (
<YStack space="$2"> <YStack space="$2">
{Array.from({ length: 3 }).map((_, idx) => ( {Array.from({ length: 3 }).map((_, idx) => (
<MobileCard key={`ph-skel-${idx}`} height={110} opacity={0.6} /> <SkeletonCard key={`ph-skel-${idx}`} height={110} />
))} ))}
</YStack> </YStack>
) : ( ) : (

View File

@@ -1,12 +1,14 @@
import React from 'react'; import React from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Image as ImageIcon, RefreshCcw, Search, Filter } from 'lucide-react'; import { Image as ImageIcon, RefreshCcw, Filter, Check } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell } from './components/MobileShell'; import { AnimatePresence, motion } from 'framer-motion';
import { MobileCard, PillBadge, CTAButton } from './components/Primitives'; import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, PillBadge, CTAButton, SkeletonCard } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect } from './components/FormControls';
import { import {
getEventPhotos, getEventPhotos,
updatePhotoVisibility, updatePhotoVisibility,
@@ -56,6 +58,9 @@ export default function MobileEventPhotosPage() {
const [onlyFeatured, setOnlyFeatured] = React.useState(false); const [onlyFeatured, setOnlyFeatured] = React.useState(false);
const [onlyHidden, setOnlyHidden] = React.useState(false); const [onlyHidden, setOnlyHidden] = React.useState(false);
const [lightbox, setLightbox] = React.useState<TenantPhoto | null>(null); const [lightbox, setLightbox] = React.useState<TenantPhoto | null>(null);
const [selectionMode, setSelectionMode] = React.useState(false);
const [selectedIds, setSelectedIds] = React.useState<number[]>([]);
const [bulkBusy, setBulkBusy] = React.useState(false);
const [limits, setLimits] = React.useState<EventLimitSummary | null>(null); const [limits, setLimits] = React.useState<EventLimitSummary | null>(null);
const [catalogAddons, setCatalogAddons] = React.useState<EventAddonCatalogItem[]>([]); const [catalogAddons, setCatalogAddons] = React.useState<EventAddonCatalogItem[]>([]);
const [eventAddons, setEventAddons] = React.useState<EventAddonSummary[]>([]); const [eventAddons, setEventAddons] = React.useState<EventAddonSummary[]>([]);
@@ -73,19 +78,13 @@ export default function MobileEventPhotosPage() {
const surface = String(theme.surface?.val ?? '#ffffff'); const surface = String(theme.surface?.val ?? '#ffffff');
const backdrop = String(theme.gray12?.val ?? '#0f172a'); const backdrop = String(theme.gray12?.val ?? '#0f172a');
const baseInputStyle = React.useMemo<React.CSSProperties>( React.useEffect(() => {
() => ({ if (lightbox) {
width: '100%', setSelectionMode(false);
height: 38, setSelectedIds([]);
borderRadius: 10, }
border: `1px solid ${border}`, }, [lightbox]);
padding: '0 12px',
fontSize: 13,
background: surface,
color: text,
}),
[border, surface, text],
);
React.useEffect(() => { React.useEffect(() => {
if (slugParam && activeEvent?.slug !== slugParam) { if (slugParam && activeEvent?.slug !== slugParam) {
selectEvent(slugParam); selectEvent(slugParam);
@@ -213,6 +212,75 @@ export default function MobileEventPhotosPage() {
} }
} }
const selectedPhotos = React.useMemo(
() => photos.filter((photo) => selectedIds.includes(photo.id)),
[photos, selectedIds],
);
const hasPendingSelection = selectedPhotos.some((photo) => photo.status === 'pending');
const hasHiddenSelection = selectedPhotos.some((photo) => photo.status === 'hidden');
const hasVisibleSelection = selectedPhotos.some((photo) => photo.status !== 'hidden');
const hasFeaturedSelection = selectedPhotos.some((photo) => photo.is_featured);
const hasUnfeaturedSelection = selectedPhotos.some((photo) => !photo.is_featured);
function toggleSelection(id: number) {
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]));
}
function clearSelection() {
setSelectedIds([]);
setSelectionMode(false);
}
async function applyBulkAction(action: 'approve' | 'hide' | 'show' | 'feature' | 'unfeature') {
if (!slug || bulkBusy || selectedPhotos.length === 0) return;
setBulkBusy(true);
const targets = selectedPhotos.filter((photo) => {
if (action === 'approve') return photo.status === 'pending';
if (action === 'hide') return photo.status !== 'hidden';
if (action === 'show') return photo.status === 'hidden';
if (action === 'feature') return !photo.is_featured;
if (action === 'unfeature') return photo.is_featured;
return false;
});
if (targets.length === 0) {
setBulkBusy(false);
return;
}
try {
const results = await Promise.allSettled(
targets.map(async (photo) => {
if (action === 'approve') {
return await updatePhotoStatus(slug, photo.id, 'approved');
}
if (action === 'hide') {
return await updatePhotoVisibility(slug, photo.id, true);
}
if (action === 'show') {
return await updatePhotoVisibility(slug, photo.id, false);
}
if (action === 'feature') {
return await featurePhoto(slug, photo.id);
}
return await unfeaturePhoto(slug, photo.id);
}),
);
const updates = results
.filter((result): result is PromiseFulfilledResult<TenantPhoto> => result.status === 'fulfilled')
.map((result) => result.value);
if (updates.length) {
setPhotos((prev) => prev.map((photo) => updates.find((update) => update.id === photo.id) ?? photo));
setLightbox((prev) => (prev ? updates.find((update) => update.id === prev.id) ?? prev : prev));
toast.success(t('mobilePhotos.bulkUpdated', 'Bulk update applied'));
}
} catch {
toast.error(t('mobilePhotos.bulkFailed', 'Bulk update failed'));
} finally {
setBulkBusy(false);
}
}
function resolveScopeAndAddonKey(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) { function resolveScopeAndAddonKey(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) {
const scope = const scope =
scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests' scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests'
@@ -276,12 +344,26 @@ export default function MobileEventPhotosPage() {
onBack={() => navigate(-1)} onBack={() => navigate(-1)}
headerActions={ headerActions={
<XStack space="$3"> <XStack space="$3">
<Pressable onPress={() => setShowFilters(true)}> <HeaderActionButton
onPress={() => {
if (selectionMode) {
clearSelection();
} else {
setSelectionMode(true);
}
}}
ariaLabel={selectionMode ? t('common.done', 'Done') : t('common.select', 'Select')}
>
<Text fontSize="$xs" fontWeight="800" color={text}>
{selectionMode ? t('common.done', 'Done') : t('common.select', 'Select')}
</Text>
</HeaderActionButton>
<HeaderActionButton onPress={() => setShowFilters(true)} ariaLabel={t('mobilePhotos.filtersTitle', 'Filter')}>
<Filter size={18} color={text} /> <Filter size={18} color={text} />
</Pressable> </HeaderActionButton>
<Pressable onPress={() => load()}> <HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color={text} /> <RefreshCcw size={18} color={text} />
</Pressable> </HeaderActionButton>
</XStack> </XStack>
} }
> >
@@ -293,15 +375,16 @@ export default function MobileEventPhotosPage() {
</MobileCard> </MobileCard>
) : null} ) : null}
<input <MobileInput
type="search" type="search"
value={search} value={search}
onChange={(e) => { onChange={(e) => {
setSearch(e.target.value); setSearch(e.target.value);
setPage(1); setPage(1);
}} }}
placeholder={t('photos.filters.search', 'Search uploads …')} placeholder={t('photos.filters.search', 'Search uploads …')}
style={{ ...baseInputStyle, marginBottom: 12 }} compact
style={{ marginBottom: 12 }}
/> />
<XStack space="$2" flexWrap="wrap"> <XStack space="$2" flexWrap="wrap">
@@ -341,7 +424,7 @@ export default function MobileEventPhotosPage() {
{loading ? ( {loading ? (
<YStack space="$2"> <YStack space="$2">
{Array.from({ length: 4 }).map((_, idx) => ( {Array.from({ length: 4 }).map((_, idx) => (
<MobileCard key={`ph-${idx}`} height={100} opacity={0.6} /> <SkeletonCard key={`ph-${idx}`} height={100} />
))} ))}
</YStack> </YStack>
) : photos.length === 0 ? ( ) : photos.length === 0 ? (
@@ -363,24 +446,53 @@ export default function MobileEventPhotosPage() {
gap: 8, gap: 8,
}} }}
> >
{photos.map((photo) => ( {photos.map((photo) => {
<Pressable key={photo.id} onPress={() => setLightbox(photo)}> const isSelected = selectedIds.includes(photo.id);
<YStack borderRadius={10} overflow="hidden" borderWidth={1} borderColor={border}> return (
<img <Pressable
src={photo.thumbnail_url ?? photo.url ?? undefined} key={photo.id}
alt={photo.caption ?? 'Photo'} onPress={() => (selectionMode ? toggleSelection(photo.id) : setLightbox(photo))}
style={{ width: '100%', height: 110, objectFit: 'cover' }} >
/> <YStack
<XStack position="absolute" top={6} left={6} space="$1"> borderRadius={10}
{photo.is_featured ? <PillBadge tone="warning">{t('photos.filters.featured', 'Featured')}</PillBadge> : null} overflow="hidden"
{photo.status === 'pending' ? ( borderWidth={1}
<PillBadge tone="warning">{t('photos.filters.pending', 'Pending')}</PillBadge> borderColor={isSelected ? infoBorder : border}
>
<motion.img
layoutId={`photo-${photo.id}`}
src={photo.thumbnail_url ?? photo.url ?? undefined}
alt={photo.caption ?? 'Photo'}
style={{ width: '100%', height: 110, objectFit: 'cover' }}
/>
<XStack position="absolute" top={6} left={6} space="$1">
{photo.is_featured ? <PillBadge tone="warning">{t('photos.filters.featured', 'Featured')}</PillBadge> : null}
{photo.status === 'pending' ? (
<PillBadge tone="warning">{t('photos.filters.pending', 'Pending')}</PillBadge>
) : null}
{photo.status === 'hidden' ? <PillBadge tone="muted">{t('photos.filters.hidden', 'Hidden')}</PillBadge> : null}
</XStack>
{selectionMode ? (
<XStack
position="absolute"
top={6}
right={6}
width={24}
height={24}
borderRadius={999}
alignItems="center"
justifyContent="center"
backgroundColor={isSelected ? String(theme.primary?.val ?? '#007AFF') : 'rgba(255,255,255,0.85)'}
borderWidth={1}
borderColor={isSelected ? String(theme.primary?.val ?? '#007AFF') : border}
>
{isSelected ? <Check size={14} color="white" /> : null}
</XStack>
) : null} ) : null}
{photo.status === 'hidden' ? <PillBadge tone="muted">{t('photos.filters.hidden', 'Hidden')}</PillBadge> : null} </YStack>
</XStack> </Pressable>
</YStack> );
</Pressable> })}
))}
</div> </div>
{hasMore ? ( {hasMore ? (
<CTAButton label={t('common.loadMore', 'Mehr laden')} onPress={() => setPage((prev) => prev + 1)} /> <CTAButton label={t('common.loadMore', 'Mehr laden')} onPress={() => setPage((prev) => prev + 1)} />
@@ -388,75 +500,169 @@ export default function MobileEventPhotosPage() {
</YStack> </YStack>
)} )}
{lightbox ? ( {selectionMode ? (
<div className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center"> <YStack
<div position="fixed"
style={{ left={12}
width: '100%', right={12}
maxWidth: 520, bottom="calc(env(safe-area-inset-bottom, 0px) + 96px)"
margin: '0 16px', padding="$3"
background: surface, borderRadius={18}
borderRadius: 20, backgroundColor={surface}
overflow: 'hidden', borderWidth={1}
boxShadow: '0 10px 30px rgba(0,0,0,0.25)', borderColor={border}
}} shadowColor="#0f172a"
shadowOpacity={0.18}
shadowRadius={16}
shadowOffset={{ width: 0, height: 8 }}
zIndex={60}
space="$2"
>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobilePhotos.selectedCount', '{{count}} selected', { count: selectedIds.length })}
</Text>
<Pressable onPress={() => clearSelection()}>
<Text fontSize="$sm" color={String(theme.primary?.val ?? '#007AFF')} fontWeight="700">
{t('common.clear', 'Clear')}
</Text>
</Pressable>
</XStack>
<XStack space="$2" flexWrap="wrap">
{hasPendingSelection ? (
<CTAButton
label={t('photos.actions.approve', 'Approve')}
onPress={() => applyBulkAction('approve')}
fullWidth={false}
disabled={bulkBusy}
loading={bulkBusy}
/>
) : null}
{hasVisibleSelection ? (
<CTAButton
label={t('photos.actions.hide', 'Hide')}
onPress={() => applyBulkAction('hide')}
tone="ghost"
fullWidth={false}
disabled={bulkBusy}
loading={bulkBusy}
/>
) : null}
{hasHiddenSelection ? (
<CTAButton
label={t('photos.actions.show', 'Show')}
onPress={() => applyBulkAction('show')}
tone="ghost"
fullWidth={false}
disabled={bulkBusy}
loading={bulkBusy}
/>
) : null}
{hasUnfeaturedSelection ? (
<CTAButton
label={t('photos.actions.feature', 'Set highlight')}
onPress={() => applyBulkAction('feature')}
tone="ghost"
fullWidth={false}
disabled={bulkBusy}
loading={bulkBusy}
/>
) : null}
{hasFeaturedSelection ? (
<CTAButton
label={t('photos.actions.unfeature', 'Remove highlight')}
onPress={() => applyBulkAction('unfeature')}
tone="ghost"
fullWidth={false}
disabled={bulkBusy}
loading={bulkBusy}
/>
) : null}
</XStack>
</YStack>
) : null}
<AnimatePresence>
{lightbox ? (
<motion.div
className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
> >
<img <motion.div
src={lightbox.url ?? lightbox.thumbnail_url ?? undefined} initial={{ y: 12, scale: 0.98, opacity: 0 }}
alt={lightbox.caption ?? 'Photo'} animate={{ y: 0, scale: 1, opacity: 1 }}
style={{ width: '100%', maxHeight: '60vh', objectFit: 'contain', background: backdrop }} exit={{ y: 12, scale: 0.98, opacity: 0 }}
/> transition={{ duration: 0.2, ease: 'easeOut' }}
<YStack padding="$3" space="$2"> style={{
<XStack space="$2" alignItems="center"> width: '100%',
<PillBadge tone="muted">{lightbox.uploader_name || t('events.members.roles.guest', 'Guest')}</PillBadge> maxWidth: 520,
<PillBadge tone="muted"> {lightbox.likes_count ?? 0}</PillBadge> margin: '0 16px',
{lightbox.status === 'pending' ? ( background: surface,
<PillBadge tone="warning">{t('photos.filters.pending', 'Pending')}</PillBadge> borderRadius: 20,
) : null} overflow: 'hidden',
{lightbox.status === 'hidden' ? ( boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
<PillBadge tone="muted">{t('photos.filters.hidden', 'Hidden')}</PillBadge> }}
) : null} >
</XStack> <motion.img
<XStack space="$2" flexWrap="wrap"> layoutId={`photo-${lightbox.id}`}
{lightbox.status === 'pending' ? ( src={lightbox.url ?? lightbox.thumbnail_url ?? undefined}
alt={lightbox.caption ?? 'Photo'}
style={{ width: '100%', maxHeight: '60vh', objectFit: 'contain', background: backdrop }}
/>
<YStack padding="$3" space="$2">
<XStack space="$2" alignItems="center">
<PillBadge tone="muted">{lightbox.uploader_name || t('events.members.roles.guest', 'Guest')}</PillBadge>
<PillBadge tone="muted"> {lightbox.likes_count ?? 0}</PillBadge>
{lightbox.status === 'pending' ? (
<PillBadge tone="warning">{t('photos.filters.pending', 'Pending')}</PillBadge>
) : null}
{lightbox.status === 'hidden' ? (
<PillBadge tone="muted">{t('photos.filters.hidden', 'Hidden')}</PillBadge>
) : null}
</XStack>
<XStack space="$2" flexWrap="wrap">
{lightbox.status === 'pending' ? (
<CTAButton
label={
busyId === lightbox.id
? t('common.processing', '...')
: t('photos.actions.approve', 'Approve')
}
onPress={() => approvePhoto(lightbox)}
style={{ flex: 1, minWidth: 140 }}
/>
) : null}
<CTAButton <CTAButton
label={ label={
busyId === lightbox.id busyId === lightbox.id
? t('common.processing', '...') ? t('common.processing', '...')
: t('photos.actions.approve', 'Approve') : lightbox.is_featured
? t('photos.actions.unfeature', 'Remove highlight')
: t('photos.actions.feature', 'Set highlight')
} }
onPress={() => approvePhoto(lightbox)} onPress={() => toggleFeature(lightbox)}
style={{ flex: 1, minWidth: 140 }} style={{ flex: 1, minWidth: 140 }}
/> />
) : null} <CTAButton
<CTAButton label={
label={ busyId === lightbox.id
busyId === lightbox.id ? t('common.processing', '...')
? t('common.processing', '...') : lightbox.status === 'hidden'
: lightbox.is_featured ? t('photos.actions.show', 'Show')
? t('photos.actions.unfeature', 'Remove highlight') : t('photos.actions.hide', 'Hide')
: t('photos.actions.feature', 'Set highlight') }
} onPress={() => toggleVisibility(lightbox)}
onPress={() => toggleFeature(lightbox)} style={{ flex: 1, minWidth: 140 }}
style={{ flex: 1, minWidth: 140 }} />
/> </XStack>
<CTAButton <CTAButton label={t('common.close', 'Close')} tone="ghost" onPress={() => setLightbox(null)} />
label={ </YStack>
busyId === lightbox.id </motion.div>
? t('common.processing', '...') </motion.div>
: lightbox.status === 'hidden' ) : null}
? t('photos.actions.show', 'Show') </AnimatePresence>
: t('photos.actions.hide', 'Hide')
}
onPress={() => toggleVisibility(lightbox)}
style={{ flex: 1, minWidth: 140 }}
/>
</XStack>
<CTAButton label={t('common.close', 'Close')} tone="ghost" onPress={() => setLightbox(null)} />
</YStack>
</div>
</div>
) : null}
<MobileSheet <MobileSheet
open={showFilters} open={showFilters}
@@ -474,15 +680,15 @@ export default function MobileEventPhotosPage() {
} }
> >
<YStack space="$2"> <YStack space="$2">
<Field label={t('mobilePhotos.uploader', 'Uploader')} color={text}> <MobileField label={t('mobilePhotos.uploader', 'Uploader')}>
<input <MobileInput
type="text" type="text"
value={uploaderFilter} value={uploaderFilter}
onChange={(e) => setUploaderFilter(e.target.value)} onChange={(e) => setUploaderFilter(e.target.value)}
placeholder={t('mobilePhotos.uploaderPlaceholder', 'Name or email')} placeholder={t('mobilePhotos.uploaderPlaceholder', 'Name or email')}
style={baseInputStyle} compact
/> />
</Field> </MobileField>
<XStack space="$2" alignItems="center"> <XStack space="$2" alignItems="center">
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input <input
@@ -541,17 +747,6 @@ export default function MobileEventPhotosPage() {
); );
} }
function Field({ label, color, children }: { label: string; color: string; children: React.ReactNode }) {
return (
<YStack space="$1">
<Text fontSize="$sm" color={color}>
{label}
</Text>
{children}
</YStack>
);
}
type LimitTranslator = (key: string, options?: Record<string, unknown>) => string; type LimitTranslator = (key: string, options?: Record<string, unknown>) => string;
function translateLimits(t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string): LimitTranslator { function translateLimits(t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string): LimitTranslator {
@@ -659,25 +854,13 @@ function MobileAddonsPicker({
return ( return (
<XStack space="$2" alignItems="center"> <XStack space="$2" alignItems="center">
<select <MobileSelect value={selected} onChange={(event) => setSelected(event.target.value)} style={{ flex: 1 }} compact>
value={selected}
onChange={(event) => setSelected(event.target.value)}
style={{
flex: 1,
height: 40,
borderRadius: 10,
border: '1px solid #e5e7eb',
padding: '0 12px',
fontSize: 13,
background: '#fff',
}}
>
{options.map((addon) => ( {options.map((addon) => (
<option key={addon.key} value={addon.key}> <option key={addon.key} value={addon.key}>
{addon.label ?? addon.key} {addon.label ?? addon.key}
</option> </option>
))} ))}
</select> </MobileSelect>
<CTAButton <CTAButton
label={ label={
scope === 'gallery' scope === 'gallery'

View File

@@ -22,8 +22,8 @@ import {
} from '../api'; } from '../api';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError'; import { getApiErrorMessage } from '../lib/apiError';
import { MobileShell } from './components/MobileShell'; import { HeaderActionButton, MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives'; import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
import { adminPath } from '../constants'; import { adminPath } from '../constants';
import { selectAddonKeyForScope } from './addons'; import { selectAddonKeyForScope } from './addons';
import { LegalConsentSheet } from './components/LegalConsentSheet'; import { LegalConsentSheet } from './components/LegalConsentSheet';
@@ -199,9 +199,9 @@ export default function MobileEventRecapPage() {
subtitle={event?.event_date ? formatDate(event.event_date) : undefined} subtitle={event?.event_date ? formatDate(event.event_date) : undefined}
onBack={() => navigate(-1)} onBack={() => navigate(-1)}
headerActions={ headerActions={
<Pressable onPress={() => load()}> <HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color="#0f172a" /> <RefreshCcw size={18} color="#0f172a" />
</Pressable> </HeaderActionButton>
} }
> >
{error ? ( {error ? (
@@ -213,7 +213,7 @@ export default function MobileEventRecapPage() {
{loading ? ( {loading ? (
<YStack space="$2"> <YStack space="$2">
{Array.from({ length: 4 }).map((_, idx) => ( {Array.from({ length: 4 }).map((_, idx) => (
<MobileCard key={`sk-${idx}`} height={90} opacity={0.5} /> <SkeletonCard key={`sk-${idx}`} height={90} />
))} ))}
</YStack> </YStack>
) : event && stats ? ( ) : event && stats ? (

View File

@@ -6,8 +6,9 @@ import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { ListItem } from '@tamagui/list-item'; import { ListItem } from '@tamagui/list-item';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell } from './components/MobileShell'; import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives'; import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
import { import {
getEvent, getEvent,
getEvents, getEvents,
@@ -38,14 +39,6 @@ import { useEventContext } from '../context/EventContext';
import { useTheme } from '@tamagui/core'; import { useTheme } from '@tamagui/core';
import { RadioGroup } from '@tamagui/radio-group'; import { RadioGroup } from '@tamagui/radio-group';
const inputBaseStyle = {
width: '100%',
height: 40,
borderRadius: 10,
padding: '0 12px',
fontSize: 13,
} as const;
function InlineSeparator() { function InlineSeparator() {
const theme = useTheme(); const theme = useTheme();
return <XStack height={1} opacity={0.7} marginLeft="$3" backgroundColor={theme.borderColor?.val ?? '#e5e7eb'} />; return <XStack height={1} opacity={0.7} marginLeft="$3" backgroundColor={theme.borderColor?.val ?? '#e5e7eb'} />;
@@ -65,16 +58,6 @@ export default function MobileEventTasksPage() {
const primary = String(theme.primary?.val ?? '#007AFF'); const primary = String(theme.primary?.val ?? '#007AFF');
const danger = String(theme.red10?.val ?? '#ef4444'); const danger = String(theme.red10?.val ?? '#ef4444');
const surface = String(theme.surface?.val ?? '#ffffff'); const surface = String(theme.surface?.val ?? '#ffffff');
const inputStyle = React.useMemo<React.CSSProperties>(
() => ({
...inputBaseStyle,
border: `1px solid ${border}`,
background: surface,
color: text,
}),
[border, surface, text],
);
const [assignedTasks, setAssignedTasks] = React.useState<TenantTask[]>([]); const [assignedTasks, setAssignedTasks] = React.useState<TenantTask[]>([]);
const [library, setLibrary] = React.useState<TenantTask[]>([]); const [library, setLibrary] = React.useState<TenantTask[]>([]);
const [collections, setCollections] = React.useState<TenantTaskCollection[]>([]); const [collections, setCollections] = React.useState<TenantTaskCollection[]>([]);
@@ -375,9 +358,9 @@ export default function MobileEventTasksPage() {
onBack={() => navigate(-1)} onBack={() => navigate(-1)}
headerActions={ headerActions={
<XStack space="$2"> <XStack space="$2">
<Pressable onPress={() => load()}> <HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color={text} /> <RefreshCcw size={18} color={text} />
</Pressable> </HeaderActionButton>
</XStack> </XStack>
} }
> >
@@ -392,7 +375,7 @@ export default function MobileEventTasksPage() {
{loading ? ( {loading ? (
<YStack space="$2"> <YStack space="$2">
{Array.from({ length: 4 }).map((_, idx) => ( {Array.from({ length: 4 }).map((_, idx) => (
<MobileCard key={`tsk-${idx}`} height={70} opacity={0.6} /> <SkeletonCard key={`tsk-${idx}`} height={70} />
))} ))}
</YStack> </YStack>
) : assignedTasks.length === 0 ? ( ) : assignedTasks.length === 0 ? (
@@ -474,12 +457,12 @@ export default function MobileEventTasksPage() {
) : ( ) : (
<YStack space="$2"> <YStack space="$2">
<YStack space="$2"> <YStack space="$2">
<input <MobileInput
type="search" type="search"
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
placeholder={t('events.tasks.search', 'Search tasks')} placeholder={t('events.tasks.search', 'Search tasks')}
style={{ ...inputStyle, height: 38 }} compact
/> />
<Pressable onPress={() => setShowEmotionFilterSheet(true)}> <Pressable onPress={() => setShowEmotionFilterSheet(true)}>
<MobileCard borderColor={border} backgroundColor={surface} space="$2"> <MobileCard borderColor={border} backgroundColor={surface} space="$2">
@@ -667,28 +650,27 @@ export default function MobileEventTasksPage() {
} }
> >
<YStack space="$2"> <YStack space="$2">
<Field label={t('events.tasks.titleLabel', 'Titel')} color={text}> <MobileField label={t('events.tasks.titleLabel', 'Titel')}>
<input <MobileInput
type="text" type="text"
value={newTask.title} value={newTask.title}
onChange={(e) => setNewTask((prev) => ({ ...prev, title: e.target.value }))} onChange={(e) => setNewTask((prev) => ({ ...prev, title: e.target.value }))}
placeholder={t('events.tasks.titlePlaceholder', 'z.B. Erstes Gruppenfoto')} placeholder={t('events.tasks.titlePlaceholder', 'z.B. Erstes Gruppenfoto')}
style={inputStyle}
/> />
</Field> </MobileField>
<Field label={t('events.tasks.description', 'Beschreibung')} color={text}> <MobileField label={t('events.tasks.description', 'Beschreibung')}>
<textarea <MobileTextArea
value={newTask.description} value={newTask.description}
onChange={(e) => setNewTask((prev) => ({ ...prev, description: e.target.value }))} onChange={(e) => setNewTask((prev) => ({ ...prev, description: e.target.value }))}
placeholder={t('events.tasks.descriptionPlaceholder', 'Optionale Hinweise')} placeholder={t('events.tasks.descriptionPlaceholder', 'Optionale Hinweise')}
style={{ ...inputStyle, minHeight: 80 }} compact
style={{ minHeight: 80 }}
/> />
</Field> </MobileField>
<Field label={t('events.tasks.emotion', 'Emotion')} color={text}> <MobileField label={t('events.tasks.emotion', 'Emotion')}>
<select <MobileSelect
value={newTask.emotion_id} value={newTask.emotion_id}
onChange={(e) => setNewTask((prev) => ({ ...prev, emotion_id: e.target.value }))} onChange={(e) => setNewTask((prev) => ({ ...prev, emotion_id: e.target.value }))}
style={{ ...inputStyle, height: 42 }}
> >
<option value="">{t('events.tasks.emotionNone', 'Keine')}</option> <option value="">{t('events.tasks.emotionNone', 'Keine')}</option>
{emotions.map((emotion) => ( {emotions.map((emotion) => (
@@ -696,8 +678,8 @@ export default function MobileEventTasksPage() {
{emotion.name} {emotion.name}
</option> </option>
))} ))}
</select> </MobileSelect>
</Field> </MobileField>
</YStack> </YStack>
</MobileSheet> </MobileSheet>
@@ -711,11 +693,11 @@ export default function MobileEventTasksPage() {
<Text fontSize={12} color={muted}> <Text fontSize={12} color={muted}>
{t('events.tasks.bulkHint', 'One task per line. These will be created and added to the event.')} {t('events.tasks.bulkHint', 'One task per line. These will be created and added to the event.')}
</Text> </Text>
<textarea <MobileTextArea
value={bulkLines} value={bulkLines}
onChange={(e) => setBulkLines(e.target.value)} onChange={(e) => setBulkLines(e.target.value)}
placeholder={t('events.tasks.bulkPlaceholder', 'e.g.\nBride & groom portrait\nGroup photo main guests')} placeholder={t('events.tasks.bulkPlaceholder', 'e.g.\nBride & groom portrait\nGroup photo main guests')}
style={{ ...inputStyle, minHeight: 140, fontSize: 12.5 }} style={{ minHeight: 140, fontSize: 12.5 }}
/> />
</YStack> </YStack>
</MobileSheet> </MobileSheet>
@@ -736,23 +718,22 @@ export default function MobileEventTasksPage() {
} }
> >
<YStack space="$2"> <YStack space="$2">
<Field label={t('events.tasks.emotionName', 'Name')} color={text}> <MobileField label={t('events.tasks.emotionName', 'Name')}>
<input <MobileInput
type="text" type="text"
value={emotionForm.name} value={emotionForm.name}
onChange={(e) => setEmotionForm((prev) => ({ ...prev, name: e.target.value }))} onChange={(e) => setEmotionForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder={t('events.tasks.emotionNamePlaceholder', 'z.B. Joy')} placeholder={t('events.tasks.emotionNamePlaceholder', 'z.B. Joy')}
style={inputStyle}
/> />
</Field> </MobileField>
<Field label={t('events.tasks.emotionColor', 'Farbe')} color={text}> <MobileField label={t('events.tasks.emotionColor', 'Farbe')}>
<input <MobileInput
type="color" type="color"
value={emotionForm.color} value={emotionForm.color}
onChange={(e) => setEmotionForm((prev) => ({ ...prev, color: e.target.value }))} onChange={(e) => setEmotionForm((prev) => ({ ...prev, color: e.target.value }))}
style={{ width: '100%', height: 44, borderRadius: 10, border: `1px solid ${border}`, background: surface }} style={{ padding: 0 }}
/> />
</Field> </MobileField>
<YStack space="$2"> <YStack space="$2">
{emotions.map((em) => ( {emotions.map((em) => (
<ListItem <ListItem
@@ -829,7 +810,7 @@ export default function MobileEventTasksPage() {
style={{ style={{
position: 'fixed', position: 'fixed',
right: 20, right: 20,
bottom: 90, bottom: 'calc(env(safe-area-inset-bottom, 0px) + 96px)',
width: 56, width: 56,
height: 56, height: 56,
borderRadius: 28, borderRadius: 28,
@@ -908,14 +889,3 @@ export default function MobileEventTasksPage() {
</MobileShell> </MobileShell>
); );
} }
function Field({ label, color, children }: { label: string; color: string; children: React.ReactNode }) {
return (
<YStack space="$1">
<Text fontSize={12.5} fontWeight="600" color={color}>
{label}
</Text>
{children}
</YStack>
);
}

View File

@@ -5,8 +5,9 @@ import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { MobileShell } from './components/MobileShell'; import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, PillBadge, CTAButton } from './components/Primitives'; import { MobileCard, PillBadge, CTAButton, FloatingActionButton, SkeletonCard } from './components/Primitives';
import { MobileInput } from './components/FormControls';
import { getEvents, TenantEvent } from '../api'; import { getEvents, TenantEvent } from '../api';
import { adminPath } from '../constants'; import { adminPath } from '../constants';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
@@ -20,6 +21,7 @@ export default function MobileEventsPage() {
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [query, setQuery] = React.useState(''); const [query, setQuery] = React.useState('');
const searchRef = React.useRef<HTMLInputElement>(null);
const theme = useTheme(); const theme = useTheme();
const text = String(theme.color?.val ?? '#111827'); const text = String(theme.color?.val ?? '#111827');
const muted = String(theme.gray?.val ?? '#4b5563'); const muted = String(theme.gray?.val ?? '#4b5563');
@@ -28,20 +30,6 @@ export default function MobileEventsPage() {
const primary = String(theme.primary?.val ?? '#007AFF'); const primary = String(theme.primary?.val ?? '#007AFF');
const danger = String(theme.red10?.val ?? '#b91c1c'); const danger = String(theme.red10?.val ?? '#b91c1c');
const surface = String(theme.surface?.val ?? '#ffffff'); const surface = String(theme.surface?.val ?? '#ffffff');
const baseInputStyle = React.useMemo<React.CSSProperties>(
() => ({
width: '100%',
height: 38,
borderRadius: 10,
border: `1px solid ${border}`,
padding: '0 12px',
fontSize: 13,
background: surface,
color: text,
}),
[border, surface, text],
);
React.useEffect(() => { React.useEffect(() => {
(async () => { (async () => {
try { try {
@@ -62,9 +50,9 @@ export default function MobileEventsPage() {
title={t('events.list.dashboardTitle', 'All Events Dashboard')} title={t('events.list.dashboardTitle', 'All Events Dashboard')}
onBack={() => navigate(-1)} onBack={() => navigate(-1)}
headerActions={ headerActions={
<Pressable> <HeaderActionButton onPress={() => searchRef.current?.focus()} ariaLabel={t('events.list.search', 'Search events')}>
<Search size={18} color={text} /> <Search size={18} color={text} />
</Pressable> </HeaderActionButton>
} }
> >
{error ? ( {error ? (
@@ -75,20 +63,20 @@ export default function MobileEventsPage() {
</MobileCard> </MobileCard>
) : null} ) : null}
<CTAButton label={t('events.actions.create', 'Create New Event')} onPress={() => navigate(adminPath('/mobile/events/new'))} /> <MobileInput
ref={searchRef}
<input
type="search" type="search"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder={t('events.list.search', 'Search events')} placeholder={t('events.list.search', 'Search events')}
style={{ ...baseInputStyle, marginBottom: 12 }} compact
style={{ marginBottom: 12 }}
/> />
{loading ? ( {loading ? (
<YStack space="$2"> <YStack space="$2">
{Array.from({ length: 3 }).map((_, idx) => ( {Array.from({ length: 3 }).map((_, idx) => (
<MobileCard key={`sk-${idx}`} height={90} opacity={0.6} /> <SkeletonCard key={`sk-${idx}`} height={90} />
))} ))}
</YStack> </YStack>
) : events.length === 0 ? ( ) : events.length === 0 ? (
@@ -124,6 +112,12 @@ export default function MobileEventsPage() {
))} ))}
</YStack> </YStack>
)} )}
<FloatingActionButton
label={t('events.actions.create', 'Create New Event')}
icon={Plus}
onPress={() => navigate(adminPath('/mobile/events/new'))}
/>
</MobileShell> </MobileShell>
); );
} }

View File

@@ -45,6 +45,10 @@ export default function MobileLoginPage() {
const { t } = useTranslation('auth'); const { t } = useTranslation('auth');
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const safeAreaStyle: React.CSSProperties = {
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
};
const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]); const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
const rawReturnTo = searchParams.get('return_to'); const rawReturnTo = searchParams.get('return_to');
@@ -105,7 +109,10 @@ export default function MobileLoginPage() {
}; };
return ( return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-[#0b1020] via-[#0f172a] to-[#0b1020] px-5 py-10 text-white"> <div
className="flex min-h-screen items-center justify-center bg-gradient-to-br from-[#0b1020] via-[#0f172a] to-[#0b1020] px-5 py-10 text-white"
style={safeAreaStyle}
>
<div className="w-full max-w-md space-y-8 rounded-3xl border border-white/10 bg-white/5 p-8 shadow-2xl shadow-blue-500/10 backdrop-blur-lg"> <div className="w-full max-w-md space-y-8 rounded-3xl border border-white/10 bg-white/5 p-8 shadow-2xl shadow-blue-500/10 backdrop-blur-lg">
<div className="flex flex-col items-center gap-3 text-center"> <div className="flex flex-col items-center gap-3 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/10 ring-1 ring-white/15"> <div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/10 ring-1 ring-white/15">

View File

@@ -8,6 +8,10 @@ export default function LoginStartPage(): React.ReactElement {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation('auth'); const { t } = useTranslation('auth');
const safeAreaStyle: React.CSSProperties = {
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
};
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
@@ -21,7 +25,10 @@ export default function LoginStartPage(): React.ReactElement {
}, [location.search, navigate]); }, [location.search, navigate]);
return ( return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-slate-950 p-6 text-center text-white/70"> <div
className="flex min-h-screen flex-col items-center justify-center gap-4 bg-slate-950 p-6 text-center text-white/70"
style={safeAreaStyle}
>
<p className="text-sm font-medium">{t('redirecting', 'Redirecting to login …')}</p> <p className="text-sm font-medium">{t('redirecting', 'Redirecting to login …')}</p>
</div> </div>
); );

View File

@@ -4,13 +4,20 @@ import { ADMIN_PUBLIC_LANDING_PATH } from '../constants';
export default function LogoutPage() { export default function LogoutPage() {
const { logout } = useAuth(); const { logout } = useAuth();
const safeAreaStyle: React.CSSProperties = {
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
};
React.useEffect(() => { React.useEffect(() => {
logout({ redirect: ADMIN_PUBLIC_LANDING_PATH }); logout({ redirect: ADMIN_PUBLIC_LANDING_PATH });
}, [logout]); }, [logout]);
return ( return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-rose-50 via-white to-slate-50 text-sm text-slate-600"> <div
className="flex min-h-screen items-center justify-center bg-gradient-to-br from-rose-50 via-white to-slate-50 text-sm text-slate-600"
style={safeAreaStyle}
>
<div className="rounded-2xl border border-rose-100 bg-white/90 px-6 py-4 shadow-sm shadow-rose-100/60"> <div className="rounded-2xl border border-rose-100 bg-white/90 px-6 py-4 shadow-sm shadow-rose-100/60">
Abmeldung wird vorbereitet ... Abmeldung wird vorbereitet ...
</div> </div>

View File

@@ -5,8 +5,9 @@ import { Bell, RefreshCcw } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell } from './components/MobileShell'; import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, PillBadge } from './components/Primitives'; import { MobileCard, PillBadge, SkeletonCard, CTAButton } from './components/Primitives';
import { MobileSelect } from './components/FormControls';
import { listNotificationLogs, markNotificationLogs, NotificationLogEntry } from '../api'; import { listNotificationLogs, markNotificationLogs, NotificationLogEntry } from '../api';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError'; import { getApiErrorMessage } from '../lib/apiError';
@@ -212,6 +213,8 @@ export default function MobileNotificationsPage() {
const scopeParam = search.get('scope') ?? 'all'; const scopeParam = search.get('scope') ?? 'all';
const statusParam = search.get('status') ?? 'unread'; const statusParam = search.get('status') ?? 'unread';
const [notifications, setNotifications] = React.useState<NotificationItem[]>([]); const [notifications, setNotifications] = React.useState<NotificationItem[]>([]);
const [selectedNotification, setSelectedNotification] = React.useState<NotificationItem | null>(null);
const [detailOpen, setDetailOpen] = React.useState(false);
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [events, setEvents] = React.useState<TenantEvent[]>([]); const [events, setEvents] = React.useState<TenantEvent[]>([]);
@@ -284,15 +287,29 @@ export default function MobileNotificationsPage() {
const showFilterNotice = Boolean(slug) && filtered.length === 0 && notifications.length > 0; const showFilterNotice = Boolean(slug) && filtered.length === 0 && notifications.length > 0;
const markSelectedRead = 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.'));
}
};
return ( return (
<MobileShell <MobileShell
activeTab="home" activeTab="home"
title={t('mobileNotifications.title', 'Notifications')} title={t('mobileNotifications.title', 'Notifications')}
onBack={() => navigate(-1)} onBack={() => navigate(-1)}
headerActions={ headerActions={
<Pressable onPress={() => reload()}> <HeaderActionButton onPress={() => reload()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color={text} /> <RefreshCcw size={18} color={text} />
</Pressable> </HeaderActionButton>
} }
> >
{error ? ( {error ? (
@@ -321,19 +338,21 @@ export default function MobileNotificationsPage() {
) : null} ) : null}
<XStack space="$2" marginBottom="$2"> <XStack space="$2" marginBottom="$2">
<select <MobileSelect
value={statusParam} value={statusParam}
onChange={(e) => navigate(`/admin/mobile/notifications?${new URLSearchParams({ status: e.target.value, scope: scopeParam, event: slug ?? '' }).toString()}`)} onChange={(e) => navigate(`/admin/mobile/notifications?${new URLSearchParams({ status: e.target.value, scope: scopeParam, event: slug ?? '' }).toString()}`)}
style={{ border: `1px solid ${border}`, borderRadius: 10, padding: '6px 10px', fontSize: 13 }} compact
style={{ minWidth: 120 }}
> >
<option value="unread">{t('notificationLogs.filter.unread', 'Unread')}</option> <option value="unread">{t('notificationLogs.filter.unread', 'Unread')}</option>
<option value="read">{t('notificationLogs.filter.read', 'Read')}</option> <option value="read">{t('notificationLogs.filter.read', 'Read')}</option>
<option value="all">{t('notificationLogs.filter.all', 'All')}</option> <option value="all">{t('notificationLogs.filter.all', 'All')}</option>
</select> </MobileSelect>
<select <MobileSelect
value={scopeParam} value={scopeParam}
onChange={(e) => navigate(`/admin/mobile/notifications?${new URLSearchParams({ scope: e.target.value, status: statusParam, event: slug ?? '' }).toString()}`)} onChange={(e) => navigate(`/admin/mobile/notifications?${new URLSearchParams({ scope: e.target.value, status: statusParam, event: slug ?? '' }).toString()}`)}
style={{ border: `1px solid ${border}`, borderRadius: 10, padding: '6px 10px', fontSize: 13 }} compact
style={{ minWidth: 140 }}
> >
<option value="all">{t('notificationLogs.scope.all', 'All scopes')}</option> <option value="all">{t('notificationLogs.scope.all', 'All scopes')}</option>
<option value="photos">{t('notificationLogs.scope.photos', 'Photos')}</option> <option value="photos">{t('notificationLogs.scope.photos', 'Photos')}</option>
@@ -342,7 +361,7 @@ export default function MobileNotificationsPage() {
<option value="events">{t('notificationLogs.scope.events', 'Events')}</option> <option value="events">{t('notificationLogs.scope.events', 'Events')}</option>
<option value="package">{t('notificationLogs.scope.package', 'Package')}</option> <option value="package">{t('notificationLogs.scope.package', 'Package')}</option>
<option value="general">{t('notificationLogs.scope.general', 'General')}</option> <option value="general">{t('notificationLogs.scope.general', 'General')}</option>
</select> </MobileSelect>
{unreadIds.length ? ( {unreadIds.length ? (
<CTAButton <CTAButton
label={t('notificationLogs.markAllRead', 'Mark all read')} label={t('notificationLogs.markAllRead', 'Mark all read')}
@@ -362,7 +381,7 @@ export default function MobileNotificationsPage() {
{loading ? ( {loading ? (
<YStack space="$2"> <YStack space="$2">
{Array.from({ length: 4 }).map((_, idx) => ( {Array.from({ length: 4 }).map((_, idx) => (
<MobileCard key={`al-${idx}`} height={70} opacity={0.6} /> <SkeletonCard key={`al-${idx}`} height={70} />
))} ))}
</YStack> </YStack>
) : statusFiltered.length === 0 ? ( ) : statusFiltered.length === 0 ? (
@@ -382,34 +401,76 @@ export default function MobileNotificationsPage() {
</Pressable> </Pressable>
) : null} ) : null}
{statusFiltered.map((item) => ( {statusFiltered.map((item) => (
<MobileCard key={item.id} space="$2" borderColor={item.is_read ? border : primary}> <Pressable
<XStack alignItems="center" space="$2"> key={item.id}
<XStack onPress={() => {
width={36} setSelectedNotification(item);
height={36} setDetailOpen(true);
borderRadius={12} }}
alignItems="center" >
justifyContent="center" <MobileCard space="$2" borderColor={item.is_read ? border : primary}>
backgroundColor={item.tone === 'warning' ? warningBg : infoBg} <XStack alignItems="center" space="$2">
> <XStack
<Bell size={18} color={item.tone === 'warning' ? warningIcon : infoIcon} /> width={36}
height={36}
borderRadius={12}
alignItems="center"
justifyContent="center"
backgroundColor={item.tone === 'warning' ? warningBg : infoBg}
>
<Bell size={18} color={item.tone === 'warning' ? warningIcon : infoIcon} />
</XStack>
<YStack space="$0.5" flex={1}>
<Text fontSize="$sm" fontWeight="700" color={text}>
{item.title}
</Text>
<Text fontSize="$xs" color={muted}>
{item.body}
</Text>
</YStack>
{!item.is_read ? <PillBadge tone="warning">{t('notificationLogs.unread', 'Unread')}</PillBadge> : null}
<PillBadge tone={item.tone === 'warning' ? 'warning' : 'muted'}>{item.time}</PillBadge>
</XStack> </XStack>
<YStack space="$0.5" flex={1}> </MobileCard>
<Text fontSize="$sm" fontWeight="700" color={text}> </Pressable>
{item.title}
</Text>
<Text fontSize="$xs" color={muted}>
{item.body}
</Text>
</YStack>
{!item.is_read ? <PillBadge tone="warning">{t('notificationLogs.unread', 'Unread')}</PillBadge> : null}
<PillBadge tone={item.tone === 'warning' ? 'warning' : 'muted'}>{item.time}</PillBadge>
</XStack>
</MobileCard>
))} ))}
</YStack> </YStack>
)} )}
<MobileSheet
open={detailOpen && Boolean(selectedNotification)}
onClose={() => {
setDetailOpen(false);
setSelectedNotification(null);
}}
title={selectedNotification?.title ?? t('mobileNotifications.title', 'Notifications')}
footer={
selectedNotification && !selectedNotification.is_read ? (
<CTAButton label={t('notificationLogs.markRead', 'Mark as read')} onPress={() => markSelectedRead()} />
) : null
}
>
{selectedNotification ? (
<YStack space="$2">
<Text fontSize="$sm" color={text} fontWeight="700">
{selectedNotification.title}
</Text>
<Text fontSize="$sm" color={muted}>
{selectedNotification.body}
</Text>
<XStack space="$2">
<PillBadge tone={selectedNotification.tone === 'warning' ? 'warning' : 'muted'}>
{selectedNotification.scope}
</PillBadge>
{!selectedNotification.is_read ? <PillBadge tone="warning">{t('notificationLogs.unread', 'Unread')}</PillBadge> : null}
</XStack>
<Text fontSize="$xs" color={muted}>
{selectedNotification.time}
</Text>
</YStack>
) : null}
</MobileSheet>
<MobileSheet <MobileSheet
open={showEventPicker} open={showEventPicker}
onClose={() => setShowEventPicker(false)} onClose={() => setShowEventPicker(false)}

View File

@@ -6,8 +6,11 @@ import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import { useTheme } from '@tamagui/core'; import { useTheme } from '@tamagui/core';
import { YGroup } from '@tamagui/group';
import { ListItem } from '@tamagui/list-item';
import { MobileShell } from './components/MobileShell'; import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives'; import { MobileCard, CTAButton } from './components/Primitives';
import { MobileSelect } from './components/FormControls';
import { useAuth } from '../auth/context'; import { useAuth } from '../auth/context';
import { fetchTenantProfile } from '../api'; import { fetchTenantProfile } from '../api';
import { adminPath } from '../constants'; import { adminPath } from '../constants';
@@ -78,67 +81,111 @@ export default function MobileProfilePage() {
<Text fontSize="$md" fontWeight="800" color={textColor}> <Text fontSize="$md" fontWeight="800" color={textColor}>
{t('mobileProfile.settings', 'Settings')} {t('mobileProfile.settings', 'Settings')}
</Text> </Text>
<Pressable onPress={() => navigate(adminPath('/mobile/settings'))}> <YGroup borderRadius="$4" borderWidth={1} borderColor={borderColor} overflow="hidden">
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2" borderBottomWidth={1} borderColor={borderColor}> <YGroup.Item bordered>
<Text fontSize="$sm" color={textColor}> <Pressable onPress={() => navigate(adminPath('/mobile/settings'))}>
{t('mobileProfile.account', 'Account & security')} <ListItem
</Text> hoverTheme
<Settings size={18} color="#9ca3af" /> pressTheme
</XStack> paddingVertical="$2"
</Pressable> paddingHorizontal="$3"
<Pressable onPress={() => navigate(adminPath('/mobile/billing#packages'))}> title={
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2" borderBottomWidth={1} borderColor={borderColor}> <Text fontSize="$sm" color={textColor}>
<Text fontSize="$sm" color={textColor}> {t('mobileProfile.account', 'Account & security')}
{t('billing.sections.packages.title', 'Packages & Billing')} </Text>
</Text> }
<Settings size={18} color="#9ca3af" /> iconAfter={<Settings size={18} color="#9ca3af" />}
</XStack> />
</Pressable> </Pressable>
<Pressable onPress={() => navigate(adminPath('/mobile/billing#invoices'))}> </YGroup.Item>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2"> <YGroup.Item bordered>
<Text fontSize="$sm" color={textColor}> <Pressable onPress={() => navigate(adminPath('/mobile/billing#packages'))}>
{t('billing.sections.invoices.title', 'Invoices & Payments')} <ListItem
</Text> hoverTheme
<Settings size={18} color="#9ca3af" /> pressTheme
</XStack> paddingVertical="$2"
</Pressable> paddingHorizontal="$3"
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2" borderBottomWidth={1} borderColor={borderColor}> title={
<XStack space="$2" alignItems="center"> <Text fontSize="$sm" color={textColor}>
<Globe size={16} color="#6b7280" /> {t('billing.sections.packages.title', 'Packages & Billing')}
<Text fontSize="$sm" color={textColor}> </Text>
{t('mobileProfile.language', 'Language')} }
</Text> iconAfter={<Settings size={18} color="#9ca3af" />}
</XStack> />
<select </Pressable>
value={language} </YGroup.Item>
onChange={(e) => { <YGroup.Item bordered>
const lng = e.target.value; <Pressable onPress={() => navigate(adminPath('/mobile/billing#invoices'))}>
setLanguage(lng); <ListItem
void i18n.changeLanguage(lng); hoverTheme
}} pressTheme
style={{ border: '1px solid #e5e7eb', borderRadius: 10, padding: '6px 10px', background: 'white', fontSize: 13 }} paddingVertical="$2"
> paddingHorizontal="$3"
<option value="de">{t('mobileProfile.languageDe', 'Deutsch')}</option> title={
<option value="en">{t('mobileProfile.languageEn', 'English')}</option> <Text fontSize="$sm" color={textColor}>
</select> {t('billing.sections.invoices.title', 'Invoices & Payments')}
</XStack> </Text>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2"> }
<XStack space="$2" alignItems="center"> iconAfter={<Settings size={18} color="#9ca3af" />}
<Moon size={16} color="#6b7280" /> />
<Text fontSize="$sm" color={textColor}> </Pressable>
{t('mobileProfile.theme', 'Theme')} </YGroup.Item>
</Text> <YGroup.Item bordered>
</XStack> <ListItem
<select paddingVertical="$2"
value={appearance} paddingHorizontal="$3"
onChange={(e) => updateAppearance(e.target.value as 'light' | 'dark' | 'system')} title={
style={{ border: '1px solid #e5e7eb', borderRadius: 10, padding: '6px 10px', background: 'white', fontSize: 13 }} <XStack space="$2" alignItems="center">
> <Globe size={16} color="#6b7280" />
<option value="light">{t('mobileProfile.themeLight', 'Light')}</option> <Text fontSize="$sm" color={textColor}>
<option value="dark">{t('mobileProfile.themeDark', 'Dark')}</option> {t('mobileProfile.language', 'Language')}
<option value="system">{t('mobileProfile.themeSystem', 'System')}</option> </Text>
</select> </XStack>
</XStack> }
iconAfter={
<MobileSelect
value={language}
onChange={(e) => {
const lng = e.target.value;
setLanguage(lng);
void i18n.changeLanguage(lng);
}}
compact
style={{ minWidth: 130 }}
>
<option value="de">{t('mobileProfile.languageDe', 'Deutsch')}</option>
<option value="en">{t('mobileProfile.languageEn', 'English')}</option>
</MobileSelect>
}
/>
</YGroup.Item>
<YGroup.Item>
<ListItem
paddingVertical="$2"
paddingHorizontal="$3"
title={
<XStack space="$2" alignItems="center">
<Moon size={16} color="#6b7280" />
<Text fontSize="$sm" color={textColor}>
{t('mobileProfile.theme', 'Theme')}
</Text>
</XStack>
}
iconAfter={
<MobileSelect
value={appearance}
onChange={(e) => updateAppearance(e.target.value as 'light' | 'dark' | 'system')}
compact
style={{ minWidth: 130 }}
>
<option value="light">{t('mobileProfile.themeLight', 'Light')}</option>
<option value="dark">{t('mobileProfile.themeDark', 'Dark')}</option>
<option value="system">{t('mobileProfile.themeSystem', 'System')}</option>
</MobileSelect>
}
/>
</YGroup.Item>
</YGroup>
</MobileCard> </MobileCard>
<CTAButton <CTAButton

View File

@@ -9,7 +9,7 @@ import { Input, TextArea } from 'tamagui';
import { Accordion } from '@tamagui/accordion'; import { Accordion } from '@tamagui/accordion';
import { HexColorPicker } from 'react-colorful'; import { HexColorPicker } from 'react-colorful';
import { Portal } from '@tamagui/portal'; import { Portal } from '@tamagui/portal';
import { MobileShell } from './components/MobileShell'; import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives'; import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import { import {
TenantEvent, TenantEvent,
@@ -183,9 +183,9 @@ export default function MobileQrLayoutCustomizePage() {
title={t('events.qr.customize', 'Layout anpassen')} title={t('events.qr.customize', 'Layout anpassen')}
onBack={() => navigate(-1)} onBack={() => navigate(-1)}
headerActions={ headerActions={
<Pressable onPress={() => window.location.reload()}> <HeaderActionButton onPress={() => window.location.reload()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color="#0f172a" /> <RefreshCcw size={18} color="#0f172a" />
</Pressable> </HeaderActionButton>
} }
> >
{error ? ( {error ? (

View File

@@ -5,7 +5,7 @@ import { ChevronRight, RefreshCcw, ArrowLeft } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell } from './components/MobileShell'; import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives'; import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import { import {
TenantEvent, TenantEvent,
@@ -75,9 +75,9 @@ export default function MobileQrPrintPage() {
title={t('events.qr.title', 'QR Code & Print Layouts')} title={t('events.qr.title', 'QR Code & Print Layouts')}
onBack={() => navigate(-1)} onBack={() => navigate(-1)}
headerActions={ headerActions={
<Pressable onPress={() => window.location.reload()}> <HeaderActionButton onPress={() => window.location.reload()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color="#0f172a" /> <RefreshCcw size={18} color="#0f172a" />
</Pressable> </HeaderActionButton>
} }
> >
{error ? ( {error ? (

View File

@@ -1,11 +1,13 @@
import React from 'react'; import React from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Shield, Bell, LogOut, User } from 'lucide-react'; import { Shield, Bell, User } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { YGroup } from '@tamagui/group';
import { ListItem } from '@tamagui/list-item';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { Switch } from '@tamagui/switch'; import { Switch } from '@tamagui/switch';
import { useTheme } from '@tamagui/core';
import { MobileShell } from './components/MobileShell'; import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives'; import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import { useAuth } from '../auth/context'; import { useAuth } from '../auth/context';
@@ -36,6 +38,10 @@ export default function MobileSettingsPage() {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const navigate = useNavigate(); const navigate = useNavigate();
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const theme = useTheme();
const text = String(theme.color?.val ?? '#0f172a');
const muted = String(theme.gray?.val ?? '#6b7280');
const border = String(theme.borderColor?.val ?? '#e5e7eb');
const [preferences, setPreferences] = React.useState<NotificationPreferences>({}); const [preferences, setPreferences] = React.useState<NotificationPreferences>({});
const [defaults, setDefaults] = React.useState<NotificationPreferences>({}); const [defaults, setDefaults] = React.useState<NotificationPreferences>({});
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
@@ -104,12 +110,12 @@ export default function MobileSettingsPage() {
<MobileCard space="$3"> <MobileCard space="$3">
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
<Shield size={18} color="#0f172a" /> <Shield size={18} color={text} />
<Text fontSize="$md" fontWeight="800" color="#0f172a"> <Text fontSize="$md" fontWeight="800" color={text}>
{t('mobileSettings.accountTitle', 'Account')} {t('mobileSettings.accountTitle', 'Account')}
</Text> </Text>
</XStack> </XStack>
<Text fontSize="$sm" color="#4b5563"> <Text fontSize="$sm" color={muted}>
{user?.name ?? user?.email ?? t('settings.session.unknown', 'Benutzer')} {user?.name ?? user?.email ?? t('settings.session.unknown', 'Benutzer')}
</Text> </Text>
{user?.tenant_id ? ( {user?.tenant_id ? (
@@ -123,47 +129,48 @@ export default function MobileSettingsPage() {
<MobileCard space="$3"> <MobileCard space="$3">
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
<Bell size={18} color="#0f172a" /> <Bell size={18} color={text} />
<Text fontSize="$md" fontWeight="800" color="#0f172a"> <Text fontSize="$md" fontWeight="800" color={text}>
{t('mobileSettings.notificationsTitle', 'Notifications')} {t('mobileSettings.notificationsTitle', 'Notifications')}
</Text> </Text>
</XStack> </XStack>
{loading ? ( {loading ? (
<Text fontSize="$sm" color="#6b7280"> <Text fontSize="$sm" color={muted}>
{t('mobileSettings.notificationsLoading', 'Loading settings ...')} {t('mobileSettings.notificationsLoading', 'Loading settings ...')}
</Text> </Text>
) : ( ) : (
<YStack space="$2"> <YGroup borderRadius="$4" borderWidth={1} borderColor={border} overflow="hidden">
{AVAILABLE_PREFS.map((key) => ( {AVAILABLE_PREFS.map((key, index) => (
<XStack <YGroup.Item key={key} bordered={index < AVAILABLE_PREFS.length - 1}>
key={key} <ListItem
alignItems="center" hoverTheme
justifyContent="space-between" pressTheme
borderBottomWidth={1} paddingVertical="$2"
borderColor="#e5e7eb" paddingHorizontal="$3"
paddingBottom="$2" title={
paddingTop="$1.5" <Text fontSize="$sm" color={text} fontWeight="700">
space="$2" {t(`settings.notifications.keys.${key}.label`, key)}
> </Text>
<YStack flex={1} minWidth={0} space="$1"> }
<Text fontSize="$sm" color="#0f172a" fontWeight="700"> subTitle={
{t(`settings.notifications.keys.${key}.label`, key)} <Text fontSize="$xs" color={muted}>
</Text> {t(`settings.notifications.keys.${key}.description`, '')}
<Text fontSize="$xs" color="#6b7280"> </Text>
{t(`settings.notifications.keys.${key}.description`, '')} }
</Text> iconAfter={
</YStack> <Switch
<Switch size="$4"
size="$4" checked={Boolean(preferences[key])}
checked={Boolean(preferences[key])} onCheckedChange={() => togglePref(key)}
onCheckedChange={() => togglePref(key)} aria-label={t(`settings.notifications.keys.${key}.label`, key)}
aria-label={t(`settings.notifications.keys.${key}.label`, key)} >
> <Switch.Thumb />
<Switch.Thumb /> </Switch>
</Switch> }
</XStack> />
</YGroup.Item>
))} ))}
</YStack> </YGroup>
)} )}
<XStack space="$2"> <XStack space="$2">
<CTAButton label={saving ? t('common.processing', '...') : t('settings.notifications.actions.save', 'Speichern')} onPress={() => handleSave()} /> <CTAButton label={saving ? t('common.processing', '...') : t('settings.notifications.actions.save', 'Speichern')} onPress={() => handleSave()} />
@@ -173,12 +180,12 @@ export default function MobileSettingsPage() {
<MobileCard space="$3"> <MobileCard space="$3">
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
<User size={18} color="#0f172a" /> <User size={18} color={text} />
<Text fontSize="$md" fontWeight="800" color="#0f172a"> <Text fontSize="$md" fontWeight="800" color={text}>
{t('settings.appearance.title', 'Darstellung')} {t('settings.appearance.title', 'Darstellung')}
</Text> </Text>
</XStack> </XStack>
<Text fontSize="$sm" color="#4b5563"> <Text fontSize="$sm" color={muted}>
{t('settings.appearance.description', 'Schalte Dark-Mode oder passe Branding im Admin an.')} {t('settings.appearance.description', 'Schalte Dark-Mode oder passe Branding im Admin an.')}
</Text> </Text>
<CTAButton label={t('settings.appearance.title', 'Darstellung & Branding')} tone="ghost" onPress={() => navigate(adminPath('/settings'))} /> <CTAButton label={t('settings.appearance.title', 'Darstellung & Branding')} tone="ghost" onPress={() => navigate(adminPath('/settings'))} />

View File

@@ -5,14 +5,18 @@ import { Pressable } from '@tamagui/react-native-web-lite';
import { Home, CheckSquare, Image as ImageIcon, User } from 'lucide-react'; import { Home, CheckSquare, Image as ImageIcon, User } from 'lucide-react';
import { useTheme } from '@tamagui/core'; import { useTheme } from '@tamagui/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { withAlpha } from './colors';
const ICON_SIZE = 18; const ICON_SIZE = 20;
export type NavKey = 'home' | 'tasks' | 'uploads' | 'profile'; export type NavKey = 'home' | 'tasks' | 'uploads' | 'profile';
export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate: (key: NavKey) => void }) { export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate: (key: NavKey) => void }) {
const { t } = useTranslation('mobile'); const { t } = useTranslation('mobile');
const theme = useTheme(); const theme = useTheme();
const surfaceColor = String(theme.surface?.val ?? 'white');
const navSurface = withAlpha(surfaceColor, 0.92);
const [pressedKey, setPressedKey] = React.useState<NavKey | null>(null);
const items: Array<{ key: NavKey; icon: React.ComponentType<{ size?: number; color?: string }>; label: string }> = [ const items: Array<{ key: NavKey; icon: React.ComponentType<{ size?: number; color?: string }>; label: string }> = [
{ key: 'home', icon: Home, label: t('nav.home', 'Home') }, { key: 'home', icon: Home, label: t('nav.home', 'Home') },
{ key: 'tasks', icon: CheckSquare, label: t('nav.tasks', 'Tasks') }, { key: 'tasks', icon: CheckSquare, label: t('nav.tasks', 'Tasks') },
@@ -21,30 +25,41 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
]; ];
return ( return (
<YStack <YStack
position="fixed" position="fixed"
bottom={0} bottom={0}
left={0} left={0}
right={0} right={0}
backgroundColor={String(theme.surface?.val ?? 'white')} backgroundColor={navSurface}
borderTopWidth={1} borderTopWidth={1}
borderColor={String(theme.borderColor?.val ?? '#e5e7eb')} borderColor={String(theme.borderColor?.val ?? '#e5e7eb')}
paddingVertical="$2" paddingVertical="$2"
paddingHorizontal="$4" paddingHorizontal="$4"
zIndex={50} zIndex={50}
shadowColor="#0f172a" shadowColor="#0f172a"
shadowOpacity={0.08} shadowOpacity={0.08}
shadowRadius={12} shadowRadius={12}
shadowOffset={{ width: 0, height: -4 }} shadowOffset={{ width: 0, height: -4 }}
// allow for safe-area inset on modern phones // allow for safe-area inset on modern phones
style={{ paddingBottom: 'max(env(safe-area-inset-bottom, 0px), 8px)' }} style={{
paddingBottom: 'max(env(safe-area-inset-bottom, 0px), 8px)',
backdropFilter: 'blur(14px)',
WebkitBackdropFilter: 'blur(14px)',
}}
> >
<XStack justifyContent="space-between" alignItems="center"> <XStack justifyContent="space-between" alignItems="center">
{items.map((item) => { {items.map((item) => {
const activeState = item.key === active; const activeState = item.key === active;
const isPressed = pressedKey === item.key;
const IconCmp = item.icon; const IconCmp = item.icon;
return ( return (
<Pressable key={item.key} onPress={() => onNavigate(item.key)}> <Pressable
key={item.key}
onPress={() => onNavigate(item.key)}
onPressIn={() => setPressedKey(item.key)}
onPressOut={() => setPressedKey(null)}
onPointerLeave={() => setPressedKey(null)}
>
<YStack <YStack
flexGrow={1} flexGrow={1}
flexBasis="0%" flexBasis="0%"
@@ -59,7 +74,22 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
borderRadius={12} borderRadius={12}
backgroundColor={activeState ? '#e8f1ff' : 'transparent'} backgroundColor={activeState ? '#e8f1ff' : 'transparent'}
gap="$1" gap="$1"
style={{
transform: isPressed ? 'scale(0.96)' : 'scale(1)',
opacity: isPressed ? 0.9 : 1,
transition: 'transform 140ms ease, background-color 140ms ease, opacity 140ms ease',
}}
> >
{activeState ? (
<YStack
position="absolute"
top={6}
width={28}
height={3}
borderRadius={999}
backgroundColor={String(theme.primary?.val ?? '#007AFF')}
/>
) : null}
<YStack width={ICON_SIZE} height={ICON_SIZE} alignItems="center" justifyContent="center" shrink={0}> <YStack width={ICON_SIZE} height={ICON_SIZE} alignItems="center" justifyContent="center" shrink={0}>
<IconCmp size={ICON_SIZE} color={activeState ? String(theme.primary?.val ?? '#007AFF') : '#94a3b8'} /> <IconCmp size={ICON_SIZE} color={activeState ? String(theme.primary?.val ?? '#007AFF') : '#94a3b8'} />
</YStack> </YStack>

View File

@@ -0,0 +1,193 @@
import React from 'react';
import { ChevronDown } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { useTheme } from '@tamagui/core';
import { withAlpha } from './colors';
type FieldProps = {
label: string;
hint?: string;
error?: string | null;
children: React.ReactNode;
};
export function MobileField({ label, hint, error, children }: FieldProps) {
const theme = useTheme();
const labelColor = String(theme.color?.val ?? '#111827');
const hintColor = String(theme.gray?.val ?? '#6b7280');
const errorColor = String(theme.red10?.val ?? '#b91c1c');
return (
<YStack space="$1.5">
<Text fontSize="$sm" fontWeight="800" color={labelColor}>
{label}
</Text>
{children}
{hint ? (
<Text fontSize="$xs" color={hintColor}>
{hint}
</Text>
) : null}
{error ? (
<Text fontSize="$xs" color={errorColor}>
{error}
</Text>
) : null}
</YStack>
);
}
type ControlProps = {
hasError?: boolean;
compact?: boolean;
};
export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<'input'> & ControlProps>(
function MobileInput({ hasError = false, compact = false, style, ...props }, ref) {
const theme = useTheme();
const [focused, setFocused] = React.useState(false);
const border = String(theme.borderColor?.val ?? '#e5e7eb');
const surface = String(theme.surface?.val ?? '#ffffff');
const text = String(theme.color?.val ?? '#111827');
const primary = String(theme.primary?.val ?? '#2563eb');
const danger = String(theme.red10?.val ?? '#b91c1c');
const height = compact ? 36 : 44;
const borderColor = hasError ? danger : focused ? primary : border;
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
return (
<input
ref={ref}
{...props}
onFocus={(event) => {
setFocused(true);
props.onFocus?.(event);
}}
onBlur={(event) => {
setFocused(false);
props.onBlur?.(event);
}}
style={{
width: '100%',
height,
borderRadius: 12,
border: `1px solid ${borderColor}`,
padding: '0 12px',
fontSize: compact ? 13 : 14,
background: surface,
color: text,
outline: 'none',
boxShadow: focused || hasError ? `0 0 0 3px ${ringColor}` : 'none',
transition: 'border-color 150ms ease, box-shadow 150ms ease',
...style,
}}
/>
);
},
);
export const MobileTextArea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentPropsWithoutRef<'textarea'> & ControlProps
>(function MobileTextArea({ hasError = false, compact = false, style, ...props }, ref) {
const theme = useTheme();
const [focused, setFocused] = React.useState(false);
const border = String(theme.borderColor?.val ?? '#e5e7eb');
const surface = String(theme.surface?.val ?? '#ffffff');
const text = String(theme.color?.val ?? '#111827');
const primary = String(theme.primary?.val ?? '#2563eb');
const danger = String(theme.red10?.val ?? '#b91c1c');
const borderColor = hasError ? danger : focused ? primary : border;
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
return (
<textarea
ref={ref}
{...props}
onFocus={(event) => {
setFocused(true);
props.onFocus?.(event);
}}
onBlur={(event) => {
setFocused(false);
props.onBlur?.(event);
}}
style={{
width: '100%',
borderRadius: 12,
border: `1px solid ${borderColor}`,
padding: '10px 12px',
fontSize: compact ? 13 : 14,
background: surface,
color: text,
outline: 'none',
minHeight: compact ? 72 : 96,
boxShadow: focused || hasError ? `0 0 0 3px ${ringColor}` : 'none',
transition: 'border-color 150ms ease, box-shadow 150ms ease',
resize: 'vertical',
...style,
}}
/>
);
});
export function MobileSelect({
children,
hasError = false,
compact = false,
style,
...props
}: React.ComponentPropsWithoutRef<'select'> & ControlProps) {
const theme = useTheme();
const [focused, setFocused] = React.useState(false);
const border = String(theme.borderColor?.val ?? '#e5e7eb');
const surface = String(theme.surface?.val ?? '#ffffff');
const text = String(theme.color?.val ?? '#111827');
const primary = String(theme.primary?.val ?? '#2563eb');
const danger = String(theme.red10?.val ?? '#b91c1c');
const muted = String(theme.gray?.val ?? '#94a3b8');
const height = compact ? 36 : 44;
const borderColor = hasError ? danger : focused ? primary : border;
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
return (
<XStack position="relative" alignItems="center">
<select
{...props}
onFocus={(event) => {
setFocused(true);
props.onFocus?.(event);
}}
onBlur={(event) => {
setFocused(false);
props.onBlur?.(event);
}}
style={{
width: '100%',
height,
borderRadius: 12,
border: `1px solid ${borderColor}`,
padding: '0 36px 0 12px',
fontSize: compact ? 13 : 14,
background: surface,
color: text,
outline: 'none',
appearance: 'none',
WebkitAppearance: 'none',
boxShadow: focused || hasError ? `0 0 0 3px ${ringColor}` : 'none',
transition: 'border-color 150ms ease, box-shadow 150ms ease',
...style,
}}
>
{children}
</select>
<XStack position="absolute" right={12} pointerEvents="none">
<ChevronDown size={16} color={muted} />
</XStack>
</XStack>
);
}

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { Outlet, useLocation, useNavigationType } from 'react-router-dom';
export default function MobileAnimatedOutlet() {
const location = useLocation();
const navigationType = useNavigationType();
const reduceMotion = useReducedMotion();
const direction = navigationType === 'POP' ? -1 : 1;
const variants = reduceMotion
? {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
}
: {
initial: { opacity: 0, x: 16 * direction },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -16 * direction },
};
return (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={location.key}
initial="initial"
animate="animate"
exit="exit"
variants={variants}
transition={{ duration: 0.22, ease: 'easeOut' }}
style={{ height: '100%' }}
>
<Outlet />
</motion.div>
</AnimatePresence>
);
}

View File

@@ -13,8 +13,10 @@ import { adminPath } from '../../constants';
import { MobileSheet } from './Sheet'; import { MobileSheet } from './Sheet';
import { MobileCard, PillBadge } from './Primitives'; import { MobileCard, PillBadge } from './Primitives';
import { useNotificationsBadge } from '../hooks/useNotificationsBadge'; import { useNotificationsBadge } from '../hooks/useNotificationsBadge';
import { useOnlineStatus } from '../hooks/useOnlineStatus';
import { formatEventDate, resolveEventDisplayName } from '../../lib/events'; import { formatEventDate, resolveEventDisplayName } from '../../lib/events';
import { TenantEvent, getEvents } from '../../api'; import { TenantEvent, getEvents } from '../../api';
import { withAlpha } from './colors';
type MobileShellProps = { type MobileShellProps = {
title?: string; title?: string;
@@ -31,12 +33,16 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
const navigate = useNavigate(); const navigate = useNavigate();
const { t, i18n } = useTranslation('mobile'); const { t, i18n } = useTranslation('mobile');
const { count: notificationCount } = useNotificationsBadge(); const { count: notificationCount } = useNotificationsBadge();
const online = useOnlineStatus();
const theme = useTheme(); const theme = useTheme();
const backgroundColor = String(theme.background?.val ?? '#f7f8fb'); const backgroundColor = String(theme.background?.val ?? '#f7f8fb');
const surfaceColor = String(theme.surface?.val ?? '#ffffff'); const surfaceColor = String(theme.surface?.val ?? '#ffffff');
const borderColor = String(theme.borderColor?.val ?? '#e5e7eb'); const borderColor = String(theme.borderColor?.val ?? '#e5e7eb');
const textColor = String(theme.color?.val ?? '#111827'); const textColor = String(theme.color?.val ?? '#111827');
const mutedText = String(theme.gray?.val ?? '#6b7280'); const mutedText = String(theme.gray?.val ?? '#6b7280');
const warningBg = String(theme.yellow3?.val ?? '#fef3c7');
const warningText = String(theme.yellow11?.val ?? '#92400e');
const headerSurface = withAlpha(surfaceColor, 0.94);
const [pickerOpen, setPickerOpen] = React.useState(false); const [pickerOpen, setPickerOpen] = React.useState(false);
const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]); const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
const [loadingEvents, setLoadingEvents] = React.useState(false); const [loadingEvents, setLoadingEvents] = React.useState(false);
@@ -90,7 +96,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
return ( return (
<YStack backgroundColor={backgroundColor} minHeight="100vh" alignItems="center"> <YStack backgroundColor={backgroundColor} minHeight="100vh" alignItems="center">
<YStack <YStack
backgroundColor={surfaceColor} backgroundColor={headerSurface}
borderBottomWidth={1} borderBottomWidth={1}
borderColor={borderColor} borderColor={borderColor}
paddingHorizontal="$4" paddingHorizontal="$4"
@@ -102,14 +108,22 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
shadowOffset={{ width: 0, height: 4 }} shadowOffset={{ width: 0, height: 4 }}
width="100%" width="100%"
maxWidth={800} maxWidth={800}
position="sticky"
top={0}
zIndex={60}
style={{
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
}}
> >
<XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$3"> <XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$3">
{onBack ? ( {onBack ? (
<Pressable onPress={onBack}> <HeaderActionButton onPress={onBack} ariaLabel={t('actions.back', 'Back')}>
<XStack alignItems="center" space="$1.5"> <XStack alignItems="center" space="$1.5">
<ChevronLeft size={28} color="#007AFF" strokeWidth={2.5} /> <ChevronLeft size={28} color="#007AFF" strokeWidth={2.5} />
</XStack> </XStack>
</Pressable> </HeaderActionButton>
) : ( ) : (
<XStack width={28} /> <XStack width={28} />
)} )}
@@ -134,7 +148,10 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
</XStack> </XStack>
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
<Pressable onPress={() => navigate(adminPath('/mobile/notifications'))}> <HeaderActionButton
onPress={() => navigate(adminPath('/mobile/notifications'))}
ariaLabel={t('mobile.notifications', 'Notifications')}
>
<XStack <XStack
width={34} width={34}
height={34} height={34}
@@ -164,9 +181,12 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
</YStack> </YStack>
) : null} ) : null}
</XStack> </XStack>
</Pressable> </HeaderActionButton>
{showQr ? ( {showQr ? (
<Pressable onPress={() => navigate(adminPath(`/mobile/events/${effectiveActive?.slug}/qr`))}> <HeaderActionButton
onPress={() => navigate(adminPath(`/mobile/events/${effectiveActive?.slug}/qr`))}
ariaLabel={t('header.quickQr', 'Quick QR')}
>
<XStack <XStack
height={34} height={34}
paddingHorizontal="$3" paddingHorizontal="$3"
@@ -181,7 +201,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
{t('header.quickQr', 'Quick QR')} {t('header.quickQr', 'Quick QR')}
</Text> </Text>
</XStack> </XStack>
</Pressable> </HeaderActionButton>
) : null} ) : null}
{headerActions ?? null} {headerActions ?? null}
</XStack> </XStack>
@@ -189,7 +209,29 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
</XStack> </XStack>
</YStack> </YStack>
<YStack flex={1} padding="$4" paddingBottom="$10" space="$3" width="100%" maxWidth={800}> <YStack
flex={1}
padding="$4"
paddingBottom="$10"
space="$3"
width="100%"
maxWidth={800}
style={{ paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 96px)' }}
>
{!online ? (
<XStack
alignItems="center"
justifyContent="center"
borderRadius={12}
backgroundColor={warningBg}
paddingVertical="$2"
paddingHorizontal="$3"
>
<Text fontSize="$xs" fontWeight="700" color={warningText}>
{t('mobile.offline', 'Offline mode: changes will sync when you are back online.')}
</Text>
</XStack>
) : null}
{children} {children}
</YStack> </YStack>
@@ -268,6 +310,34 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
); );
} }
export function HeaderActionButton({
onPress,
children,
ariaLabel,
}: {
onPress: () => void;
children: React.ReactNode;
ariaLabel?: string;
}) {
const [pressed, setPressed] = React.useState(false);
return (
<Pressable
onPress={onPress}
onPressIn={() => setPressed(true)}
onPressOut={() => setPressed(false)}
onPointerLeave={() => setPressed(false)}
aria-label={ariaLabel}
style={{
transform: pressed ? 'scale(0.96)' : 'scale(1)',
opacity: pressed ? 0.86 : 1,
transition: 'transform 120ms ease, opacity 120ms ease',
}}
>
{children}
</Pressable>
);
}
export function renderEventLocation(event?: TenantEvent | null): string { export function renderEventLocation(event?: TenantEvent | null): string {
if (!event) return 'Location'; if (!event) return 'Location';
const settings = (event.settings ?? {}) as Record<string, unknown>; const settings = (event.settings ?? {}) as Record<string, unknown>;

View File

@@ -147,6 +147,12 @@ export function KpiTile({
); );
} }
export function SkeletonCard({ height = 80 }: { height?: number }) {
return (
<MobileCard className="mobile-skeleton" height={height} />
);
}
export function ActionTile({ export function ActionTile({
icon: IconCmp, icon: IconCmp,
label, label,
@@ -189,3 +195,53 @@ export function ActionTile({
</Pressable> </Pressable>
); );
} }
export function FloatingActionButton({
onPress,
label,
icon: IconCmp,
}: {
onPress: () => void;
label: string;
icon: React.ComponentType<{ size?: number; color?: string }>;
}) {
const theme = useTheme();
const [pressed, setPressed] = React.useState(false);
return (
<Pressable
onPress={onPress}
onPressIn={() => setPressed(true)}
onPressOut={() => setPressed(false)}
onPointerLeave={() => setPressed(false)}
style={{
position: 'fixed',
right: 18,
bottom: 'calc(env(safe-area-inset-bottom, 0px) + 96px)',
zIndex: 60,
transform: pressed ? 'scale(0.96)' : 'scale(1)',
opacity: pressed ? 0.92 : 1,
transition: 'transform 140ms ease, opacity 140ms ease',
}}
aria-label={label}
>
<XStack
height={56}
paddingHorizontal="$4"
borderRadius={999}
alignItems="center"
justifyContent="center"
space="$2"
backgroundColor={String(theme.primary?.val ?? '#007AFF')}
shadowColor="#0f172a"
shadowOpacity={0.2}
shadowRadius={16}
shadowOffset={{ width: 0, height: 8 }}
>
<IconCmp size={18} color="white" />
<Text fontSize="$sm" fontWeight="800" color="white">
{label}
</Text>
</XStack>
</Pressable>
);
}

View File

@@ -5,6 +5,7 @@ import { Pressable } from '@tamagui/react-native-web-lite';
import { ChevronLeft } from 'lucide-react'; import { ChevronLeft } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useTheme } from '@tamagui/core'; import { useTheme } from '@tamagui/core';
import { withAlpha } from './colors';
type MobileScaffoldProps = { type MobileScaffoldProps = {
title: string; title: string;
@@ -21,6 +22,7 @@ export function MobileScaffold({ title, onBack, rightSlot, children, footer }: M
const surface = String(theme.surface?.val ?? '#ffffff'); const surface = String(theme.surface?.val ?? '#ffffff');
const border = String(theme.borderColor?.val ?? '#e5e7eb'); const border = String(theme.borderColor?.val ?? '#e5e7eb');
const textColor = String(theme.color?.val ?? '#111827'); const textColor = String(theme.color?.val ?? '#111827');
const headerSurface = withAlpha(surface, 0.94);
return ( return (
<YStack backgroundColor={background} minHeight="100vh"> <YStack backgroundColor={background} minHeight="100vh">
@@ -30,9 +32,17 @@ export function MobileScaffold({ title, onBack, rightSlot, children, footer }: M
paddingHorizontal="$4" paddingHorizontal="$4"
paddingTop="$4" paddingTop="$4"
paddingBottom="$3" paddingBottom="$3"
backgroundColor={surface} backgroundColor={headerSurface}
borderBottomWidth={1} borderBottomWidth={1}
borderColor={border} borderColor={border}
position="sticky"
top={0}
zIndex={60}
style={{
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
}}
> >
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
{onBack ? ( {onBack ? (

View File

@@ -0,0 +1,25 @@
export function withAlpha(color: string, alpha: number): string {
const trimmed = color.trim();
if (trimmed.startsWith('#')) {
const hex = trimmed.slice(1);
const normalized = hex.length === 3 ? hex.split('').map((ch) => ch + ch).join('') : hex;
if (normalized.length === 6) {
const r = Number.parseInt(normalized.slice(0, 2), 16);
const g = Number.parseInt(normalized.slice(2, 4), 16);
const b = Number.parseInt(normalized.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
}
const rgb = trimmed.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/i);
if (rgb) {
return `rgba(${rgb[1]}, ${rgb[2]}, ${rgb[3]}, ${alpha})`;
}
const rgba = trimmed.match(/^rgba\((\d+),\s*(\d+),\s*(\d+),\s*([0-9.]+)\)$/i);
if (rgba) {
return `rgba(${rgba[1]}, ${rgba[2]}, ${rgba[3]}, ${alpha})`;
}
return color;
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
export function useOnlineStatus() {
const [online, setOnline] = React.useState(() => (typeof navigator !== 'undefined' ? navigator.onLine : true));
React.useEffect(() => {
const handleOnline = () => setOnline(true);
const handleOffline = () => setOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return online;
}

View File

@@ -0,0 +1,22 @@
export function prefetchMobileRoutes() {
if (typeof window === 'undefined') return;
const schedule = (callback: () => void) => {
if ('requestIdleCallback' in window) {
(window as Window & { requestIdleCallback: (cb: () => void) => number }).requestIdleCallback(callback);
return;
}
window.setTimeout(callback, 1200);
};
schedule(() => {
void import('./DashboardPage');
void import('./EventsPage');
void import('./EventDetailPage');
void import('./EventPhotosPage');
void import('./EventTasksPage');
void import('./NotificationsPage');
void import('./ProfilePage');
void import('./SettingsPage');
});
}

View File

@@ -33,6 +33,7 @@ const MobileLoginPage = React.lazy(() => import('./mobile/LoginPage'));
const MobileDashboardPage = React.lazy(() => import('./mobile/DashboardPage')); const MobileDashboardPage = React.lazy(() => import('./mobile/DashboardPage'));
const MobileTasksTabPage = React.lazy(() => import('./mobile/TasksTabPage')); const MobileTasksTabPage = React.lazy(() => import('./mobile/TasksTabPage'));
const MobileUploadsTabPage = React.lazy(() => import('./mobile/UploadsTabPage')); const MobileUploadsTabPage = React.lazy(() => import('./mobile/UploadsTabPage'));
const MobileAnimatedOutlet = React.lazy(() => import('./mobile/components/MobileAnimatedOutlet'));
function RequireAuth() { function RequireAuth() {
const { status } = useAuth(); const { status } = useAuth();
@@ -50,7 +51,11 @@ function RequireAuth() {
return <Navigate to={ADMIN_LOGIN_START_PATH} state={{ from: location }} replace />; return <Navigate to={ADMIN_LOGIN_START_PATH} state={{ from: location }} replace />;
} }
return <Outlet />; return (
<React.Suspense fallback={<Outlet />}>
<MobileAnimatedOutlet />
</React.Suspense>
);
} }
function LandingGate() { function LandingGate() {

View File

@@ -2,7 +2,7 @@
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}"> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ __('admin.shell.tenant_admin_title') }}</title> <title>{{ __('admin.shell.tenant_admin_title') }}</title>
<link rel="icon" href="{{ asset('favicon.ico') }}" type="image/x-icon"> <link rel="icon" href="{{ asset('favicon.ico') }}" type="image/x-icon">

View File

@@ -27,6 +27,7 @@ class TenantAdminEntryTest extends TestCase
$response->assertOk(); $response->assertOk();
$response->assertViewIs('admin'); $response->assertViewIs('admin');
$response->assertSee('viewport-fit=cover', false);
} }
public function test_regular_user_is_redirected_to_packages(): void public function test_regular_user_is_redirected_to_packages(): void