Files
fotospiel-app/resources/js/admin/mobile/BrandingPage.tsx
Codex Agent 73e550ee87 Implemented a shared mobile shell and navigation aligned to the new architecture, plus refactored the dashboard and
tab flows.

  - Added a dynamic MobileShell with sticky header (notification bell with badge, quick QR when an event is
    active, event switcher for multi-event users) and stabilized bottom tabs (home, tasks, uploads, profile)
    driven by useMobileNav (resources/js/admin/mobile/components/MobileShell.tsx, components/BottomNav.tsx, hooks/
    useMobileNav.ts).
  - Centralized event handling now supports 0/1/many-event states without auto-selecting in multi-tenant mode and
    exposes helper flags/activeSlug for consumers (resources/js/admin/context/EventContext.tsx).
  - Rebuilt the mobile dashboard into explicit states: onboarding/no-event, single-event focus, and multi-event picker
    with featured/secondary actions, KPI strip, and alerts (resources/js/admin/mobile/DashboardPage.tsx).
  - Introduced tab entry points that respect event context and prompt selection when needed (resources/js/admin/
    mobile/TasksTabPage.tsx, UploadsTabPage.tsx). Refreshed tasks/uploads detail screens to use the new shell and sync
    event selection (resources/js/admin/mobile/EventTasksPage.tsx, EventPhotosPage.tsx).
  - Updated mobile routes and existing screens to the new tab keys and header/footer behavior (resources/js/admin/
    router.tsx, mobile/* pages, i18n nav/header strings).
2025-12-10 16:13:44 +01:00

379 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Image as ImageIcon, RefreshCcw, Save } 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 { MobileScaffold } from './components/Scaffold';
import { MobileCard, CTAButton } from './components/Primitives';
import { BottomNav } from './components/BottomNav';
import { TenantEvent, getEvent, updateEvent, getTenantFonts, TenantFont } from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { useMobileNav } from './hooks/useMobileNav';
import { MobileSheet } from './components/Sheet';
type BrandingForm = {
primary: string;
accent: string;
headingFont: string;
bodyFont: string;
};
export default function MobileBrandingPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const slug = slugParam ?? null;
const navigate = useNavigate();
const { t } = useTranslation('management');
const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [form, setForm] = React.useState<BrandingForm>({
primary: '#007AFF',
accent: '#5AD2F4',
headingFont: '',
bodyFont: '',
});
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const { go } = useMobileNav(slug);
const [showFontsSheet, setShowFontsSheet] = React.useState(false);
const [fonts, setFonts] = React.useState<TenantFont[]>([]);
const [fontsLoading, setFontsLoading] = React.useState(false);
React.useEffect(() => {
if (!slug) return;
(async () => {
setLoading(true);
try {
const data = await getEvent(slug);
setEvent(data);
setForm(extractBranding(data));
setError(null);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Branding konnte nicht geladen werden.')));
}
} finally {
setLoading(false);
}
})();
}, [slug, t]);
React.useEffect(() => {
(async () => {
setFontsLoading(true);
try {
const data = await getTenantFonts();
setFonts(data ?? []);
} catch {
// non-fatal
} finally {
setFontsLoading(false);
}
})();
}, []);
const previewTitle = event ? renderName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
async function handleSave() {
if (!event?.slug) return;
setSaving(true);
setError(null);
try {
const settings = { ...(event.settings ?? {}) };
settings.branding = {
...(typeof settings.branding === 'object' ? (settings.branding as Record<string, unknown>) : {}),
primary_color: form.primary,
accent_color: form.accent,
heading_font: form.headingFont,
body_font: form.bodyFont,
};
const updated = await updateEvent(event.slug, { settings });
setEvent(updated);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Branding konnte nicht gespeichert werden.')));
}
} finally {
setSaving(false);
}
}
function handleReset() {
if (event) {
setForm(extractBranding(event));
}
}
return (
<MobileScaffold
title={t('events.branding.title', 'Branding & Customization')}
onBack={() => navigate(-1)}
rightSlot={
<Pressable disabled={saving} onPress={() => handleSave()}>
<Save size={18} color="#007AFF" />
</Pressable>
}
footer={
<BottomNav active="home" onNavigate={go} />
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
{error}
</Text>
</MobileCard>
) : null}
<MobileCard space="$3">
<Text fontSize="$sm" fontWeight="700" color="#111827">
{t('events.branding.previewTitle', 'Guest App Preview')}
</Text>
<YStack borderRadius={16} borderWidth={1} borderColor="#e5e7eb" backgroundColor="#f8fafc" padding="$3" space="$2" alignItems="center">
<YStack width="100%" borderRadius={12} backgroundColor="white" borderWidth={1} borderColor="#e5e7eb" overflow="hidden">
<YStack backgroundColor={form.primary} height={64} />
<YStack padding="$3" space="$1.5">
<Text fontSize="$md" fontWeight="800" color="#111827">
{previewTitle}
</Text>
<Text fontSize="$sm" color="#4b5563">
{t('events.branding.previewSubtitle', 'Aktuelle Farben & Schriften')}
</Text>
<XStack space="$2" marginTop="$1">
<ColorSwatch color={form.primary} label={t('events.branding.primary', 'Primary')} />
<ColorSwatch color={form.accent} label={t('events.branding.accent', 'Accent')} />
</XStack>
</YStack>
</YStack>
</YStack>
</MobileCard>
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color="#111827">
{t('events.branding.colors', 'Colors')}
</Text>
<ColorField
label={t('events.branding.primary', 'Primary Color')}
value={form.primary}
onChange={(value) => setForm((prev) => ({ ...prev, primary: value }))}
/>
<ColorField
label={t('events.branding.accent', 'Accent Color')}
value={form.accent}
onChange={(value) => setForm((prev) => ({ ...prev, accent: value }))}
/>
</MobileCard>
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color="#111827">
{t('events.branding.fonts', 'Fonts')}
</Text>
<InputField
label={t('events.branding.headingFont', 'Headline Font')}
value={form.headingFont}
placeholder="SF Pro Display"
onChange={(value) => setForm((prev) => ({ ...prev, headingFont: value }))}
/>
<InputField
label={t('events.branding.bodyFont', 'Body Font')}
value={form.bodyFont}
placeholder="SF Pro Text"
onChange={(value) => setForm((prev) => ({ ...prev, bodyFont: value }))}
/>
<Pressable onPress={() => setShowFontsSheet(true)}>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
<Text fontSize="$sm" color="#007AFF" fontWeight="700">
{t('events.branding.chooseFont', 'Choose from installed fonts')}
</Text>
<Save size={16} color="#007AFF" />
</XStack>
</Pressable>
</MobileCard>
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color="#111827">
{t('events.branding.logo', 'Logo')}
</Text>
<YStack
borderRadius={14}
borderWidth={1}
borderColor="#e5e7eb"
backgroundColor="#f8fafc"
padding="$3"
alignItems="center"
justifyContent="center"
space="$2"
>
<ImageIcon size={28} color="#94a3b8" />
<Text fontSize="$sm" color="#4b5563">
{t('events.branding.logoHint', 'Logo Upload folgt nutze Farben/Schriften.')}
</Text>
</YStack>
</MobileCard>
<YStack space="$2">
<CTAButton label={saving ? t('events.branding.saving', 'Saving...') : t('events.branding.save', 'Save Branding')} onPress={() => handleSave()} />
<Pressable disabled={loading || saving} onPress={handleReset}>
<XStack
height={52}
borderRadius={14}
alignItems="center"
justifyContent="center"
backgroundColor="white"
borderWidth={1}
borderColor="#e5e7eb"
space="$2"
>
<RefreshCcw size={16} color="#111827" />
<Text fontSize="$sm" color="#111827" fontWeight="700">
{t('events.branding.reset', 'Reset to Defaults')}
</Text>
</XStack>
</Pressable>
</YStack>
<MobileSheet
open={showFontsSheet}
onClose={() => setShowFontsSheet(false)}
title={t('events.branding.fontPicker', 'Select font')}
footer={null}
bottomOffsetPx={120}
>
<YStack space="$2">
{fontsLoading ? (
Array.from({ length: 4 }).map((_, idx) => <MobileCard key={`font-sk-${idx}`} height={48} opacity={0.6} />)
) : fonts.length === 0 ? (
<Text fontSize="$sm" color="#4b5563">
{t('events.branding.noFonts', 'Keine Schriftarten gefunden.')}
</Text>
) : (
fonts.map((font) => (
<Pressable
key={font.family}
onPress={() => {
setForm((prev) => ({ ...prev, headingFont: font.family, bodyFont: font.family }));
setShowFontsSheet(false);
}}
>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
<YStack>
<Text fontSize="$sm" color="#111827">
{font.family}
</Text>
{font.variants?.length ? (
<Text fontSize="$xs" color="#6b7280">
{font.variants.map((v) => v.style ?? v.weight ?? '').filter(Boolean).join(', ')}
</Text>
) : null}
</YStack>
{form.headingFont === font.family || form.bodyFont === font.family ? (
<Text fontSize="$xs" color="#007AFF">
{t('common.active', 'Active')}
</Text>
) : null}
</XStack>
</Pressable>
))
)}
</YStack>
</MobileSheet>
</MobileScaffold>
);
}
function extractBranding(event: TenantEvent): BrandingForm {
const source = (event.settings as Record<string, unknown>) ?? {};
const branding = (source.branding as Record<string, unknown>) ?? source;
const readColor = (key: string, fallback: string) => {
const value = branding[key];
return typeof value === 'string' && value.startsWith('#') ? value : fallback;
};
const readText = (key: string) => {
const value = branding[key];
return typeof value === 'string' ? value : '';
};
return {
primary: readColor('primary_color', '#007AFF'),
accent: readColor('accent_color', '#5AD2F4'),
headingFont: readText('heading_font'),
bodyFont: readText('body_font'),
};
}
function renderName(name: TenantEvent['name']): string {
if (typeof name === 'string') return name;
if (name && typeof name === 'object') {
return name.de ?? name.en ?? Object.values(name)[0] ?? '';
}
return '';
}
function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (next: string) => void }) {
return (
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
{label}
</Text>
<XStack alignItems="center" space="$2">
<input
type="color"
value={value}
onChange={(event) => onChange(event.target.value)}
style={{ width: 52, height: 52, borderRadius: 12, border: '1px solid #e5e7eb', background: 'white' }}
/>
<Text fontSize="$sm" color="#4b5563">
{value}
</Text>
</XStack>
</YStack>
);
}
function ColorSwatch({ color, label }: { color: string; label: string }) {
return (
<YStack alignItems="center" space="$1">
<YStack width={40} height={40} borderRadius={12} borderWidth={1} borderColor="#e5e7eb" backgroundColor={color} />
<Text fontSize="$xs" color="#4b5563">
{label}
</Text>
</YStack>
);
}
function InputField({
label,
value,
placeholder,
onChange,
}: {
label: string;
value: string;
placeholder?: string;
onChange: (next: string) => void;
}) {
return (
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
{label}
</Text>
<input
type="text"
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
style={{
width: '100%',
height: 48,
borderRadius: 12,
border: '1px solid #e5e7eb',
padding: '0 12px',
fontSize: 14,
}}
/>
</YStack>
);
}