upgrade to tamagui v2 and guest pwa overhaul

This commit is contained in:
Codex Agent
2026-02-02 13:01:20 +01:00
parent 2e78f3ab8d
commit 7c6e14ffe2
168 changed files with 47462 additions and 8914 deletions

View File

@@ -93,7 +93,7 @@ export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: D
borderColor="rgba(234,179,8,0.5)"
backgroundColor="rgba(255,255,255,0.95)"
padding="$3"
space="$2"
gap="$2"
borderRadius="$4"
shadowColor="#f59e0b"
shadowOpacity={0.25}
@@ -102,7 +102,7 @@ export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: D
maxWidth={320}
>
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<Text fontSize={13} fontWeight="800" color="#92400e">
Demo tenants
</Text>
@@ -119,7 +119,7 @@ export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: D
aria-label="Switcher minimieren"
/>
</XStack>
<YStack space="$1">
<YStack gap="$1">
{DEV_TENANT_KEYS.map(({ key, label }) => (
<Button
key={key}
@@ -162,7 +162,7 @@ export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: D
right="$4"
zIndex={1000}
maxWidth={320}
space="$2"
gap="$2"
borderWidth={1}
borderColor="rgba(234,179,8,0.5)"
backgroundColor="rgba(255,255,255,0.95)"
@@ -176,7 +176,7 @@ export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: D
style={{ bottom: bottomOffset + 70 }}
>
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<Text fontSize={13} fontWeight="800" color="#92400e">
Demo tenants
</Text>
@@ -196,7 +196,7 @@ export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: D
<Text fontSize={11} color="#a16207">
Select a seeded tenant to mint Sanctum PATs and jump straight into their admin space. Available only in development builds.
</Text>
<YStack space="$1">
<YStack gap="$1">
{DEV_TENANT_KEYS.map(({ key, label }) => (
<Button
key={key}

View File

@@ -67,7 +67,7 @@ export default function AuthCallbackPage(): React.ReactElement {
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
>
<YStack alignItems="center" space="$2">
<YStack alignItems="center" gap="$2">
<Spinner size="small" color={textStrong} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('processing.title', 'Signing you in …')}

View File

@@ -274,9 +274,9 @@ export default function MobileBillingPage() {
<ContextHelpLink slug="billing-packages-exports" />
</XStack>
{pendingCheckout && (checkoutStatus === 'failed' || checkoutStatus === 'cancelled') ? (
<MobileCard borderColor={danger} backgroundColor="$red1" space="$2">
<MobileCard borderColor={danger} backgroundColor="$red1" gap="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$0.5" flex={1}>
<YStack gap="$0.5" flex={1}>
<Text fontSize="$sm" fontWeight="800" color={danger}>
{t('billing.checkoutFailedTitle', 'Checkout failed')}
</Text>
@@ -296,7 +296,7 @@ export default function MobileBillingPage() {
{t('billing.checkoutFailedBadge', 'Failed')}
</PillBadge>
</XStack>
<XStack space="$2">
<XStack gap="$2">
<CTAButton
label={t('billing.checkoutFailedRetry', 'Try again')}
onPress={() => navigate(shopLink)}
@@ -312,9 +312,9 @@ export default function MobileBillingPage() {
</MobileCard>
) : null}
{pendingCheckout && checkoutStatus === 'requires_customer_action' ? (
<MobileCard borderColor={accentSoft} backgroundColor={accentSoft} space="$2">
<MobileCard borderColor={accentSoft} backgroundColor={accentSoft} gap="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$0.5" flex={1}>
<YStack gap="$0.5" flex={1}>
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
{t('billing.checkoutActionTitle', 'Action required')}
</Text>
@@ -326,7 +326,7 @@ export default function MobileBillingPage() {
{t('billing.checkoutActionBadge', 'Action needed')}
</PillBadge>
</XStack>
<XStack space="$2">
<XStack gap="$2">
<CTAButton
label={t('billing.checkoutActionButton', 'Continue checkout')}
onPress={() => {
@@ -348,9 +348,9 @@ export default function MobileBillingPage() {
</MobileCard>
) : null}
{pendingCheckout && checkoutStatus !== 'failed' && checkoutStatus !== 'cancelled' && checkoutStatus !== 'requires_customer_action' ? (
<MobileCard borderColor={accentSoft} backgroundColor={accentSoft} space="$2">
<MobileCard borderColor={accentSoft} backgroundColor={accentSoft} gap="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$0.5" flex={1}>
<YStack gap="$0.5" flex={1}>
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
{t('billing.checkoutPendingTitle', 'Activating your package')}
</Text>
@@ -365,7 +365,7 @@ export default function MobileBillingPage() {
{t('billing.checkoutPendingBadge', 'Pending')}
</PillBadge>
</XStack>
<XStack space="$2">
<XStack gap="$2">
<CTAButton label={t('billing.checkoutPendingRefresh', 'Refresh')} onPress={load} fullWidth={false} />
<CTAButton
label={t('billing.checkoutPendingDismiss', 'Dismiss')}
@@ -377,8 +377,8 @@ export default function MobileBillingPage() {
</MobileCard>
) : null}
<MobileCard space="$2" ref={packagesRef as any}>
<XStack alignItems="center" space="$2">
<MobileCard gap="$2" ref={packagesRef as any}>
<XStack alignItems="center" gap="$2">
<Package size={18} color={textStrong} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('billing.sections.packages.title', 'Packages')}
@@ -397,7 +397,7 @@ export default function MobileBillingPage() {
{t('common.loading', 'Lädt...')}
</Text>
) : (
<YStack space="$2">
<YStack gap="$2">
{activePackage ? (
<PackageCard
pkg={activePackage}
@@ -415,8 +415,8 @@ export default function MobileBillingPage() {
)}
</MobileCard>
<MobileCard space="$2" ref={invoicesRef as any}>
<XStack alignItems="center" space="$2">
<MobileCard gap="$2" ref={invoicesRef as any}>
<XStack alignItems="center" gap="$2">
<Receipt size={18} color={textStrong} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('billing.sections.invoices.title', 'Invoices & Payments')}
@@ -430,7 +430,7 @@ export default function MobileBillingPage() {
{t('common.loading', 'Lädt...')}
</Text>
) : transactions.length === 0 ? (
<YStack space="$2">
<YStack gap="$2">
<Text fontSize="$sm" color={text}>
{t('billing.sections.invoices.empty', 'Keine Zahlungen gefunden.')}
</Text>
@@ -438,7 +438,7 @@ export default function MobileBillingPage() {
<CTAButton label={t('billing.actions.contactSupport', 'Contact support')} tone="ghost" onPress={openSupport} />
</YStack>
) : (
<YStack space="$1.5">
<YStack gap="$1.5">
{transactions.slice(0, 8).map((trx) => (
<XStack key={trx.id} alignItems="center" justifyContent="space-between" borderBottomWidth={1} borderColor={border} paddingVertical="$1.5">
<YStack>
@@ -475,8 +475,8 @@ export default function MobileBillingPage() {
)}
</MobileCard>
<MobileCard space="$2">
<XStack alignItems="center" space="$2">
<MobileCard gap="$2">
<XStack alignItems="center" gap="$2">
<Sparkles size={18} color={textStrong} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('billing.sections.addOns.title', 'Add-ons')}
@@ -494,7 +494,7 @@ export default function MobileBillingPage() {
{t('billing.sections.addOns.empty', 'Keine Add-ons gebucht.')}
</Text>
) : (
<YStack space="$1.5">
<YStack gap="$1.5">
{addons.slice(0, 8).map((addon) => (
<AddonRow key={addon.id} addon={addon} />
))}
@@ -550,7 +550,7 @@ function PackageCard({
borderColor={isActive ? primary : border}
borderWidth={isActive ? 2 : 1}
backgroundColor={isActive ? accentSoft : undefined}
space="$2"
gap="$2"
>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
@@ -563,7 +563,7 @@ function PackageCard({
{expires}
</Text>
) : null}
<XStack space="$2" marginTop="$2" flexWrap="wrap">
<XStack gap="$2" marginTop="$2" flexWrap="wrap">
<PillBadge tone="muted">{remainingText}</PillBadge>
{pkg.price !== null && pkg.price !== undefined ? (
<PillBadge tone="muted">{formatAmount(pkg.price, pkg.currency ?? 'EUR')}</PillBadge>
@@ -578,7 +578,7 @@ function PackageCard({
</Text>
) : null}
{limitEntries.length ? (
<YStack space="$1.5" marginTop="$2">
<YStack gap="$1.5" marginTop="$2">
<Text fontSize="$xs" color={muted}>
{t('mobileBilling.details.limitsTitle', 'Limits')}
</Text>
@@ -595,12 +595,12 @@ function PackageCard({
</YStack>
) : null}
{featureKeys.length ? (
<YStack space="$1.5" marginTop="$2">
<YStack gap="$1.5" marginTop="$2">
<Text fontSize="$xs" color={muted}>
{t('mobileBilling.details.featuresTitle', 'Features')}
</Text>
{featureKeys.map((feature) => (
<XStack key={feature} alignItems="center" space="$2">
<XStack key={feature} alignItems="center" gap="$2">
<Sparkles size={14} color={primary} />
<Text fontSize="$xs" color={textStrong}>
{getPackageFeatureLabel(feature, t)}
@@ -610,7 +610,7 @@ function PackageCard({
</YStack>
) : null}
{usageMetrics.length ? (
<YStack space="$2" marginTop="$2">
<YStack gap="$2" marginTop="$2">
{usageMetrics.map((metric) => (
<UsageBar key={metric.key} metric={metric} />
))}
@@ -684,12 +684,12 @@ function UsageBar({ metric }: { metric: PackageUsageMetric }) {
const fillColor = status === 'danger' ? danger : status === 'warning' ? warningText : primary;
return (
<YStack space="$1.5">
<YStack gap="$1.5">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$xs" color={muted}>
{labelMap[metric.key]}
</Text>
<XStack alignItems="center" space="$1.5">
<XStack alignItems="center" gap="$1.5">
{statusLabel ? <PillBadge tone={status === 'danger' ? 'danger' : 'warning'}>{statusLabel}</PillBadge> : null}
<Text fontSize="$xs" color={textStrong} fontWeight="700">
{valueText}
@@ -737,7 +737,7 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
const eventPath = addon.event?.slug ? ADMIN_EVENT_VIEW_PATH(addon.event.slug) : null;
const hasImpact = Boolean(addon.extra_photos || addon.extra_guests || addon.extra_gallery_days);
const impactBadges = hasImpact ? (
<XStack space="$2" marginTop="$1.5" flexWrap="wrap">
<XStack gap="$2" marginTop="$1.5" flexWrap="wrap">
{addon.extra_photos ? (
<PillBadge tone="muted">{t('mobileBilling.extra.photos', '+{{count}} photos', { count: addon.extra_photos })}</PillBadge>
) : null}
@@ -751,7 +751,7 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
) : null;
return (
<MobileCard borderColor={border} padding="$3" space="$1.5">
<MobileCard borderColor={border} padding="$3" gap="$1.5">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{addon.label ?? addon.addon_key}

View File

@@ -389,7 +389,7 @@ export default function MobileBrandingPage() {
return (
<>
<MobileCard space="$3">
<MobileCard gap="$3">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.watermark.previewTitle', 'Watermark Preview')}
</Text>
@@ -421,7 +421,7 @@ export default function MobileBrandingPage() {
/>
) : null}
<MobileCard space="$3">
<MobileCard gap="$3">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.watermark.title', 'Wasserzeichen')}
</Text>
@@ -449,14 +449,14 @@ export default function MobileBrandingPage() {
</MobileField>
{resolvedMode === 'custom' && !controlsLocked ? (
<YStack space="$2">
<YStack gap="$2">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.watermark.upload', 'Wasserzeichen hochladen')}
</Text>
<Pressable onPress={() => document.getElementById('watermark-upload-input')?.click()}>
<XStack
alignItems="center"
space="$2"
gap="$2"
paddingHorizontal="$3.5"
paddingVertical="$2.5"
borderRadius={12}
@@ -520,7 +520,7 @@ export default function MobileBrandingPage() {
) : null}
</MobileCard>
<MobileCard space="$3">
<MobileCard gap="$3">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.watermark.placement', 'Position & Größe')}
</Text>
@@ -603,8 +603,8 @@ export default function MobileBrandingPage() {
<ContextHelpLink slug="event-branding-assets" />
</XStack>
<MobileCard space="$2">
<XStack space="$2">
<MobileCard gap="$2">
<XStack gap="$2">
<TabButton label={t('events.branding.titleShort', 'Branding')} active={activeTab === 'branding'} onPress={() => setActiveTab('branding')} />
<TabButton label={t('events.watermark.tab', 'Wasserzeichen')} active={activeTab === 'watermark'} onPress={() => setActiveTab('watermark')} />
</XStack>
@@ -612,20 +612,20 @@ export default function MobileBrandingPage() {
{activeTab === 'branding' ? (
<>
<MobileCard space="$3">
<MobileCard gap="$3">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.branding.previewTitle', 'Guest App Preview')}
</Text>
<YStack borderRadius={16} borderWidth={1} borderColor={previewBorder} backgroundColor={previewBackground} padding="$3" space="$2" alignItems="center">
<YStack borderRadius={16} borderWidth={1} borderColor={previewBorder} backgroundColor={previewBackground} padding="$3" gap="$2" alignItems="center">
<YStack width="100%" borderRadius={12} backgroundColor={previewSurface} borderWidth={1} borderColor={previewBorder} overflow="hidden">
<YStack
height={64}
style={{ background: `linear-gradient(135deg, ${previewForm.primary}, ${previewForm.accent})` }}
/>
<YStack padding="$3" space="$2">
<YStack padding="$3" gap="$2">
<XStack
alignItems="center"
space="$2"
gap="$2"
flexDirection={previewForm.logoPosition === 'center' ? 'column' : previewForm.logoPosition === 'right' ? 'row-reverse' : 'row'}
justifyContent={previewForm.logoPosition === 'center' ? 'center' : 'flex-start'}
>
@@ -665,7 +665,7 @@ export default function MobileBrandingPage() {
</Text>
</YStack>
</XStack>
<XStack space="$2" marginTop="$1">
<XStack gap="$2" marginTop="$1">
<ColorSwatch color={previewForm.primary} label={t('events.branding.primary', 'Primary')} borderColor={previewBorder} />
<ColorSwatch color={previewForm.accent} label={t('events.branding.accent', 'Accent')} borderColor={previewBorder} />
<ColorSwatch color={previewBackground} label={t('events.branding.background', 'Background')} borderColor={previewBorder} />
@@ -701,11 +701,11 @@ export default function MobileBrandingPage() {
) : null}
<>
<MobileCard space="$3">
<MobileCard gap="$3">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.branding.mode', 'Theme')}
</Text>
<XStack space="$2">
<XStack gap="$2">
<ModeButton
label={t('events.branding.modeLight', 'Light')}
active={form.mode === 'light'}
@@ -727,7 +727,7 @@ export default function MobileBrandingPage() {
</XStack>
</MobileCard>
<MobileCard space="$3">
<MobileCard gap="$3">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.branding.colors', 'Colors')}
</Text>
@@ -757,7 +757,7 @@ export default function MobileBrandingPage() {
/>
</MobileCard>
<MobileCard space="$3">
<MobileCard gap="$3">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.branding.fonts', 'Fonts')}
</Text>
@@ -786,7 +786,7 @@ export default function MobileBrandingPage() {
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.branding.fontSize', 'Font Size')}
</Text>
<XStack space="$2">
<XStack gap="$2">
<ModeButton
label={t('events.branding.fontSizeSmall', 'S')}
active={form.fontSize === 's'}
@@ -808,14 +808,14 @@ export default function MobileBrandingPage() {
</XStack>
</MobileCard>
<MobileCard space="$3">
<MobileCard gap="$3">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.branding.logo', 'Logo')}
</Text>
<Text fontSize="$sm" color={muted}>
{t('events.branding.logoHint', 'Upload a logo or use an emoji for the guest header.')}
</Text>
<XStack space="$2">
<XStack gap="$2">
<ModeButton
label={t('events.branding.logoModeUpload', 'Upload')}
active={form.logoMode === 'upload'}
@@ -847,7 +847,7 @@ export default function MobileBrandingPage() {
padding="$3"
alignItems="center"
justifyContent="center"
space="$2"
gap="$2"
>
{form.logoDataUrl ? (
<>
@@ -856,7 +856,7 @@ export default function MobileBrandingPage() {
alt={t('events.branding.logoAlt', 'Logo')}
style={{ maxHeight: 80, maxWidth: '100%', objectFit: 'contain' }}
/>
<XStack space="$2">
<XStack gap="$2">
<CTAButton
label={t('events.branding.replaceLogo', 'Replace logo')}
onPress={() => document.getElementById('branding-logo-input')?.click()}
@@ -868,7 +868,7 @@ export default function MobileBrandingPage() {
>
<XStack
alignItems="center"
space="$1.5"
gap="$1.5"
paddingHorizontal="$3"
paddingVertical="$2"
borderRadius={12}
@@ -892,7 +892,7 @@ export default function MobileBrandingPage() {
>
<XStack
alignItems="center"
space="$2"
gap="$2"
paddingHorizontal="$3.5"
paddingVertical="$2.5"
borderRadius={12}
@@ -939,7 +939,7 @@ export default function MobileBrandingPage() {
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.branding.logoPosition', 'Position')}
</Text>
<XStack space="$2">
<XStack gap="$2">
<ModeButton
label={t('events.branding.positionLeft', 'Left')}
active={form.logoPosition === 'left'}
@@ -962,7 +962,7 @@ export default function MobileBrandingPage() {
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.branding.logoSize', 'Size')}
</Text>
<XStack space="$2">
<XStack gap="$2">
<ModeButton
label={t('events.branding.logoSizeSmall', 'S')}
active={form.logoSize === 's'}
@@ -984,14 +984,14 @@ export default function MobileBrandingPage() {
</XStack>
</MobileCard>
<MobileCard space="$3">
<MobileCard gap="$3">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.branding.buttons', 'Buttons & Links')}
</Text>
<Text fontSize="$sm" color={muted}>
{t('events.branding.buttonsHint', 'Style, radius, and link color for CTA buttons.')}
</Text>
<XStack space="$2">
<XStack gap="$2">
<ModeButton
label={t('events.branding.buttonFilled', 'Filled')}
active={form.buttonStyle === 'filled'}
@@ -1039,7 +1039,7 @@ export default function MobileBrandingPage() {
renderWatermarkTab()
)}
<YStack space="$2">
<YStack gap="$2">
<CTAButton label={saving ? t('events.branding.saving', 'Saving...') : t('events.branding.save', 'Save Branding')} onPress={() => handleSave()} />
<Pressable disabled={loading || saving} onPress={handleReset}>
<XStack
@@ -1050,7 +1050,7 @@ export default function MobileBrandingPage() {
backgroundColor={surface}
borderWidth={1}
borderColor={border}
space="$2"
gap="$2"
>
<RefreshCcw size={16} color={textStrong} />
<Text fontSize="$sm" color={textStrong} fontWeight="700">
@@ -1067,7 +1067,7 @@ export default function MobileBrandingPage() {
footer={null}
bottomOffsetPx={120}
>
<YStack space="$2">
<YStack gap="$2">
{fontsLoading ? (
Array.from({ length: 4 }).map((_, idx) => <SkeletonCard key={`font-sk-${idx}`} height={48} />)
) : fonts.length === 0 ? (
@@ -1228,11 +1228,11 @@ function ColorField({
}) {
const { textStrong, muted } = useAdminTheme();
return (
<YStack space="$2" opacity={disabled ? 0.6 : 1}>
<YStack gap="$2" opacity={disabled ? 0.6 : 1}>
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{label}
</Text>
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<MobileColorInput
value={value}
onChange={(event) => onChange(event.target.value)}
@@ -1249,7 +1249,7 @@ function ColorField({
function ColorSwatch({ color, label, borderColor }: { color: string; label: string; borderColor?: string }) {
const { border, muted } = useAdminTheme();
return (
<YStack alignItems="center" space="$1">
<YStack alignItems="center" gap="$1">
<YStack width={40} height={40} borderRadius={12} borderWidth={1} borderColor={borderColor ?? border} backgroundColor={color} />
<Text fontSize="$xs" color={muted}>
{label}
@@ -1276,7 +1276,7 @@ function InputField({
const { primary } = useAdminTheme();
return (
<MobileField label={label}>
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<MobileInput
value={value}
placeholder={placeholder}
@@ -1316,7 +1316,7 @@ function LabeledSlider({
}) {
const { textStrong, muted, primary, border, surface } = useAdminTheme();
return (
<YStack space="$1.5">
<YStack gap="$1.5">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{label}
@@ -1367,7 +1367,7 @@ function PositionGrid({
];
return (
<YStack space="$2">
<YStack gap="$2">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
Position
</Text>
@@ -1503,8 +1503,8 @@ function InfoBadge({ icon, text, tone = 'info' }: { icon?: React.ReactNode; text
const color = tone === 'danger' ? dangerText : textStrong;
return (
<MobileCard space="$2" backgroundColor={background} borderColor={border}>
<XStack space="$2" alignItems="center">
<MobileCard gap="$2" backgroundColor={background} borderColor={border}>
<XStack gap="$2" alignItems="center">
{icon}
<Text fontSize="$sm" color={color}>
{text}
@@ -1529,7 +1529,7 @@ function UpgradeCard({
return (
<MobileCard
space="$4"
gap="$4"
padding="$6"
alignItems="center"
justifyContent="center"
@@ -1547,7 +1547,7 @@ function UpgradeCard({
>
<Lock size={32} color={primary} />
</YStack>
<YStack space="$2" alignItems="center">
<YStack gap="$2" alignItems="center">
<Text fontSize="$xl" fontWeight="900" color={textStrong} textAlign="center">
{title}
</Text>

View File

@@ -89,7 +89,7 @@ function SectionHeader({
const subtitleSize = compact ? '$xs' : '$sm';
const spacing = compact ? '$1' : '$1.5';
return (
<YStack space={spacing}>
<YStack gap={spacing}>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize={titleSize} fontWeight="800" color={theme.textStrong}>
{title}
@@ -222,7 +222,7 @@ export default function MobileDashboardPage() {
return (
<MobileShell activeTab="home" title={t('mobileDashboard.title', 'Dashboard')}>
<DashboardCard padding="$0">
<YStack padding="$3" space="$2">
<YStack padding="$3" gap="$2">
<SectionHeader
title={t('dashboard:overview.title', 'At a glance')}
showSeparator={false}
@@ -231,7 +231,7 @@ export default function MobileDashboardPage() {
/>
</YStack>
<Separator backgroundColor={theme.border} opacity={0.6} />
<YStack padding="$3" space="$2.5">
<YStack padding="$3" gap="$2.5">
{/* 1. LIFECYCLE HERO */}
<LifecycleHero
event={activeEvent}
@@ -326,7 +326,7 @@ function LifecycleHero({
if (phase === 'live') {
return (
<YStack space="$2">
<YStack gap="$2">
<Header />
<DashboardCard
variant={cardVariant}
@@ -335,10 +335,10 @@ function LifecycleHero({
borderColor="transparent"
style={{ backgroundImage: 'linear-gradient(135deg, #4F46E5 0%, #4338CA 100%)' }}
>
<YStack space={isEmbedded ? '$2.5' : '$3'}>
<YStack gap={isEmbedded ? '$2.5' : '$3'}>
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$1">
<XStack alignItems="center" space="$2">
<YStack gap="$1">
<XStack alignItems="center" gap="$2">
<YStack width={8} height={8} borderRadius={4} backgroundColor="#22C55E" />
<Text color="white" fontWeight="700" fontSize="$xs" textTransform="uppercase" letterSpacing={1}>
{t('dashboard:liveNow.status', 'Happening Now')}
@@ -404,11 +404,11 @@ function LifecycleHero({
if (phase === 'post') {
return (
<YStack space="$2">
<YStack gap="$2">
<Header />
<DashboardCard variant={cardVariant} padding={cardPadding}>
<YStack space={isEmbedded ? '$2.5' : '$3'}>
<XStack alignItems="center" space="$2.5">
<YStack gap={isEmbedded ? '$2.5' : '$3'}>
<XStack alignItems="center" gap="$2.5">
<YStack width={40} height={40} borderRadius={20} backgroundColor={theme.successText} alignItems="center" justifyContent="center">
<CheckCircle2 size={20} color="white" />
</YStack>
@@ -427,7 +427,7 @@ function LifecycleHero({
height={48}
borderRadius={16}
>
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<Download size={16} color="white" />
<Text fontSize="$sm" fontWeight="800" color="white">
{t('events.recap.downloadAll', 'Download photos')}
@@ -443,7 +443,7 @@ function LifecycleHero({
height={48}
borderRadius={16}
>
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<Text fontSize="$sm" fontWeight="800" color={theme.textStrong}>
{t('events.recap.openRecap', 'Open recap')}
</Text>
@@ -458,7 +458,7 @@ function LifecycleHero({
// SETUP
return (
<YStack space="$2">
<YStack gap="$2">
<Header />
{showQuickControls ? (
<XStack
@@ -472,7 +472,7 @@ function LifecycleHero({
paddingVertical="$2"
>
<Pressable onPress={() => navigate(adminPath(`/mobile/events/${event.slug}/edit`))}>
<XStack alignItems="center" space="$1.5">
<XStack alignItems="center" gap="$1.5">
<Settings size={16} color={theme.primary} />
<Text fontSize="$sm" fontWeight="700" color={theme.textStrong}>
{t('dashboard:readiness.quickSettings', 'Event settings')}
@@ -480,8 +480,8 @@ function LifecycleHero({
</XStack>
</Pressable>
<XStack alignItems="center" space="$3">
<YStack alignItems="center" space="$1">
<XStack alignItems="center" gap="$3">
<YStack alignItems="center" gap="$1">
<Text fontSize="$xs" color={theme.muted} textTransform="uppercase" letterSpacing={0.8}>
{t('dashboard:readiness.publishToggle', 'Live')}
</Text>
@@ -499,13 +499,13 @@ function LifecycleHero({
</XStack>
) : null}
<DashboardCard variant={cardVariant} padding={cardPadding}>
<YStack space={isEmbedded ? '$2.5' : '$3'}>
<YStack gap={isEmbedded ? '$2.5' : '$3'}>
<XStack alignItems="center" justifyContent="space-between">
<YStack>
<Text fontSize="$xs" color={theme.muted} fontWeight="700" textTransform="uppercase">
{t('dashboard:upcoming.status.planning', 'Countdown')}
</Text>
<Text fontSize="$2xl" fontWeight="900" color={theme.primary}>
<Text fontSize="$xxl" fontWeight="900" color={theme.primary}>
{daysToGo}{' '}
<Text fontSize="$sm" color={theme.muted} fontWeight="500">
{t('management:galleryStatus.daysLabel', 'days')}
@@ -518,7 +518,7 @@ function LifecycleHero({
</XStack>
{showNextStep && nextStep ? (
<YStack space="$2">
<YStack gap="$2">
<Text fontSize="$xs" fontWeight="700" color={theme.muted} textTransform="uppercase" letterSpacing={1}>
{t('dashboard:readiness.nextStepTitle', 'Next step')}
</Text>
@@ -531,7 +531,7 @@ function LifecycleHero({
paddingHorizontal="$3"
onPress={() => navigate(adminPath(nextStep.targetPath))}
title={
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<Circle size={18} color={theme.primary} strokeWidth={2.5} />
<Text fontSize="$sm" fontWeight="700" color={theme.textStrong}>
{nextStep.label}
@@ -546,7 +546,7 @@ function LifecycleHero({
) : undefined
}
iconAfter={
<XStack alignItems="center" space="$1">
<XStack alignItems="center" gap="$1">
<PillBadge tone="success">{nextStep.ctaLabel}</PillBadge>
<ChevronRight size={16} color={theme.muted} />
</XStack>
@@ -665,7 +665,7 @@ function UnifiedToolGrid({ event, navigate, permissions, isMember, isCompleted }
return (
<DashboardCard padding="$0">
<YStack padding="$3.5" space="$2">
<YStack padding="$3.5" gap="$2">
<SectionHeader
title={t('dashboard:quickActions.title', 'Quick actions')}
subtitle={t('dashboard:quickActions.description', 'Jump straight to the most important actions.')}
@@ -674,9 +674,9 @@ function UnifiedToolGrid({ event, navigate, permissions, isMember, isCompleted }
/>
</YStack>
<Separator backgroundColor={theme.border} opacity={0.6} />
<YStack padding="$3.5" space="$3">
<YStack padding="$3.5" gap="$3">
{sections.map((section) => (
<YStack key={section.title} space="$2">
<YStack key={section.title} gap="$2">
<Text fontSize="$xs" fontWeight="700" color={theme.muted} textTransform="uppercase" letterSpacing={1}>
{section.title}
</Text>
@@ -692,7 +692,7 @@ function UnifiedToolGrid({ event, navigate, permissions, isMember, isCompleted }
paddingHorizontal="$3"
onPress={() => navigate(adminPath(item.path))}
title={
<XStack alignItems="center" space="$2.5">
<XStack alignItems="center" gap="$2.5">
<XStack
width={32}
height={32}
@@ -729,7 +729,7 @@ function RecentPhotosSection({ photos, navigate, slug }: { photos: TenantPhoto[]
return (
<DashboardCard>
<YStack space="$2">
<YStack gap="$2">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$xs" fontWeight="700" color={theme.muted} textTransform="uppercase" letterSpacing={1}>
{t('photos.recentTitle', 'Latest Uploads')}
@@ -749,7 +749,7 @@ function RecentPhotosSection({ photos, navigate, slug }: { photos: TenantPhoto[]
<Separator backgroundColor={theme.border} opacity={0.6} />
<XStack space="$2" overflow="scroll" paddingVertical="$1">
<XStack gap="$2" overflow="scroll" paddingVertical="$1">
{photos.map((photo) => (
<Pressable key={photo.id} onPress={() => navigate(adminPath(`/mobile/events/${slug}/control-room`))}>
<YStack
@@ -762,7 +762,7 @@ function RecentPhotosSection({ photos, navigate, slug }: { photos: TenantPhoto[]
borderColor={theme.border}
>
{photo.thumbnail_url ? (
<Image source={{ uri: photo.thumbnail_url }} width={80} height={80} resizeMode="cover" />
<Image src={photo.thumbnail_url} width={80} height={80} objectFit="cover" />
) : (
<YStack flex={1} alignItems="center" justifyContent="center">
<ImageIcon size={20} color={theme.muted} />
@@ -785,12 +785,12 @@ function AlertsSection({ event, stats, t }: any) {
return (
<DashboardCard>
<YStack space="$2">
<YStack gap="$2">
<Text fontSize="$xs" fontWeight="700" color={theme.muted} textTransform="uppercase" letterSpacing={1}>
{t('management:alertsTitle', 'Alerts')}
</Text>
<Separator backgroundColor={theme.border} opacity={0.6} />
<YStack space="$2">
<YStack gap="$2">
{limitWarnings.map((w: any, idx: number) => {
const isDanger = w.tone === 'danger';
const bg = isDanger ? theme.dangerBg : theme.warningBg;
@@ -807,7 +807,7 @@ function AlertsSection({ event, stats, t }: any) {
borderWidth={1}
borderColor={border}
alignItems="center"
space="$2"
gap="$2"
>
<Icon size={16} color={text} />
<Text fontSize="$sm" color={text} fontWeight="600">
@@ -826,7 +826,7 @@ function EmptyState({ canManage, onCreate }: any) {
const theme = useAdminTheme();
const { t } = useTranslation(['management', 'mobile']);
return (
<YStack flex={1} alignItems="center" justifyContent="center" space="$4">
<YStack flex={1} alignItems="center" justifyContent="center" gap="$4">
<Sparkles size={48} color={theme.primary} />
<Text fontSize="$lg" fontWeight="800" color={theme.textStrong} textAlign="center">
{t('mobile:header.appName', 'Event Admin')}

View File

@@ -192,14 +192,14 @@ export function DataExportsPanel({
</MobileCard>
) : null}
<MobileCard space="$2">
<MobileCard gap="$2">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('dataExports.request.title', 'Export request')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('dataExports.request.hint', 'Export account data or a specific event archive.')}
</Text>
<YStack space="$2">
<YStack gap="$2">
{!isRecap ? (
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" color={text}>
@@ -276,7 +276,7 @@ export function DataExportsPanel({
/>
</MobileCard>
<MobileCard space="$2">
<MobileCard gap="$2">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('dataExports.history.title', 'Recent exports')}
</Text>
@@ -284,7 +284,7 @@ export function DataExportsPanel({
{t('dataExports.history.hint', 'Latest 10 exports for your account and events.')}
</Text>
{loading ? (
<YStack space="$2">
<YStack gap="$2">
<SkeletonCard height={72} />
<SkeletonCard height={72} />
</YStack>
@@ -293,9 +293,9 @@ export function DataExportsPanel({
{t('dataExports.history.empty', 'No exports yet.')}
</Text>
) : (
<YStack space="$2">
<YStack gap="$2">
{visibleExports.map((entry) => (
<MobileCard key={entry.id} space="$2">
<MobileCard key={entry.id} gap="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack>
<Text fontSize="$sm" fontWeight="700" color={textStrong}>

View File

@@ -37,7 +37,7 @@ export default function MobileEventAnalyticsPage() {
return (
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="home">
<MobileCard
space="$4"
gap="$4"
padding="$6"
alignItems="center"
justifyContent="center"
@@ -55,7 +55,7 @@ export default function MobileEventAnalyticsPage() {
>
<Lock size={32} color={primary} />
</YStack>
<YStack space="$2" alignItems="center">
<YStack gap="$2" alignItems="center">
<Text fontSize="$xl" fontWeight="900" color={textStrong} textAlign="center">
{t('analytics.lockedTitle', 'Unlock Analytics')}
</Text>
@@ -75,7 +75,7 @@ export default function MobileEventAnalyticsPage() {
if (isLoading) {
return (
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="home">
<YStack space="$3">
<YStack gap="$3">
<SkeletonCard height={200} />
<SkeletonCard height={150} />
<SkeletonCard height={150} />
@@ -116,12 +116,12 @@ export default function MobileEventAnalyticsPage() {
activeTab="home"
onBack={() => navigate(-1)}
>
<YStack space="$4">
<YStack space="$2">
<YStack gap="$4">
<YStack gap="$2">
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
{t('analytics.kpiTitle', 'Event snapshot')}
</Text>
<XStack space="$2" flexWrap="wrap">
<XStack gap="$2" flexWrap="wrap">
<KpiTile
icon={TrendingUp}
label={t('analytics.kpiUploads', 'Uploads')}
@@ -140,14 +140,14 @@ export default function MobileEventAnalyticsPage() {
</XStack>
</YStack>
{/* Activity Timeline */}
<MobileCard space="$3" borderColor={border} backgroundColor={surface}>
<XStack alignItems="center" space="$2">
<MobileCard gap="$3" borderColor={border} backgroundColor={surface}>
<XStack alignItems="center" gap="$2">
<TrendingUp size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('analytics.activityTitle', 'Activity Timeline')}
</Text>
</XStack>
<YStack space="$0.5">
<YStack gap="$0.5">
<Text fontSize="$xs" color={muted}>
{t('analytics.timeframe', 'Last {{hours}} hours', { hours: timeframeHours })}
</Text>
@@ -159,7 +159,7 @@ export default function MobileEventAnalyticsPage() {
</YStack>
{hasTimeline ? (
<YStack height={180} justifyContent="flex-end" space="$2">
<YStack height={180} justifyContent="flex-end" gap="$2">
<XStack alignItems="flex-end" justifyContent="space-between" height={150} gap="$1">
{timeline.map((point, index) => {
const heightPercent = (point.count / maxTimelineCount) * 100;
@@ -168,7 +168,7 @@ export default function MobileEventAnalyticsPage() {
const showLabel = timeline.length < 8 || index % 3 === 0;
return (
<YStack key={point.timestamp} flex={1} alignItems="center" space="$1">
<YStack key={point.timestamp} flex={1} alignItems="center" gap="$1">
<YStack
width="100%"
height={`${Math.max(heightPercent, 4)}%`}
@@ -200,8 +200,8 @@ export default function MobileEventAnalyticsPage() {
</MobileCard>
{/* Top Contributors */}
<MobileCard space="$3" borderColor={border} backgroundColor={surface}>
<XStack alignItems="center" space="$2">
<MobileCard gap="$3" borderColor={border} backgroundColor={surface}>
<XStack alignItems="center" gap="$2">
<Trophy size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('analytics.contributorsTitle', 'Top Contributors')}
@@ -209,10 +209,10 @@ export default function MobileEventAnalyticsPage() {
</XStack>
{hasContributors ? (
<YStack space="$3">
<YStack gap="$3">
{contributors.map((contributor, idx) => (
<XStack key={idx} alignItems="center" justifyContent="space-between" paddingVertical="$1">
<XStack alignItems="center" space="$3">
<XStack alignItems="center" gap="$3">
<YStack
width={28}
height={28}
@@ -250,8 +250,8 @@ export default function MobileEventAnalyticsPage() {
</MobileCard>
{/* Task Stats */}
<MobileCard space="$3" borderColor={border} backgroundColor={surface}>
<XStack alignItems="center" space="$2">
<MobileCard gap="$3" borderColor={border} backgroundColor={surface}>
<XStack alignItems="center" gap="$2">
<ListTodo size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('analytics.tasksTitle', 'Popular photo tasks')}
@@ -259,11 +259,11 @@ export default function MobileEventAnalyticsPage() {
</XStack>
{hasTasks ? (
<YStack space="$3">
<YStack gap="$3">
{tasks.map((task) => {
const percent = (task.count / maxTaskCount) * 100;
return (
<YStack key={task.task_id} space="$1">
<YStack key={task.task_id} gap="$1">
<XStack justifyContent="space-between">
<Text fontSize="$sm" color={textStrong} numberOfLines={1} flex={1}>
{task.task_name}
@@ -308,7 +308,7 @@ function EmptyState({
}) {
const { muted } = useAdminTheme();
return (
<YStack padding="$4" alignItems="center" justifyContent="center" space="$2">
<YStack padding="$4" alignItems="center" justifyContent="center" gap="$2">
<Text fontSize="$sm" color={muted}>
{message}
</Text>

View File

@@ -179,7 +179,7 @@ function PhotoGridTile({
)}
{badges.length ? (
<XStack position="absolute" top={6} left={6} space="$1.5">
<XStack position="absolute" top={6} left={6} gap="$1.5">
{badges.map((label) => (
<PhotoStatusTag key={`${photo.id}-${label}`} label={label} />
))}
@@ -194,7 +194,7 @@ function PhotoGridTile({
padding="$1"
borderRadius={12}
backgroundColor={overlayBg}
space="$2"
gap="$2"
justifyContent="space-between"
>
{actions.map((action) => (
@@ -1034,7 +1034,7 @@ export default function MobileEventControlRoomPage() {
}, [queuedActions, slug]);
const headerActions = (
<XStack space="$2">
<XStack gap="$2">
<HeaderActionButton
onPress={() => {
if (activeTab === 'moderation') {
@@ -1070,7 +1070,7 @@ export default function MobileEventControlRoomPage() {
value={activeTab}
onValueChange={(val) => setActiveTab(val as 'moderation' | 'live')}
header={(
<YStack space="$2">
<YStack gap="$2">
<XStack justifyContent="flex-end">
<ContextHelpLink slug="control-room-moderation" />
</XStack>
@@ -1094,8 +1094,8 @@ export default function MobileEventControlRoomPage() {
</XStack>
</Accordion.Trigger>
<Accordion.Content {...({ paddingTop: '$2' } as any)}>
<YStack space="$3">
<YStack space="$2">
<YStack gap="$3">
<YStack gap="$2">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('controlRoom.automation.title', 'Automation')}
</Text>
@@ -1177,7 +1177,7 @@ export default function MobileEventControlRoomPage() {
</MobileField>
</YStack>
<YStack space="$2">
<YStack gap="$2">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('controlRoom.automation.uploaders.title', 'Uploader overrides')}
</Text>
@@ -1195,7 +1195,7 @@ export default function MobileEventControlRoomPage() {
'Uploads from these devices skip the approval queue.',
)}
>
<YStack space="$2">
<YStack gap="$2">
<MobileSelect
value={trustedUploaderSelection}
onChange={(event) => setTrustedUploaderSelection(event.target.value)}
@@ -1215,7 +1215,7 @@ export default function MobileEventControlRoomPage() {
/>
</YStack>
{trustedUploaders.length ? (
<YStack space="$2">
<YStack gap="$2">
{trustedUploaders.map((rule) => (
<XStack
key={`trusted-${rule.device_id}`}
@@ -1227,7 +1227,7 @@ export default function MobileEventControlRoomPage() {
borderColor={border}
backgroundColor={surfaceMuted}
>
<YStack space="$0.5">
<YStack gap="$0.5">
<Text fontSize="$sm" fontWeight="700" color={text}>
{rule.label ?? t('common.anonymous', 'Anonymous')}
</Text>
@@ -1267,7 +1267,7 @@ export default function MobileEventControlRoomPage() {
'Uploads from these devices always need approval.',
)}
>
<YStack space="$2">
<YStack gap="$2">
<MobileSelect
value={forceReviewSelection}
onChange={(event) => setForceReviewSelection(event.target.value)}
@@ -1287,7 +1287,7 @@ export default function MobileEventControlRoomPage() {
/>
</YStack>
{forceReviewUploaders.length ? (
<YStack space="$2">
<YStack gap="$2">
{forceReviewUploaders.map((rule) => (
<XStack
key={`force-${rule.device_id}`}
@@ -1299,7 +1299,7 @@ export default function MobileEventControlRoomPage() {
borderColor={border}
backgroundColor={surfaceMuted}
>
<YStack space="$0.5">
<YStack gap="$0.5">
<Text fontSize="$sm" fontWeight="700" color={text}>
{rule.label ?? t('common.anonymous', 'Anonymous')}
</Text>
@@ -1344,11 +1344,11 @@ export default function MobileEventControlRoomPage() {
value: 'moderation',
label: t('controlRoom.tabs.moderation', 'Moderation'),
content: (
<YStack space="$2">
<YStack gap="$2">
{queuedEventCount > 0 ? (
<MobileCard>
<XStack alignItems="center" justifyContent="space-between" space="$2">
<YStack space="$1" flex={1}>
<XStack alignItems="center" justifyContent="space-between" gap="$2">
<YStack gap="$1" flex={1}>
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('mobilePhotos.queueTitle', 'Changes waiting to sync')}
</Text>
@@ -1371,7 +1371,7 @@ export default function MobileEventControlRoomPage() {
) : null}
<MobileCard>
<YStack space="$2">
<YStack gap="$2">
<Text fontSize="$xs" fontWeight="800" color={text}>
{t('mobilePhotos.filtersTitle', 'Filter')}
</Text>
@@ -1390,7 +1390,7 @@ export default function MobileEventControlRoomPage() {
value={moderationFilter}
onValueChange={(value: string) => value && setModerationFilter(value as ModerationFilter)}
>
<XStack space="$1.5">
<XStack gap="$1.5">
{MODERATION_FILTERS.map((option) => {
const active = option.value === moderationFilter;
const count = moderationCounts[option.value] ?? 0;
@@ -1407,7 +1407,7 @@ export default function MobileEventControlRoomPage() {
paddingHorizontal="$3"
pressStyle={{ opacity: 0.85 }}
>
<XStack alignItems="center" space="$1.5">
<XStack alignItems="center" gap="$1.5">
<Text fontSize="$xs" fontWeight="700" color={active ? '#fff' : muted}>
{t(option.labelKey, option.fallback)}
</Text>
@@ -1455,13 +1455,13 @@ export default function MobileEventControlRoomPage() {
) : null}
{moderationLoading && moderationPage === 1 ? (
<YStack space="$2">
<YStack gap="$2">
{Array.from({ length: 3 }).map((_, idx) => (
<SkeletonCard key={`moderation-skeleton-${idx}`} height={120} />
))}
</YStack>
) : moderationPhotos.length === 0 ? (
<MobileCard alignItems="center" justifyContent="center" space="$2">
<MobileCard alignItems="center" justifyContent="center" gap="$2">
<ImageIcon size={28} color={muted} />
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('controlRoom.emptyModeration', 'No uploads match this filter.')}
@@ -1543,7 +1543,7 @@ export default function MobileEventControlRoomPage() {
value: 'live',
label: t('controlRoom.tabs.live', 'Live Show'),
content: (
<YStack space="$2">
<YStack gap="$2">
<MobileCard borderColor={border} backgroundColor="transparent">
<Text fontSize="$sm" color={muted}>
{t(
@@ -1559,7 +1559,7 @@ export default function MobileEventControlRoomPage() {
</MobileCard>
<MobileCard>
<YStack space="$2">
<YStack gap="$2">
<Text fontSize="$xs" fontWeight="800" color={text}>
{t('liveShowQueue.filterLabel', 'Live status')}
</Text>
@@ -1578,7 +1578,7 @@ export default function MobileEventControlRoomPage() {
value={liveStatusFilter}
onValueChange={(value: string) => value && setLiveStatusFilter(value as LiveShowQueueStatus)}
>
<XStack space="$1.5">
<XStack gap="$1.5">
{LIVE_STATUS_OPTIONS.map((option) => {
const active = option.value === liveStatusFilter;
const count = liveCounts[option.value] ?? 0;
@@ -1595,7 +1595,7 @@ export default function MobileEventControlRoomPage() {
paddingHorizontal="$3"
pressStyle={{ opacity: 0.85 }}
>
<XStack alignItems="center" space="$1.5">
<XStack alignItems="center" gap="$1.5">
<Text fontSize="$xs" fontWeight="700" color={active ? '#fff' : muted}>
{t(option.labelKey, option.fallback)}
</Text>
@@ -1631,13 +1631,13 @@ export default function MobileEventControlRoomPage() {
) : null}
{liveLoading && livePage === 1 ? (
<YStack space="$2">
<YStack gap="$2">
{Array.from({ length: 3 }).map((_, idx) => (
<SkeletonCard key={`live-skeleton-${idx}`} height={120} />
))}
</YStack>
) : livePhotos.length === 0 ? (
<MobileCard alignItems="center" justifyContent="center" space="$2">
<MobileCard alignItems="center" justifyContent="center" gap="$2">
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('controlRoom.emptyLive', 'No photos waiting for Live Show.')}
</Text>

View File

@@ -377,7 +377,7 @@ export default function MobileEventFormPage() {
const requiredLabel = React.useCallback(
(label: string) => (
<XStack alignItems="center" space="$1">
<XStack alignItems="center" gap="$1">
<Text fontSize="$sm" fontWeight="800" color={text}>
{label}
</Text>
@@ -407,7 +407,7 @@ export default function MobileEventFormPage() {
<ContextHelpLink slug="event-settings" />
</XStack>
<MobileCard space="$3">
<MobileCard gap="$3">
<MobileField label={requiredLabel(t('eventForm.fields.name.label', 'Event name'))}>
<MobileInput
type="text"
@@ -471,7 +471,7 @@ export default function MobileEventFormPage() {
) : null}
<MobileField label={requiredLabel(t('eventForm.fields.date.label', 'Date & time'))}>
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<MobileDateInput
value={extractDateValue(form.date)}
onChange={handleDateChange}
@@ -511,7 +511,7 @@ export default function MobileEventFormPage() {
</MobileField>
<MobileField label={t('eventForm.fields.location.label', 'Location')}>
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<MobileInput
type="text"
value={form.location}
@@ -524,7 +524,7 @@ export default function MobileEventFormPage() {
</MobileField>
<MobileField label={t('eventForm.fields.publish.label', 'Publish immediately')}>
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<Switch
checked={form.published}
onCheckedChange={(checked: boolean) =>
@@ -543,7 +543,7 @@ export default function MobileEventFormPage() {
</MobileField>
<MobileField label={t('eventForm.fields.tasksMode.label', 'Photo tasks & challenges')}>
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<Switch
checked={form.tasksEnabled}
onCheckedChange={(checked: boolean) =>
@@ -574,7 +574,7 @@ export default function MobileEventFormPage() {
</MobileField>
<MobileField label={t('eventForm.fields.uploadVisibility.label', 'Uploads visible immediately')}>
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<Switch
checked={form.autoApproveUploads}
onCheckedChange={(checked: boolean) =>
@@ -605,7 +605,7 @@ export default function MobileEventFormPage() {
</MobileField>
</MobileCard>
<YStack space="$2" paddingBottom="$10">
<YStack gap="$2" paddingBottom="$10">
{!isEdit ? (
<CTAButton
label={t('eventForm.actions.create', 'Create event')}

View File

@@ -204,11 +204,11 @@ export default function MobileEventGuestNotificationsPage() {
) : null}
<YStack ref={formRef}>
<MobileCard space="$3">
<MobileCard gap="$3">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('guestMessages.composeTitle', 'Send a message')}
</Text>
<YStack space="$2">
<YStack gap="$2">
<MobileField label={t('guestMessages.form.title', 'Title')}>
<MobileInput
type="text"
@@ -244,7 +244,7 @@ export default function MobileEventGuestNotificationsPage() {
</MobileField>
) : null}
<MobileField label={t('guestMessages.form.cta', 'CTA (optional)')}>
<YStack space="$1.5">
<YStack gap="$1.5">
<MobileInput
type="text"
value={form.cta_label}
@@ -262,7 +262,7 @@ export default function MobileEventGuestNotificationsPage() {
</Text>
</YStack>
</MobileField>
<XStack space="$2">
<XStack gap="$2">
<MobileField label={t('guestMessages.form.expiresIn', 'Expires in (minutes)')}>
<MobileInput
type="number"
@@ -301,7 +301,7 @@ export default function MobileEventGuestNotificationsPage() {
</MobileCard>
</YStack>
<MobileCard space="$2">
<MobileCard gap="$2">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('guestMessages.historyTitle', 'Recent messages')}
@@ -312,13 +312,13 @@ export default function MobileEventGuestNotificationsPage() {
</XStack>
{loading ? (
<YStack space="$2">
<YStack gap="$2">
{Array.from({ length: 3 }).map((_, idx) => (
<SkeletonCard key={`s-${idx}`} height={72} />
))}
</YStack>
) : history.length === 0 ? (
<YStack space="$2">
<YStack gap="$2">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('guestMessages.emptyTitle', 'Send your first guest message')}
</Text>
@@ -332,14 +332,14 @@ export default function MobileEventGuestNotificationsPage() {
/>
</YStack>
) : (
<YStack space="$2">
<YStack gap="$2">
{history.map((item) => (
<MobileCard key={item.id} space="$2" borderColor={border}>
<MobileCard key={item.id} gap="$2" borderColor={border}>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
{item.title || t('guestMessages.history.untitled', 'Untitled')}
</Text>
<XStack space="$1.5" alignItems="center">
<XStack gap="$1.5" alignItems="center">
<PillBadge tone={item.status === 'active' ? 'success' : 'muted'}>
{t(`guestMessages.status.${item.status}`, item.status)}
</PillBadge>
@@ -354,11 +354,11 @@ export default function MobileEventGuestNotificationsPage() {
{item.body ?? t('guestMessages.history.noBody', 'No body provided.')}
</Text>
<XStack alignItems="center" justifyContent="space-between">
<XStack space="$1.5" alignItems="center">
<XStack gap="$1.5" alignItems="center">
<PillBadge tone="muted">{t(`guestMessages.type.${item.type}`, item.type)}</PillBadge>
{item.target_identifier ? (
<PillBadge tone="muted">
<XStack alignItems="center" space="$1.5">
<XStack alignItems="center" gap="$1.5">
<User size={12} color={muted} />
<Text fontSize="$xs" fontWeight="700" color={muted}>
{item.target_identifier}
@@ -367,7 +367,7 @@ export default function MobileEventGuestNotificationsPage() {
</PillBadge>
) : (
<PillBadge tone="muted">
<XStack alignItems="center" space="$1.5">
<XStack alignItems="center" gap="$1.5">
<Users size={12} color={muted} />
<Text fontSize="$xs" fontWeight="700" color={muted}>
{t('guestMessages.audience.all', 'All guests')}

View File

@@ -285,15 +285,15 @@ export default function MobileEventLiveShowSettingsPage() {
</XStack>
{loading ? (
<YStack space="$2">
<YStack gap="$2">
{Array.from({ length: 3 }).map((_, idx) => (
<SkeletonCard key={`ls-skel-${idx}`} height={110} />
))}
</YStack>
) : (
<YStack space="$2">
<MobileCard space="$2">
<XStack alignItems="center" space="$2">
<YStack gap="$2">
<MobileCard gap="$2">
<XStack alignItems="center" gap="$2">
<Link2 size={18} color={text} />
<Text fontSize="$md" fontWeight="800" color={text}>
{t('liveShowSettings.link.title', 'Live Show link')}
@@ -313,7 +313,7 @@ export default function MobileEventLiveShowSettingsPage() {
{t('liveShowSettings.link.empty', 'No Live Show link available.')}
</Text>
)}
<XStack space="$2" marginTop="$2" alignItems="center" flexWrap="nowrap">
<XStack gap="$2" marginTop="$2" alignItems="center" flexWrap="nowrap">
<IconAction
label={t('liveShowSettings.link.copy', 'Copy')}
disabled={!liveShowLink?.url}
@@ -344,7 +344,7 @@ export default function MobileEventLiveShowSettingsPage() {
</IconAction>
</XStack>
{liveShowLink?.qr_code_data_url ? (
<XStack space="$2" alignItems="center" marginTop="$2" flexWrap="wrap">
<XStack gap="$2" alignItems="center" marginTop="$2" flexWrap="wrap">
<Pressable
onPress={() => downloadQr(liveShowLink.qr_code_data_url!, 'live-show-qr.png')}
title={t('liveShowSettings.link.downloadQr', 'Download QR')}
@@ -373,8 +373,8 @@ export default function MobileEventLiveShowSettingsPage() {
) : null}
</MobileCard>
<MobileCard space="$2">
<XStack alignItems="center" space="$2">
<MobileCard gap="$2">
<XStack alignItems="center" gap="$2">
<Settings size={18} color={text} />
<Text fontSize="$md" fontWeight="800" color={text}>
{t('liveShowSettings.title', 'Live Show settings')}
@@ -385,7 +385,7 @@ export default function MobileEventLiveShowSettingsPage() {
</Text>
</MobileCard>
<MobileCard space="$3">
<MobileCard gap="$3">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('liveShowSettings.sections.moderation', 'Moderation')}
</Text>
@@ -423,7 +423,7 @@ export default function MobileEventLiveShowSettingsPage() {
</MobileField>
</MobileCard>
<MobileCard space="$3">
<MobileCard gap="$3">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('liveShowSettings.sections.playback', 'Playback')}
</Text>
@@ -478,7 +478,7 @@ export default function MobileEventLiveShowSettingsPage() {
) : null}
</MobileCard>
<MobileCard space="$3">
<MobileCard gap="$3">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('liveShowSettings.sections.effects', 'Effects & layout')}
</Text>
@@ -661,7 +661,7 @@ function EffectSlider({
const { text, muted, primary, border, surface } = useAdminTheme();
return (
<YStack space="$1.5">
<YStack gap="$1.5">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="700" color={text}>
{label}

View File

@@ -163,11 +163,11 @@ export default function MobileEventMembersPage() {
<ContextHelpLink slug="event-team-invites" />
</XStack>
<MobileCard space="$3">
<MobileCard gap="$3">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.members.inviteTitle', 'Invite Member')}
</Text>
<YStack space="$2">
<YStack gap="$2">
<MobileField label={t('events.members.name', 'Name')}>
<MobileInput
type="text"
@@ -223,11 +223,11 @@ export default function MobileEventMembersPage() {
) : null}
{members.length > 0 ? (
<YStack space="$2">
<YStack gap="$2">
<Text fontSize="$xs" fontWeight="700" color={muted}>
{t('events.members.filters.statusLabel', 'Status')}
</Text>
<XStack space="$2" flexWrap="wrap">
<XStack gap="$2" flexWrap="wrap">
{statusOptions.map((option) => {
const isActive = statusFilter === option.key;
return (
@@ -251,7 +251,7 @@ export default function MobileEventMembersPage() {
<Text fontSize="$xs" fontWeight="700" color={muted}>
{t('events.members.filters.roleLabel', 'Role')}
</Text>
<XStack space="$2" flexWrap="wrap">
<XStack gap="$2" flexWrap="wrap">
{roleOptions.map((option) => {
const isActive = roleFilter === option.key;
return (
@@ -275,18 +275,18 @@ export default function MobileEventMembersPage() {
</YStack>
) : null}
<MobileCard space="$3">
<MobileCard gap="$3">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.members.listTitle', 'Team & Guests')}
</Text>
{loading ? (
<YStack space="$2">
<YStack gap="$2">
{Array.from({ length: 4 }).map((_, idx) => (
<SkeletonCard key={`m-${idx}`} height={70} />
))}
</YStack>
) : members.length === 0 ? (
<YStack space="$2">
<YStack gap="$2">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.members.emptyTitle', 'Invite your team')}
</Text>
@@ -295,9 +295,9 @@ export default function MobileEventMembersPage() {
</Text>
</YStack>
) : (
<YStack space="$2">
<YStack gap="$2">
{filteredMembers.length === 0 ? (
<YStack space="$1.5" padding="$2">
<YStack gap="$1.5" padding="$2">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.members.emptyFilteredTitle', 'No matching members')}
</Text>
@@ -322,14 +322,14 @@ export default function MobileEventMembersPage() {
return (
<MobileCard key={member.id} padding="$3" borderColor={border}>
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$1">
<YStack gap="$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">
<XStack gap="$1.5" alignItems="center">
<PillBadge tone={statusInfo.tone}>
{statusInfo.label}
</PillBadge>
@@ -340,7 +340,7 @@ export default function MobileEventMembersPage() {
</PillBadge>
</XStack>
</YStack>
<XStack space="$2">
<XStack gap="$2">
<Pressable
aria-label={t('events.members.copyEmailLabel', 'Copy email')}
onPress={async () => {

View File

@@ -191,15 +191,15 @@ export default function MobileEventPhotoboothPage() {
) : null}
{loading ? (
<YStack space="$2">
<YStack gap="$2">
{Array.from({ length: 3 }).map((_, idx) => (
<SkeletonCard key={`ph-skel-${idx}`} height={110} />
))}
</YStack>
) : (
<YStack space="$2">
<MobileCard space="$3">
<YStack space="$1">
<YStack gap="$2">
<MobileCard gap="$3">
<YStack gap="$1">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('photobooth.steps.activate.title', '1. Photobooth aktivieren')}
</Text>
@@ -207,7 +207,7 @@ export default function MobileEventPhotoboothPage() {
{t('photobooth.steps.activate.description', 'Schalte den Upload-Zugang fuer dieses Event frei.')}
</Text>
</YStack>
<XStack alignItems="center" justifyContent="space-between" space="$3" flexWrap="wrap">
<XStack alignItems="center" justifyContent="space-between" gap="$3" flexWrap="wrap">
<PillBadge tone={isActive ? 'success' : 'warning'}>
{isActive ? t('photobooth.status.badgeActive', 'ACTIVE') : t('photobooth.status.badgeInactive', 'INACTIVE')}
</PillBadge>
@@ -215,7 +215,7 @@ export default function MobileEventPhotoboothPage() {
{t('photobooth.mode.active', 'Current: {{mode}}', { mode: modeLabel })}
</Text>
</XStack>
<XStack space="$2" marginTop="$2">
<XStack gap="$2" marginTop="$2">
<CTAButton
label={isActive ? t('photobooth.actions.disable', 'Disable uploads') : t('photobooth.actions.enable', 'Enable uploads')}
onPress={() => (isActive ? handleDisable() : handleEnable())}
@@ -237,8 +237,8 @@ export default function MobileEventPhotoboothPage() {
</XStack>
</MobileCard>
<MobileCard space="$3">
<YStack space="$1">
<MobileCard gap="$3">
<YStack gap="$1">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('photobooth.steps.download.title', '2. Uploader App herunterladen')}
</Text>
@@ -249,7 +249,7 @@ export default function MobileEventPhotoboothPage() {
)}
</Text>
</YStack>
<XStack space="$2" marginTop="$2" flexWrap="wrap">
<XStack gap="$2" marginTop="$2" flexWrap="wrap">
<CTAButton
label={t('photobooth.uploaderDownload.actionWindows', 'Uploader herunterladen (Windows)')}
onPress={() => {
@@ -278,7 +278,7 @@ export default function MobileEventPhotoboothPage() {
fullWidth={false}
/>
</XStack>
<XStack space="$2" marginTop="$2">
<XStack gap="$2" marginTop="$2">
<CTAButton
label={
sendingEmail
@@ -294,8 +294,8 @@ export default function MobileEventPhotoboothPage() {
</XStack>
</MobileCard>
<MobileCard space="$3">
<YStack space="$1">
<MobileCard gap="$3">
<YStack gap="$1">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('photobooth.steps.access.title', '3. Verbindungscode erstellen')}
</Text>
@@ -303,7 +303,7 @@ export default function MobileEventPhotoboothPage() {
{t('photobooth.steps.access.description', 'Der Code verbindet die App sicher mit deinem Event.')}
</Text>
</YStack>
<XStack space="$2" marginTop="$2">
<XStack gap="$2" marginTop="$2">
<CTAButton
label={
connectLoading
@@ -326,7 +326,7 @@ export default function MobileEventPhotoboothPage() {
fullWidth={false}
/>
</XStack>
<YStack space="$2" marginTop="$2">
<YStack gap="$2" marginTop="$2">
{connectCode ? (
<CredentialRow label={t('photobooth.connectCode.label', 'Connect code')} value={connectCode} border={border} />
) : null}
@@ -338,7 +338,7 @@ export default function MobileEventPhotoboothPage() {
</Text>
) : null}
{showCredentials ? (
<YStack space="$1">
<YStack gap="$1">
<CredentialRow label={t('photobooth.credentials.postUrl', 'Upload URL')} value={uploadUrl ?? '—'} border={border} />
<CredentialRow label={t('photobooth.credentials.username', 'Username')} value={username ?? '—'} border={border} />
<CredentialRow label={t('photobooth.credentials.password', 'Password')} value={password ?? '—'} border={border} masked />
@@ -354,11 +354,11 @@ export default function MobileEventPhotoboothPage() {
</YStack>
</MobileCard>
<MobileCard space="$2">
<MobileCard gap="$2">
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('photobooth.status.heading', 'Status')}
</Text>
<YStack space="$1">
<YStack gap="$1">
<StatusRow icon={<ShieldCheck size={16} color={text} />} label={t('photobooth.status.mode', 'Mode')} value={modeLabel} />
<StatusRow
icon={<PlugZap size={16} color={text} />}
@@ -437,7 +437,7 @@ function StatusRow({ icon, label, value }: { icon: React.ReactNode; label: strin
const { text } = useAdminTheme();
return (
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
{icon}
<Text fontSize="$sm" color={text}>
{label}

View File

@@ -170,7 +170,7 @@ export default function MobileEventRecapPage() {
if (loading) {
return (
<MobileShell activeTab="home" title={t('events.recap.title', 'Event Recap')} onBack={back}>
<YStack space="$3">
<YStack gap="$3">
<SkeletonCard height={120} />
<SkeletonCard height={200} />
<SkeletonCard height={150} />
@@ -207,8 +207,8 @@ export default function MobileEventRecapPage() {
title={t('events.recap.title', 'Event Recap')}
onBack={back}
>
<YStack space="$4">
<XStack space="$2">
<YStack gap="$4">
<XStack gap="$2">
<TabButton
label={t('events.recap.tabs.overview', 'Overview')}
active={activeTab === 'overview'}
@@ -227,10 +227,10 @@ export default function MobileEventRecapPage() {
</XStack>
{activeTab === 'overview' ? (
<YStack space="$4">
<MobileCard space="$3">
<YStack gap="$4">
<MobileCard gap="$3">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$1">
<YStack gap="$1">
<Text fontSize="$xl" fontWeight="800" color={textStrong}>
{t('events.recap.completedTitle', 'Event abgeschlossen')}
</Text>
@@ -248,8 +248,8 @@ export default function MobileEventRecapPage() {
</XStack>
</MobileCard>
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<MobileCard gap="$3">
<XStack alignItems="center" gap="$2">
<Share2 size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.recap.shareGuests', 'Gäste-Galerie teilen')}
@@ -260,7 +260,7 @@ export default function MobileEventRecapPage() {
</Text>
{guestLink ? (
<YStack space="$2" marginTop="$1">
<YStack gap="$2" marginTop="$1">
<XStack
backgroundColor={border}
padding="$3"
@@ -292,7 +292,7 @@ export default function MobileEventRecapPage() {
)}
{guestLink && activeInvite?.qr_code_data_url ? (
<YStack alignItems="center" space="$2" marginTop="$2">
<YStack alignItems="center" gap="$2" marginTop="$2">
<YStack
padding="$2"
backgroundColor="white"
@@ -315,15 +315,15 @@ export default function MobileEventRecapPage() {
) : null}
</MobileCard>
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<MobileCard gap="$3">
<XStack alignItems="center" gap="$2">
<Users size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.recap.settings', 'Nachlauf-Optionen')}
</Text>
</XStack>
<YStack space="$1.5">
<YStack gap="$1.5">
<ToggleOption
label={t('events.recap.allowDownloads', 'Gäste dürfen Fotos laden')}
value={Boolean(event.settings?.guest_downloads_enabled)}
@@ -337,8 +337,8 @@ export default function MobileEventRecapPage() {
</YStack>
</MobileCard>
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<MobileCard gap="$3">
<XStack alignItems="center" gap="$2">
<Sparkles size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.recap.addons', 'Galerie verlängern')}
@@ -348,7 +348,7 @@ export default function MobileEventRecapPage() {
{t('events.recap.addonBody', 'Die Online-Zeit deiner Galerie neigt sich dem Ende? Hier kannst du sie verlängern.')}
</Text>
<YStack space="$2">
<YStack gap="$2">
{addons
.filter((a) => a.key === 'gallery_extension')
.map((addon) => (
@@ -365,9 +365,9 @@ export default function MobileEventRecapPage() {
) : null}
{activeTab === 'engagement' ? (
<YStack space="$4">
<YStack gap="$4">
{engagementLoading ? (
<YStack space="$2">
<YStack gap="$2">
<SkeletonCard height={140} />
<SkeletonCard height={180} />
<SkeletonCard height={180} />
@@ -387,9 +387,9 @@ export default function MobileEventRecapPage() {
</Text>
</MobileCard>
) : (
<YStack space="$4">
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<YStack gap="$4">
<MobileCard gap="$3">
<XStack alignItems="center" gap="$2">
<TrendingUp size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.recap.engagement.title', 'Guest engagement')}
@@ -418,8 +418,8 @@ export default function MobileEventRecapPage() {
</XStack>
</MobileCard>
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<MobileCard gap="$3">
<XStack alignItems="center" gap="$2">
<Trophy size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.recap.engagement.leaderboards.uploadsTitle', 'Top contributors')}
@@ -430,7 +430,7 @@ export default function MobileEventRecapPage() {
{t('events.recap.engagement.leaderboards.uploadsEmpty', 'No uploads yet.')}
</Text>
) : (
<YStack space="$1.5" marginTop="$1">
<YStack gap="$1.5" marginTop="$1">
{engagement.leaderboards.uploads.slice(0, 5).map((entry, index) => (
<LeaderboardRow
key={`${entry.guest}-${entry.photos}-${index}`}
@@ -443,8 +443,8 @@ export default function MobileEventRecapPage() {
)}
</MobileCard>
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<MobileCard gap="$3">
<XStack alignItems="center" gap="$2">
<Heart size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.recap.engagement.leaderboards.likesTitle', 'Most liked')}
@@ -455,7 +455,7 @@ export default function MobileEventRecapPage() {
{t('events.recap.engagement.leaderboards.likesEmpty', 'No likes yet.')}
</Text>
) : (
<YStack space="$1.5" marginTop="$1">
<YStack gap="$1.5" marginTop="$1">
{engagement.leaderboards.likes.slice(0, 5).map((entry, index) => (
<LeaderboardRow
key={`${entry.guest}-${entry.likes}-${index}`}
@@ -468,15 +468,15 @@ export default function MobileEventRecapPage() {
)}
</MobileCard>
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<MobileCard gap="$3">
<XStack alignItems="center" gap="$2">
<Sparkles size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.recap.engagement.highlightsTitle', 'Highlights')}
</Text>
</XStack>
<YStack space="$2" marginTop="$1">
<XStack space="$2" alignItems="center">
<YStack gap="$2" marginTop="$1">
<XStack gap="$2" alignItems="center">
<YStack
width={72}
height={72}
@@ -519,7 +519,7 @@ export default function MobileEventRecapPage() {
</Text>
</XStack>
<YStack space="$1">
<YStack gap="$1">
<Text fontSize="$sm" fontWeight="600" color={textStrong}>
{t('events.recap.engagement.timeline', 'Uploads over time')}
</Text>
@@ -528,7 +528,7 @@ export default function MobileEventRecapPage() {
{t('events.recap.engagement.timelineEmpty', 'No timeline data yet.')}
</Text>
) : (
<YStack space="$1">
<YStack gap="$1">
{engagement.highlights.timeline.slice(-5).map((point) => (
<XStack key={point.date} alignItems="center" justifyContent="space-between">
<Text fontSize="$xs" color={muted}>
@@ -553,7 +553,7 @@ export default function MobileEventRecapPage() {
) : null}
{activeTab === 'compliance' ? (
<YStack space="$4">
<YStack gap="$4">
<DataExportsPanel variant="recap" event={event} />
</YStack>
) : null}
@@ -643,7 +643,7 @@ function LeaderboardRow({ rank, name, value }: { rank: number; name: string; val
borderColor={border}
backgroundColor={surfaceMuted}
>
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<Text fontSize="$xs" color={muted} fontWeight="700">
#{rank}
</Text>

View File

@@ -588,8 +588,8 @@ export default function MobileEventTasksPage() {
}
const taskPanel = assignedTasks.length === 0 ? (
<YStack space="$2">
<MobileCard space="$2">
<YStack gap="$2">
<MobileCard gap="$2">
<Text fontSize={13} fontWeight="700" color={text}>
{t('events.tasks.emptyTitle', 'No photo tasks yet')}
</Text>
@@ -602,7 +602,7 @@ export default function MobileEventTasksPage() {
{limitReachedHint ? ` ${limitReachedHint}` : ''}
</Text>
) : null}
<XStack space="$2">
<XStack gap="$2">
<CTAButton
label={t('events.tasks.emptyActionTask', 'Add photo task')}
onPress={() => setShowTaskSheet(true)}
@@ -631,7 +631,7 @@ export default function MobileEventTasksPage() {
setShowTaskSheet(true);
}}
title={
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<YStack
width={28}
height={28}
@@ -669,7 +669,7 @@ export default function MobileEventTasksPage() {
setActiveTab('collections');
}}
title={
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<YStack
width={28}
height={28}
@@ -698,9 +698,9 @@ export default function MobileEventTasksPage() {
</YGroup>
</YStack>
) : (
<YStack space="$2">
<XStack alignItems="baseline" justifyContent="space-between" flexWrap="wrap" space="$2">
<XStack alignItems="baseline" space="$2" flexWrap="wrap">
<YStack gap="$2">
<XStack alignItems="baseline" justifyContent="space-between" flexWrap="wrap" gap="$2">
<XStack alignItems="baseline" gap="$2" flexWrap="wrap">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('events.tasks.assignedTitle', 'Task list')}
</Text>
@@ -718,11 +718,11 @@ export default function MobileEventTasksPage() {
) : null}
</XStack>
{selectionMode ? (
<MobileCard padding="$2.5" space="$2">
<MobileCard padding="$2.5" gap="$2">
<Text fontSize={12} fontWeight="700" color={text}>
{t('events.tasks.selectionCount', '{{count}} ausgewählt', { count: selectedTaskIds.size })}
</Text>
<XStack space="$2">
<XStack gap="$2">
<CTAButton
label={t('events.tasks.bulkRemove', 'Auswahl löschen')}
tone="danger"
@@ -751,7 +751,7 @@ export default function MobileEventTasksPage() {
onPointerLeave={cancelLongPress}
onPointerCancel={cancelLongPress}
title={
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
{selectionMode ? (
<Checkbox
size="$3"
@@ -779,7 +779,7 @@ export default function MobileEventTasksPage() {
}
iconAfter={
selectionMode ? null : (
<XStack space="$2" alignItems="center">
<XStack gap="$2" alignItems="center">
{task.emotion ? (
<Tag label={task.emotion.name ?? ''} color={task.emotion.color ?? text} />
) : null}
@@ -811,7 +811,7 @@ export default function MobileEventTasksPage() {
);
const libraryPanel = (
<YStack space="$2">
<YStack gap="$2">
<XStack alignItems="center" justifyContent="space-between" flexWrap="wrap">
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('events.tasks.tabs.library', 'Task Library')}
@@ -865,7 +865,7 @@ export default function MobileEventTasksPage() {
) : null
}
iconAfter={
<XStack space="$1.5" alignItems="center">
<XStack gap="$1.5" alignItems="center">
<Pressable
onPress={() => {
if (!canAddTasks) {
@@ -875,7 +875,7 @@ export default function MobileEventTasksPage() {
quickAssign(task.id);
}}
>
<XStack alignItems="center" space="$1">
<XStack alignItems="center" gap="$1">
<Plus size={14} color={canAddTasks ? primary : muted} />
<Text fontSize={12} fontWeight="600" color={canAddTasks ? primary : muted}>
{assigningId === task.id ? t('common.processing', '...') : t('events.tasks.add', 'Add')}
@@ -896,7 +896,7 @@ export default function MobileEventTasksPage() {
);
const collectionsPanel = (
<YStack space="$2">
<YStack gap="$2">
<Text fontSize="$xs" color={muted}>
{t('events.tasks.importHint', 'Use predefined packs for your event type.')}
</Text>
@@ -937,7 +937,7 @@ export default function MobileEventTasksPage() {
) : null
}
iconAfter={
<XStack space="$1.5" alignItems="center">
<XStack gap="$1.5" alignItems="center">
<Button
size="$2"
backgroundColor={withAlpha(primary, 0.12)}
@@ -969,7 +969,7 @@ export default function MobileEventTasksPage() {
);
const emotionsPanel = (
<YStack space="$2">
<YStack gap="$2">
<XStack alignItems="center" justifyContent="space-between" flexWrap="wrap">
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('events.tasks.tabs.emotions', 'Emotions')}
@@ -1002,12 +1002,12 @@ export default function MobileEventTasksPage() {
hoverTheme
pressTheme
title={
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<Tag label={emotion.name ?? ''} color={emotion.color ?? border} />
</XStack>
}
iconAfter={
<XStack space="$2">
<XStack gap="$2">
<Pressable
onPress={() => {
setEditingEmotion(emotion);
@@ -1039,7 +1039,7 @@ export default function MobileEventTasksPage() {
title={t('events.tasks.title', 'Photo tasks for guests')}
onBack={back}
headerActions={
<XStack space="$2">
<XStack gap="$2">
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color={text} />
</HeaderActionButton>
@@ -1061,7 +1061,7 @@ export default function MobileEventTasksPage() {
) : null}
{loading ? (
<YStack space="$2">
<YStack gap="$2">
{Array.from({ length: 4 }).map((_, idx) => (
<SkeletonCard key={`tsk-${idx}`} height={70} />
))}
@@ -1074,7 +1074,7 @@ export default function MobileEventTasksPage() {
backgroundColor={surface}
padding="$3"
>
<YStack space="$3">
<YStack gap="$3">
<Card
borderRadius={18}
borderWidth={1}
@@ -1082,8 +1082,8 @@ export default function MobileEventTasksPage() {
backgroundColor={surfaceMuted}
padding="$3"
>
<YStack space="$2">
<XStack alignItems="center" justifyContent="space-between" space="$2">
<YStack gap="$2">
<XStack alignItems="center" justifyContent="space-between" gap="$2">
<Text fontSize="$xs" fontWeight="700" color={text}>
{t('events.tasks.toggle.title', 'Photo tasks for guests')}
</Text>
@@ -1121,7 +1121,7 @@ export default function MobileEventTasksPage() {
<Text fontSize="$xs" fontWeight="700" color={text}>
{t('events.tasks.toggle.switchLabel', 'Photo task mode')}
</Text>
<XStack alignItems="center" space="$1.5">
<XStack alignItems="center" gap="$1.5">
<Switch
size="$3"
checked={tasksEnabled}
@@ -1205,7 +1205,7 @@ export default function MobileEventTasksPage() {
<Tabs.Content value="assigned" paddingTop="$2">
<Card borderRadius={18} borderWidth={1} borderColor={border} backgroundColor={surface} padding="$3">
<YStack space="$2">
<YStack gap="$2">
<Card
borderRadius={16}
borderWidth={1}
@@ -1213,7 +1213,7 @@ export default function MobileEventTasksPage() {
backgroundColor={surfaceMuted}
padding="$2.5"
>
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<XStack flex={1}>
<MobileInput
type="search"
@@ -1226,7 +1226,7 @@ export default function MobileEventTasksPage() {
<Pressable onPress={() => setShowEmotionFilterSheet(true)}>
<XStack
alignItems="center"
space="$1.5"
gap="$1.5"
paddingVertical="$2"
paddingHorizontal="$3"
borderRadius={14}
@@ -1286,7 +1286,7 @@ export default function MobileEventTasksPage() {
/>
}
>
<YStack space="$2">
<YStack gap="$2">
{!canAddTasks ? (
<Text fontSize={12} fontWeight="600" color={dangerText}>
{limitReachedMessage}
@@ -1338,7 +1338,7 @@ export default function MobileEventTasksPage() {
/>
}
>
<YStack space="$2">
<YStack gap="$2">
{!canAddTasks ? (
<Text fontSize={12} fontWeight="600" color={dangerText}>
{limitReachedMessage}
@@ -1381,7 +1381,7 @@ export default function MobileEventTasksPage() {
/>
}
>
<YStack space="$2">
<YStack gap="$2">
<MobileField label={t('events.tasks.emotionName', 'Name')}>
<MobileInput
type="text"
@@ -1416,8 +1416,8 @@ export default function MobileEventTasksPage() {
setShowEmotionFilterSheet(false);
}}
>
<YStack space="$2">
<XStack alignItems="center" space="$2">
<YStack gap="$2">
<XStack alignItems="center" gap="$2">
<RadioGroup.Item value="">
<RadioGroup.Indicator />
</RadioGroup.Item>
@@ -1426,7 +1426,7 @@ export default function MobileEventTasksPage() {
</Text>
</XStack>
{emotions.map((emotion) => (
<XStack key={`emo-filter-${emotion.id}`} alignItems="center" space="$2">
<XStack key={`emo-filter-${emotion.id}`} alignItems="center" gap="$2">
<RadioGroup.Item value={String(emotion.id)}>
<RadioGroup.Indicator />
</RadioGroup.Item>
@@ -1458,7 +1458,7 @@ export default function MobileEventTasksPage() {
maxWidth={420}
width="90%"
>
<YStack space="$3">
<YStack gap="$3">
<AlertDialog.Title>
<Text fontSize="$md" fontWeight="800" color={text}>
{t('events.tasks.removeTitle', 'Remove photo task?')}
@@ -1471,7 +1471,7 @@ export default function MobileEventTasksPage() {
: t('events.tasks.removeBodyFallback', 'This will remove the photo task from the event.')}
</Text>
</AlertDialog.Description>
<XStack space="$2" justifyContent="flex-end">
<XStack gap="$2" justifyContent="flex-end">
<AlertDialog.Cancel asChild>
<CTAButton
label={t('common.cancel', 'Cancel')}
@@ -1513,7 +1513,7 @@ export default function MobileEventTasksPage() {
maxWidth={420}
width="90%"
>
<YStack space="$3">
<YStack gap="$3">
<AlertDialog.Title>
<Text fontSize="$md" fontWeight="800" color={text}>
{t('events.tasks.bulkRemoveTitle', 'Auswahl löschen')}
@@ -1524,7 +1524,7 @@ export default function MobileEventTasksPage() {
{t('events.tasks.bulkRemoveBody', 'This will remove the selected photo tasks from the event.')}
</Text>
</AlertDialog.Description>
<XStack space="$2" justifyContent="flex-end">
<XStack gap="$2" justifyContent="flex-end">
<AlertDialog.Cancel asChild>
<CTAButton
label={t('common.cancel', 'Cancel')}

View File

@@ -136,7 +136,7 @@ export default function MobileEventsPage() {
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
>
<YStack space="$2.5">
<YStack gap="$2.5">
<XStack
alignItems="center"
paddingHorizontal="$3"
@@ -165,7 +165,7 @@ export default function MobileEventsPage() {
</Card>
{loading ? (
<YStack space="$2">
<YStack gap="$2">
{Array.from({ length: 3 }).map((_, idx) => (
<SkeletonCard key={`sk-${idx}`} height={90} />
))}
@@ -182,7 +182,7 @@ export default function MobileEventsPage() {
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
>
<YStack space="$2" alignItems="center">
<YStack gap="$2" alignItems="center">
<Text fontSize="$md" fontWeight="700">
{t('events.list.title')}
</Text>
@@ -263,7 +263,7 @@ function EventsList({
];
return (
<YStack space="$3">
<YStack gap="$3">
{filteredEvents.length === 0 ? (
<Card
borderRadius={22}
@@ -276,7 +276,7 @@ function EventsList({
shadowRadius={14}
shadowOffset={{ width: 0, height: 8 }}
>
<YStack space="$2" alignItems="center">
<YStack gap="$2" alignItems="center">
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('events.list.empty.filtered')}
</Text>
@@ -303,7 +303,7 @@ function EventsList({
shadowRadius={14}
shadowOffset={{ width: 0, height: 8 }}
>
<YStack space="$2.5">
<YStack gap="$2.5">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$xs" fontWeight="800" color={text}>
{t('events.workspace.fields.status')}
@@ -326,7 +326,7 @@ function EventsList({
value={statusFilter}
onValueChange={(value: string) => value && onStatusChange(value as EventStatusKey)}
>
<XStack space="$1.5">
<XStack gap="$1.5">
{filters.map((filter) => {
const active = filter.key === statusFilter;
return (
@@ -340,7 +340,7 @@ function EventsList({
paddingVertical="$1.5"
paddingHorizontal="$3"
>
<XStack alignItems="center" space="$1.5">
<XStack alignItems="center" gap="$1.5">
<Text fontSize="$xs" fontWeight="700" color={active ? primary : muted}>
{filter.label}
</Text>
@@ -407,7 +407,7 @@ function EventsList({
/>
}
iconAfter={
<XStack alignItems="center" space="$1.5">
<XStack alignItems="center" gap="$1.5">
<Text fontSize="$xs" color={primary} fontWeight="700">
{t('events.list.actions.open')}
</Text>
@@ -448,12 +448,12 @@ function EventListItem({
const locale = i18n.language;
const stats = buildEventListStats(event);
return (
<YStack space="$1.5">
<XStack alignItems="center" justifyContent="space-between" space="$2">
<YStack gap="$1.5">
<XStack alignItems="center" justifyContent="space-between" gap="$2">
<Text fontSize="$md" fontWeight="800" color={text}>
{renderName(event.name, t)}
</Text>
<XStack alignItems="center" space="$1.5">
<XStack alignItems="center" gap="$1.5">
<PillBadge tone={statusTone}>{statusLabel}</PillBadge>
{onEdit ? (
<Pressable onPress={() => onEdit(event.slug)}>
@@ -464,21 +464,21 @@ function EventListItem({
) : null}
</XStack>
</XStack>
<XStack alignItems="center" space="$2" flexWrap="wrap">
<XStack alignItems="center" space="$1.5">
<XStack alignItems="center" gap="$2" flexWrap="wrap">
<XStack alignItems="center" gap="$1.5">
<CalendarDays size={12} color={subtle} />
<Text fontSize="$xs" color={muted}>
{formatDate(event.event_date, t, locale)}
</Text>
</XStack>
<XStack alignItems="center" space="$1.5">
<XStack alignItems="center" gap="$1.5">
<MapPin size={12} color={subtle} />
<Text fontSize="$xs" color={muted}>
{resolveLocation(event, t)}
</Text>
</XStack>
</XStack>
<XStack alignItems="center" space="$2" flexWrap="wrap">
<XStack alignItems="center" gap="$2" flexWrap="wrap">
<EventStatChip icon={Camera} label={t('events.list.stats.photos')} value={stats.photos} muted={subtle} />
<EventStatChip icon={Users} label={t('events.list.stats.guests')} value={stats.guests} muted={subtle} />
<EventStatChip icon={Sparkles} label={t('events.list.stats.tasks')} value={stats.tasks} muted={subtle} />
@@ -499,7 +499,7 @@ function EventStatChip({
muted: string;
}) {
return (
<XStack alignItems="center" space="$1">
<XStack alignItems="center" gap="$1">
<Icon size={12} color={muted} />
<Text fontSize="$xs" color={muted}>
{value} {label}

View File

@@ -76,10 +76,10 @@ export default function ForgotPasswordPage() {
paddingVertical="$5"
style={{ ...safeAreaStyle, background: ADMIN_GRADIENTS.loginBackground }}
>
<YStack width="100%" maxWidth={520} space="$4">
<YStack width="100%" maxWidth={520} gap="$4">
<MobileCard backgroundColor="rgba(15, 23, 42, 0.6)" borderColor="rgba(255,255,255,0.12)">
<YStack space="$2">
<XStack alignItems="center" space="$2">
<YStack gap="$2">
<XStack alignItems="center" gap="$2">
<XStack
width={42}
height={42}
@@ -103,7 +103,7 @@ export default function ForgotPasswordPage() {
</MobileCard>
<MobileCard backgroundColor={surface} borderColor={border}>
<YStack space="$3">
<YStack gap="$3">
<MobileField label={t('login.email', 'Email address')}>
<MobileInput
type="email"

View File

@@ -34,7 +34,7 @@ export default function MobileHelpArticlePage() {
return (
<MobileShell activeTab="profile" title={article?.title ?? t('common.help', 'Help')} onBack={back}>
{isLoading ? (
<YStack space="$2">
<YStack gap="$2">
<SkeletonCard height={120} />
<SkeletonCard height={160} />
</YStack>
@@ -42,7 +42,7 @@ export default function MobileHelpArticlePage() {
{isError ? (
<MobileCard>
<YStack space="$2">
<YStack gap="$2">
<Text fontSize="$sm" fontWeight="700" color={theme.textStrong}>
{t('dashboard:help.error', 'Help could not be loaded.')}
</Text>
@@ -56,9 +56,9 @@ export default function MobileHelpArticlePage() {
) : null}
{!isLoading && article ? (
<YStack space="$3">
<YStack gap="$3">
<MobileCard>
<YStack space="$2">
<YStack gap="$2">
<Text fontSize="$lg" fontWeight="800" color={theme.textStrong}>
{article.title}
</Text>
@@ -81,7 +81,7 @@ export default function MobileHelpArticlePage() {
{article.related && article.related.length > 0 ? (
<MobileCard>
<YStack space="$2">
<YStack gap="$2">
<Text fontSize="$sm" fontWeight="700" color={theme.textStrong}>
{t('help.article.relatedTitle', 'Weitere Artikel')}
</Text>

View File

@@ -41,7 +41,7 @@ export default function MobileHelpCenterPage() {
return (
<MobileShell activeTab="profile" title={t('common.help', 'Help')} onBack={back}>
{isLoading ? (
<YStack space="$2">
<YStack gap="$2">
<SkeletonCard height={120} />
<SkeletonCard height={120} />
</YStack>
@@ -49,7 +49,7 @@ export default function MobileHelpCenterPage() {
{isError ? (
<MobileCard>
<YStack space="$2">
<YStack gap="$2">
<Text fontSize="$sm" fontWeight="700" color={theme.textStrong}>
{t('dashboard:help.error', 'Help could not be loaded.')}
</Text>
@@ -63,7 +63,7 @@ export default function MobileHelpCenterPage() {
) : null}
{!isLoading && !isError ? (
<YStack space="$3">
<YStack gap="$3">
<HelpSection
title={t('dashboard:help.title', 'FAQ')}
icon={HelpCircle}
@@ -100,8 +100,8 @@ function HelpSection({
return (
<MobileCard padding="$0">
<YStack padding="$3" space="$2">
<XStack alignItems="center" space="$2">
<YStack padding="$3" gap="$2">
<XStack alignItems="center" gap="$2">
{IconCmp ? (
<XStack
width={28}

View File

@@ -202,9 +202,9 @@ export default function MobileLoginPage() {
paddingVertical="$5"
style={{ ...safeAreaStyle, background: ADMIN_GRADIENTS.loginBackground }}
>
<YStack width="100%" maxWidth={520} space="$4">
<YStack width="100%" maxWidth={520} gap="$4">
<MobileCard backgroundColor="rgba(15, 23, 42, 0.6)" borderColor="rgba(255,255,255,0.12)">
<YStack alignItems="center" space="$3">
<YStack alignItems="center" gap="$3">
<XStack
width={56}
height={56}
@@ -217,7 +217,7 @@ export default function MobileLoginPage() {
>
<img src="/logo-transparent-md.png" alt={t('auth.logoAlt', 'Fotospiel')} width={40} height={40} />
</XStack>
<YStack alignItems="center" space="$1">
<YStack alignItems="center" gap="$1">
<Text fontSize="$xl" fontWeight="800" color="white" textAlign="center">
{t('login.panel_title', 'Fotospiel.App Event Login')}
</Text>
@@ -230,7 +230,7 @@ export default function MobileLoginPage() {
<MobileCard backgroundColor={surface} borderColor={border}>
<form onSubmit={handleSubmit}>
<YStack space="$3">
<YStack gap="$3">
{oauthMessage ? (
<YStack
borderRadius={12}
@@ -313,7 +313,7 @@ export default function MobileLoginPage() {
pressStyle={{ opacity: 0.9 }}
style={{ boxShadow: '0 12px 24px rgba(255, 90, 95, 0.25)' }}
>
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<Loader2 size={16} className={isSubmitting ? 'animate-spin' : ''} />
<Text fontSize="$sm" color="white" fontWeight="800">
{isSubmitting ? t('login.loading', 'Anmeldung …') : t('login.submit', 'Anmelden')}
@@ -332,7 +332,7 @@ export default function MobileLoginPage() {
borderWidth={1}
color={text}
>
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
{isRedirectingToGoogle ? (
<Loader2 size={16} className="animate-spin" />
) : (
@@ -355,7 +355,7 @@ export default function MobileLoginPage() {
borderWidth={1}
color={text}
>
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
{isRedirectingToFacebook ? (
<Loader2 size={16} className="animate-spin" />
) : (
@@ -380,7 +380,7 @@ export default function MobileLoginPage() {
}}
/>
<YStack alignItems="center" space="$2">
<YStack alignItems="center" gap="$2">
<Text fontSize="$xs" color={muted} textAlign="center">
{t('login.support', 'Fragen? Schreib uns an support@fotospiel.de oder antworte direkt auf deine Einladung.')}
</Text>

View File

@@ -52,7 +52,7 @@ export default function LoginStartPage(): React.ReactElement {
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
>
<YStack alignItems="center" space="$2">
<YStack alignItems="center" gap="$2">
<Spinner size="small" color={textStrong} />
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('redirecting', 'Redirecting to login …')}

View File

@@ -43,7 +43,7 @@ export default function LogoutPage() {
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
>
<YStack alignItems="center" space="$2">
<YStack alignItems="center" gap="$2">
<Spinner size="small" color={textStrong} />
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
Abmeldung wird vorbereitet ...

View File

@@ -86,13 +86,13 @@ function NotificationSwipeRow({ item, onOpen, onMarkRead, children }: Notificati
pointerEvents="none"
style={{ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0 }}
>
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<Check size={16} color={markText} />
<Text fontSize="$xs" fontWeight="700" color={markText}>
{item.is_read ? t('notificationLogs.read', 'Read') : t('notificationLogs.markRead', 'Mark read')}
</Text>
</XStack>
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<Text fontSize="$xs" fontWeight="700" color={detailText}>
Details
</Text>
@@ -508,7 +508,7 @@ export default function MobileNotificationsPage() {
) : null}
{showFilterNotice ? (
<MobileCard space="$2">
<MobileCard gap="$2">
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('notificationLogs.filterEmpty', 'No notifications for this event.')}
</Text>
@@ -524,7 +524,7 @@ export default function MobileNotificationsPage() {
</MobileCard>
) : null}
<XStack space="$2" marginBottom="$2">
<XStack gap="$2" marginBottom="$2">
<MobileSelect
value={statusParam}
onChange={(e) => updateFilters({ status: e.target.value })}
@@ -552,7 +552,7 @@ export default function MobileNotificationsPage() {
) : null}
</XStack>
<XStack space="$2" flexWrap="wrap" marginBottom="$2">
<XStack gap="$2" flexWrap="wrap" marginBottom="$2">
{([
{ key: 'all', label: t('notificationLogs.scope.all', 'All scopes') },
{ key: 'photos', label: t('notificationLogs.scope.photos', 'Photos') },
@@ -585,13 +585,13 @@ export default function MobileNotificationsPage() {
</XStack>
{loading ? (
<YStack space="$2">
<YStack gap="$2">
{Array.from({ length: 4 }).map((_, idx) => (
<SkeletonCard key={`al-${idx}`} height={70} />
))}
</YStack>
) : statusFiltered.length === 0 ? (
<MobileCard alignItems="center" justifyContent="center" space="$2">
<MobileCard alignItems="center" justifyContent="center" gap="$2">
<Bell size={24} color={subtle} />
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('mobileNotifications.emptyTitle', 'All caught up')}
@@ -607,7 +607,7 @@ export default function MobileNotificationsPage() {
/>
</MobileCard>
) : (
<YStack space="$2">
<YStack gap="$2">
{events.length ? (
<Pressable onPress={() => setShowEventPicker(true)}>
<Text fontSize="$sm" color={primary} fontWeight="700">
@@ -616,12 +616,12 @@ export default function MobileNotificationsPage() {
</Pressable>
) : null}
{grouped.map((group) => (
<YStack key={group.scope} space="$2">
<YStack key={group.scope} gap="$2">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$xs" fontWeight="700" color={muted}>
{t(`notificationLogs.scope.${group.scope}`, group.scope)}
</Text>
<XStack space="$2" alignItems="center">
<XStack gap="$2" alignItems="center">
{group.unread > 0 ? (
<Pressable onPress={() => markGroupRead(group)}>
<Text fontSize="$xs" color={primary} fontWeight="700">
@@ -646,8 +646,8 @@ export default function MobileNotificationsPage() {
onOpen={openNotification}
onMarkRead={markNotificationRead}
>
<MobileCard space="$2" borderColor={item.is_read ? border : primary}>
<XStack alignItems="center" space="$2">
<MobileCard gap="$2" borderColor={item.is_read ? border : primary}>
<XStack alignItems="center" gap="$2">
<XStack
width={36}
height={36}
@@ -658,7 +658,7 @@ export default function MobileNotificationsPage() {
>
<Bell size={18} color={item.tone === 'warning' ? warningIcon : infoIcon} />
</XStack>
<YStack space="$0.5" flex={1}>
<YStack gap="$0.5" flex={1}>
<Text fontSize="$sm" fontWeight="700" color={text}>
{item.title}
</Text>
@@ -699,14 +699,14 @@ export default function MobileNotificationsPage() {
}
>
{selectedNotification ? (
<YStack space="$2">
<YStack gap="$2">
<Text fontSize="$sm" color={text} fontWeight="700">
{selectedNotification.title}
</Text>
<Text fontSize="$sm" color={muted}>
{selectedNotification.body}
</Text>
<XStack space="$2" flexWrap="wrap" style={{ rowGap: 8 }}>
<XStack gap="$2" flexWrap="wrap" style={{ rowGap: 8 }}>
<PillBadge tone={selectedNotification.tone === 'warning' ? 'warning' : 'muted'}>
{selectedNotification.scope}
</PillBadge>
@@ -725,7 +725,7 @@ export default function MobileNotificationsPage() {
title={t('mobileNotifications.filterByEvent', 'Nach Event filtern')}
footer={null}
>
<YStack space="$2">
<YStack gap="$2">
{events.length === 0 ? (
<Text fontSize="$sm" color={muted}>
{t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}

View File

@@ -65,7 +65,7 @@ export default function MobilePackageShopPage() {
onBack={() => navigate(-1)}
activeTab="profile"
>
<YStack space="$3">
<YStack gap="$3">
<SkeletonCard height={150} />
<SkeletonCard height={150} />
</YStack>
@@ -125,10 +125,10 @@ export default function MobilePackageShopPage() {
onBack={() => navigate(-1)}
activeTab="profile"
>
<YStack space="$4">
<YStack gap="$4">
{catalogType !== 'reseller' && recommendedFeature && (
<MobileCard borderColor={primary} backgroundColor={accentSoft} space="$2" padding="$3">
<XStack space="$2" alignItems="center">
<MobileCard borderColor={primary} backgroundColor={accentSoft} gap="$2" padding="$3">
<XStack gap="$2" alignItems="center">
<Sparkles size={16} color={primary} />
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('shop.recommendationTitle', 'Recommended for you')}
@@ -149,7 +149,7 @@ export default function MobilePackageShopPage() {
</YStack>
{packageEntries.length > 1 ? (
<XStack space="$2" paddingHorizontal="$2">
<XStack gap="$2" paddingHorizontal="$2">
<CTAButton
label={t('shop.compare.toggleCards', 'Cards')}
tone={viewMode === 'cards' ? 'primary' : 'ghost'}
@@ -167,7 +167,7 @@ export default function MobilePackageShopPage() {
</XStack>
) : null}
<YStack space="$3">
<YStack gap="$3">
{viewMode === 'compare' ? (
<PackageShopCompareView
entries={packageEntries}
@@ -234,14 +234,14 @@ function PackageShopCard({
onPress={handlePress}
borderColor={isRecommended ? primary : (isActive ? '$green8' : border)}
borderWidth={isRecommended || isActive ? 2 : 1}
space="$3"
gap="$3"
pressStyle={handlePress ? { backgroundColor: accentSoft } : undefined}
backgroundColor={isActive ? '$green1' : undefined}
style={{ opacity: isSubdued ? 0.6 : 1 }}
>
<XStack justifyContent="space-between" alignItems="flex-start">
<YStack space="$1">
<XStack space="$2" alignItems="center">
<YStack gap="$1">
<XStack gap="$2" alignItems="center">
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
{pkg.name}
</Text>
@@ -255,7 +255,7 @@ function PackageShopCard({
{!isResellerCatalog && isActive ? <PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge> : null}
</XStack>
<XStack space="$2" alignItems="center">
<XStack gap="$2" alignItems="center">
<Text fontSize="$md" color={primary} fontWeight="700">
{new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(pkg.price)}
</Text>
@@ -271,7 +271,7 @@ function PackageShopCard({
</YStack>
</XStack>
<YStack space="$1.5">
<YStack gap="$1.5">
{isResellerCatalog ? (
<>
{includedTierLabel ? (
@@ -333,7 +333,7 @@ function PackageShopCard({
function FeatureRow({ label }: { label: string }) {
const { textStrong, primary } = useAdminTheme();
return (
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<Check size={14} color={primary} />
<Text fontSize="$sm" color={textStrong}>{label}</Text>
</XStack>
@@ -411,8 +411,8 @@ function PackageShopCompareView({
};
return (
<MobileCard space="$3" borderColor={border}>
<YStack space="$1">
<MobileCard gap="$3" borderColor={border}>
<YStack gap="$1">
<Text fontSize="$md" fontWeight="700" color={textStrong}>
{t('shop.compare.title', 'Compare plans')}
</Text>
@@ -422,7 +422,7 @@ function PackageShopCompareView({
</YStack>
<XStack style={{ overflowX: 'auto' }}>
<YStack space="$1.5" minWidth={labelWidth + columnWidth * entries.length}>
<YStack gap="$1.5" minWidth={labelWidth + columnWidth * entries.length}>
{rows.map((row) => (
<XStack key={row.id} borderBottomWidth={1} borderColor={border}>
<YStack
@@ -443,11 +443,11 @@ function PackageShopCompareView({
if (row.id === 'meta.plan') {
const statusLabel = getPackageStatusLabel({ t, isActive: entry.isActive, owned: entry.owned });
content = (
<YStack space="$1">
<YStack gap="$1">
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
{entry.pkg.name}
</Text>
<XStack space="$1.5" flexWrap="wrap">
<XStack gap="$1.5" flexWrap="wrap">
{entry.isRecommended ? (
<PillBadge tone="warning">{t('shop.badges.recommended', 'Recommended')}</PillBadge>
) : null}
@@ -492,7 +492,7 @@ function PackageShopCompareView({
} else if (row.type === 'feature') {
const enabled = getEnabledPackageFeatures(entry.pkg).includes(row.featureKey);
content = (
<XStack alignItems="center" space="$1.5">
<XStack alignItems="center" gap="$1.5">
{enabled ? (
<Check size={16} color={primary} />
) : (
@@ -607,8 +607,8 @@ function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () =>
return (
<MobileShell title={t('shop.confirmTitle', 'Confirm Purchase')} onBack={onCancel} activeTab="profile">
<YStack space="$4">
<MobileCard space="$2" borderColor={border}>
<YStack gap="$4">
<MobileCard gap="$2" borderColor={border}>
<Text fontSize="$sm" color={muted}>{subtitle}</Text>
<Text fontSize="$xl" fontWeight="800" color={textStrong}>{pkg.name}</Text>
<Text fontSize="$lg" color={primary} fontWeight="700">
@@ -616,13 +616,13 @@ function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () =>
</Text>
</MobileCard>
<MobileCard space="$3" borderColor={border}>
<XStack alignItems="center" space="$2">
<MobileCard gap="$3" borderColor={border}>
<XStack alignItems="center" gap="$2">
<ShieldCheck size={18} color={textStrong} />
<Text fontSize="$md" fontWeight="700" color={textStrong}>{t('shop.legalTitle', 'Terms & Conditions')}</Text>
</XStack>
<XStack space="$3" alignItems="flex-start">
<XStack gap="$3" alignItems="flex-start">
<Checkbox
id="agb"
size="$4"
@@ -638,7 +638,7 @@ function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () =>
</Text>
</XStack>
<XStack space="$3" alignItems="flex-start">
<XStack gap="$3" alignItems="flex-start">
<Checkbox
id="withdrawal"
size="$4"
@@ -655,7 +655,7 @@ function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () =>
</XStack>
</MobileCard>
<YStack space="$2">
<YStack gap="$2">
<CTAButton
label={busy ? t('shop.processing', 'Processing...') : t('shop.payNow', 'Pay Now')}
onPress={handleCheckout}

View File

@@ -306,7 +306,7 @@ export default function MobileProfileAccountPage() {
onBack={back}
>
{brandingTabEnabled ? (
<XStack space="$2">
<XStack gap="$2">
<TabButton
label={t('profile.tabs.account', 'Account')}
active={activeTab === 'account'}
@@ -330,7 +330,7 @@ export default function MobileProfileAccountPage() {
</MobileCard>
) : null}
<MobileCard space="$3">
<MobileCard gap="$3">
<Text fontSize="$md" fontWeight="800" color={text}>
{t('profile.branding.title', 'Standard-Branding')}
</Text>
@@ -339,11 +339,11 @@ export default function MobileProfileAccountPage() {
</Text>
</MobileCard>
<MobileCard space="$3">
<MobileCard gap="$3">
<Text fontSize="$md" fontWeight="800" color={text}>
{t('profile.branding.theme', 'Theme')}
</Text>
<YStack space="$3">
<YStack gap="$3">
<MobileField label={t('events.branding.mode', 'Theme')}>
<MobileSelect
value={brandingForm.mode}
@@ -369,11 +369,11 @@ export default function MobileProfileAccountPage() {
</YStack>
</MobileCard>
<MobileCard space="$3">
<MobileCard gap="$3">
<Text fontSize="$md" fontWeight="800" color={text}>
{t('events.branding.colors', 'Colors')}
</Text>
<YStack space="$3">
<YStack gap="$3">
<ColorField
label={t('events.branding.primary', 'Primary Color')}
value={brandingForm.primary}
@@ -401,11 +401,11 @@ export default function MobileProfileAccountPage() {
</YStack>
</MobileCard>
<MobileCard space="$3">
<MobileCard gap="$3">
<Text fontSize="$md" fontWeight="800" color={text}>
{t('events.branding.fonts', 'Fonts')}
</Text>
<YStack space="$3">
<YStack gap="$3">
<MobileField label={t('events.branding.headingFont', 'Headline Font')}>
<MobileInput
value={brandingForm.headingFont}
@@ -444,8 +444,8 @@ export default function MobileProfileAccountPage() {
</MobileCard>
) : null}
<MobileCard space="$3">
<XStack alignItems="center" space="$3">
<MobileCard gap="$3">
<XStack alignItems="center" gap="$3">
<XStack
width={48}
height={48}
@@ -456,7 +456,7 @@ export default function MobileProfileAccountPage() {
>
<User size={20} color={primary} />
</XStack>
<YStack space="$1">
<YStack gap="$1">
<Text fontSize="$md" fontWeight="800" color={text}>
{form.name || profile?.email || t('profile.title', 'Profil')}
</Text>
@@ -465,7 +465,7 @@ export default function MobileProfileAccountPage() {
</Text>
</YStack>
</XStack>
<XStack alignItems="center" space="$2" flexWrap="wrap">
<XStack alignItems="center" gap="$2" flexWrap="wrap">
{profile?.email_verified ? (
<CheckCircle2 size={14} color={subtle} />
) : (
@@ -480,8 +480,8 @@ export default function MobileProfileAccountPage() {
</XStack>
</MobileCard>
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<MobileCard gap="$3">
<XStack alignItems="center" gap="$2">
<User size={16} color={text} />
<Text fontSize="$md" fontWeight="800" color={text}>
{t('profile.sections.account.heading', 'Account-Informationen')}
@@ -495,7 +495,7 @@ export default function MobileProfileAccountPage() {
{t('profile.loading', 'Lädt ...')}
</Text>
) : (
<YStack space="$3">
<YStack gap="$3">
<MobileField label={t('profile.fields.name', 'Anzeigename')}>
<MobileInput
value={form.name}
@@ -535,8 +535,8 @@ export default function MobileProfileAccountPage() {
)}
</MobileCard>
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<MobileCard gap="$3">
<XStack alignItems="center" gap="$2">
<Lock size={16} color={text} />
<Text fontSize="$md" fontWeight="800" color={text}>
{t('profile.sections.password.heading', 'Passwort ändern')}
@@ -545,7 +545,7 @@ export default function MobileProfileAccountPage() {
<Text fontSize="$sm" color={muted}>
{t('profile.sections.password.description', 'Wähle ein sicheres Passwort, um deinen Admin-Zugang zu schützen.')}
</Text>
<YStack space="$3">
<YStack gap="$3">
<MobileField label={t('profile.fields.currentPassword', 'Aktuelles Passwort')}>
<MobileInput
value={form.currentPassword}
@@ -625,11 +625,11 @@ function ColorField({
}) {
const { text, muted } = useAdminTheme();
return (
<YStack space="$2" opacity={disabled ? 0.6 : 1}>
<YStack gap="$2" opacity={disabled ? 0.6 : 1}>
<Text fontSize="$sm" fontWeight="700" color={text}>
{label}
</Text>
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<MobileColorInput
value={value}
onChange={(event) => onChange(event.target.value)}

View File

@@ -68,13 +68,13 @@ export default function MobileProfilePage() {
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
>
<YStack space="$2.5" alignItems="center">
<YStack gap="$2.5" alignItems="center">
<Avatar size="$7" borderRadius={20} backgroundColor={avatarBg}>
<Avatar.Fallback>
<User size={28} color={primary} />
</Avatar.Fallback>
</Avatar>
<YStack space="$0.5" alignItems="center">
<YStack gap="$0.5" alignItems="center">
<Text fontSize="$md" fontWeight="800" color={textColor}>
{name}
</Text>
@@ -101,7 +101,7 @@ export default function MobileProfilePage() {
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
>
<YStack space="$2.5">
<YStack gap="$2.5">
<XStack
alignItems="center"
paddingHorizontal="$3"
@@ -210,7 +210,7 @@ export default function MobileProfilePage() {
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
>
<YStack space="$2.5">
<YStack gap="$2.5">
<XStack
alignItems="center"
paddingHorizontal="$3"
@@ -230,7 +230,7 @@ export default function MobileProfilePage() {
paddingVertical="$2"
paddingHorizontal="$3"
title={
<XStack space="$2" alignItems="center">
<XStack gap="$2" alignItems="center">
<Globe size={16} color={muted} />
<Text fontSize="$sm" color={textColor}>
{t('mobileProfile.language', 'Language')}
@@ -259,7 +259,7 @@ export default function MobileProfilePage() {
paddingVertical="$2"
paddingHorizontal="$3"
title={
<XStack space="$2" alignItems="center">
<XStack gap="$2" alignItems="center">
<Moon size={16} color={muted} />
<Text fontSize="$sm" color={textColor}>
{t('mobileProfile.theme', 'Theme')}
@@ -295,8 +295,8 @@ export default function MobileProfilePage() {
shadowRadius={12}
shadowOffset={{ width: 0, height: 6 }}
>
<YStack space="$2">
<XStack alignItems="center" space="$2">
<YStack gap="$2">
<XStack alignItems="center" gap="$2">
<XStack
width={36}
height={36}

View File

@@ -54,10 +54,10 @@ export default function PublicHelpPage() {
paddingVertical="$4"
style={{ ...safeAreaStyle, background: ADMIN_GRADIENTS.loginBackground }}
>
<YStack width="100%" maxWidth={680} alignSelf="center" space="$4">
<YStack width="100%" maxWidth={680} alignSelf="center" gap="$4">
<MobileCard backgroundColor="rgba(15, 23, 42, 0.55)" borderColor="rgba(255,255,255,0.08)">
<YStack space="$3">
<XStack alignItems="center" space="$2">
<YStack gap="$3">
<XStack alignItems="center" gap="$2">
<XStack
width={42}
height={42}
@@ -82,8 +82,8 @@ export default function PublicHelpPage() {
</MobileCard>
<MobileCard backgroundColor={surface} borderColor={border}>
<YStack space="$3">
<XStack alignItems="center" space="$2">
<YStack gap="$3">
<XStack alignItems="center" gap="$2">
<XStack
width={36}
height={36}
@@ -98,7 +98,7 @@ export default function PublicHelpPage() {
{t('login.help_faq_title', 'Haeufige Fragen vor dem Login')}
</Text>
</XStack>
<YStack space="$2">
<YStack gap="$2">
{faqItems.map((item, index) => (
<YStack
key={`${item.question}-${index}`}
@@ -107,7 +107,7 @@ export default function PublicHelpPage() {
borderWidth={1}
borderColor={border}
backgroundColor="rgba(255,255,255,0.6)"
space="$1"
gap="$1"
>
<Text fontSize="$sm" fontWeight="700" color={text}>
{item.question}
@@ -122,8 +122,8 @@ export default function PublicHelpPage() {
</MobileCard>
<MobileCard backgroundColor={surface} borderColor={border}>
<YStack space="$3">
<XStack alignItems="center" space="$2">
<YStack gap="$3">
<XStack alignItems="center" gap="$2">
<XStack
width={36}
height={36}

View File

@@ -262,7 +262,7 @@ export default function MobileQrLayoutCustomizePage() {
<Stepper current={step} onStepChange={setStep} />
<MobileCard space="$2" marginTop="$2">
<MobileCard gap="$2" marginTop="$2">
{step === 'background' && (
<BackgroundStep
onBack={back}
@@ -330,8 +330,8 @@ function Stepper({ current, onStepChange }: { current: Step; onStepChange: (step
const progress = ((currentIndex + 1) / steps.length) * 100;
return (
<YStack space="$2" marginTop="$2">
<XStack space="$2" alignItems="center">
<YStack gap="$2" marginTop="$2">
<XStack gap="$2" alignItems="center">
{steps.map((step, idx) => {
const active = step.key === current;
const completed = idx < currentIndex;
@@ -800,10 +800,10 @@ function BackgroundStep({
];
return (
<YStack space="$3" marginTop="$2">
<XStack alignItems="center" space="$2">
<YStack gap="$3" marginTop="$2">
<XStack alignItems="center" gap="$2">
<Pressable onPress={onBack}>
<XStack alignItems="center" space="$1">
<XStack alignItems="center" gap="$1">
<ArrowLeft size={16} color={textStrong} />
<Text fontSize="$sm" color={textStrong}>
{t('common.back', 'Zurück')}
@@ -813,7 +813,7 @@ function BackgroundStep({
<PillBadge tone="muted">{formatLabel}</PillBadge>
</XStack>
<YStack space="$2">
<YStack gap="$2">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{disablePresets
? t('events.qr.backgroundPickerFoldable', 'Hintergrund für A5 (Gradient/Farbe)')
@@ -859,7 +859,7 @@ function BackgroundStep({
</>
) : null}
<YStack space="$2" marginTop="$2">
<YStack gap="$2" marginTop="$2">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.gradients', 'Gradienten')}
</Text>
@@ -894,7 +894,7 @@ function BackgroundStep({
</XStack>
</YStack>
<YStack space="$2" marginTop="$2">
<YStack gap="$2" marginTop="$2">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.colors', 'Vollfarbe')}
</Text>
@@ -966,10 +966,10 @@ function TextStep({
};
return (
<YStack space="$3" marginTop="$2">
<XStack alignItems="center" space="$2">
<YStack gap="$3" marginTop="$2">
<XStack alignItems="center" gap="$2">
<Pressable onPress={onBack}>
<XStack alignItems="center" space="$1">
<XStack alignItems="center" gap="$1">
<ArrowLeft size={16} color={textStrong} />
<Text fontSize="$sm" color={textStrong}>
{t('common.back', 'Zurück')}
@@ -979,7 +979,7 @@ function TextStep({
<PillBadge tone="muted">{t('events.qr.textStep', 'Texte & Hinweise')}</PillBadge>
</XStack>
<YStack space="$2">
<YStack gap="$2">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.textFields', 'Texte')}
</Text>
@@ -1000,12 +1000,12 @@ function TextStep({
/>
</YStack>
<YStack space="$2">
<YStack gap="$2">
<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">
<XStack key={idx} alignItems="center" gap="$2">
<MobileTextArea
value={item}
onChange={(event) => updateInstruction(idx, event.target.value)}
@@ -1196,10 +1196,10 @@ function PreviewStep({
const qrImageSrc = qrPngUrl ?? qrUrl ?? '';
return (
<YStack space="$3" marginTop="$2">
<XStack alignItems="center" space="$2">
<YStack gap="$3" marginTop="$2">
<XStack alignItems="center" gap="$2">
<Pressable onPress={onBack}>
<XStack alignItems="center" space="$1">
<XStack alignItems="center" gap="$1">
<ArrowLeft size={16} color={textStrong} />
<Text fontSize="$sm" color={textStrong}>
{t('common.back', 'Zurück')}
@@ -1211,7 +1211,7 @@ function PreviewStep({
</PillBadge>
</XStack>
<YStack space="$2">
<YStack gap="$2">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.preview', 'Vorschau')}
</Text>
@@ -1243,7 +1243,7 @@ function PreviewStep({
<LayoutControls slots={resolvedSlots} slotOverrides={slotOverrides} onUpdateSlot={onUpdateSlot} tenantFonts={tenantFonts} qrUrl={qrImageSrc} />
<XStack space="$2" width="100%" flexWrap="wrap">
<XStack gap="$2" width="100%" flexWrap="wrap">
<CTAButton
label={t('events.qr.exportPdf', 'Export PDF')}
onPress={async () => {
@@ -1318,7 +1318,7 @@ function LayoutControls({
const decimals = step % 1 === 0 ? 0 : Math.min(4, `${step}`.split('.')[1]?.length ?? 1);
const formatValue = (val: number) => val.toFixed(decimals);
return (
<XStack space="$1" alignItems="center">
<XStack gap="$1" alignItems="center">
<Pressable onPress={dec}>
<XStack width={36} height={36} borderRadius={10} alignItems="center" justifyContent="center" borderWidth={1} borderColor={border} backgroundColor={surface}>
<Text fontSize="$md" fontWeight="800" color={textStrong}>
@@ -1398,13 +1398,13 @@ function LayoutControls({
</XStack>
</Accordion.Trigger>
<Accordion.Content {...({ paddingTop: "$2" } as any)}>
<YStack space="$2" padding="$2" borderWidth={1} borderColor={border} borderRadius={12}>
<XStack space="$3">
<YStack flex={1} space="$1">
<YStack gap="$2" padding="$2" borderWidth={1} borderColor={border} borderRadius={12}>
<XStack gap="$3">
<YStack flex={1} gap="$1">
<Text fontSize="$xs" color={muted}>
{t('events.qr.positionX', 'X (%)')}
</Text>
<XStack space="$2" alignItems="center">
<XStack gap="$2" alignItems="center">
<StepperInput
value={currentX * 100}
min={0}
@@ -1428,7 +1428,7 @@ function LayoutControls({
</Pressable>
</XStack>
</YStack>
<YStack flex={1} space="$1">
<YStack flex={1} gap="$1">
<Text fontSize="$xs" color={muted}>
{t('events.qr.positionY', 'Y (%)')}
</Text>
@@ -1440,7 +1440,7 @@ function LayoutControls({
onChange={(val) => onPercentChange('y')(val)}
/>
</YStack>
<YStack flex={1} space="$1">
<YStack flex={1} gap="$1">
<Text fontSize="$xs" color={muted}>
{t('events.qr.width', 'Breite (%)')}
</Text>
@@ -1454,14 +1454,14 @@ function LayoutControls({
</YStack>
</XStack>
<XStack space="$3">
<YStack flex={1} space="$1">
<XStack gap="$3">
<YStack flex={1} gap="$1">
<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">
<YStack flex={1} gap="$1">
<Text fontSize="$xs" color={muted}>
{t('events.qr.fontFamily', 'Font Family')}
</Text>
@@ -1477,12 +1477,12 @@ function LayoutControls({
))}
</MobileSelect>
</YStack>
<YStack flex={1} space="$1">
<YStack flex={1} gap="$1">
<Text fontSize="$xs" color={muted}>
{t('events.qr.fontColor', 'Schriftfarbe')}
</Text>
<YStack space="$2">
<XStack space="$2" alignItems="center">
<YStack gap="$2">
<XStack gap="$2" alignItems="center">
<Pressable onPress={() => setOpenColorSlot(openColorSlot === slotKey ? null : slotKey)}>
<XStack
width={48}
@@ -1536,7 +1536,7 @@ function LayoutControls({
onChange={(val) => onUpdateSlot(slotKey, { color: val })}
style={{ width: 240, height: 200 }}
/>
<XStack space="$2" justifyContent="flex-end">
<XStack gap="$2" justifyContent="flex-end">
<Pressable onPress={() => setOpenColorSlot(null)}>
<XStack
paddingHorizontal="$3"
@@ -1560,8 +1560,8 @@ function LayoutControls({
</YStack>
</XStack>
<XStack space="$2">
<YStack flex={1} space="$1">
<XStack gap="$2">
<YStack flex={1} gap="$1">
<Text fontSize="$xs" color={muted}>
{t('events.qr.align', 'Align')}
</Text>
@@ -1574,7 +1574,7 @@ function LayoutControls({
<option value="right">{t('common.right', 'Rechts')}</option>
</MobileSelect>
</YStack>
<YStack flex={1} space="$1">
<YStack flex={1} gap="$1">
<Text fontSize="$xs" color={muted}>
{t('events.qr.lineHeight', 'Line Height')}
</Text>
@@ -1606,7 +1606,7 @@ function LayoutControls({
const accordionDefaults = ['headline'];
return (
<YStack space="$3" marginTop="$2">
<YStack gap="$3" marginTop="$2">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.layoutControls', 'Layout & Schrift')}
</Text>
@@ -1627,9 +1627,9 @@ function LayoutControls({
</XStack>
</Accordion.Trigger>
<Accordion.Content {...({ paddingTop: "$2" } as any)}>
<YStack space="$2" padding="$2" borderWidth={1} borderColor={border} borderRadius={12}>
<XStack space="$2">
<YStack flex={1} space="$1">
<YStack gap="$2" padding="$2" borderWidth={1} borderColor={border} borderRadius={12}>
<XStack gap="$2">
<YStack flex={1} gap="$1">
<Text fontSize="$xs" color={muted}>
{t('events.qr.positionX', 'X (%)')}
</Text>
@@ -1641,7 +1641,7 @@ function LayoutControls({
onChange={(val) => onQrPercentChange('x')(val)}
/>
</YStack>
<YStack flex={1} space="$1">
<YStack flex={1} gap="$1">
<Text fontSize="$xs" color={muted}>
{t('events.qr.positionY', 'Y (%)')}
</Text>
@@ -1653,7 +1653,7 @@ function LayoutControls({
onChange={(val) => onQrPercentChange('y')(val)}
/>
</YStack>
<YStack flex={1} space="$1">
<YStack flex={1} gap="$1">
<Text fontSize="$xs" color={muted}>
{t('events.qr.size', 'Größe (%)')}
</Text>

View File

@@ -106,7 +106,7 @@ export default function MobileQrPrintPage() {
<ContextHelpLink slug="guest-access-qr" />
</XStack>
<MobileCard space="$3" alignItems="center">
<MobileCard gap="$3" alignItems="center">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.qr.heroTitle', 'Entrance QR Code')}
</Text>
@@ -148,7 +148,7 @@ export default function MobileQrPrintPage() {
<Text fontSize="$xs" color={muted}>
{t('events.qr.description', 'Scan to access the event guest app.')}
</Text>
<XStack space="$2" width="100%" marginTop="$2">
<XStack gap="$2" width="100%" marginTop="$2">
<CTAButton
label={t('events.qr.download', 'Download')}
fullWidth={false}
@@ -191,7 +191,7 @@ export default function MobileQrPrintPage() {
</XStack>
</MobileCard>
<MobileCard space="$2">
<MobileCard gap="$2">
<Text fontSize="$xs" color={muted}>
{t('events.qr.step1', 'Schritt 1: Format wählen')}
</Text>
@@ -226,7 +226,7 @@ export default function MobileQrPrintPage() {
/>
</MobileCard>
<MobileCard space="$2">
<MobileCard gap="$2">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.createLink', 'Neuen QR-Link erstellen')}
</Text>
@@ -299,7 +299,7 @@ function FormatSelection({
];
return (
<YStack space="$2" marginTop="$2">
<YStack gap="$2" marginTop="$2">
{cards.map((card) => {
const isSelected = selectedFormat === card.key;
return (
@@ -314,15 +314,15 @@ function FormatSelection({
borderWidth={isSelected ? 2 : 1}
backgroundColor={isSelected ? accentSoft : surface}
>
<XStack alignItems="center" justifyContent="space-between" space="$3">
<YStack space="$1" flex={1}>
<XStack alignItems="center" justifyContent="space-between" gap="$3">
<YStack gap="$1" flex={1}>
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{card.title}
</Text>
<Text fontSize="$xs" color={muted}>
{card.subtitle}
</Text>
<XStack space="$2" alignItems="center" flexWrap="wrap">
<XStack gap="$2" alignItems="center" flexWrap="wrap">
{card.badges.map((badge) => (
<PillBadge tone="muted" key={badge}>
{badge}
@@ -377,10 +377,10 @@ function BackgroundStep({
const formatLabel = isFoldable ? 'A4 Landscape (Double/Mirror)' : 'A4 Portrait';
return (
<YStack space="$3" marginTop="$2">
<XStack alignItems="center" space="$2">
<YStack gap="$3" marginTop="$2">
<XStack alignItems="center" gap="$2">
<Pressable onPress={onBack}>
<XStack alignItems="center" space="$1">
<XStack alignItems="center" gap="$1">
<ArrowLeft size={16} color={textStrong} />
<Text fontSize="$sm" color={textStrong}>
{t('common.back', 'Zurück')}
@@ -392,7 +392,7 @@ function BackgroundStep({
</PillBadge>
</XStack>
<YStack space="$2">
<YStack gap="$2">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t(
'events.qr.backgroundPicker',
@@ -488,10 +488,10 @@ function TextStep({
};
return (
<YStack space="$3" marginTop="$2">
<XStack alignItems="center" space="$2">
<YStack gap="$3" marginTop="$2">
<XStack alignItems="center" gap="$2">
<Pressable onPress={onBack}>
<XStack alignItems="center" space="$1">
<XStack alignItems="center" gap="$1">
<ArrowLeft size={16} color={textStrong} />
<Text fontSize="$sm" color={textStrong}>
{t('common.back', 'Zurück')}
@@ -501,7 +501,7 @@ function TextStep({
<PillBadge tone="muted">{t('events.qr.textStep', 'Texte & Hinweise')}</PillBadge>
</XStack>
<YStack space="$2">
<YStack gap="$2">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.textFields', 'Texte')}
</Text>
@@ -522,12 +522,12 @@ function TextStep({
/>
</YStack>
<YStack space="$2">
<YStack gap="$2">
<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">
<XStack key={idx} alignItems="center" gap="$2">
<MobileInput
style={{ flex: 1 }}
placeholder={t('events.qr.instructionsPlaceholder', 'Schritt hinzufügen')}
@@ -580,10 +580,10 @@ function PreviewStep({
const previewBody = layout?.preview?.text ?? text;
return (
<YStack space="$3" marginTop="$2">
<XStack alignItems="center" space="$2">
<YStack gap="$3" marginTop="$2">
<XStack alignItems="center" gap="$2">
<Pressable onPress={onBack}>
<XStack alignItems="center" space="$1">
<XStack alignItems="center" gap="$1">
<ArrowLeft size={16} color={textStrong} />
<Text fontSize="$sm" color={textStrong}>
{t('common.back', 'Zurück')}
@@ -597,7 +597,7 @@ function PreviewStep({
) : null}
</XStack>
<YStack space="$2">
<YStack gap="$2">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.qr.preview', 'Vorschau')}
</Text>
@@ -632,7 +632,7 @@ function PreviewStep({
{textFields.description}
</Text>
) : null}
<YStack space="$1">
<YStack gap="$1">
{textFields.instructions.filter((i) => i.trim().length > 0).map((item, idx) => (
<Text key={idx} fontSize="$xs" color={previewBody}>
{item}
@@ -660,7 +660,7 @@ function PreviewStep({
</YStack>
</YStack>
<XStack space="$2">
<XStack gap="$2">
<CTAButton label={t('events.qr.exportPdf', 'Export PDF')} onPress={() => onExport('pdf')} />
<CTAButton label={t('events.qr.exportPng', 'Export PNG')} onPress={() => onExport('png')} />
</XStack>

View File

@@ -110,10 +110,10 @@ export default function ResetPasswordPage() {
paddingVertical="$5"
style={{ ...safeAreaStyle, background: ADMIN_GRADIENTS.loginBackground }}
>
<YStack width="100%" maxWidth={520} space="$4">
<YStack width="100%" maxWidth={520} gap="$4">
<MobileCard backgroundColor="rgba(15, 23, 42, 0.6)" borderColor="rgba(255,255,255,0.12)">
<YStack space="$2">
<XStack alignItems="center" space="$2">
<YStack gap="$2">
<XStack alignItems="center" gap="$2">
<XStack
width={42}
height={42}
@@ -137,7 +137,7 @@ export default function ResetPasswordPage() {
</MobileCard>
<MobileCard backgroundColor={surface} borderColor={border}>
<YStack space="$3">
<YStack gap="$3">
<MobileField label={t('login.email', 'Email address')} error={fieldErrors.email?.[0]}>
<MobileInput
type="email"

View File

@@ -210,8 +210,8 @@ export default function MobileSettingsPage() {
}}
/>
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<MobileCard gap="$3">
<XStack alignItems="center" gap="$2">
<Shield size={18} color={text} />
<Text fontSize="$md" fontWeight="800" color={text}>
{t('mobileSettings.accountTitle', 'Account')}
@@ -223,14 +223,14 @@ export default function MobileSettingsPage() {
{user?.tenant_id ? (
<PillBadge tone="muted">{t('mobileSettings.tenantBadge', 'Account #{{id}}', { id: user.tenant_id })}</PillBadge>
) : null}
<XStack space="$2">
<XStack gap="$2">
<CTAButton label={t('settings.profile.actions.openProfile', 'Profil bearbeiten')} onPress={() => navigate(ADMIN_PROFILE_ACCOUNT_PATH)} />
<CTAButton label={t('settings.session.logout', 'Abmelden')} tone="ghost" onPress={() => logout({ redirect: adminPath('/logout') })} />
</XStack>
</MobileCard>
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<MobileCard gap="$3">
<XStack alignItems="center" gap="$2">
<Bell size={18} color={text} />
<Text fontSize="$md" fontWeight="800" color={text}>
{t('mobileSettings.notificationsTitle', 'Notifications')}
@@ -316,14 +316,14 @@ export default function MobileSettingsPage() {
{pushState.error}
</Text>
) : null}
<XStack space="$2">
<XStack gap="$2">
<CTAButton label={saving ? t('common.processing', '...') : t('settings.notifications.actions.save', 'Speichern')} onPress={() => handleSave()} />
<CTAButton label={t('common.reset', 'Reset')} tone="ghost" onPress={() => handleReset()} />
</XStack>
</MobileCard>
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<MobileCard gap="$3">
<XStack alignItems="center" gap="$2">
<Smartphone size={18} color={text} />
<Text fontSize="$md" fontWeight="800" color={text}>
{t('mobileSettings.deviceTitle', 'Device & permissions')}
@@ -337,9 +337,9 @@ export default function MobileSettingsPage() {
{t('mobileSettings.deviceLoading', 'Checking device status ...')}
</Text>
) : (
<YStack space="$2">
<YStack gap="$2">
<XStack alignItems="center" justifyContent="space-between" gap="$2">
<YStack flex={1} space="$1">
<YStack flex={1} gap="$1">
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('mobileSettings.deviceStatus.notifications.label', 'Notifications')}
</Text>
@@ -352,7 +352,7 @@ export default function MobileSettingsPage() {
</PillBadge>
</XStack>
<XStack alignItems="center" justifyContent="space-between" gap="$2">
<YStack flex={1} space="$1">
<YStack flex={1} gap="$1">
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('mobileSettings.deviceStatus.camera.label', 'Camera')}
</Text>
@@ -365,7 +365,7 @@ export default function MobileSettingsPage() {
</PillBadge>
</XStack>
<XStack alignItems="center" justifyContent="space-between" gap="$2">
<YStack flex={1} space="$1">
<YStack flex={1} gap="$1">
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('mobileSettings.deviceStatus.storage.label', 'Offline storage')}
</Text>
@@ -378,7 +378,7 @@ export default function MobileSettingsPage() {
</PillBadge>
</XStack>
<XStack alignItems="center" justifyContent="space-between" gap="$2">
<YStack flex={1} space="$1">
<YStack flex={1} gap="$1">
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('mobileSettings.deviceStatus.connection.label', 'Connection')}
</Text>
@@ -404,8 +404,8 @@ export default function MobileSettingsPage() {
) : null}
</MobileCard>
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<MobileCard gap="$3">
<XStack alignItems="center" gap="$2">
<Sparkles size={18} color={text} />
<Text fontSize="$md" fontWeight="800" color={text}>
{t('mobileSettings.experienceTitle', 'Experience')}
@@ -414,7 +414,7 @@ export default function MobileSettingsPage() {
<Text fontSize="$sm" color={muted}>
{t('mobileSettings.experienceBody', 'Replay the quick tour or re-enable the install banner.')}
</Text>
<XStack space="$2">
<XStack gap="$2">
<CTAButton
label={t('mobileSettings.experienceReplay', 'Replay quick tour')}
onPress={handleReplayTour}
@@ -430,8 +430,8 @@ export default function MobileSettingsPage() {
</XStack>
</MobileCard>
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<MobileCard gap="$3">
<XStack alignItems="center" gap="$2">
<User size={18} color={text} />
<Text fontSize="$md" fontWeight="800" color={text}>
{t('settings.appearance.title', 'Darstellung')}

View File

@@ -25,7 +25,7 @@ export default function MobileTasksTabPage() {
if (activeEvent?.slug && !tasksEnabled) {
return (
<MobileShell activeTab="tasks" title={t('events.tasks.title', 'Photo tasks')}>
<MobileCard alignItems="flex-start" space="$3">
<MobileCard alignItems="flex-start" gap="$3">
<Text fontSize="$lg" fontWeight="800" color={text}>
{t('events.tasks.disabledTitle', 'Photo task mode is off for this event')}
</Text>
@@ -44,7 +44,7 @@ export default function MobileTasksTabPage() {
if (!hasEvents) {
return (
<MobileShell activeTab="tasks" title={t('events.tasks.title', 'Photo tasks')}>
<MobileCard alignItems="flex-start" space="$3">
<MobileCard alignItems="flex-start" gap="$3">
<Text fontSize="$lg" fontWeight="800" color={text}>
{t('events.tasks.emptyTitle', 'Create an event first')}
</Text>
@@ -64,7 +64,7 @@ export default function MobileTasksTabPage() {
return (
<MobileShell activeTab="tasks" title={t('events.tasks.title', 'Photo tasks')}>
<YStack space="$2">
<YStack gap="$2">
<Text fontSize="$sm" color={text} fontWeight="700">
{t('events.tasks.pickEvent', 'Pick an event to manage photo tasks')}
</Text>
@@ -78,9 +78,9 @@ export default function MobileTasksTabPage() {
}
}}
>
<MobileCard borderColor={border} space="$2">
<MobileCard borderColor={border} gap="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$1">
<YStack gap="$1">
<Text fontSize="$md" fontWeight="800" color={text}>
{resolveEventDisplayName(event)}
</Text>

View File

@@ -37,7 +37,7 @@ export default function MobileUploadsTabPage() {
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
>
<YStack space="$2.5">
<YStack gap="$2.5">
<XStack
alignItems="center"
paddingHorizontal="$3"
@@ -82,7 +82,7 @@ export default function MobileUploadsTabPage() {
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
>
<YStack space="$2.5">
<YStack gap="$2.5">
<XStack
alignItems="center"
paddingHorizontal="$3"
@@ -105,7 +105,7 @@ export default function MobileUploadsTabPage() {
paddingVertical="$2"
paddingHorizontal="$3"
title={
<YStack space="$1">
<YStack gap="$1">
<Text fontSize="$md" fontWeight="800" color={text}>
{resolveEventDisplayName(event)}
</Text>

View File

@@ -77,7 +77,7 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
<YStack
alignItems="center"
justifyContent="center"
space="$1"
gap="$1"
minHeight={50}
style={{
transform: isPressed ? 'scale(0.92)' : (activeState ? 'scale(1.05)' : 'scale(1)'),

View File

@@ -27,7 +27,7 @@ export function ContextHelpLink({ slug, label }: ContextHelpLinkProps) {
>
<XStack
alignItems="center"
space="$1.5"
gap="$1.5"
paddingHorizontal="$2.5"
paddingVertical="$1.5"
borderRadius={999}

View File

@@ -41,14 +41,14 @@ export function EventSwitcherSheet({
onClose={onClose}
snapPoints={[65]}
>
<YStack space="$2">
<YStack gap="$2">
{events.map((event) => {
const isActive = event.slug === activeSlug;
return (
<Pressable key={event.slug} onPress={() => handleSelect(event.slug)}>
<XStack
alignItems="center"
space="$3"
gap="$3"
padding="$3"
borderRadius={14}
backgroundColor={isActive ? theme.surfaceMuted : 'transparent'}

View File

@@ -18,7 +18,7 @@ export function MobileField({ label, hint, error, children }: FieldProps) {
const { text, muted, danger } = useAdminTheme();
return (
<YStack space="$1.5">
<YStack gap="$1.5">
{typeof label === 'string' || typeof label === 'number' ? (
<Text fontSize="$sm" fontWeight="800" color={text}>
{label}

View File

@@ -76,7 +76,7 @@ export function LegalConsentSheet({
onClose={onClose}
title={copy?.title ?? t('events.legalConsent.title', 'Before purchase')}
footer={
<YStack space="$2">
<YStack gap="$2">
{error ? (
<Text fontSize="$sm" color={danger}>
{error}
@@ -97,12 +97,12 @@ export function LegalConsentSheet({
</YStack>
}
>
<YStack space="$2">
<YStack gap="$2">
<Text fontSize="$sm" color={text}>
{copy?.description ?? t('events.legalConsent.description', 'Please confirm the legal notes before buying an add-on.')}
</Text>
{requireTerms ? (
<XStack space="$3" alignItems="flex-start">
<XStack gap="$3" alignItems="flex-start">
<Checkbox
id="legal-terms"
size="$4"
@@ -130,7 +130,7 @@ export function LegalConsentSheet({
</XStack>
) : null}
{requireWaiver ? (
<XStack space="$3" alignItems="flex-start">
<XStack gap="$3" alignItems="flex-start">
<Checkbox
id="legal-waiver"
size="$4"

View File

@@ -33,9 +33,9 @@ export function LimitWarnings({
}
return (
<YStack space="$2">
<YStack gap="$2">
{warnings.map((warning) => (
<MobileCard key={warning.id} borderColor={borderColor} space="$2">
<MobileCard key={warning.id} borderColor={borderColor} gap="$2">
<Text fontSize="$sm" color={textColor} fontWeight="700">
{warning.message}
</Text>
@@ -100,7 +100,7 @@ function MobileAddonsPicker({
}
return (
<XStack space="$2" alignItems="center">
<XStack gap="$2" alignItems="center">
<MobileSelect
value={selected}
onChange={(event) => setSelected(event.target.value)}

View File

@@ -33,13 +33,13 @@ export function MobileInstallBanner({
return (
<MobileCard
space={isCompact ? '$1.5' : '$2'}
gap={isCompact ? '$1.5' : '$2'}
borderColor={border}
backgroundColor={surfaceMuted}
padding={isCompact ? '$2' : '$3'}
>
<XStack alignItems="center" justifyContent="space-between" gap="$2">
<XStack alignItems="center" space="$2" flex={1}>
<XStack alignItems="center" gap="$2" flex={1}>
<XStack
width={isCompact ? 32 : 36}
height={isCompact ? 32 : 36}
@@ -50,7 +50,7 @@ export function MobileInstallBanner({
>
{isPrompt ? <Download size={16} color={primary} /> : <Share2 size={16} color={primary} />}
</XStack>
<YStack flex={1} space="$0.5">
<YStack flex={1} gap="$0.5">
<Text fontSize={isCompact ? '$xs' : '$sm'} fontWeight="800" color={textStrong}>
{t('installBanner.title', 'Install Fotospiel Admin')}
</Text>
@@ -61,7 +61,7 @@ export function MobileInstallBanner({
</Text>
</YStack>
</XStack>
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
{isPrompt && onInstall && isCompact ? (
<Pressable onPress={onInstall}>
<Text fontSize={10} fontWeight="700" color={primary}>

View File

@@ -177,7 +177,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
paddingVertical="$1.5"
borderRadius={999}
alignItems="center"
space="$1.5"
gap="$1.5"
maxWidth={220}
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.08)"
@@ -201,7 +201,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
const headerBackButton = onBack ? (
<HeaderActionButton onPress={onBack} ariaLabel={t('actions.back', 'Back')}>
<XStack alignItems="center" space="$1.5">
<XStack alignItems="center" gap="$1.5">
<ChevronLeft size={28} color="white" strokeWidth={2.5} />
</XStack>
</HeaderActionButton>
@@ -214,7 +214,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
);
const headerActionsRow = (
<XStack alignItems="center" space="$2.5">
<XStack alignItems="center" gap="$2.5">
{showQr ? (
<HeaderActionButton
onPress={() => navigate(adminPath(`/mobile/events/${effectiveActive?.slug}/qr`))}
@@ -282,7 +282,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
borderWidth={1} borderColor={actionBorder}
>
{user?.avatar_url ? (
<Image source={{ uri: user.avatar_url }} width={36} height={36} resizeMode="cover" />
<Image src={user.avatar_url} width={36} height={36} objectFit="cover" />
) : (
<Text fontSize="$xs" fontWeight="700" color="white">
{user?.name?.charAt(0).toUpperCase() ?? 'U'}
@@ -315,7 +315,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
}}
>
<XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$2">
<XStack alignItems="center" justifyContent="space-between" minHeight={48} gap="$2">
{headerBackButton}
<XStack flex={1} justifyContent="center" alignItems="center">
@@ -332,7 +332,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
flex={1}
padding="$4"
paddingBottom="$10"
space="$3"
gap="$3"
width="100%"
maxWidth={800}
style={{ paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 96px)' }}
@@ -345,7 +345,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
</XStack>
) : null}
{queuedPhotoCount > 0 ? (
<MobileCard space="$2">
<MobileCard gap="$2">
<Text fontSize="$sm" fontWeight="700" color={theme.textStrong}>
{t('status.queueTitle', 'Photo actions pending')}
</Text>
@@ -365,7 +365,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
</MobileCard>
) : null}
{subtitle ? (
<YStack space="$1">
<YStack gap="$1">
{title ? (
<Text fontSize="$lg" fontWeight="800" color={theme.textStrong}>
{title}

View File

@@ -45,7 +45,7 @@ export function OnboardingShell({
paddingHorizontal="$5"
paddingTop="$5"
paddingBottom="$6"
space="$4"
gap="$4"
style={{
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 20px)',
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 24px)',
@@ -54,7 +54,7 @@ export function OnboardingShell({
<XStack alignItems="center" justifyContent="space-between">
{onBack ? (
<Pressable onPress={onBack} aria-label={resolvedBackLabel}>
<XStack alignItems="center" space="$1.5">
<XStack alignItems="center" gap="$1.5">
<ChevronLeft size={22} color={text} />
<Text fontSize="$sm" fontWeight="700" color={text}>
{resolvedBackLabel}
@@ -86,7 +86,7 @@ export function OnboardingShell({
shadowOpacity={0.06}
shadowRadius={14}
shadowOffset={{ width: 0, height: 8 }}
space="$2"
gap="$2"
>
{eyebrow ? (
<Text fontSize="$xs" fontWeight="700" color={muted} textTransform="uppercase" letterSpacing={0.6}>
@@ -103,7 +103,7 @@ export function OnboardingShell({
) : null}
</YStack>
<YStack space="$4">{children}</YStack>
<YStack gap="$4">{children}</YStack>
{footer ? <YStack marginTop="$2">{footer}</YStack> : null}
</YStack>
</YStack>

View File

@@ -26,7 +26,7 @@ export function MobileCard({
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
padding="$3.5"
space="$2"
gap="$2"
style={{
...style,
}}
@@ -138,7 +138,7 @@ export function CTAButton({
backgroundColor={backgroundColor}
borderWidth={isPrimary || isDanger ? 0 : 2}
borderColor={borderColor}
space="$2"
gap="$2"
style={primaryStyle}
>
{iconLeft}
@@ -169,7 +169,7 @@ export function KpiTile({
const iconColor = color || primary;
return (
<MobileCard borderRadius={14} padding="$2.5" width="31%" minWidth={100} space="$1.5">
<MobileCard borderRadius={14} padding="$2.5" width="31%" minWidth={100} gap="$1.5">
<XStack
width={28}
height={28}
@@ -181,7 +181,7 @@ export function KpiTile({
<IconCmp size={14} color={iconColor} />
</XStack>
<YStack space="$0">
<YStack gap="$0">
<Text fontSize="$xl" fontWeight="900" color={textStrong} letterSpacing={-0.5} lineHeight="$xl">
{value}
</Text>
@@ -237,7 +237,7 @@ export function KpiStrip({
minWidth={150}
maxWidth={220}
>
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<Text
fontSize={32}
fontWeight="900"
@@ -248,7 +248,7 @@ export function KpiStrip({
{item.value}
</Text>
<Separator vertical backgroundColor={separatorColor} height={32} marginHorizontal="$1.5" />
<YStack alignItems="center" space="$0.5" paddingLeft="$0.5">
<YStack alignItems="center" gap="$0.5" paddingLeft="$0.5">
<XStack
width={28}
height={28}
@@ -339,7 +339,7 @@ export function ActionTile({
style={tileStyle}
borderRadius={isCluster ? 14 : 16}
padding="$3"
space="$2.5"
gap="$2.5"
backgroundColor={glassSurface ?? backgroundColor}
borderWidth={2}
borderColor={borderColor}
@@ -405,7 +405,7 @@ export function FloatingActionButton({
borderRadius={999}
alignItems="center"
justifyContent="center"
space="$2"
gap="$2"
backgroundColor={primary}
shadowColor={shadow}
shadowOpacity={0.2}

View File

@@ -41,10 +41,10 @@ export function MobileScaffold({ title, onBack, rightSlot, children, footer }: M
WebkitBackdropFilter: 'blur(12px)',
}}
>
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
{onBack ? (
<Pressable onPress={onBack}>
<XStack alignItems="center" space="$1.5">
<XStack alignItems="center" gap="$1.5">
<ChevronLeft size={18} color={primary} />
<Text fontSize="$sm" color={primary} fontWeight="600">
{t('actions.back', 'Back')}
@@ -63,7 +63,7 @@ export function MobileScaffold({ title, onBack, rightSlot, children, footer }: M
</XStack>
</XStack>
<YStack flex={1} padding="$4" space="$3" paddingBottom={footer ? '$14' : '$5'}>
<YStack flex={1} padding="$4" gap="$3" paddingBottom={footer ? '$14' : '$5'}>
{children}
</YStack>

View File

@@ -38,9 +38,9 @@ export function SetupChecklist({
const content = (
<YStack>
<Pressable onPress={() => setCollapsed(!collapsed)}>
<YStack padding="$3" paddingVertical="$2.5" space="$2">
<YStack padding="$3" paddingVertical="$2.5" gap="$2">
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<Text fontSize="$xs" fontWeight="700" color={theme.muted} textTransform="uppercase" letterSpacing={1}>
{title}
</Text>
@@ -51,7 +51,7 @@ export function SetupChecklist({
)}
</XStack>
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<Text fontSize="$xs" color={theme.muted} fontWeight="600">
{completedCount}/{steps.length}
</Text>
@@ -89,7 +89,7 @@ export function SetupChecklist({
backgroundColor={isNext ? theme.surfaceMuted : 'transparent'}
onPress={() => navigate(adminPath(step.targetPath))}
title={
<XStack alignItems="center" space="$2.5">
<XStack alignItems="center" gap="$2.5">
{step.isComplete ? (
<CheckCircle2 size={18} color={theme.successText} />
) : isNext ? (

View File

@@ -77,7 +77,7 @@ export function MobileSheet({
showsVerticalScrollIndicator={false}
{...({ contentContainerStyle: { paddingBottom: 6 } } as any)}
>
<YStack space={contentSpacing}>
<YStack gap={contentSpacing}>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{title}

View File

@@ -106,7 +106,7 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
borderLeftWidth={1}
borderColor={theme.border}
padding="$4"
space="$3"
gap="$3"
style={{
transform: open ? 'translateX(0)' : 'translateX(100%)',
transition: 'transform 220ms ease',
@@ -133,7 +133,7 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
</Pressable>
</XStack>
<XStack alignItems="center" space="$3">
<XStack alignItems="center" gap="$3">
<XStack
width={48}
height={48}
@@ -158,7 +158,7 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
<Separator backgroundColor={theme.border} opacity={0.6} />
<YStack space="$2">
<YStack gap="$2">
<Text fontSize="$xs" fontWeight="700" color={theme.muted} textTransform="uppercase" letterSpacing={1}>
{t('mobileProfile.settings', 'Einstellungen')}
</Text>
@@ -172,7 +172,7 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
paddingHorizontal="$3"
onPress={() => handleNavigate(item.path)}
title={
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<XStack
width={28}
height={28}
@@ -197,7 +197,7 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
</YGroup>
</YStack>
<YStack space="$2">
<YStack gap="$2">
<Text fontSize="$xs" fontWeight="700" color={theme.muted} textTransform="uppercase" letterSpacing={1}>
{t('settings.appearance.title', 'Darstellung')}
</Text>
@@ -207,7 +207,7 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
paddingVertical="$2"
paddingHorizontal="$3"
title={
<XStack space="$2" alignItems="center">
<XStack gap="$2" alignItems="center">
<Text fontSize="$sm" color={theme.textStrong}>
{t('mobileProfile.language', 'Sprache')}
</Text>
@@ -235,7 +235,7 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
paddingVertical="$2"
paddingHorizontal="$3"
title={
<XStack space="$2" alignItems="center">
<XStack gap="$2" alignItems="center">
<Text fontSize="$sm" color={theme.textStrong}>
{t('mobileProfile.theme', 'Dark Mode')}
</Text>

View File

@@ -50,7 +50,7 @@ export default function WelcomeEventPage() {
onSkip={handleSkip}
skipLabel={t('layout.jumpToDashboard', 'Jump to dashboard')}
>
<MobileCard space="$3">
<MobileCard gap="$3">
<Text fontSize="$sm" fontWeight="800">
{t('eventSetup.step.title', 'Event setup in minutes')}
</Text>
@@ -60,7 +60,7 @@ export default function WelcomeEventPage() {
'We guide you through name, date, mood, and tasks. Afterwards you can moderate photos and support guests live.',
)}
</Text>
<YStack space="$2">
<YStack gap="$2">
<FeatureRow
icon={Sparkles}
title={t('eventSetup.tiles.story.title', 'Story & mood')}
@@ -79,7 +79,7 @@ export default function WelcomeEventPage() {
</YStack>
</MobileCard>
<MobileCard space="$2">
<MobileCard gap="$2">
<Text fontSize="$sm" fontWeight="800">
{t('eventSetup.cta.heading', 'Ready for your first event?')}
</Text>
@@ -95,7 +95,7 @@ export default function WelcomeEventPage() {
/>
</MobileCard>
<YStack space="$2">
<YStack gap="$2">
<CTAButton
label={t('eventSetup.actions.dashboard.button', 'Open dashboard')}
tone="ghost"
@@ -121,7 +121,7 @@ function FeatureRow({
body: string;
}) {
return (
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<XStack
width={34}
height={34}

View File

@@ -48,7 +48,7 @@ export default function WelcomeLandingPage() {
onSkip={handleSkip}
skipLabel={t('layout.jumpToDashboard', 'Jump to dashboard')}
>
<MobileCard space="$3">
<MobileCard gap="$3">
<PillBadge tone="muted">{t('hero.eyebrow', 'Your event, your stage')}</PillBadge>
<Text fontSize="$lg" fontWeight="900">
{t('hero.title', 'Design the next Fotospiel experience')}
@@ -59,7 +59,7 @@ export default function WelcomeLandingPage() {
'In just a few steps you guide guests through a magical photo journey complete with storytelling, tasks, and a moderated gallery.',
)}
</Text>
<XStack space="$2" flexWrap="wrap">
<XStack gap="$2" flexWrap="wrap">
<CTAButton
label={
shouldGoBilling
@@ -80,7 +80,7 @@ export default function WelcomeLandingPage() {
</XStack>
</MobileCard>
<YStack space="$3">
<YStack gap="$3">
<FeatureCard
icon={ImageIcon}
title={t('highlights.gallery.title', 'Premium guest gallery')}
@@ -117,9 +117,9 @@ function FeatureCard({
badge?: string;
}) {
return (
<MobileCard space="$2">
<MobileCard gap="$2">
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<XStack
width={36}
height={36}

View File

@@ -85,7 +85,7 @@ export default function WelcomePackagesPage() {
</Text>
</MobileCard>
) : (
<YStack space="$3">
<YStack gap="$3">
{packages?.map((pkg) => (
<PackageCard
key={pkg.id}
@@ -97,7 +97,7 @@ export default function WelcomePackagesPage() {
</YStack>
)}
<MobileCard space="$2">
<MobileCard gap="$2">
<Text fontSize="$sm" fontWeight="800">
{t('packages.step.title', 'Activate the right plan')}
</Text>
@@ -106,7 +106,7 @@ export default function WelcomePackagesPage() {
</Text>
</MobileCard>
<XStack space="$2">
<XStack gap="$2">
<CTAButton
label={t('packages.cta.summary.button', 'Continue to summary')}
onPress={() => navigate(ADMIN_WELCOME_SUMMARY_PATH)}
@@ -143,9 +143,9 @@ function PackageCard({
return (
<Pressable onPress={onSelect}>
<MobileCard borderColor={selected ? primary : border} space="$2">
<MobileCard borderColor={selected ? primary : border} gap="$2">
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<XStack width={36} height={36} borderRadius={12} backgroundColor={accentSoft} alignItems="center" justifyContent="center">
<PackageIcon size={18} color={primary} />
</XStack>
@@ -162,7 +162,7 @@ function PackageCard({
{selected ? t('packages.card.selected', 'Selected') : t('packages.card.select', 'Select package')}
</PillBadge>
</XStack>
<XStack flexWrap="wrap" space="$2">
<XStack flexWrap="wrap" gap="$2">
{badges.map((badge) => (
<PillBadge key={badge as any} tone="muted">
{badge as any}
@@ -170,7 +170,7 @@ function PackageCard({
))}
</XStack>
{selected ? (
<XStack alignItems="center" space="$1">
<XStack alignItems="center" gap="$1">
<Check size={14} color={primary} />
<Text fontSize="$xs" color={primary} fontWeight="700">
{t('packages.card.selected', 'Selected')}

View File

@@ -94,9 +94,9 @@ export default function WelcomeSummaryPage() {
<CTAButton label={t('summary.footer.back', 'Back to package selection')} onPress={() => navigate(ADMIN_WELCOME_PACKAGES_PATH)} />
</MobileCard>
) : (
<MobileCard space="$3">
<MobileCard gap="$3">
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<XStack
width={36}
height={36}
@@ -123,7 +123,7 @@ export default function WelcomeSummaryPage() {
</PillBadge>
</XStack>
<YStack space="$2">
<YStack gap="$2">
<SummaryRow
label={t('summary.details.section.photosTitle', 'Photos & gallery')}
value={t('summary.details.section.photosValue', {
@@ -148,7 +148,7 @@ export default function WelcomeSummaryPage() {
</YStack>
{resolvedPackage.active ? (
<XStack alignItems="center" space="$2">
<XStack alignItems="center" gap="$2">
<CheckCircle2 size={18} color={ADMIN_COLORS.success} />
<Text fontSize="$sm" color={ADMIN_COLORS.success} fontWeight="700">
{t('summary.details.section.statusActive', 'Already purchased')}
@@ -158,11 +158,11 @@ export default function WelcomeSummaryPage() {
</MobileCard>
)}
<MobileCard space="$2">
<MobileCard gap="$2">
<Text fontSize="$sm" fontWeight="800">
{t('summary.nextStepsTitle', 'Next steps')}
</Text>
<YStack space="$1">
<YStack gap="$1">
{(t('summary.nextSteps', {
returnObjects: true,
defaultValue: [
@@ -171,7 +171,7 @@ export default function WelcomeSummaryPage() {
'Check your event slots before go-live and share your guest link.',
],
}) as string[]).map((item) => (
<XStack key={item} space="$2">
<XStack key={item} gap="$2">
<Text fontSize="$xs" color={ADMIN_COLORS.textMuted}>
</Text>
@@ -183,7 +183,7 @@ export default function WelcomeSummaryPage() {
</YStack>
</MobileCard>
<XStack space="$2">
<XStack gap="$2">
<CTAButton
label={t('summary.cta.billing.button', 'Go to billing')}
tone="ghost"

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { TamaguiProvider, Theme } from '@tamagui/core';
import { RouterProvider } from 'react-router-dom';
import tamaguiConfig from '../../../tamagui.config';
import { router } from './router';
import { ConsentProvider } from '@/contexts/consent';
import { AppearanceProvider } from '@/hooks/use-appearance';
import { useAppearance } from '@/hooks/use-appearance';
export default function App() {
return (
<TamaguiProvider config={tamaguiConfig} defaultTheme="guestLight" themeClassNameOnRoot>
<AppearanceProvider>
<ConsentProvider>
<AppThemeRouter />
</ConsentProvider>
</AppearanceProvider>
</TamaguiProvider>
);
}
function AppThemeRouter() {
const { resolved } = useAppearance();
const themeName = resolved === 'dark' ? 'guestNight' : 'guestLight';
return (
<Theme name={themeName}>
<RouterProvider router={router} />
</Theme>
);
}

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
vi.mock('react-router-dom', () => ({
useLocation: () => ({ pathname: '/e/demo' }),
useNavigate: () => vi.fn(),
}));
vi.mock('../context/EventDataContext', () => ({
useEventData: () => ({ token: 'demo' }),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({
t: (_key: string, fallback?: string) => fallback ?? _key,
}),
}));
vi.mock('@tamagui/stacks', () => ({
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('lucide-react', () => ({
Home: () => <span>home</span>,
Image: () => <span>image</span>,
Share2: () => <span>share</span>,
}));
import BottomDock from '../components/BottomDock';
describe('BottomDock', () => {
it('renders navigation labels', () => {
render(<BottomDock />);
expect(screen.getByText('Home')).toBeInTheDocument();
expect(screen.getByText('Gallery')).toBeInTheDocument();
expect(screen.getByText('Share')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
let identityState = { hydrated: true, name: '' };
vi.mock('react-router-dom', () => ({
useParams: () => ({ token: 'demo-token' }),
Navigate: ({ to }: { to: string }) => <div>navigate:{to}</div>,
Outlet: () => <div>outlet</div>,
}));
vi.mock('../context/EventDataContext', () => ({
EventDataProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useEventData: () => ({ event: null }),
}));
vi.mock('@/guest/context/EventBrandingContext', () => ({
EventBrandingProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
LocaleProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DEFAULT_LOCALE: 'de',
isLocaleCode: () => true,
}));
vi.mock('@/guest/context/NotificationCenterContext', () => ({
NotificationCenterProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock('../context/GuestIdentityContext', () => ({
GuestIdentityProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useOptionalGuestIdentity: () => identityState,
}));
import EventLayout from '../layouts/EventLayout';
describe('EventLayout profile gate', () => {
it('redirects to setup when profile is missing', () => {
identityState = { hydrated: true, name: '' };
render(<EventLayout requireProfile />);
expect(screen.getByText('navigate:/setup/demo-token')).toBeInTheDocument();
});
it('renders outlet when profile exists', () => {
identityState = { hydrated: true, name: 'Ava' };
render(<EventLayout requireProfile />);
expect(screen.getByText('outlet')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,73 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('@tamagui/input', () => ({
Input: ({ value }: { value?: string }) => <input value={value} readOnly />,
}));
vi.mock('../components/AppShell', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/StandaloneShell', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/SurfaceCard', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@/guest/components/PullToRefresh', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key, locale: 'de' }),
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de' }),
}));
vi.mock('@/guest/services/helpApi', () => ({
getHelpArticles: () => Promise.resolve({
servedFromCache: false,
articles: [{ slug: 'intro', title: 'Intro', summary: 'Summary', updated_at: null }],
}),
}));
vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ resolved: 'light' }),
}));
vi.mock('react-router-dom', () => ({
useParams: () => ({}),
Link: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
import HelpCenterScreen from '../screens/HelpCenterScreen';
describe('HelpCenterScreen', () => {
it('renders help center title', async () => {
render(<HelpCenterScreen />);
expect(await screen.findByText('help.center.title')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { EventDataProvider } from '../context/EventDataContext';
vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('lucide-react', () => ({
Camera: () => <span>camera</span>,
Sparkles: () => <span>sparkles</span>,
Image: () => <span>image</span>,
Star: () => <span>star</span>,
Trophy: () => <span>trophy</span>,
}));
vi.mock('../components/AppShell', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../services/uploadApi', () => ({
useUploadQueue: () => ({ items: [], loading: false, refresh: vi.fn() }),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({ t: (key: string, fallback?: string) => fallback ?? key, locale: 'de' }),
}));
vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ resolved: 'light' }),
}));
import HomeScreen from '../screens/HomeScreen';
describe('HomeScreen', () => {
it('shows prompt quest content when tasks are enabled', () => {
render(
<EventDataProvider tasksEnabledFallback>
<HomeScreen />
</EventDataProvider>
);
expect(screen.getByText('Prompt quest')).toBeInTheDocument();
expect(screen.getByText('Start prompt')).toBeInTheDocument();
});
it('shows capture-ready content when tasks are disabled', () => {
render(
<EventDataProvider tasksEnabledFallback={false}>
<HomeScreen />
</EventDataProvider>
);
expect(screen.getByText('Capture ready')).toBeInTheDocument();
expect(screen.getByText('Upload / Take photo')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { LocaleProvider } from '@/guest/i18n/LocaleContext';
vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('@tamagui/input', () => ({
Input: ({ ...rest }: { [key: string]: unknown }) => <input {...rest} />,
}));
vi.mock('@tamagui/card', () => ({
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('html5-qrcode', () => ({
Html5Qrcode: vi.fn().mockImplementation(() => ({
start: vi.fn(),
stop: vi.fn(),
})),
}));
vi.mock('lucide-react', () => ({
QrCode: () => <span>qr</span>,
ArrowRight: () => <span>arrow</span>,
}));
import LandingScreen from '../screens/LandingScreen';
describe('LandingScreen', () => {
it('renders join panel copy', () => {
render(
<LocaleProvider>
<LandingScreen />
</LocaleProvider>
);
expect(screen.getByRole('button', { name: 'Event beitreten' })).toBeInTheDocument();
expect(screen.getAllByText('Event beitreten').length).toBeGreaterThan(1);
expect(screen.getByPlaceholderText('Event-Code eingeben')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({
t: (_key: string, fallback?: string) => fallback ?? _key,
}),
}));
import NotFoundScreen from '../screens/NotFoundScreen';
describe('NotFoundScreen', () => {
it('renders fallback copy', () => {
render(<NotFoundScreen />);
expect(screen.getByText('Seite nicht gefunden')).toBeInTheDocument();
expect(screen.getByText('Die Seite konnte nicht gefunden werden.')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,70 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
vi.mock('react-router-dom', () => ({
useParams: () => ({ photoId: '123' }),
useNavigate: () => vi.fn(),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('../components/AppShell', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/SurfaceCard', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../context/EventDataContext', () => ({
useEventData: () => ({ token: 'token' }),
}));
vi.mock('../services/photosApi', () => ({
fetchGallery: vi.fn().mockResolvedValue({ data: [], next_cursor: null, latest_photo_at: null }),
fetchPhoto: vi.fn().mockResolvedValue({ id: 123, file_path: 'storage/demo.jpg', likes_count: 5 }),
likePhoto: vi.fn().mockResolvedValue(6),
createPhotoShareLink: vi.fn().mockResolvedValue({ url: 'http://example.com' }),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({
t: (key: string, arg2?: Record<string, string | number> | string, arg3?: string) =>
typeof arg2 === 'string' || arg2 === undefined ? (arg2 ?? arg3 ?? key) : (arg3 ?? key),
}),
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de' }),
}));
vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ resolved: 'light' }),
}));
import PhotoLightboxScreen from '../screens/PhotoLightboxScreen';
describe('PhotoLightboxScreen', () => {
it('renders lightbox layout', async () => {
render(<PhotoLightboxScreen />);
expect(await screen.findByText('Gallery')).toBeInTheDocument();
expect(await screen.findByText('Like')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,155 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { EventDataProvider } from '../context/EventDataContext';
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('@tamagui/sheet', () => {
const Sheet = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
Sheet.Overlay = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
Sheet.Frame = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
Sheet.Handle = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
return { Sheet };
});
vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
useSearchParams: () => [new URLSearchParams()],
}));
vi.mock('lucide-react', () => ({
Image: () => <span>image</span>,
Filter: () => <span>filter</span>,
Camera: () => <span>camera</span>,
Grid2x2: () => <span>grid</span>,
Zap: () => <span>zap</span>,
UploadCloud: () => <span>upload</span>,
ListVideo: () => <span>list</span>,
RefreshCcw: () => <span>refresh</span>,
FlipHorizontal: () => <span>flip</span>,
X: () => <span>close</span>,
Sparkles: () => <span>sparkles</span>,
Trophy: () => <span>trophy</span>,
Play: () => <span>play</span>,
Share2: () => <span>share</span>,
QrCode: () => <span>qr</span>,
Link: () => <span>link</span>,
Users: () => <span>users</span>,
}));
vi.mock('../components/AppShell', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../context/EventDataContext', () => ({
EventDataProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useEventData: () => ({ token: 'demo', tasksEnabled: true, event: null }),
}));
vi.mock('../services/uploadApi', () => ({
useUploadQueue: () => ({ items: [], loading: false, add: vi.fn() }),
uploadPhoto: vi.fn(),
}));
vi.mock('@/guest/services/pendingUploadsApi', () => ({
fetchPendingUploadsSummary: vi.fn().mockResolvedValue({ items: [], totalCount: 0 }),
}));
vi.mock('../services/photosApi', () => ({
fetchGallery: vi.fn().mockResolvedValue({ data: [], next_cursor: null, latest_photo_at: null, notModified: false }),
}));
vi.mock('../hooks/usePollGalleryDelta', () => ({
usePollGalleryDelta: () => ({ data: { photos: [], latestPhotoAt: null, nextCursor: null }, loading: false, error: null }),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({
t: (key: string, arg2?: Record<string, string | number> | string, arg3?: string) =>
typeof arg2 === 'string' || arg2 === undefined ? (arg2 ?? arg3 ?? key) : (arg3 ?? key),
locale: 'de',
}),
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de', availableLocales: [], setLocale: vi.fn() }),
}));
vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ resolved: 'light' }),
}));
vi.mock('../services/tasksApi', () => ({
fetchTasks: vi.fn().mockResolvedValue([]),
}));
vi.mock('../services/emotionsApi', () => ({
fetchEmotions: vi.fn().mockResolvedValue([]),
}));
vi.mock('@/guest/hooks/useGuestTaskProgress', () => ({
useGuestTaskProgress: () => ({ completedCount: 0 }),
}));
import GalleryScreen from '../screens/GalleryScreen';
import UploadScreen from '../screens/UploadScreen';
import TasksScreen from '../screens/TasksScreen';
import ShareScreen from '../screens/ShareScreen';
describe('Guest v2 screens copy', () => {
it('renders gallery header', () => {
render(
<EventDataProvider tasksEnabledFallback>
<GalleryScreen />
</EventDataProvider>
);
expect(screen.getByText('Gallery')).toBeInTheDocument();
});
it('renders upload preview prompt', () => {
render(
<EventDataProvider tasksEnabledFallback>
<UploadScreen />
</EventDataProvider>
);
expect(screen.getByText('Camera')).toBeInTheDocument();
});
it('renders tasks quest when enabled', () => {
render(
<EventDataProvider tasksEnabledFallback>
<TasksScreen />
</EventDataProvider>
);
expect(screen.getByText('Prompt quest')).toBeInTheDocument();
});
it('renders share hub header', () => {
render(
<EventDataProvider tasksEnabledFallback>
<ShareScreen />
</EventDataProvider>
);
expect(screen.getByText('Invite guests')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,124 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
const updateAppearance = vi.fn();
vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ appearance: 'dark', updateAppearance }),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }),
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de', availableLocales: [], setLocale: vi.fn() }),
}));
vi.mock('../context/EventDataContext', () => ({
useEventData: () => ({ token: 'demo-token' }),
}));
vi.mock('../context/GuestIdentityContext', () => ({
useOptionalGuestIdentity: () => ({ hydrated: false, name: '', setName: vi.fn(), clearName: vi.fn() }),
}));
vi.mock('@/guest/hooks/useHapticsPreference', () => ({
useHapticsPreference: () => ({ enabled: false, setEnabled: vi.fn(), supported: true }),
}));
vi.mock('@/contexts/consent', () => ({
useConsent: () => ({ preferences: { analytics: false }, savePreferences: vi.fn() }),
}));
vi.mock('react-router-dom', () => ({
Link: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({
children,
onPress,
...rest
}: {
children: React.ReactNode;
onPress?: () => void;
}) => (
<button type="button" onClick={onPress} {...rest}>
{children}
</button>
),
}));
vi.mock('@tamagui/input', () => ({
Input: ({
value,
onChange,
onChangeText,
...rest
}: React.InputHTMLAttributes<HTMLInputElement> & { onChangeText?: (next: string) => void }) => (
<input
value={value}
onChange={(event) => {
onChange?.(event);
onChangeText?.(event.target.value);
}}
readOnly={!onChange && !onChangeText}
{...rest}
/>
),
}));
vi.mock('@tamagui/card', () => ({
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/switch', () => ({
Switch: Object.assign(
({
checked,
onCheckedChange,
'aria-label': ariaLabel,
children,
}: {
checked?: boolean;
onCheckedChange?: (next: boolean) => void;
'aria-label'?: string;
children?: React.ReactNode;
}) => (
<label>
<input
type="checkbox"
aria-label={ariaLabel}
checked={checked}
onChange={(event) => onCheckedChange?.(event.target.checked)}
/>
{children}
</label>
),
{ Thumb: ({ children }: { children?: React.ReactNode }) => <span>{children}</span> },
),
}));
import SettingsContent from '../components/SettingsContent';
describe('SettingsContent', () => {
it('toggles appearance mode', () => {
render(<SettingsContent />);
const toggle = screen.getByLabelText('Dark mode');
fireEvent.click(toggle);
expect(updateAppearance).toHaveBeenCalledWith('light');
});
});

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }),
}));
vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ resolved: 'dark' }),
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de' }),
}));
vi.mock('@/guest/components/legal-markdown', () => ({
LegalMarkdown: () => <div>Legal markdown</div>,
}));
vi.mock('@tamagui/scroll-view', () => ({
ScrollView: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('lucide-react', () => ({
X: () => <span>x</span>,
}));
vi.mock('../components/SettingsContent', () => ({
default: () => <div>Settings content</div>,
}));
import SettingsSheet from '../components/SettingsSheet';
describe('SettingsSheet', () => {
it('renders settings content inside the sheet', () => {
render(<SettingsSheet open onOpenChange={vi.fn()} />);
expect(screen.getByText('Settings content')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { EventDataProvider } from '../context/EventDataContext';
vi.mock('framer-motion', () => ({
AnimatePresence: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
motion: { div: ({ children }: { children: React.ReactNode }) => <div>{children}</div> },
}));
vi.mock('../services/photosApi', () => ({
fetchGallery: () => Promise.resolve({ data: [] }),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key, locale: 'de' }),
}));
import SlideshowScreen from '../screens/SlideshowScreen';
describe('SlideshowScreen', () => {
it('shows empty state when no photos', async () => {
render(
<EventDataProvider token="token">
<SlideshowScreen />
</EventDataProvider>
);
expect(await screen.findByText('Noch keine Fotos')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,60 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { EventDataProvider } from '../context/EventDataContext';
vi.mock('react-router-dom', () => ({
useParams: () => ({ taskId: '12' }),
useNavigate: () => vi.fn(),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('../components/AppShell', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/SurfaceCard', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../services/tasksApi', () => ({
fetchTasks: () => Promise.resolve([{ id: 12, title: 'Capture the dancefloor', description: 'Find the happiest crew.' }]),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key, locale: 'de' }),
}));
vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ resolved: 'light' }),
}));
import TaskDetailScreen from '../screens/TaskDetailScreen';
describe('TaskDetailScreen', () => {
it('renders task title', async () => {
render(
<EventDataProvider token="token">
<TaskDetailScreen />
</EventDataProvider>
);
expect(await screen.findByText('Capture the dancefloor')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,72 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('../components/AppShell', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/SurfaceCard', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../services/uploadApi', () => ({
useUploadQueue: () => ({
items: [],
loading: false,
retryAll: vi.fn(),
clearFinished: vi.fn(),
refresh: vi.fn(),
}),
}));
vi.mock('../context/EventDataContext', () => ({
useEventData: () => ({ token: 'token' }),
}));
vi.mock('@/guest/services/pendingUploadsApi', () => ({
fetchPendingUploadsSummary: vi.fn().mockResolvedValue({ items: [], totalCount: 0 }),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({
t: (key: string, arg2?: Record<string, string | number> | string, arg3?: string) =>
typeof arg2 === 'string' || arg2 === undefined ? (arg2 ?? arg3 ?? key) : (arg3 ?? key),
locale: 'de',
}),
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de' }),
}));
vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ resolved: 'light' }),
}));
import UploadQueueScreen from '../screens/UploadQueueScreen';
describe('UploadQueueScreen', () => {
it('renders empty queue state', () => {
render(<UploadQueueScreen />);
expect(screen.getByText('Uploads')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,87 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { EventDataProvider } from '../context/EventDataContext';
vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
useSearchParams: () => [new URLSearchParams('taskId=12')],
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('../components/AppShell', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../services/uploadApi', () => ({
uploadPhoto: vi.fn(),
useUploadQueue: () => ({ items: [], add: vi.fn() }),
}));
vi.mock('../services/tasksApi', () => ({
fetchTasks: vi.fn().mockResolvedValue([{ id: 12, title: 'Capture the dancefloor', description: 'Find the happiest crew.' }]),
}));
vi.mock('@/guest/services/pendingUploadsApi', () => ({
fetchPendingUploadsSummary: vi.fn().mockResolvedValue({ items: [], totalCount: 0 }),
}));
vi.mock('../context/GuestIdentityContext', () => ({
useOptionalGuestIdentity: () => ({ name: 'Alex' }),
}));
vi.mock('@/guest/hooks/useGuestTaskProgress', () => ({
useGuestTaskProgress: () => ({ markCompleted: vi.fn() }),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({
t: (_key: string, arg2?: Record<string, string | number> | string, arg3?: string) =>
typeof arg2 === 'string' || arg2 === undefined ? (arg2 ?? arg3 ?? _key) : (arg3 ?? _key),
locale: 'en',
}),
}));
vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ resolved: 'light' }),
}));
import UploadScreen from '../screens/UploadScreen';
describe('UploadScreen', () => {
it('renders queue entry point', () => {
render(
<EventDataProvider token="token">
<UploadScreen />
</EventDataProvider>
);
expect(screen.getByText('Queue')).toBeInTheDocument();
});
it('renders task summary when taskId is present', async () => {
render(
<EventDataProvider token="token">
<UploadScreen />
</EventDataProvider>
);
expect(await screen.findByText('Capture the dancefloor')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,74 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { resolveGuestThemeName } from '../lib/brandingTheme';
import type { EventBranding } from '@/guest/types/event-branding';
const baseBranding: EventBranding = {
primaryColor: '#FF5A5F',
secondaryColor: '#F43F5E',
backgroundColor: '#ffffff',
fontFamily: 'Inter',
logoUrl: null,
palette: {
primary: '#FF5A5F',
secondary: '#F43F5E',
background: '#ffffff',
surface: '#ffffff',
},
typography: {
heading: 'Inter',
body: 'Inter',
sizePreset: 'm',
},
mode: 'auto',
};
const originalMatchMedia = window.matchMedia;
function mockMatchMedia(matches: boolean) {
window.matchMedia = ((query: string) => ({
matches,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as typeof window.matchMedia;
}
describe('resolveGuestThemeName', () => {
beforeEach(() => {
mockMatchMedia(false);
});
afterEach(() => {
window.matchMedia = originalMatchMedia;
});
it('uses branding mode overrides', () => {
expect(resolveGuestThemeName({ ...baseBranding, mode: 'dark' }, 'light')).toBe('guestNight');
expect(resolveGuestThemeName({ ...baseBranding, mode: 'light' }, 'dark')).toBe('guestLight');
});
it('respects explicit appearance when mode is auto', () => {
expect(resolveGuestThemeName({ ...baseBranding, mode: 'auto' }, 'dark')).toBe('guestNight');
expect(resolveGuestThemeName({ ...baseBranding, mode: 'auto' }, 'light')).toBe('guestLight');
});
it('falls back to background luminance when appearance is system', () => {
const darkBackground = { ...baseBranding, backgroundColor: '#0a0f1f' };
expect(resolveGuestThemeName(darkBackground, 'system')).toBe('guestNight');
const lightBackground = { ...baseBranding, backgroundColor: '#fdf9f4' };
expect(resolveGuestThemeName(lightBackground, 'system')).toBe('guestLight');
});
it('uses system preference when background is neutral', () => {
const neutralBackground = { ...baseBranding, backgroundColor: '#b0b0b0' };
mockMatchMedia(true);
expect(resolveGuestThemeName(neutralBackground, 'system')).toBe('guestNight');
mockMatchMedia(false);
expect(resolveGuestThemeName(neutralBackground, 'system')).toBe('guestLight');
});
});

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { mapEventBranding } from '../lib/eventBranding';
describe('mapEventBranding', () => {
it('maps palette, typography, and buttons from payload', () => {
const result = mapEventBranding({
primary_color: '#112233',
secondary_color: '#445566',
background_color: '#000000',
font_family: 'Event Body',
heading_font: 'Event Heading',
button_radius: 16,
button_primary_color: '#abcdef',
palette: {
surface: '#111111',
},
typography: {
size: 'l',
},
});
expect(result?.primaryColor).toBe('#112233');
expect(result?.secondaryColor).toBe('#445566');
expect(result?.palette?.surface).toBe('#111111');
expect(result?.typography?.heading).toBe('Event Heading');
expect(result?.typography?.body).toBe('Event Body');
expect(result?.typography?.sizePreset).toBe('l');
expect(result?.buttons?.radius).toBe(16);
expect(result?.buttons?.primary).toBe('#abcdef');
});
});

View File

@@ -0,0 +1,33 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { fetchEventStats, clearStatsCache } from '../services/statsApi';
const fetchMock = vi.fn();
global.fetch = fetchMock as unknown as typeof fetch;
describe('fetchEventStats', () => {
beforeEach(() => {
fetchMock.mockReset();
clearStatsCache();
});
afterEach(() => {
clearStatsCache();
});
it('returns cached stats on 304', async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ online_guests: 4, tasks_solved: 1, latest_photo_at: '2024-01-01T00:00:00Z' }), {
status: 200,
headers: { ETag: '"demo"' },
})
);
const first = await fetchEventStats('demo');
expect(first.onlineGuests).toBe(4);
fetchMock.mockResolvedValueOnce(new Response(null, { status: 304, headers: { ETag: '"demo"' } }));
const second = await fetchEventStats('demo');
expect(second).toEqual(first);
});
});

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { YStack } from '@tamagui/stacks';
import { useAppearance } from '@/hooks/use-appearance';
type AmbientBackgroundProps = {
children: React.ReactNode;
};
export default function AmbientBackground({ children }: AmbientBackgroundProps) {
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
return (
<YStack
flex={1}
position="relative"
style={{
backgroundImage: isDark
? 'radial-gradient(circle at 15% 10%, rgba(255, 79, 216, 0.2), transparent 48%), radial-gradient(circle at 90% 20%, rgba(79, 209, 255, 0.18), transparent 40%), linear-gradient(180deg, rgba(6, 10, 22, 0.96), rgba(10, 15, 31, 1))'
: 'radial-gradient(circle at 15% 10%, color-mix(in oklab, var(--guest-primary, #FF5A5F) 28%, white), transparent 48%), radial-gradient(circle at 90% 20%, color-mix(in oklab, var(--guest-secondary, #F43F5E) 24%, white), transparent 40%), linear-gradient(180deg, var(--guest-background, #FFF8F5), color-mix(in oklab, var(--guest-background, #FFF8F5) 85%, white))',
backgroundSize: '140% 140%, 140% 140%, 100% 100%',
animation: 'guestNightAmbientDrift 18s ease-in-out infinite',
}}
>
{children}
</YStack>
);
}

View File

@@ -0,0 +1,236 @@
import React from 'react';
import { YStack } from '@tamagui/stacks';
import { Trophy, UploadCloud, Sparkles, Cast, Share2, Compass, Image, Camera, Settings, Home } from 'lucide-react';
import { useLocation, useNavigate } from 'react-router-dom';
import TopBar from './TopBar';
import BottomDock from './BottomDock';
import FloatingActionButton from './FloatingActionButton';
import FabActionSheet from './FabActionSheet';
import CompassHub, { type CompassAction } from './CompassHub';
import AmbientBackground from './AmbientBackground';
import NotificationSheet from './NotificationSheet';
import SettingsSheet from './SettingsSheet';
import GuestAnalyticsNudge from './GuestAnalyticsNudge';
import { useEventData } from '../context/EventDataContext';
import { buildEventPath } from '../lib/routes';
import { useOptionalNotificationCenter } from '@/guest/context/NotificationCenterContext';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useAppearance } from '@/hooks/use-appearance';
type AppShellProps = {
children: React.ReactNode;
};
export default function AppShell({ children }: AppShellProps) {
const [sheetOpen, setSheetOpen] = React.useState(false);
const [compassOpen, setCompassOpen] = React.useState(false);
const [notificationsOpen, setNotificationsOpen] = React.useState(false);
const [settingsOpen, setSettingsOpen] = React.useState(false);
const { tasksEnabled, event, token } = useEventData();
const notificationCenter = useOptionalNotificationCenter();
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation();
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const actionIconColor = isDark ? '#F8FAFF' : '#0F172A';
const matomoEnabled = typeof window !== 'undefined' && Boolean((window as any).__MATOMO_GUEST__?.enabled);
const showFab = !/\/photo\/\d+/.test(location.pathname);
const goTo = (path: string) => () => {
setSheetOpen(false);
setCompassOpen(false);
setNotificationsOpen(false);
setSettingsOpen(false);
navigate(buildEventPath(token, path));
};
const openSheet = () => {
setCompassOpen(false);
setNotificationsOpen(false);
setSettingsOpen(false);
setSheetOpen(true);
};
const openCompass = () => {
setSheetOpen(false);
setNotificationsOpen(false);
setSettingsOpen(false);
setCompassOpen(true);
};
const actions = [
{
key: 'upload',
label: t('appShell.actions.upload.label', 'Upload / Take photo'),
description: t('appShell.actions.upload.description', 'Add a moment from your device or camera.'),
icon: <UploadCloud size={18} color={actionIconColor} />,
onPress: goTo('/upload'),
},
{
key: 'compass',
label: t('appShell.actions.compass.label', 'Compass hub'),
description: t('appShell.actions.compass.description', 'Quick jump to key areas.'),
icon: <Compass size={18} color={actionIconColor} />,
onPress: () => {
setSheetOpen(false);
openCompass();
},
},
tasksEnabled
? {
key: 'task',
label: t('appShell.actions.task.label', 'Start a task'),
description: t('appShell.actions.task.description', 'Pick a challenge and capture it now.'),
icon: <Sparkles size={18} color={actionIconColor} />,
onPress: goTo('/tasks'),
}
: null,
{
key: 'live',
label: t('appShell.actions.live.label', 'Live show'),
description: t('appShell.actions.live.description', 'See the real-time highlight stream.'),
icon: <Cast size={18} color={actionIconColor} />,
onPress: () => {
setSheetOpen(false);
setCompassOpen(false);
setNotificationsOpen(false);
setSettingsOpen(false);
if (token) {
navigate(`/show/${encodeURIComponent(token)}`);
}
},
},
{
key: 'slideshow',
label: t('appShell.actions.slideshow.label', 'Slideshow'),
description: t('appShell.actions.slideshow.description', 'Lean back and watch the gallery roll.'),
icon: <Image size={18} color={actionIconColor} />,
onPress: goTo('/slideshow'),
},
{
key: 'share',
label: t('appShell.actions.share.label', 'Share invite'),
description: t('appShell.actions.share.description', 'Send the event link or QR code.'),
icon: <Share2 size={18} color={actionIconColor} />,
onPress: goTo('/share'),
},
tasksEnabled
? {
key: 'achievements',
label: t('appShell.actions.achievements.label', 'Achievements'),
description: t('appShell.actions.achievements.description', 'Track your photo streaks.'),
icon: <Trophy size={18} color={actionIconColor} />,
onPress: goTo('/achievements'),
}
: null,
].filter(Boolean) as Array<{
key: string;
label: string;
description: string;
icon: React.ReactNode;
onPress?: () => void;
}>;
const compassQuadrants: [CompassAction, CompassAction, CompassAction, CompassAction] = [
{
key: 'home',
label: t('navigation.home', 'Home'),
icon: <Home size={18} color={actionIconColor} />,
onPress: goTo('/'),
},
{
key: 'gallery',
label: t('navigation.gallery', 'Gallery'),
icon: <Image size={18} color={actionIconColor} />,
onPress: goTo('/gallery'),
},
tasksEnabled
? {
key: 'tasks',
label: t('navigation.tasks', 'Tasks'),
icon: <Sparkles size={18} color={actionIconColor} />,
onPress: goTo('/tasks'),
}
: {
key: 'settings',
label: t('settings.title', 'Settings'),
icon: <Settings size={18} color={actionIconColor} />,
onPress: goTo('/settings'),
},
{
key: 'share',
label: t('navigation.share', 'Share'),
icon: <Share2 size={18} color={actionIconColor} />,
onPress: goTo('/share'),
},
];
return (
<AmbientBackground>
<YStack minHeight="100vh" position="relative">
<YStack
position="fixed"
top={0}
left={0}
right={0}
zIndex={1000}
style={{
backgroundColor: isDark ? 'rgba(10, 14, 28, 0.72)' : 'rgba(255, 255, 255, 0.85)',
backdropFilter: 'saturate(160%) blur(18px)',
WebkitBackdropFilter: 'saturate(160%) blur(18px)',
}}
>
<TopBar
eventName={event?.name ?? t('galleryPage.hero.eventFallback', 'Event')}
onProfilePress={() => {
setNotificationsOpen(false);
setSheetOpen(false);
setCompassOpen(false);
setSettingsOpen(true);
}}
onNotificationsPress={() => {
setSettingsOpen(false);
setSheetOpen(false);
setCompassOpen(false);
setNotificationsOpen(true);
}}
notificationCount={notificationCenter?.unreadCount ?? 0}
/>
</YStack>
<YStack
flex={1}
padding="$4"
gap="$4"
position="relative"
zIndex={1}
style={{ paddingTop: '88px', paddingBottom: '128px' }}
>
{children}
</YStack>
{showFab ? <FloatingActionButton onPress={openSheet} onLongPress={openCompass} /> : null}
<BottomDock />
<FabActionSheet
open={sheetOpen}
onOpenChange={(next) => setSheetOpen(next)}
title={t('appShell.fab.title', 'Create a moment')}
actions={actions}
/>
<CompassHub
open={compassOpen}
onOpenChange={setCompassOpen}
centerAction={{
key: 'capture',
label: t('appShell.compass.capture', 'Capture'),
icon: <Camera size={18} color="white" />,
onPress: goTo('/upload'),
}}
quadrants={compassQuadrants}
/>
<NotificationSheet open={notificationsOpen} onOpenChange={setNotificationsOpen} />
<SettingsSheet open={settingsOpen} onOpenChange={setSettingsOpen} />
<GuestAnalyticsNudge enabled={matomoEnabled} pathname={location.pathname} />
</YStack>
</AmbientBackground>
);
}

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { XStack, YStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Home, Image, Share2 } from 'lucide-react';
import { useEventData } from '../context/EventDataContext';
import { buildEventPath } from '../lib/routes';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useAppearance } from '@/hooks/use-appearance';
export default function BottomDock() {
const location = useLocation();
const navigate = useNavigate();
const { token } = useEventData();
const { t } = useTranslation();
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const dockItems = [
{ key: 'home', label: t('navigation.home', 'Home'), path: '/', icon: Home },
{ key: 'gallery', label: t('navigation.gallery', 'Gallery'), path: '/gallery', icon: Image },
{ key: 'share', label: t('navigation.share', 'Share'), path: '/share', icon: Share2 },
];
const activeIconColor = isDark ? '#F8FAFF' : '#0F172A';
const inactiveIconColor = isDark ? '#94A3B8' : '#64748B';
return (
<XStack
position="fixed"
left={0}
right={0}
bottom={0}
zIndex={1000}
paddingHorizontal="$4"
paddingBottom="$3"
paddingTop="$2"
alignItems="flex-end"
justifyContent="space-between"
borderTopWidth={1}
borderColor="rgba(255, 255, 255, 0.08)"
style={{
paddingBottom: 'calc(12px + env(safe-area-inset-bottom))',
backgroundColor: isDark ? 'rgba(10, 14, 28, 0.85)' : 'rgba(255, 255, 255, 0.9)',
backdropFilter: 'saturate(160%) blur(18px)',
WebkitBackdropFilter: 'saturate(160%) blur(18px)',
}}
>
{dockItems.map((item) => {
const targetPath = buildEventPath(token, item.path);
const active = location.pathname === targetPath || (item.path !== '/' && location.pathname.startsWith(targetPath));
const Icon = item.icon;
return (
<Button
key={item.key}
unstyled
onPress={() => navigate(targetPath)}
padding="$2"
borderRadius="$pill"
backgroundColor={active ? '$surface' : 'transparent'}
borderWidth={active ? 1 : 0}
borderColor={active ? '$borderColor' : 'transparent'}
>
<YStack alignItems="center" gap="$1">
<Icon size={18} color={active ? activeIconColor : inactiveIconColor} />
<Text fontSize="$1" color={active ? '$color' : '$color'} opacity={active ? 1 : 0.6}>
{item.label}
</Text>
</YStack>
</Button>
);
})}
</XStack>
);
}

View File

@@ -0,0 +1,150 @@
import React from 'react';
import { Sheet } from '@tamagui/sheet';
import { YStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { useAppearance } from '@/hooks/use-appearance';
export type CompassAction = {
key: string;
label: string;
icon?: React.ReactNode;
onPress?: () => void;
};
type CompassHubProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
quadrants: [CompassAction, CompassAction, CompassAction, CompassAction];
centerAction: CompassAction;
title?: string;
};
const quadrantPositions: Array<{
top?: number;
right?: number;
bottom?: number;
left?: number;
}> = [
{ top: 0, left: 0 },
{ top: 0, right: 0 },
{ bottom: 0, left: 0 },
{ bottom: 0, right: 0 },
];
export default function CompassHub({
open,
onOpenChange,
quadrants,
centerAction,
title = 'Quick jump',
}: CompassHubProps) {
const close = () => onOpenChange(false);
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
if (!open) {
return null;
}
return (
<Sheet
modal
open={open}
onOpenChange={onOpenChange}
snapPoints={[100]}
snapPointsMode="percent"
dismissOnOverlayPress
dismissOnSnapToBottom
zIndex={100000}
>
<Sheet.Overlay
{...({
backgroundColor: isDark ? 'rgba(15, 23, 42, 0.55)' : 'rgba(15, 23, 42, 0.22)',
pointerEvents: 'auto',
onClick: close,
onMouseDown: close,
onTouchStart: close,
} as any)}
/>
<Sheet.Frame
{...({
width: '100%',
height: '100%',
alignSelf: 'center',
backgroundColor: 'transparent',
padding: 24,
pointerEvents: 'box-none',
} as any)}
>
<YStack
position="absolute"
top={0}
right={0}
bottom={0}
left={0}
pointerEvents="auto"
onPress={close}
onClick={close}
onTouchStart={close}
/>
<YStack flex={1} alignItems="center" justifyContent="center" gap="$3" pointerEvents="auto">
<Text fontSize="$5" fontFamily="$display" fontWeight="$8" color="$color">
{title}
</Text>
<YStack width={280} height={280} position="relative" className="guest-compass-flyin">
{quadrants.map((action, index) => (
<Button
key={action.key}
onPress={() => {
action.onPress?.();
close();
}}
width={120}
height={120}
borderRadius={24}
backgroundColor="$surface"
borderWidth={1}
borderColor="$borderColor"
position="absolute"
{...quadrantPositions[index]}
>
<YStack alignItems="center" gap="$2">
{action.icon}
<Text fontSize="$3" fontWeight="$7">
{action.label}
</Text>
</YStack>
</Button>
))}
<Button
onPress={() => {
centerAction.onPress?.();
close();
}}
width={90}
height={90}
borderRadius={45}
backgroundColor="$primary"
position="absolute"
top="50%"
left="50%"
style={{ transform: 'translate(-45px, -45px)' }}
>
<YStack alignItems="center" gap="$1">
{centerAction.icon}
<Text fontSize="$2" fontWeight="$7" color="white">
{centerAction.label}
</Text>
</YStack>
</Button>
</YStack>
<Text fontSize="$2" color="$color" opacity={0.6}>
Tap outside to close
</Text>
</YStack>
</Sheet.Frame>
</Sheet>
);
}

View File

@@ -0,0 +1,105 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Sheet } from '@tamagui/sheet';
import { useAppearance } from '@/hooks/use-appearance';
export type FabAction = {
key: string;
label: string;
description?: string;
icon?: React.ReactNode;
onPress?: () => void;
};
type FabActionSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
actions: FabAction[];
};
export default function FabActionSheet({ open, onOpenChange, title, actions }: FabActionSheetProps) {
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
if (!open) {
return null;
}
return (
<Sheet
modal
open={open}
onOpenChange={onOpenChange}
snapPoints={[70]}
snapPointsMode="percent"
dismissOnOverlayPress
dismissOnSnapToBottom
zIndex={100000}
>
<Sheet.Overlay {...({ backgroundColor: isDark ? 'rgba(15, 23, 42, 0.45)' : 'rgba(15, 23, 42, 0.2)' } as any)} />
<Sheet.Frame
{...({
width: '100%',
maxWidth: 560,
alignSelf: 'center',
borderTopLeftRadius: 28,
borderTopRightRadius: 28,
backgroundColor: '$surface',
padding: 20,
shadowColor: isDark ? 'rgba(15, 23, 42, 0.25)' : 'rgba(15, 23, 42, 0.12)',
shadowOpacity: 0.2,
shadowRadius: 20,
shadowOffset: { width: 0, height: -6 },
} as any)}
style={{ marginBottom: 'calc(16px + env(safe-area-inset-bottom))' }}
>
<Sheet.Handle height={5} width={52} backgroundColor="#CBD5E1" borderRadius={999} marginBottom="$3" />
<YStack gap="$3">
<Text fontSize="$6" fontFamily="$display" fontWeight="$8">
{title}
</Text>
<YStack gap="$2">
{actions.map((action) => (
<Button
key={action.key}
onPress={action.onPress}
backgroundColor="$surface"
borderRadius="$card"
borderWidth={1}
borderColor="$borderColor"
padding="$3"
justifyContent="flex-start"
>
<XStack alignItems="center" gap="$3">
<YStack
width={40}
height={40}
alignItems="center"
justifyContent="center"
borderRadius={999}
backgroundColor="$accentSoft"
>
{action.icon ? action.icon : null}
</YStack>
<YStack gap="$1" flex={1}>
<Text fontSize="$4" fontWeight="$7">
{action.label}
</Text>
{action.description ? (
<Text fontSize="$2" color="$color" opacity={0.6}>
{action.description}
</Text>
) : null}
</YStack>
</XStack>
</Button>
))}
</YStack>
</YStack>
</Sheet.Frame>
</Sheet>
);
}

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { Button } from '@tamagui/button';
import { Plus } from 'lucide-react';
import { useAppearance } from '@/hooks/use-appearance';
type FloatingActionButtonProps = {
onPress: () => void;
onLongPress?: () => void;
};
export default function FloatingActionButton({ onPress, onLongPress }: FloatingActionButtonProps) {
const longPressTriggered = React.useRef(false);
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
return (
<Button
onPress={() => {
if (longPressTriggered.current) {
longPressTriggered.current = false;
return;
}
onPress();
}}
onPressIn={() => {
longPressTriggered.current = false;
}}
onLongPress={() => {
longPressTriggered.current = true;
onLongPress?.();
}}
position="fixed"
bottom={88}
right={20}
zIndex={1100}
width={56}
height={56}
borderRadius={999}
backgroundColor="$primary"
borderWidth={0}
elevation={4}
shadowColor={isDark ? 'rgba(255, 79, 216, 0.5)' : 'rgba(15, 23, 42, 0.2)'}
shadowOpacity={0.5}
shadowRadius={18}
shadowOffset={{ width: 0, height: 10 }}
style={{
boxShadow: isDark
? '0 18px 36px rgba(255, 79, 216, 0.35), 0 0 0 6px rgba(255, 79, 216, 0.15)'
: '0 16px 28px rgba(15, 23, 42, 0.18), 0 0 0 6px rgba(255, 255, 255, 0.7)',
}}
>
<Plus size={22} color="white" />
</Button>
);
}

View File

@@ -0,0 +1,267 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { useConsent } from '@/contexts/consent';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { isUploadPath, shouldShowAnalyticsNudge } from '@/guest/lib/analyticsConsent';
import { useAppearance } from '@/hooks/use-appearance';
const PROMPT_STORAGE_KEY = 'fotospiel.guest.analyticsPrompt';
const SNOOZE_MS = 60 * 60 * 1000;
const ACTIVE_IDLE_LIMIT_MS = 20_000;
type PromptStorage = {
snoozedUntil?: number | null;
};
function readSnoozedUntil(): number | null {
if (typeof window === 'undefined') {
return null;
}
try {
const raw = window.localStorage.getItem(PROMPT_STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as PromptStorage;
return typeof parsed.snoozedUntil === 'number' ? parsed.snoozedUntil : null;
} catch {
return null;
}
}
function writeSnoozedUntil(value: number | null) {
if (typeof window === 'undefined') {
return;
}
try {
const payload: PromptStorage = { snoozedUntil: value };
window.localStorage.setItem(PROMPT_STORAGE_KEY, JSON.stringify(payload));
} catch {
// ignore storage failures
}
}
function randomInt(min: number, max: number): number {
const low = Math.ceil(min);
const high = Math.floor(max);
return Math.floor(Math.random() * (high - low + 1)) + low;
}
export default function GuestAnalyticsNudge({
enabled,
pathname,
}: {
enabled: boolean;
pathname: string;
}) {
const { t } = useTranslation();
const { decisionMade, preferences, savePreferences } = useConsent();
const analyticsConsent = Boolean(preferences?.analytics);
const [thresholdSeconds] = React.useState(() => randomInt(60, 120));
const [thresholdRoutes] = React.useState(() => randomInt(2, 3));
const [activeSeconds, setActiveSeconds] = React.useState(0);
const [routeCount, setRouteCount] = React.useState(0);
const [isOpen, setIsOpen] = React.useState(false);
const [snoozedUntil, setSnoozedUntil] = React.useState<number | null>(() => readSnoozedUntil());
const lastPathRef = React.useRef(pathname);
const lastActivityAtRef = React.useRef(Date.now());
const visibleRef = React.useRef(typeof document === 'undefined' ? true : document.visibilityState === 'visible');
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const isUpload = isUploadPath(pathname);
React.useEffect(() => {
const previousPath = lastPathRef.current;
const currentPath = pathname;
lastPathRef.current = currentPath;
if (previousPath === currentPath) {
return;
}
if (isUploadPath(previousPath) || isUploadPath(currentPath)) {
return;
}
setRouteCount((count) => count + 1);
}, [pathname]);
React.useEffect(() => {
if (typeof window === 'undefined') {
return undefined;
}
const handleActivity = () => {
lastActivityAtRef.current = Date.now();
};
const events: Array<keyof WindowEventMap> = [
'pointerdown',
'pointermove',
'keydown',
'scroll',
'touchstart',
];
events.forEach((event) => window.addEventListener(event, handleActivity, { passive: true }));
return () => {
events.forEach((event) => window.removeEventListener(event, handleActivity));
};
}, []);
React.useEffect(() => {
if (typeof document === 'undefined') {
return undefined;
}
const handleVisibility = () => {
visibleRef.current = document.visibilityState === 'visible';
};
document.addEventListener('visibilitychange', handleVisibility);
return () => document.removeEventListener('visibilitychange', handleVisibility);
}, []);
React.useEffect(() => {
if (typeof window === 'undefined') {
return undefined;
}
const interval = window.setInterval(() => {
const now = Date.now();
if (!visibleRef.current) {
return;
}
if (isUploadPath(lastPathRef.current)) {
return;
}
if (now - lastActivityAtRef.current > ACTIVE_IDLE_LIMIT_MS) {
return;
}
setActiveSeconds((seconds) => seconds + 1);
}, 1000);
return () => window.clearInterval(interval);
}, []);
React.useEffect(() => {
if (!enabled || analyticsConsent || decisionMade) {
setIsOpen(false);
return;
}
const shouldOpen = shouldShowAnalyticsNudge({
decisionMade,
analyticsConsent,
snoozedUntil,
now: Date.now(),
activeSeconds,
routeCount,
thresholdSeconds,
thresholdRoutes,
isUpload,
});
if (shouldOpen) {
setIsOpen(true);
}
}, [
enabled,
analyticsConsent,
decisionMade,
snoozedUntil,
activeSeconds,
routeCount,
thresholdSeconds,
thresholdRoutes,
isUpload,
]);
React.useEffect(() => {
if (isUpload) {
setIsOpen(false);
}
}, [isUpload]);
if (!enabled || decisionMade || analyticsConsent || !isOpen || isUpload) {
return null;
}
const handleSnooze = () => {
const until = Date.now() + SNOOZE_MS;
setSnoozedUntil(until);
writeSnoozedUntil(until);
setIsOpen(false);
};
const handleAllow = () => {
savePreferences({ analytics: true });
writeSnoozedUntil(null);
setIsOpen(false);
};
return (
<YStack
position="fixed"
left={0}
right={0}
zIndex={1400}
pointerEvents="none"
paddingHorizontal="$4"
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 96px)' }}
>
<YStack
pointerEvents="auto"
marginHorizontal="auto"
maxWidth={560}
borderRadius="$6"
padding="$4"
borderWidth={1}
borderColor={isDark ? 'rgba(148, 163, 184, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.96)' : 'rgba(255, 255, 255, 0.96)'}
style={{ backdropFilter: 'blur(16px)' }}
>
<XStack flexWrap="wrap" gap="$3" alignItems="center" justifyContent="space-between">
<YStack gap="$1" flexShrink={1} minWidth={220}>
<Text fontSize="$4" fontWeight="$7" color={isDark ? '#F8FAFF' : '#0F172A'}>
{t('consent.analytics.title')}
</Text>
<Text fontSize="$2" color={isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)'}>
{t('consent.analytics.body')}
</Text>
</YStack>
<XStack gap="$2" flexWrap="wrap">
<Button
size="$2"
borderRadius="$pill"
backgroundColor={isDark ? 'rgba(248, 250, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'}
borderColor={isDark ? 'rgba(248, 250, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
borderWidth={1}
onPress={handleSnooze}
>
<Text fontSize="$2" fontWeight="$6" color={isDark ? '#F8FAFF' : '#0F172A'}>
{t('consent.analytics.later')}
</Text>
</Button>
<Button size="$2" borderRadius="$pill" backgroundColor="$primary" onPress={handleAllow}>
<Text fontSize="$2" fontWeight="$6" color="#FFFFFF">
{t('consent.analytics.allow')}
</Text>
</Button>
</XStack>
</XStack>
</YStack>
</YStack>
);
}

View File

@@ -0,0 +1,200 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { ScrollView } from '@tamagui/scroll-view';
import { X } from 'lucide-react';
import { useOptionalNotificationCenter } from '@/guest/context/NotificationCenterContext';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useAppearance } from '@/hooks/use-appearance';
type NotificationSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
export default function NotificationSheet({ open, onOpenChange }: NotificationSheetProps) {
const { t } = useTranslation();
const center = useOptionalNotificationCenter();
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const mutedButton = isDark ? 'rgba(248, 250, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
const mutedButtonBorder = isDark ? 'rgba(248, 250, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
const notifications = center?.notifications ?? [];
const unreadCount = center?.unreadCount ?? 0;
const uploadCount = (center?.queueCount ?? 0) + (center?.pendingCount ?? 0);
return (
<>
<YStack
position="fixed"
top={0}
right={0}
bottom={0}
left={0}
zIndex={1200}
pointerEvents={open ? 'auto' : 'none'}
style={{
backgroundColor: isDark ? 'rgba(15, 23, 42, 0.45)' : 'rgba(15, 23, 42, 0.2)',
opacity: open ? 1 : 0,
transition: 'opacity 240ms ease',
}}
onPress={() => onOpenChange(false)}
onClick={() => onOpenChange(false)}
onMouseDown={() => onOpenChange(false)}
onTouchStart={() => onOpenChange(false)}
/>
<YStack
position="fixed"
left={0}
right={0}
bottom={0}
zIndex={1300}
padding="$4"
backgroundColor={isDark ? '#0B101E' : '#FFFFFF'}
borderTopLeftRadius="$6"
borderTopRightRadius="$6"
pointerEvents={open ? 'auto' : 'none'}
style={{
transform: open ? 'translateY(0)' : 'translateY(100%)',
opacity: open ? 1 : 0,
transition: 'transform 320ms cubic-bezier(0.22, 0.61, 0.36, 1), opacity 220ms ease',
maxHeight: '82vh',
paddingBottom: 'calc(16px + env(safe-area-inset-bottom))',
}}
>
<YStack
width={52}
height={5}
borderRadius={999}
marginBottom="$3"
alignSelf="center"
style={{ backgroundColor: isDark ? 'rgba(148, 163, 184, 0.6)' : '#CBD5E1' }}
/>
<XStack alignItems="center" justifyContent="space-between" marginBottom="$3">
<YStack gap="$1">
<Text fontSize="$6" fontFamily="$display" fontWeight="$8" color={isDark ? '#F8FAFF' : '#0F172A'}>
{t('header.notifications.title', 'Updates')}
</Text>
<Text color={isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)'}>
{unreadCount > 0
? t('header.notifications.unread', { count: unreadCount }, '{count} neu')
: t('header.notifications.allRead', 'Alles gelesen')}
</Text>
</YStack>
<Button
size="$3"
circular
backgroundColor={mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
onPress={() => onOpenChange(false)}
aria-label="Close notifications"
>
<X size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
</XStack>
<ScrollView flex={1} showsVerticalScrollIndicator={false}>
<YStack gap="$4" paddingBottom="$2">
{center ? (
<XStack gap="$3" flexWrap="wrap">
<InfoBadge label={t('header.notifications.tabUploads', 'Uploads')} value={uploadCount} />
<InfoBadge label={t('header.notifications.tabUnread', 'Nachrichten')} value={unreadCount} />
</XStack>
) : null}
{center?.loading ? (
<Text color={isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)'}>
{t('common.actions.loading', 'Loading...')}
</Text>
) : notifications.length === 0 ? (
<YStack gap="$1">
<Text color={isDark ? '#F8FAFF' : '#0F172A'} fontSize="$5" fontWeight="$7">
{t('header.notifications.emptyUnread', 'Du bist auf dem neuesten Stand!')}
</Text>
<Text color={isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)'}>
{t('header.notifications.emptyStatus', 'Keine Upload-Hinweise oder Wartungen aktiv.')}
</Text>
</YStack>
) : (
<YStack gap="$3">
{notifications.map((item) => (
<YStack
key={item.id}
padding="$3"
borderRadius="$4"
backgroundColor={
item.status === 'new'
? isDark
? 'rgba(148, 163, 184, 0.18)'
: 'rgba(15, 23, 42, 0.06)'
: isDark
? 'rgba(15, 23, 42, 0.7)'
: 'rgba(255, 255, 255, 0.8)'
}
borderWidth={1}
borderColor={isDark ? 'rgba(148, 163, 184, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
gap="$2"
>
<Text fontSize="$4" fontWeight="$7" color={isDark ? '#F8FAFF' : '#0F172A'}>
{item.title}
</Text>
{item.body ? (
<Text color={isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)'}>
{item.body}
</Text>
) : null}
<XStack gap="$2" flexWrap="wrap">
<Button
size="$2"
backgroundColor="$primary"
color="#FFFFFF"
onPress={() => center?.markAsRead(item.id)}
>
{t('header.notifications.markRead', 'Als gelesen markieren')}
</Button>
<Button
size="$2"
backgroundColor={mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
onPress={() => center?.dismiss(item.id)}
>
{t('header.notifications.dismiss', 'Ausblenden')}
</Button>
</XStack>
</YStack>
))}
</YStack>
)}
</YStack>
</ScrollView>
</YStack>
</>
);
}
function InfoBadge({ label, value }: { label: string; value: number }) {
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
return (
<YStack
padding="$3"
borderRadius="$4"
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.7)' : 'rgba(255, 255, 255, 0.8)'}
borderWidth={1}
borderColor={isDark ? 'rgba(148, 163, 184, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
gap="$1"
>
<Text fontSize="$2" color={isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)'}>
{label}
</Text>
<Text fontSize="$5" fontWeight="$7" color={isDark ? '#F8FAFF' : '#0F172A'}>
{value}
</Text>
</YStack>
);
}

View File

@@ -0,0 +1,71 @@
import React from 'react';
import { YStack } from '@tamagui/stacks';
import { useAppearance } from '@/hooks/use-appearance';
type PhotoFrameTileProps = {
height: number;
borderRadius?: number | string;
children?: React.ReactNode;
shimmer?: boolean;
shimmerDelayMs?: number;
};
export default function PhotoFrameTile({
height,
borderRadius = '$tile',
children,
shimmer = false,
shimmerDelayMs = 0,
}: PhotoFrameTileProps) {
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
return (
<YStack
height={height}
borderRadius={borderRadius}
padding={6}
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.04)' : 'rgba(15, 23, 42, 0.04)'}
borderWidth={1}
borderColor={isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)'}
style={{
boxShadow: isDark ? '0 18px 32px rgba(2, 6, 23, 0.4)' : '0 16px 28px rgba(15, 23, 42, 0.12)',
}}
>
<YStack
flex={1}
borderRadius={borderRadius}
backgroundColor="$muted"
borderWidth={1}
borderColor={isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.1)'}
overflow="hidden"
position="relative"
style={{
boxShadow: isDark
? 'inset 0 0 0 1px rgba(255, 255, 255, 0.06)'
: 'inset 0 0 0 1px rgba(15, 23, 42, 0.04)',
}}
>
{shimmer ? (
<YStack
position="absolute"
top={-40}
bottom={-40}
left="-60%"
width="60%"
backgroundColor="transparent"
style={{
backgroundImage:
'linear-gradient(120deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.24), rgba(255, 255, 255, 0))',
animation: 'guestNightShimmer 4.6s ease-in-out infinite',
animationDelay: `${shimmerDelayMs}ms`,
}}
/>
) : null}
<YStack position="relative" zIndex={1} flex={1}>
{children}
</YStack>
</YStack>
</YStack>
);
}

View File

@@ -0,0 +1,362 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Input } from '@tamagui/input';
import { Card } from '@tamagui/card';
import { Switch } from '@tamagui/switch';
import { Check, Moon, RotateCcw, Sun, Languages, FileText, LifeBuoy } from 'lucide-react';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useLocale } from '@/guest/i18n/LocaleContext';
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
import { useHapticsPreference } from '@/guest/hooks/useHapticsPreference';
import { triggerHaptic } from '@/guest/lib/haptics';
import { useConsent } from '@/contexts/consent';
import { useAppearance } from '@/hooks/use-appearance';
import { useEventData } from '../context/EventDataContext';
import { buildEventPath } from '../lib/routes';
const legalLinks = [
{ slug: 'impressum', labelKey: 'settings.legal.section.impressum', fallback: 'Impressum' },
{ slug: 'datenschutz', labelKey: 'settings.legal.section.privacy', fallback: 'Datenschutz' },
{ slug: 'agb', labelKey: 'settings.legal.section.terms', fallback: 'AGB' },
] as const;
type SettingsContentProps = {
onNavigate?: () => void;
showHeader?: boolean;
onOpenLegal?: (slug: (typeof legalLinks)[number]['slug'], labelKey: (typeof legalLinks)[number]['labelKey']) => void;
};
export default function SettingsContent({ onNavigate, showHeader = true, onOpenLegal }: SettingsContentProps) {
const { t } = useTranslation();
const locale = useLocale();
const identity = useOptionalGuestIdentity();
const { enabled: hapticsEnabled, setEnabled: setHapticsEnabled, supported: hapticsSupported } = useHapticsPreference();
const { preferences, savePreferences } = useConsent();
const matomoEnabled = typeof window !== 'undefined' && Boolean((window as any).__MATOMO_GUEST__?.enabled);
const { appearance, updateAppearance } = useAppearance();
const { token } = useEventData();
const isDark = appearance === 'dark';
const cardBackground = isDark ? 'rgba(15, 23, 42, 0.65)' : 'rgba(255, 255, 255, 0.82)';
const cardBorder = isDark ? 'rgba(148, 163, 184, 0.18)' : 'rgba(15, 23, 42, 0.12)';
const primaryText = isDark ? '#F8FAFF' : '#0F172A';
const mutedText = isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)';
const mutedButton = isDark ? 'rgba(248, 250, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
const mutedButtonBorder = isDark ? 'rgba(248, 250, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
const [nameDraft, setNameDraft] = React.useState(identity?.name ?? '');
const [status, setStatus] = React.useState<'idle' | 'saved'>('idle');
const helpPath = token ? buildEventPath(token, '/help') : '/help';
const supportsInlineLegal = Boolean(onOpenLegal);
React.useEffect(() => {
if (identity?.hydrated) {
setNameDraft(identity.name ?? '');
setStatus('idle');
}
}, [identity?.hydrated, identity?.name]);
const canSaveName = Boolean(
identity?.hydrated && nameDraft.trim() && nameDraft.trim() !== (identity?.name ?? '')
);
const handleSaveName = React.useCallback(() => {
if (!identity || !canSaveName) {
return;
}
identity.setName(nameDraft);
setStatus('saved');
window.setTimeout(() => setStatus('idle'), 2000);
}, [identity, nameDraft, canSaveName]);
const handleResetName = React.useCallback(() => {
if (!identity) {
return;
}
identity.clearName();
setNameDraft('');
setStatus('idle');
}, [identity]);
return (
<YStack gap="$4">
{showHeader ? (
<YStack gap="$2">
<Text fontSize="$6" fontFamily="$display" fontWeight="$8" color={primaryText}>
{t('settings.title', 'Settings')}
</Text>
<Text color={mutedText}>{t('settings.subtitle', 'Make this app yours.')}</Text>
</YStack>
) : null}
<Card padding="$3" backgroundColor={cardBackground} borderColor={cardBorder} borderWidth={1}>
<XStack alignItems="center" justifyContent="space-between">
<XStack gap="$2" alignItems="center">
<Languages size={16} color={primaryText} />
<XStack gap="$2">
{locale.availableLocales.map((option) => (
<Button
key={option.code}
size="$3"
circular
onPress={() => locale.setLocale(option.code)}
backgroundColor={option.code === locale.locale ? '$primary' : mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
aria-label={t(`settings.language.option.${option.code}`, option.label ?? option.code.toUpperCase())}
>
<Text fontSize="$2" color={option.code === locale.locale ? '#FFFFFF' : primaryText}>
{option.flag ?? option.code.toUpperCase()}
</Text>
</Button>
))}
</XStack>
</XStack>
<Button
size="$3"
circular
onPress={() => updateAppearance(isDark ? 'light' : 'dark')}
backgroundColor={isDark ? '$primary' : mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
aria-label={t('settings.appearance.darkLabel', 'Dark mode')}
>
{isDark ? <Moon size={16} color="#FFFFFF" /> : <Sun size={16} color={primaryText} />}
</Button>
</XStack>
</Card>
<Card padding="$3" backgroundColor={cardBackground} borderColor={cardBorder} borderWidth={1}>
<YStack gap="$2">
<Text fontSize="$4" fontWeight="$7" color={primaryText}>
{t('settings.name.title', 'Your name')}
</Text>
<XStack gap="$2" alignItems="center">
<Input
flex={1}
value={nameDraft}
onChangeText={setNameDraft}
placeholder={t('settings.name.placeholder', t('profileSetup.form.placeholder'))}
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.6)' : 'rgba(15, 23, 42, 0.05)'}
borderColor={isDark ? 'rgba(148, 163, 184, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
color={primaryText}
/>
<Button
size="$3"
circular
onPress={handleSaveName}
disabled={!canSaveName}
backgroundColor={canSaveName ? '$primary' : mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
aria-label={t('settings.name.save', 'Save name')}
>
<Check size={16} color={canSaveName ? '#FFFFFF' : primaryText} />
</Button>
<Button
size="$3"
circular
onPress={handleResetName}
backgroundColor={mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
aria-label={t('settings.name.reset', 'Reset')}
>
<RotateCcw size={16} color={primaryText} />
</Button>
</XStack>
{status === 'saved' ? (
<Text fontSize="$2" color={mutedText}>
{t('settings.name.saved', 'Saved')}
</Text>
) : null}
</YStack>
</Card>
<Card padding="$3" backgroundColor={cardBackground} borderColor={cardBorder} borderWidth={1}>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$3" color={primaryText}>
{t('settings.haptics.label', 'Haptic feedback')}
</Text>
<Switch
size="$3"
checked={hapticsEnabled}
disabled={!hapticsSupported}
onCheckedChange={(checked) => {
setHapticsEnabled(checked);
if (checked) {
triggerHaptic('selection');
}
}}
aria-label="haptics-toggle"
backgroundColor={hapticsEnabled ? '$primary' : mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
>
<Switch.Thumb backgroundColor={hapticsEnabled ? '#FFFFFF' : primaryText} borderRadius={999} />
</Switch>
</XStack>
{!hapticsSupported ? (
<Text fontSize="$2" color={mutedText}>
{t('settings.haptics.unsupported', 'Haptics are not available on this device.')}
</Text>
) : null}
</Card>
{matomoEnabled ? (
<Card padding="$3" backgroundColor={cardBackground} borderColor={cardBorder} borderWidth={1}>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$3" color={primaryText}>
{t('settings.analytics.label', 'Share anonymous analytics')}
</Text>
<Switch
size="$3"
checked={Boolean(preferences?.analytics)}
onCheckedChange={(checked) => savePreferences({ analytics: checked })}
backgroundColor={preferences?.analytics ? '$primary' : mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
>
<Switch.Thumb backgroundColor={preferences?.analytics ? '#FFFFFF' : primaryText} borderRadius={999} />
</Switch>
</XStack>
<Text fontSize="$2" color={mutedText} marginTop="$2">
{t('settings.analytics.note', 'You can change this anytime.')}
</Text>
</Card>
) : null}
<Card padding="$3" backgroundColor={cardBackground} borderColor={cardBorder} borderWidth={1}>
<YStack gap="$2">
<Text fontSize="$4" fontWeight="$7" color={primaryText}>
{t('settings.legal.title', 'Legal')}
</Text>
<YStack gap="$2">
{legalLinks.map((page) => {
const label = t(page.labelKey, page.fallback);
if (supportsInlineLegal) {
return (
<Button
key={page.slug}
onPress={() => onOpenLegal?.(page.slug, page.labelKey)}
justifyContent="space-between"
backgroundColor={mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
>
<XStack alignItems="center" gap="$2">
<FileText size={16} color={primaryText} />
<Text color={primaryText}>{label}</Text>
</XStack>
</Button>
);
}
return (
<Button
key={page.slug}
asChild
justifyContent="space-between"
backgroundColor={mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
>
<Link to={`/legal/${page.slug}`} onClick={onNavigate}>
{label}
</Link>
</Button>
);
})}
</YStack>
</YStack>
</Card>
<Card padding="$3" backgroundColor={cardBackground} borderColor={cardBorder} borderWidth={1}>
<YStack gap="$3">
<Text fontSize="$4" fontWeight="$7" color={primaryText}>
{t('settings.cache.title', 'Offline cache')}
</Text>
<ClearCacheButton />
<Text fontSize="$2" color={mutedText}>
{t('settings.cache.note', 'This only affects this browser. Pending uploads may be lost.')}
</Text>
</YStack>
</Card>
<Card padding="$3" backgroundColor={cardBackground} borderColor={cardBorder} borderWidth={1}>
<YStack gap="$3">
<Text fontSize="$4" fontWeight="$7" color={primaryText}>
{t('settings.help.title', 'Help Center')}
</Text>
<Button asChild backgroundColor={mutedButton} borderColor={mutedButtonBorder} borderWidth={1}>
<Link to={helpPath} onClick={onNavigate}>
<XStack alignItems="center" gap="$2">
<LifeBuoy size={16} color={primaryText} />
<Text color={primaryText}>{t('settings.help.cta', 'Open help center')}</Text>
</XStack>
</Link>
</Button>
</YStack>
</Card>
</YStack>
);
}
function ClearCacheButton() {
const { t } = useTranslation();
const [busy, setBusy] = React.useState(false);
const [done, setDone] = React.useState(false);
const { appearance } = useAppearance();
const isDark = appearance === 'dark';
const mutedButton = isDark ? 'rgba(248, 250, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
const mutedButtonBorder = isDark ? 'rgba(248, 250, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
const mutedText = isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)';
const clearAll = React.useCallback(async () => {
setBusy(true);
setDone(false);
try {
if ('caches' in window) {
const keys = await caches.keys();
await Promise.all(keys.map((key) => caches.delete(key)));
}
if ('indexedDB' in window) {
const databases = ['guest-upload-queue', 'upload-queue'];
await Promise.all(
databases.map(
(name) =>
new Promise((resolve) => {
const request = indexedDB.deleteDatabase(name);
request.onsuccess = () => resolve(null);
request.onerror = () => resolve(null);
})
)
);
}
setDone(true);
} finally {
setBusy(false);
window.setTimeout(() => setDone(false), 2500);
}
}, []);
return (
<YStack gap="$2">
<Button
onPress={clearAll}
disabled={busy}
backgroundColor={mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
>
{busy ? t('settings.cache.clearing', 'Clearing cache...') : t('settings.cache.clear', 'Clear cache')}
</Button>
{done ? (
<Text fontSize="$2" color={mutedText}>
{t('settings.cache.cleared', 'Cache cleared.')}
</Text>
) : null}
</YStack>
);
}

View File

@@ -0,0 +1,268 @@
import React from 'react';
import { ScrollView } from '@tamagui/scroll-view';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { ArrowLeft, X } from 'lucide-react';
import SettingsContent from './SettingsContent';
import { useAppearance } from '@/hooks/use-appearance';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useLocale } from '@/guest/i18n/LocaleContext';
import { LegalMarkdown } from '@/guest/components/legal-markdown';
import type { LocaleCode } from '@/guest/i18n/messages';
const legalLinks = [
{ slug: 'impressum', labelKey: 'settings.legal.section.impressum', fallback: 'Impressum' },
{ slug: 'datenschutz', labelKey: 'settings.legal.section.privacy', fallback: 'Datenschutz' },
{ slug: 'agb', labelKey: 'settings.legal.section.terms', fallback: 'AGB' },
] as const;
type ViewState =
| { mode: 'home' }
| { mode: 'legal'; slug: (typeof legalLinks)[number]['slug']; labelKey: (typeof legalLinks)[number]['labelKey'] };
type LegalDocumentState =
| { phase: 'idle'; title: string; markdown: string; html: string }
| { phase: 'loading'; title: string; markdown: string; html: string }
| { phase: 'ready'; title: string; markdown: string; html: string }
| { phase: 'error'; title: string; markdown: string; html: string };
type SettingsSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
export default function SettingsSheet({ open, onOpenChange }: SettingsSheetProps) {
const { t } = useTranslation();
const { locale } = useLocale();
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const [view, setView] = React.useState<ViewState>({ mode: 'home' });
const isLegal = view.mode === 'legal';
const legalDocument = useLegalDocument(isLegal ? view.slug : null, locale);
const handleBack = React.useCallback(() => setView({ mode: 'home' }), []);
const handleOpenLegal = React.useCallback(
(slug: (typeof legalLinks)[number]['slug'], labelKey: (typeof legalLinks)[number]['labelKey']) => {
setView({ mode: 'legal', slug, labelKey });
},
[]
);
React.useEffect(() => {
if (!open) {
setView({ mode: 'home' });
}
}, [open]);
return (
<>
<YStack
position="fixed"
top={0}
right={0}
bottom={0}
left={0}
zIndex={1200}
pointerEvents={open ? 'auto' : 'none'}
style={{
backgroundColor: isDark ? 'rgba(15, 23, 42, 0.45)' : 'rgba(15, 23, 42, 0.2)',
opacity: open ? 1 : 0,
transition: 'opacity 240ms ease',
}}
onPress={() => onOpenChange(false)}
onClick={() => onOpenChange(false)}
onMouseDown={() => onOpenChange(false)}
onTouchStart={() => onOpenChange(false)}
/>
<YStack
position="fixed"
top={0}
right={0}
bottom={0}
zIndex={1300}
width="85%"
maxWidth={420}
backgroundColor={isDark ? '#0B101E' : '#FFFFFF'}
borderTopLeftRadius="$6"
borderBottomLeftRadius="$6"
borderTopRightRadius={0}
borderBottomRightRadius={0}
overflow="hidden"
pointerEvents={open ? 'auto' : 'none'}
style={{
transform: open ? 'translateX(0)' : 'translateX(100%)',
opacity: open ? 1 : 0,
transition: 'transform 320ms cubic-bezier(0.22, 0.61, 0.36, 1), opacity 220ms ease',
}}
>
<XStack
alignItems="center"
justifyContent="space-between"
paddingHorizontal="$4"
paddingVertical="$3"
style={{
position: 'sticky',
top: 0,
zIndex: 2,
backgroundColor: isDark ? 'rgba(11, 16, 30, 0.95)' : 'rgba(255, 255, 255, 0.95)',
borderBottom: isDark ? '1px solid rgba(255, 255, 255, 0.08)' : '1px solid rgba(15, 23, 42, 0.1)',
backdropFilter: 'saturate(160%) blur(18px)',
WebkitBackdropFilter: 'saturate(160%) blur(18px)',
}}
>
{isLegal ? (
<XStack alignItems="center" gap="$2">
<Button
size="$3"
circular
backgroundColor={isDark ? 'rgba(248, 250, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'}
borderColor={isDark ? 'rgba(248, 250, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
borderWidth={1}
onPress={handleBack}
aria-label={t('common.actions.back', 'Back')}
>
<ArrowLeft size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
<YStack>
<Text fontSize="$5" fontFamily="$display" fontWeight="$8" color={isDark ? '#F8FAFF' : '#0F172A'}>
{legalDocument.phase === 'ready' && legalDocument.title
? legalDocument.title
: t(view.labelKey, 'Legal')}
</Text>
<Text fontSize="$2" color={isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)'}>
{legalDocument.phase === 'loading'
? t('common.actions.loading', 'Loading...')
: t('settings.legal.description', 'Legal notice')}
</Text>
</YStack>
</XStack>
) : (
<Text fontSize="$6" fontFamily="$display" fontWeight="$8" color={isDark ? '#F8FAFF' : '#0F172A'}>
{t('settings.title', 'Settings')}
</Text>
)}
<Button
size="$3"
circular
backgroundColor={isDark ? 'rgba(248, 250, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'}
borderColor={isDark ? 'rgba(248, 250, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
borderWidth={1}
onPress={() => onOpenChange(false)}
aria-label={t('common.actions.close', 'Close')}
>
<X size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
</XStack>
<ScrollView flex={1} showsVerticalScrollIndicator={false} contentContainerStyle={{ padding: 16, paddingBottom: 48 }}>
<YStack gap="$4">
{isLegal ? (
<LegalView
document={legalDocument}
fallbackTitle={t(view.labelKey, 'Legal')}
/>
) : (
<SettingsContent
onNavigate={() => onOpenChange(false)}
showHeader={false}
onOpenLegal={handleOpenLegal}
/>
)}
</YStack>
</ScrollView>
</YStack>
</>
);
}
function LegalView({ document, fallbackTitle }: { document: LegalDocumentState; fallbackTitle: string }) {
const { t } = useTranslation();
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const mutedText = isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)';
if (document.phase === 'error') {
return (
<YStack gap="$3">
<Text fontSize="$4" fontWeight="$7" color={isDark ? '#F8FAFF' : '#0F172A'}>
{t('settings.legal.error', 'Etwas ist schiefgelaufen.')}
</Text>
<Text fontSize="$2" color={mutedText}>
{t('settings.legal.loading', 'Lade...')}
</Text>
</YStack>
);
}
if (document.phase === 'loading' || document.phase === 'idle') {
return (
<Text fontSize="$2" color={mutedText}>
{t('settings.legal.loading', 'Lade...')}
</Text>
);
}
return (
<YStack gap="$3">
<Text fontSize="$5" fontWeight="$8" color={isDark ? '#F8FAFF' : '#0F172A'}>
{document.title || fallbackTitle}
</Text>
<YStack
padding="$3"
borderRadius="$4"
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.65)' : 'rgba(255, 255, 255, 0.85)'}
borderColor={isDark ? 'rgba(148, 163, 184, 0.18)' : 'rgba(15, 23, 42, 0.12)'}
borderWidth={1}
>
<LegalMarkdown markdown={document.markdown} html={document.html} />
</YStack>
</YStack>
);
}
function useLegalDocument(slug: string | null, locale: LocaleCode): LegalDocumentState {
const [state, setState] = React.useState<LegalDocumentState>({
phase: 'idle',
title: '',
markdown: '',
html: '',
});
React.useEffect(() => {
if (!slug) {
setState({ phase: 'idle', title: '', markdown: '', html: '' });
return;
}
const controller = new AbortController();
setState((prev) => ({ ...prev, phase: 'loading' }));
fetch(`/api/v1/legal/${encodeURIComponent(slug)}?lang=${encodeURIComponent(locale)}`, {
headers: { Accept: 'application/json' },
signal: controller.signal,
})
.then(async (res) => {
if (!res.ok) {
throw new Error('Failed to load legal page');
}
return res.json();
})
.then((data) => {
setState({
phase: 'ready',
title: data?.title ?? '',
markdown: data?.body_markdown ?? '',
html: data?.body_html ?? '',
});
})
.catch((error) => {
if (error?.name === 'AbortError') return;
console.error('Failed to load legal page', error);
setState((prev) => ({ ...prev, phase: 'error' }));
});
return () => controller.abort();
}, [slug, locale]);
return state;
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { YStack } from '@tamagui/stacks';
import AmbientBackground from './AmbientBackground';
type StandaloneShellProps = {
children: React.ReactNode;
compact?: boolean;
};
export default function StandaloneShell({ children, compact = false }: StandaloneShellProps) {
return (
<AmbientBackground>
<YStack minHeight="100vh" padding="$4" paddingTop={compact ? '$4' : '$6'} paddingBottom="$6" gap="$4">
{children}
</YStack>
</AmbientBackground>
);
}

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { YStack } from '@tamagui/stacks';
import type { YStackProps } from '@tamagui/stacks';
import { useAppearance } from '@/hooks/use-appearance';
type SurfaceCardProps = YStackProps & {
glow?: boolean;
};
export default function SurfaceCard({ children, glow = false, ...props }: SurfaceCardProps) {
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const borderColor = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
const boxShadow = isDark
? glow
? '0 22px 40px rgba(6, 10, 22, 0.55)'
: '0 16px 30px rgba(2, 6, 23, 0.35)'
: glow
? '0 22px 38px rgba(15, 23, 42, 0.16)'
: '0 14px 24px rgba(15, 23, 42, 0.12)';
return (
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={borderColor}
style={{ boxShadow }}
{...props}
>
{children}
</YStack>
);
}

View File

@@ -0,0 +1,97 @@
import React from 'react';
import { XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Bell, Settings } from 'lucide-react';
import { useAppearance } from '@/hooks/use-appearance';
type TopBarProps = {
eventName: string;
onProfilePress?: () => void;
onNotificationsPress?: () => void;
notificationCount?: number;
};
export default function TopBar({
eventName,
onProfilePress,
onNotificationsPress,
notificationCount = 0,
}: TopBarProps) {
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
return (
<XStack
alignItems="center"
justifyContent="space-between"
paddingHorizontal="$4"
paddingVertical="$3"
style={{
backgroundColor: isDark ? 'rgba(10, 14, 28, 0.72)' : 'rgba(255, 255, 255, 0.85)',
backdropFilter: 'saturate(160%) blur(18px)',
WebkitBackdropFilter: 'saturate(160%) blur(18px)',
}}
>
<Text
fontSize="$6"
fontFamily="$display"
fontWeight="$8"
numberOfLines={1}
style={{ textShadow: '0 6px 18px rgba(2, 6, 23, 0.7)' }}
>
{eventName}
</Text>
<XStack gap="$2" alignItems="center">
<Button
size="$3"
circular
borderWidth={1}
borderColor={isDark ? 'rgba(255, 255, 255, 0.14)' : 'rgba(15, 23, 42, 0.14)'}
style={{
backgroundColor: isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)',
boxShadow: isDark ? '0 8px 18px rgba(2, 6, 23, 0.4)' : '0 8px 18px rgba(15, 23, 42, 0.12)',
position: 'relative',
}}
onPress={onNotificationsPress}
>
<Bell size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
{notificationCount > 0 ? (
<span
style={{
position: 'absolute',
top: -2,
right: -2,
width: 18,
height: 18,
borderRadius: 999,
backgroundColor: '#F97316',
color: '#0B101E',
fontSize: 10,
fontWeight: 700,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{notificationCount > 9 ? '9+' : notificationCount}
</span>
) : null}
</Button>
<Button
size="$3"
circular
borderWidth={1}
borderColor={isDark ? 'rgba(255, 255, 255, 0.14)' : 'rgba(15, 23, 42, 0.14)'}
style={{
backgroundColor: isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)',
boxShadow: isDark ? '0 8px 18px rgba(2, 6, 23, 0.4)' : '0 8px 18px rgba(15, 23, 42, 0.12)',
}}
onPress={onProfilePress}
>
<Settings size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
</XStack>
</XStack>
);
}

View File

@@ -0,0 +1,103 @@
import React from 'react';
import { fetchEvent, type EventData, FetchEventError } from '../services/eventApi';
import { isTaskModeEnabled } from '@/guest/lib/engagement';
type EventDataStatus = 'idle' | 'loading' | 'ready' | 'error';
type EventDataContextValue = {
event: EventData | null;
status: EventDataStatus;
error: string | null;
token: string | null;
tasksEnabled: boolean;
};
const EventDataContext = React.createContext<EventDataContextValue>({
event: null,
status: 'idle',
error: null,
token: null,
tasksEnabled: true,
});
type EventDataProviderProps = {
token?: string | null;
tasksEnabledFallback?: boolean;
children: React.ReactNode;
};
export function EventDataProvider({
token,
tasksEnabledFallback = true,
children,
}: EventDataProviderProps) {
const [event, setEvent] = React.useState<EventData | null>(null);
const [status, setStatus] = React.useState<EventDataStatus>(token ? 'loading' : 'idle');
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
if (!token) {
setEvent(null);
setStatus('idle');
setError(null);
return;
}
let cancelled = false;
const loadEvent = async () => {
setStatus('loading');
setError(null);
try {
const eventData = await fetchEvent(token);
if (cancelled) {
return;
}
setEvent(eventData);
setStatus('ready');
} catch (err) {
if (cancelled) {
return;
}
setEvent(null);
setStatus('error');
if (err instanceof FetchEventError) {
setError(err.message);
} else if (err instanceof Error) {
setError(err.message || 'Event could not be loaded.');
} else {
setError('Event could not be loaded.');
}
}
};
loadEvent();
return () => {
cancelled = true;
};
}, [token]);
const tasksEnabled = event ? isTaskModeEnabled(event) : tasksEnabledFallback;
return (
<EventDataContext.Provider
value={{
event,
status,
error,
token: token ?? null,
tasksEnabled,
}}
>
{children}
</EventDataContext.Provider>
);
}
export function useEventData() {
return React.useContext(EventDataContext);
}

View File

@@ -0,0 +1,111 @@
import React from 'react';
type GuestIdentityContextValue = {
eventKey: string;
slug: string;
name: string;
hydrated: boolean;
setName: (nextName: string) => void;
clearName: () => void;
reload: () => void;
};
const GuestIdentityContext = React.createContext<GuestIdentityContextValue | undefined>(undefined);
function storageKey(eventKey: string) {
return `guestName_${eventKey}`;
}
export function readGuestName(eventKey: string) {
if (!eventKey || typeof window === 'undefined') {
return '';
}
try {
return window.localStorage.getItem(storageKey(eventKey)) ?? '';
} catch (error) {
console.warn('Failed to read guest name', error);
return '';
}
}
export function GuestIdentityProvider({ eventKey, children }: { eventKey: string; children: React.ReactNode }) {
const [name, setNameState] = React.useState('');
const [hydrated, setHydrated] = React.useState(false);
const loadFromStorage = React.useCallback(() => {
if (!eventKey) {
setHydrated(true);
setNameState('');
return;
}
try {
const stored = window.localStorage.getItem(storageKey(eventKey));
setNameState(stored ?? '');
} catch (error) {
console.warn('Failed to read guest name from storage', error);
setNameState('');
} finally {
setHydrated(true);
}
}, [eventKey]);
React.useEffect(() => {
setHydrated(false);
loadFromStorage();
}, [loadFromStorage]);
const persistName = React.useCallback(
(nextName: string) => {
const trimmed = nextName.trim();
setNameState(trimmed);
try {
if (trimmed) {
window.localStorage.setItem(storageKey(eventKey), trimmed);
} else {
window.localStorage.removeItem(storageKey(eventKey));
}
} catch (error) {
console.warn('Failed to persist guest name', error);
}
},
[eventKey]
);
const clearName = React.useCallback(() => {
setNameState('');
try {
window.localStorage.removeItem(storageKey(eventKey));
} catch (error) {
console.warn('Failed to clear guest name', error);
}
}, [eventKey]);
const value = React.useMemo<GuestIdentityContextValue>(
() => ({
eventKey,
slug: eventKey,
name,
hydrated,
setName: persistName,
clearName,
reload: loadFromStorage,
}),
[eventKey, name, hydrated, persistName, clearName, loadFromStorage]
);
return <GuestIdentityContext.Provider value={value}>{children}</GuestIdentityContext.Provider>;
}
export function useGuestIdentity() {
const ctx = React.useContext(GuestIdentityContext);
if (!ctx) {
throw new Error('useGuestIdentity must be used within a GuestIdentityProvider');
}
return ctx;
}
export function useOptionalGuestIdentity() {
return React.useContext(GuestIdentityContext);
}

View File

@@ -0,0 +1,82 @@
import React from 'react';
import { fetchGallery } from '../services/photosApi';
export type GalleryDelta = {
photos: Record<string, unknown>[];
latestPhotoAt: string | null;
nextCursor: string | null;
};
const emptyDelta: GalleryDelta = {
photos: [],
latestPhotoAt: null,
nextCursor: null,
};
export function usePollGalleryDelta(
eventToken: string | null,
options: { intervalMs?: number; locale?: string } = {}
) {
const intervalMs = options.intervalMs ?? 30000;
const [data, setData] = React.useState<GalleryDelta>(emptyDelta);
const [loading, setLoading] = React.useState(Boolean(eventToken));
const [error, setError] = React.useState<string | null>(null);
const latestRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (!eventToken) {
setData(emptyDelta);
setLoading(false);
setError(null);
latestRef.current = null;
return;
}
let active = true;
let timer: number | null = null;
const poll = async () => {
if (document.visibilityState === 'hidden') {
timer = window.setTimeout(poll, intervalMs);
return;
}
try {
setLoading(true);
const response = await fetchGallery(eventToken, {
since: latestRef.current ?? undefined,
locale: options.locale,
});
if (!active) return;
const photos = Array.isArray(response.data) ? response.data : [];
const latestPhotoAt = response.latest_photo_at ?? latestRef.current ?? null;
latestRef.current = latestPhotoAt;
setData({
photos,
latestPhotoAt,
nextCursor: response.next_cursor ?? null,
});
setError(null);
} catch (err) {
if (!active) return;
setError(err instanceof Error ? err.message : 'Failed to load gallery updates');
} finally {
if (active) {
setLoading(false);
timer = window.setTimeout(poll, intervalMs);
}
}
};
poll();
return () => {
active = false;
if (timer) {
window.clearTimeout(timer);
}
};
}, [eventToken, intervalMs, options.locale]);
return { data, loading, error } as const;
}

View File

@@ -0,0 +1,57 @@
import React from 'react';
import { fetchEventStats } from '../services/statsApi';
import type { EventStats } from '../services/eventApi';
const defaultStats: EventStats = { onlineGuests: 0, tasksSolved: 0, latestPhotoAt: null };
export function usePollStats(eventToken: string | null, intervalMs = 10000) {
const [stats, setStats] = React.useState<EventStats>(defaultStats);
const [loading, setLoading] = React.useState<boolean>(Boolean(eventToken));
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
if (!eventToken) {
setStats(defaultStats);
setLoading(false);
setError(null);
return;
}
let active = true;
let timer: number | null = null;
const poll = async () => {
if (document.visibilityState === 'hidden') {
timer = window.setTimeout(poll, intervalMs);
return;
}
try {
setLoading(true);
const next = await fetchEventStats(eventToken);
if (!active) return;
setStats(next);
setError(null);
} catch (err) {
if (!active) return;
setError(err instanceof Error ? err.message : 'Failed to load stats');
} finally {
if (active) {
setLoading(false);
timer = window.setTimeout(poll, intervalMs);
}
}
};
poll();
return () => {
active = false;
if (timer) {
window.clearTimeout(timer);
}
};
}, [eventToken, intervalMs]);
return { stats, loading, error } as const;
}

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { Navigate, Outlet, useParams } from 'react-router-dom';
import { LocaleProvider } from '@/guest/i18n/LocaleContext';
import { DEFAULT_LOCALE, isLocaleCode } from '@/guest/i18n/messages';
import { NotificationCenterProvider } from '@/guest/context/NotificationCenterContext';
import { EventBrandingProvider } from '@/guest/context/EventBrandingContext';
import { EventDataProvider, useEventData } from '../context/EventDataContext';
import { GuestIdentityProvider, useOptionalGuestIdentity } from '../context/GuestIdentityContext';
import { mapEventBranding } from '../lib/eventBranding';
import { BrandingTheme } from '../lib/brandingTheme';
type EventLayoutProps = {
tasksEnabledFallback?: boolean;
requireProfile?: boolean;
};
export default function EventLayout({ tasksEnabledFallback = true, requireProfile = false }: EventLayoutProps) {
const { token } = useParams<{ token: string }>();
return (
<EventDataProvider token={token} tasksEnabledFallback={tasksEnabledFallback}>
<EventProviders token={token} requireProfile={requireProfile}>
<Outlet />
</EventProviders>
</EventDataProvider>
);
}
function EventProviders({
token,
children,
requireProfile,
}: {
token?: string;
children: React.ReactNode;
requireProfile: boolean;
}) {
const { event } = useEventData();
const eventLocale = event && isLocaleCode(event.default_locale) ? event.default_locale : DEFAULT_LOCALE;
const localeStorageKey = event
? `guestLocale_event_${event.id ?? token ?? 'global'}`
: `guestLocale_event_${token ?? 'global'}`;
const branding = mapEventBranding(
event?.branding ?? (event as unknown as { settings?: { branding?: any } })?.settings?.branding ?? null
);
const content = (
<EventBrandingProvider branding={branding}>
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
<GuestIdentityProvider eventKey={token ?? ''}>
<BrandingTheme>
{requireProfile ? <ProfileGate token={token}>{children}</ProfileGate> : children}
</BrandingTheme>
</GuestIdentityProvider>
</LocaleProvider>
</EventBrandingProvider>
);
if (!token) {
return content;
}
return <NotificationCenterProvider eventToken={token}>{content}</NotificationCenterProvider>;
}
function ProfileGate({ token, children }: { token?: string; children: React.ReactNode }) {
const identity = useOptionalGuestIdentity();
if (token && identity?.hydrated && !identity.name) {
return <Navigate to={`/setup/${encodeURIComponent(token)}`} replace />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,12 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import { LocaleProvider } from '@/guest/i18n/LocaleContext';
import { DEFAULT_LOCALE } from '@/guest/i18n/messages';
export default function GuestLocaleLayout() {
return (
<LocaleProvider defaultLocale={DEFAULT_LOCALE} storageKey="guestLocale_global">
<Outlet />
</LocaleProvider>
);
}

View File

@@ -0,0 +1 @@
export * from './brandingTheme.tsx';

View File

@@ -0,0 +1,66 @@
import { Theme } from '@tamagui/core';
import React from 'react';
import type { Appearance } from '@/hooks/use-appearance';
import { useAppearance } from '@/hooks/use-appearance';
import { useEventBranding } from '@/guest/context/EventBrandingContext';
import { relativeLuminance } from '@/guest/lib/color';
import type { EventBranding } from '@/guest/types/event-branding';
const LIGHT_LUMINANCE_THRESHOLD = 0.65;
const DARK_LUMINANCE_THRESHOLD = 0.35;
type ThemeVariant = 'light' | 'dark';
function resolveThemeVariant(
mode: EventBranding['mode'],
backgroundColor: string,
appearanceOverride: 'light' | 'dark' | null
): ThemeVariant {
const prefersDark =
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia('(prefers-color-scheme: dark)').matches
: false;
const backgroundLuminance = relativeLuminance(backgroundColor);
const backgroundPrefers =
backgroundLuminance >= LIGHT_LUMINANCE_THRESHOLD
? 'light'
: backgroundLuminance <= DARK_LUMINANCE_THRESHOLD
? 'dark'
: null;
if (mode === 'dark') {
return 'dark';
}
if (mode === 'light') {
return 'light';
}
if (appearanceOverride) {
return appearanceOverride;
}
if (backgroundPrefers) {
return backgroundPrefers;
}
return prefersDark ? 'dark' : 'light';
}
export function resolveGuestThemeName(
branding: EventBranding,
appearance: Appearance
): 'guestLight' | 'guestNight' {
const appearanceOverride = appearance === 'light' || appearance === 'dark' ? appearance : null;
const background = branding.backgroundColor || branding.palette?.background || '#ffffff';
const variant = resolveThemeVariant(branding.mode ?? 'auto', background, appearanceOverride);
return variant === 'dark' ? 'guestNight' : 'guestLight';
}
export function BrandingTheme({ children }: { children: React.ReactNode }) {
const { branding } = useEventBranding();
const { appearance } = useAppearance();
const themeName = resolveGuestThemeName(branding, appearance);
return <Theme name={themeName}>{children}</Theme>;
}

View File

@@ -0,0 +1,18 @@
export function getDeviceId(): string {
const KEY = 'device-id';
let id = localStorage.getItem(KEY);
if (!id) {
id = genId();
localStorage.setItem(KEY, id);
}
return id;
}
function genId() {
// Simple UUID v4-ish generator
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (crypto.getRandomValues(new Uint8Array(1))[0] & 0xf) >> 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}

View File

@@ -0,0 +1,74 @@
import type { EventBranding } from '@/guest/types/event-branding';
import type { EventBrandingPayload } from '@/guest/services/eventApi';
export function mapEventBranding(raw?: EventBrandingPayload | null): EventBranding | null {
if (!raw) {
return null;
}
const palette = raw.palette ?? {};
const typography = raw.typography ?? {};
const buttons = raw.buttons ?? {};
const logo = raw.logo ?? {};
const primary = palette.primary ?? raw.primary_color ?? '';
const secondary = palette.secondary ?? raw.secondary_color ?? '';
const background = palette.background ?? raw.background_color ?? '';
const surface = palette.surface ?? raw.surface_color ?? background;
const headingFont = typography.heading ?? raw.heading_font ?? raw.font_family ?? null;
const bodyFont = typography.body ?? raw.body_font ?? raw.font_family ?? null;
const sizePreset =
(typography.size as 's' | 'm' | 'l' | undefined)
?? (raw.font_size as 's' | 'm' | 'l' | undefined)
?? 'm';
const logoMode = logo.mode ?? raw.logo_mode ?? (logo.value || raw.logo_url ? 'upload' : 'emoticon');
const logoValue = logo.value ?? raw.logo_value ?? raw.logo_url ?? raw.icon ?? null;
const logoPosition = logo.position ?? raw.logo_position ?? 'left';
const logoSize = (logo.size as 's' | 'm' | 'l' | undefined) ?? (raw.logo_size as 's' | 'm' | 'l' | undefined) ?? 'm';
const buttonStyle =
(buttons.style as 'filled' | 'outline' | undefined)
?? (raw.button_style as 'filled' | 'outline' | undefined)
?? 'filled';
const buttonRadius =
typeof buttons.radius === 'number'
? buttons.radius
: typeof raw.button_radius === 'number'
? raw.button_radius
: 12;
const buttonPrimary = buttons.primary ?? raw.button_primary_color ?? primary ?? '';
const buttonSecondary = buttons.secondary ?? raw.button_secondary_color ?? secondary ?? '';
const linkColor = buttons.link_color ?? raw.link_color ?? secondary ?? '';
return {
primaryColor: primary ?? '',
secondaryColor: secondary ?? '',
backgroundColor: background ?? '',
fontFamily: bodyFont,
logoUrl: logoMode === 'upload' ? (logoValue ?? null) : null,
palette: {
primary: primary ?? '',
secondary: secondary ?? '',
background: background ?? '',
surface: surface ?? background ?? '',
},
typography: {
heading: headingFont,
body: bodyFont,
sizePreset,
},
logo: {
mode: logoMode,
value: logoValue,
position: logoPosition,
size: logoSize,
},
buttons: {
style: buttonStyle,
radius: buttonRadius,
primary: buttonPrimary,
secondary: buttonSecondary,
linkColor,
},
mode: (raw.mode as 'light' | 'dark' | 'auto' | undefined) ?? 'auto',
useDefaultBranding: raw.use_default_branding ?? undefined,
};
}

View File

@@ -0,0 +1,13 @@
export function buildEventPath(token: string | null, path: string): string {
const normalized = path.startsWith('/') ? path : `/${path}`;
if (!token) {
return normalized;
}
if (normalized === '/') {
return `/e/${encodeURIComponent(token)}`;
}
return `/e/${encodeURIComponent(token)}${normalized}`;
}

View File

@@ -0,0 +1,39 @@
import React from 'react';
type UsePulseAnimationOptions = {
intervalMs?: number;
delayMs?: number;
};
export function usePulseAnimation({ intervalMs = 2400, delayMs = 0 }: UsePulseAnimationOptions = {}) {
const [active, setActive] = React.useState(false);
React.useEffect(() => {
let interval: ReturnType<typeof setInterval> | undefined;
let timeout: ReturnType<typeof setTimeout> | undefined;
const start = () => {
setActive((prev) => !prev);
interval = setInterval(() => {
setActive((prev) => !prev);
}, intervalMs);
};
if (delayMs > 0) {
timeout = setTimeout(start, delayMs);
} else {
start();
}
return () => {
if (interval) {
clearInterval(interval);
}
if (timeout) {
clearTimeout(timeout);
}
};
}, [delayMs, intervalMs]);
return active;
}

View File

@@ -0,0 +1,29 @@
import React from 'react';
type UseStaggeredRevealOptions = {
steps: number;
intervalMs?: number;
delayMs?: number;
};
export function useStaggeredReveal({ steps, intervalMs = 140, delayMs = 80 }: UseStaggeredRevealOptions) {
const [stage, setStage] = React.useState(0);
React.useEffect(() => {
const timers: Array<ReturnType<typeof setTimeout>> = [];
for (let index = 1; index <= steps; index += 1) {
timers.push(
setTimeout(() => {
setStage(index);
}, delayMs + intervalMs * (index - 1))
);
}
return () => {
timers.forEach((timer) => clearTimeout(timer));
};
}, [delayMs, intervalMs, steps]);
return stage;
}

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import '@tamagui/core/reset.css';
import '../../css/app.css';
import { initializeTheme } from '@/hooks/use-appearance';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Guest v2 root element not found.');
}
initializeTheme();
if (typeof window !== 'undefined' && !window.localStorage.getItem('theme')) {
window.localStorage.setItem('theme', 'light');
initializeTheme();
}
createRoot(rootElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,106 @@
import React from 'react';
import { createBrowserRouter } from 'react-router-dom';
import HomeScreen from './screens/HomeScreen';
import GalleryScreen from './screens/GalleryScreen';
import PhotoLightboxScreen from './screens/PhotoLightboxScreen';
import TasksScreen from './screens/TasksScreen';
import TaskDetailScreen from './screens/TaskDetailScreen';
import SettingsScreen from './screens/SettingsScreen';
import UploadScreen from './screens/UploadScreen';
import UploadQueueScreen from './screens/UploadQueueScreen';
import ShareScreen from './screens/ShareScreen';
import AchievementsScreen from './screens/AchievementsScreen';
import NotFoundScreen from './screens/NotFoundScreen';
import LandingScreen from './screens/LandingScreen';
import ProfileSetupScreen from './screens/ProfileSetupScreen';
import LegalScreen from './screens/LegalScreen';
import HelpCenterScreen from './screens/HelpCenterScreen';
import HelpArticleScreen from './screens/HelpArticleScreen';
import PublicGalleryScreen from './screens/PublicGalleryScreen';
import SharedPhotoScreen from './screens/SharedPhotoScreen';
import LiveShowScreen from './screens/LiveShowScreen';
import SlideshowScreen from './screens/SlideshowScreen';
import EventLayout from './layouts/EventLayout';
import GuestLocaleLayout from './layouts/GuestLocaleLayout';
import MockupsIndexScreen from './screens/mockups/MockupsIndexScreen';
import MockupsHomeIndexScreen from './screens/mockups/MockupsHomeIndexScreen';
import Mockup01CaptureOrbit from './screens/mockups/Mockup01CaptureOrbit';
import Mockup02GalleryMosaic from './screens/mockups/Mockup02GalleryMosaic';
import Mockup03PromptQuest from './screens/mockups/Mockup03PromptQuest';
import Mockup04TimelineStream from './screens/mockups/Mockup04TimelineStream';
import Mockup05CompassHub from './screens/mockups/Mockup05CompassHub';
import Mockup06SplitCapture from './screens/mockups/Mockup06SplitCapture';
import Mockup07SwipeDeck from './screens/mockups/Mockup07SwipeDeck';
import Mockup08Daybook from './screens/mockups/Mockup08Daybook';
import Mockup09ChecklistFlow from './screens/mockups/Mockup09ChecklistFlow';
import Mockup10SpotlightReel from './screens/mockups/Mockup10SpotlightReel';
import MockupHome01PulseHero from './screens/mockups/MockupHome01PulseHero';
import MockupHome02StoryRings from './screens/mockups/MockupHome02StoryRings';
import MockupHome03LiveStream from './screens/mockups/MockupHome03LiveStream';
import MockupHome04TaskSprint from './screens/mockups/MockupHome04TaskSprint';
import MockupHome05GalleryFirst from './screens/mockups/MockupHome05GalleryFirst';
import MockupHome06CalmFocus from './screens/mockups/MockupHome06CalmFocus';
import MockupHome07MomentStack from './screens/mockups/MockupHome07MomentStack';
import MockupHome08CountdownStage from './screens/mockups/MockupHome08CountdownStage';
import MockupHome09ShareHub from './screens/mockups/MockupHome09ShareHub';
import MockupHome10Moodboard from './screens/mockups/MockupHome10Moodboard';
const screenChildren = [
{ index: true, element: <HomeScreen /> },
{ path: 'gallery', element: <GalleryScreen /> },
{ path: 'photo/:photoId', element: <PhotoLightboxScreen /> },
{ path: 'tasks', element: <TasksScreen /> },
{ path: 'tasks/:taskId', element: <TaskDetailScreen /> },
{ path: 'upload', element: <UploadScreen /> },
{ path: 'queue', element: <UploadQueueScreen /> },
{ path: 'share', element: <ShareScreen /> },
{ path: 'achievements', element: <AchievementsScreen /> },
{ path: 'settings', element: <SettingsScreen /> },
{ path: 'help', element: <HelpCenterScreen /> },
{ path: 'help/:slug', element: <HelpArticleScreen /> },
{ path: 'slideshow', element: <SlideshowScreen /> },
];
export const router = createBrowserRouter(
[
{
element: <GuestLocaleLayout />,
children: [
{ path: '/event', element: <LandingScreen /> },
{ path: '/event-v2', element: <LandingScreen /> },
{ path: '/legal/:page', element: <LegalScreen /> },
{ path: '/help', element: <HelpCenterScreen /> },
{ path: '/help/:slug', element: <HelpArticleScreen /> },
{ path: '/g/:token', element: <PublicGalleryScreen /> },
{ path: '/share/:slug', element: <SharedPhotoScreen /> },
],
},
{ path: '/setup/:token', element: <EventLayout tasksEnabledFallback />, children: [{ index: true, element: <ProfileSetupScreen /> }] },
{ path: '/e/:token', element: <EventLayout tasksEnabledFallback={false} requireProfile />, children: screenChildren },
{ path: '/show/:token', element: <EventLayout tasksEnabledFallback={false} />, children: [{ index: true, element: <LiveShowScreen /> }] },
{ path: '/mockups', element: <MockupsIndexScreen /> },
{ path: '/mockups/1', element: <Mockup01CaptureOrbit /> },
{ path: '/mockups/2', element: <Mockup02GalleryMosaic /> },
{ path: '/mockups/3', element: <Mockup03PromptQuest /> },
{ path: '/mockups/4', element: <Mockup04TimelineStream /> },
{ path: '/mockups/5', element: <Mockup05CompassHub /> },
{ path: '/mockups/6', element: <Mockup06SplitCapture /> },
{ path: '/mockups/7', element: <Mockup07SwipeDeck /> },
{ path: '/mockups/8', element: <Mockup08Daybook /> },
{ path: '/mockups/9', element: <Mockup09ChecklistFlow /> },
{ path: '/mockups/10', element: <Mockup10SpotlightReel /> },
{ path: '/mockups/home', element: <MockupsHomeIndexScreen /> },
{ path: '/mockups/home/1', element: <MockupHome01PulseHero /> },
{ path: '/mockups/home/2', element: <MockupHome02StoryRings /> },
{ path: '/mockups/home/3', element: <MockupHome03LiveStream /> },
{ path: '/mockups/home/4', element: <MockupHome04TaskSprint /> },
{ path: '/mockups/home/5', element: <MockupHome05GalleryFirst /> },
{ path: '/mockups/home/6', element: <MockupHome06CalmFocus /> },
{ path: '/mockups/home/7', element: <MockupHome07MomentStack /> },
{ path: '/mockups/home/8', element: <MockupHome08CountdownStage /> },
{ path: '/mockups/home/9', element: <MockupHome09ShareHub /> },
{ path: '/mockups/home/10', element: <MockupHome10Moodboard /> },
{ path: '*', element: <NotFoundScreen /> },
],
{}
);

View File

@@ -0,0 +1,174 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Trophy, Star } from 'lucide-react';
import AppShell from '../components/AppShell';
import { useEventData } from '../context/EventDataContext';
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
import { fetchAchievements, type AchievementsPayload } from '../services/achievementsApi';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useLocale } from '@/guest/i18n/LocaleContext';
import { useAppearance } from '@/hooks/use-appearance';
export default function AchievementsScreen() {
const { token } = useEventData();
const identity = useOptionalGuestIdentity();
const { t } = useTranslation();
const { locale } = useLocale();
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
const cardShadow = isDark ? '0 18px 40px rgba(2, 6, 23, 0.4)' : '0 16px 30px rgba(15, 23, 42, 0.12)';
const [payload, setPayload] = React.useState<AchievementsPayload | null>(null);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
if (!token) {
setPayload(null);
return;
}
let active = true;
setLoading(true);
setError(null);
fetchAchievements(token, { guestName: identity?.name ?? undefined, locale })
.then((data) => {
if (!active) return;
setPayload(data);
})
.catch((err) => {
console.error('Failed to load achievements', err);
if (active) {
setError(t('achievements.error', 'Achievements could not be loaded.'));
}
})
.finally(() => {
if (active) {
setLoading(false);
}
});
return () => {
active = false;
};
}, [token, identity?.name, locale, t]);
const topPhoto = payload?.highlights?.topPhoto ?? null;
const totalPhotos = payload?.summary?.totalPhotos ?? 0;
const totalTasks = payload?.summary?.tasksSolved ?? 0;
const totalLikes = payload?.summary?.likesTotal ?? 0;
return (
<AppShell>
<YStack gap="$4">
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
gap="$2"
style={{
boxShadow: cardShadow,
}}
>
<XStack alignItems="center" gap="$2">
<Trophy size={18} color="#FDE047" />
<Text fontSize="$4" fontWeight="$7">
{t('achievements.page.title', 'Achievements')}
</Text>
</XStack>
<Text fontSize="$2" color="$color" opacity={0.7}>
{loading
? t('common.actions.loading', 'Loading...')
: t('achievements.page.subtitle', 'Track your milestones and highlight streaks.')}
</Text>
</YStack>
{error ? (
<YStack
padding="$3"
borderRadius="$card"
backgroundColor="rgba(248, 113, 113, 0.12)"
borderWidth={1}
borderColor="rgba(248, 113, 113, 0.4)"
>
<Text fontSize="$2" color="#FEE2E2">
{error ?? t('achievements.page.loadError', 'Achievements could not be loaded.')}
</Text>
</YStack>
) : null}
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
gap="$2"
style={{
backgroundImage: isDark
? 'linear-gradient(135deg, rgba(255, 79, 216, 0.18), rgba(79, 209, 255, 0.12))'
: 'linear-gradient(135deg, color-mix(in oklab, var(--guest-primary, #FF5A5F) 12%, white), color-mix(in oklab, var(--guest-secondary, #F43F5E) 10%, white))',
}}
>
<XStack alignItems="center" gap="$2">
<Star size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$3" fontWeight="$7">
{topPhoto
? t('achievements.highlights.topTitle', 'Top photo')
: t('achievements.summary.topContributor', 'Top contributor')}
</Text>
</XStack>
<Text fontSize="$2" color="$color" opacity={0.7}>
{topPhoto
? t('achievements.highlights.likesAmount', { count: topPhoto.likes }, '{count} Likes')
: t('achievements.summary.placeholder', 'Keep sharing to unlock highlights.')}
</Text>
</YStack>
<XStack gap="$3">
{[1, 2].map((card) => (
<YStack
key={card}
flex={1}
padding="$3"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
gap="$1"
>
<Text fontSize="$4" fontWeight="$8">
{card === 1 ? totalTasks : totalPhotos}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7}>
{card === 1
? t('achievements.summary.tasksCompleted', 'Tasks completed')
: t('achievements.summary.photosShared', 'Photos shared')}
</Text>
</YStack>
))}
</XStack>
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
gap="$1"
>
<Text fontSize="$4" fontWeight="$7">
{totalLikes}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7}>
{t('achievements.summary.likesCollected', 'Likes collected')}
</Text>
</YStack>
</YStack>
</AppShell>
);
}

View File

@@ -0,0 +1,347 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Image as ImageIcon, Filter } from 'lucide-react';
import AppShell from '../components/AppShell';
import PhotoFrameTile from '../components/PhotoFrameTile';
import { useEventData } from '../context/EventDataContext';
import { fetchGallery } from '../services/photosApi';
import { usePollGalleryDelta } from '../hooks/usePollGalleryDelta';
import { useAppearance } from '@/hooks/use-appearance';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useLocale } from '@/guest/i18n/LocaleContext';
import { useNavigate } from 'react-router-dom';
import { buildEventPath } from '../lib/routes';
type GalleryFilter = 'latest' | 'popular' | 'mine' | 'photobooth';
type GalleryTile = {
id: number;
imageUrl: string;
likes: number;
createdAt?: string | null;
ingestSource?: string | null;
sessionId?: string | null;
};
function normalizeImageUrl(src?: string | null) {
if (!src) {
return '';
}
if (/^https?:/i.test(src)) {
return src;
}
let cleanPath = src.replace(/^\/+/g, '').replace(/\/+/g, '/');
if (!cleanPath.startsWith('storage/')) {
cleanPath = `storage/${cleanPath}`;
}
return `/${cleanPath}`.replace(/\/+/g, '/');
}
export default function GalleryScreen() {
const { token } = useEventData();
const { t } = useTranslation();
const { locale } = useLocale();
const navigate = useNavigate();
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
const cardShadow = isDark ? '0 18px 40px rgba(2, 6, 23, 0.4)' : '0 16px 30px rgba(15, 23, 42, 0.12)';
const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
const mutedButtonBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
const [photos, setPhotos] = React.useState<GalleryTile[]>([]);
const [loading, setLoading] = React.useState(false);
const { data: delta } = usePollGalleryDelta(token ?? null, { locale });
const [filter, setFilter] = React.useState<GalleryFilter>('latest');
React.useEffect(() => {
if (!token) {
setPhotos([]);
return;
}
let active = true;
setLoading(true);
fetchGallery(token, { limit: 18, locale })
.then((response) => {
if (!active) return;
const list = Array.isArray(response.data) ? response.data : [];
const mapped = list
.map((photo) => {
const record = photo as Record<string, unknown>;
const id = Number(record.id ?? 0);
const likesCount = typeof record.likes_count === 'number' ? record.likes_count : 0;
const imageUrl = normalizeImageUrl(
(record.thumbnail_url as string | null | undefined)
?? (record.thumbnail_path as string | null | undefined)
?? (record.file_path as string | null | undefined)
?? (record.full_url as string | null | undefined)
?? (record.url as string | null | undefined)
?? (record.image_url as string | null | undefined)
);
return {
id,
imageUrl,
likes: likesCount,
createdAt: typeof record.created_at === 'string' ? record.created_at : null,
ingestSource: typeof record.ingest_source === 'string' ? record.ingest_source : null,
sessionId: typeof record.session_id === 'string' ? record.session_id : null,
};
})
.filter((item) => item.id && item.imageUrl);
setPhotos(mapped);
})
.catch((error) => {
console.error('Failed to load gallery', error);
if (active) {
setPhotos([]);
}
})
.finally(() => {
if (active) {
setLoading(false);
}
});
return () => {
active = false;
};
}, [token, locale]);
const myPhotoIds = React.useMemo(() => {
try {
const raw = localStorage.getItem('my-photo-ids');
return new Set<number>(raw ? JSON.parse(raw) : []);
} catch {
return new Set<number>();
}
}, [token]);
const filteredPhotos = React.useMemo(() => {
let list = photos.slice();
if (filter === 'popular') {
list.sort((a, b) => (b.likes ?? 0) - (a.likes ?? 0));
} else if (filter === 'mine') {
list = list.filter((photo) => myPhotoIds.has(photo.id));
} else if (filter === 'photobooth') {
list = list.filter((photo) => photo.ingestSource === 'photobooth');
list.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime());
} else {
list.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime());
}
return list;
}, [filter, myPhotoIds, photos]);
const displayPhotos = filteredPhotos;
const leftColumn = displayPhotos.filter((_, index) => index % 2 === 0);
const rightColumn = displayPhotos.filter((_, index) => index % 2 === 1);
React.useEffect(() => {
if (filter === 'photobooth' && !photos.some((photo) => photo.ingestSource === 'photobooth')) {
setFilter('latest');
}
}, [filter, photos]);
const newUploads = React.useMemo(() => {
if (delta.photos.length === 0) {
return 0;
}
const existing = new Set(photos.map((item) => item.id));
return delta.photos.reduce((count, photo) => {
const id = Number((photo as Record<string, unknown>).id ?? 0);
if (id && !existing.has(id)) {
return count + 1;
}
return count;
}, 0);
}, [delta.photos, photos]);
const openLightbox = React.useCallback(
(photoId: number) => {
if (!token) return;
navigate(buildEventPath(token, `/photo/${photoId}`));
},
[navigate, token]
);
React.useEffect(() => {
if (delta.photos.length === 0) {
return;
}
setPhotos((prev) => {
const existing = new Set(prev.map((item) => item.id));
const mapped = delta.photos
.map((photo) => {
const record = photo as Record<string, unknown>;
const id = Number(record.id ?? 0);
const likesCount = typeof record.likes_count === 'number' ? record.likes_count : 0;
const imageUrl = normalizeImageUrl(
(record.thumbnail_url as string | null | undefined)
?? (record.thumbnail_path as string | null | undefined)
?? (record.file_path as string | null | undefined)
?? (record.full_url as string | null | undefined)
?? (record.url as string | null | undefined)
?? (record.image_url as string | null | undefined)
);
if (!id || !imageUrl || existing.has(id)) {
return null;
}
return {
id,
imageUrl,
likes: likesCount,
createdAt: typeof record.created_at === 'string' ? record.created_at : null,
ingestSource: typeof record.ingest_source === 'string' ? record.ingest_source : null,
sessionId: typeof record.session_id === 'string' ? record.session_id : null,
} satisfies GalleryTile;
})
.filter(Boolean) as GalleryTile[];
if (mapped.length === 0) {
return prev;
}
return [...mapped, ...prev];
});
}, [delta.photos]);
return (
<AppShell>
<YStack gap="$4">
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
gap="$3"
style={{
boxShadow: cardShadow,
}}
>
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" gap="$2">
<ImageIcon size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$4" fontWeight="$7">
{t('galleryPage.title', 'Gallery')}
</Text>
</XStack>
<Button
size="$3"
backgroundColor={mutedButton}
borderRadius="$pill"
borderWidth={1}
borderColor={mutedButtonBorder}
>
<Filter size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
</XStack>
<XStack gap="$2" flexWrap="wrap">
{(
[
{ value: 'latest', label: t('galleryPage.filters.latest', 'Newest') },
{ value: 'popular', label: t('galleryPage.filters.popular', 'Popular') },
{ value: 'mine', label: t('galleryPage.filters.mine', 'My photos') },
photos.some((photo) => photo.ingestSource === 'photobooth')
? { value: 'photobooth', label: t('galleryPage.filters.photobooth', 'Photo booth') }
: null,
].filter(Boolean) as Array<{ value: GalleryFilter; label: string }>
).map((chip) => (
<Button
key={chip.value}
size="$3"
backgroundColor={filter === chip.value ? '$primary' : mutedButton}
borderRadius="$pill"
borderWidth={1}
borderColor={filter === chip.value ? '$primary' : mutedButtonBorder}
onPress={() => setFilter(chip.value)}
>
<Text fontSize="$2" fontWeight="$6" color={filter === chip.value ? '#FFFFFF' : undefined}>
{chip.label}
</Text>
</Button>
))}
</XStack>
</YStack>
<XStack gap="$3">
<YStack flex={1} gap="$3">
{(loading || leftColumn.length === 0 ? Array.from({ length: 5 }, (_, index) => index) : leftColumn).map(
(tile, index) => {
if (typeof tile === 'number') {
return <PhotoFrameTile key={`left-${tile}`} height={140 + (index % 3) * 24} shimmer shimmerDelayMs={200 + index * 120} />;
}
return (
<Button
key={tile.id}
unstyled
onPress={() => openLightbox(tile.id)}
>
<PhotoFrameTile height={140 + (index % 3) * 24}>
<YStack flex={1} width="100%" height="100%" alignItems="center" justifyContent="center">
<img
src={tile.imageUrl}
alt={t('galleryPage.photo.alt', { id: tile.id }, 'Photo {id}')}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
</YStack>
</PhotoFrameTile>
</Button>
);
}
)}
</YStack>
<YStack flex={1} gap="$3">
{(loading || rightColumn.length === 0 ? Array.from({ length: 5 }, (_, index) => index) : rightColumn).map(
(tile, index) => {
if (typeof tile === 'number') {
return <PhotoFrameTile key={`right-${tile}`} height={120 + (index % 3) * 28} shimmer shimmerDelayMs={260 + index * 140} />;
}
return (
<Button
key={tile.id}
unstyled
onPress={() => openLightbox(tile.id)}
>
<PhotoFrameTile height={120 + (index % 3) * 28}>
<YStack flex={1} width="100%" height="100%" alignItems="center" justifyContent="center">
<img
src={tile.imageUrl}
alt={t('galleryPage.photo.alt', { id: tile.id }, 'Photo {id}')}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
</YStack>
</PhotoFrameTile>
</Button>
);
}
)}
</YStack>
</XStack>
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
gap="$1"
style={{
boxShadow: cardShadow,
}}
>
<Text fontSize="$3" fontWeight="$7">
{t('galleryPage.feed.title', 'Live feed')}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7}>
{newUploads > 0
? t('galleryPage.feed.newUploads', { count: newUploads }, '{count} new uploads just landed.')
: t('galleryPage.feed.description', 'Updated every few seconds.')}
</Text>
</YStack>
</YStack>
</AppShell>
);
}

Some files were not shown because too many files have changed in this diff Show More