neues Admin UI Layout eingeführt. Alle Tests auf den neusten Stand gebracht.

This commit is contained in:
Codex Agent
2025-12-30 10:24:06 +01:00
parent 902e78cae9
commit efe2f25b3e
85 changed files with 95235 additions and 19197 deletions

View File

@@ -20,11 +20,13 @@ import { getApiErrorMessage } from '../lib/apiError';
import { ADMIN_EVENT_VIEW_PATH, adminPath } from '../constants';
import { buildPackageUsageMetrics, PackageUsageMetric, usagePercent } from './billingUsage';
import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme';
export default function MobileBillingPage() {
const { t } = useTranslation('management');
const navigate = useNavigate();
const location = useLocation();
const { textStrong, text, muted, subtle, danger, border, primary, accentSoft } = useAdminTheme();
const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]);
const [activePackage, setActivePackage] = React.useState<TenantPackageSummary | null>(null);
const [transactions, setTransactions] = React.useState<PaddleTransactionSummary[]>([]);
@@ -107,13 +109,13 @@ export default function MobileBillingPage() {
onBack={back}
headerActions={
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color="#0f172a" />
<RefreshCcw size={18} color={textStrong} />
</HeaderActionButton>
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
<Text fontWeight="700" color={danger}>
{error}
</Text>
<CTAButton label={t('billing.actions.refresh', 'Refresh')} tone="ghost" onPress={load} />
@@ -122,12 +124,12 @@ export default function MobileBillingPage() {
<MobileCard space="$2" ref={packagesRef as any}>
<XStack alignItems="center" space="$2">
<Package size={18} color="#0f172a" />
<Text fontSize="$md" fontWeight="800" color="#0f172a">
<Package size={18} color={textStrong} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('billing.sections.packages.title', 'Packages')}
</Text>
</XStack>
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={muted}>
{t('billing.sections.packages.hint', 'Active package, limits, and history at a glance.')}
</Text>
<CTAButton
@@ -136,7 +138,7 @@ export default function MobileBillingPage() {
disabled={portalBusy}
/>
{loading ? (
<Text fontSize="$sm" color="#6b7280">
<Text fontSize="$sm" color={muted}>
{t('common.loading', 'Lädt...')}
</Text>
) : (
@@ -159,21 +161,21 @@ export default function MobileBillingPage() {
<MobileCard space="$2" ref={invoicesRef as any}>
<XStack alignItems="center" space="$2">
<Receipt size={18} color="#0f172a" />
<Text fontSize="$md" fontWeight="800" color="#0f172a">
<Receipt size={18} color={textStrong} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('billing.sections.invoices.title', 'Invoices & Payments')}
</Text>
</XStack>
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={muted}>
{t('billing.sections.invoices.hint', 'Review transactions and download receipts.')}
</Text>
{loading ? (
<Text fontSize="$sm" color="#6b7280">
<Text fontSize="$sm" color={muted}>
{t('common.loading', 'Lädt...')}
</Text>
) : transactions.length === 0 ? (
<YStack space="$2">
<Text fontSize="$sm" color="#4b5563">
<Text fontSize="$sm" color={text}>
{t('billing.sections.invoices.empty', 'Keine Zahlungen gefunden.')}
</Text>
<CTAButton label={t('billing.actions.openPackages', 'Open packages')} onPress={scrollToPackages} />
@@ -182,31 +184,31 @@ export default function MobileBillingPage() {
) : (
<YStack space="$1.5">
{transactions.slice(0, 8).map((trx) => (
<XStack key={trx.id} alignItems="center" justifyContent="space-between" borderBottomWidth={1} borderColor="#e5e7eb" paddingVertical="$1.5">
<XStack key={trx.id} alignItems="center" justifyContent="space-between" borderBottomWidth={1} borderColor={border} paddingVertical="$1.5">
<YStack>
<Text fontSize="$sm" color="#0f172a" fontWeight="700">
<Text fontSize="$sm" color={textStrong} fontWeight="700">
{trx.status ?? '—'}
</Text>
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={muted}>
{formatDate(trx.created_at)}
</Text>
{trx.origin ? (
<Text fontSize="$xs" color="#9ca3af">
<Text fontSize="$xs" color={subtle}>
{trx.origin}
</Text>
) : null}
</YStack>
<YStack alignItems="flex-end">
<Text fontSize="$sm" color="#0f172a" fontWeight="700">
<Text fontSize="$sm" color={textStrong} fontWeight="700">
{formatAmount(trx.amount, trx.currency)}
</Text>
{trx.tax ? (
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={muted}>
{t('billing.sections.transactions.labels.tax', { value: formatAmount(trx.tax, trx.currency) })}
</Text>
) : null}
{trx.receipt_url ? (
<a href={trx.receipt_url} target="_blank" rel="noreferrer" style={{ fontSize: 12, color: '#2563eb' }}>
<a href={trx.receipt_url} target="_blank" rel="noreferrer" style={{ fontSize: 12, color: primary }}>
{t('billing.sections.transactions.labels.receipt', 'Beleg')}
</a>
) : null}
@@ -220,20 +222,20 @@ export default function MobileBillingPage() {
<MobileCard space="$2">
<XStack alignItems="center" space="$2">
<Sparkles size={18} color="#0f172a" />
<Text fontSize="$md" fontWeight="800" color="#0f172a">
<Sparkles size={18} color={textStrong} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('billing.sections.addOns.title', 'Add-ons')}
</Text>
</XStack>
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={muted}>
{t('billing.sections.addOns.hint', 'Track extra photo, guest, or time bundles per event.')}
</Text>
{loading ? (
<Text fontSize="$sm" color="#6b7280">
<Text fontSize="$sm" color={muted}>
{t('common.loading', 'Lädt...')}
</Text>
) : addons.length === 0 ? (
<Text fontSize="$sm" color="#4b5563">
<Text fontSize="$sm" color={text}>
{t('billing.sections.addOns.empty', 'Keine Add-ons gebucht.')}
</Text>
) : (
@@ -251,19 +253,25 @@ export default function MobileBillingPage() {
function PackageCard({ pkg, label, isActive = false }: { pkg: TenantPackageSummary; label?: string; isActive?: boolean }) {
const { t } = useTranslation('management');
const { border, primary, accentSoft, textStrong, muted } = useAdminTheme();
const remaining = pkg.remaining_events ?? (pkg.package_limits?.max_events_per_year as number | undefined) ?? 0;
const expires = pkg.expires_at ? formatDate(pkg.expires_at) : null;
const usageMetrics = buildPackageUsageMetrics(pkg);
return (
<MobileCard borderColor={isActive ? '#2563eb' : '#e5e7eb'} borderWidth={isActive ? 2 : 1} backgroundColor={isActive ? '#eff6ff' : undefined} space="$2">
<MobileCard
borderColor={isActive ? primary : border}
borderWidth={isActive ? 2 : 1}
backgroundColor={isActive ? accentSoft : undefined}
space="$2"
>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$md" fontWeight="800" color="#0f172a">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{pkg.package_name ?? t('mobileBilling.packageFallback', 'Package')}
</Text>
{label ? <PillBadge tone="success">{label}</PillBadge> : null}
</XStack>
{expires ? (
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={muted}>
{expires}
</Text>
) : null}
@@ -297,6 +305,7 @@ function renderFeatureBadge(pkg: TenantPackageSummary, t: any, key: string, labe
function UsageBar({ metric }: { metric: PackageUsageMetric }) {
const { t } = useTranslation('management');
const { muted, textStrong, border, primary, subtle } = useAdminTheme();
const labelMap: Record<PackageUsageMetric['key'], string> = {
events: t('mobileBilling.usage.events', 'Events'),
guests: t('mobileBilling.usage.guests', 'Guests'),
@@ -319,18 +328,18 @@ function UsageBar({ metric }: { metric: PackageUsageMetric }) {
return (
<YStack space="$1.5">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={muted}>
{labelMap[metric.key]}
</Text>
<Text fontSize="$xs" color="#0f172a" fontWeight="700">
<Text fontSize="$xs" color={textStrong} fontWeight="700">
{valueText}
</Text>
</XStack>
<YStack height={6} borderRadius={999} backgroundColor="#e5e7eb" overflow="hidden">
<YStack height="100%" width={`${fill}%`} backgroundColor={hasUsage ? '#2563eb' : '#94a3b8'} />
<YStack height={6} borderRadius={999} backgroundColor={border} overflow="hidden">
<YStack height="100%" width={`${fill}%`} backgroundColor={hasUsage ? primary : subtle} />
</YStack>
{remainingText ? (
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={muted}>
{remainingText}
</Text>
) : null}
@@ -353,6 +362,7 @@ function formatAmount(value: number | null | undefined, currency: string | null
function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
const { t } = useTranslation('management');
const navigate = useNavigate();
const { border, textStrong, text, muted, subtle, primary } = useAdminTheme();
const labels: Record<TenantAddonHistoryEntry['status'], { tone: 'success' | 'warning' | 'muted'; text: string }> = {
completed: { tone: 'success', text: t('mobileBilling.status.completed', 'Completed') },
pending: { tone: 'warning', text: t('mobileBilling.status.pending', 'Pending') },
@@ -380,9 +390,9 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
) : null;
return (
<MobileCard borderColor="#e5e7eb" padding="$3" space="$1.5">
<MobileCard borderColor={border} padding="$3" space="$1.5">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="700" color="#0f172a">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{addon.label ?? addon.addon_key}
</Text>
<PillBadge tone={status.tone}>{status.text}</PillBadge>
@@ -391,25 +401,25 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
eventPath ? (
<Pressable onPress={() => navigate(eventPath)}>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$xs" color="#0f172a" fontWeight="600">
<Text fontSize="$xs" color={textStrong} fontWeight="600">
{eventName}
</Text>
<Text fontSize="$xs" color="#2563eb" fontWeight="700">
<Text fontSize="$xs" color={primary} fontWeight="700">
{t('mobileBilling.openEvent', 'Open event')}
</Text>
</XStack>
</Pressable>
) : (
<Text fontSize="$xs" color="#9ca3af">
<Text fontSize="$xs" color={subtle}>
{eventName}
</Text>
)
) : null}
{impactBadges}
<Text fontSize="$sm" color="#0f172a" marginTop="$1.5">
<Text fontSize="$sm" color={text} marginTop="$1.5">
{formatAmount(addon.amount, addon.currency)}
</Text>
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={muted}>
{formatDate(addon.purchased_at)}
</Text>
</MobileCard>

View File

@@ -15,6 +15,7 @@ import { MobileSheet } from './components/Sheet';
import toast from 'react-hot-toast';
import { adminPath } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation';
import { ADMIN_COLORS, ADMIN_GRADIENTS, useAdminTheme } from './theme';
type BrandingForm = {
primary: string;
@@ -54,11 +55,12 @@ export default function MobileBrandingPage() {
const slug = slugParam ?? null;
const navigate = useNavigate();
const { t } = useTranslation('management');
const { textStrong, muted, subtle, border, primary, accentSoft, danger, surfaceMuted, surface } = useAdminTheme();
const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [form, setForm] = React.useState<BrandingForm>({
primary: '#007AFF',
accent: '#5AD2F4',
primary: ADMIN_COLORS.primary,
accent: ADMIN_COLORS.accent,
headingFont: '',
bodyFont: '',
logoDataUrl: '',
@@ -118,8 +120,8 @@ export default function MobileBrandingPage() {
}, [showFontsSheet, fontsLoaded]);
const previewTitle = event ? renderName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
const previewHeadingFont = form.headingFont || 'Montserrat';
const previewBodyFont = form.bodyFont || 'Montserrat';
const previewHeadingFont = form.headingFont || 'Fraunces';
const previewBodyFont = form.bodyFont || 'Manrope';
const watermarkAllowed = event?.package?.watermark_allowed !== false;
const brandingAllowed = isBrandingAllowed(event ?? null);
const watermarkLocked = watermarkAllowed && !brandingAllowed;
@@ -229,7 +231,7 @@ export default function MobileBrandingPage() {
return (
<>
<MobileCard space="$3">
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.watermark.previewTitle', 'Watermark Preview')}
</Text>
<WatermarkPreview
@@ -244,7 +246,7 @@ export default function MobileBrandingPage() {
{disabled ? (
<InfoBadge
icon={<Lock size={16} color="#b91c1c" />}
icon={<Lock size={16} color={danger} />}
text={t('events.watermark.lockedDisabled', 'Kein Wasserzeichen in diesem Paket.')}
tone="danger"
/>
@@ -252,13 +254,13 @@ export default function MobileBrandingPage() {
{watermarkLocked ? (
<InfoBadge
icon={<Lock size={16} color="#111827" />}
icon={<Lock size={16} color={textStrong} />}
text={t('events.watermark.lockedBranding', 'Custom-Wasserzeichen ist im aktuellen Paket gesperrt. Standard wird genutzt.')}
/>
) : null}
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color="#111827">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.watermark.title', 'Wasserzeichen')}
</Text>
@@ -291,7 +293,7 @@ export default function MobileBrandingPage() {
{mode === 'custom' && !controlsLocked ? (
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.watermark.upload', 'Wasserzeichen hochladen')}
</Text>
<Pressable onPress={() => document.getElementById('watermark-upload-input')?.click()}>
@@ -302,11 +304,11 @@ export default function MobileBrandingPage() {
paddingVertical="$2.5"
borderRadius={12}
borderWidth={1}
borderColor="#e5e7eb"
backgroundColor="white"
borderColor={border}
backgroundColor={surface}
>
<UploadCloud size={18} color="#007AFF" />
<Text fontSize="$sm" color="#007AFF" fontWeight="700">
<UploadCloud size={18} color={primary} />
<Text fontSize="$sm" color={primary} fontWeight="700">
{watermarkForm.assetPath
? t('events.watermark.replace', 'Wasserzeichen ersetzen')
: t('events.watermark.uploadCta', 'PNG/SVG/JPG (max. 3 MB)')}
@@ -334,7 +336,7 @@ export default function MobileBrandingPage() {
reader.readAsDataURL(file);
}}
/>
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={muted}>
{t('events.watermark.uploadHint', 'PNG mit transparenter Fläche empfohlen.')}
</Text>
</YStack>
@@ -342,7 +344,7 @@ export default function MobileBrandingPage() {
</MobileCard>
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color="#111827">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.watermark.placement', 'Position & Größe')}
</Text>
<PositionGrid
@@ -408,13 +410,13 @@ export default function MobileBrandingPage() {
onBack={back}
headerActions={
<HeaderActionButton onPress={() => handleSave()} ariaLabel={t('common.save', 'Save')}>
<Save size={18} color="#007AFF" />
<Save size={18} color={primary} />
</HeaderActionButton>
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
<Text fontWeight="700" color={danger}>
{error}
</Text>
</MobileCard>
@@ -430,17 +432,17 @@ export default function MobileBrandingPage() {
{activeTab === 'branding' ? (
<>
<MobileCard space="$3">
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{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 borderRadius={16} borderWidth={1} borderColor={border} backgroundColor={surfaceMuted} padding="$3" space="$2" alignItems="center">
<YStack width="100%" borderRadius={12} backgroundColor={surface} borderWidth={1} borderColor={border} overflow="hidden">
<YStack backgroundColor={form.primary} height={64} />
<YStack padding="$3" space="$1.5">
<Text fontSize="$md" fontWeight="800" color="#111827" style={{ fontFamily: previewHeadingFont }}>
<Text fontSize="$md" fontWeight="800" color={textStrong} style={{ fontFamily: previewHeadingFont }}>
{previewTitle}
</Text>
<Text fontSize="$sm" color="#4b5563" style={{ fontFamily: previewBodyFont }}>
<Text fontSize="$sm" color={muted} style={{ fontFamily: previewBodyFont }}>
{t('events.branding.previewSubtitle', 'Aktuelle Farben & Schriften')}
</Text>
<XStack space="$2" marginTop="$1">
@@ -453,7 +455,7 @@ export default function MobileBrandingPage() {
</MobileCard>
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color="#111827">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.branding.colors', 'Colors')}
</Text>
<ColorField
@@ -469,13 +471,13 @@ export default function MobileBrandingPage() {
</MobileCard>
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color="#111827">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.branding.fonts', 'Fonts')}
</Text>
<InputField
label={t('events.branding.headingFont', 'Headline Font')}
value={form.headingFont}
placeholder="SF Pro Display"
placeholder={t('events.branding.headingFontPlaceholder', 'SF Pro Display')}
onChange={(value) => setForm((prev) => ({ ...prev, headingFont: value }))}
onPicker={() => {
setFontField('heading');
@@ -485,7 +487,7 @@ export default function MobileBrandingPage() {
<InputField
label={t('events.branding.bodyFont', 'Body Font')}
value={form.bodyFont}
placeholder="SF Pro Text"
placeholder={t('events.branding.bodyFontPlaceholder', 'SF Pro Text')}
onChange={(value) => setForm((prev) => ({ ...prev, bodyFont: value }))}
onPicker={() => {
setFontField('body');
@@ -495,14 +497,14 @@ export default function MobileBrandingPage() {
</MobileCard>
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color="#111827">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.branding.logo', 'Logo')}
</Text>
<YStack
borderRadius={14}
borderWidth={1}
borderColor="#e5e7eb"
backgroundColor="#f8fafc"
borderColor={border}
backgroundColor={surfaceMuted}
padding="$3"
alignItems="center"
justifyContent="center"
@@ -510,7 +512,11 @@ export default function MobileBrandingPage() {
>
{form.logoDataUrl ? (
<>
<img src={form.logoDataUrl} alt="Logo" style={{ maxHeight: 80, maxWidth: '100%', objectFit: 'contain' }} />
<img
src={form.logoDataUrl}
alt={t('events.branding.logoAlt', 'Logo')}
style={{ maxHeight: 80, maxWidth: '100%', objectFit: 'contain' }}
/>
<XStack space="$2">
<CTAButton
label={t('events.branding.replaceLogo', 'Replace logo')}
@@ -524,10 +530,10 @@ export default function MobileBrandingPage() {
paddingVertical="$2"
borderRadius={12}
borderWidth={1}
borderColor="#e5e7eb"
borderColor={border}
>
<Trash2 size={16} color="#b91c1c" />
<Text fontSize="$sm" color="#b91c1c" fontWeight="700">
<Trash2 size={16} color={danger} />
<Text fontSize="$sm" color={danger} fontWeight="700">
{t('events.branding.removeLogo', 'Remove')}
</Text>
</XStack>
@@ -536,8 +542,8 @@ export default function MobileBrandingPage() {
</>
) : (
<>
<ImageIcon size={28} color="#94a3b8" />
<Text fontSize="$sm" color="#4b5563" textAlign="center">
<ImageIcon size={28} color={subtle} />
<Text fontSize="$sm" color={muted} textAlign="center">
{t('events.branding.logoHint', 'Upload a logo to brand guest invites and QR posters.')}
</Text>
<Pressable onPress={() => document.getElementById('branding-logo-input')?.click()}>
@@ -548,11 +554,11 @@ export default function MobileBrandingPage() {
paddingVertical="$2.5"
borderRadius={12}
borderWidth={1}
borderColor="#e5e7eb"
backgroundColor="white"
borderColor={border}
backgroundColor={surface}
>
<UploadCloud size={18} color="#007AFF" />
<Text fontSize="$sm" color="#007AFF" fontWeight="700">
<UploadCloud size={18} color={primary} />
<Text fontSize="$sm" color={primary} fontWeight="700">
{t('events.branding.uploadLogo', 'Upload logo (max. 1 MB)')}
</Text>
</XStack>
@@ -600,13 +606,13 @@ export default function MobileBrandingPage() {
borderRadius={14}
alignItems="center"
justifyContent="center"
backgroundColor="white"
backgroundColor={surface}
borderWidth={1}
borderColor="#e5e7eb"
borderColor={border}
space="$2"
>
<RefreshCcw size={16} color="#111827" />
<Text fontSize="$sm" color="#111827" fontWeight="700">
<RefreshCcw size={16} color={textStrong} />
<Text fontSize="$sm" color={textStrong} fontWeight="700">
{t('events.branding.reset', 'Reset to Defaults')}
</Text>
</XStack>
@@ -624,7 +630,7 @@ export default function MobileBrandingPage() {
{fontsLoading ? (
Array.from({ length: 4 }).map((_, idx) => <SkeletonCard key={`font-sk-${idx}`} height={48} />)
) : fonts.length === 0 ? (
<Text fontSize="$sm" color="#4b5563">
<Text fontSize="$sm" color={muted}>
{t('events.branding.noFonts', 'Keine Schriftarten gefunden.')}
</Text>
) : (
@@ -641,17 +647,17 @@ export default function MobileBrandingPage() {
>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
<YStack>
<Text fontSize="$sm" color="#111827" style={{ fontFamily: font.family }}>
<Text fontSize="$sm" color={textStrong} style={{ fontFamily: font.family }}>
{font.family}
</Text>
{font.variants?.length ? (
<Text fontSize="$xs" color="#6b7280" style={{ fontFamily: font.family }}>
<Text fontSize="$xs" color={muted} style={{ fontFamily: font.family }}>
{font.variants.map((v) => v.style ?? v.weight ?? '').filter(Boolean).join(', ')}
</Text>
) : null}
</YStack>
{form[fontField === 'heading' ? 'headingFont' : 'bodyFont'] === font.family ? (
<Text fontSize="$xs" color="#007AFF">
<Text fontSize="$xs" color={primary}>
{t('common.active', 'Active')}
</Text>
) : null}
@@ -677,8 +683,8 @@ function extractBranding(event: TenantEvent): BrandingForm {
return typeof value === 'string' ? value : '';
};
return {
primary: readColor('primary_color', '#007AFF'),
accent: readColor('accent_color', '#5AD2F4'),
primary: readColor('primary_color', ADMIN_COLORS.primary),
accent: readColor('accent_color', ADMIN_COLORS.accent),
headingFont: readText('heading_font'),
bodyFont: readText('body_font'),
logoDataUrl: readText('logo_data_url'),
@@ -757,9 +763,10 @@ function renderName(name: TenantEvent['name']): string {
}
function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (next: string) => void }) {
const { textStrong, muted, border, surface } = useAdminTheme();
return (
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{label}
</Text>
<XStack alignItems="center" space="$2">
@@ -767,9 +774,9 @@ function ColorField({ label, value, onChange }: { label: string; value: string;
type="color"
value={value}
onChange={(event) => onChange(event.target.value)}
style={{ width: 52, height: 52, borderRadius: 12, border: '1px solid #e5e7eb', background: 'white' }}
style={{ width: 52, height: 52, borderRadius: 12, border: `1px solid ${border}`, background: surface }}
/>
<Text fontSize="$sm" color="#4b5563">
<Text fontSize="$sm" color={muted}>
{value}
</Text>
</XStack>
@@ -778,10 +785,11 @@ function ColorField({ label, value, onChange }: { label: string; value: string;
}
function ColorSwatch({ color, label }: { color: string; label: string }) {
const { border, muted } = useAdminTheme();
return (
<YStack alignItems="center" space="$1">
<YStack width={40} height={40} borderRadius={12} borderWidth={1} borderColor="#e5e7eb" backgroundColor={color} />
<Text fontSize="$xs" color="#4b5563">
<YStack width={40} height={40} borderRadius={12} borderWidth={1} borderColor={border} backgroundColor={color} />
<Text fontSize="$xs" color={muted}>
{label}
</Text>
</YStack>
@@ -803,20 +811,21 @@ function InputField({
onPicker?: () => void;
children?: React.ReactNode;
}) {
const { textStrong, border, surface, primary } = useAdminTheme();
return (
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{label}
</Text>
<XStack
alignItems="center"
borderRadius={12}
borderWidth={1}
borderColor="#e5e7eb"
borderColor={border}
paddingLeft="$3"
paddingRight="$2"
height={48}
backgroundColor="white"
backgroundColor={surface}
space="$2"
>
{children ?? (
@@ -838,7 +847,7 @@ function InputField({
)}
{onPicker ? (
<Pressable onPress={onPicker}>
<ChevronDown size={16} color="#007AFF" />
<ChevronDown size={16} color={primary} />
</Pressable>
) : null}
</XStack>
@@ -865,13 +874,14 @@ function LabeledSlider({
disabled?: boolean;
suffix?: string;
}) {
const { textStrong, muted } = useAdminTheme();
return (
<YStack space="$1.5">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{label}
</Text>
<Text fontSize="$xs" color="#4b5563">
<Text fontSize="$xs" color={muted}>
{value}
{suffix ? ` ${suffix}` : ''}
</Text>
@@ -899,6 +909,7 @@ function PositionGrid({
onChange: (value: WatermarkPosition) => void;
disabled?: boolean;
}) {
const { textStrong, primary, border, accentSoft, surface } = useAdminTheme();
const positions: WatermarkPosition[] = [
'top-left',
'top-center',
@@ -913,7 +924,7 @@ function PositionGrid({
return (
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
Position
</Text>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 8 }}>
@@ -926,11 +937,11 @@ function PositionGrid({
style={{
height: 40,
borderRadius: 10,
border: value === pos ? '2px solid #007AFF' : '1px solid #e5e7eb',
background: value === pos ? '#eff6ff' : '#fff',
border: value === pos ? `2px solid ${primary}` : `1px solid ${border}`,
background: value === pos ? accentSoft : surface,
}}
>
<Text fontSize="$xs" color="#0f172a" textAlign="center">
<Text fontSize="$xs" color={textStrong} textAlign="center">
{pos.replace('-', ' ')}
</Text>
</button>
@@ -955,6 +966,7 @@ function WatermarkPreview({
offsetX: number;
offsetY: number;
}) {
const { border, muted, textStrong, overlay } = useAdminTheme();
const width = 280;
const height = 180;
const wmWidth = Math.max(24, Math.round(width * Math.min(1, Math.max(0.05, scale))));
@@ -1001,11 +1013,11 @@ function WatermarkPreview({
height,
borderRadius: 16,
overflow: 'hidden',
border: '1px solid #e5e7eb',
background: 'linear-gradient(135deg, #e0f2fe, #c7d2fe)',
border: `1px solid ${border}`,
background: ADMIN_GRADIENTS.softCard,
}}
>
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(180deg, #0f172a66, transparent)' }} />
<div style={{ position: 'absolute', inset: 0, background: `linear-gradient(180deg, ${overlay}, transparent)` }} />
<div
style={{
position: 'absolute',
@@ -1019,10 +1031,10 @@ function WatermarkPreview({
alignItems: 'center',
justifyContent: 'center',
opacity: Math.min(1, Math.max(0, opacity)),
border: '1px dashed #64748b',
border: `1px dashed ${muted}`,
}}
>
<Droplets size={18} color="#0f172a" />
<Droplets size={18} color={textStrong} />
</div>
</div>
</div>
@@ -1030,11 +1042,12 @@ function WatermarkPreview({
}
function InfoBadge({ icon, text, tone = 'info' }: { icon?: React.ReactNode; text: string; tone?: 'info' | 'danger' }) {
const background = tone === 'danger' ? '#fef2f2' : '#f1f5f9';
const color = tone === 'danger' ? '#991b1b' : '#0f172a';
const { dangerBg, dangerText, surfaceMuted, textStrong, border } = useAdminTheme();
const background = tone === 'danger' ? dangerBg : surfaceMuted;
const color = tone === 'danger' ? dangerText : textStrong;
return (
<MobileCard space="$2" backgroundColor={background} borderColor="#e2e8f0">
<MobileCard space="$2" backgroundColor={background} borderColor={border}>
<XStack space="$2" alignItems="center">
{icon}
<Text fontSize="$sm" color={color}>
@@ -1046,6 +1059,7 @@ function InfoBadge({ icon, text, tone = 'info' }: { icon?: React.ReactNode; text
}
function TabButton({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
const { backdrop, surfaceMuted, border, surface } = useAdminTheme();
return (
<Pressable onPress={onPress} style={{ flex: 1 }}>
<XStack
@@ -1053,11 +1067,11 @@ function TabButton({ label, active, onPress }: { label: string; active: boolean;
justifyContent="center"
paddingVertical="$2.5"
borderRadius={12}
backgroundColor={active ? '#0f172a' : '#f1f5f9'}
backgroundColor={active ? backdrop : surfaceMuted}
borderWidth={1}
borderColor={active ? '#0f172a' : '#e5e7eb'}
borderColor={active ? backdrop : border}
>
<Text fontSize="$sm" color={active ? '#fff' : '#0f172a'} fontWeight="700">
<Text fontSize="$sm" color={active ? surface : backdrop} fontWeight="700">
{label}
</Text>
</XStack>

View File

@@ -13,13 +13,13 @@ import { adminPath, ADMIN_WELCOME_BASE_PATH } from '../constants';
import { useEventContext } from '../context/EventContext';
import { getEventStats, EventStats, TenantEvent, getEvents } from '../api';
import { formatEventDate, isBrandingAllowed, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';
import { useTheme } from '@tamagui/core';
import { useAdminPushSubscription } from './hooks/useAdminPushSubscription';
import { useDevicePermissions } from './hooks/useDevicePermissions';
import { useInstallPrompt } from './hooks/useInstallPrompt';
import { getTourSeen, resolveTourStepKeys, setTourSeen, type TourStepKey } from './lib/mobileTour';
import { trackOnboarding } from '../api';
import { useAuth } from '../auth/context';
import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme';
type DeviceSetupProps = {
installPrompt: ReturnType<typeof useInstallPrompt>;
@@ -43,13 +43,9 @@ export default function MobileDashboardPage() {
const installPrompt = useInstallPrompt();
const pushState = useAdminPushSubscription();
const devicePermissions = useDevicePermissions();
const theme = useTheme();
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#cbd5e1');
const border = String(theme.borderColor?.val ?? '#334155');
const surface = String(theme.surface?.val ?? '#ffffff');
const accentSoft = String(theme.blue3?.val ?? '#e0f2fe');
const accentText = String(theme.primary?.val ?? '#3b82f6');
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
const text = textStrong;
const accentText = primary;
const { data: stats, isLoading: statsLoading } = useQuery<EventStats | null>({
queryKey: ['mobile', 'dashboard', 'stats', activeEvent?.slug],
@@ -246,9 +242,9 @@ export default function MobileDashboardPage() {
borderRadius={14}
alignItems="center"
justifyContent="center"
backgroundColor={String(theme.blue3?.val ?? '#e0f2fe')}
backgroundColor={accentSoft}
>
<activeTourStep.icon size={18} color={String(theme.blue10?.val ?? '#2563eb')} />
<activeTourStep.icon size={18} color={accentText} />
</XStack>
<Text fontSize="$lg" fontWeight="800" color={text}>
{activeTourStep.title}
@@ -384,13 +380,11 @@ export default function MobileDashboardPage() {
function DeviceSetupCard({ installPrompt, pushState, devicePermissions, onOpenSettings }: DeviceSetupProps) {
const { t } = useTranslation('management');
const theme = useTheme();
const text = String(theme.color12?.val ?? theme.color?.val ?? '#0f172a');
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#6b7280');
const border = String(theme.borderColor?.val ?? '#e5e7eb');
const accent = String(theme.primary?.val ?? '#2563eb');
const iconBg = String(theme.blue3?.val ?? '#e0f2fe');
const iconColor = String(theme.blue10?.val ?? '#2563eb');
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
const text = textStrong;
const accent = primary;
const iconBg = accentSoft;
const iconColor = primary;
const items: Array<{
key: string;
@@ -523,17 +517,13 @@ function DeviceSetupCard({ installPrompt, pushState, devicePermissions, onOpenSe
function OnboardingEmptyState({ installPrompt, pushState, devicePermissions, onOpenSettings }: DeviceSetupProps) {
const { t } = useTranslation('management');
const navigate = useNavigate();
const theme = useTheme();
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#cbd5e1');
const border = String(theme.borderColor?.val ?? '#cbd5e1');
const accent = String(theme.primary?.val ?? '#2563eb');
const accentSoft = String(theme.blue3?.val ?? '#e0f2fe');
const accentStrong = String(theme.blue10?.val ?? '#1d4ed8');
const stepBg = String(theme.gray2?.val ?? '#f8fafc');
const stepBorder = String(theme.gray5?.val ?? '#e2e8f0');
const supportBg = String(theme.gray2?.val ?? '#f8fafc');
const supportBorder = String(theme.gray5?.val ?? '#e2e8f0');
const { textStrong, muted, border, accentSoft, accentStrong, surfaceMuted, primary, shadow } = useAdminTheme();
const text = textStrong;
const accent = primary;
const stepBg = surfaceMuted;
const stepBorder = border;
const supportBg = surfaceMuted;
const supportBorder = border;
const steps = [
t('mobileDashboard.emptyStepDetails', 'Add name & date'),
@@ -653,7 +643,7 @@ function OnboardingEmptyState({ installPrompt, pushState, devicePermissions, onO
borderWidth={1}
borderColor={`${border}aa`}
backgroundColor="rgba(255,255,255,0.6)"
shadowColor="#0f172a"
shadowColor={shadow}
shadowOpacity={0.04}
shadowRadius={10}
shadowOffset={{ width: 0, height: 6 }}
@@ -785,16 +775,15 @@ function FeaturedActions({
onShowQr: () => void;
}) {
const { t } = useTranslation('management');
const theme = useTheme();
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#cbd5e1');
const { textStrong, muted, subtle } = useAdminTheme();
const text = textStrong;
const cards = [
{
key: 'photos',
label: t('mobileDashboard.photosLabel', 'Review photos'),
desc: t('mobileDashboard.photosDesc', 'Moderate uploads and highlights'),
icon: ImageIcon,
color: '#0ea5e9',
color: ADMIN_ACTION_COLORS.images,
action: onReviewPhotos,
},
{
@@ -804,7 +793,7 @@ function FeaturedActions({
? t('mobileDashboard.tasksDesc', 'Assign and track progress')
: t('mobileDashboard.tasksDisabledDesc', 'Guests do not see tasks (task mode off)'),
icon: ListTodo,
color: '#22c55e',
color: ADMIN_ACTION_COLORS.tasks,
action: onManageTasks,
},
{
@@ -812,7 +801,7 @@ function FeaturedActions({
label: t('mobileDashboard.qrLabel', 'Show / share QR code'),
desc: t('mobileDashboard.qrDesc', 'Posters, cards, and links'),
icon: QrCode,
color: '#f59e0b',
color: ADMIN_ACTION_COLORS.qr,
action: onShowQr,
},
];
@@ -834,7 +823,7 @@ function FeaturedActions({
{card.desc}
</Text>
</YStack>
<Text fontSize="$xl" color={String(theme.gray9?.val ?? '#94a3b8')}>
<Text fontSize="$xl" color={subtle}>
˃
</Text>
</XStack>
@@ -859,41 +848,38 @@ function SecondaryGrid({
onSettings: () => void;
}) {
const { t } = useTranslation('management');
const theme = useTheme();
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#cbd5e1');
const border = String(theme.borderColor?.val ?? '#334155');
const surface = String(theme.surface?.val ?? '#0b1220');
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
const text = textStrong;
const brandingAllowed = isBrandingAllowed(event ?? null);
const tiles = [
{
icon: Users,
label: t('mobileDashboard.shortcutGuests', 'Guest management'),
color: '#60a5fa',
color: ADMIN_ACTION_COLORS.guests,
action: onGuests,
},
{
icon: QrCode,
label: t('mobileDashboard.shortcutPrints', 'Print & poster downloads'),
color: '#fbbf24',
color: ADMIN_ACTION_COLORS.qr,
action: onPrint,
},
{
icon: Sparkles,
label: t('mobileDashboard.shortcutInvites', 'Team / helper invites'),
color: '#a855f7',
color: ADMIN_ACTION_COLORS.invites,
action: onInvites,
},
{
icon: Settings,
label: t('mobileDashboard.shortcutSettings', 'Event settings'),
color: '#10b981',
color: ADMIN_ACTION_COLORS.success,
action: onSettings,
},
{
icon: Sparkles,
label: t('mobileDashboard.shortcutBranding', 'Branding & moderation'),
color: '#22d3ee',
color: ADMIN_ACTION_COLORS.branding,
action: brandingAllowed ? onSettings : undefined,
disabled: !brandingAllowed,
},
@@ -905,7 +891,7 @@ function SecondaryGrid({
{t('mobileDashboard.shortcutsTitle', 'Shortcuts')}
</Text>
<XStack flexWrap="wrap" space="$2">
{tiles.map((tile) => (
{tiles.map((tile, index) => (
<ActionTile
key={tile.label}
icon={tile.icon}
@@ -913,6 +899,7 @@ function SecondaryGrid({
color={tile.color}
onPress={tile.action}
disabled={tile.disabled}
delayMs={index * ADMIN_MOTION.tileStaggerMs}
/>
))}
</XStack>
@@ -944,9 +931,8 @@ function KpiStrip({
tasksEnabled: boolean;
}) {
const { t } = useTranslation('management');
const theme = useTheme();
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#cbd5e1');
const { textStrong, muted } = useAdminTheme();
const text = textStrong;
if (!event) return null;
const kpis = [
@@ -997,11 +983,8 @@ function KpiStrip({
function AlertsAndHints({ event, stats, tasksEnabled }: { event: TenantEvent | null; stats: EventStats | null | undefined; tasksEnabled: boolean }) {
const { t } = useTranslation('management');
const theme = useTheme();
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
const warningBg = String(theme.yellow3?.val ?? '#fff7ed');
const warningBorder = String(theme.yellow6?.val ?? '#fed7aa');
const warningText = String(theme.yellow11?.val ?? '#9a3412');
const { textStrong, warningBg, warningBorder, warningText } = useAdminTheme();
const text = textStrong;
if (!event) return null;
const alerts: string[] = [];

View File

@@ -16,6 +16,7 @@ import { useEventContext } from '../context/EventContext';
import { formatEventDate, isBrandingAllowed, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';
import { isPastEvent } from './eventDate';
import { useBackNavigation } from './hooks/useBackNavigation';
import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme';
export default function MobileEventDetailPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
@@ -31,6 +32,7 @@ export default function MobileEventDetailPage() {
const { events, activeEvent, selectEvent } = useEventContext();
const [showEventPicker, setShowEventPicker] = React.useState(false);
const back = useBackNavigation(adminPath('/mobile/events'));
const { textStrong, text, muted, danger, accentSoft } = useAdminTheme();
React.useEffect(() => {
if (!slug) return;
@@ -105,33 +107,33 @@ export default function MobileEventDetailPage() {
headerActions={
<XStack space="$3" alignItems="center">
<HeaderActionButton onPress={() => navigate(adminPath('/mobile/settings'))} ariaLabel={t('mobileSettings.title', 'Settings')}>
<Settings size={18} color="#0f172a" />
<Settings size={18} color={textStrong} />
</HeaderActionButton>
<HeaderActionButton onPress={() => navigate(0)} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color="#0f172a" />
<RefreshCcw size={18} color={textStrong} />
</HeaderActionButton>
</XStack>
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
<Text fontWeight="700" color={danger}>
{error}
</Text>
</MobileCard>
) : null}
<MobileCard space="$3">
<Text fontSize="$lg" fontWeight="800" color="#111827">
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
{event ? renderName(event.name, t) : t('events.placeholders.untitled', 'Unbenanntes Event')}
</Text>
<XStack alignItems="center" space="$2">
<CalendarDays size={16} color="#6b7280" />
<Text fontSize="$sm" color="#4b5563">
<CalendarDays size={16} color={muted} />
<Text fontSize="$sm" color={muted}>
{formatDate(event?.event_date, t)}
</Text>
<MapPin size={16} color="#6b7280" />
<Text fontSize="$sm" color="#4b5563">
<MapPin size={16} color={muted} />
<Text fontSize="$sm" color={muted}>
{resolveLocation(event, t)}
</Text>
</XStack>
@@ -148,13 +150,13 @@ export default function MobileEventDetailPage() {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: '#e2e8f0',
backgroundColor: accentSoft,
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 6px 16px rgba(0,0,0,0.12)',
}}
>
<Pencil size={18} color="#0f172a" />
<Pencil size={18} color={textStrong} />
</Pressable>
</MobileCard>
@@ -183,7 +185,7 @@ export default function MobileEventDetailPage() {
>
<YStack space="$2">
{events.length === 0 ? (
<Text fontSize={12.5} color="#4b5563">
<Text fontSize={12.5} color={muted}>
{t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
</Text>
) : (
@@ -198,12 +200,12 @@ export default function MobileEventDetailPage() {
>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
<YStack space="$1">
<Text fontSize={13} fontWeight="700" color="#111827">
<Text fontSize={13} fontWeight="700" color={textStrong}>
{renderName(ev.name, t)}
</Text>
<XStack alignItems="center" space="$1.5">
<CalendarDays size={14} color="#6b7280" />
<Text fontSize={12} color="#4b5563">
<CalendarDays size={14} color={muted} />
<Text fontSize={12} color={muted}>
{formatDate(ev.event_date, t)}
</Text>
</XStack>
@@ -219,7 +221,7 @@ export default function MobileEventDetailPage() {
</MobileSheet>
<YStack space="$2">
<Text fontSize="$md" fontWeight="800" color="#111827">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.detail.managementTitle', 'Event Management')}
</Text>
<XStack flexWrap="wrap" space="$2">
@@ -230,55 +232,63 @@ export default function MobileEventDetailPage() {
? t('events.quick.tasks', 'Tasks & Checklists')
: `${t('events.quick.tasks', 'Tasks & Checklists')} (${t('common:states.disabled', 'Disabled')})`
}
color="#60a5fa"
color={ADMIN_ACTION_COLORS.tasks}
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/tasks`))}
delayMs={0}
/>
<ActionTile
icon={QrCode}
label={t('events.quick.qr', 'QR Code Layouts')}
color="#fbbf24"
color={ADMIN_ACTION_COLORS.qr}
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/qr`))}
delayMs={ADMIN_MOTION.tileStaggerMs}
/>
<ActionTile
icon={Image}
label={t('events.quick.images', 'Image Management')}
color="#a855f7"
color={ADMIN_ACTION_COLORS.images}
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/photos`))}
delayMs={ADMIN_MOTION.tileStaggerMs * 2}
/>
<ActionTile
icon={Users}
label={t('events.quick.guests', 'Guest Management')}
color="#4ade80"
color={ADMIN_ACTION_COLORS.guests}
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/members`))}
delayMs={ADMIN_MOTION.tileStaggerMs * 3}
/>
<ActionTile
icon={Megaphone}
label={t('events.quick.guestMessages', 'Guest messages')}
color="#fb923c"
color={ADMIN_ACTION_COLORS.guestMessages}
onPress={() => slug && navigate(ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH(slug))}
disabled={!slug}
delayMs={ADMIN_MOTION.tileStaggerMs * 4}
/>
<ActionTile
icon={Layout}
label={t('events.quick.branding', 'Branding & Theme')}
color="#fb7185"
color={ADMIN_ACTION_COLORS.branding}
onPress={
brandingAllowed ? () => navigate(adminPath(`/mobile/events/${slug ?? ''}/branding`)) : undefined
}
disabled={!brandingAllowed}
delayMs={ADMIN_MOTION.tileStaggerMs * 5}
/>
<ActionTile
icon={Camera}
label={t('events.quick.photobooth', 'Photobooth')}
color="#38bdf8"
color={ADMIN_ACTION_COLORS.photobooth}
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/photobooth`))}
delayMs={ADMIN_MOTION.tileStaggerMs * 6}
/>
{isPastEvent(event?.event_date) ? (
<ActionTile
icon={Sparkles}
label={t('events.quick.recap', 'Recap & Archive')}
color="#f59e0b"
color={ADMIN_ACTION_COLORS.recap}
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/recap`))}
delayMs={ADMIN_MOTION.tileStaggerMs * 7}
/>
) : null}
</XStack>

View File

@@ -16,6 +16,7 @@ import { isAuthError } from '../auth/tokens';
import { getApiValidationMessage, isApiError } from '../lib/apiError';
import toast from 'react-hot-toast';
import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme';
type FormState = {
name: string;
@@ -34,6 +35,7 @@ export default function MobileEventFormPage() {
const isEdit = Boolean(slug);
const navigate = useNavigate();
const { t } = useTranslation(['management', 'common']);
const { text, muted, subtle, danger, border, surfaceMuted } = useAdminTheme();
const [form, setForm] = React.useState<FormState>({
name: '',
@@ -204,7 +206,7 @@ export default function MobileEventFormPage() {
>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
<Text fontWeight="700" color={danger}>
{error}
</Text>
</MobileCard>
@@ -228,15 +230,15 @@ export default function MobileEventFormPage() {
onChange={(e) => setForm((prev) => ({ ...prev, date: e.target.value }))}
style={{ flex: 1 }}
/>
<CalendarDays size={16} color="#9ca3af" />
<CalendarDays size={16} color={subtle} />
</XStack>
</MobileField>
<MobileField label={t('eventForm.fields.type.label', 'Event type')}>
{typesLoading ? (
<Text fontSize="$sm" color="#6b7280">{t('eventForm.fields.type.loading', 'Loading event types…')}</Text>
<Text fontSize="$sm" color={muted}>{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>
<Text fontSize="$sm" color={muted}>{t('eventForm.fields.type.empty', 'No event types available yet. Please add one in the admin area.')}</Text>
) : (
<MobileSelect
value={form.eventTypeId ?? ''}
@@ -269,7 +271,7 @@ export default function MobileEventFormPage() {
placeholder={t('eventForm.fields.location.placeholder', 'Location')}
style={{ flex: 1 }}
/>
<MapPin size={16} color="#9ca3af" />
<MapPin size={16} color={subtle} />
</XStack>
</MobileField>
@@ -285,11 +287,11 @@ export default function MobileEventFormPage() {
>
<Switch.Thumb />
</Switch>
<Text fontSize="$sm" color="#111827">
<Text fontSize="$sm" color={text}>
{form.published ? t('common:states.enabled', 'Enabled') : t('common:states.disabled', 'Disabled')}
</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>
<Text fontSize="$xs" color={muted}>{t('eventForm.fields.publish.help', 'Enable if guests should see the event right away. You can change the status later.')}</Text>
</MobileField>
<MobileField label={t('eventForm.fields.tasksMode.label', 'Tasks & challenges')}>
@@ -304,13 +306,13 @@ export default function MobileEventFormPage() {
>
<Switch.Thumb />
</Switch>
<Text fontSize="$sm" color="#111827">
<Text fontSize="$sm" color={text}>
{form.tasksEnabled
? t('common:states.enabled', 'Enabled')
: t('common:states.disabled', 'Disabled')}
</Text>
</XStack>
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={muted}>
{form.tasksEnabled
? t(
'eventForm.fields.tasksMode.helpOn',
@@ -335,13 +337,13 @@ export default function MobileEventFormPage() {
>
<Switch.Thumb />
</Switch>
<Text fontSize="$sm" color="#111827">
<Text fontSize="$sm" color={text}>
{form.autoApproveUploads
? t('common:states.enabled', 'Enabled')
: t('common:states.disabled', 'Disabled')}
</Text>
</XStack>
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={muted}>
{form.autoApproveUploads
? t(
'eventForm.fields.uploadVisibility.helpOn',
@@ -364,8 +366,8 @@ export default function MobileEventFormPage() {
...inputStyle,
height: 48,
borderRadius: 12,
border: '1px solid #e5e7eb',
background: '#f1f5f9',
border: `1px solid ${border}`,
background: surfaceMuted,
fontWeight: 700,
}}
>

View File

@@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next';
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 { RefreshCcw, Users, User } from 'lucide-react';
import toast from 'react-hot-toast';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
@@ -24,6 +23,7 @@ import { getApiErrorMessage } from '../lib/apiError';
import { adminPath } from '../constants';
import { formatGuestMessageDate } from './guestMessages';
import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme';
type FormState = {
title: string;
@@ -40,7 +40,7 @@ export default function MobileEventGuestNotificationsPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const navigate = useNavigate();
const { t, i18n } = useTranslation('management');
const theme = useTheme();
const { textStrong, text, muted, border, danger } = useAdminTheme();
const { activeEvent, selectEvent } = useEventContext();
const slug = slugParam ?? activeEvent?.slug ?? null;
const [history, setHistory] = React.useState<GuestNotificationSummary[]>([]);
@@ -181,7 +181,7 @@ export default function MobileEventGuestNotificationsPage() {
}
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
const mutedText = String(theme.gray?.val ?? '#6b7280');
const mutedText = muted;
return (
<MobileShell
@@ -191,13 +191,13 @@ export default function MobileEventGuestNotificationsPage() {
onBack={back}
headerActions={
<HeaderActionButton onPress={() => loadHistory()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color={String(theme.color?.val ?? '#111827')} />
<RefreshCcw size={18} color={textStrong} />
</HeaderActionButton>
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color={String(theme.red10?.val ?? '#b91c1c')}>
<Text fontWeight="700" color={danger}>
{error}
</Text>
</MobileCard>
@@ -205,10 +205,10 @@ export default function MobileEventGuestNotificationsPage() {
<div ref={formRef}>
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color={String(theme.color?.val ?? '#111827')}>
{t('guestMessages.composeTitle', 'Send a message')}
</Text>
<YStack space="$2">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('guestMessages.composeTitle', 'Send a message')}
</Text>
<YStack space="$2">
<MobileField label={t('guestMessages.form.title', 'Title')}>
<MobileInput
type="text"
@@ -270,7 +270,7 @@ export default function MobileEventGuestNotificationsPage() {
max={2880}
value={form.expires_in_minutes}
onChange={(e) => setForm((prev) => ({ ...prev, expires_in_minutes: e.target.value }))}
placeholder="60"
placeholder={t('guestMessages.form.expiresPlaceholder', '60')}
/>
</MobileField>
<MobileField label={t('guestMessages.form.priority', 'Priority')}>
@@ -297,17 +297,17 @@ export default function MobileEventGuestNotificationsPage() {
{t('guestMessages.form.validation', 'Add a title and message. Target guests need an identifier.')}
</Text>
) : null}
</YStack>
</YStack>
</MobileCard>
</div>
<MobileCard space="$2">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$md" fontWeight="800" color={String(theme.color?.val ?? '#111827')}>
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('guestMessages.historyTitle', 'Recent messages')}
</Text>
<Pressable onPress={() => loadHistory()}>
<RefreshCcw size={18} color={String(theme.color?.val ?? '#111827')} />
<RefreshCcw size={18} color={textStrong} />
</Pressable>
</XStack>
@@ -319,7 +319,7 @@ export default function MobileEventGuestNotificationsPage() {
</YStack>
) : history.length === 0 ? (
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color={String(theme.color?.val ?? '#111827')}>
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('guestMessages.emptyTitle', 'Send your first guest message')}
</Text>
<Text fontSize="$xs" color={mutedText}>
@@ -334,9 +334,9 @@ export default function MobileEventGuestNotificationsPage() {
) : (
<YStack space="$2">
{history.map((item) => (
<MobileCard key={item.id} space="$2" borderColor={String(theme.borderColor?.val ?? '#e5e7eb')}>
<MobileCard key={item.id} space="$2" borderColor={border}>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="800" color={String(theme.color?.val ?? '#111827')}>
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
{item.title || t('guestMessages.history.untitled', 'Untitled')}
</Text>
<XStack space="$1.5" alignItems="center">
@@ -350,7 +350,7 @@ export default function MobileEventGuestNotificationsPage() {
</PillBadge>
</XStack>
</XStack>
<Text fontSize="$sm" color={String(theme.color?.val ?? '#111827')}>
<Text fontSize="$sm" color={text}>
{item.body ?? t('guestMessages.history.noBody', 'No body provided.')}
</Text>
<XStack alignItems="center" justifyContent="space-between">
@@ -359,8 +359,8 @@ export default function MobileEventGuestNotificationsPage() {
{item.target_identifier ? (
<PillBadge tone="muted">
<XStack alignItems="center" space="$1.5">
<User size={12} color={String(theme.gray10?.val ?? '#6b7280')} />
<Text fontSize="$xs" fontWeight="700" color={String(theme.gray10?.val ?? '#6b7280')}>
<User size={12} color={muted} />
<Text fontSize="$xs" fontWeight="700" color={muted}>
{item.target_identifier}
</Text>
</XStack>
@@ -368,8 +368,8 @@ export default function MobileEventGuestNotificationsPage() {
) : (
<PillBadge tone="muted">
<XStack alignItems="center" space="$1.5">
<Users size={12} color={String(theme.gray10?.val ?? '#6b7280')} />
<Text fontSize="$xs" fontWeight="700" color={String(theme.gray10?.val ?? '#6b7280')}>
<Users size={12} color={muted} />
<Text fontSize="$xs" fontWeight="700" color={muted}>
{t('guestMessages.audience.all', 'All guests')}
</Text>
</XStack>

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { UserPlus, Trash2, Copy, RefreshCcw } from 'lucide-react';
import { 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';
@@ -15,21 +15,24 @@ import toast from 'react-hot-toast';
import { MobileSheet } from './components/Sheet';
import { adminPath } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme';
export default function MobileEventMembersPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const slug = slugParam ?? null;
const navigate = useNavigate();
const { t } = useTranslation('management');
const { textStrong, text, muted, border, primary, danger } = useAdminTheme();
const [members, setMembers] = React.useState<EventMember[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [invite, setInvite] = React.useState({ name: '', email: '', role: 'member' as EventMember['role'] });
const [saving, setSaving] = React.useState(false);
const [inviteLink, setInviteLink] = React.useState<string | null>(null);
const emailInputRef = React.useRef<HTMLInputElement | null>(null);
const [search, setSearch] = React.useState('');
const [statusFilter, setStatusFilter] = React.useState<'all' | 'pending' | 'active' | 'invited'>('all');
const [roleFilter, setRoleFilter] = React.useState<'all' | 'member' | 'tenant_admin'>('all');
const [confirmRemove, setConfirmRemove] = React.useState<EventMember | null>(null);
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
@@ -40,12 +43,6 @@ export default function MobileEventMembersPage() {
try {
const result = await getEventMembers(slug, 1);
setMembers(result.data);
if (result.data.length) {
const pending = result.data.find((m) => m.status === 'pending' && m.permissions?.includes('invite_link'));
if (pending?.email) {
setInviteLink(`mailto:${pending.email}`);
}
}
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Mitglieder konnten nicht geladen werden.')));
@@ -59,13 +56,58 @@ export default function MobileEventMembersPage() {
void load();
}, [load]);
const normalizedEmail = invite.email.trim();
const isEmailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail);
const canInvite = Boolean(normalizedEmail) && isEmailValid && !saving;
const filteredMembers = members.filter((member) => {
if (statusFilter !== 'all') {
const currentStatus = member.status ?? 'pending';
if (currentStatus !== statusFilter) return false;
}
if (roleFilter !== 'all' && member.role !== roleFilter) return false;
if (!search.trim()) return true;
const hay = `${member.name ?? ''} ${member.email ?? ''}`.toLowerCase();
return hay.includes(search.toLowerCase());
});
const statusOptions = [
{ key: 'all', label: t('events.members.filters.statusAll', 'All statuses') },
{ key: 'pending', label: t('events.members.filters.statusPending', 'Pending') },
{ key: 'active', label: t('events.members.filters.statusActive', 'Active') },
{ key: 'invited', label: t('events.members.filters.statusInvited', 'Invited') },
] as const;
const roleOptions = [
{ key: 'all', label: t('events.members.filters.roleAll', 'All roles') },
{ key: 'tenant_admin', label: t('events.members.filters.roleAdmin', 'Admins') },
{ key: 'member', label: t('events.members.filters.roleMember', 'Members') },
] as const;
const resolveStatus = (status?: string) => {
switch (status ?? 'pending') {
case 'active':
return { label: t('events.members.statuses.active', 'Active'), tone: 'success' as const };
case 'invited':
return { label: t('events.members.statuses.invited', 'Invited'), tone: 'warning' as const };
case 'pending':
return { label: t('events.members.statuses.pending', 'Pending'), tone: 'warning' as const };
default:
return { label: t('events.members.statuses.unknown', 'Unknown'), tone: 'muted' as const };
}
};
async function handleInvite() {
if (!slug || !invite.email.trim()) return;
if (!slug || !normalizedEmail) return;
if (!isEmailValid) {
toast.error(t('events.members.emailInvalid', 'Please enter a valid email address.'));
return;
}
setSaving(true);
setError(null);
try {
const member = await inviteEventMember(slug, {
email: invite.email.trim(),
email: normalizedEmail,
name: invite.name.trim() || undefined,
role: invite.role,
});
@@ -104,20 +146,20 @@ export default function MobileEventMembersPage() {
onBack={back}
headerActions={
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color="#0f172a" />
<RefreshCcw size={18} color={textStrong} />
</HeaderActionButton>
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
<Text fontWeight="700" color={danger}>
{error}
</Text>
</MobileCard>
) : null}
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color="#111827">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.members.inviteTitle', 'Invite Member')}
</Text>
<YStack space="$2">
@@ -126,7 +168,7 @@ export default function MobileEventMembersPage() {
type="text"
value={invite.name}
onChange={(e) => setInvite((prev) => ({ ...prev, name: e.target.value }))}
placeholder="Alex Example"
placeholder={t('events.members.namePlaceholder', 'Alex Example')}
/>
</MobileField>
<MobileField label={t('events.members.email', 'Email')}>
@@ -134,7 +176,7 @@ export default function MobileEventMembersPage() {
type="email"
value={invite.email}
onChange={(e) => setInvite((prev) => ({ ...prev, email: e.target.value }))}
placeholder="alex@example.com"
placeholder={t('events.members.emailPlaceholder', 'alex@example.com')}
ref={emailInputRef}
/>
</MobileField>
@@ -147,46 +189,91 @@ export default function MobileEventMembersPage() {
<option value="tenant_admin">{t('events.members.roleAdmin', 'Admin')}</option>
</MobileSelect>
</MobileField>
<CTAButton label={saving ? t('common.saving', 'Saving...') : t('events.members.invite', 'Send Invite')} onPress={() => handleInvite()} />
<CTAButton
label={saving ? t('common.saving', 'Saving...') : t('events.members.invite', 'Send Invite')}
onPress={() => handleInvite()}
disabled={!canInvite}
/>
{saving ? (
<Text fontSize="$xs" color="#4b5563">
<Text fontSize="$xs" color={muted}>
{t('common.processing', 'Processing...')}
</Text>
) : null}
{!isEmailValid && invite.email.trim().length > 0 ? (
<Text fontSize="$xs" color={danger}>
{t('events.members.emailInvalid', 'Please enter a valid email address.')}
</Text>
) : null}
</YStack>
</MobileCard>
<MobileInput
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('events.members.search', 'Search members')}
compact
/>
{members.length > 0 || search.trim().length > 0 ? (
<MobileInput
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('events.members.search', 'Search members')}
compact
/>
) : null}
{members.length > 0 ? (
<YStack space="$2">
<Text fontSize="$xs" fontWeight="700" color={muted}>
{t('events.members.filters.statusLabel', 'Status')}
</Text>
<XStack space="$2" flexWrap="wrap">
{statusOptions.map((option) => {
const isActive = statusFilter === option.key;
return (
<Pressable key={option.key} onPress={() => setStatusFilter(option.key)}>
<XStack
paddingHorizontal="$3"
paddingVertical="$1.5"
borderRadius={999}
borderWidth={1}
borderColor={isActive ? primary : border}
backgroundColor={isActive ? primary : 'transparent'}
>
<Text fontSize="$xs" fontWeight="700" color={isActive ? 'white' : text}>
{option.label}
</Text>
</XStack>
</Pressable>
);
})}
</XStack>
<Text fontSize="$xs" fontWeight="700" color={muted}>
{t('events.members.filters.roleLabel', 'Role')}
</Text>
<XStack space="$2" flexWrap="wrap">
{roleOptions.map((option) => {
const isActive = roleFilter === option.key;
return (
<Pressable key={option.key} onPress={() => setRoleFilter(option.key)}>
<XStack
paddingHorizontal="$3"
paddingVertical="$1.5"
borderRadius={999}
borderWidth={1}
borderColor={isActive ? primary : border}
backgroundColor={isActive ? primary : 'transparent'}
>
<Text fontSize="$xs" fontWeight="700" color={isActive ? 'white' : text}>
{option.label}
</Text>
</XStack>
</Pressable>
);
})}
</XStack>
</YStack>
) : null}
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color="#111827">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.members.listTitle', 'Team & Guests')}
</Text>
{inviteLink ? (
<Pressable
onPress={async () => {
try {
await navigator.clipboard.writeText(inviteLink);
toast.success(t('events.members.copyInvite', 'Einladungslink kopiert'));
} catch {
toast.error(t('events.members.copyInviteFailed', 'Kopieren nicht möglich'));
}
}}
>
<XStack alignItems="center" space="$2" marginBottom="$2">
<Copy size={16} color="#007AFF" />
<Text fontSize="$sm" color="#007AFF">
{t('events.members.copyInviteLabel', 'Invite Link kopieren')}
</Text>
</XStack>
</Pressable>
) : null}
{loading ? (
<YStack space="$2">
{Array.from({ length: 4 }).map((_, idx) => (
@@ -195,72 +282,86 @@ export default function MobileEventMembersPage() {
</YStack>
) : members.length === 0 ? (
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.members.emptyTitle', 'Invite your team')}
</Text>
<Text fontSize="$xs" color="#4b5563">
<Text fontSize="$xs" color={muted}>
{t('events.members.emptyBody', 'Send the first invite so helpers can access the event.')}
</Text>
<CTAButton
label={t('events.members.emptyAction', 'Send first invite')}
onPress={() => emailInputRef.current?.focus()}
fullWidth={false}
/>
</YStack>
) : (
<YStack space="$2">
{members
.filter((member) => {
if (!search.trim()) return true;
const hay = `${member.name ?? ''} ${member.email ?? ''}`.toLowerCase();
return hay.includes(search.toLowerCase());
})
.map((member) => (
<MobileCard key={member.id} padding="$3" borderColor="#e5e7eb">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$1">
<Text fontSize="$sm" fontWeight="700" color="#111827">
{member.name || member.email || 'Gast'}
</Text>
<Text fontSize="$xs" color="#6b7280">
{member.email ?? ''}
</Text>
<XStack space="$1.5" alignItems="center">
<PillBadge tone={member.status === 'pending' ? 'warning' : 'muted'}>
{member.status ?? 'pending'}
</PillBadge>
<PillBadge tone={member.role === 'tenant_admin' ? 'success' : 'muted'}>
{member.role === 'tenant_admin'
? t('events.members.admin', 'Admin')
: t('events.members.member', 'Member')}
</PillBadge>
{filteredMembers.length === 0 ? (
<YStack space="$1.5" padding="$2">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.members.emptyFilteredTitle', 'No matching members')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('events.members.emptyFilteredBody', 'Adjust your search or filters to see members.')}
</Text>
<Pressable
onPress={() => {
setSearch('');
setStatusFilter('all');
setRoleFilter('all');
}}
>
<Text fontSize="$xs" fontWeight="700" color={primary}>
{t('events.members.clearFilters', 'Clear filters')}
</Text>
</Pressable>
</YStack>
) : (
filteredMembers.map((member) => {
const statusInfo = resolveStatus(member.status);
return (
<MobileCard key={member.id} padding="$3" borderColor={border}>
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$1">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{member.name || member.email || t('events.members.fallbackName', 'Guest')}
</Text>
<Text fontSize="$xs" color={muted}>
{member.email ?? ''}
</Text>
<XStack space="$1.5" alignItems="center">
<PillBadge tone={statusInfo.tone}>
{statusInfo.label}
</PillBadge>
<PillBadge tone={member.role === 'tenant_admin' ? 'success' : 'muted'}>
{member.role === 'tenant_admin'
? t('events.members.admin', 'Admin')
: t('events.members.member', 'Member')}
</PillBadge>
</XStack>
</YStack>
<XStack space="$2">
<Pressable
aria-label={t('events.members.copyEmailLabel', 'Copy email')}
onPress={async () => {
if (!member.email) {
toast.error(t('events.members.copyEmailFailed', 'Kopieren nicht möglich'));
return;
}
try {
await navigator.clipboard.writeText(member.email);
toast.success(t('events.members.copyEmail', 'E-Mail kopiert'));
} catch {
toast.error(t('events.members.copyEmailFailed', 'Kopieren nicht möglich'));
}
}}
>
<Copy size={16} color={muted} />
</Pressable>
<Pressable aria-label={t('events.members.remove', 'Remove')} onPress={() => setConfirmRemove(member)}>
<Trash2 size={16} color={danger} />
</Pressable>
</XStack>
</YStack>
<XStack space="$2">
<Pressable
onPress={async () => {
const link = inviteLink || (member.email ? `mailto:${member.email}` : null);
if (!link) {
toast.error(t('events.members.copyInviteFailed', 'Kopieren nicht möglich'));
return;
}
try {
await navigator.clipboard.writeText(link);
toast.success(t('events.members.copyInvite', 'Einladungslink kopiert'));
} catch {
toast.error(t('events.members.copyInviteFailed', 'Kopieren nicht möglich'));
}
}}
>
<Copy size={16} color="#6b7280" />
</Pressable>
<Pressable onPress={() => setConfirmRemove(member)}>
<Trash2 size={16} color="#ef4444" />
</Pressable>
</XStack>
</XStack>
</MobileCard>
))}
</MobileCard>
);
})
)}
</YStack>
)}
</MobileCard>
@@ -282,10 +383,10 @@ export default function MobileEventMembersPage() {
}
bottomOffsetPx={120}
>
<Text fontSize={12.5} color="#4b5563">
<Text fontSize={12.5} color={muted}>
{t('events.members.removeHint', 'Dieses Mitglied verliert den Zugang zum Event.')}
</Text>
<Text fontSize={13} fontWeight="700" color="#111827">
<Text fontSize={13} fontWeight="700" color={textStrong}>
{confirmRemove?.name || confirmRemove?.email}
</Text>
</MobileSheet>

View File

@@ -6,7 +6,6 @@ import { YStack, XStack } from '@tamagui/stacks';
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, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
import {
@@ -24,17 +23,14 @@ import { formatEventDate, resolveEventDisplayName } from '../lib/events';
import toast from 'react-hot-toast';
import { adminPath } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme';
export default function MobileEventPhotoboothPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const slug = slugParam ?? null;
const navigate = useNavigate();
const { t, i18n } = useTranslation('management');
const theme = useTheme();
const text = String(theme.color?.val ?? '#111827');
const muted = String(theme.gray?.val ?? '#4b5563');
const border = String(theme.borderColor?.val ?? '#e5e7eb');
const surface = String(theme.surface?.val ?? '#ffffff');
const { text, muted, border, surface, danger } = useAdminTheme();
const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [status, setStatus] = React.useState<PhotoboothStatus | null>(null);
@@ -177,7 +173,7 @@ export default function MobileEventPhotoboothPage() {
>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
<Text fontWeight="700" color={danger}>
{error}
</Text>
</MobileCard>
@@ -383,13 +379,14 @@ export default function MobileEventPhotoboothPage() {
function CredentialRow({ label, value, border, masked }: { label: string; value: string; border: string; masked?: boolean }) {
const { t } = useTranslation('management');
const { muted, text } = useAdminTheme();
return (
<XStack alignItems="center" justifyContent="space-between" borderWidth={1} borderColor={border} borderRadius="$3" padding="$2">
<YStack>
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={muted}>
{label}
</Text>
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={text}>
{masked ? '••••••••' : value}
</Text>
</YStack>
@@ -403,22 +400,23 @@ function CredentialRow({ label, value, border, masked }: { label: string; value:
}
}}
>
<Copy size={16} color="#6b7280" />
<Copy size={16} color={muted} />
</Pressable>
</XStack>
);
}
function StatusRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
const { text } = useAdminTheme();
return (
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" space="$2">
{icon}
<Text fontSize="$sm" color="#111827">
<Text fontSize="$sm" color={text}>
{label}
</Text>
</XStack>
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={text}>
{value}
</Text>
</XStack>

View File

@@ -29,7 +29,7 @@ import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { MobileSheet } from './components/Sheet';
import { useEventContext } from '../context/EventContext';
import { useTheme } from '@tamagui/core';
import { useAdminTheme } from './theme';
import { buildLimitWarnings } from '../lib/limitWarnings';
import { adminPath } from '../constants';
import { scopeDefaults, selectAddonKeyForScope } from './addons';
@@ -88,15 +88,9 @@ export default function MobileEventPhotosPage() {
const [queuedActions, setQueuedActions] = React.useState<PhotoModerationAction[]>(() => loadPhotoQueue());
const online = useOnlineStatus();
const syncingQueueRef = React.useRef(false);
const theme = useTheme();
const text = String(theme.color?.val ?? '#111827');
const muted = String(theme.gray?.val ?? '#4b5563');
const border = String(theme.borderColor?.val ?? '#e5e7eb');
const infoBg = String(theme.blue3?.val ?? '#e8f1ff');
const infoBorder = String(theme.blue6?.val ?? '#bfdbfe');
const danger = String(theme.red10?.val ?? '#b91c1c');
const surface = String(theme.surface?.val ?? '#ffffff');
const backdrop = String(theme.gray12?.val ?? '#0f172a');
const { text, muted, border, accentSoft, accent, danger, surface, backdrop, primary } = useAdminTheme();
const infoBg = accentSoft;
const infoBorder = accent;
const basePhotosPath = slug ? adminPath(`/mobile/events/${slug}/photos`) : adminPath('/mobile/events');
const photoQuery = React.useMemo(() => {
@@ -858,9 +852,9 @@ export default function MobileEventPhotosPage() {
borderRadius={999}
alignItems="center"
justifyContent="center"
backgroundColor={isSelected ? String(theme.primary?.val ?? '#007AFF') : 'rgba(255,255,255,0.85)'}
backgroundColor={isSelected ? primary : 'rgba(255,255,255,0.85)'}
borderWidth={1}
borderColor={isSelected ? String(theme.primary?.val ?? '#007AFF') : border}
borderColor={isSelected ? primary : border}
>
{isSelected ? <Check size={14} color="white" /> : null}
</XStack>
@@ -896,7 +890,7 @@ export default function MobileEventPhotosPage() {
backgroundColor={surface}
borderWidth={1}
borderColor={border}
shadowColor="#0f172a"
shadowColor={backdrop}
shadowOpacity={0.18}
shadowRadius={16}
shadowOffset={{ width: 0, height: 8 }}
@@ -908,7 +902,7 @@ export default function MobileEventPhotosPage() {
{t('mobilePhotos.selectedCount', '{{count}} selected', { count: selectedIds.length })}
</Text>
<Pressable onPress={() => clearSelection()}>
<Text fontSize="$sm" color={String(theme.primary?.val ?? '#007AFF')} fontWeight="700">
<Text fontSize="$sm" color={primary} fontWeight="700">
{t('common.clear', 'Clear')}
</Text>
</Pressable>
@@ -1167,7 +1161,7 @@ type SwipeActionConfig = {
function PhotoSwipeCard({ photo, disabled = false, onOpen, onModerate, children }: PhotoSwipeCardProps) {
const { t } = useTranslation('management');
const theme = useTheme();
const { successBg, successText, dangerBg, dangerText, infoBg, infoText } = useAdminTheme();
const controls = useAnimationControls();
const dragged = React.useRef(false);
const leftAction = resolvePhotoSwipeAction(photo, 'left');
@@ -1181,23 +1175,23 @@ function PhotoSwipeCard({ photo, disabled = false, onOpen, onModerate, children
if (action === 'approve') {
return {
label: t('photos.actions.approve', 'Approve'),
bg: String(theme.green3?.val ?? '#dcfce7'),
text: String(theme.green11?.val ?? '#166534'),
bg: successBg,
text: successText,
icon: Check,
};
}
if (action === 'hide') {
return {
label: t('photos.actions.hide', 'Hide'),
bg: String(theme.red3?.val ?? '#fee2e2'),
text: String(theme.red11?.val ?? '#b91c1c'),
bg: dangerBg,
text: dangerText,
icon: EyeOff,
};
}
return {
label: t('photos.actions.show', 'Show'),
bg: String(theme.blue3?.val ?? '#dbeafe'),
text: String(theme.blue11?.val ?? '#1d4ed8'),
bg: infoBg,
text: infoText,
icon: Eye,
};
};
@@ -1509,10 +1503,11 @@ function MobileAddonsPicker({
}
function EventAddonList({ addons, textColor, mutedColor }: { addons: EventAddonSummary[]; textColor: string; mutedColor: string }) {
const { border } = useAdminTheme();
return (
<YStack space="$2">
{addons.map((addon) => (
<MobileCard key={addon.id} borderColor="#e5e7eb" space="$1.5">
<MobileCard key={addon.id} borderColor={border} space="$1.5">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="700" color={textColor}>
{addon.label ?? addon.key}

View File

@@ -28,12 +28,14 @@ import { adminPath } from '../constants';
import { selectAddonKeyForScope } from './addons';
import { LegalConsentSheet } from './components/LegalConsentSheet';
import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme';
export default function MobileEventRecapPage() {
const { slug } = useParams<{ slug?: string }>();
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation('management');
const { textStrong, text, muted, subtle, danger, border, primary, accentSoft } = useAdminTheme();
const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [stats, setStats] = React.useState<EventStats | null>(null);
@@ -92,7 +94,7 @@ export default function MobileEventRecapPage() {
return (
<MobileShell activeTab="home" title={t('events.errors.notFoundTitle', 'Event nicht gefunden')} onBack={back}>
<MobileCard>
<Text color="#b91c1c">{t('events.errors.notFoundBody', 'Kehre zur Eventliste zurück und wähle dort ein Event aus.')}</Text>
<Text color={danger}>{t('events.errors.notFoundBody', 'Kehre zur Eventliste zurück und wähle dort ein Event aus.')}</Text>
</MobileCard>
</MobileShell>
);
@@ -202,13 +204,13 @@ export default function MobileEventRecapPage() {
onBack={back}
headerActions={
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color="#0f172a" />
<RefreshCcw size={18} color={textStrong} />
</HeaderActionButton>
}
>
{error ? (
<MobileCard>
<Text color="#b91c1c">{error}</Text>
<Text color={danger}>{error}</Text>
</MobileCard>
) : null}
@@ -223,11 +225,11 @@ export default function MobileEventRecapPage() {
<MobileCard space="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$1">
<Text fontSize="$xs" color="#6b7280" fontWeight="700" letterSpacing={1.2}>
<Text fontSize="$xs" color={muted} fontWeight="700" letterSpacing={1.2}>
{t('events.recap.badge', 'Nachbereitung')}
</Text>
<Text fontSize="$lg" fontWeight="800" color="#0f172a">{resolveName(event.name)}</Text>
<Text fontSize="$sm" color="#6b7280">
<Text fontSize="$lg" fontWeight="800" color={textStrong}>{resolveName(event.name)}</Text>
<Text fontSize="$sm" color={muted}>
{t('events.recap.subtitle', 'Abschluss, Export und Galerie-Laufzeit verwalten.')}
</Text>
</YStack>
@@ -248,7 +250,7 @@ export default function MobileEventRecapPage() {
<MobileCard space="$2">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$md" fontWeight="800" color="#0f172a">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.recap.galleryTitle', 'Galerie-Status')}
</Text>
<PillBadge tone="muted">{t('events.recap.galleryCounts', '{{photos}} Fotos, {{pending}} offen, {{likes}} Likes', galleryCounts)}</PillBadge>
@@ -262,17 +264,17 @@ export default function MobileEventRecapPage() {
<MobileCard space="$2">
<XStack alignItems="center" space="$2">
<Link2 size={16} color="#0f172a" />
<Text fontSize="$md" fontWeight="800" color="#0f172a">
<Link2 size={16} color={textStrong} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.recap.shareLink', 'Gäste-Link')}
</Text>
</XStack>
{guestLink ? (
<Text fontSize="$sm" color="#111827" selectable>
<Text fontSize="$sm" color={text} selectable>
{guestLink}
</Text>
) : (
<Text fontSize="$sm" color="#6b7280">
<Text fontSize="$sm" color={muted}>
{t('events.recap.noPublicUrl', 'Kein Gäste-Link gesetzt. Lege den öffentlichen Link im Event-Setup fest.')}
</Text>
)}
@@ -284,7 +286,11 @@ export default function MobileEventRecapPage() {
</XStack>
{activeInvite?.qr_code_data_url ? (
<XStack space="$2" alignItems="center" marginTop="$2">
<img src={activeInvite.qr_code_data_url} alt="QR" style={{ width: 96, height: 96 }} />
<img
src={activeInvite.qr_code_data_url}
alt={t('events.qr.qrAlt', 'QR code')}
style={{ width: 96, height: 96 }}
/>
<CTAButton label={t('events.recap.downloadQr', 'QR herunterladen')} tone="ghost" onPress={() => downloadQr(activeInvite.qr_code_data_url)} />
</XStack>
) : null}
@@ -292,12 +298,12 @@ export default function MobileEventRecapPage() {
<MobileCard space="$2">
<XStack alignItems="center" space="$2">
<ShoppingCart size={16} color="#0f172a" />
<Text fontSize="$md" fontWeight="800" color="#0f172a">
<ShoppingCart size={16} color={textStrong} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.sections.addons.title', 'Add-ons & Upgrades')}
</Text>
</XStack>
<Text fontSize="$sm" color="#6b7280">
<Text fontSize="$sm" color={muted}>
{t('events.sections.addons.description', 'Zusätzliche Kontingente freischalten.')}
</Text>
<CTAButton
@@ -311,8 +317,8 @@ export default function MobileEventRecapPage() {
<MobileCard space="$2">
<XStack alignItems="center" space="$2">
<Shield size={16} color="#0f172a" />
<Text fontSize="$md" fontWeight="800" color="#0f172a">
<Shield size={16} color={textStrong} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.recap.settingsTitle', 'Gast-Einstellungen')}
</Text>
</XStack>
@@ -330,12 +336,12 @@ export default function MobileEventRecapPage() {
<MobileCard space="$2">
<XStack alignItems="center" space="$2">
<Archive size={16} color="#0f172a" />
<Text fontSize="$md" fontWeight="800" color="#0f172a">
<Archive size={16} color={textStrong} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.recap.archiveTitle', 'Event archivieren')}
</Text>
</XStack>
<Text fontSize="$sm" color="#6b7280">
<Text fontSize="$sm" color={muted}>
{t('events.recap.archiveCopy', 'Schließt die Galerie und markiert das Event als abgeschlossen.')}
</Text>
<CTAButton label={t('events.recap.archive', 'Archivieren')} onPress={() => archiveEvent()} loading={archiveBusy} />
@@ -343,8 +349,8 @@ export default function MobileEventRecapPage() {
<MobileCard space="$2">
<XStack alignItems="center" space="$2">
<Sparkles size={16} color="#0f172a" />
<Text fontSize="$md" fontWeight="800" color="#0f172a">
<Sparkles size={16} color={textStrong} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.recap.feedbackTitle', 'Wie lief das Event?')}
</Text>
</XStack>
@@ -372,12 +378,13 @@ export default function MobileEventRecapPage() {
}
function Stat({ label, value }: { label: string; value: string }) {
const { border, muted, textStrong } = useAdminTheme();
return (
<MobileCard borderColor="#e5e7eb" space="$1.5">
<Text fontSize="$xs" color="#6b7280">
<MobileCard borderColor={border} space="$1.5">
<Text fontSize="$xs" color={muted}>
{label}
</Text>
<Text fontSize="$md" fontWeight="800" color="#0f172a">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{value}
</Text>
</MobileCard>
@@ -385,9 +392,10 @@ function Stat({ label, value }: { label: string; value: string }) {
}
function ToggleRow({ label, value, onToggle }: { label: string; value: boolean; onToggle: (value: boolean) => void }) {
const { textStrong } = useAdminTheme();
return (
<XStack alignItems="center" justifyContent="space-between" marginTop="$1.5">
<Text fontSize="$sm" color="#0f172a">
<Text fontSize="$sm" color={textStrong}>
{label}
</Text>
<input

View File

@@ -36,15 +36,15 @@ import toast from 'react-hot-toast';
import { MobileSheet } from './components/Sheet';
import { Tag } from './components/Tag';
import { useEventContext } from '../context/EventContext';
import { useTheme } from '@tamagui/core';
import { RadioGroup } from '@tamagui/radio-group';
import { useBackNavigation } from './hooks/useBackNavigation';
import { buildTaskSummary } from './lib/taskSummary';
import { buildTaskSectionCounts, type TaskSectionKey } from './lib/taskSectionCounts';
import { useAdminTheme } from './theme';
function InlineSeparator() {
const theme = useTheme();
return <XStack height={1} opacity={0.7} marginLeft="$3" backgroundColor={theme.borderColor?.val ?? '#e5e7eb'} />;
const { border } = useAdminTheme();
return <XStack height={1} opacity={0.7} marginLeft="$3" backgroundColor={border} />;
}
function TaskSummaryCard({
@@ -102,14 +102,7 @@ export default function MobileEventTasksPage() {
const slug = slugParam ?? activeEvent?.slug ?? null;
const navigate = useNavigate();
const { t } = useTranslation('management');
const theme = useTheme();
const text = String(theme.color12?.val ?? theme.color?.val ?? '#e5e7eb');
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#cbd5e1');
const subtle = String(theme.gray8?.val ?? '#94a3b8');
const border = String(theme.borderColor?.val ?? '#334155');
const primary = String(theme.primary?.val ?? '#007AFF');
const danger = String(theme.red10?.val ?? '#ef4444');
const surface = String(theme.surface?.val ?? '#ffffff');
const { textStrong, muted, subtle, border, primary, danger, surface } = useAdminTheme();
const [assignedTasks, setAssignedTasks] = React.useState<TenantTask[]>([]);
const [library, setLibrary] = React.useState<TenantTask[]>([]);
const [collections, setCollections] = React.useState<TenantTaskCollection[]>([]);
@@ -140,6 +133,7 @@ export default function MobileEventTasksPage() {
const [emotionForm, setEmotionForm] = React.useState({ name: '', color: String(border) });
const [savingEmotion, setSavingEmotion] = React.useState(false);
const [showEmotionFilterSheet, setShowEmotionFilterSheet] = React.useState(false);
const text = textStrong;
const assignedRef = React.useRef<HTMLDivElement>(null);
const libraryRef = React.useRef<HTMLDivElement>(null);
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));

View File

@@ -12,10 +12,10 @@ import { getEvents, TenantEvent } from '../api';
import { adminPath } from '../constants';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { useTheme } from '@tamagui/core';
import { useBackNavigation } from './hooks/useBackNavigation';
import { buildEventStatusCounts, filterEventsByStatus, resolveEventStatusKey, type EventStatusKey } from './lib/eventFilters';
import { buildEventListStats } from './lib/eventListStats';
import { useAdminTheme } from './theme';
export default function MobileEventsPage() {
const { t } = useTranslation('management');
@@ -27,14 +27,7 @@ export default function MobileEventsPage() {
const [statusFilter, setStatusFilter] = React.useState<EventStatusKey>('all');
const searchRef = React.useRef<HTMLInputElement>(null);
const back = useBackNavigation();
const theme = useTheme();
const text = String(theme.color?.val ?? '#111827');
const muted = String(theme.gray?.val ?? '#4b5563');
const subtle = String(theme.gray8?.val ?? '#6b7280');
const border = String(theme.borderColor?.val ?? '#e5e7eb');
const primary = String(theme.primary?.val ?? '#007AFF');
const danger = String(theme.red10?.val ?? '#b91c1c');
const surface = String(theme.surface?.val ?? '#ffffff');
const { text, muted, subtle, border, primary, danger, surface, accentSoft, accent } = useAdminTheme();
React.useEffect(() => {
(async () => {
try {
@@ -130,15 +123,9 @@ function EventsList({
onEdit: (slug: string) => void;
}) {
const { t } = useTranslation('management');
const theme = useTheme();
const text = String(theme.color?.val ?? '#111827');
const muted = String(theme.gray?.val ?? '#4b5563');
const subtle = String(theme.gray8?.val ?? '#6b7280');
const border = String(theme.borderColor?.val ?? '#e5e7eb');
const primary = String(theme.primary?.val ?? '#007AFF');
const surface = String(theme.surface?.val ?? '#ffffff');
const activeBg = String(theme.blue3?.val ?? '#e0f2fe');
const activeBorder = String(theme.blue6?.val ?? '#bfdbfe');
const { text, muted, subtle, border, primary, surface, accentSoft, accent } = useAdminTheme();
const activeBg = accentSoft;
const activeBorder = accent;
const statusCounts = React.useMemo(() => buildEventStatusCounts(events), [events]);
const filteredByStatus = React.useMemo(

View File

@@ -9,6 +9,7 @@ import { resolveReturnTarget } from '../lib/returnTo';
import { useInstallPrompt } from './hooks/useInstallPrompt';
import { getInstallBannerDismissed, setInstallBannerDismissed, shouldShowInstallBanner } from './lib/installBanner';
import { MobileInstallBanner } from './components/MobileInstallBanner';
import { ADMIN_GRADIENTS } from './theme';
type LoginResponse = {
token: string;
@@ -124,13 +125,13 @@ 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"
style={safeAreaStyle}
className="flex min-h-screen items-center justify-center px-5 py-10 text-white"
style={{ ...safeAreaStyle, background: ADMIN_GRADIENTS.loginBackground }}
>
<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">
<img src="/logo-transparent-md.png" alt="Fotospiel" className="h-10 w-10" />
<img src="/logo-transparent-md.png" alt={t('auth.logoAlt', 'Fotospiel')} className="h-10 w-10" />
</div>
<h1 className="text-2xl font-semibold tracking-tight">{t('login.panel_title', 'Team Login')}</h1>
<p className="text-sm text-white/70">
@@ -186,7 +187,8 @@ export default function MobileLoginPage() {
<button
type="submit"
disabled={isSubmitting}
className="flex h-12 w-full items-center justify-center gap-2 rounded-2xl bg-gradient-to-r from-[#2563eb] via-[#3b82f6] to-[#22d3ee] text-sm font-semibold text-white shadow-lg shadow-blue-500/25 transition hover:brightness-110 disabled:opacity-70"
className="flex h-12 w-full items-center justify-center gap-2 rounded-2xl text-sm font-semibold text-white shadow-lg shadow-rose-500/25 transition hover:brightness-110 disabled:opacity-70"
style={{ background: ADMIN_GRADIENTS.primaryCta }}
>
<Loader2 className={`h-4 w-4 animate-spin ${isSubmitting ? 'opacity-100' : 'opacity-0'}`} />
<span>{isSubmitting ? t('login.loading', 'Anmeldung …') : t('login.submit', 'Anmelden')}</span>

View File

@@ -15,13 +15,13 @@ import { getApiErrorMessage } from '../lib/apiError';
import toast from 'react-hot-toast';
import { MobileSheet } from './components/Sheet';
import { getEvents, TenantEvent } from '../api';
import { useTheme } from '@tamagui/core';
import { triggerHaptic } from './lib/haptics';
import { adminPath } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation';
import { groupNotificationsByScope, type NotificationScope, type NotificationGroup } from './lib/notificationGrouping';
import { collectUnreadIds } from './lib/notificationUnread';
import { formatRelativeTime } from './lib/relativeTime';
import { useAdminTheme } from './theme';
type NotificationItem = {
id: string;
@@ -44,13 +44,13 @@ type NotificationSwipeRowProps = {
function NotificationSwipeRow({ item, onOpen, onMarkRead, children }: NotificationSwipeRowProps) {
const { t } = useTranslation('management');
const theme = useTheme();
const { successBg, successText, infoBg, infoText } = useAdminTheme();
const controls = useAnimationControls();
const dragged = React.useRef(false);
const markBg = String(theme.green3?.val ?? '#dcfce7');
const markText = String(theme.green10?.val ?? '#166534');
const detailBg = String(theme.blue3?.val ?? '#dbeafe');
const detailText = String(theme.blue10?.val ?? '#1d4ed8');
const markBg = successBg;
const markText = successText;
const detailBg = infoBg;
const detailText = infoText;
const handleDrag = (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
dragged.current = Math.abs(info.offset.x) > 6;
@@ -326,16 +326,10 @@ export default function MobileNotificationsPage() {
const [events, setEvents] = React.useState<TenantEvent[]>([]);
const [showEventPicker, setShowEventPicker] = React.useState(false);
const back = useBackNavigation(adminPath('/mobile/dashboard'));
const theme = useTheme();
const text = String(theme.color?.val ?? '#111827');
const muted = String(theme.gray?.val ?? '#4b5563');
const border = String(theme.borderColor?.val ?? '#e5e7eb');
const warningBg = String(theme.yellow3?.val ?? '#fef3c7');
const warningIcon = String(theme.yellow11?.val ?? '#92400e');
const infoBg = String(theme.blue3?.val ?? '#e0f2fe');
const infoIcon = String(theme.primary?.val ?? '#2563eb');
const errorText = String(theme.red10?.val ?? '#b91c1c');
const primary = String(theme.primary?.val ?? '#007AFF');
const { text, muted, border, warningBg, warningText, infoBg, primary, danger, accentSoft, subtle } = useAdminTheme();
const warningIcon = warningText;
const infoIcon = primary;
const errorText = danger;
const reload = React.useCallback(async () => {
setLoading(true);
@@ -579,7 +573,7 @@ export default function MobileNotificationsPage() {
borderRadius={14}
borderWidth={1}
borderColor={active ? primary : border}
backgroundColor={active ? String(theme.blue3?.val ?? '#e0f2fe') : 'transparent'}
backgroundColor={active ? accentSoft : 'transparent'}
>
<Text fontSize="$xs" fontWeight="700" color={active ? primary : muted}>
{filter.label}
@@ -598,7 +592,7 @@ export default function MobileNotificationsPage() {
</YStack>
) : statusFiltered.length === 0 ? (
<MobileCard alignItems="center" justifyContent="center" space="$2">
<Bell size={24} color={String(theme.gray9?.val ?? '#9ca3af')} />
<Bell size={24} color={subtle} />
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('mobileNotifications.emptyTitle', 'All caught up')}
</Text>

View File

@@ -5,7 +5,6 @@ import { LogOut, User, Settings, Shield, Globe, Moon } 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 { useTheme } from '@tamagui/core';
import { YGroup } from '@tamagui/group';
import { ListItem } from '@tamagui/list-item';
import { MobileShell } from './components/MobileShell';
@@ -17,18 +16,18 @@ import { adminPath } from '../constants';
import i18n from '../i18n';
import { useAppearance } from '@/hooks/use-appearance';
import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme';
export default function MobileProfilePage() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const { t } = useTranslation('management');
const { appearance, updateAppearance } = useAppearance();
const theme = useTheme();
const textColor = String(theme.color?.val ?? '#111827');
const mutedText = String(theme.gray?.val ?? '#4b5563');
const borderColor = String(theme.borderColor?.val ?? '#e5e7eb');
const avatarBg = String(theme.surface?.val ?? '#e0f2fe');
const primary = String(theme.primary?.val ?? '#2563eb');
const { textStrong, muted, border, accentSoft, primary, subtle } = useAdminTheme();
const textColor = textStrong;
const mutedText = muted;
const borderColor = border;
const avatarBg = accentSoft;
const back = useBackNavigation(adminPath('/mobile/dashboard'));
const [name, setName] = React.useState(user?.name ?? t('events.members.roles.guest', 'Guest'));
@@ -96,7 +95,7 @@ export default function MobileProfilePage() {
{t('mobileProfile.account', 'Account & security')}
</Text>
}
iconAfter={<Settings size={18} color="#9ca3af" />}
iconAfter={<Settings size={18} color={subtle} />}
/>
</Pressable>
</YGroup.Item>
@@ -112,7 +111,7 @@ export default function MobileProfilePage() {
{t('billing.sections.packages.title', 'Packages & Billing')}
</Text>
}
iconAfter={<Settings size={18} color="#9ca3af" />}
iconAfter={<Settings size={18} color={subtle} />}
/>
</Pressable>
</YGroup.Item>
@@ -128,7 +127,7 @@ export default function MobileProfilePage() {
{t('billing.sections.invoices.title', 'Invoices & Payments')}
</Text>
}
iconAfter={<Settings size={18} color="#9ca3af" />}
iconAfter={<Settings size={18} color={subtle} />}
/>
</Pressable>
</YGroup.Item>
@@ -138,7 +137,7 @@ export default function MobileProfilePage() {
paddingHorizontal="$3"
title={
<XStack space="$2" alignItems="center">
<Globe size={16} color="#6b7280" />
<Globe size={16} color={muted} />
<Text fontSize="$sm" color={textColor}>
{t('mobileProfile.language', 'Language')}
</Text>
@@ -167,7 +166,7 @@ export default function MobileProfilePage() {
paddingHorizontal="$3"
title={
<XStack space="$2" alignItems="center">
<Moon size={16} color="#6b7280" />
<Moon size={16} color={muted} />
<Text fontSize="$sm" color={textColor}>
{t('mobileProfile.theme', 'Theme')}
</Text>

View File

@@ -34,14 +34,32 @@ import { LayoutElement, CANVAS_WIDTH, CANVAS_HEIGHT } from './invite-layout/sche
import { buildInitialTextFields } from './qr/utils';
import { adminPath } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation';
import { ADMIN_COLORS, ADMIN_GRADIENTS, useAdminTheme } from './theme';
type Step = 'background' | 'text' | 'preview';
const BACKGROUND_PRESETS = [
{ id: 'bg-blue-floral', src: '/storage/layouts/backgrounds-portrait/bg-blue-floral.png', label: 'Blue Floral' },
{ id: 'bg-goldframe', src: '/storage/layouts/backgrounds-portrait/bg-goldframe.png', label: 'Gold Frame' },
{ id: 'gr-green-floral', src: '/storage/layouts/backgrounds-portrait/gr-green-floral.png', label: 'Green Floral' },
{
id: 'bg-blue-floral',
src: '/storage/layouts/backgrounds-portrait/bg-blue-floral.png',
labelKey: 'events.qr.backgroundPresets.blueFloral',
label: 'Blue Floral',
},
{
id: 'bg-goldframe',
src: '/storage/layouts/backgrounds-portrait/bg-goldframe.png',
labelKey: 'events.qr.backgroundPresets.goldFrame',
label: 'Gold Frame',
},
{
id: 'gr-green-floral',
src: '/storage/layouts/backgrounds-portrait/gr-green-floral.png',
labelKey: 'events.qr.backgroundPresets.greenFloral',
label: 'Green Floral',
},
];
const DEFAULT_BODY_FONT = 'Manrope';
const DEFAULT_DISPLAY_FONT = 'Fraunces';
export default function MobileQrLayoutCustomizePage() {
const { slug: slugParam, tokenId: tokenParam } = useParams<{ slug?: string; tokenId?: string }>();
@@ -51,6 +69,7 @@ export default function MobileQrLayoutCustomizePage() {
const layoutParam = searchParams.get('layout');
const navigate = useNavigate();
const { t } = useTranslation('management');
const { textStrong, danger } = useAdminTheme();
const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [invite, setInvite] = React.useState<EventQrInvite | null>(null);
@@ -187,13 +206,13 @@ export default function MobileQrLayoutCustomizePage() {
onBack={back}
headerActions={
<HeaderActionButton onPress={() => window.location.reload()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color="#0f172a" />
<RefreshCcw size={18} color={textStrong} />
</HeaderActionButton>
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
<Text fontWeight="700" color={danger}>
{error}
</Text>
</MobileCard>
@@ -259,6 +278,7 @@ export default function MobileQrLayoutCustomizePage() {
function Stepper({ current, onStepChange }: { current: Step; onStepChange: (step: Step) => void }) {
const { t } = useTranslation('management');
const { textStrong, muted, border, primary, accentSoft, surface, successText } = useAdminTheme();
const steps: { key: Step; label: string }[] = [
{ key: 'background', label: t('events.qr.background', 'Hintergrund') },
{ key: 'text', label: t('events.qr.text', 'Text') },
@@ -278,16 +298,16 @@ function Stepper({ current, onStepChange }: { current: Step; onStepChange: (step
<YStack
borderRadius={12}
borderWidth={1}
borderColor={active ? '#2563EB' : completed ? '#16a34a' : '#e5e7eb'}
backgroundColor={active ? '#eff6ff' : '#fff'}
borderColor={active ? primary : completed ? successText : border}
backgroundColor={active ? accentSoft : surface}
padding="$2"
alignItems="center"
justifyContent="center"
>
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{step.label}
</Text>
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={muted}>
{t('events.qr.step', 'Schritt')} {idx + 1}
</Text>
</YStack>
@@ -295,8 +315,8 @@ function Stepper({ current, onStepChange }: { current: Step; onStepChange: (step
);
})}
</XStack>
<YStack height={8} borderRadius={999} backgroundColor="#e5e7eb" overflow="hidden">
<YStack height="100%" width={`${progress}%`} backgroundColor="#2563EB" />
<YStack height={8} borderRadius={999} backgroundColor={border} overflow="hidden">
<YStack height="100%" width={`${progress}%`} backgroundColor={primary} />
</YStack>
</YStack>
);
@@ -400,10 +420,10 @@ function renderEventName(name: TenantEvent['name'] | null | undefined): string |
function getDefaultSlots(): Record<string, SlotDefinition> {
return {
headline: { x: 0.08, y: 0.12, w: 0.84, fontSize: 90, fontWeight: 800, fontFamily: 'Playfair Display', align: 'center' },
subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: 'Montserrat', align: 'center' },
description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 26, fontFamily: 'Lora', lineHeight: 1.4, align: 'center' },
instructions: { x: 0.1, y: 0.7, w: 0.8, fontSize: 24, fontFamily: 'Lora', lineHeight: 1.5 },
headline: { x: 0.08, y: 0.12, w: 0.84, fontSize: 90, fontWeight: 800, fontFamily: DEFAULT_DISPLAY_FONT, align: 'center' },
subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: DEFAULT_BODY_FONT, align: 'center' },
description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 26, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.4, align: 'center' },
instructions: { x: 0.1, y: 0.7, w: 0.8, fontSize: 24, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.5 },
qr: { x: 0.39, y: 0.37, w: 0.27 },
};
}
@@ -433,10 +453,10 @@ function resolveSlots(layout: EventQrInviteLayout | null, isFoldable: boolean, o
const baseSlots = isFoldable
? {
headline: { x: 0.08, y: 0.15, w: 0.84, fontSize: 50, fontWeight: 800, fontFamily: 'Playfair Display', align: 'center' },
subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: 'Montserrat', align: 'center' },
description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 13, fontFamily: 'Lora', lineHeight: 1.4, align: 'center' },
instructions: { x: 0.12, y: 0.36, w: 0.76, fontSize: 13, fontFamily: 'Lora', lineHeight: 1.3 },
headline: { x: 0.08, y: 0.15, w: 0.84, fontSize: 50, fontWeight: 800, fontFamily: DEFAULT_DISPLAY_FONT, align: 'center' },
subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: DEFAULT_BODY_FONT, align: 'center' },
description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 13, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.4, align: 'center' },
instructions: { x: 0.12, y: 0.36, w: 0.76, fontSize: 13, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.3 },
qr: { x: 0.3, y: 0.3, w: 0.28 },
}
: getDefaultSlots();
@@ -498,9 +518,9 @@ function buildFabricOptions({
const slots = resolveSlots(layout, isFoldable, slotOverrides);
const elements: LayoutElement[] = [];
const textColor = layout?.preview?.text ?? '#0f172a';
const accentColor = layout?.preview?.accent ?? '#2563EB';
const secondaryColor = layout?.preview?.secondary ?? '#1f2937';
const textColor = layout?.preview?.text ?? ADMIN_COLORS.backdrop;
const accentColor = layout?.preview?.accent ?? ADMIN_COLORS.primary;
const secondaryColor = layout?.preview?.secondary ?? ADMIN_COLORS.text;
const badgeColor = layout?.preview?.badge ?? accentColor;
const rotatePoint = (cx: number, cy: number, x: number, y: number, angleDeg: number) => {
@@ -617,7 +637,7 @@ function buildFabricOptions({
badgeColor,
qrCodeDataUrl: qrPngUrl ?? (qrUrl ? `https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=${encodeURIComponent(qrUrl)}` : null),
logoDataUrl: null,
backgroundColor: backgroundSolid ?? layout?.preview?.background ?? '#ffffff',
backgroundColor: backgroundSolid ?? layout?.preview?.background ?? ADMIN_COLORS.surface,
backgroundGradient,
backgroundImageUrl,
readOnly: true,
@@ -639,7 +659,7 @@ function BackgroundStep({
saving,
}: {
onBack: () => void;
presets: { id: string; src: string; label: string }[];
presets: { id: string; src: string; labelKey: string; label: string }[];
selectedPreset: string | null;
onSelectPreset: (id: string) => void;
selectedLayout: EventQrInviteLayout | null;
@@ -652,6 +672,7 @@ function BackgroundStep({
saving: boolean;
}) {
const { t } = useTranslation('management');
const { textStrong, muted, border, primary, accentSoft, surface, surfaceMuted } = useAdminTheme();
const resolvedLayout =
selectedLayout ??
layouts.find((layout) => {
@@ -664,25 +685,55 @@ function BackgroundStep({
const isFoldable = (resolvedLayout?.panel_mode ?? '').toLowerCase() === 'double-mirror';
const formatPaper = resolvePaper(resolvedLayout).toUpperCase();
const orientation = ((resolvedLayout?.orientation ?? (isFoldable ? 'landscape' : 'portrait')) || '').toLowerCase() === 'landscape' ? 'Landscape' : 'Portrait';
const isLandscape = ((resolvedLayout?.orientation ?? (isFoldable ? 'landscape' : 'portrait')) || '').toLowerCase() === 'landscape';
const orientationLabel = isLandscape
? t('events.qr.orientation.landscape', 'Landscape')
: t('events.qr.orientation.portrait', 'Portrait');
const formatLabel = isFoldable
? `${formatPaper} Landscape (double A5/mirrored)`
: `${formatPaper} ${orientation}`;
? t('events.qr.formatLabel.foldable', '{{paper}} {{orientation}} (double A5/mirrored)', {
paper: formatPaper,
orientation: orientationLabel,
})
: t('events.qr.formatLabel.standard', '{{paper}} {{orientation}}', {
paper: formatPaper,
orientation: orientationLabel,
});
const disablePresets = isFoldable;
const gradientPresets = [
{ angle: 180, stops: ['#F8FAFC', '#EEF2FF', '#F8FAFC'], label: 'Soft Lilac' },
{ angle: 135, stops: ['#FEE2E2', '#EFF6FF', '#ECFDF3'], label: 'Pastell' },
{ angle: 210, stops: ['#0B132B', '#1C2541', '#274690'], label: 'Midnight' },
{
angle: 180,
stops: ['#F8FAFC', '#EEF2FF', '#F8FAFC'],
labelKey: 'events.qr.gradientPresets.softLilac',
label: 'Soft Lilac',
},
{
angle: 135,
stops: ['#FEE2E2', '#EFF6FF', '#ECFDF3'],
labelKey: 'events.qr.gradientPresets.pastel',
label: 'Pastel',
},
{
angle: 210,
stops: ['#0B132B', '#1C2541', '#274690'],
labelKey: 'events.qr.gradientPresets.midnight',
label: 'Midnight',
},
];
const solidPresets = [
ADMIN_COLORS.surface,
ADMIN_COLORS.surfaceMuted,
ADMIN_COLORS.backdrop,
ADMIN_COLORS.primary,
ADMIN_COLORS.warning,
];
const solidPresets = ['#FFFFFF', '#F8FAFC', '#0F172A', '#2563EB', '#F59E0B'];
return (
<YStack space="$3" marginTop="$2">
<XStack alignItems="center" space="$2">
<Pressable onPress={onBack}>
<XStack alignItems="center" space="$1">
<ArrowLeft size={16} color="#111827" />
<Text fontSize="$sm" color="#111827">
<ArrowLeft size={16} color={textStrong} />
<Text fontSize="$sm" color={textStrong}>
{t('common.back', 'Zurück')}
</Text>
</XStack>
@@ -691,10 +742,10 @@ function BackgroundStep({
</XStack>
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{disablePresets
? t('events.qr.backgroundPicker', 'Hintergrund für A5 (Gradient/Farbe)')
: t('events.qr.backgroundPicker', `Hintergrund auswählen (${formatLabel})`)}
? t('events.qr.backgroundPickerFoldable', 'Hintergrund für A5 (Gradient/Farbe)')
: t('events.qr.backgroundPicker', 'Hintergrund auswählen ({{formatLabel}})', { formatLabel })}
</Text>
{!disablePresets ? (
<>
@@ -709,8 +760,8 @@ function BackgroundStep({
borderRadius={14}
overflow="hidden"
borderWidth={isSelected ? 2 : 1}
borderColor={isSelected ? '#2563EB' : '#e5e7eb'}
backgroundColor="#f8fafc"
borderColor={isSelected ? primary : border}
backgroundColor={surfaceMuted}
>
<YStack
flex={1}
@@ -719,8 +770,8 @@ function BackgroundStep({
backgroundPosition="center"
/>
<XStack padding="$2" justifyContent="space-between" alignItems="center" backgroundColor="rgba(255,255,255,0.92)">
<Text fontSize="$xs" color="#111827">
{preset.label}
<Text fontSize="$xs" color={textStrong}>
{t(preset.labelKey, preset.label)}
</Text>
{isSelected ? <PillBadge tone="muted">{t('common.selected', 'Ausgewählt')}</PillBadge> : null}
</XStack>
@@ -729,20 +780,20 @@ function BackgroundStep({
);
})}
</XStack>
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={muted}>
{t('events.qr.backgroundNote', 'Diese Presets sind für A4 Hochformat. Spiegelung erfolgt automatisch bei Tischkarten.')}
</Text>
</>
) : (
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={muted}>
{t('events.qr.foldableBackgroundNote', 'Für A5 Tischkarten bitte Gradient oder Vollfarbe wählen.')}
</Text>
)}
<YStack space="$2" marginTop="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
{t('events.qr.gradients', 'Gradienten')}
</Text>
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.gradients', 'Gradienten')}
</Text>
<XStack flexWrap="wrap" gap="$2">
{gradientPresets.map((gradient, idx) => {
const isSelected =
@@ -753,13 +804,18 @@ function BackgroundStep({
backgroundImage: `linear-gradient(${gradient.angle}deg, ${gradient.stops.join(',')})`,
} as React.CSSProperties;
return (
<Pressable key={idx} onPress={() => onSelectGradient(gradient)} style={{ width: '30%' }}>
<Pressable
key={idx}
onPress={() => onSelectGradient(gradient)}
style={{ width: '30%' }}
aria-label={t(gradient.labelKey, gradient.label)}
>
<YStack
height={70}
borderRadius={12}
overflow="hidden"
borderWidth={isSelected ? 2 : 1}
borderColor={isSelected ? '#2563EB' : '#e5e7eb'}
borderColor={isSelected ? primary : border}
style={style}
/>
</Pressable>
@@ -769,7 +825,7 @@ function BackgroundStep({
</YStack>
<YStack space="$2" marginTop="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.colors', 'Vollfarbe')}
</Text>
<XStack flexWrap="wrap" gap="$2">
@@ -782,7 +838,7 @@ function BackgroundStep({
borderRadius={12}
overflow="hidden"
borderWidth={isSelected ? 2 : 1}
borderColor={isSelected ? '#2563EB' : '#e5e7eb'}
borderColor={isSelected ? primary : border}
backgroundColor={color}
/>
</Pressable>
@@ -840,8 +896,8 @@ function TextStep({
<XStack alignItems="center" space="$2">
<Pressable onPress={onBack}>
<XStack alignItems="center" space="$1">
<ArrowLeft size={16} color="#111827" />
<Text fontSize="$sm" color="#111827">
<ArrowLeft size={16} color={textStrong} />
<Text fontSize="$sm" color={textStrong}>
{t('common.back', 'Zurück')}
</Text>
</XStack>
@@ -850,7 +906,7 @@ function TextStep({
</XStack>
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.textFields', 'Texte')}
</Text>
<Input
@@ -875,9 +931,9 @@ function TextStep({
</YStack>
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
{t('events.qr.instructions', 'Anleitung')}
</Text>
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.instructions', 'Anleitung')}
</Text>
{textFields.instructions.map((item, idx) => (
<XStack key={idx} alignItems="center" space="$2">
<TextArea
@@ -896,10 +952,10 @@ function TextStep({
alignItems="center"
justifyContent="center"
borderWidth={1}
borderColor="#e5e7eb"
backgroundColor="#fff"
borderColor={border}
backgroundColor={surface}
>
<Text fontSize="$md" fontWeight="800" color="#111827">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
</Text>
</XStack>
@@ -913,10 +969,10 @@ function TextStep({
alignItems="center"
justifyContent="center"
borderWidth={1}
borderColor="#e5e7eb"
backgroundColor="#fff"
borderColor={border}
backgroundColor={surface}
>
<Plus size={18} color="#111827" />
<Plus size={18} color={textStrong} />
</XStack>
</Pressable>
) : null}
@@ -953,7 +1009,7 @@ function PreviewStep({
backgroundPreset: string | null;
backgroundGradient: { angle: number; stops: string[] } | null;
backgroundSolid: string | null;
presets: { id: string; src: string; label: string }[];
presets: { id: string; src: string; labelKey: string; label: string }[];
textFields: { headline: string; subtitle: string; description: string; instructions: string[] };
qrUrl: string;
qrPngUrl?: string | null;
@@ -963,6 +1019,7 @@ function PreviewStep({
tenantFonts: TenantFont[];
}) {
const { t } = useTranslation('management');
const { textStrong, muted, border, surface } = useAdminTheme();
const presetSrc = backgroundPreset ? presets.find((p) => p.id === backgroundPreset)?.src ?? null : null;
const resolvedBgGradient =
backgroundGradient ??
@@ -1039,7 +1096,10 @@ function PreviewStep({
const aspectRatio = `${canvasBase.width}/${canvasBase.height}`;
const paper = resolvePaper(layout);
const orientation = ((layout?.orientation ?? (isFoldable ? 'landscape' : 'portrait')) || '').toLowerCase() === 'landscape' ? 'landscape' : 'portrait';
const isLandscape = ((layout?.orientation ?? (isFoldable ? 'landscape' : 'portrait')) || '').toLowerCase() === 'landscape';
const orientationLabel = isLandscape
? t('events.qr.orientation.landscape', 'Landscape')
: t('events.qr.orientation.portrait', 'Portrait');
const qrImageSrc = qrPngUrl ?? qrUrl ?? '';
return (
@@ -1047,19 +1107,19 @@ function PreviewStep({
<XStack alignItems="center" space="$2">
<Pressable onPress={onBack}>
<XStack alignItems="center" space="$1">
<ArrowLeft size={16} color="#111827" />
<Text fontSize="$sm" color="#111827">
<ArrowLeft size={16} color={textStrong} />
<Text fontSize="$sm" color={textStrong}>
{t('common.back', 'Zurück')}
</Text>
</XStack>
</Pressable>
<PillBadge tone="muted">
{paper.toUpperCase()} {orientation === 'landscape' ? 'Landscape' : 'Portrait'}
{paper.toUpperCase()} {orientationLabel}
</PillBadge>
</XStack>
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.preview', 'Vorschau')}
</Text>
<YStack
@@ -1068,22 +1128,22 @@ function PreviewStep({
borderRadius={16}
overflow="hidden"
borderWidth={1}
borderColor="#e5e7eb"
backgroundColor="#fff"
borderColor={border}
backgroundColor={surface}
style={{ aspectRatio }}
alignItems="center"
justifyContent="center"
>
{previewLoading ? (
<Text color="#6b7280">{t('common.loading', 'Lädt Vorschau …')}</Text>
<Text color={muted}>{t('common.loading', 'Lädt Vorschau …')}</Text>
) : previewUrl ? (
<img
src={previewUrl}
alt="QR Layout Preview"
alt={t('events.qr.previewAlt', 'QR layout preview')}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<Text color="#6b7280">{t('events.qr.missing', 'Kein QR-Link vorhanden')}</Text>
<Text color={muted}>{t('events.qr.missing', 'Kein QR-Link vorhanden')}</Text>
)}
</YStack>
</YStack>
@@ -1140,8 +1200,9 @@ function LayoutControls({
}) {
const { t } = useTranslation('management');
const [openColorSlot, setOpenColorSlot] = React.useState<string | null>(null);
const { border, surface, surfaceMuted, text, textStrong, muted, overlay, shadow, danger } = useAdminTheme();
const fontOptions = React.useMemo(() => {
const preset = ['Playfair Display', 'Lora', 'Montserrat', 'Inter', 'Roboto'];
const preset = ['Fraunces', 'Manrope', 'Inter', 'Roboto', 'Lora'];
const tenant = tenantFonts.map((font) => font.family);
return Array.from(new Set([...tenant, ...preset]));
}, [tenantFonts]);
@@ -1149,20 +1210,24 @@ function LayoutControls({
const numberInputStyle: React.CSSProperties = {
width: 90,
padding: '10px 12px',
border: '1px solid #e5e7eb',
borderRadius: 10,
border: `1px solid ${border}`,
borderRadius: 12,
fontSize: 14,
background: '#fff',
fontFamily: DEFAULT_BODY_FONT,
background: surfaceMuted,
color: text,
appearance: 'textfield',
};
const selectStyle: React.CSSProperties = {
width: '100%',
padding: '10px 12px',
border: '1px solid #e5e7eb',
borderRadius: 10,
border: `1px solid ${border}`,
borderRadius: 12,
fontSize: 14,
background: '#fff',
fontFamily: DEFAULT_BODY_FONT,
background: surfaceMuted,
color: text,
};
const StepperInput = ({
@@ -1185,8 +1250,8 @@ function LayoutControls({
return (
<XStack space="$1" alignItems="center">
<Pressable onPress={dec}>
<XStack width={36} height={36} borderRadius={10} alignItems="center" justifyContent="center" borderWidth={1} borderColor="#e5e7eb" backgroundColor="#fff">
<Text fontSize="$md" fontWeight="800" color="#111827">
<XStack width={36} height={36} borderRadius={10} alignItems="center" justifyContent="center" borderWidth={1} borderColor={border} backgroundColor={surface}>
<Text fontSize="$md" fontWeight="800" color={textStrong}>
</Text>
</XStack>
@@ -1206,8 +1271,8 @@ function LayoutControls({
style={numberInputStyle}
/>
<Pressable onPress={inc}>
<XStack width={36} height={36} borderRadius={10} alignItems="center" justifyContent="center" borderWidth={1} borderColor="#e5e7eb" backgroundColor="#fff">
<Text fontSize="$md" fontWeight="800" color="#111827">
<XStack width={36} height={36} borderRadius={10} alignItems="center" justifyContent="center" borderWidth={1} borderColor={border} backgroundColor={surface}>
<Text fontSize="$md" fontWeight="800" color={textStrong}>
+
</Text>
</XStack>
@@ -1250,20 +1315,20 @@ function LayoutControls({
return (
<Accordion.Item value={slotKey} key={slotKey}>
<Accordion.Trigger padding="$2" borderWidth={1} borderColor="#e5e7eb" borderRadius={12} backgroundColor="#f8fafc">
<Accordion.Trigger padding="$2" borderWidth={1} borderColor={border} borderRadius={12} backgroundColor={surfaceMuted}>
<XStack justifyContent="space-between" alignItems="center" flex={1}>
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{label}
</Text>
<ChevronDown size={16} color="#6b7280" />
<ChevronDown size={16} color={muted} />
</XStack>
</Accordion.Trigger>
<Accordion.Content paddingTop="$2">
<YStack space="$2" padding="$2" borderWidth={1} borderColor="#e5e7eb" borderRadius={12}>
<YStack space="$2" padding="$2" borderWidth={1} borderColor={border} borderRadius={12}>
<XStack space="$3">
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
X (%)
<Text fontSize="$xs" color={muted}>
{t('events.qr.positionX', 'X (%)')}
</Text>
<XStack space="$2" alignItems="center">
<StepperInput
@@ -1279,10 +1344,10 @@ function LayoutControls({
paddingVertical="$2"
borderRadius={10}
borderWidth={1}
borderColor="#e5e7eb"
backgroundColor="#fff"
borderColor={border}
backgroundColor={surface}
>
<Text fontSize="$xs" color="#111827">
<Text fontSize="$xs" color={textStrong}>
{t('common.center', 'Zentrieren')}
</Text>
</XStack>
@@ -1290,8 +1355,8 @@ function LayoutControls({
</XStack>
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
Y (%)
<Text fontSize="$xs" color={muted}>
{t('events.qr.positionY', 'Y (%)')}
</Text>
<StepperInput
value={currentY * 100}
@@ -1302,8 +1367,8 @@ function LayoutControls({
/>
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
Breite (%)
<Text fontSize="$xs" color={muted}>
{t('events.qr.width', 'Breite (%)')}
</Text>
<StepperInput
value={currentW * 100}
@@ -1317,14 +1382,14 @@ function LayoutControls({
<XStack space="$3">
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
Font Size (px)
<Text fontSize="$xs" color={muted}>
{t('events.qr.fontSize', 'Font Size (px)')}
</Text>
<StepperInput value={override.fontSize ?? slot.fontSize ?? 16} min={8} max={200} step={1} onChange={(val) => onFontSizeChange(val)} />
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
Font Family
<Text fontSize="$xs" color={muted}>
{t('events.qr.fontFamily', 'Font Family')}
</Text>
<select
value={override.fontFamily ?? slot.fontFamily ?? ''}
@@ -1340,7 +1405,7 @@ function LayoutControls({
</select>
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={muted}>
{t('events.qr.fontColor', 'Schriftfarbe')}
</Text>
<YStack space="$2">
@@ -1351,14 +1416,14 @@ function LayoutControls({
height={36}
borderRadius={10}
borderWidth={1}
borderColor="#e5e7eb"
backgroundColor={override.color ?? slot.color ?? '#0f172a'}
borderColor={border}
backgroundColor={override.color ?? slot.color ?? textStrong}
/>
</Pressable>
<input
type="text"
value={override.color ?? slot.color ?? ''}
placeholder="#0f172a"
placeholder={ADMIN_COLORS.text}
onChange={(event) => onUpdateSlot(slotKey, { color: event.target.value })}
style={{ ...numberInputStyle, width: 110 }}
/>
@@ -1373,28 +1438,28 @@ function LayoutControls({
bottom={0}
alignItems="center"
justifyContent="center"
backgroundColor="rgba(0,0,0,0.35)"
backgroundColor={overlay}
zIndex={9999}
onPress={() => setOpenColorSlot(null)}
>
<YStack
padding="$3"
borderRadius={16}
backgroundColor="#fff"
backgroundColor={surface}
borderWidth={1}
borderColor="#e5e7eb"
borderColor={border}
elevation="$4"
shadowColor="rgba(0,0,0,0.08)"
shadowColor={shadow}
shadowOffset={{ width: 0, height: 4 }}
shadowOpacity={0.2}
shadowRadius={12}
gap="$2"
>
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.fontColor', 'Schriftfarbe')}
</Text>
<HexColorPicker
color={override.color ?? slot.color ?? '#0f172a'}
color={override.color ?? slot.color ?? ADMIN_COLORS.text}
onChange={(val) => onUpdateSlot(slotKey, { color: val })}
style={{ width: 240, height: 200 }}
/>
@@ -1405,10 +1470,10 @@ function LayoutControls({
paddingVertical="$2"
borderRadius={10}
borderWidth={1}
borderColor="#e5e7eb"
backgroundColor="#fff"
borderColor={border}
backgroundColor={surface}
>
<Text fontSize="$xs" color="#111827">
<Text fontSize="$xs" color={textStrong}>
{t('common.close', 'Schließen')}
</Text>
</XStack>
@@ -1424,8 +1489,8 @@ function LayoutControls({
<XStack space="$2">
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
Align
<Text fontSize="$xs" color={muted}>
{t('events.qr.align', 'Align')}
</Text>
<select
value={override.align ?? slot.align ?? 'left'}
@@ -1438,8 +1503,8 @@ function LayoutControls({
</select>
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
Line Height
<Text fontSize="$xs" color={muted}>
{t('events.qr.lineHeight', 'Line Height')}
</Text>
<StepperInput
value={override.lineHeight ?? slot.lineHeight ?? 1.35}
@@ -1470,7 +1535,7 @@ function LayoutControls({
return (
<YStack space="$3" marginTop="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.layoutControls', 'Layout & Schrift')}
</Text>
<Accordion type="multiple" defaultValue={accordionDefaults}>
@@ -1481,20 +1546,20 @@ function LayoutControls({
{qrSlot ? (
<Accordion.Item value="qr">
<Accordion.Trigger padding="$2" borderWidth={1} borderColor="#e5e7eb" borderRadius={12} backgroundColor="#f8fafc">
<Accordion.Trigger padding="$2" borderWidth={1} borderColor={border} borderRadius={12} backgroundColor={surfaceMuted}>
<XStack justifyContent="space-between" alignItems="center" flex={1}>
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.qr_code_label', 'QRCode')}
</Text>
<ChevronDown size={16} color="#6b7280" />
<ChevronDown size={16} color={muted} />
</XStack>
</Accordion.Trigger>
<Accordion.Content paddingTop="$2">
<YStack space="$2" padding="$2" borderWidth={1} borderColor="#e5e7eb" borderRadius={12}>
<YStack space="$2" padding="$2" borderWidth={1} borderColor={border} borderRadius={12}>
<XStack space="$2">
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
X (%)
<Text fontSize="$xs" color={muted}>
{t('events.qr.positionX', 'X (%)')}
</Text>
<StepperInput
value={(qrOverride.x ?? qrSlot.x) * 100}
@@ -1505,8 +1570,8 @@ function LayoutControls({
/>
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
Y (%)
<Text fontSize="$xs" color={muted}>
{t('events.qr.positionY', 'Y (%)')}
</Text>
<StepperInput
value={(qrOverride.y ?? qrSlot.y) * 100}
@@ -1517,8 +1582,8 @@ function LayoutControls({
/>
</YStack>
<YStack flex={1} space="$1">
<Text fontSize="$xs" color="#6b7280">
Größe (%)
<Text fontSize="$xs" color={muted}>
{t('events.qr.size', 'Größe (%)')}
</Text>
<StepperInput
value={(qrOverride.w ?? qrSlot.w) * 100}
@@ -1530,7 +1595,7 @@ function LayoutControls({
</YStack>
</XStack>
{!qrUrl ? (
<Text fontSize="$xs" color="#b91c1c">
<Text fontSize="$xs" color={danger}>
{t('events.qr.missing', 'Kein QR-Link vorhanden')}
</Text>
) : null}

View File

@@ -21,12 +21,14 @@ import toast from 'react-hot-toast';
import { ADMIN_BASE_PATH, adminPath } from '../constants';
import { resolveLayoutForFormat } from './qr/utils';
import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme';
export default function MobileQrPrintPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const slug = slugParam ?? null;
const navigate = useNavigate();
const { t } = useTranslation('management');
const { textStrong, text, muted, subtle, border, surfaceMuted, primary, danger, accentSoft } = useAdminTheme();
const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [selectedInvite, setSelectedInvite] = React.useState<EventQrInvite | null>(null);
@@ -80,13 +82,13 @@ export default function MobileQrPrintPage() {
onBack={back}
headerActions={
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color="#0f172a" />
<RefreshCcw size={18} color={textStrong} />
</HeaderActionButton>
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
<Text fontWeight="700" color={danger}>
{error}
</Text>
<CTAButton
@@ -99,7 +101,7 @@ export default function MobileQrPrintPage() {
) : null}
<MobileCard space="$3" alignItems="center">
<Text fontSize="$md" fontWeight="800" color="#111827">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.qr.heroTitle', 'Entrance QR Code')}
</Text>
<YStack
@@ -107,8 +109,8 @@ export default function MobileQrPrintPage() {
height={180}
borderRadius={16}
borderWidth={1}
borderColor="#e5e7eb"
backgroundColor="#f8fafc"
borderColor={border}
backgroundColor={surfaceMuted}
alignItems="center"
justifyContent="center"
overflow="hidden"
@@ -116,21 +118,21 @@ export default function MobileQrPrintPage() {
{qrImage ? (
<img
src={qrImage}
alt="QR"
alt={t('events.qr.qrAlt', 'QR code')}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
) : (
<Text color="#9ca3af" fontSize="$sm">
<Text color={subtle} fontSize="$sm">
{t('events.qr.missing', 'Kein QR-Link vorhanden')}
</Text>
)}
</YStack>
{qrUrl ? (
<Text fontSize="$xs" color="#334155" textAlign="center" marginTop="$2" style={{ wordBreak: 'break-word' }}>
<Text fontSize="$xs" color={text} textAlign="center" marginTop="$2" style={{ wordBreak: 'break-word' }}>
{qrUrl}
</Text>
) : null}
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={muted}>
{t('events.qr.description', 'Scan to access the event guest app.')}
</Text>
<XStack space="$2" width="100%" marginTop="$2">
@@ -177,10 +179,10 @@ export default function MobileQrPrintPage() {
</MobileCard>
<MobileCard space="$2">
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={muted}>
{t('events.qr.step1', 'Schritt 1: Format wählen')}
</Text>
<Text fontSize="$md" fontWeight="800" color="#111827">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.qr.layouts', 'Print Layouts')}
</Text>
<FormatSelection
@@ -212,7 +214,7 @@ export default function MobileQrPrintPage() {
</MobileCard>
<MobileCard space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.createLink', 'Neuen QR-Link erstellen')}
</Text>
<CTAButton
@@ -220,7 +222,7 @@ export default function MobileQrPrintPage() {
onPress={async () => {
if (!slug) return;
try {
const invite = await createQrInvite(slug, { label: 'Mobile Link' });
const invite = await createQrInvite(slug, { label: t('events.qr.mobileLinkLabel', 'Mobile link') });
setQrUrl(invite.url);
setQrImage(invite.qr_code_data_url ?? '');
setSelectedInvite(invite);
@@ -246,6 +248,7 @@ function FormatSelection({
onSelect: (format: 'a4-poster' | 'a5-foldable', layoutId: string | null) => void;
}) {
const { t } = useTranslation('management');
const { border, primary, accentSoft, surface, textStrong, muted, subtle } = useAdminTheme();
const selectLayoutId = (format: 'a4-poster' | 'a5-foldable'): string | null => {
return resolveLayoutForFormat(format, layouts);
@@ -278,16 +281,16 @@ function FormatSelection({
>
<MobileCard
padding="$3"
borderColor={isSelected ? '#2563EB' : '#e5e7eb'}
borderColor={isSelected ? primary : border}
borderWidth={isSelected ? 2 : 1}
backgroundColor={isSelected ? '#eff6ff' : '#fff'}
backgroundColor={isSelected ? accentSoft : surface}
>
<XStack alignItems="center" justifyContent="space-between" space="$3">
<YStack space="$1" flex={1}>
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{card.title}
</Text>
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={muted}>
{card.subtitle}
</Text>
<XStack space="$2" alignItems="center" flexWrap="wrap">
@@ -298,7 +301,7 @@ function FormatSelection({
))}
</XStack>
</YStack>
<ChevronRight size={16} color="#9ca3af" />
<ChevronRight size={16} color={subtle} />
</XStack>
</MobileCard>
</Pressable>
@@ -328,6 +331,7 @@ function BackgroundStep({
saving: boolean;
}) {
const { t } = useTranslation('management');
const { textStrong, muted, border, primary, surfaceMuted } = useAdminTheme();
const resolvedLayout =
selectedLayout ??
layouts.find((layout) => {
@@ -348,8 +352,8 @@ function BackgroundStep({
<XStack alignItems="center" space="$2">
<Pressable onPress={onBack}>
<XStack alignItems="center" space="$1">
<ArrowLeft size={16} color="#111827" />
<Text fontSize="$sm" color="#111827">
<ArrowLeft size={16} color={textStrong} />
<Text fontSize="$sm" color={textStrong}>
{t('common.back', 'Zurück')}
</Text>
</XStack>
@@ -360,7 +364,7 @@ function BackgroundStep({
</XStack>
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t(
'events.qr.backgroundPicker',
disablePresets ? 'Hintergrund für A5 (Gradient/Farbe)' : `Hintergrund auswählen (${formatLabel})`
@@ -379,8 +383,8 @@ function BackgroundStep({
borderRadius={14}
overflow="hidden"
borderWidth={isSelected ? 2 : 1}
borderColor={isSelected ? '#2563EB' : '#e5e7eb'}
backgroundColor="#f8fafc"
borderColor={isSelected ? primary : border}
backgroundColor={surfaceMuted}
>
<YStack
flex={1}
@@ -389,7 +393,7 @@ function BackgroundStep({
backgroundPosition="center"
/>
<XStack padding="$2" justifyContent="space-between" alignItems="center" backgroundColor="rgba(255,255,255,0.92)">
<Text fontSize="$xs" color="#111827">
<Text fontSize="$xs" color={textStrong}>
{preset.label}
</Text>
{isSelected ? <PillBadge tone="muted">{t('common.selected', 'Ausgewählt')}</PillBadge> : null}
@@ -399,12 +403,12 @@ function BackgroundStep({
);
})}
</XStack>
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={muted}>
{t('events.qr.backgroundNote', 'Diese Presets sind für A4 Hochformat. Spiegelung erfolgt automatisch bei Tischkarten.')}
</Text>
</>
) : (
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={muted}>
{t('events.qr.foldableBackgroundNote', 'Für A5 Tischkarten bitte Gradient oder Vollfarbe wählen.')}
</Text>
)}
@@ -433,6 +437,7 @@ function TextStep({
saving: boolean;
}) {
const { t } = useTranslation('management');
const { textStrong } = useAdminTheme();
const updateField = (key: 'headline' | 'subtitle' | 'description', value: string) => {
onChange({ ...textFields, [key]: value });
@@ -458,8 +463,8 @@ function TextStep({
<XStack alignItems="center" space="$2">
<Pressable onPress={onBack}>
<XStack alignItems="center" space="$1">
<ArrowLeft size={16} color="#111827" />
<Text fontSize="$sm" color="#111827">
<ArrowLeft size={16} color={textStrong} />
<Text fontSize="$sm" color={textStrong}>
{t('common.back', 'Zurück')}
</Text>
</XStack>
@@ -468,7 +473,7 @@ function TextStep({
</XStack>
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.textFields', 'Texte')}
</Text>
<StyledInput
@@ -489,7 +494,7 @@ function TextStep({
</YStack>
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.instructions', 'Anleitung')}
</Text>
{textFields.instructions.map((item, idx) => (
@@ -537,16 +542,19 @@ function PreviewStep({
onExport: (format: 'pdf' | 'png') => void;
}) {
const { t } = useTranslation('management');
const { textStrong, text, muted, border, surfaceMuted } = useAdminTheme();
const presetSrc = backgroundPreset ? presets.find((p) => p.id === backgroundPreset)?.src ?? null : null;
const resolvedBg = presetSrc ?? layout?.preview?.background ?? '#f8fafc';
const resolvedBg = presetSrc ?? layout?.preview?.background ?? surfaceMuted;
const previewText = layout?.preview?.text ?? textStrong;
const previewBody = layout?.preview?.text ?? text;
return (
<YStack space="$3" marginTop="$2">
<XStack alignItems="center" space="$2">
<Pressable onPress={onBack}>
<XStack alignItems="center" space="$1">
<ArrowLeft size={16} color="#111827" />
<Text fontSize="$sm" color="#111827">
<ArrowLeft size={16} color={textStrong} />
<Text fontSize="$sm" color={textStrong}>
{t('common.back', 'Zurück')}
</Text>
</XStack>
@@ -559,15 +567,15 @@ function PreviewStep({
</XStack>
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.preview', 'Vorschau')}
</Text>
<YStack
borderRadius={16}
borderWidth={1}
borderColor="#e5e7eb"
borderColor={border}
overflow="hidden"
backgroundColor={presetSrc ? 'transparent' : '#f8fafc'}
backgroundColor={presetSrc ? 'transparent' : surfaceMuted}
style={
presetSrc
? {
@@ -575,27 +583,27 @@ function PreviewStep({
backgroundSize: 'cover',
backgroundPosition: 'center',
}
: { background: resolvedBg ?? '#f8fafc' }
: { background: resolvedBg ?? surfaceMuted }
}
padding="$3"
gap="$3"
>
<Text fontSize="$lg" fontWeight="800" color={layout?.preview?.text ?? '#0f172a'}>
<Text fontSize="$lg" fontWeight="800" color={previewText}>
{textFields.headline || layout?.name || t('events.qr.previewHeadline', 'Event QR')}
</Text>
{textFields.subtitle ? (
<Text fontSize="$sm" color={layout?.preview?.text ?? '#1f2937'}>
<Text fontSize="$sm" color={previewBody}>
{textFields.subtitle}
</Text>
) : null}
{textFields.description ? (
<Text fontSize="$sm" color={layout?.preview?.text ?? '#1f2937'}>
<Text fontSize="$sm" color={previewBody}>
{textFields.description}
</Text>
) : null}
<YStack space="$1">
{textFields.instructions.filter((i) => i.trim().length > 0).map((item, idx) => (
<Text key={idx} fontSize="$xs" color={layout?.preview?.text ?? '#1f2937'}>
<Text key={idx} fontSize="$xs" color={previewBody}>
{item}
</Text>
))}
@@ -605,15 +613,15 @@ function PreviewStep({
<>
<img
src={qrImage}
alt="QR"
alt={t('events.qr.qrAlt', 'QR code')}
style={{ width: 140, height: 140, objectFit: 'contain' }}
/>
<Text fontSize="$xs" color="#334155" textAlign="center" marginTop="$2" style={{ wordBreak: 'break-word' }}>
<Text fontSize="$xs" color={text} textAlign="center" marginTop="$2" style={{ wordBreak: 'break-word' }}>
{qrUrl}
</Text>
</>
) : (
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={muted}>
{t('events.qr.missing', 'Kein QR-Link vorhanden')}
</Text>
)}
@@ -639,13 +647,14 @@ function StyledInput({
placeholder?: string;
flex?: number;
}) {
const { border } = useAdminTheme();
return (
<input
style={{
width: '100%',
padding: '10px 12px',
borderRadius: 12,
border: '1px solid #e5e7eb',
border: `1px solid ${border}`,
fontSize: 14,
outline: 'none',
flex: flex ?? undefined,
@@ -666,13 +675,14 @@ function StyledTextarea({
onChangeText: (value: string) => void;
placeholder?: string;
}) {
const { border } = useAdminTheme();
return (
<textarea
style={{
width: '100%',
padding: '10px 12px',
borderRadius: 12,
border: '1px solid #e5e7eb',
border: `1px solid ${border}`,
fontSize: 14,
outline: 'none',
minHeight: 96,

View File

@@ -7,7 +7,6 @@ import { YGroup } from '@tamagui/group';
import { ListItem } from '@tamagui/list-item';
import { SizableText as Text } from '@tamagui/text';
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';
@@ -27,6 +26,7 @@ import { MobileInstallBanner } from './components/MobileInstallBanner';
import { setTourSeen } from './lib/mobileTour';
import { useBackNavigation } from './hooks/useBackNavigation';
import { useOnlineStatus } from './hooks/useOnlineStatus';
import { useAdminTheme } from './theme';
type PreferenceKey = keyof NotificationPreferences;
@@ -47,10 +47,7 @@ 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 { text, muted, border, danger } = useAdminTheme();
const [preferences, setPreferences] = React.useState<NotificationPreferences>({});
const [defaults, setDefaults] = React.useState<NotificationPreferences>({});
const [loading, setLoading] = React.useState(true);
@@ -199,7 +196,7 @@ export default function MobileSettingsPage() {
<MobileShell activeTab="profile" title={t('mobileSettings.title', 'Settings')} onBack={back}>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
<Text fontWeight="700" color={danger}>
{error}
</Text>
</MobileCard>
@@ -315,7 +312,7 @@ export default function MobileSettingsPage() {
</YGroup>
)}
{pushState.error ? (
<Text fontSize="$xs" color="#b91c1c">
<Text fontSize="$xs" color={danger}>
{pushState.error}
</Text>
) : null}
@@ -401,7 +398,7 @@ export default function MobileSettingsPage() {
/>
) : null}
{storageError ? (
<Text fontSize="$xs" color="#b91c1c">
<Text fontSize="$xs" color={danger}>
{storageError}
</Text>
) : null}

View File

@@ -9,17 +9,13 @@ import { MobileCard, CTAButton } from './components/Primitives';
import { useEventContext } from '../context/EventContext';
import { formatEventDate, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';
import { adminPath } from '../constants';
import { useTheme } from '@tamagui/core';
import { useAdminTheme } from './theme';
export default function MobileTasksTabPage() {
const { events, activeEvent, hasEvents, selectEvent } = useEventContext();
const { t, i18n } = useTranslation('management');
const navigate = useNavigate();
const theme = useTheme();
const text = String(theme.color?.val ?? '#111827');
const muted = String(theme.gray?.val ?? '#4b5563');
const border = String(theme.borderColor?.val ?? '#e5e7eb');
const primary = String(theme.primary?.val ?? '#007AFF');
const { text, muted, border, primary } = useAdminTheme();
const tasksEnabled = resolveEngagementMode(activeEvent ?? null) !== 'photo_only';
if (activeEvent?.slug && tasksEnabled) {

View File

@@ -9,17 +9,13 @@ import { MobileCard, CTAButton } from './components/Primitives';
import { useEventContext } from '../context/EventContext';
import { formatEventDate, resolveEventDisplayName } from '../lib/events';
import { adminPath } from '../constants';
import { useTheme } from '@tamagui/core';
import { useAdminTheme } from './theme';
export default function MobileUploadsTabPage() {
const { events, activeEvent, hasEvents, selectEvent } = useEventContext();
const { t, i18n } = useTranslation('management');
const navigate = useNavigate();
const theme = useTheme();
const text = String(theme.color?.val ?? '#111827');
const muted = String(theme.gray?.val ?? '#4b5563');
const border = String(theme.borderColor?.val ?? '#e5e7eb');
const primary = String(theme.primary?.val ?? '#007AFF');
const { text, muted, border, primary } = useAdminTheme();
if (activeEvent?.slug) {
return <Navigate to={adminPath(`/mobile/events/${activeEvent.slug}/photos`)} replace />;

View File

@@ -17,8 +17,8 @@ vi.mock('@tamagui/core', () => ({
color: { val: '#111827' },
gray: { val: '#4b5563' },
borderColor: { val: '#e5e7eb' },
blue3: { val: '#e8f1ff' },
blue6: { val: '#bfdbfe' },
blue3: { val: '#FFE5EC' },
blue6: { val: '#FFB6C1' },
red10: { val: '#b91c1c' },
surface: { val: '#ffffff' },
gray12: { val: '#0f172a' },

View File

@@ -3,9 +3,9 @@ import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
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';
import { useAdminTheme } from '../theme';
const ICON_SIZE = 20;
@@ -13,8 +13,8 @@ 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 { surface, border, primary, accentSoft, muted, subtle, shadow } = useAdminTheme();
const surfaceColor = surface;
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 }> = [
@@ -32,11 +32,11 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
right={0}
backgroundColor={navSurface}
borderTopWidth={1}
borderColor={String(theme.borderColor?.val ?? '#e5e7eb')}
borderColor={border}
paddingVertical="$2"
paddingHorizontal="$4"
zIndex={50}
shadowColor="#0f172a"
shadowColor={shadow}
shadowOpacity={0.08}
shadowRadius={12}
shadowOffset={{ width: 0, height: -4 }}
@@ -72,7 +72,7 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
paddingHorizontal="$3"
paddingVertical="$2"
borderRadius={12}
backgroundColor={activeState ? '#e8f1ff' : 'transparent'}
backgroundColor={activeState ? accentSoft : 'transparent'}
gap="$1"
style={{
transform: isPressed ? 'scale(0.96)' : 'scale(1)',
@@ -87,17 +87,17 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
width={28}
height={3}
borderRadius={999}
backgroundColor={String(theme.primary?.val ?? '#007AFF')}
backgroundColor={primary}
/>
) : 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'} />
<IconCmp size={ICON_SIZE} color={activeState ? primary : subtle} />
</YStack>
<Text
fontSize="$xs"
fontWeight="700"
fontFamily="$body"
color={activeState ? '$primary' : '#6b7280'}
color={activeState ? primary : muted}
textAlign="center"
flexShrink={1}
>

View File

@@ -2,8 +2,8 @@ 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';
import { useAdminTheme } from '../theme';
type FieldProps = {
label: string;
@@ -13,24 +13,21 @@ type FieldProps = {
};
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');
const { text, muted, danger } = useAdminTheme();
return (
<YStack space="$1.5">
<Text fontSize="$sm" fontWeight="800" color={labelColor}>
<Text fontSize="$sm" fontWeight="800" color={text}>
{label}
</Text>
{children}
{hint ? (
<Text fontSize="$xs" color={hintColor}>
<Text fontSize="$xs" color={muted}>
{hint}
</Text>
) : null}
{error ? (
<Text fontSize="$xs" color={errorColor}>
<Text fontSize="$xs" color={danger}>
{error}
</Text>
) : null}
@@ -45,13 +42,8 @@ type ControlProps = {
export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<'input'> & ControlProps>(
function MobileInput({ hasError = false, compact = false, style, ...props }, ref) {
const theme = useTheme();
const { border, surface, text, primary, danger } = useAdminTheme();
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;
@@ -92,13 +84,8 @@ export const MobileTextArea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentPropsWithoutRef<'textarea'> & ControlProps
>(function MobileTextArea({ hasError = false, compact = false, style, ...props }, ref) {
const theme = useTheme();
const { border, surface, text, primary, danger } = useAdminTheme();
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);
@@ -141,14 +128,8 @@ export function MobileSelect({
style,
...props
}: React.ComponentPropsWithoutRef<'select'> & ControlProps) {
const theme = useTheme();
const { border, surface, text, primary, danger, subtle } = useAdminTheme();
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;
@@ -186,7 +167,7 @@ export function MobileSelect({
{children}
</select>
<XStack position="absolute" right={12} pointerEvents="none">
<ChevronDown size={16} color={muted} />
<ChevronDown size={16} color={subtle} />
</XStack>
</XStack>
);

View File

@@ -1,9 +1,9 @@
import React from 'react';
import { YStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { useTheme } from '@tamagui/core';
import { MobileSheet } from './Sheet';
import { CTAButton } from './Primitives';
import { useAdminTheme } from '../theme';
type Translator = (key: string, defaultValue?: string) => string;
@@ -37,20 +37,17 @@ export function LegalConsentSheet({
copy,
t,
}: LegalConsentSheetProps) {
const theme = useTheme();
const { primary, border, surface, danger, text } = useAdminTheme();
const [acceptedTerms, setAcceptedTerms] = React.useState(false);
const [acceptedWaiver, setAcceptedWaiver] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const checkboxAccent = String(theme.primary?.val ?? '#2563eb');
const checkboxBorder = String(theme.borderColor?.val ?? '#e5e7eb');
const checkboxSurface = String(theme.surface?.val ?? '#ffffff');
const checkboxStyle = {
marginTop: 4,
width: 18,
height: 18,
accentColor: checkboxAccent,
backgroundColor: checkboxSurface,
border: `1px solid ${checkboxBorder}`,
accentColor: primary,
backgroundColor: surface,
border: `1px solid ${border}`,
borderRadius: 4,
appearance: 'auto',
WebkitAppearance: 'auto',
@@ -90,7 +87,7 @@ export function LegalConsentSheet({
footer={
<YStack space="$2">
{error ? (
<Text fontSize="$sm" color="#b91c1c">
<Text fontSize="$sm" color={danger}>
{error}
</Text>
) : null}
@@ -110,7 +107,7 @@ export function LegalConsentSheet({
}
>
<YStack space="$2">
<Text fontSize="$sm" color="#111827">
<Text fontSize="$sm" color={text}>
{copy?.description ?? t('events.legalConsent.description', 'Please confirm the legal notes before buying an add-on.')}
</Text>
{requireTerms ? (
@@ -121,7 +118,7 @@ export function LegalConsentSheet({
onChange={(event) => setAcceptedTerms(event.target.checked)}
style={checkboxStyle}
/>
<Text fontSize="$sm" color="#111827">
<Text fontSize="$sm" color={text}>
{copy?.checkboxTerms ?? t(
'events.legalConsent.checkboxTerms',
'I have read and accept the Terms & Conditions, Privacy Policy, and Right of Withdrawal.',
@@ -137,7 +134,7 @@ export function LegalConsentSheet({
onChange={(event) => setAcceptedWaiver(event.target.checked)}
style={checkboxStyle}
/>
<Text fontSize="$sm" color="#111827">
<Text fontSize="$sm" color={text}>
{copy?.checkboxWaiver ?? t(
'events.legalConsent.checkboxWaiver',
'I expressly request immediate provision of the digital service and understand my right of withdrawal expires once fulfilled.',

View File

@@ -9,8 +9,8 @@ vi.mock('@tamagui/core', () => ({
gray11: { val: '#6b7280' },
gray6: { val: '#e5e7eb' },
gray2: { val: '#f8fafc' },
blue3: { val: '#dbeafe' },
primary: { val: '#2563eb' },
blue3: { val: '#FFE5EC' },
primary: { val: '#FF5A5F' },
}),
}));

View File

@@ -2,11 +2,11 @@ import React from 'react';
import { Download, Share2, X } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { useTheme } from '@tamagui/core';
import { Pressable } from '@tamagui/react-native-web-lite';
import { InstallBannerState } from '../lib/installBanner';
import { CTAButton, MobileCard } from './Primitives';
import { useTranslation } from 'react-i18next';
import { useAdminTheme } from '../theme';
type MobileInstallBannerProps = {
state: InstallBannerState | null;
@@ -22,13 +22,7 @@ export function MobileInstallBanner({
density = 'default',
}: MobileInstallBannerProps) {
const { t } = useTranslation('common');
const theme = useTheme();
const text = String(theme.color12?.val ?? theme.color?.val ?? '#0f172a');
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#6b7280');
const border = String(theme.gray6?.val ?? theme.borderColor?.val ?? '#e5e7eb');
const accent = String(theme.primary?.val ?? '#2563eb');
const surface = String(theme.gray2?.val ?? '#f8fafc');
const accentSoft = String(theme.blue3?.val ?? '#dbeafe');
const { textStrong, muted, border, primary, surfaceMuted, accentSoft } = useAdminTheme();
if (!state) {
return null;
@@ -41,7 +35,7 @@ export function MobileInstallBanner({
<MobileCard
space={isCompact ? '$1.5' : '$2'}
borderColor={border}
backgroundColor={surface}
backgroundColor={surfaceMuted}
padding={isCompact ? '$2' : '$3'}
>
<XStack alignItems="center" justifyContent="space-between" gap="$2">
@@ -54,10 +48,10 @@ export function MobileInstallBanner({
justifyContent="center"
backgroundColor={accentSoft}
>
{isPrompt ? <Download size={16} color={accent} /> : <Share2 size={16} color={accent} />}
{isPrompt ? <Download size={16} color={primary} /> : <Share2 size={16} color={primary} />}
</XStack>
<YStack flex={1} space="$0.5">
<Text fontSize={isCompact ? '$xs' : '$sm'} fontWeight="800" color={text}>
<Text fontSize={isCompact ? '$xs' : '$sm'} fontWeight="800" color={textStrong}>
{t('installBanner.title', 'Install Fotospiel Admin')}
</Text>
<Text fontSize={isCompact ? 10 : '$xs'} color={muted}>
@@ -70,7 +64,7 @@ export function MobileInstallBanner({
<XStack alignItems="center" space="$2">
{isPrompt && onInstall && isCompact ? (
<Pressable onPress={onInstall}>
<Text fontSize={10} fontWeight="700" color={accent}>
<Text fontSize={10} fontWeight="700" color={primary}>
{t('installBanner.action', 'Install')}
</Text>
</Pressable>

View File

@@ -5,7 +5,6 @@ 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 { useTheme } from '@tamagui/core';
import { useEventContext } from '../../context/EventContext';
import { BottomNav, NavKey } from './BottomNav';
import { useMobileNav } from '../hooks/useMobileNav';
@@ -20,6 +19,7 @@ import { withAlpha } from './colors';
import { setTabHistory } from '../lib/tabHistory';
import { loadPhotoQueue } from '../lib/photoModerationQueue';
import { countQueuedPhotoActions } from '../lib/queueStatus';
import { useAdminTheme } from '../theme';
type MobileShellProps = {
title?: string;
@@ -38,14 +38,12 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
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 { background, surface, border, text, muted, warningBg, warningText, primary, danger, shadow } = useAdminTheme();
const backgroundColor = background;
const surfaceColor = surface;
const borderColor = border;
const textColor = text;
const mutedText = muted;
const headerSurface = withAlpha(surfaceColor, 0.94);
const [pickerOpen, setPickerOpen] = React.useState(false);
const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
@@ -129,7 +127,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
paddingHorizontal="$4"
paddingTop="$4"
paddingBottom="$3"
shadowColor="#0f172a"
shadowColor={shadow}
shadowOpacity={0.06}
shadowRadius={10}
shadowOffset={{ width: 0, height: 4 }}
@@ -148,7 +146,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
{onBack ? (
<HeaderActionButton onPress={onBack} ariaLabel={t('actions.back', 'Back')}>
<XStack alignItems="center" space="$1.5">
<ChevronLeft size={28} color="#007AFF" strokeWidth={2.5} />
<ChevronLeft size={28} color={primary} strokeWidth={2.5} />
</XStack>
</HeaderActionButton>
) : (
@@ -198,7 +196,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
height={18}
paddingHorizontal={6}
borderRadius={999}
backgroundColor="#ef4444"
backgroundColor={danger}
alignItems="center"
justifyContent="center"
>
@@ -218,7 +216,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
height={34}
paddingHorizontal="$3"
borderRadius={12}
backgroundColor="#0ea5e9"
backgroundColor={primary}
alignItems="center"
justifyContent="center"
space="$1.5"
@@ -302,7 +300,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
</Text>
<Pressable onPress={() => navigate(adminPath('/mobile/events/new'))}>
<XStack alignItems="center" space="$2">
<Text fontSize="$sm" color="#007AFF" fontWeight="700">
<Text fontSize="$sm" color={primary} fontWeight="700">
{t('header.createEvent', 'Create event')}
</Text>
</XStack>
@@ -346,7 +344,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
setPickerOpen(false);
}}
>
<Text fontSize="$xs" color="#6b7280" textAlign="center">
<Text fontSize="$xs" color={mutedText} textAlign="center">
{t('header.clearSelection', 'Clear selection')}
</Text>
</Pressable>

View File

@@ -3,8 +3,8 @@ import { ChevronLeft } 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 { useTheme } from '@tamagui/core';
import { useTranslation } from 'react-i18next';
import { useAdminTheme } from '../theme';
type OnboardingShellProps = {
eyebrow?: string;
@@ -30,12 +30,7 @@ export function OnboardingShell({
skipLabel,
}: OnboardingShellProps) {
const { t } = useTranslation('onboarding');
const theme = useTheme();
const background = String(theme.background?.val ?? '#f7f8fb');
const surface = String(theme.surface?.val ?? '#ffffff');
const text = String(theme.color?.val ?? '#111827');
const muted = String(theme.gray?.val ?? '#6b7280');
const border = String(theme.borderColor?.val ?? '#e5e7eb');
const { background, surface, text, textStrong, muted, border, shadow } = useAdminTheme();
const resolvedBackLabel = backLabel ?? t('layout.back', 'Back');
const resolvedSkipLabel = skipLabel ?? t('layout.skip', 'Skip');
@@ -55,7 +50,7 @@ export function OnboardingShell({
>
<XStack alignItems="center" justifyContent="space-between">
{onBack ? (
<Pressable onPress={onBack} aria-label="Back">
<Pressable onPress={onBack} aria-label={resolvedBackLabel}>
<XStack alignItems="center" space="$1.5">
<ChevronLeft size={22} color={text} />
<Text fontSize="$sm" fontWeight="700" color={text}>
@@ -68,7 +63,7 @@ export function OnboardingShell({
)}
{onSkip ? (
<Pressable onPress={onSkip} aria-label="Skip">
<Pressable onPress={onSkip} aria-label={resolvedSkipLabel}>
<Text fontSize="$sm" fontWeight="700" color={muted}>
{resolvedSkipLabel}
</Text>
@@ -84,7 +79,7 @@ export function OnboardingShell({
borderWidth={1}
borderColor={border}
backgroundColor={surface}
shadowColor="#0f172a"
shadowColor={shadow}
shadowOpacity={0.06}
shadowRadius={14}
shadowOffset={{ width: 0, height: 8 }}
@@ -95,7 +90,7 @@ export function OnboardingShell({
{eyebrow}
</Text>
) : null}
<Text fontSize="$xl" fontWeight="900" color={text}>
<Text fontSize="$xl" fontWeight="900" color={textStrong}>
{title}
</Text>
{subtitle ? (

View File

@@ -2,20 +2,25 @@ import React from 'react';
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 { useAdminTheme } from '../theme';
export function MobileCard({ children, ...rest }: React.ComponentProps<typeof YStack>) {
const theme = useTheme();
export function MobileCard({
children,
className,
...rest
}: React.ComponentProps<typeof YStack>) {
const { surface, border, shadow } = useAdminTheme();
return (
<YStack
backgroundColor={String(theme.surface?.val ?? 'white')}
borderRadius={16}
className={['admin-fade-up', className].filter(Boolean).join(' ')}
backgroundColor={surface}
borderRadius={18}
borderWidth={1}
borderColor={String(theme.borderColor?.val ?? '#e5e7eb')}
shadowColor="#0f172a"
shadowOpacity={0.06}
shadowRadius={12}
shadowOffset={{ width: 0, height: 8 }}
borderColor={border}
shadowColor={shadow}
shadowOpacity={0.08}
shadowRadius={14}
shadowOffset={{ width: 0, height: 10 }}
padding="$3.5"
space="$2"
{...rest}
@@ -32,7 +37,7 @@ export function PillBadge({
tone?: 'success' | 'warning' | 'muted';
children: React.ReactNode;
}) {
const theme = useTheme();
const { theme } = useAdminTheme();
const palette: Record<typeof tone, { bg: string; text: string; border: string }> = {
success: {
bg: String(theme.backgroundStrong?.val ?? '#ecfdf3'),
@@ -83,7 +88,7 @@ export function CTAButton({
disabled?: boolean;
loading?: boolean;
}) {
const theme = useTheme();
const { primary, surface, border, text } = useAdminTheme();
const isPrimary = tone === 'primary';
const isDisabled = disabled || loading;
return (
@@ -97,15 +102,15 @@ export function CTAButton({
}}
>
<XStack
height={56}
borderRadius={14}
height={52}
borderRadius={16}
alignItems="center"
justifyContent="center"
backgroundColor={isPrimary ? String(theme.primary?.val ?? '#007AFF') : String(theme.surface?.val ?? 'white')}
backgroundColor={isPrimary ? primary : surface}
borderWidth={isPrimary ? 0 : 1}
borderColor={isPrimary ? 'transparent' : String(theme.borderColor?.val ?? '#e5e7eb')}
borderColor={isPrimary ? 'transparent' : border}
>
<Text fontSize="$sm" fontWeight="800" color={isPrimary ? 'white' : String(theme.color?.val ?? '#111827')}>
<Text fontSize="$sm" fontWeight="800" color={isPrimary ? 'white' : text}>
{label}
</Text>
</XStack>
@@ -122,7 +127,7 @@ export function KpiTile({
label: string;
value: string | number;
}) {
const theme = useTheme();
const { accentSoft, primary, text } = useAdminTheme();
return (
<MobileCard borderRadius={14} padding="$3" width="32%" minWidth={110} alignItems="flex-start">
<XStack alignItems="center" space="$2">
@@ -130,17 +135,17 @@ export function KpiTile({
width={32}
height={32}
borderRadius={12}
backgroundColor={String(theme.blue3?.val ?? '#e5f0ff')}
backgroundColor={accentSoft}
alignItems="center"
justifyContent="center"
>
<IconCmp size={16} color={String(theme.primary?.val ?? '#2563eb')} />
<IconCmp size={16} color={primary} />
</XStack>
<Text fontSize="$xs" color={String(theme.color?.val ?? '#111827')}>
<Text fontSize="$xs" color={text}>
{label}
</Text>
</XStack>
<Text fontSize="$xl" fontWeight="800" color={String(theme.color?.val ?? '#111827')}>
<Text fontSize="$xl" fontWeight="800" color={text}>
{value}
</Text>
</MobileCard>
@@ -159,15 +164,16 @@ export function ActionTile({
color,
onPress,
disabled = false,
delayMs = 0,
}: {
icon: React.ComponentType<{ size?: number; color?: string }>;
label: string;
color: string;
onPress?: () => void;
disabled?: boolean;
delayMs?: number;
}) {
const theme = useTheme();
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
const { textStrong } = useAdminTheme();
return (
<Pressable
onPress={disabled ? undefined : onPress}
@@ -175,6 +181,8 @@ export function ActionTile({
disabled={disabled}
>
<YStack
className="admin-fade-up"
style={delayMs ? { animationDelay: `${delayMs}ms` } : undefined}
borderRadius={16}
padding="$3"
space="$2.5"
@@ -188,7 +196,7 @@ export function ActionTile({
<XStack width={34} height={34} borderRadius={12} backgroundColor={color} alignItems="center" justifyContent="center">
<IconCmp size={16} color="white" />
</XStack>
<Text fontSize="$sm" fontWeight="700" color={text} textAlign="center">
<Text fontSize="$sm" fontWeight="700" color={textStrong} textAlign="center">
{label}
</Text>
</YStack>
@@ -205,7 +213,7 @@ export function FloatingActionButton({
label: string;
icon: React.ComponentType<{ size?: number; color?: string }>;
}) {
const theme = useTheme();
const { primary, shadow } = useAdminTheme();
const [pressed, setPressed] = React.useState(false);
return (
<Pressable
@@ -231,8 +239,8 @@ export function FloatingActionButton({
alignItems="center"
justifyContent="center"
space="$2"
backgroundColor={String(theme.primary?.val ?? '#007AFF')}
shadowColor="#0f172a"
backgroundColor={primary}
shadowColor={shadow}
shadowOpacity={0.2}
shadowRadius={16}
shadowOffset={{ width: 0, height: 8 }}

View File

@@ -4,8 +4,8 @@ import { SizableText as Text } from '@tamagui/text';
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';
import { useAdminTheme } from '../theme';
type MobileScaffoldProps = {
title: string;
@@ -17,11 +17,7 @@ type MobileScaffoldProps = {
export function MobileScaffold({ title, onBack, rightSlot, children, footer }: MobileScaffoldProps) {
const { t } = useTranslation('mobile');
const theme = useTheme();
const background = String(theme.background?.val ?? '#f7f8fb');
const surface = String(theme.surface?.val ?? '#ffffff');
const border = String(theme.borderColor?.val ?? '#e5e7eb');
const textColor = String(theme.color?.val ?? '#111827');
const { background, surface, border, text, primary } = useAdminTheme();
const headerSurface = withAlpha(surface, 0.94);
return (
@@ -48,8 +44,8 @@ export function MobileScaffold({ title, onBack, rightSlot, children, footer }: M
{onBack ? (
<Pressable onPress={onBack}>
<XStack alignItems="center" space="$1.5">
<ChevronLeft size={18} color={String(theme.primary?.val ?? '#007AFF')} />
<Text fontSize="$sm" color={String(theme.primary?.val ?? '#007AFF')} fontWeight="600">
<ChevronLeft size={18} color={primary} />
<Text fontSize="$sm" color={primary} fontWeight="600">
{t('actions.back', 'Back')}
</Text>
</XStack>
@@ -58,7 +54,7 @@ export function MobileScaffold({ title, onBack, rightSlot, children, footer }: M
<Text />
)}
</XStack>
<Text fontSize="$lg" fontWeight="800" color={textColor}>
<Text fontSize="$lg" fontWeight="800" color={text}>
{title}
</Text>
<XStack minWidth={40} justifyContent="flex-end">

View File

@@ -3,7 +3,7 @@ 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 { useTheme } from '@tamagui/core';
import { useAdminTheme } from '../theme';
type SheetProps = {
open: boolean;
@@ -17,11 +17,7 @@ type SheetProps = {
export function MobileSheet({ open, title, onClose, children, footer, bottomOffsetPx = 88 }: SheetProps) {
const { t } = useTranslation('mobile');
const theme = useTheme();
const surface = String(theme.surface?.val ?? '#111827');
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#cbd5e1');
const overlay = String(theme.gray12?.val ?? 'rgba(0,0,0,0.6)');
const { surface, textStrong, muted, overlay, shadow } = useAdminTheme();
if (!open) return null;
return (
@@ -35,7 +31,7 @@ export function MobileSheet({ open, title, onClose, children, footer, bottomOffs
padding="$4"
paddingBottom="$7"
space="$3"
shadowColor="#0f172a"
shadowColor={shadow}
shadowOpacity={0.12}
shadowRadius={18}
shadowOffset={{ width: 0, height: -8 }}
@@ -45,7 +41,7 @@ export function MobileSheet({ open, title, onClose, children, footer, bottomOffs
style={{ marginBottom: `max(env(safe-area-inset-bottom, 0px), ${bottomOffsetPx}px)` }}
>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$md" fontWeight="800" color={text}>
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{title}
</Text>
<Pressable onPress={onClose}>

View File

@@ -1,12 +1,11 @@
import React from 'react';
import { SizableText as Text } from '@tamagui/text';
import { XStack } from '@tamagui/stacks';
import { useTheme } from '@tamagui/core';
import { useAdminTheme } from '../theme';
export function Tag({ label, color }: { label: string; color?: string }) {
const theme = useTheme();
const baseColor = color ?? String(theme.borderColor?.val ?? '#e5e7eb');
const textColor = String(theme.color?.val ?? '#111827');
const { border, text } = useAdminTheme();
const baseColor = color ?? border;
return (
<XStack
@@ -19,7 +18,7 @@ export function Tag({ label, color }: { label: string; color?: string }) {
borderColor={`${baseColor}55`}
alignSelf="flex-start"
>
<Text fontSize={11} fontWeight="600" color={textColor}>
<Text fontSize={11} fontWeight="600" color={text}>
{label}
</Text>
</XStack>

View File

@@ -4,7 +4,7 @@ import { render } from '@testing-library/react';
vi.mock('@tamagui/core', () => ({
useTheme: () => ({
primary: { val: '#2563eb' },
primary: { val: '#FF5A5F' },
borderColor: { val: '#e5e7eb' },
surface: { val: '#ffffff' },
}),

View File

@@ -706,7 +706,7 @@ export async function createFabricObject({
height: element.height,
fontSize: element.fontSize ?? 36,
fill: element.fill ?? textColor,
fontFamily: element.fontFamily ?? 'Lora',
fontFamily: element.fontFamily ?? 'Manrope',
textAlign: mapTextAlign(element.align),
lineHeight: element.lineHeight ?? 1.5,
charSpacing: element.letterSpacing ?? 0.5,
@@ -719,7 +719,7 @@ export async function createFabricObject({
height: element.height,
fontSize: element.fontSize ?? 24,
fill: element.fill ?? accentColor,
fontFamily: element.fontFamily ?? 'Montserrat',
fontFamily: element.fontFamily ?? 'Manrope',
underline: true,
textAlign: mapTextAlign(element.align),
lineHeight: element.lineHeight ?? 1.5,
@@ -799,7 +799,7 @@ export async function createFabricObject({
height: element.height,
fontSize: element.fontSize ?? 24,
fill: secondaryColor,
fontFamily: element.fontFamily ?? 'Lora',
fontFamily: element.fontFamily ?? 'Manrope',
textAlign: mapTextAlign(element.align),
});
}
@@ -846,7 +846,7 @@ export function createTextBadge({
top: height / 2,
fontSize,
fill: textColor,
fontFamily: 'Montserrat',
fontFamily: 'Manrope',
originY: 'center',
textAlign: 'center',
lineHeight,

View File

@@ -182,13 +182,13 @@ const DEFAULT_PRESET: LayoutPreset = [
height: 220,
fontSize: 110,
align: 'center',
fontFamily: 'Playfair Display',
fontFamily: 'Fraunces',
lineHeight: 1.3,
},
{ id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 800) / 2, y: 580, width: 800, height: 120, fontSize: 42, align: 'center', fontFamily: 'Montserrat', lineHeight: 1.4 },
{ id: 'description', type: 'description', x: (c) => (c.canvasWidth - 900) / 2, y: 720, width: 900, height: 180, fontSize: 34, align: 'center', fontFamily: 'Lora', lineHeight: 1.5 },
{ id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 800) / 2, y: 580, width: 800, height: 120, fontSize: 42, align: 'center', fontFamily: 'Manrope', lineHeight: 1.4 },
{ id: 'description', type: 'description', x: (c) => (c.canvasWidth - 900) / 2, y: 720, width: 900, height: 180, fontSize: 34, align: 'center', fontFamily: 'Manrope', lineHeight: 1.5 },
{ id: 'qr', type: 'qr', x: (c) => (c.canvasWidth - 500) / 2, y: 940, width: (c) => Math.min(c.qrSize, 500), height: (c) => Math.min(c.qrSize, 500) },
{ id: 'link', type: 'link', x: (c) => (c.canvasWidth - 700) / 2, y: (c) => 940 + Math.min(c.qrSize, 500) + 160, width: 700, height: 80, align: 'center', fontSize: 26, fontFamily: 'Montserrat', lineHeight: 1.5 },
{ id: 'link', type: 'link', x: (c) => (c.canvasWidth - 700) / 2, y: (c) => 940 + Math.min(c.qrSize, 500) + 160, width: 700, height: 80, align: 'center', fontSize: 26, fontFamily: 'Manrope', lineHeight: 1.5 },
];
const evergreenVowsPreset: LayoutPreset = [
// Elegant, linksbündig mit verbesserter Balance
@@ -202,7 +202,7 @@ const evergreenVowsPreset: LayoutPreset = [
height: 200,
fontSize: 95,
align: 'left',
fontFamily: 'Playfair Display',
fontFamily: 'Fraunces',
lineHeight: 1.3,
},
{
@@ -214,7 +214,7 @@ const evergreenVowsPreset: LayoutPreset = [
height: 140,
fontSize: 40,
align: 'left',
fontFamily: 'Montserrat',
fontFamily: 'Manrope',
lineHeight: 1.4,
},
{
@@ -226,7 +226,7 @@ const evergreenVowsPreset: LayoutPreset = [
height: 220,
fontSize: 32,
align: 'left',
fontFamily: 'Lora',
fontFamily: 'Manrope',
lineHeight: 1.5,
},
{
@@ -237,7 +237,7 @@ const evergreenVowsPreset: LayoutPreset = [
width: (c) => Math.min(c.qrSize, 440),
height: (c) => Math.min(c.qrSize, 440),
},
{ id: 'link', type: 'link', x: (c) => c.canvasWidth - 440 - 120, y: (c) => 920 + Math.min(c.qrSize, 440) + 160, width: 440, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' },
{ id: 'link', type: 'link', x: (c) => c.canvasWidth - 440 - 120, y: (c) => 920 + Math.min(c.qrSize, 440) + 160, width: 440, height: 80, align: 'center', fontSize: 24, fontFamily: 'Manrope' },
];
const midnightGalaPreset: LayoutPreset = [
@@ -251,11 +251,11 @@ const midnightGalaPreset: LayoutPreset = [
height: 220,
fontSize: 105,
align: 'center',
fontFamily: 'Playfair Display',
fontFamily: 'Fraunces',
lineHeight: 1.3,
},
{ id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 900) / 2, y: 480, width: 900, height: 120, fontSize: 40, align: 'center', fontFamily: 'Montserrat', lineHeight: 1.4 },
{ id: 'description', type: 'description', x: (c) => (c.canvasWidth - 900) / 2, y: 620, width: 900, height: 180, fontSize: 32, align: 'center', fontFamily: 'Lora', lineHeight: 1.5 },
{ id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 900) / 2, y: 480, width: 900, height: 120, fontSize: 40, align: 'center', fontFamily: 'Manrope', lineHeight: 1.4 },
{ id: 'description', type: 'description', x: (c) => (c.canvasWidth - 900) / 2, y: 620, width: 900, height: 180, fontSize: 32, align: 'center', fontFamily: 'Manrope', lineHeight: 1.5 },
{
id: 'qr',
type: 'qr',
@@ -264,13 +264,13 @@ const midnightGalaPreset: LayoutPreset = [
width: (c) => Math.min(c.qrSize, 480),
height: (c) => Math.min(c.qrSize, 480),
},
{ id: 'link', type: 'link', x: (c) => (c.canvasWidth - 600) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 160, width: 600, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' },
{ id: 'link', type: 'link', x: (c) => (c.canvasWidth - 600) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 160, width: 600, height: 80, align: 'center', fontSize: 24, fontFamily: 'Manrope' },
];
const gardenBrunchPreset: LayoutPreset = [
// Verspielt, asymmetrisch, aber ausbalanciert
{ id: 'headline', type: 'headline', x: 120, y: 240, width: 900, height: 200, fontSize: 90, align: 'left', fontFamily: 'Playfair Display', lineHeight: 1.3 },
{ id: 'subtitle', type: 'subtitle', x: 120, y: 450, width: 700, height: 120, fontSize: 40, align: 'left', fontFamily: 'Montserrat', lineHeight: 1.4 },
{ id: 'headline', type: 'headline', x: 120, y: 240, width: 900, height: 200, fontSize: 90, align: 'left', fontFamily: 'Fraunces', lineHeight: 1.3 },
{ id: 'subtitle', type: 'subtitle', x: 120, y: 450, width: 700, height: 120, fontSize: 40, align: 'left', fontFamily: 'Manrope', lineHeight: 1.4 },
{
id: 'qr',
type: 'qr',
@@ -288,7 +288,7 @@ const gardenBrunchPreset: LayoutPreset = [
height: 400,
fontSize: 32,
align: 'left',
fontFamily: 'Lora',
fontFamily: 'Manrope',
lineHeight: 1.6,
},
{ id: 'link', type: 'link', x: 120, y: (c) => 880 + Math.min(c.qrSize, 460) + 160, width: 460, height: 80, align: 'center', fontSize: 24 },
@@ -305,11 +305,11 @@ const sparklerSoireePreset: LayoutPreset = [
height: 220,
fontSize: 100,
align: 'center',
fontFamily: 'Playfair Display',
fontFamily: 'Fraunces',
lineHeight: 1.3,
},
{ id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 800) / 2, y: 480, width: 800, height: 120, fontSize: 40, align: 'center', fontFamily: 'Montserrat' },
{ id: 'description', type: 'description', x: (c) => (c.canvasWidth - 900) / 2, y: 620, width: 900, height: 180, fontSize: 32, align: 'center', fontFamily: 'Lora', lineHeight: 1.5 },
{ id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 800) / 2, y: 480, width: 800, height: 120, fontSize: 40, align: 'center', fontFamily: 'Manrope' },
{ id: 'description', type: 'description', x: (c) => (c.canvasWidth - 900) / 2, y: 620, width: 900, height: 180, fontSize: 32, align: 'center', fontFamily: 'Manrope', lineHeight: 1.5 },
{
id: 'qr',
type: 'qr',
@@ -318,7 +318,7 @@ const sparklerSoireePreset: LayoutPreset = [
width: (c) => Math.min(c.qrSize, 480),
height: (c) => Math.min(c.qrSize, 480),
},
{ id: 'link', type: 'link', x: (c) => (c.canvasWidth - 600) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 160, width: 600, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' },
{ id: 'link', type: 'link', x: (c) => (c.canvasWidth - 600) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 160, width: 600, height: 80, align: 'center', fontSize: 24, fontFamily: 'Manrope' },
];
const confettiBashPreset: LayoutPreset = [
@@ -333,7 +333,7 @@ const confettiBashPreset: LayoutPreset = [
height: 220,
fontSize: 110,
align: 'center',
fontFamily: 'Playfair Display',
fontFamily: 'Fraunces',
lineHeight: 1.3,
},
{
@@ -345,7 +345,7 @@ const confettiBashPreset: LayoutPreset = [
height: 120,
fontSize: 42,
align: 'center',
fontFamily: 'Montserrat',
fontFamily: 'Manrope',
lineHeight: 1.4,
},
{
@@ -357,7 +357,7 @@ const confettiBashPreset: LayoutPreset = [
height: 180,
fontSize: 34,
align: 'center',
fontFamily: 'Lora',
fontFamily: 'Manrope',
lineHeight: 1.5,
},
{
@@ -377,7 +377,7 @@ const confettiBashPreset: LayoutPreset = [
height: 80,
align: 'center',
fontSize: 26,
fontFamily: 'Montserrat',
fontFamily: 'Manrope',
lineHeight: 1.5,
},
];
@@ -394,7 +394,7 @@ const balancedModernPreset: LayoutPreset = [
height: 380,
fontSize: 100,
align: 'left',
fontFamily: 'Playfair Display',
fontFamily: 'Fraunces',
lineHeight: 1.3,
},
{
@@ -406,7 +406,7 @@ const balancedModernPreset: LayoutPreset = [
height: 140,
fontSize: 42,
align: 'left',
fontFamily: 'Montserrat',
fontFamily: 'Manrope',
lineHeight: 1.4,
},
{
@@ -418,7 +418,7 @@ const balancedModernPreset: LayoutPreset = [
height: 300,
fontSize: 34,
align: 'left',
fontFamily: 'Lora',
fontFamily: 'Manrope',
lineHeight: 1.5,
},
{
@@ -429,7 +429,7 @@ const balancedModernPreset: LayoutPreset = [
width: 480,
height: 480,
},
{ id: 'link', type: 'link', x: (c) => c.canvasWidth - 480 - 120, y: 1000, width: 480, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' },
{ id: 'link', type: 'link', x: (c) => c.canvasWidth - 480 - 120, y: 1000, width: 480, height: 80, align: 'center', fontSize: 24, fontFamily: 'Manrope' },
];
const LAYOUT_PRESETS: Record<string, LayoutPreset> = {
@@ -495,7 +495,7 @@ export function buildDefaultElements(
height: resolvePresetValue(config.height, context, heightFallback),
fontSize: config.fontSize ?? typeStyle.fontSize,
align: config.align ?? typeStyle.align ?? 'left',
fontFamily: config.fontFamily ?? 'Lora',
fontFamily: config.fontFamily ?? 'Manrope',
content: null,
locked: config.locked ?? typeStyle.locked ?? false,
initial: config.initial ?? true,

View File

@@ -0,0 +1,75 @@
import { useTheme } from '@tamagui/core';
export const ADMIN_COLORS = {
primary: '#FF5A5F',
primaryStrong: '#C2413B',
accent: '#FFB6C1',
accentSoft: '#FFE5EC',
accentWarm: '#FFD6DE',
warning: '#F5C542',
success: '#06D6A0',
danger: '#B91C1C',
text: '#1F2937',
textMuted: '#6B7280',
textSubtle: '#94A3B8',
border: '#F2E4DA',
surface: '#FFFFFF',
surfaceMuted: '#FFFDFB',
backdrop: '#0F172A',
};
export const ADMIN_ACTION_COLORS = {
tasks: '#FF8A8E',
qr: ADMIN_COLORS.warning,
images: ADMIN_COLORS.accent,
guests: ADMIN_COLORS.success,
guestMessages: ADMIN_COLORS.primary,
invites: ADMIN_COLORS.primaryStrong,
branding: ADMIN_COLORS.accent,
photobooth: '#FF8A8E',
recap: ADMIN_COLORS.warning,
packages: ADMIN_COLORS.primary,
};
export const ADMIN_GRADIENTS = {
primaryCta: `linear-gradient(135deg, ${ADMIN_COLORS.primary}, #FF8A8E, ${ADMIN_COLORS.accent})`,
softCard: `linear-gradient(135deg, ${ADMIN_COLORS.accentSoft}, ${ADMIN_COLORS.accentWarm})`,
loginBackground: 'linear-gradient(135deg, #0b1020, #0f172a, #0b1020)',
};
export const ADMIN_MOTION = {
tileStaggerMs: 40,
};
export function useAdminTheme() {
const theme = useTheme();
return {
theme,
background: String(theme.background?.val ?? '#FFF8F5'),
surface: String(theme.surface?.val ?? ADMIN_COLORS.surface),
surfaceMuted: String(theme.gray2?.val ?? ADMIN_COLORS.surfaceMuted),
border: String(theme.borderColor?.val ?? ADMIN_COLORS.border),
text: String(theme.color?.val ?? ADMIN_COLORS.text),
textStrong: String(theme.color12?.val ?? theme.color?.val ?? ADMIN_COLORS.text),
muted: String(theme.gray?.val ?? ADMIN_COLORS.textMuted),
subtle: String(theme.gray8?.val ?? ADMIN_COLORS.textSubtle),
primary: String(theme.primary?.val ?? ADMIN_COLORS.primary),
accent: String(theme.blue6?.val ?? theme.accent?.val ?? ADMIN_COLORS.accent),
accentSoft: String(theme.blue3?.val ?? ADMIN_COLORS.accentSoft),
accentStrong: String(theme.blue10?.val ?? ADMIN_COLORS.primaryStrong),
successBg: String(theme.green3?.val ?? '#DCFCE7'),
successText: String(theme.green10?.val ?? '#166534'),
dangerBg: String(theme.red3?.val ?? '#FEE2E2'),
dangerText: String(theme.red11?.val ?? ADMIN_COLORS.danger),
warningBg: String(theme.yellow3?.val ?? '#FFF7ED'),
warningBorder: String(theme.yellow6?.val ?? '#FED7AA'),
warningText: String(theme.yellow11?.val ?? '#92400E'),
infoBg: String(theme.blue3?.val ?? ADMIN_COLORS.accentSoft),
infoText: String(theme.blue10?.val ?? ADMIN_COLORS.primaryStrong),
danger: String(theme.red10?.val ?? ADMIN_COLORS.danger),
backdrop: String(theme.gray12?.val ?? ADMIN_COLORS.backdrop),
overlay: String(theme.gray12?.val ?? 'rgba(15, 23, 42, 0.6)'),
shadow: String(theme.shadowColor?.val ?? 'rgba(31, 41, 55, 0.12)'),
};
}

View File

@@ -10,6 +10,7 @@ import { MobileCard, CTAButton } from '../components/Primitives';
import { ADMIN_HOME_PATH, ADMIN_WELCOME_BASE_PATH, ADMIN_WELCOME_PACKAGES_PATH, ADMIN_WELCOME_SUMMARY_PATH, adminPath } from '../../constants';
import { getTenantPackagesOverview, trackOnboarding } from '../../api';
import { getSelectedPackageId } from '../lib/onboardingSelection';
import { ADMIN_COLORS } from '../theme';
export default function WelcomeEventPage() {
const navigate = useNavigate();
@@ -51,7 +52,7 @@ export default function WelcomeEventPage() {
<Text fontSize="$sm" fontWeight="800">
{t('eventSetup.step.title', 'Event setup in minutes')}
</Text>
<Text fontSize="$sm" color="#6b7280">
<Text fontSize="$sm" color={ADMIN_COLORS.textMuted}>
{t(
'eventSetup.step.description',
'We guide you through name, date, mood, and tasks. Afterwards you can moderate photos and support guests live.',
@@ -80,7 +81,7 @@ export default function WelcomeEventPage() {
<Text fontSize="$sm" fontWeight="800">
{t('eventSetup.cta.heading', 'Ready for your first event?')}
</Text>
<Text fontSize="$sm" color="#6b7280">
<Text fontSize="$sm" color={ADMIN_COLORS.textMuted}>
{t(
'eventSetup.cta.description',
"You're switching to the event manager. Assign tasks, invite members, and test the gallery. You can always return to the welcome journey.",
@@ -116,14 +117,21 @@ function FeatureRow({
}) {
return (
<XStack alignItems="center" space="$2">
<XStack width={34} height={34} borderRadius={12} backgroundColor="#e0f2fe" alignItems="center" justifyContent="center">
<Icon size={16} color="#0284c7" />
<XStack
width={34}
height={34}
borderRadius={12}
backgroundColor={ADMIN_COLORS.accentSoft}
alignItems="center"
justifyContent="center"
>
<Icon size={16} color={ADMIN_COLORS.primary} />
</XStack>
<YStack>
<Text fontSize="$sm" fontWeight="700">
{title}
</Text>
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={ADMIN_COLORS.textMuted}>
{body}
</Text>
</YStack>

View File

@@ -15,6 +15,7 @@ import {
ADMIN_WELCOME_PACKAGES_PATH,
adminPath,
} from '../../constants';
import { ADMIN_COLORS } from '../theme';
export default function WelcomeLandingPage() {
const navigate = useNavigate();
@@ -50,7 +51,7 @@ export default function WelcomeLandingPage() {
<Text fontSize="$lg" fontWeight="900">
{t('hero.title', 'Design the next Fotospiel experience')}
</Text>
<Text fontSize="$sm" color="#6b7280">
<Text fontSize="$sm" color={ADMIN_COLORS.textMuted}>
{t(
'hero.description',
'In just a few steps you guide guests through a magical photo journey complete with storytelling, tasks, and a moderated gallery.',
@@ -117,8 +118,15 @@ function FeatureCard({
<MobileCard space="$2">
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" space="$2">
<XStack width={36} height={36} borderRadius={12} backgroundColor="#e0f2fe" alignItems="center" justifyContent="center">
<Icon size={18} color="#0284c7" />
<XStack
width={36}
height={36}
borderRadius={12}
backgroundColor={ADMIN_COLORS.accentSoft}
alignItems="center"
justifyContent="center"
>
<Icon size={18} color={ADMIN_COLORS.primary} />
</XStack>
<Text fontSize="$sm" fontWeight="800">
{title}
@@ -126,7 +134,7 @@ function FeatureCard({
</XStack>
{badge ? <PillBadge tone="muted">{badge}</PillBadge> : null}
</XStack>
<Text fontSize="$sm" color="#6b7280">
<Text fontSize="$sm" color={ADMIN_COLORS.textMuted}>
{body}
</Text>
</MobileCard>

View File

@@ -11,10 +11,12 @@ import { MobileCard, CTAButton, PillBadge } from '../components/Primitives';
import { getPackages, getTenantPackagesOverview, Package, trackOnboarding } from '../../api';
import { ADMIN_WELCOME_BASE_PATH, ADMIN_WELCOME_EVENT_PATH, ADMIN_WELCOME_SUMMARY_PATH, adminPath } from '../../constants';
import { getSelectedPackageId, setSelectedPackageId } from '../lib/onboardingSelection';
import { useAdminTheme } from '../theme';
export default function WelcomePackagesPage() {
const navigate = useNavigate();
const { t } = useTranslation('onboarding');
const { muted } = useAdminTheme();
const [selectedId, setSelectedId] = React.useState<number | null>(() => getSelectedPackageId());
const { data: overview } = useQuery({
@@ -60,7 +62,7 @@ export default function WelcomePackagesPage() {
>
{isLoading ? (
<MobileCard>
<Text fontSize="$sm" color="#6b7280">
<Text fontSize="$sm" color={muted}>
{t('packages.state.loading', 'Loading packages …')}
</Text>
</MobileCard>
@@ -69,7 +71,7 @@ export default function WelcomePackagesPage() {
<Text fontSize="$sm" fontWeight="700">
{t('packages.state.errorTitle', 'Failed to load')}
</Text>
<Text fontSize="$sm" color="#6b7280">
<Text fontSize="$sm" color={muted}>
{t('packages.state.errorDescription', 'Please try again or contact support.')}
</Text>
</MobileCard>
@@ -78,7 +80,7 @@ export default function WelcomePackagesPage() {
<Text fontSize="$sm" fontWeight="700">
{t('packages.state.emptyTitle', 'Catalogue is empty')}
</Text>
<Text fontSize="$sm" color="#6b7280">
<Text fontSize="$sm" color={muted}>
{t('packages.state.emptyDescription', 'No packages are currently available. Reach out to support to enable new offers.')}
</Text>
</MobileCard>
@@ -99,7 +101,7 @@ export default function WelcomePackagesPage() {
<Text fontSize="$sm" fontWeight="800">
{t('packages.step.title', 'Activate the right plan')}
</Text>
<Text fontSize="$sm" color="#6b7280">
<Text fontSize="$sm" color={muted}>
{t('packages.step.description', 'Secure capacity for your next event. Upgrade at any time only pay for what you need.')}
</Text>
</MobileCard>
@@ -132,6 +134,7 @@ function PackageCard({
onSelect: () => void;
}) {
const { t } = useTranslation('onboarding');
const { primary, border, accentSoft, muted } = useAdminTheme();
const badges = [
t('packages.card.badges.photos', { count: pkg.max_photos ?? t('summary.details.infinity', '∞') }),
t('packages.card.badges.guests', { count: pkg.max_guests ?? t('summary.details.infinity', '∞') }),
@@ -140,17 +143,17 @@ function PackageCard({
return (
<Pressable onPress={onSelect}>
<MobileCard borderColor={selected ? '#2563eb' : '#e5e7eb'} space="$2">
<MobileCard borderColor={selected ? primary : border} space="$2">
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" space="$2">
<XStack width={36} height={36} borderRadius={12} backgroundColor="#e0f2fe" alignItems="center" justifyContent="center">
<PackageIcon size={18} color="#0ea5e9" />
<XStack width={36} height={36} borderRadius={12} backgroundColor={accentSoft} alignItems="center" justifyContent="center">
<PackageIcon size={18} color={primary} />
</XStack>
<YStack>
<Text fontSize="$sm" fontWeight="800">
{pkg.name}
</Text>
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={muted}>
{t('packages.card.description', 'Ready for your next event right away.')}
</Text>
</YStack>
@@ -168,8 +171,8 @@ function PackageCard({
</XStack>
{selected ? (
<XStack alignItems="center" space="$1">
<Check size={14} color="#2563eb" />
<Text fontSize="$xs" color="#2563eb" fontWeight="700">
<Check size={14} color={primary} />
<Text fontSize="$xs" color={primary} fontWeight="700">
{t('packages.card.selected', 'Selected')}
</Text>
</XStack>

View File

@@ -10,6 +10,7 @@ import { MobileCard, CTAButton, PillBadge } from '../components/Primitives';
import { getPackages, getTenantPackagesOverview } from '../../api';
import { ADMIN_WELCOME_BASE_PATH, ADMIN_WELCOME_EVENT_PATH, ADMIN_WELCOME_PACKAGES_PATH, adminPath } from '../../constants';
import { getSelectedPackageId } from '../lib/onboardingSelection';
import { ADMIN_COLORS } from '../theme';
type SummaryPackage = {
id: number;
@@ -78,7 +79,7 @@ export default function WelcomeSummaryPage() {
>
{loading ? (
<MobileCard>
<Text fontSize="$sm" color="#6b7280">
<Text fontSize="$sm" color={ADMIN_COLORS.textMuted}>
{t('summary.state.loading', 'Checking available packages …')}
</Text>
</MobileCard>
@@ -87,7 +88,7 @@ export default function WelcomeSummaryPage() {
<Text fontSize="$sm" fontWeight="800">
{t('summary.state.missingTitle', 'No package selected')}
</Text>
<Text fontSize="$sm" color="#6b7280">
<Text fontSize="$sm" color={ADMIN_COLORS.textMuted}>
{t('summary.state.missingDescription', 'Select a package first or refresh if data changed.')}
</Text>
<CTAButton label={t('summary.footer.back', 'Back to package selection')} onPress={() => navigate(ADMIN_WELCOME_PACKAGES_PATH)} />
@@ -96,14 +97,21 @@ export default function WelcomeSummaryPage() {
<MobileCard space="$3">
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" space="$2">
<XStack width={36} height={36} borderRadius={12} backgroundColor="#e0f2fe" alignItems="center" justifyContent="center">
<PackageIcon size={18} color="#0ea5e9" />
<XStack
width={36}
height={36}
borderRadius={12}
backgroundColor={ADMIN_COLORS.accentSoft}
alignItems="center"
justifyContent="center"
>
<PackageIcon size={18} color={ADMIN_COLORS.primary} />
</XStack>
<YStack>
<Text fontSize="$sm" fontWeight="800">
{resolvedPackage.name}
</Text>
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={ADMIN_COLORS.textMuted}>
{resolvedPackage.active
? t('summary.details.section.statusActive', 'Already purchased')
: t('summary.details.section.statusInactive', 'Not purchased yet')}
@@ -139,8 +147,8 @@ export default function WelcomeSummaryPage() {
{resolvedPackage.active ? (
<XStack alignItems="center" space="$2">
<CheckCircle2 size={18} color="#22c55e" />
<Text fontSize="$sm" color="#16a34a" fontWeight="700">
<CheckCircle2 size={18} color={ADMIN_COLORS.success} />
<Text fontSize="$sm" color={ADMIN_COLORS.success} fontWeight="700">
{t('summary.details.section.statusActive', 'Already purchased')}
</Text>
</XStack>
@@ -162,10 +170,10 @@ export default function WelcomeSummaryPage() {
],
}) as string[]).map((item) => (
<XStack key={item} space="$2">
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={ADMIN_COLORS.textMuted}>
</Text>
<Text fontSize="$sm" color="#6b7280">
<Text fontSize="$sm" color={ADMIN_COLORS.textMuted}>
{item}
</Text>
</XStack>
@@ -194,10 +202,10 @@ export default function WelcomeSummaryPage() {
function SummaryRow({ label, value }: { label: string; value: string }) {
return (
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" color="#111827">
<Text fontSize="$sm" color={ADMIN_COLORS.text}>
{label}
</Text>
<Text fontSize="$sm" color="#6b7280">
<Text fontSize="$sm" color={ADMIN_COLORS.textMuted}>
{value}
</Text>
</XStack>