first implementation of tamagui mobile pages

This commit is contained in:
Codex Agent
2025-12-10 15:49:08 +01:00
parent 5c93bfa405
commit 9930b272ca
39 changed files with 491904 additions and 2727 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,10 @@
import React from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { AlertTriangle, ArrowRight, CalendarDays, Camera, Heart, Plus } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { FrostedSurface, SectionCard } from '../components/tenant';
import { cn } from '@/lib/utils';
import { AppCard, PrimaryCTA, Segmented, StatusPill, MetaRow, BottomNav } from '../tamagui/primitives';
import { AdminLayout } from '../components/AdminLayout';
import { getEvents, TenantEvent } from '../api';
@@ -86,33 +84,32 @@ export default function EventsPage() {
];
return (
<AdminLayout title={pageTitle}>
{error && (
<Alert variant="destructive">
<AlertTitle>Fehler beim Laden</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<AdminLayout title={pageTitle} disableCommandShelf>
<YStack space="$3" maxWidth={560} marginHorizontal="auto" paddingBottom="$8">
{error ? (
<Alert variant="destructive">
<AlertTitle>Fehler beim Laden</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<AppCard>
<YStack space="$1">
<Text fontSize="$lg" fontWeight="700" color="$color">
{t('events.list.dashboardTitle', 'All Events Dashboard')}
</Text>
<Text fontSize="$sm" color="$color">
{t('events.list.dashboardSubtitle', 'Schneller Überblick über deine Events')}
</Text>
</YStack>
<Segmented
options={filterOptions.map((opt) => ({ key: opt.key, label: `${opt.label} (${opt.count})` }))}
value={statusFilter}
onChange={(key) => setStatusFilter(key as typeof statusFilter)}
/>
<PrimaryCTA label={t('events.actions.create', 'Create New Event')} onPress={() => navigate(adminPath('/events/new'))} />
</AppCard>
<SectionCard className="space-y-4">
<div className="flex gap-2 overflow-x-auto pb-1">
{filterOptions.map((option) => (
<button
key={option.key}
type="button"
onClick={() => setStatusFilter(option.key)}
className={cn(
'flex items-center gap-2 rounded-full border px-4 py-1.5 text-xs font-semibold transition',
statusFilter === option.key
? 'border-rose-200 bg-rose-50 text-rose-700 shadow shadow-rose-100/40 dark:border-white/60 dark:bg-white/10 dark:text-white'
: 'border-slate-200 text-slate-600 hover:text-slate-900 dark:border-white/15 dark:text-slate-300 dark:hover:text-white'
)}
>
{option.label}
<span className="text-[11px] text-slate-400 dark:text-slate-500">{option.count}</span>
</button>
))}
</div>
{loading ? (
<LoadingState />
) : filteredRows.length === 0 ? (
@@ -125,7 +122,7 @@ export default function EventsPage() {
onCreate={() => navigate(adminPath('/events/new'))}
/>
) : (
<div className="space-y-3">
<YStack space="$3">
{filteredRows.map((event) => (
<EventCard
key={event.id}
@@ -134,9 +131,21 @@ export default function EventsPage() {
translateCommon={translateCommon}
/>
))}
</div>
</YStack>
)}
</SectionCard>
</YStack>
<BottomNav
active="events"
onNavigate={(key) => {
if (key === 'analytics') {
navigate(adminPath('/dashboard'));
} else if (key === 'settings') {
navigate(adminPath('/settings'));
} else {
navigate(adminPath('/events'));
}
}}
/>
</AdminLayout>
);
}
@@ -158,6 +167,14 @@ function EventCard({
() => buildLimitWarnings(event.limits ?? null, (key, opts) => translateCommon(`limits.${key}`, undefined, opts)),
[event.limits, translateCommon],
);
const statusLabel = translateCommon(
event.status === 'published'
? 'events.status.published'
: event.status === 'archived'
? 'events.status.archived'
: 'events.status.draft',
event.status === 'published' ? 'Live' : event.status === 'archived' ? 'Archiviert' : 'Entwurf',
);
const metaItems = [
{
key: 'date',
@@ -189,68 +206,71 @@ function EventCard({
];
return (
<FrostedSurface className="space-y-4 rounded-3xl p-5 shadow-lg shadow-rose-100/30 transition hover:-translate-y-0.5 hover:shadow-rose-200/60">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.3em] text-rose-300/80">{translate('events.list.item.label', 'Event')}</p>
<h3 className="text-xl font-semibold text-slate-900">{renderName(event.name)}</h3>
</div>
<Badge className={isPublished ? 'bg-emerald-500/90 text-white shadow shadow-emerald-500/30' : 'bg-slate-200 text-slate-700'}>
{isPublished
? translateCommon('events.status.published', 'Veröffentlicht')
: translateCommon('events.status.draft', 'Entwurf')}
</Badge>
</div>
<AppCard>
<XStack justifyContent="space-between" alignItems="flex-start" space="$3">
<YStack space="$1">
<Text fontSize="$xs" letterSpacing={2.6} textTransform="uppercase" color="$color">
{translate('events.list.item.label', 'Event')}
</Text>
<Text fontSize="$lg" fontWeight="700" color="$color">
{renderName(event.name)}
</Text>
<MetaRow date={formatDate(event.event_date)} location={resolveLocation(event, translate)} status={statusLabel} />
</YStack>
<StatusPill tone={isPublished ? 'success' : 'warning'}>{statusLabel}</StatusPill>
</XStack>
<div className="-mx-1 flex snap-x snap-mandatory gap-3 overflow-x-auto px-1">
<XStack space="$2" flexWrap="wrap">
{metaItems.map((item) => (
<MetaChip key={item.key} icon={item.icon} label={item.label} value={item.value} />
))}
</div>
</XStack>
{limitWarnings.length > 0 && (
<div className="space-y-2">
{limitWarnings.length > 0 ? (
<YStack space="$2">
{limitWarnings.map((warning) => (
<div
<XStack
key={warning.id}
className={cn(
'flex items-start gap-2 rounded-2xl border p-3 text-xs',
warning.tone === 'danger'
? 'border-rose-200/60 bg-rose-50 text-rose-900'
: 'border-amber-200/60 bg-amber-50 text-amber-900',
)}
space="$2"
alignItems="flex-start"
borderWidth={1}
borderRadius="$tile"
padding="$3"
backgroundColor={warning.tone === 'danger' ? '#fff1f2' : '#fffbeb'}
borderColor={warning.tone === 'danger' ? '#fecdd3' : '#fef3c7'}
>
<AlertTriangle className="h-4 w-4" />
<span>{warning.message}</span>
</div>
<Text fontSize="$xs" color="$color">
{warning.message}
</Text>
</XStack>
))}
</div>
)}
</YStack>
) : null}
<div className="grid gap-2 sm:grid-cols-2">
<Button
asChild
className="rounded-full bg-brand-rose text-white shadow shadow-rose-400/40 hover:bg-brand-rose/90"
<XStack space="$2">
<Link
to={ADMIN_EVENT_VIEW_PATH(slug)}
className="flex-1 rounded-xl bg-[#007AFF] px-4 py-3 text-center text-sm font-semibold text-white shadow-sm transition hover:opacity-90"
>
<Link to={ADMIN_EVENT_VIEW_PATH(slug)}>
{translateCommon('actions.open', 'Öffnen')} <ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
<Button asChild variant="outline" className="rounded-full border-rose-200 text-rose-600 hover:bg-rose-50">
<Link to={ADMIN_EVENT_PHOTOS_PATH(slug)}>
{translate('events.list.actions.photos', 'Fotos moderieren')}
</Link>
</Button>
</div>
{translateCommon('actions.open', 'Öffnen')} <ArrowRight className="inline h-4 w-4" />
</Link>
<Link
to={ADMIN_EVENT_PHOTOS_PATH(slug)}
className="flex-1 rounded-xl border border-slate-200 px-4 py-3 text-center text-sm font-semibold text-[#007AFF] transition hover:bg-slate-50"
>
{translate('events.list.actions.photos', 'Fotos moderieren')}
</Link>
</XStack>
<div className="flex flex-wrap gap-2">
<XStack flexWrap="wrap" space="$2">
{secondaryLinks.map((action) => (
<ActionChip key={action.key} to={action.to}>
{action.label}
</ActionChip>
))}
</div>
</FrostedSurface>
</XStack>
</AppCard>
);
}
@@ -264,22 +284,23 @@ function MetaChip({
value: string | number;
}) {
return (
<div className="min-w-[55%] snap-center rounded-2xl border border-slate-200 bg-white p-3 text-left text-xs shadow-sm sm:min-w-0 dark:border-white/15 dark:bg-white/10 dark:text-white">
<div className="flex items-center gap-2 text-slate-500 dark:text-slate-300">
<YStack borderWidth={1} borderColor="$muted" borderRadius="$tile" padding="$3" minWidth="45%">
<XStack alignItems="center" space="$2">
{icon}
<span>{label}</span>
</div>
<p className="mt-1 text-sm font-semibold text-slate-900 dark:text-white">{value}</p>
</div>
<Text fontSize="$xs" color="$color">
{label}
</Text>
</XStack>
<Text fontSize="$md" fontWeight="700" color="$color" marginTop="$1">
{value}
</Text>
</YStack>
);
}
function ActionChip({ to, children }: { to: string; children: React.ReactNode }) {
return (
<Link
to={to}
className="inline-flex items-center rounded-full border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-rose-200 hover:bg-rose-50 hover:text-rose-700 dark:border-white/15 dark:text-slate-300 dark:hover:border-white/40 dark:hover:bg-white/10 dark:hover:text-white"
>
<Link to={to} className="rounded-full border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-600 hover:bg-slate-50">
{children}
</Link>
);
@@ -287,14 +308,11 @@ function ActionChip({ to, children }: { to: string; children: React.ReactNode })
function LoadingState() {
return (
<div className="space-y-3">
<YStack space="$2">
{Array.from({ length: 3 }).map((_, index) => (
<FrostedSurface
key={index}
className="h-24 animate-pulse rounded-3xl bg-gradient-to-r from-white/20 via-white/60 to-white/20"
/>
<AppCard key={index} height={96} opacity={0.6} />
))}
</div>
</YStack>
);
}
@@ -308,21 +326,20 @@ function EmptyState({
onCreate: () => void;
}) {
return (
<FrostedSurface className="flex flex-col items-center justify-center gap-4 border-dashed border-pink-200/70 p-10 text-center">
<div className="rounded-full bg-pink-100 p-3 text-pink-600 shadow-inner shadow-pink-200/80">
<Plus className="h-5 w-5" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold text-slate-900">{title}</h3>
<p className="text-sm text-slate-600">{description}</p>
</div>
<Button
onClick={onCreate}
className="rounded-full bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 px-6 text-white shadow-lg shadow-pink-500/20"
>
<Plus className="mr-1 h-4 w-4" /> Event erstellen
</Button>
</FrostedSurface>
<AppCard alignItems="center" justifyContent="center" space="$3" borderStyle="dashed" borderColor="$muted">
<YStack bg="$muted" padding="$3" borderRadius="$pill">
<Plus className="h-5 w-5 text-[#007AFF]" />
</YStack>
<YStack space="$1" alignItems="center">
<Text fontSize="$lg" fontWeight="700" color="$color">
{title}
</Text>
<Text fontSize="$sm" color="$color" textAlign="center">
{description}
</Text>
</YStack>
<PrimaryCTA label="Event erstellen" onPress={onCreate} />
</AppCard>
);
}
@@ -346,3 +363,20 @@ function renderName(name: TenantEvent['name']): string {
}
return 'Unbenanntes Event';
}
function resolveLocation(
event: TenantEvent,
translate: (key: string, fallback?: string, options?: Record<string, unknown>) => string,
): string {
const settings = (event.settings ?? {}) as Record<string, unknown>;
const candidate =
(settings.location as string | undefined) ??
(settings.address as string | undefined) ??
(settings.city as string | undefined);
if (typeof candidate === 'string' && candidate.trim().length > 0) {
return candidate;
}
return translate('events.list.meta.locationFallback', 'Ort folgt');
}