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

@@ -511,6 +511,27 @@ h4,
--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 {
* {
@apply border-border;

View File

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

View File

@@ -10,6 +10,10 @@ export default function AuthCallbackPage(): React.ReactElement {
const navigate = useNavigate();
const { t } = useTranslation('auth');
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 rawReturnTo = searchParams.get('return_to');
@@ -36,7 +40,10 @@ export default function AuthCallbackPage(): React.ReactElement {
}, [destination, navigate, redirected, status]);
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>
<p className="max-w-xs text-xs text-muted-foreground">{t('processing.copy', 'One moment please while we prepare your dashboard.')}</p>
</div>

View File

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

View File

@@ -5,8 +5,8 @@ import { Image as ImageIcon, RefreshCcw, UploadCloud, Trash2, ChevronDown, Save,
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
import { TenantEvent, getEvent, updateEvent, getTenantFonts, TenantFont, WatermarkSettings } from '../api';
import { isAuthError } from '../auth/tokens';
import { ApiError, getApiErrorMessage } from '../lib/apiError';
@@ -403,9 +403,9 @@ export default function MobileBrandingPage() {
title={t('events.branding.titleShort', 'Branding')}
onBack={() => navigate(-1)}
headerActions={
<Pressable disabled={saving} onPress={() => handleSave()}>
<HeaderActionButton onPress={() => handleSave()} ariaLabel={t('common.save', 'Save')}>
<Save size={18} color="#007AFF" />
</Pressable>
</HeaderActionButton>
}
>
{error ? (
@@ -618,7 +618,7 @@ export default function MobileBrandingPage() {
>
<YStack space="$2">
{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 ? (
<Text fontSize="$sm" color="#4b5563">
{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 { Pressable } from '@tamagui/react-native-web-lite';
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 { useEventContext } from '../context/EventContext';
import { getEventStats, EventStats, TenantEvent, getEvents } from '../api';
@@ -73,13 +73,13 @@ export default function MobileDashboardPage() {
if (isLoading || fallbackLoading) {
return (
<MobileShell activeTab="home" title={t('mobileDashboard.title', 'Dashboard')}>
<YStack space="$2">
{Array.from({ length: 3 }).map((_, idx) => (
<MobileCard key={`sk-${idx}`} height={110} opacity={0.6} />
))}
</YStack>
</MobileShell>
);
<YStack space="$2">
{Array.from({ length: 3 }).map((_, idx) => (
<SkeletonCard key={`sk-${idx}`} height={110} />
))}
</YStack>
</MobileShell>
);
}
if (!effectiveHasEvents) {

View File

@@ -5,7 +5,7 @@ import { CalendarDays, MapPin, Settings, Users, Camera, Sparkles, QrCode, Image,
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
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 { 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';
@@ -102,12 +102,12 @@ export default function MobileEventDetailPage() {
onBack={() => navigate(-1)}
headerActions={
<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" />
</Pressable>
<Pressable onPress={() => navigate(0)}>
</HeaderActionButton>
<HeaderActionButton onPress={() => navigate(0)} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color="#0f172a" />
</Pressable>
</HeaderActionButton>
</XStack>
}
>

View File

@@ -7,6 +7,7 @@ import { SizableText as Text } from '@tamagui/text';
import { Switch } from '@tamagui/switch';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
import { LegalConsentSheet } from './components/LegalConsentSheet';
import { createEvent, getEvent, updateEvent, getEventTypes, TenantEvent, TenantEventType } from '../api';
import { resolveEventSlugAfterUpdate } from './eventFormNavigation';
@@ -206,38 +207,36 @@ export default function MobileEventFormPage() {
) : null}
<MobileCard space="$3">
<Field label={t('eventForm.fields.name.label', 'Event name')}>
<input
<MobileField label={t('eventForm.fields.name.label', 'Event name')}>
<MobileInput
type="text"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
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">
<input
<MobileInput
type="datetime-local"
value={form.date}
onChange={(e) => setForm((prev) => ({ ...prev, date: e.target.value }))}
style={{ ...inputStyle, flex: 1 }}
style={{ flex: 1 }}
/>
<CalendarDays size={16} color="#9ca3af" />
</XStack>
</Field>
</MobileField>
<Field label={t('eventForm.fields.type.label', 'Event type')}>
<MobileField label={t('eventForm.fields.type.label', 'Event type')}>
{typesLoading ? (
<Text fontSize="$sm" color="#6b7280">{t('eventForm.fields.type.loading', 'Loading event types…')}</Text>
) : 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>
) : (
<select
<MobileSelect
value={form.eventTypeId ?? ''}
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>
{eventTypes.map((type) => (
@@ -245,33 +244,32 @@ export default function MobileEventFormPage() {
{renderName(type.name as any) || type.slug}
</option>
))}
</select>
</MobileSelect>
)}
</Field>
</MobileField>
<Field label={t('eventForm.fields.description.label', 'Optional details')}>
<textarea
<MobileField label={t('eventForm.fields.description.label', 'Optional details')}>
<MobileTextArea
value={form.description}
onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
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">
<input
<MobileInput
type="text"
value={form.location}
onChange={(e) => setForm((prev) => ({ ...prev, location: e.target.value }))}
placeholder={t('eventForm.fields.location.placeholder', 'Location')}
style={{ ...inputStyle, flex: 1 }}
style={{ flex: 1 }}
/>
<MapPin size={16} color="#9ca3af" />
</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">
<Switch
checked={form.published}
@@ -288,9 +286,9 @@ export default function MobileEventFormPage() {
</Text>
</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>
</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">
<Switch
checked={form.tasksEnabled}
@@ -319,9 +317,9 @@ export default function MobileEventFormPage() {
'Task mode is off: guests only see the photo feed.',
)}
</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">
<Switch
checked={form.autoApproveUploads}
@@ -350,7 +348,7 @@ export default function MobileEventFormPage() {
'Uploads werden zunächst geprüft und erscheinen nach Freigabe.',
)}
</Text>
</Field>
</MobileField>
</MobileCard>
<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 {
if (typeof name === 'string') return name;
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 { RefreshCcw, Users, User } from 'lucide-react';
import toast from 'react-hot-toast';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
import { useEventContext } from '../context/EventContext';
import {
GuestNotificationSummary,
@@ -58,21 +59,6 @@ export default function MobileEventGuestNotificationsPage() {
priority: '1',
});
const inputStyle = React.useMemo<React.CSSProperties>(() => {
const border = String(theme.borderColor?.val ?? '#e5e7eb');
const surface = String(theme.surface?.val ?? 'white');
const text = String(theme.color?.val ?? '#111827');
return {
width: '100%',
borderRadius: 10,
border: `1px solid ${border}`,
padding: '10px 12px',
fontSize: 13,
background: surface,
color: text,
};
}, [theme]);
React.useEffect(() => {
if (slugParam && activeEvent?.slug !== slugParam) {
selectEvent(slugParam);
@@ -201,9 +187,9 @@ export default function MobileEventGuestNotificationsPage() {
subtitle={t('guestMessages.subtitle', 'Send push messages to guests')}
onBack={() => navigate(-1)}
headerActions={
<Pressable onPress={() => loadHistory()}>
<HeaderActionButton onPress={() => loadHistory()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color={String(theme.color?.val ?? '#111827')} />
</Pressable>
</HeaderActionButton>
}
>
{error ? (
@@ -219,90 +205,82 @@ export default function MobileEventGuestNotificationsPage() {
{t('guestMessages.composeTitle', 'Send a message')}
</Text>
<YStack space="$2">
<Field label={t('guestMessages.form.title', 'Title')}>
<input
<MobileField label={t('guestMessages.form.title', 'Title')}>
<MobileInput
type="text"
value={form.title}
onChange={(e) => setForm((prev) => ({ ...prev, title: e.target.value }))}
placeholder={t('guestMessages.form.titlePlaceholder', 'Gallery reminder, upload nudge, ...')}
style={{ ...inputStyle, height: 40 }}
/>
</Field>
<Field label={t('guestMessages.form.message', 'Message')}>
<textarea
</MobileField>
<MobileField label={t('guestMessages.form.message', 'Message')}>
<MobileTextArea
value={form.message}
onChange={(e) => setForm((prev) => ({ ...prev, message: e.target.value }))}
placeholder={t('guestMessages.form.messagePlaceholder', 'Write a short note for your guests.')}
style={{ ...inputStyle, minHeight: 96, resize: 'vertical' }}
/>
</Field>
<Field label={t('guestMessages.form.audience', 'Audience')}>
<select
</MobileField>
<MobileField label={t('guestMessages.form.audience', 'Audience')}>
<MobileSelect
value={form.audience}
onChange={(e) => setForm((prev) => ({ ...prev, audience: e.target.value as FormState['audience'] }))}
style={{ ...inputStyle, height: 42 }}
>
<option value="all">{t('guestMessages.form.audienceAll', 'All guests')}</option>
<option value="guest">{t('guestMessages.form.audienceGuest', 'Specific guest (name or device)')}</option>
</select>
</Field>
</MobileSelect>
</MobileField>
{form.audience === 'guest' ? (
<Field label={t('guestMessages.form.guestIdentifier', 'Guest name or device ID')}>
<input
<MobileField label={t('guestMessages.form.guestIdentifier', 'Guest name or device ID')}>
<MobileInput
type="text"
value={form.guest_identifier}
onChange={(e) => setForm((prev) => ({ ...prev, guest_identifier: e.target.value }))}
placeholder={t('guestMessages.form.guestPlaceholder', 'e.g., Alex or device token')}
style={{ ...inputStyle, height: 40 }}
/>
</Field>
</MobileField>
) : null}
<Field label={t('guestMessages.form.cta', 'CTA (optional)')}>
<MobileField label={t('guestMessages.form.cta', 'CTA (optional)')}>
<YStack space="$1.5">
<input
<MobileInput
type="text"
value={form.cta_label}
onChange={(e) => setForm((prev) => ({ ...prev, cta_label: e.target.value }))}
placeholder={t('guestMessages.form.ctaLabel', 'Button label')}
style={{ ...inputStyle, height: 40 }}
/>
<input
<MobileInput
type="url"
value={form.cta_url}
onChange={(e) => setForm((prev) => ({ ...prev, cta_url: e.target.value }))}
placeholder={t('guestMessages.form.ctaUrl', 'https://your-link.com')}
style={{ ...inputStyle, height: 40 }}
/>
<Text fontSize="$xs" color={mutedText}>
{t('guestMessages.form.ctaHint', 'Both fields are required to add a button.')}
</Text>
</YStack>
</Field>
</MobileField>
<XStack space="$2">
<Field label={t('guestMessages.form.expiresIn', 'Expires in (minutes)')}>
<input
<MobileField label={t('guestMessages.form.expiresIn', 'Expires in (minutes)')}>
<MobileInput
type="number"
min={5}
max={2880}
value={form.expires_in_minutes}
onChange={(e) => setForm((prev) => ({ ...prev, expires_in_minutes: e.target.value }))}
placeholder="60"
style={{ ...inputStyle, height: 40 }}
/>
</Field>
<Field label={t('guestMessages.form.priority', 'Priority')}>
<select
</MobileField>
<MobileField label={t('guestMessages.form.priority', 'Priority')}>
<MobileSelect
value={form.priority}
onChange={(e) => setForm((prev) => ({ ...prev, priority: e.target.value }))}
style={{ ...inputStyle, height: 40 }}
>
{[0, 1, 2, 3, 4, 5].map((value) => (
<option key={value} value={value}>
{t('guestMessages.form.priorityValue', 'Priority {{value}}', { value })}
</option>
))}
</select>
</Field>
</MobileSelect>
</MobileField>
</XStack>
<CTAButton
label={sending ? t('common.processing', 'Processing…') : t('guestMessages.form.send', 'Send notification')}
@@ -331,7 +309,7 @@ export default function MobileEventGuestNotificationsPage() {
{loading ? (
<YStack space="$2">
{Array.from({ length: 3 }).map((_, idx) => (
<MobileCard key={`s-${idx}`} height={72} opacity={0.6} />
<SkeletonCard key={`s-${idx}`} height={72} />
))}
</YStack>
) : history.length === 0 ? (
@@ -397,14 +375,3 @@ export default function MobileEventGuestNotificationsPage() {
</MobileShell>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<YStack space="$1.5">
<Text fontSize="$sm" fontWeight="800" color="#111827">
{label}
</Text>
{children}
</YStack>
);
}

View File

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

View File

@@ -1,12 +1,14 @@
import React from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
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 { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell } from './components/MobileShell';
import { MobileCard, PillBadge, CTAButton } from './components/Primitives';
import { AnimatePresence, motion } from 'framer-motion';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, PillBadge, CTAButton, SkeletonCard } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect } from './components/FormControls';
import {
getEventPhotos,
updatePhotoVisibility,
@@ -56,6 +58,9 @@ export default function MobileEventPhotosPage() {
const [onlyFeatured, setOnlyFeatured] = React.useState(false);
const [onlyHidden, setOnlyHidden] = React.useState(false);
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 [catalogAddons, setCatalogAddons] = React.useState<EventAddonCatalogItem[]>([]);
const [eventAddons, setEventAddons] = React.useState<EventAddonSummary[]>([]);
@@ -73,19 +78,13 @@ export default function MobileEventPhotosPage() {
const surface = String(theme.surface?.val ?? '#ffffff');
const backdrop = String(theme.gray12?.val ?? '#0f172a');
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(() => {
if (lightbox) {
setSelectionMode(false);
setSelectedIds([]);
}
}, [lightbox]);
React.useEffect(() => {
if (slugParam && activeEvent?.slug !== 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) {
const scope =
scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests'
@@ -276,12 +344,26 @@ export default function MobileEventPhotosPage() {
onBack={() => navigate(-1)}
headerActions={
<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} />
</Pressable>
<Pressable onPress={() => load()}>
</HeaderActionButton>
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color={text} />
</Pressable>
</HeaderActionButton>
</XStack>
}
>
@@ -293,15 +375,16 @@ export default function MobileEventPhotosPage() {
</MobileCard>
) : null}
<input
<MobileInput
type="search"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder={t('photos.filters.search', 'Search uploads …')}
style={{ ...baseInputStyle, marginBottom: 12 }}
compact
style={{ marginBottom: 12 }}
/>
<XStack space="$2" flexWrap="wrap">
@@ -341,7 +424,7 @@ export default function MobileEventPhotosPage() {
{loading ? (
<YStack space="$2">
{Array.from({ length: 4 }).map((_, idx) => (
<MobileCard key={`ph-${idx}`} height={100} opacity={0.6} />
<SkeletonCard key={`ph-${idx}`} height={100} />
))}
</YStack>
) : photos.length === 0 ? (
@@ -363,24 +446,53 @@ export default function MobileEventPhotosPage() {
gap: 8,
}}
>
{photos.map((photo) => (
<Pressable key={photo.id} onPress={() => setLightbox(photo)}>
<YStack borderRadius={10} overflow="hidden" borderWidth={1} borderColor={border}>
<img
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>
{photos.map((photo) => {
const isSelected = selectedIds.includes(photo.id);
return (
<Pressable
key={photo.id}
onPress={() => (selectionMode ? toggleSelection(photo.id) : setLightbox(photo))}
>
<YStack
borderRadius={10}
overflow="hidden"
borderWidth={1}
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}
{photo.status === 'hidden' ? <PillBadge tone="muted">{t('photos.filters.hidden', 'Hidden')}</PillBadge> : null}
</XStack>
</YStack>
</Pressable>
))}
</YStack>
</Pressable>
);
})}
</div>
{hasMore ? (
<CTAButton label={t('common.loadMore', 'Mehr laden')} onPress={() => setPage((prev) => prev + 1)} />
@@ -388,75 +500,169 @@ export default function MobileEventPhotosPage() {
</YStack>
)}
{lightbox ? (
<div className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center">
<div
style={{
width: '100%',
maxWidth: 520,
margin: '0 16px',
background: surface,
borderRadius: 20,
overflow: 'hidden',
boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
}}
{selectionMode ? (
<YStack
position="fixed"
left={12}
right={12}
bottom="calc(env(safe-area-inset-bottom, 0px) + 96px)"
padding="$3"
borderRadius={18}
backgroundColor={surface}
borderWidth={1}
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
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' ? (
<motion.div
initial={{ y: 12, scale: 0.98, opacity: 0 }}
animate={{ y: 0, scale: 1, opacity: 1 }}
exit={{ y: 12, scale: 0.98, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
style={{
width: '100%',
maxWidth: 520,
margin: '0 16px',
background: surface,
borderRadius: 20,
overflow: 'hidden',
boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
}}
>
<motion.img
layoutId={`photo-${lightbox.id}`}
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
label={
busyId === lightbox.id
? 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 }}
/>
) : null}
<CTAButton
label={
busyId === lightbox.id
? t('common.processing', '...')
: lightbox.is_featured
? t('photos.actions.unfeature', 'Remove highlight')
: t('photos.actions.feature', 'Set highlight')
}
onPress={() => toggleFeature(lightbox)}
style={{ flex: 1, minWidth: 140 }}
/>
<CTAButton
label={
busyId === lightbox.id
? t('common.processing', '...')
: lightbox.status === 'hidden'
? t('photos.actions.show', 'Show')
: 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}
<CTAButton
label={
busyId === lightbox.id
? t('common.processing', '...')
: lightbox.status === 'hidden'
? t('photos.actions.show', 'Show')
: 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>
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
<MobileSheet
open={showFilters}
@@ -474,15 +680,15 @@ export default function MobileEventPhotosPage() {
}
>
<YStack space="$2">
<Field label={t('mobilePhotos.uploader', 'Uploader')} color={text}>
<input
<MobileField label={t('mobilePhotos.uploader', 'Uploader')}>
<MobileInput
type="text"
value={uploaderFilter}
onChange={(e) => setUploaderFilter(e.target.value)}
placeholder={t('mobilePhotos.uploaderPlaceholder', 'Name or email')}
style={baseInputStyle}
compact
/>
</Field>
</MobileField>
<XStack space="$2" alignItems="center">
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<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;
function translateLimits(t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string): LimitTranslator {
@@ -659,25 +854,13 @@ function MobileAddonsPicker({
return (
<XStack space="$2" alignItems="center">
<select
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',
}}
>
<MobileSelect value={selected} onChange={(event) => setSelected(event.target.value)} style={{ flex: 1 }} compact>
{options.map((addon) => (
<option key={addon.key} value={addon.key}>
{addon.label ?? addon.key}
</option>
))}
</select>
</MobileSelect>
<CTAButton
label={
scope === 'gallery'

View File

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

View File

@@ -6,8 +6,9 @@ import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { ListItem } from '@tamagui/list-item';
import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
import {
getEvent,
getEvents,
@@ -38,14 +39,6 @@ import { useEventContext } from '../context/EventContext';
import { useTheme } from '@tamagui/core';
import { RadioGroup } from '@tamagui/radio-group';
const inputBaseStyle = {
width: '100%',
height: 40,
borderRadius: 10,
padding: '0 12px',
fontSize: 13,
} as const;
function InlineSeparator() {
const theme = useTheme();
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 danger = String(theme.red10?.val ?? '#ef4444');
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 [library, setLibrary] = React.useState<TenantTask[]>([]);
const [collections, setCollections] = React.useState<TenantTaskCollection[]>([]);
@@ -375,9 +358,9 @@ export default function MobileEventTasksPage() {
onBack={() => navigate(-1)}
headerActions={
<XStack space="$2">
<Pressable onPress={() => load()}>
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color={text} />
</Pressable>
</HeaderActionButton>
</XStack>
}
>
@@ -392,7 +375,7 @@ export default function MobileEventTasksPage() {
{loading ? (
<YStack space="$2">
{Array.from({ length: 4 }).map((_, idx) => (
<MobileCard key={`tsk-${idx}`} height={70} opacity={0.6} />
<SkeletonCard key={`tsk-${idx}`} height={70} />
))}
</YStack>
) : assignedTasks.length === 0 ? (
@@ -474,12 +457,12 @@ export default function MobileEventTasksPage() {
) : (
<YStack space="$2">
<YStack space="$2">
<input
<MobileInput
type="search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={t('events.tasks.search', 'Search tasks')}
style={{ ...inputStyle, height: 38 }}
compact
/>
<Pressable onPress={() => setShowEmotionFilterSheet(true)}>
<MobileCard borderColor={border} backgroundColor={surface} space="$2">
@@ -667,28 +650,27 @@ export default function MobileEventTasksPage() {
}
>
<YStack space="$2">
<Field label={t('events.tasks.titleLabel', 'Titel')} color={text}>
<input
<MobileField label={t('events.tasks.titleLabel', 'Titel')}>
<MobileInput
type="text"
value={newTask.title}
onChange={(e) => setNewTask((prev) => ({ ...prev, title: e.target.value }))}
placeholder={t('events.tasks.titlePlaceholder', 'z.B. Erstes Gruppenfoto')}
style={inputStyle}
/>
</Field>
<Field label={t('events.tasks.description', 'Beschreibung')} color={text}>
<textarea
</MobileField>
<MobileField label={t('events.tasks.description', 'Beschreibung')}>
<MobileTextArea
value={newTask.description}
onChange={(e) => setNewTask((prev) => ({ ...prev, description: e.target.value }))}
placeholder={t('events.tasks.descriptionPlaceholder', 'Optionale Hinweise')}
style={{ ...inputStyle, minHeight: 80 }}
compact
style={{ minHeight: 80 }}
/>
</Field>
<Field label={t('events.tasks.emotion', 'Emotion')} color={text}>
<select
</MobileField>
<MobileField label={t('events.tasks.emotion', 'Emotion')}>
<MobileSelect
value={newTask.emotion_id}
onChange={(e) => setNewTask((prev) => ({ ...prev, emotion_id: e.target.value }))}
style={{ ...inputStyle, height: 42 }}
>
<option value="">{t('events.tasks.emotionNone', 'Keine')}</option>
{emotions.map((emotion) => (
@@ -696,8 +678,8 @@ export default function MobileEventTasksPage() {
{emotion.name}
</option>
))}
</select>
</Field>
</MobileSelect>
</MobileField>
</YStack>
</MobileSheet>
@@ -711,11 +693,11 @@ export default function MobileEventTasksPage() {
<Text fontSize={12} color={muted}>
{t('events.tasks.bulkHint', 'One task per line. These will be created and added to the event.')}
</Text>
<textarea
<MobileTextArea
value={bulkLines}
onChange={(e) => setBulkLines(e.target.value)}
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>
</MobileSheet>
@@ -736,23 +718,22 @@ export default function MobileEventTasksPage() {
}
>
<YStack space="$2">
<Field label={t('events.tasks.emotionName', 'Name')} color={text}>
<input
<MobileField label={t('events.tasks.emotionName', 'Name')}>
<MobileInput
type="text"
value={emotionForm.name}
onChange={(e) => setEmotionForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder={t('events.tasks.emotionNamePlaceholder', 'z.B. Joy')}
style={inputStyle}
/>
</Field>
<Field label={t('events.tasks.emotionColor', 'Farbe')} color={text}>
<input
</MobileField>
<MobileField label={t('events.tasks.emotionColor', 'Farbe')}>
<MobileInput
type="color"
value={emotionForm.color}
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">
{emotions.map((em) => (
<ListItem
@@ -829,7 +810,7 @@ export default function MobileEventTasksPage() {
style={{
position: 'fixed',
right: 20,
bottom: 90,
bottom: 'calc(env(safe-area-inset-bottom, 0px) + 96px)',
width: 56,
height: 56,
borderRadius: 28,
@@ -908,14 +889,3 @@ export default function MobileEventTasksPage() {
</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 { Pressable } from '@tamagui/react-native-web-lite';
import { useTranslation } from 'react-i18next';
import { MobileShell } from './components/MobileShell';
import { MobileCard, PillBadge, CTAButton } from './components/Primitives';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, PillBadge, CTAButton, FloatingActionButton, SkeletonCard } from './components/Primitives';
import { MobileInput } from './components/FormControls';
import { getEvents, TenantEvent } from '../api';
import { adminPath } from '../constants';
import { isAuthError } from '../auth/tokens';
@@ -20,6 +21,7 @@ export default function MobileEventsPage() {
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [query, setQuery] = React.useState('');
const searchRef = React.useRef<HTMLInputElement>(null);
const theme = useTheme();
const text = String(theme.color?.val ?? '#111827');
const muted = String(theme.gray?.val ?? '#4b5563');
@@ -28,20 +30,6 @@ export default function MobileEventsPage() {
const primary = String(theme.primary?.val ?? '#007AFF');
const danger = String(theme.red10?.val ?? '#b91c1c');
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(() => {
(async () => {
try {
@@ -62,9 +50,9 @@ export default function MobileEventsPage() {
title={t('events.list.dashboardTitle', 'All Events Dashboard')}
onBack={() => navigate(-1)}
headerActions={
<Pressable>
<HeaderActionButton onPress={() => searchRef.current?.focus()} ariaLabel={t('events.list.search', 'Search events')}>
<Search size={18} color={text} />
</Pressable>
</HeaderActionButton>
}
>
{error ? (
@@ -75,20 +63,20 @@ export default function MobileEventsPage() {
</MobileCard>
) : null}
<CTAButton label={t('events.actions.create', 'Create New Event')} onPress={() => navigate(adminPath('/mobile/events/new'))} />
<input
<MobileInput
ref={searchRef}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('events.list.search', 'Search events')}
style={{ ...baseInputStyle, marginBottom: 12 }}
compact
style={{ marginBottom: 12 }}
/>
{loading ? (
<YStack space="$2">
{Array.from({ length: 3 }).map((_, idx) => (
<MobileCard key={`sk-${idx}`} height={90} opacity={0.6} />
<SkeletonCard key={`sk-${idx}`} height={90} />
))}
</YStack>
) : events.length === 0 ? (
@@ -124,6 +112,12 @@ export default function MobileEventsPage() {
))}
</YStack>
)}
<FloatingActionButton
label={t('events.actions.create', 'Create New Event')}
icon={Plus}
onPress={() => navigate(adminPath('/mobile/events/new'))}
/>
</MobileShell>
);
}

View File

@@ -45,6 +45,10 @@ export default function MobileLoginPage() {
const { t } = useTranslation('auth');
const location = useLocation();
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 rawReturnTo = searchParams.get('return_to');
@@ -105,7 +109,10 @@ export default function MobileLoginPage() {
};
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="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">

View File

@@ -8,6 +8,10 @@ export default function LoginStartPage(): React.ReactElement {
const location = useLocation();
const navigate = useNavigate();
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(() => {
const params = new URLSearchParams(location.search);
@@ -21,7 +25,10 @@ export default function LoginStartPage(): React.ReactElement {
}, [location.search, navigate]);
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>
</div>
);

View File

@@ -4,13 +4,20 @@ import { ADMIN_PUBLIC_LANDING_PATH } from '../constants';
export default function LogoutPage() {
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(() => {
logout({ redirect: ADMIN_PUBLIC_LANDING_PATH });
}, [logout]);
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">
Abmeldung wird vorbereitet ...
</div>

View File

@@ -5,8 +5,9 @@ import { Bell, RefreshCcw } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell } from './components/MobileShell';
import { MobileCard, PillBadge } from './components/Primitives';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, PillBadge, SkeletonCard, CTAButton } from './components/Primitives';
import { MobileSelect } from './components/FormControls';
import { listNotificationLogs, markNotificationLogs, NotificationLogEntry } from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
@@ -212,6 +213,8 @@ export default function MobileNotificationsPage() {
const scopeParam = search.get('scope') ?? 'all';
const statusParam = search.get('status') ?? 'unread';
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 [error, setError] = React.useState<string | null>(null);
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 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 (
<MobileShell
activeTab="home"
title={t('mobileNotifications.title', 'Notifications')}
onBack={() => navigate(-1)}
headerActions={
<Pressable onPress={() => reload()}>
<HeaderActionButton onPress={() => reload()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color={text} />
</Pressable>
</HeaderActionButton>
}
>
{error ? (
@@ -321,19 +338,21 @@ export default function MobileNotificationsPage() {
) : null}
<XStack space="$2" marginBottom="$2">
<select
<MobileSelect
value={statusParam}
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="read">{t('notificationLogs.filter.read', 'Read')}</option>
<option value="all">{t('notificationLogs.filter.all', 'All')}</option>
</select>
<select
</MobileSelect>
<MobileSelect
value={scopeParam}
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="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="package">{t('notificationLogs.scope.package', 'Package')}</option>
<option value="general">{t('notificationLogs.scope.general', 'General')}</option>
</select>
</MobileSelect>
{unreadIds.length ? (
<CTAButton
label={t('notificationLogs.markAllRead', 'Mark all read')}
@@ -362,7 +381,7 @@ export default function MobileNotificationsPage() {
{loading ? (
<YStack space="$2">
{Array.from({ length: 4 }).map((_, idx) => (
<MobileCard key={`al-${idx}`} height={70} opacity={0.6} />
<SkeletonCard key={`al-${idx}`} height={70} />
))}
</YStack>
) : statusFiltered.length === 0 ? (
@@ -382,34 +401,76 @@ export default function MobileNotificationsPage() {
</Pressable>
) : null}
{statusFiltered.map((item) => (
<MobileCard key={item.id} space="$2" borderColor={item.is_read ? border : primary}>
<XStack alignItems="center" space="$2">
<XStack
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} />
<Pressable
key={item.id}
onPress={() => {
setSelectedNotification(item);
setDetailOpen(true);
}}
>
<MobileCard space="$2" borderColor={item.is_read ? border : primary}>
<XStack alignItems="center" space="$2">
<XStack
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>
<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>
</MobileCard>
</MobileCard>
</Pressable>
))}
</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
open={showEventPicker}
onClose={() => setShowEventPicker(false)}

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,13 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
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 { YGroup } from '@tamagui/group';
import { ListItem } from '@tamagui/list-item';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { Switch } from '@tamagui/switch';
import { useTheme } from '@tamagui/core';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import { useAuth } from '../auth/context';
@@ -36,6 +38,10 @@ export default function MobileSettingsPage() {
const { t } = useTranslation('management');
const navigate = useNavigate();
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 [defaults, setDefaults] = React.useState<NotificationPreferences>({});
const [loading, setLoading] = React.useState(true);
@@ -104,12 +110,12 @@ export default function MobileSettingsPage() {
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<Shield size={18} color="#0f172a" />
<Text fontSize="$md" fontWeight="800" color="#0f172a">
<Shield size={18} color={text} />
<Text fontSize="$md" fontWeight="800" color={text}>
{t('mobileSettings.accountTitle', 'Account')}
</Text>
</XStack>
<Text fontSize="$sm" color="#4b5563">
<Text fontSize="$sm" color={muted}>
{user?.name ?? user?.email ?? t('settings.session.unknown', 'Benutzer')}
</Text>
{user?.tenant_id ? (
@@ -123,47 +129,48 @@ export default function MobileSettingsPage() {
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<Bell size={18} color="#0f172a" />
<Text fontSize="$md" fontWeight="800" color="#0f172a">
<Bell size={18} color={text} />
<Text fontSize="$md" fontWeight="800" color={text}>
{t('mobileSettings.notificationsTitle', 'Notifications')}
</Text>
</XStack>
{loading ? (
<Text fontSize="$sm" color="#6b7280">
<Text fontSize="$sm" color={muted}>
{t('mobileSettings.notificationsLoading', 'Loading settings ...')}
</Text>
) : (
<YStack space="$2">
{AVAILABLE_PREFS.map((key) => (
<XStack
key={key}
alignItems="center"
justifyContent="space-between"
borderBottomWidth={1}
borderColor="#e5e7eb"
paddingBottom="$2"
paddingTop="$1.5"
space="$2"
>
<YStack flex={1} minWidth={0} space="$1">
<Text fontSize="$sm" color="#0f172a" fontWeight="700">
{t(`settings.notifications.keys.${key}.label`, key)}
</Text>
<Text fontSize="$xs" color="#6b7280">
{t(`settings.notifications.keys.${key}.description`, '')}
</Text>
</YStack>
<Switch
size="$4"
checked={Boolean(preferences[key])}
onCheckedChange={() => togglePref(key)}
aria-label={t(`settings.notifications.keys.${key}.label`, key)}
>
<Switch.Thumb />
</Switch>
</XStack>
<YGroup borderRadius="$4" borderWidth={1} borderColor={border} overflow="hidden">
{AVAILABLE_PREFS.map((key, index) => (
<YGroup.Item key={key} bordered={index < AVAILABLE_PREFS.length - 1}>
<ListItem
hoverTheme
pressTheme
paddingVertical="$2"
paddingHorizontal="$3"
title={
<Text fontSize="$sm" color={text} fontWeight="700">
{t(`settings.notifications.keys.${key}.label`, key)}
</Text>
}
subTitle={
<Text fontSize="$xs" color={muted}>
{t(`settings.notifications.keys.${key}.description`, '')}
</Text>
}
iconAfter={
<Switch
size="$4"
checked={Boolean(preferences[key])}
onCheckedChange={() => togglePref(key)}
aria-label={t(`settings.notifications.keys.${key}.label`, key)}
>
<Switch.Thumb />
</Switch>
}
/>
</YGroup.Item>
))}
</YStack>
</YGroup>
)}
<XStack space="$2">
<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">
<XStack alignItems="center" space="$2">
<User size={18} color="#0f172a" />
<Text fontSize="$md" fontWeight="800" color="#0f172a">
<User size={18} color={text} />
<Text fontSize="$md" fontWeight="800" color={text}>
{t('settings.appearance.title', 'Darstellung')}
</Text>
</XStack>
<Text fontSize="$sm" color="#4b5563">
<Text fontSize="$sm" color={muted}>
{t('settings.appearance.description', 'Schalte Dark-Mode oder passe Branding im Admin an.')}
</Text>
<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 { useTheme } from '@tamagui/core';
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 function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate: (key: NavKey) => void }) {
const { t } = useTranslation('mobile');
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 }> = [
{ key: 'home', icon: Home, label: t('nav.home', 'Home') },
{ key: 'tasks', icon: CheckSquare, label: t('nav.tasks', 'Tasks') },
@@ -21,30 +25,41 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
];
return (
<YStack
position="fixed"
bottom={0}
left={0}
right={0}
backgroundColor={String(theme.surface?.val ?? 'white')}
borderTopWidth={1}
borderColor={String(theme.borderColor?.val ?? '#e5e7eb')}
paddingVertical="$2"
paddingHorizontal="$4"
zIndex={50}
<YStack
position="fixed"
bottom={0}
left={0}
right={0}
backgroundColor={navSurface}
borderTopWidth={1}
borderColor={String(theme.borderColor?.val ?? '#e5e7eb')}
paddingVertical="$2"
paddingHorizontal="$4"
zIndex={50}
shadowColor="#0f172a"
shadowOpacity={0.08}
shadowRadius={12}
shadowOffset={{ width: 0, height: -4 }}
// 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">
{items.map((item) => {
const activeState = item.key === active;
const isPressed = pressedKey === item.key;
const IconCmp = item.icon;
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
flexGrow={1}
flexBasis="0%"
@@ -59,7 +74,22 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
borderRadius={12}
backgroundColor={activeState ? '#e8f1ff' : 'transparent'}
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}>
<IconCmp size={ICON_SIZE} color={activeState ? String(theme.primary?.val ?? '#007AFF') : '#94a3b8'} />
</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 { MobileCard, PillBadge } from './Primitives';
import { useNotificationsBadge } from '../hooks/useNotificationsBadge';
import { useOnlineStatus } from '../hooks/useOnlineStatus';
import { formatEventDate, resolveEventDisplayName } from '../../lib/events';
import { TenantEvent, getEvents } from '../../api';
import { withAlpha } from './colors';
type MobileShellProps = {
title?: string;
@@ -31,12 +33,16 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
const navigate = useNavigate();
const { t, i18n } = useTranslation('mobile');
const { count: notificationCount } = useNotificationsBadge();
const online = useOnlineStatus();
const theme = useTheme();
const backgroundColor = String(theme.background?.val ?? '#f7f8fb');
const surfaceColor = String(theme.surface?.val ?? '#ffffff');
const borderColor = String(theme.borderColor?.val ?? '#e5e7eb');
const textColor = String(theme.color?.val ?? '#111827');
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 [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
const [loadingEvents, setLoadingEvents] = React.useState(false);
@@ -90,7 +96,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
return (
<YStack backgroundColor={backgroundColor} minHeight="100vh" alignItems="center">
<YStack
backgroundColor={surfaceColor}
backgroundColor={headerSurface}
borderBottomWidth={1}
borderColor={borderColor}
paddingHorizontal="$4"
@@ -102,14 +108,22 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
shadowOffset={{ width: 0, height: 4 }}
width="100%"
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">
{onBack ? (
<Pressable onPress={onBack}>
<HeaderActionButton onPress={onBack} ariaLabel={t('actions.back', 'Back')}>
<XStack alignItems="center" space="$1.5">
<ChevronLeft size={28} color="#007AFF" strokeWidth={2.5} />
</XStack>
</Pressable>
</HeaderActionButton>
) : (
<XStack width={28} />
)}
@@ -134,7 +148,10 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
</XStack>
<XStack alignItems="center" space="$2">
<Pressable onPress={() => navigate(adminPath('/mobile/notifications'))}>
<HeaderActionButton
onPress={() => navigate(adminPath('/mobile/notifications'))}
ariaLabel={t('mobile.notifications', 'Notifications')}
>
<XStack
width={34}
height={34}
@@ -164,9 +181,12 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
</YStack>
) : null}
</XStack>
</Pressable>
</HeaderActionButton>
{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
height={34}
paddingHorizontal="$3"
@@ -181,7 +201,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
{t('header.quickQr', 'Quick QR')}
</Text>
</XStack>
</Pressable>
</HeaderActionButton>
) : null}
{headerActions ?? null}
</XStack>
@@ -189,7 +209,29 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
</XStack>
</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}
</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 {
if (!event) return 'Location';
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({
icon: IconCmp,
label,
@@ -189,3 +195,53 @@ export function ActionTile({
</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 { useTranslation } from 'react-i18next';
import { useTheme } from '@tamagui/core';
import { withAlpha } from './colors';
type MobileScaffoldProps = {
title: string;
@@ -21,6 +22,7 @@ export function MobileScaffold({ title, onBack, rightSlot, children, footer }: M
const surface = String(theme.surface?.val ?? '#ffffff');
const border = String(theme.borderColor?.val ?? '#e5e7eb');
const textColor = String(theme.color?.val ?? '#111827');
const headerSurface = withAlpha(surface, 0.94);
return (
<YStack backgroundColor={background} minHeight="100vh">
@@ -30,9 +32,17 @@ export function MobileScaffold({ title, onBack, rightSlot, children, footer }: M
paddingHorizontal="$4"
paddingTop="$4"
paddingBottom="$3"
backgroundColor={surface}
backgroundColor={headerSurface}
borderBottomWidth={1}
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">
{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 MobileTasksTabPage = React.lazy(() => import('./mobile/TasksTabPage'));
const MobileUploadsTabPage = React.lazy(() => import('./mobile/UploadsTabPage'));
const MobileAnimatedOutlet = React.lazy(() => import('./mobile/components/MobileAnimatedOutlet'));
function RequireAuth() {
const { status } = useAuth();
@@ -50,7 +51,11 @@ function RequireAuth() {
return <Navigate to={ADMIN_LOGIN_START_PATH} state={{ from: location }} replace />;
}
return <Outlet />;
return (
<React.Suspense fallback={<Outlet />}>
<MobileAnimatedOutlet />
</React.Suspense>
);
}
function LandingGate() {

View File

@@ -2,7 +2,7 @@
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<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() }}">
<title>{{ __('admin.shell.tenant_admin_title') }}</title>
<link rel="icon" href="{{ asset('favicon.ico') }}" type="image/x-icon">