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:
@@ -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;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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.')}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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'))} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
193
resources/js/admin/mobile/components/FormControls.tsx
Normal file
193
resources/js/admin/mobile/components/FormControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
25
resources/js/admin/mobile/components/colors.ts
Normal file
25
resources/js/admin/mobile/components/colors.ts
Normal 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;
|
||||
}
|
||||
18
resources/js/admin/mobile/hooks/useOnlineStatus.tsx
Normal file
18
resources/js/admin/mobile/hooks/useOnlineStatus.tsx
Normal 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;
|
||||
}
|
||||
22
resources/js/admin/mobile/prefetch.ts
Normal file
22
resources/js/admin/mobile/prefetch.ts
Normal 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');
|
||||
});
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user