upgrade to tamagui v2 and guest pwa overhaul
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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 …')}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 …')}
|
||||
|
||||
@@ -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 ...
|
||||
|
||||
@@ -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.')}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)'),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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"
|
||||
|
||||
31
resources/js/guest-v2/App.tsx
Normal file
31
resources/js/guest-v2/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
resources/js/guest-v2/__tests__/BottomDock.test.tsx
Normal file
53
resources/js/guest-v2/__tests__/BottomDock.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
53
resources/js/guest-v2/__tests__/EventLayout.test.tsx
Normal file
53
resources/js/guest-v2/__tests__/EventLayout.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
73
resources/js/guest-v2/__tests__/HelpCenterScreen.test.tsx
Normal file
73
resources/js/guest-v2/__tests__/HelpCenterScreen.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
75
resources/js/guest-v2/__tests__/HomeScreen.test.tsx
Normal file
75
resources/js/guest-v2/__tests__/HomeScreen.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
61
resources/js/guest-v2/__tests__/LandingScreen.test.tsx
Normal file
61
resources/js/guest-v2/__tests__/LandingScreen.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
28
resources/js/guest-v2/__tests__/NotFoundScreen.test.tsx
Normal file
28
resources/js/guest-v2/__tests__/NotFoundScreen.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
70
resources/js/guest-v2/__tests__/PhotoLightboxScreen.test.tsx
Normal file
70
resources/js/guest-v2/__tests__/PhotoLightboxScreen.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
155
resources/js/guest-v2/__tests__/ScreensCopy.test.tsx
Normal file
155
resources/js/guest-v2/__tests__/ScreensCopy.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
124
resources/js/guest-v2/__tests__/SettingsContent.test.tsx
Normal file
124
resources/js/guest-v2/__tests__/SettingsContent.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
58
resources/js/guest-v2/__tests__/SettingsSheet.test.tsx
Normal file
58
resources/js/guest-v2/__tests__/SettingsSheet.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
31
resources/js/guest-v2/__tests__/SlideshowScreen.test.tsx
Normal file
31
resources/js/guest-v2/__tests__/SlideshowScreen.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
60
resources/js/guest-v2/__tests__/TaskDetailScreen.test.tsx
Normal file
60
resources/js/guest-v2/__tests__/TaskDetailScreen.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
72
resources/js/guest-v2/__tests__/UploadQueueScreen.test.tsx
Normal file
72
resources/js/guest-v2/__tests__/UploadQueueScreen.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
87
resources/js/guest-v2/__tests__/UploadScreen.test.tsx
Normal file
87
resources/js/guest-v2/__tests__/UploadScreen.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
74
resources/js/guest-v2/__tests__/brandingTheme.test.ts
Normal file
74
resources/js/guest-v2/__tests__/brandingTheme.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
31
resources/js/guest-v2/__tests__/eventBranding.test.ts
Normal file
31
resources/js/guest-v2/__tests__/eventBranding.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
33
resources/js/guest-v2/__tests__/statsApi.test.ts
Normal file
33
resources/js/guest-v2/__tests__/statsApi.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
28
resources/js/guest-v2/components/AmbientBackground.tsx
Normal file
28
resources/js/guest-v2/components/AmbientBackground.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
236
resources/js/guest-v2/components/AppShell.tsx
Normal file
236
resources/js/guest-v2/components/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
resources/js/guest-v2/components/BottomDock.tsx
Normal file
75
resources/js/guest-v2/components/BottomDock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
resources/js/guest-v2/components/CompassHub.tsx
Normal file
150
resources/js/guest-v2/components/CompassHub.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
resources/js/guest-v2/components/FabActionSheet.tsx
Normal file
105
resources/js/guest-v2/components/FabActionSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
resources/js/guest-v2/components/FloatingActionButton.tsx
Normal file
55
resources/js/guest-v2/components/FloatingActionButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
267
resources/js/guest-v2/components/GuestAnalyticsNudge.tsx
Normal file
267
resources/js/guest-v2/components/GuestAnalyticsNudge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
200
resources/js/guest-v2/components/NotificationSheet.tsx
Normal file
200
resources/js/guest-v2/components/NotificationSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
resources/js/guest-v2/components/PhotoFrameTile.tsx
Normal file
71
resources/js/guest-v2/components/PhotoFrameTile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
362
resources/js/guest-v2/components/SettingsContent.tsx
Normal file
362
resources/js/guest-v2/components/SettingsContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
268
resources/js/guest-v2/components/SettingsSheet.tsx
Normal file
268
resources/js/guest-v2/components/SettingsSheet.tsx
Normal 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;
|
||||
}
|
||||
18
resources/js/guest-v2/components/StandaloneShell.tsx
Normal file
18
resources/js/guest-v2/components/StandaloneShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
resources/js/guest-v2/components/SurfaceCard.tsx
Normal file
35
resources/js/guest-v2/components/SurfaceCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
resources/js/guest-v2/components/TopBar.tsx
Normal file
97
resources/js/guest-v2/components/TopBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
resources/js/guest-v2/context/EventDataContext.tsx
Normal file
103
resources/js/guest-v2/context/EventDataContext.tsx
Normal 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);
|
||||
}
|
||||
111
resources/js/guest-v2/context/GuestIdentityContext.tsx
Normal file
111
resources/js/guest-v2/context/GuestIdentityContext.tsx
Normal 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);
|
||||
}
|
||||
82
resources/js/guest-v2/hooks/usePollGalleryDelta.ts
Normal file
82
resources/js/guest-v2/hooks/usePollGalleryDelta.ts
Normal 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;
|
||||
}
|
||||
57
resources/js/guest-v2/hooks/usePollStats.ts
Normal file
57
resources/js/guest-v2/hooks/usePollStats.ts
Normal 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;
|
||||
}
|
||||
74
resources/js/guest-v2/layouts/EventLayout.tsx
Normal file
74
resources/js/guest-v2/layouts/EventLayout.tsx
Normal 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}</>;
|
||||
}
|
||||
12
resources/js/guest-v2/layouts/GuestLocaleLayout.tsx
Normal file
12
resources/js/guest-v2/layouts/GuestLocaleLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
resources/js/guest-v2/lib/brandingTheme.ts
Normal file
1
resources/js/guest-v2/lib/brandingTheme.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './brandingTheme.tsx';
|
||||
66
resources/js/guest-v2/lib/brandingTheme.tsx
Normal file
66
resources/js/guest-v2/lib/brandingTheme.tsx
Normal 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>;
|
||||
}
|
||||
18
resources/js/guest-v2/lib/device.ts
Normal file
18
resources/js/guest-v2/lib/device.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
74
resources/js/guest-v2/lib/eventBranding.ts
Normal file
74
resources/js/guest-v2/lib/eventBranding.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
13
resources/js/guest-v2/lib/routes.ts
Normal file
13
resources/js/guest-v2/lib/routes.ts
Normal 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}`;
|
||||
}
|
||||
39
resources/js/guest-v2/lib/usePulseAnimation.ts
Normal file
39
resources/js/guest-v2/lib/usePulseAnimation.ts
Normal 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;
|
||||
}
|
||||
29
resources/js/guest-v2/lib/useStaggeredReveal.ts
Normal file
29
resources/js/guest-v2/lib/useStaggeredReveal.ts
Normal 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;
|
||||
}
|
||||
25
resources/js/guest-v2/main.tsx
Normal file
25
resources/js/guest-v2/main.tsx
Normal 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>
|
||||
);
|
||||
106
resources/js/guest-v2/router.tsx
Normal file
106
resources/js/guest-v2/router.tsx
Normal 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 /> },
|
||||
],
|
||||
{}
|
||||
);
|
||||
174
resources/js/guest-v2/screens/AchievementsScreen.tsx
Normal file
174
resources/js/guest-v2/screens/AchievementsScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
347
resources/js/guest-v2/screens/GalleryScreen.tsx
Normal file
347
resources/js/guest-v2/screens/GalleryScreen.tsx
Normal 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
Reference in New Issue
Block a user