upgrade to tamagui v2 and guest pwa overhaul
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
2147
package-lock.json
generated
2147
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
27
package.json
27
package.json
@@ -22,7 +22,7 @@
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@laravel/vite-plugin-wayfinder": "^0.1.7",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@tamagui/cli": "^1.144.2",
|
||||
"@tamagui/cli": "^2.0.0-rc.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
@@ -71,17 +71,18 @@
|
||||
"@sentry/vite-plugin": "^4.6.2",
|
||||
"@stripe/stripe-js": "^8.6.1",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tamagui/button": "~1.144.2",
|
||||
"@tamagui/config": "~1.144.2",
|
||||
"@tamagui/font": "~1.144.2",
|
||||
"@tamagui/group": "~1.144.2",
|
||||
"@tamagui/list-item": "~1.144.2",
|
||||
"@tamagui/radio-group": "~1.144.2",
|
||||
"@tamagui/stacks": "~1.144.2",
|
||||
"@tamagui/switch": "~1.144.2",
|
||||
"@tamagui/text": "~1.144.2",
|
||||
"@tamagui/themes": "~1.144.2",
|
||||
"@tamagui/vite-plugin": "~1.144.2",
|
||||
"@tamagui/animations-react-native": "^2.0.0-rc.0",
|
||||
"@tamagui/button": "~2.0.0-rc.0",
|
||||
"@tamagui/config": "~2.0.0-rc.0",
|
||||
"@tamagui/font": "~2.0.0-rc.0",
|
||||
"@tamagui/group": "~2.0.0-rc.0",
|
||||
"@tamagui/list-item": "~2.0.0-rc.0",
|
||||
"@tamagui/radio-group": "~2.0.0-rc.0",
|
||||
"@tamagui/stacks": "~2.0.0-rc.0",
|
||||
"@tamagui/switch": "~2.0.0-rc.0",
|
||||
"@tamagui/text": "~2.0.0-rc.0",
|
||||
"@tamagui/themes": "~2.0.0-rc.0",
|
||||
"@tamagui/vite-plugin": "~2.0.0-rc.0",
|
||||
"@tanstack/react-query": "^5.90.19",
|
||||
"@types/react": "^19.2.8",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -114,7 +115,7 @@
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tamagui": "^1.144.2",
|
||||
"tamagui": "^2.0.0-rc.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
|
||||
@@ -4,6 +4,54 @@
|
||||
|
||||
@import '../../public/fonts/google/fonts.css';
|
||||
|
||||
@keyframes guestNightAmbientDrift {
|
||||
0% {
|
||||
background-position: 0% 0%, 100% 0%, 0% 0%;
|
||||
}
|
||||
50% {
|
||||
background-position: 12% 8%, 88% 12%, 0% 0%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 0%, 100% 0%, 0% 0%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes guestNightShimmer {
|
||||
0% {
|
||||
transform: translateX(-180px);
|
||||
opacity: 0.25;
|
||||
}
|
||||
45% {
|
||||
transform: translateX(180px);
|
||||
opacity: 0.75;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(180px);
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes guestCompassFlyIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(120px, 260px) scale(0.35) rotate(-90deg);
|
||||
}
|
||||
60% {
|
||||
opacity: 1;
|
||||
transform: translate(0px, 0px) scale(1.05) rotate(8deg);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translate(0px, 0px) scale(1) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.guest-compass-flyin {
|
||||
animation: guestCompassFlyIn 650ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
transform-origin: center;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
@source '../views';
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user