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",
|
"@eslint/js": "^9.19.0",
|
||||||
"@laravel/vite-plugin-wayfinder": "^0.1.7",
|
"@laravel/vite-plugin-wayfinder": "^0.1.7",
|
||||||
"@playwright/test": "^1.57.0",
|
"@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/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.1",
|
"@testing-library/react": "^16.3.1",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
@@ -71,17 +71,18 @@
|
|||||||
"@sentry/vite-plugin": "^4.6.2",
|
"@sentry/vite-plugin": "^4.6.2",
|
||||||
"@stripe/stripe-js": "^8.6.1",
|
"@stripe/stripe-js": "^8.6.1",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tamagui/button": "~1.144.2",
|
"@tamagui/animations-react-native": "^2.0.0-rc.0",
|
||||||
"@tamagui/config": "~1.144.2",
|
"@tamagui/button": "~2.0.0-rc.0",
|
||||||
"@tamagui/font": "~1.144.2",
|
"@tamagui/config": "~2.0.0-rc.0",
|
||||||
"@tamagui/group": "~1.144.2",
|
"@tamagui/font": "~2.0.0-rc.0",
|
||||||
"@tamagui/list-item": "~1.144.2",
|
"@tamagui/group": "~2.0.0-rc.0",
|
||||||
"@tamagui/radio-group": "~1.144.2",
|
"@tamagui/list-item": "~2.0.0-rc.0",
|
||||||
"@tamagui/stacks": "~1.144.2",
|
"@tamagui/radio-group": "~2.0.0-rc.0",
|
||||||
"@tamagui/switch": "~1.144.2",
|
"@tamagui/stacks": "~2.0.0-rc.0",
|
||||||
"@tamagui/text": "~1.144.2",
|
"@tamagui/switch": "~2.0.0-rc.0",
|
||||||
"@tamagui/themes": "~1.144.2",
|
"@tamagui/text": "~2.0.0-rc.0",
|
||||||
"@tamagui/vite-plugin": "~1.144.2",
|
"@tamagui/themes": "~2.0.0-rc.0",
|
||||||
|
"@tamagui/vite-plugin": "~2.0.0-rc.0",
|
||||||
"@tanstack/react-query": "^5.90.19",
|
"@tanstack/react-query": "^5.90.19",
|
||||||
"@types/react": "^19.2.8",
|
"@types/react": "^19.2.8",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -114,7 +115,7 @@
|
|||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tamagui": "^1.144.2",
|
"tamagui": "^2.0.0-rc.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,54 @@
|
|||||||
|
|
||||||
@import '../../public/fonts/google/fonts.css';
|
@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 '../views';
|
||||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
@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)"
|
borderColor="rgba(234,179,8,0.5)"
|
||||||
backgroundColor="rgba(255,255,255,0.95)"
|
backgroundColor="rgba(255,255,255,0.95)"
|
||||||
padding="$3"
|
padding="$3"
|
||||||
space="$2"
|
gap="$2"
|
||||||
borderRadius="$4"
|
borderRadius="$4"
|
||||||
shadowColor="#f59e0b"
|
shadowColor="#f59e0b"
|
||||||
shadowOpacity={0.25}
|
shadowOpacity={0.25}
|
||||||
@@ -102,7 +102,7 @@ export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: D
|
|||||||
maxWidth={320}
|
maxWidth={320}
|
||||||
>
|
>
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Text fontSize={13} fontWeight="800" color="#92400e">
|
<Text fontSize={13} fontWeight="800" color="#92400e">
|
||||||
Demo tenants
|
Demo tenants
|
||||||
</Text>
|
</Text>
|
||||||
@@ -119,7 +119,7 @@ export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: D
|
|||||||
aria-label="Switcher minimieren"
|
aria-label="Switcher minimieren"
|
||||||
/>
|
/>
|
||||||
</XStack>
|
</XStack>
|
||||||
<YStack space="$1">
|
<YStack gap="$1">
|
||||||
{DEV_TENANT_KEYS.map(({ key, label }) => (
|
{DEV_TENANT_KEYS.map(({ key, label }) => (
|
||||||
<Button
|
<Button
|
||||||
key={key}
|
key={key}
|
||||||
@@ -162,7 +162,7 @@ export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: D
|
|||||||
right="$4"
|
right="$4"
|
||||||
zIndex={1000}
|
zIndex={1000}
|
||||||
maxWidth={320}
|
maxWidth={320}
|
||||||
space="$2"
|
gap="$2"
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
borderColor="rgba(234,179,8,0.5)"
|
borderColor="rgba(234,179,8,0.5)"
|
||||||
backgroundColor="rgba(255,255,255,0.95)"
|
backgroundColor="rgba(255,255,255,0.95)"
|
||||||
@@ -176,7 +176,7 @@ export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: D
|
|||||||
style={{ bottom: bottomOffset + 70 }}
|
style={{ bottom: bottomOffset + 70 }}
|
||||||
>
|
>
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Text fontSize={13} fontWeight="800" color="#92400e">
|
<Text fontSize={13} fontWeight="800" color="#92400e">
|
||||||
Demo tenants
|
Demo tenants
|
||||||
</Text>
|
</Text>
|
||||||
@@ -196,7 +196,7 @@ export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: D
|
|||||||
<Text fontSize={11} color="#a16207">
|
<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.
|
Select a seeded tenant to mint Sanctum PATs and jump straight into their admin space. Available only in development builds.
|
||||||
</Text>
|
</Text>
|
||||||
<YStack space="$1">
|
<YStack gap="$1">
|
||||||
{DEV_TENANT_KEYS.map(({ key, label }) => (
|
{DEV_TENANT_KEYS.map(({ key, label }) => (
|
||||||
<Button
|
<Button
|
||||||
key={key}
|
key={key}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export default function AuthCallbackPage(): React.ReactElement {
|
|||||||
shadowRadius={16}
|
shadowRadius={16}
|
||||||
shadowOffset={{ width: 0, height: 10 }}
|
shadowOffset={{ width: 0, height: 10 }}
|
||||||
>
|
>
|
||||||
<YStack alignItems="center" space="$2">
|
<YStack alignItems="center" gap="$2">
|
||||||
<Spinner size="small" color={textStrong} />
|
<Spinner size="small" color={textStrong} />
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('processing.title', 'Signing you in …')}
|
{t('processing.title', 'Signing you in …')}
|
||||||
|
|||||||
@@ -274,9 +274,9 @@ export default function MobileBillingPage() {
|
|||||||
<ContextHelpLink slug="billing-packages-exports" />
|
<ContextHelpLink slug="billing-packages-exports" />
|
||||||
</XStack>
|
</XStack>
|
||||||
{pendingCheckout && (checkoutStatus === 'failed' || checkoutStatus === 'cancelled') ? (
|
{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">
|
<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}>
|
<Text fontSize="$sm" fontWeight="800" color={danger}>
|
||||||
{t('billing.checkoutFailedTitle', 'Checkout failed')}
|
{t('billing.checkoutFailedTitle', 'Checkout failed')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -296,7 +296,7 @@ export default function MobileBillingPage() {
|
|||||||
{t('billing.checkoutFailedBadge', 'Failed')}
|
{t('billing.checkoutFailedBadge', 'Failed')}
|
||||||
</PillBadge>
|
</PillBadge>
|
||||||
</XStack>
|
</XStack>
|
||||||
<XStack space="$2">
|
<XStack gap="$2">
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={t('billing.checkoutFailedRetry', 'Try again')}
|
label={t('billing.checkoutFailedRetry', 'Try again')}
|
||||||
onPress={() => navigate(shopLink)}
|
onPress={() => navigate(shopLink)}
|
||||||
@@ -312,9 +312,9 @@ export default function MobileBillingPage() {
|
|||||||
</MobileCard>
|
</MobileCard>
|
||||||
) : null}
|
) : null}
|
||||||
{pendingCheckout && checkoutStatus === 'requires_customer_action' ? (
|
{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">
|
<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}>
|
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
||||||
{t('billing.checkoutActionTitle', 'Action required')}
|
{t('billing.checkoutActionTitle', 'Action required')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -326,7 +326,7 @@ export default function MobileBillingPage() {
|
|||||||
{t('billing.checkoutActionBadge', 'Action needed')}
|
{t('billing.checkoutActionBadge', 'Action needed')}
|
||||||
</PillBadge>
|
</PillBadge>
|
||||||
</XStack>
|
</XStack>
|
||||||
<XStack space="$2">
|
<XStack gap="$2">
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={t('billing.checkoutActionButton', 'Continue checkout')}
|
label={t('billing.checkoutActionButton', 'Continue checkout')}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@@ -348,9 +348,9 @@ export default function MobileBillingPage() {
|
|||||||
</MobileCard>
|
</MobileCard>
|
||||||
) : null}
|
) : null}
|
||||||
{pendingCheckout && checkoutStatus !== 'failed' && checkoutStatus !== 'cancelled' && checkoutStatus !== 'requires_customer_action' ? (
|
{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">
|
<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}>
|
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
||||||
{t('billing.checkoutPendingTitle', 'Activating your package')}
|
{t('billing.checkoutPendingTitle', 'Activating your package')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -365,7 +365,7 @@ export default function MobileBillingPage() {
|
|||||||
{t('billing.checkoutPendingBadge', 'Pending')}
|
{t('billing.checkoutPendingBadge', 'Pending')}
|
||||||
</PillBadge>
|
</PillBadge>
|
||||||
</XStack>
|
</XStack>
|
||||||
<XStack space="$2">
|
<XStack gap="$2">
|
||||||
<CTAButton label={t('billing.checkoutPendingRefresh', 'Refresh')} onPress={load} fullWidth={false} />
|
<CTAButton label={t('billing.checkoutPendingRefresh', 'Refresh')} onPress={load} fullWidth={false} />
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={t('billing.checkoutPendingDismiss', 'Dismiss')}
|
label={t('billing.checkoutPendingDismiss', 'Dismiss')}
|
||||||
@@ -377,8 +377,8 @@ export default function MobileBillingPage() {
|
|||||||
</MobileCard>
|
</MobileCard>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<MobileCard space="$2" ref={packagesRef as any}>
|
<MobileCard gap="$2" ref={packagesRef as any}>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Package size={18} color={textStrong} />
|
<Package size={18} color={textStrong} />
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('billing.sections.packages.title', 'Packages')}
|
{t('billing.sections.packages.title', 'Packages')}
|
||||||
@@ -397,7 +397,7 @@ export default function MobileBillingPage() {
|
|||||||
{t('common.loading', 'Lädt...')}
|
{t('common.loading', 'Lädt...')}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
{activePackage ? (
|
{activePackage ? (
|
||||||
<PackageCard
|
<PackageCard
|
||||||
pkg={activePackage}
|
pkg={activePackage}
|
||||||
@@ -415,8 +415,8 @@ export default function MobileBillingPage() {
|
|||||||
)}
|
)}
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$2" ref={invoicesRef as any}>
|
<MobileCard gap="$2" ref={invoicesRef as any}>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Receipt size={18} color={textStrong} />
|
<Receipt size={18} color={textStrong} />
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('billing.sections.invoices.title', 'Invoices & Payments')}
|
{t('billing.sections.invoices.title', 'Invoices & Payments')}
|
||||||
@@ -430,7 +430,7 @@ export default function MobileBillingPage() {
|
|||||||
{t('common.loading', 'Lädt...')}
|
{t('common.loading', 'Lädt...')}
|
||||||
</Text>
|
</Text>
|
||||||
) : transactions.length === 0 ? (
|
) : transactions.length === 0 ? (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$sm" color={text}>
|
<Text fontSize="$sm" color={text}>
|
||||||
{t('billing.sections.invoices.empty', 'Keine Zahlungen gefunden.')}
|
{t('billing.sections.invoices.empty', 'Keine Zahlungen gefunden.')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -438,7 +438,7 @@ export default function MobileBillingPage() {
|
|||||||
<CTAButton label={t('billing.actions.contactSupport', 'Contact support')} tone="ghost" onPress={openSupport} />
|
<CTAButton label={t('billing.actions.contactSupport', 'Contact support')} tone="ghost" onPress={openSupport} />
|
||||||
</YStack>
|
</YStack>
|
||||||
) : (
|
) : (
|
||||||
<YStack space="$1.5">
|
<YStack gap="$1.5">
|
||||||
{transactions.slice(0, 8).map((trx) => (
|
{transactions.slice(0, 8).map((trx) => (
|
||||||
<XStack key={trx.id} alignItems="center" justifyContent="space-between" borderBottomWidth={1} borderColor={border} paddingVertical="$1.5">
|
<XStack key={trx.id} alignItems="center" justifyContent="space-between" borderBottomWidth={1} borderColor={border} paddingVertical="$1.5">
|
||||||
<YStack>
|
<YStack>
|
||||||
@@ -475,8 +475,8 @@ export default function MobileBillingPage() {
|
|||||||
)}
|
)}
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$2">
|
<MobileCard gap="$2">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Sparkles size={18} color={textStrong} />
|
<Sparkles size={18} color={textStrong} />
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('billing.sections.addOns.title', 'Add-ons')}
|
{t('billing.sections.addOns.title', 'Add-ons')}
|
||||||
@@ -494,7 +494,7 @@ export default function MobileBillingPage() {
|
|||||||
{t('billing.sections.addOns.empty', 'Keine Add-ons gebucht.')}
|
{t('billing.sections.addOns.empty', 'Keine Add-ons gebucht.')}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<YStack space="$1.5">
|
<YStack gap="$1.5">
|
||||||
{addons.slice(0, 8).map((addon) => (
|
{addons.slice(0, 8).map((addon) => (
|
||||||
<AddonRow key={addon.id} addon={addon} />
|
<AddonRow key={addon.id} addon={addon} />
|
||||||
))}
|
))}
|
||||||
@@ -550,7 +550,7 @@ function PackageCard({
|
|||||||
borderColor={isActive ? primary : border}
|
borderColor={isActive ? primary : border}
|
||||||
borderWidth={isActive ? 2 : 1}
|
borderWidth={isActive ? 2 : 1}
|
||||||
backgroundColor={isActive ? accentSoft : undefined}
|
backgroundColor={isActive ? accentSoft : undefined}
|
||||||
space="$2"
|
gap="$2"
|
||||||
>
|
>
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
@@ -563,7 +563,7 @@ function PackageCard({
|
|||||||
{expires}
|
{expires}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
<XStack space="$2" marginTop="$2" flexWrap="wrap">
|
<XStack gap="$2" marginTop="$2" flexWrap="wrap">
|
||||||
<PillBadge tone="muted">{remainingText}</PillBadge>
|
<PillBadge tone="muted">{remainingText}</PillBadge>
|
||||||
{pkg.price !== null && pkg.price !== undefined ? (
|
{pkg.price !== null && pkg.price !== undefined ? (
|
||||||
<PillBadge tone="muted">{formatAmount(pkg.price, pkg.currency ?? 'EUR')}</PillBadge>
|
<PillBadge tone="muted">{formatAmount(pkg.price, pkg.currency ?? 'EUR')}</PillBadge>
|
||||||
@@ -578,7 +578,7 @@ function PackageCard({
|
|||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{limitEntries.length ? (
|
{limitEntries.length ? (
|
||||||
<YStack space="$1.5" marginTop="$2">
|
<YStack gap="$1.5" marginTop="$2">
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{t('mobileBilling.details.limitsTitle', 'Limits')}
|
{t('mobileBilling.details.limitsTitle', 'Limits')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -595,12 +595,12 @@ function PackageCard({
|
|||||||
</YStack>
|
</YStack>
|
||||||
) : null}
|
) : null}
|
||||||
{featureKeys.length ? (
|
{featureKeys.length ? (
|
||||||
<YStack space="$1.5" marginTop="$2">
|
<YStack gap="$1.5" marginTop="$2">
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{t('mobileBilling.details.featuresTitle', 'Features')}
|
{t('mobileBilling.details.featuresTitle', 'Features')}
|
||||||
</Text>
|
</Text>
|
||||||
{featureKeys.map((feature) => (
|
{featureKeys.map((feature) => (
|
||||||
<XStack key={feature} alignItems="center" space="$2">
|
<XStack key={feature} alignItems="center" gap="$2">
|
||||||
<Sparkles size={14} color={primary} />
|
<Sparkles size={14} color={primary} />
|
||||||
<Text fontSize="$xs" color={textStrong}>
|
<Text fontSize="$xs" color={textStrong}>
|
||||||
{getPackageFeatureLabel(feature, t)}
|
{getPackageFeatureLabel(feature, t)}
|
||||||
@@ -610,7 +610,7 @@ function PackageCard({
|
|||||||
</YStack>
|
</YStack>
|
||||||
) : null}
|
) : null}
|
||||||
{usageMetrics.length ? (
|
{usageMetrics.length ? (
|
||||||
<YStack space="$2" marginTop="$2">
|
<YStack gap="$2" marginTop="$2">
|
||||||
{usageMetrics.map((metric) => (
|
{usageMetrics.map((metric) => (
|
||||||
<UsageBar key={metric.key} metric={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;
|
const fillColor = status === 'danger' ? danger : status === 'warning' ? warningText : primary;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack space="$1.5">
|
<YStack gap="$1.5">
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{labelMap[metric.key]}
|
{labelMap[metric.key]}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack alignItems="center" space="$1.5">
|
<XStack alignItems="center" gap="$1.5">
|
||||||
{statusLabel ? <PillBadge tone={status === 'danger' ? 'danger' : 'warning'}>{statusLabel}</PillBadge> : null}
|
{statusLabel ? <PillBadge tone={status === 'danger' ? 'danger' : 'warning'}>{statusLabel}</PillBadge> : null}
|
||||||
<Text fontSize="$xs" color={textStrong} fontWeight="700">
|
<Text fontSize="$xs" color={textStrong} fontWeight="700">
|
||||||
{valueText}
|
{valueText}
|
||||||
@@ -737,7 +737,7 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
|
|||||||
const eventPath = addon.event?.slug ? ADMIN_EVENT_VIEW_PATH(addon.event.slug) : null;
|
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 hasImpact = Boolean(addon.extra_photos || addon.extra_guests || addon.extra_gallery_days);
|
||||||
const impactBadges = hasImpact ? (
|
const impactBadges = hasImpact ? (
|
||||||
<XStack space="$2" marginTop="$1.5" flexWrap="wrap">
|
<XStack gap="$2" marginTop="$1.5" flexWrap="wrap">
|
||||||
{addon.extra_photos ? (
|
{addon.extra_photos ? (
|
||||||
<PillBadge tone="muted">{t('mobileBilling.extra.photos', '+{{count}} photos', { count: addon.extra_photos })}</PillBadge>
|
<PillBadge tone="muted">{t('mobileBilling.extra.photos', '+{{count}} photos', { count: addon.extra_photos })}</PillBadge>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -751,7 +751,7 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
|
|||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileCard borderColor={border} padding="$3" space="$1.5">
|
<MobileCard borderColor={border} padding="$3" gap="$1.5">
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{addon.label ?? addon.addon_key}
|
{addon.label ?? addon.addon_key}
|
||||||
|
|||||||
@@ -389,7 +389,7 @@ export default function MobileBrandingPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{t('events.watermark.previewTitle', 'Watermark Preview')}
|
{t('events.watermark.previewTitle', 'Watermark Preview')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -421,7 +421,7 @@ export default function MobileBrandingPage() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('events.watermark.title', 'Wasserzeichen')}
|
{t('events.watermark.title', 'Wasserzeichen')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -449,14 +449,14 @@ export default function MobileBrandingPage() {
|
|||||||
</MobileField>
|
</MobileField>
|
||||||
|
|
||||||
{resolvedMode === 'custom' && !controlsLocked ? (
|
{resolvedMode === 'custom' && !controlsLocked ? (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{t('events.watermark.upload', 'Wasserzeichen hochladen')}
|
{t('events.watermark.upload', 'Wasserzeichen hochladen')}
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable onPress={() => document.getElementById('watermark-upload-input')?.click()}>
|
<Pressable onPress={() => document.getElementById('watermark-upload-input')?.click()}>
|
||||||
<XStack
|
<XStack
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
space="$2"
|
gap="$2"
|
||||||
paddingHorizontal="$3.5"
|
paddingHorizontal="$3.5"
|
||||||
paddingVertical="$2.5"
|
paddingVertical="$2.5"
|
||||||
borderRadius={12}
|
borderRadius={12}
|
||||||
@@ -520,7 +520,7 @@ export default function MobileBrandingPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('events.watermark.placement', 'Position & Größe')}
|
{t('events.watermark.placement', 'Position & Größe')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -603,8 +603,8 @@ export default function MobileBrandingPage() {
|
|||||||
<ContextHelpLink slug="event-branding-assets" />
|
<ContextHelpLink slug="event-branding-assets" />
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<MobileCard space="$2">
|
<MobileCard gap="$2">
|
||||||
<XStack space="$2">
|
<XStack gap="$2">
|
||||||
<TabButton label={t('events.branding.titleShort', 'Branding')} active={activeTab === 'branding'} onPress={() => setActiveTab('branding')} />
|
<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')} />
|
<TabButton label={t('events.watermark.tab', 'Wasserzeichen')} active={activeTab === 'watermark'} onPress={() => setActiveTab('watermark')} />
|
||||||
</XStack>
|
</XStack>
|
||||||
@@ -612,20 +612,20 @@ export default function MobileBrandingPage() {
|
|||||||
|
|
||||||
{activeTab === 'branding' ? (
|
{activeTab === 'branding' ? (
|
||||||
<>
|
<>
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{t('events.branding.previewTitle', 'Guest App Preview')}
|
{t('events.branding.previewTitle', 'Guest App Preview')}
|
||||||
</Text>
|
</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 width="100%" borderRadius={12} backgroundColor={previewSurface} borderWidth={1} borderColor={previewBorder} overflow="hidden">
|
||||||
<YStack
|
<YStack
|
||||||
height={64}
|
height={64}
|
||||||
style={{ background: `linear-gradient(135deg, ${previewForm.primary}, ${previewForm.accent})` }}
|
style={{ background: `linear-gradient(135deg, ${previewForm.primary}, ${previewForm.accent})` }}
|
||||||
/>
|
/>
|
||||||
<YStack padding="$3" space="$2">
|
<YStack padding="$3" gap="$2">
|
||||||
<XStack
|
<XStack
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
space="$2"
|
gap="$2"
|
||||||
flexDirection={previewForm.logoPosition === 'center' ? 'column' : previewForm.logoPosition === 'right' ? 'row-reverse' : 'row'}
|
flexDirection={previewForm.logoPosition === 'center' ? 'column' : previewForm.logoPosition === 'right' ? 'row-reverse' : 'row'}
|
||||||
justifyContent={previewForm.logoPosition === 'center' ? 'center' : 'flex-start'}
|
justifyContent={previewForm.logoPosition === 'center' ? 'center' : 'flex-start'}
|
||||||
>
|
>
|
||||||
@@ -665,7 +665,7 @@ export default function MobileBrandingPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</YStack>
|
</YStack>
|
||||||
</XStack>
|
</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.primary} label={t('events.branding.primary', 'Primary')} borderColor={previewBorder} />
|
||||||
<ColorSwatch color={previewForm.accent} label={t('events.branding.accent', 'Accent')} 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} />
|
<ColorSwatch color={previewBackground} label={t('events.branding.background', 'Background')} borderColor={previewBorder} />
|
||||||
@@ -701,11 +701,11 @@ export default function MobileBrandingPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<>
|
<>
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('events.branding.mode', 'Theme')}
|
{t('events.branding.mode', 'Theme')}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack space="$2">
|
<XStack gap="$2">
|
||||||
<ModeButton
|
<ModeButton
|
||||||
label={t('events.branding.modeLight', 'Light')}
|
label={t('events.branding.modeLight', 'Light')}
|
||||||
active={form.mode === 'light'}
|
active={form.mode === 'light'}
|
||||||
@@ -727,7 +727,7 @@ export default function MobileBrandingPage() {
|
|||||||
</XStack>
|
</XStack>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('events.branding.colors', 'Colors')}
|
{t('events.branding.colors', 'Colors')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -757,7 +757,7 @@ export default function MobileBrandingPage() {
|
|||||||
/>
|
/>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('events.branding.fonts', 'Fonts')}
|
{t('events.branding.fonts', 'Fonts')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -786,7 +786,7 @@ export default function MobileBrandingPage() {
|
|||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{t('events.branding.fontSize', 'Font Size')}
|
{t('events.branding.fontSize', 'Font Size')}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack space="$2">
|
<XStack gap="$2">
|
||||||
<ModeButton
|
<ModeButton
|
||||||
label={t('events.branding.fontSizeSmall', 'S')}
|
label={t('events.branding.fontSizeSmall', 'S')}
|
||||||
active={form.fontSize === 's'}
|
active={form.fontSize === 's'}
|
||||||
@@ -808,14 +808,14 @@ export default function MobileBrandingPage() {
|
|||||||
</XStack>
|
</XStack>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('events.branding.logo', 'Logo')}
|
{t('events.branding.logo', 'Logo')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fontSize="$sm" color={muted}>
|
<Text fontSize="$sm" color={muted}>
|
||||||
{t('events.branding.logoHint', 'Upload a logo or use an emoji for the guest header.')}
|
{t('events.branding.logoHint', 'Upload a logo or use an emoji for the guest header.')}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack space="$2">
|
<XStack gap="$2">
|
||||||
<ModeButton
|
<ModeButton
|
||||||
label={t('events.branding.logoModeUpload', 'Upload')}
|
label={t('events.branding.logoModeUpload', 'Upload')}
|
||||||
active={form.logoMode === 'upload'}
|
active={form.logoMode === 'upload'}
|
||||||
@@ -847,7 +847,7 @@ export default function MobileBrandingPage() {
|
|||||||
padding="$3"
|
padding="$3"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
space="$2"
|
gap="$2"
|
||||||
>
|
>
|
||||||
{form.logoDataUrl ? (
|
{form.logoDataUrl ? (
|
||||||
<>
|
<>
|
||||||
@@ -856,7 +856,7 @@ export default function MobileBrandingPage() {
|
|||||||
alt={t('events.branding.logoAlt', 'Logo')}
|
alt={t('events.branding.logoAlt', 'Logo')}
|
||||||
style={{ maxHeight: 80, maxWidth: '100%', objectFit: 'contain' }}
|
style={{ maxHeight: 80, maxWidth: '100%', objectFit: 'contain' }}
|
||||||
/>
|
/>
|
||||||
<XStack space="$2">
|
<XStack gap="$2">
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={t('events.branding.replaceLogo', 'Replace logo')}
|
label={t('events.branding.replaceLogo', 'Replace logo')}
|
||||||
onPress={() => document.getElementById('branding-logo-input')?.click()}
|
onPress={() => document.getElementById('branding-logo-input')?.click()}
|
||||||
@@ -868,7 +868,7 @@ export default function MobileBrandingPage() {
|
|||||||
>
|
>
|
||||||
<XStack
|
<XStack
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
space="$1.5"
|
gap="$1.5"
|
||||||
paddingHorizontal="$3"
|
paddingHorizontal="$3"
|
||||||
paddingVertical="$2"
|
paddingVertical="$2"
|
||||||
borderRadius={12}
|
borderRadius={12}
|
||||||
@@ -892,7 +892,7 @@ export default function MobileBrandingPage() {
|
|||||||
>
|
>
|
||||||
<XStack
|
<XStack
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
space="$2"
|
gap="$2"
|
||||||
paddingHorizontal="$3.5"
|
paddingHorizontal="$3.5"
|
||||||
paddingVertical="$2.5"
|
paddingVertical="$2.5"
|
||||||
borderRadius={12}
|
borderRadius={12}
|
||||||
@@ -939,7 +939,7 @@ export default function MobileBrandingPage() {
|
|||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{t('events.branding.logoPosition', 'Position')}
|
{t('events.branding.logoPosition', 'Position')}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack space="$2">
|
<XStack gap="$2">
|
||||||
<ModeButton
|
<ModeButton
|
||||||
label={t('events.branding.positionLeft', 'Left')}
|
label={t('events.branding.positionLeft', 'Left')}
|
||||||
active={form.logoPosition === 'left'}
|
active={form.logoPosition === 'left'}
|
||||||
@@ -962,7 +962,7 @@ export default function MobileBrandingPage() {
|
|||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{t('events.branding.logoSize', 'Size')}
|
{t('events.branding.logoSize', 'Size')}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack space="$2">
|
<XStack gap="$2">
|
||||||
<ModeButton
|
<ModeButton
|
||||||
label={t('events.branding.logoSizeSmall', 'S')}
|
label={t('events.branding.logoSizeSmall', 'S')}
|
||||||
active={form.logoSize === 's'}
|
active={form.logoSize === 's'}
|
||||||
@@ -984,14 +984,14 @@ export default function MobileBrandingPage() {
|
|||||||
</XStack>
|
</XStack>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('events.branding.buttons', 'Buttons & Links')}
|
{t('events.branding.buttons', 'Buttons & Links')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fontSize="$sm" color={muted}>
|
<Text fontSize="$sm" color={muted}>
|
||||||
{t('events.branding.buttonsHint', 'Style, radius, and link color for CTA buttons.')}
|
{t('events.branding.buttonsHint', 'Style, radius, and link color for CTA buttons.')}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack space="$2">
|
<XStack gap="$2">
|
||||||
<ModeButton
|
<ModeButton
|
||||||
label={t('events.branding.buttonFilled', 'Filled')}
|
label={t('events.branding.buttonFilled', 'Filled')}
|
||||||
active={form.buttonStyle === 'filled'}
|
active={form.buttonStyle === 'filled'}
|
||||||
@@ -1039,7 +1039,7 @@ export default function MobileBrandingPage() {
|
|||||||
renderWatermarkTab()
|
renderWatermarkTab()
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<CTAButton label={saving ? t('events.branding.saving', 'Saving...') : t('events.branding.save', 'Save Branding')} onPress={() => handleSave()} />
|
<CTAButton label={saving ? t('events.branding.saving', 'Saving...') : t('events.branding.save', 'Save Branding')} onPress={() => handleSave()} />
|
||||||
<Pressable disabled={loading || saving} onPress={handleReset}>
|
<Pressable disabled={loading || saving} onPress={handleReset}>
|
||||||
<XStack
|
<XStack
|
||||||
@@ -1050,7 +1050,7 @@ export default function MobileBrandingPage() {
|
|||||||
backgroundColor={surface}
|
backgroundColor={surface}
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
borderColor={border}
|
borderColor={border}
|
||||||
space="$2"
|
gap="$2"
|
||||||
>
|
>
|
||||||
<RefreshCcw size={16} color={textStrong} />
|
<RefreshCcw size={16} color={textStrong} />
|
||||||
<Text fontSize="$sm" color={textStrong} fontWeight="700">
|
<Text fontSize="$sm" color={textStrong} fontWeight="700">
|
||||||
@@ -1067,7 +1067,7 @@ export default function MobileBrandingPage() {
|
|||||||
footer={null}
|
footer={null}
|
||||||
bottomOffsetPx={120}
|
bottomOffsetPx={120}
|
||||||
>
|
>
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
{fontsLoading ? (
|
{fontsLoading ? (
|
||||||
Array.from({ length: 4 }).map((_, idx) => <SkeletonCard key={`font-sk-${idx}`} height={48} />)
|
Array.from({ length: 4 }).map((_, idx) => <SkeletonCard key={`font-sk-${idx}`} height={48} />)
|
||||||
) : fonts.length === 0 ? (
|
) : fonts.length === 0 ? (
|
||||||
@@ -1228,11 +1228,11 @@ function ColorField({
|
|||||||
}) {
|
}) {
|
||||||
const { textStrong, muted } = useAdminTheme();
|
const { textStrong, muted } = useAdminTheme();
|
||||||
return (
|
return (
|
||||||
<YStack space="$2" opacity={disabled ? 0.6 : 1}>
|
<YStack gap="$2" opacity={disabled ? 0.6 : 1}>
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<MobileColorInput
|
<MobileColorInput
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => onChange(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
@@ -1249,7 +1249,7 @@ function ColorField({
|
|||||||
function ColorSwatch({ color, label, borderColor }: { color: string; label: string; borderColor?: string }) {
|
function ColorSwatch({ color, label, borderColor }: { color: string; label: string; borderColor?: string }) {
|
||||||
const { border, muted } = useAdminTheme();
|
const { border, muted } = useAdminTheme();
|
||||||
return (
|
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} />
|
<YStack width={40} height={40} borderRadius={12} borderWidth={1} borderColor={borderColor ?? border} backgroundColor={color} />
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{label}
|
{label}
|
||||||
@@ -1276,7 +1276,7 @@ function InputField({
|
|||||||
const { primary } = useAdminTheme();
|
const { primary } = useAdminTheme();
|
||||||
return (
|
return (
|
||||||
<MobileField label={label}>
|
<MobileField label={label}>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<MobileInput
|
<MobileInput
|
||||||
value={value}
|
value={value}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
@@ -1316,7 +1316,7 @@ function LabeledSlider({
|
|||||||
}) {
|
}) {
|
||||||
const { textStrong, muted, primary, border, surface } = useAdminTheme();
|
const { textStrong, muted, primary, border, surface } = useAdminTheme();
|
||||||
return (
|
return (
|
||||||
<YStack space="$1.5">
|
<YStack gap="$1.5">
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{label}
|
{label}
|
||||||
@@ -1367,7 +1367,7 @@ function PositionGrid({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
Position
|
Position
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1503,8 +1503,8 @@ function InfoBadge({ icon, text, tone = 'info' }: { icon?: React.ReactNode; text
|
|||||||
const color = tone === 'danger' ? dangerText : textStrong;
|
const color = tone === 'danger' ? dangerText : textStrong;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileCard space="$2" backgroundColor={background} borderColor={border}>
|
<MobileCard gap="$2" backgroundColor={background} borderColor={border}>
|
||||||
<XStack space="$2" alignItems="center">
|
<XStack gap="$2" alignItems="center">
|
||||||
{icon}
|
{icon}
|
||||||
<Text fontSize="$sm" color={color}>
|
<Text fontSize="$sm" color={color}>
|
||||||
{text}
|
{text}
|
||||||
@@ -1529,7 +1529,7 @@ function UpgradeCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileCard
|
<MobileCard
|
||||||
space="$4"
|
gap="$4"
|
||||||
padding="$6"
|
padding="$6"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
@@ -1547,7 +1547,7 @@ function UpgradeCard({
|
|||||||
>
|
>
|
||||||
<Lock size={32} color={primary} />
|
<Lock size={32} color={primary} />
|
||||||
</YStack>
|
</YStack>
|
||||||
<YStack space="$2" alignItems="center">
|
<YStack gap="$2" alignItems="center">
|
||||||
<Text fontSize="$xl" fontWeight="900" color={textStrong} textAlign="center">
|
<Text fontSize="$xl" fontWeight="900" color={textStrong} textAlign="center">
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ function SectionHeader({
|
|||||||
const subtitleSize = compact ? '$xs' : '$sm';
|
const subtitleSize = compact ? '$xs' : '$sm';
|
||||||
const spacing = compact ? '$1' : '$1.5';
|
const spacing = compact ? '$1' : '$1.5';
|
||||||
return (
|
return (
|
||||||
<YStack space={spacing}>
|
<YStack gap={spacing}>
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<Text fontSize={titleSize} fontWeight="800" color={theme.textStrong}>
|
<Text fontSize={titleSize} fontWeight="800" color={theme.textStrong}>
|
||||||
{title}
|
{title}
|
||||||
@@ -222,7 +222,7 @@ export default function MobileDashboardPage() {
|
|||||||
return (
|
return (
|
||||||
<MobileShell activeTab="home" title={t('mobileDashboard.title', 'Dashboard')}>
|
<MobileShell activeTab="home" title={t('mobileDashboard.title', 'Dashboard')}>
|
||||||
<DashboardCard padding="$0">
|
<DashboardCard padding="$0">
|
||||||
<YStack padding="$3" space="$2">
|
<YStack padding="$3" gap="$2">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title={t('dashboard:overview.title', 'At a glance')}
|
title={t('dashboard:overview.title', 'At a glance')}
|
||||||
showSeparator={false}
|
showSeparator={false}
|
||||||
@@ -231,7 +231,7 @@ export default function MobileDashboardPage() {
|
|||||||
/>
|
/>
|
||||||
</YStack>
|
</YStack>
|
||||||
<Separator backgroundColor={theme.border} opacity={0.6} />
|
<Separator backgroundColor={theme.border} opacity={0.6} />
|
||||||
<YStack padding="$3" space="$2.5">
|
<YStack padding="$3" gap="$2.5">
|
||||||
{/* 1. LIFECYCLE HERO */}
|
{/* 1. LIFECYCLE HERO */}
|
||||||
<LifecycleHero
|
<LifecycleHero
|
||||||
event={activeEvent}
|
event={activeEvent}
|
||||||
@@ -326,7 +326,7 @@ function LifecycleHero({
|
|||||||
|
|
||||||
if (phase === 'live') {
|
if (phase === 'live') {
|
||||||
return (
|
return (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Header />
|
<Header />
|
||||||
<DashboardCard
|
<DashboardCard
|
||||||
variant={cardVariant}
|
variant={cardVariant}
|
||||||
@@ -335,10 +335,10 @@ function LifecycleHero({
|
|||||||
borderColor="transparent"
|
borderColor="transparent"
|
||||||
style={{ backgroundImage: 'linear-gradient(135deg, #4F46E5 0%, #4338CA 100%)' }}
|
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">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<YStack space="$1">
|
<YStack gap="$1">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<YStack width={8} height={8} borderRadius={4} backgroundColor="#22C55E" />
|
<YStack width={8} height={8} borderRadius={4} backgroundColor="#22C55E" />
|
||||||
<Text color="white" fontWeight="700" fontSize="$xs" textTransform="uppercase" letterSpacing={1}>
|
<Text color="white" fontWeight="700" fontSize="$xs" textTransform="uppercase" letterSpacing={1}>
|
||||||
{t('dashboard:liveNow.status', 'Happening Now')}
|
{t('dashboard:liveNow.status', 'Happening Now')}
|
||||||
@@ -404,11 +404,11 @@ function LifecycleHero({
|
|||||||
|
|
||||||
if (phase === 'post') {
|
if (phase === 'post') {
|
||||||
return (
|
return (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Header />
|
<Header />
|
||||||
<DashboardCard variant={cardVariant} padding={cardPadding}>
|
<DashboardCard variant={cardVariant} padding={cardPadding}>
|
||||||
<YStack space={isEmbedded ? '$2.5' : '$3'}>
|
<YStack gap={isEmbedded ? '$2.5' : '$3'}>
|
||||||
<XStack alignItems="center" space="$2.5">
|
<XStack alignItems="center" gap="$2.5">
|
||||||
<YStack width={40} height={40} borderRadius={20} backgroundColor={theme.successText} alignItems="center" justifyContent="center">
|
<YStack width={40} height={40} borderRadius={20} backgroundColor={theme.successText} alignItems="center" justifyContent="center">
|
||||||
<CheckCircle2 size={20} color="white" />
|
<CheckCircle2 size={20} color="white" />
|
||||||
</YStack>
|
</YStack>
|
||||||
@@ -427,7 +427,7 @@ function LifecycleHero({
|
|||||||
height={48}
|
height={48}
|
||||||
borderRadius={16}
|
borderRadius={16}
|
||||||
>
|
>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Download size={16} color="white" />
|
<Download size={16} color="white" />
|
||||||
<Text fontSize="$sm" fontWeight="800" color="white">
|
<Text fontSize="$sm" fontWeight="800" color="white">
|
||||||
{t('events.recap.downloadAll', 'Download photos')}
|
{t('events.recap.downloadAll', 'Download photos')}
|
||||||
@@ -443,7 +443,7 @@ function LifecycleHero({
|
|||||||
height={48}
|
height={48}
|
||||||
borderRadius={16}
|
borderRadius={16}
|
||||||
>
|
>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Text fontSize="$sm" fontWeight="800" color={theme.textStrong}>
|
<Text fontSize="$sm" fontWeight="800" color={theme.textStrong}>
|
||||||
{t('events.recap.openRecap', 'Open recap')}
|
{t('events.recap.openRecap', 'Open recap')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -458,7 +458,7 @@ function LifecycleHero({
|
|||||||
|
|
||||||
// SETUP
|
// SETUP
|
||||||
return (
|
return (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Header />
|
<Header />
|
||||||
{showQuickControls ? (
|
{showQuickControls ? (
|
||||||
<XStack
|
<XStack
|
||||||
@@ -472,7 +472,7 @@ function LifecycleHero({
|
|||||||
paddingVertical="$2"
|
paddingVertical="$2"
|
||||||
>
|
>
|
||||||
<Pressable onPress={() => navigate(adminPath(`/mobile/events/${event.slug}/edit`))}>
|
<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} />
|
<Settings size={16} color={theme.primary} />
|
||||||
<Text fontSize="$sm" fontWeight="700" color={theme.textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={theme.textStrong}>
|
||||||
{t('dashboard:readiness.quickSettings', 'Event settings')}
|
{t('dashboard:readiness.quickSettings', 'Event settings')}
|
||||||
@@ -480,8 +480,8 @@ function LifecycleHero({
|
|||||||
</XStack>
|
</XStack>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<XStack alignItems="center" space="$3">
|
<XStack alignItems="center" gap="$3">
|
||||||
<YStack alignItems="center" space="$1">
|
<YStack alignItems="center" gap="$1">
|
||||||
<Text fontSize="$xs" color={theme.muted} textTransform="uppercase" letterSpacing={0.8}>
|
<Text fontSize="$xs" color={theme.muted} textTransform="uppercase" letterSpacing={0.8}>
|
||||||
{t('dashboard:readiness.publishToggle', 'Live')}
|
{t('dashboard:readiness.publishToggle', 'Live')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -499,13 +499,13 @@ function LifecycleHero({
|
|||||||
</XStack>
|
</XStack>
|
||||||
) : null}
|
) : null}
|
||||||
<DashboardCard variant={cardVariant} padding={cardPadding}>
|
<DashboardCard variant={cardVariant} padding={cardPadding}>
|
||||||
<YStack space={isEmbedded ? '$2.5' : '$3'}>
|
<YStack gap={isEmbedded ? '$2.5' : '$3'}>
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<YStack>
|
<YStack>
|
||||||
<Text fontSize="$xs" color={theme.muted} fontWeight="700" textTransform="uppercase">
|
<Text fontSize="$xs" color={theme.muted} fontWeight="700" textTransform="uppercase">
|
||||||
{t('dashboard:upcoming.status.planning', 'Countdown')}
|
{t('dashboard:upcoming.status.planning', 'Countdown')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fontSize="$2xl" fontWeight="900" color={theme.primary}>
|
<Text fontSize="$xxl" fontWeight="900" color={theme.primary}>
|
||||||
{daysToGo}{' '}
|
{daysToGo}{' '}
|
||||||
<Text fontSize="$sm" color={theme.muted} fontWeight="500">
|
<Text fontSize="$sm" color={theme.muted} fontWeight="500">
|
||||||
{t('management:galleryStatus.daysLabel', 'days')}
|
{t('management:galleryStatus.daysLabel', 'days')}
|
||||||
@@ -518,7 +518,7 @@ function LifecycleHero({
|
|||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
{showNextStep && nextStep ? (
|
{showNextStep && nextStep ? (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$xs" fontWeight="700" color={theme.muted} textTransform="uppercase" letterSpacing={1}>
|
<Text fontSize="$xs" fontWeight="700" color={theme.muted} textTransform="uppercase" letterSpacing={1}>
|
||||||
{t('dashboard:readiness.nextStepTitle', 'Next step')}
|
{t('dashboard:readiness.nextStepTitle', 'Next step')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -531,7 +531,7 @@ function LifecycleHero({
|
|||||||
paddingHorizontal="$3"
|
paddingHorizontal="$3"
|
||||||
onPress={() => navigate(adminPath(nextStep.targetPath))}
|
onPress={() => navigate(adminPath(nextStep.targetPath))}
|
||||||
title={
|
title={
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Circle size={18} color={theme.primary} strokeWidth={2.5} />
|
<Circle size={18} color={theme.primary} strokeWidth={2.5} />
|
||||||
<Text fontSize="$sm" fontWeight="700" color={theme.textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={theme.textStrong}>
|
||||||
{nextStep.label}
|
{nextStep.label}
|
||||||
@@ -546,7 +546,7 @@ function LifecycleHero({
|
|||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
iconAfter={
|
iconAfter={
|
||||||
<XStack alignItems="center" space="$1">
|
<XStack alignItems="center" gap="$1">
|
||||||
<PillBadge tone="success">{nextStep.ctaLabel}</PillBadge>
|
<PillBadge tone="success">{nextStep.ctaLabel}</PillBadge>
|
||||||
<ChevronRight size={16} color={theme.muted} />
|
<ChevronRight size={16} color={theme.muted} />
|
||||||
</XStack>
|
</XStack>
|
||||||
@@ -665,7 +665,7 @@ function UnifiedToolGrid({ event, navigate, permissions, isMember, isCompleted }
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardCard padding="$0">
|
<DashboardCard padding="$0">
|
||||||
<YStack padding="$3.5" space="$2">
|
<YStack padding="$3.5" gap="$2">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title={t('dashboard:quickActions.title', 'Quick actions')}
|
title={t('dashboard:quickActions.title', 'Quick actions')}
|
||||||
subtitle={t('dashboard:quickActions.description', 'Jump straight to the most important 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>
|
</YStack>
|
||||||
<Separator backgroundColor={theme.border} opacity={0.6} />
|
<Separator backgroundColor={theme.border} opacity={0.6} />
|
||||||
<YStack padding="$3.5" space="$3">
|
<YStack padding="$3.5" gap="$3">
|
||||||
{sections.map((section) => (
|
{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}>
|
<Text fontSize="$xs" fontWeight="700" color={theme.muted} textTransform="uppercase" letterSpacing={1}>
|
||||||
{section.title}
|
{section.title}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -692,7 +692,7 @@ function UnifiedToolGrid({ event, navigate, permissions, isMember, isCompleted }
|
|||||||
paddingHorizontal="$3"
|
paddingHorizontal="$3"
|
||||||
onPress={() => navigate(adminPath(item.path))}
|
onPress={() => navigate(adminPath(item.path))}
|
||||||
title={
|
title={
|
||||||
<XStack alignItems="center" space="$2.5">
|
<XStack alignItems="center" gap="$2.5">
|
||||||
<XStack
|
<XStack
|
||||||
width={32}
|
width={32}
|
||||||
height={32}
|
height={32}
|
||||||
@@ -729,7 +729,7 @@ function RecentPhotosSection({ photos, navigate, slug }: { photos: TenantPhoto[]
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardCard>
|
<DashboardCard>
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<Text fontSize="$xs" fontWeight="700" color={theme.muted} textTransform="uppercase" letterSpacing={1}>
|
<Text fontSize="$xs" fontWeight="700" color={theme.muted} textTransform="uppercase" letterSpacing={1}>
|
||||||
{t('photos.recentTitle', 'Latest Uploads')}
|
{t('photos.recentTitle', 'Latest Uploads')}
|
||||||
@@ -749,7 +749,7 @@ function RecentPhotosSection({ photos, navigate, slug }: { photos: TenantPhoto[]
|
|||||||
|
|
||||||
<Separator backgroundColor={theme.border} opacity={0.6} />
|
<Separator backgroundColor={theme.border} opacity={0.6} />
|
||||||
|
|
||||||
<XStack space="$2" overflow="scroll" paddingVertical="$1">
|
<XStack gap="$2" overflow="scroll" paddingVertical="$1">
|
||||||
{photos.map((photo) => (
|
{photos.map((photo) => (
|
||||||
<Pressable key={photo.id} onPress={() => navigate(adminPath(`/mobile/events/${slug}/control-room`))}>
|
<Pressable key={photo.id} onPress={() => navigate(adminPath(`/mobile/events/${slug}/control-room`))}>
|
||||||
<YStack
|
<YStack
|
||||||
@@ -762,7 +762,7 @@ function RecentPhotosSection({ photos, navigate, slug }: { photos: TenantPhoto[]
|
|||||||
borderColor={theme.border}
|
borderColor={theme.border}
|
||||||
>
|
>
|
||||||
{photo.thumbnail_url ? (
|
{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">
|
<YStack flex={1} alignItems="center" justifyContent="center">
|
||||||
<ImageIcon size={20} color={theme.muted} />
|
<ImageIcon size={20} color={theme.muted} />
|
||||||
@@ -785,12 +785,12 @@ function AlertsSection({ event, stats, t }: any) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardCard>
|
<DashboardCard>
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$xs" fontWeight="700" color={theme.muted} textTransform="uppercase" letterSpacing={1}>
|
<Text fontSize="$xs" fontWeight="700" color={theme.muted} textTransform="uppercase" letterSpacing={1}>
|
||||||
{t('management:alertsTitle', 'Alerts')}
|
{t('management:alertsTitle', 'Alerts')}
|
||||||
</Text>
|
</Text>
|
||||||
<Separator backgroundColor={theme.border} opacity={0.6} />
|
<Separator backgroundColor={theme.border} opacity={0.6} />
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
{limitWarnings.map((w: any, idx: number) => {
|
{limitWarnings.map((w: any, idx: number) => {
|
||||||
const isDanger = w.tone === 'danger';
|
const isDanger = w.tone === 'danger';
|
||||||
const bg = isDanger ? theme.dangerBg : theme.warningBg;
|
const bg = isDanger ? theme.dangerBg : theme.warningBg;
|
||||||
@@ -807,7 +807,7 @@ function AlertsSection({ event, stats, t }: any) {
|
|||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
borderColor={border}
|
borderColor={border}
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
space="$2"
|
gap="$2"
|
||||||
>
|
>
|
||||||
<Icon size={16} color={text} />
|
<Icon size={16} color={text} />
|
||||||
<Text fontSize="$sm" color={text} fontWeight="600">
|
<Text fontSize="$sm" color={text} fontWeight="600">
|
||||||
@@ -826,7 +826,7 @@ function EmptyState({ canManage, onCreate }: any) {
|
|||||||
const theme = useAdminTheme();
|
const theme = useAdminTheme();
|
||||||
const { t } = useTranslation(['management', 'mobile']);
|
const { t } = useTranslation(['management', 'mobile']);
|
||||||
return (
|
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} />
|
<Sparkles size={48} color={theme.primary} />
|
||||||
<Text fontSize="$lg" fontWeight="800" color={theme.textStrong} textAlign="center">
|
<Text fontSize="$lg" fontWeight="800" color={theme.textStrong} textAlign="center">
|
||||||
{t('mobile:header.appName', 'Event Admin')}
|
{t('mobile:header.appName', 'Event Admin')}
|
||||||
|
|||||||
@@ -192,14 +192,14 @@ export function DataExportsPanel({
|
|||||||
</MobileCard>
|
</MobileCard>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<MobileCard space="$2">
|
<MobileCard gap="$2">
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('dataExports.request.title', 'Export request')}
|
{t('dataExports.request.title', 'Export request')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{t('dataExports.request.hint', 'Export account data or a specific event archive.')}
|
{t('dataExports.request.hint', 'Export account data or a specific event archive.')}
|
||||||
</Text>
|
</Text>
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
{!isRecap ? (
|
{!isRecap ? (
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<Text fontSize="$sm" color={text}>
|
<Text fontSize="$sm" color={text}>
|
||||||
@@ -276,7 +276,7 @@ export function DataExportsPanel({
|
|||||||
/>
|
/>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$2">
|
<MobileCard gap="$2">
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('dataExports.history.title', 'Recent exports')}
|
{t('dataExports.history.title', 'Recent exports')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -284,7 +284,7 @@ export function DataExportsPanel({
|
|||||||
{t('dataExports.history.hint', 'Latest 10 exports for your account and events.')}
|
{t('dataExports.history.hint', 'Latest 10 exports for your account and events.')}
|
||||||
</Text>
|
</Text>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<SkeletonCard height={72} />
|
<SkeletonCard height={72} />
|
||||||
<SkeletonCard height={72} />
|
<SkeletonCard height={72} />
|
||||||
</YStack>
|
</YStack>
|
||||||
@@ -293,9 +293,9 @@ export function DataExportsPanel({
|
|||||||
{t('dataExports.history.empty', 'No exports yet.')}
|
{t('dataExports.history.empty', 'No exports yet.')}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
{visibleExports.map((entry) => (
|
{visibleExports.map((entry) => (
|
||||||
<MobileCard key={entry.id} space="$2">
|
<MobileCard key={entry.id} gap="$2">
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<YStack>
|
<YStack>
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
return (
|
return (
|
||||||
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="home">
|
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="home">
|
||||||
<MobileCard
|
<MobileCard
|
||||||
space="$4"
|
gap="$4"
|
||||||
padding="$6"
|
padding="$6"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
@@ -55,7 +55,7 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
>
|
>
|
||||||
<Lock size={32} color={primary} />
|
<Lock size={32} color={primary} />
|
||||||
</YStack>
|
</YStack>
|
||||||
<YStack space="$2" alignItems="center">
|
<YStack gap="$2" alignItems="center">
|
||||||
<Text fontSize="$xl" fontWeight="900" color={textStrong} textAlign="center">
|
<Text fontSize="$xl" fontWeight="900" color={textStrong} textAlign="center">
|
||||||
{t('analytics.lockedTitle', 'Unlock Analytics')}
|
{t('analytics.lockedTitle', 'Unlock Analytics')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -75,7 +75,7 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="home">
|
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="home">
|
||||||
<YStack space="$3">
|
<YStack gap="$3">
|
||||||
<SkeletonCard height={200} />
|
<SkeletonCard height={200} />
|
||||||
<SkeletonCard height={150} />
|
<SkeletonCard height={150} />
|
||||||
<SkeletonCard height={150} />
|
<SkeletonCard height={150} />
|
||||||
@@ -116,12 +116,12 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
activeTab="home"
|
activeTab="home"
|
||||||
onBack={() => navigate(-1)}
|
onBack={() => navigate(-1)}
|
||||||
>
|
>
|
||||||
<YStack space="$4">
|
<YStack gap="$4">
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
||||||
{t('analytics.kpiTitle', 'Event snapshot')}
|
{t('analytics.kpiTitle', 'Event snapshot')}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack space="$2" flexWrap="wrap">
|
<XStack gap="$2" flexWrap="wrap">
|
||||||
<KpiTile
|
<KpiTile
|
||||||
icon={TrendingUp}
|
icon={TrendingUp}
|
||||||
label={t('analytics.kpiUploads', 'Uploads')}
|
label={t('analytics.kpiUploads', 'Uploads')}
|
||||||
@@ -140,14 +140,14 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
</XStack>
|
</XStack>
|
||||||
</YStack>
|
</YStack>
|
||||||
{/* Activity Timeline */}
|
{/* Activity Timeline */}
|
||||||
<MobileCard space="$3" borderColor={border} backgroundColor={surface}>
|
<MobileCard gap="$3" borderColor={border} backgroundColor={surface}>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<TrendingUp size={18} color={primary} />
|
<TrendingUp size={18} color={primary} />
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('analytics.activityTitle', 'Activity Timeline')}
|
{t('analytics.activityTitle', 'Activity Timeline')}
|
||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
<YStack space="$0.5">
|
<YStack gap="$0.5">
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{t('analytics.timeframe', 'Last {{hours}} hours', { hours: timeframeHours })}
|
{t('analytics.timeframe', 'Last {{hours}} hours', { hours: timeframeHours })}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -159,7 +159,7 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
</YStack>
|
</YStack>
|
||||||
|
|
||||||
{hasTimeline ? (
|
{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">
|
<XStack alignItems="flex-end" justifyContent="space-between" height={150} gap="$1">
|
||||||
{timeline.map((point, index) => {
|
{timeline.map((point, index) => {
|
||||||
const heightPercent = (point.count / maxTimelineCount) * 100;
|
const heightPercent = (point.count / maxTimelineCount) * 100;
|
||||||
@@ -168,7 +168,7 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
const showLabel = timeline.length < 8 || index % 3 === 0;
|
const showLabel = timeline.length < 8 || index % 3 === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack key={point.timestamp} flex={1} alignItems="center" space="$1">
|
<YStack key={point.timestamp} flex={1} alignItems="center" gap="$1">
|
||||||
<YStack
|
<YStack
|
||||||
width="100%"
|
width="100%"
|
||||||
height={`${Math.max(heightPercent, 4)}%`}
|
height={`${Math.max(heightPercent, 4)}%`}
|
||||||
@@ -200,8 +200,8 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
{/* Top Contributors */}
|
{/* Top Contributors */}
|
||||||
<MobileCard space="$3" borderColor={border} backgroundColor={surface}>
|
<MobileCard gap="$3" borderColor={border} backgroundColor={surface}>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Trophy size={18} color={primary} />
|
<Trophy size={18} color={primary} />
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('analytics.contributorsTitle', 'Top Contributors')}
|
{t('analytics.contributorsTitle', 'Top Contributors')}
|
||||||
@@ -209,10 +209,10 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
{hasContributors ? (
|
{hasContributors ? (
|
||||||
<YStack space="$3">
|
<YStack gap="$3">
|
||||||
{contributors.map((contributor, idx) => (
|
{contributors.map((contributor, idx) => (
|
||||||
<XStack key={idx} alignItems="center" justifyContent="space-between" paddingVertical="$1">
|
<XStack key={idx} alignItems="center" justifyContent="space-between" paddingVertical="$1">
|
||||||
<XStack alignItems="center" space="$3">
|
<XStack alignItems="center" gap="$3">
|
||||||
<YStack
|
<YStack
|
||||||
width={28}
|
width={28}
|
||||||
height={28}
|
height={28}
|
||||||
@@ -250,8 +250,8 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
{/* Task Stats */}
|
{/* Task Stats */}
|
||||||
<MobileCard space="$3" borderColor={border} backgroundColor={surface}>
|
<MobileCard gap="$3" borderColor={border} backgroundColor={surface}>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<ListTodo size={18} color={primary} />
|
<ListTodo size={18} color={primary} />
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('analytics.tasksTitle', 'Popular photo tasks')}
|
{t('analytics.tasksTitle', 'Popular photo tasks')}
|
||||||
@@ -259,11 +259,11 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
{hasTasks ? (
|
{hasTasks ? (
|
||||||
<YStack space="$3">
|
<YStack gap="$3">
|
||||||
{tasks.map((task) => {
|
{tasks.map((task) => {
|
||||||
const percent = (task.count / maxTaskCount) * 100;
|
const percent = (task.count / maxTaskCount) * 100;
|
||||||
return (
|
return (
|
||||||
<YStack key={task.task_id} space="$1">
|
<YStack key={task.task_id} gap="$1">
|
||||||
<XStack justifyContent="space-between">
|
<XStack justifyContent="space-between">
|
||||||
<Text fontSize="$sm" color={textStrong} numberOfLines={1} flex={1}>
|
<Text fontSize="$sm" color={textStrong} numberOfLines={1} flex={1}>
|
||||||
{task.task_name}
|
{task.task_name}
|
||||||
@@ -308,7 +308,7 @@ function EmptyState({
|
|||||||
}) {
|
}) {
|
||||||
const { muted } = useAdminTheme();
|
const { muted } = useAdminTheme();
|
||||||
return (
|
return (
|
||||||
<YStack padding="$4" alignItems="center" justifyContent="center" space="$2">
|
<YStack padding="$4" alignItems="center" justifyContent="center" gap="$2">
|
||||||
<Text fontSize="$sm" color={muted}>
|
<Text fontSize="$sm" color={muted}>
|
||||||
{message}
|
{message}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ function PhotoGridTile({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{badges.length ? (
|
{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) => (
|
{badges.map((label) => (
|
||||||
<PhotoStatusTag key={`${photo.id}-${label}`} label={label} />
|
<PhotoStatusTag key={`${photo.id}-${label}`} label={label} />
|
||||||
))}
|
))}
|
||||||
@@ -194,7 +194,7 @@ function PhotoGridTile({
|
|||||||
padding="$1"
|
padding="$1"
|
||||||
borderRadius={12}
|
borderRadius={12}
|
||||||
backgroundColor={overlayBg}
|
backgroundColor={overlayBg}
|
||||||
space="$2"
|
gap="$2"
|
||||||
justifyContent="space-between"
|
justifyContent="space-between"
|
||||||
>
|
>
|
||||||
{actions.map((action) => (
|
{actions.map((action) => (
|
||||||
@@ -1034,7 +1034,7 @@ export default function MobileEventControlRoomPage() {
|
|||||||
}, [queuedActions, slug]);
|
}, [queuedActions, slug]);
|
||||||
|
|
||||||
const headerActions = (
|
const headerActions = (
|
||||||
<XStack space="$2">
|
<XStack gap="$2">
|
||||||
<HeaderActionButton
|
<HeaderActionButton
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (activeTab === 'moderation') {
|
if (activeTab === 'moderation') {
|
||||||
@@ -1070,7 +1070,7 @@ export default function MobileEventControlRoomPage() {
|
|||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={(val) => setActiveTab(val as 'moderation' | 'live')}
|
onValueChange={(val) => setActiveTab(val as 'moderation' | 'live')}
|
||||||
header={(
|
header={(
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<XStack justifyContent="flex-end">
|
<XStack justifyContent="flex-end">
|
||||||
<ContextHelpLink slug="control-room-moderation" />
|
<ContextHelpLink slug="control-room-moderation" />
|
||||||
</XStack>
|
</XStack>
|
||||||
@@ -1094,8 +1094,8 @@ export default function MobileEventControlRoomPage() {
|
|||||||
</XStack>
|
</XStack>
|
||||||
</Accordion.Trigger>
|
</Accordion.Trigger>
|
||||||
<Accordion.Content {...({ paddingTop: '$2' } as any)}>
|
<Accordion.Content {...({ paddingTop: '$2' } as any)}>
|
||||||
<YStack space="$3">
|
<YStack gap="$3">
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$sm" fontWeight="800" color={text}>
|
<Text fontSize="$sm" fontWeight="800" color={text}>
|
||||||
{t('controlRoom.automation.title', 'Automation')}
|
{t('controlRoom.automation.title', 'Automation')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1177,7 +1177,7 @@ export default function MobileEventControlRoomPage() {
|
|||||||
</MobileField>
|
</MobileField>
|
||||||
</YStack>
|
</YStack>
|
||||||
|
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$sm" fontWeight="800" color={text}>
|
<Text fontSize="$sm" fontWeight="800" color={text}>
|
||||||
{t('controlRoom.automation.uploaders.title', 'Uploader overrides')}
|
{t('controlRoom.automation.uploaders.title', 'Uploader overrides')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1195,7 +1195,7 @@ export default function MobileEventControlRoomPage() {
|
|||||||
'Uploads from these devices skip the approval queue.',
|
'Uploads from these devices skip the approval queue.',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<MobileSelect
|
<MobileSelect
|
||||||
value={trustedUploaderSelection}
|
value={trustedUploaderSelection}
|
||||||
onChange={(event) => setTrustedUploaderSelection(event.target.value)}
|
onChange={(event) => setTrustedUploaderSelection(event.target.value)}
|
||||||
@@ -1215,7 +1215,7 @@ export default function MobileEventControlRoomPage() {
|
|||||||
/>
|
/>
|
||||||
</YStack>
|
</YStack>
|
||||||
{trustedUploaders.length ? (
|
{trustedUploaders.length ? (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
{trustedUploaders.map((rule) => (
|
{trustedUploaders.map((rule) => (
|
||||||
<XStack
|
<XStack
|
||||||
key={`trusted-${rule.device_id}`}
|
key={`trusted-${rule.device_id}`}
|
||||||
@@ -1227,7 +1227,7 @@ export default function MobileEventControlRoomPage() {
|
|||||||
borderColor={border}
|
borderColor={border}
|
||||||
backgroundColor={surfaceMuted}
|
backgroundColor={surfaceMuted}
|
||||||
>
|
>
|
||||||
<YStack space="$0.5">
|
<YStack gap="$0.5">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||||
{rule.label ?? t('common.anonymous', 'Anonymous')}
|
{rule.label ?? t('common.anonymous', 'Anonymous')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1267,7 +1267,7 @@ export default function MobileEventControlRoomPage() {
|
|||||||
'Uploads from these devices always need approval.',
|
'Uploads from these devices always need approval.',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<MobileSelect
|
<MobileSelect
|
||||||
value={forceReviewSelection}
|
value={forceReviewSelection}
|
||||||
onChange={(event) => setForceReviewSelection(event.target.value)}
|
onChange={(event) => setForceReviewSelection(event.target.value)}
|
||||||
@@ -1287,7 +1287,7 @@ export default function MobileEventControlRoomPage() {
|
|||||||
/>
|
/>
|
||||||
</YStack>
|
</YStack>
|
||||||
{forceReviewUploaders.length ? (
|
{forceReviewUploaders.length ? (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
{forceReviewUploaders.map((rule) => (
|
{forceReviewUploaders.map((rule) => (
|
||||||
<XStack
|
<XStack
|
||||||
key={`force-${rule.device_id}`}
|
key={`force-${rule.device_id}`}
|
||||||
@@ -1299,7 +1299,7 @@ export default function MobileEventControlRoomPage() {
|
|||||||
borderColor={border}
|
borderColor={border}
|
||||||
backgroundColor={surfaceMuted}
|
backgroundColor={surfaceMuted}
|
||||||
>
|
>
|
||||||
<YStack space="$0.5">
|
<YStack gap="$0.5">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||||
{rule.label ?? t('common.anonymous', 'Anonymous')}
|
{rule.label ?? t('common.anonymous', 'Anonymous')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1344,11 +1344,11 @@ export default function MobileEventControlRoomPage() {
|
|||||||
value: 'moderation',
|
value: 'moderation',
|
||||||
label: t('controlRoom.tabs.moderation', 'Moderation'),
|
label: t('controlRoom.tabs.moderation', 'Moderation'),
|
||||||
content: (
|
content: (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
{queuedEventCount > 0 ? (
|
{queuedEventCount > 0 ? (
|
||||||
<MobileCard>
|
<MobileCard>
|
||||||
<XStack alignItems="center" justifyContent="space-between" space="$2">
|
<XStack alignItems="center" justifyContent="space-between" gap="$2">
|
||||||
<YStack space="$1" flex={1}>
|
<YStack gap="$1" flex={1}>
|
||||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||||
{t('mobilePhotos.queueTitle', 'Changes waiting to sync')}
|
{t('mobilePhotos.queueTitle', 'Changes waiting to sync')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1371,7 +1371,7 @@ export default function MobileEventControlRoomPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<MobileCard>
|
<MobileCard>
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$xs" fontWeight="800" color={text}>
|
<Text fontSize="$xs" fontWeight="800" color={text}>
|
||||||
{t('mobilePhotos.filtersTitle', 'Filter')}
|
{t('mobilePhotos.filtersTitle', 'Filter')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1390,7 +1390,7 @@ export default function MobileEventControlRoomPage() {
|
|||||||
value={moderationFilter}
|
value={moderationFilter}
|
||||||
onValueChange={(value: string) => value && setModerationFilter(value as ModerationFilter)}
|
onValueChange={(value: string) => value && setModerationFilter(value as ModerationFilter)}
|
||||||
>
|
>
|
||||||
<XStack space="$1.5">
|
<XStack gap="$1.5">
|
||||||
{MODERATION_FILTERS.map((option) => {
|
{MODERATION_FILTERS.map((option) => {
|
||||||
const active = option.value === moderationFilter;
|
const active = option.value === moderationFilter;
|
||||||
const count = moderationCounts[option.value] ?? 0;
|
const count = moderationCounts[option.value] ?? 0;
|
||||||
@@ -1407,7 +1407,7 @@ export default function MobileEventControlRoomPage() {
|
|||||||
paddingHorizontal="$3"
|
paddingHorizontal="$3"
|
||||||
pressStyle={{ opacity: 0.85 }}
|
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}>
|
<Text fontSize="$xs" fontWeight="700" color={active ? '#fff' : muted}>
|
||||||
{t(option.labelKey, option.fallback)}
|
{t(option.labelKey, option.fallback)}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1455,13 +1455,13 @@ export default function MobileEventControlRoomPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{moderationLoading && moderationPage === 1 ? (
|
{moderationLoading && moderationPage === 1 ? (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
{Array.from({ length: 3 }).map((_, idx) => (
|
{Array.from({ length: 3 }).map((_, idx) => (
|
||||||
<SkeletonCard key={`moderation-skeleton-${idx}`} height={120} />
|
<SkeletonCard key={`moderation-skeleton-${idx}`} height={120} />
|
||||||
))}
|
))}
|
||||||
</YStack>
|
</YStack>
|
||||||
) : moderationPhotos.length === 0 ? (
|
) : moderationPhotos.length === 0 ? (
|
||||||
<MobileCard alignItems="center" justifyContent="center" space="$2">
|
<MobileCard alignItems="center" justifyContent="center" gap="$2">
|
||||||
<ImageIcon size={28} color={muted} />
|
<ImageIcon size={28} color={muted} />
|
||||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||||
{t('controlRoom.emptyModeration', 'No uploads match this filter.')}
|
{t('controlRoom.emptyModeration', 'No uploads match this filter.')}
|
||||||
@@ -1543,7 +1543,7 @@ export default function MobileEventControlRoomPage() {
|
|||||||
value: 'live',
|
value: 'live',
|
||||||
label: t('controlRoom.tabs.live', 'Live Show'),
|
label: t('controlRoom.tabs.live', 'Live Show'),
|
||||||
content: (
|
content: (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<MobileCard borderColor={border} backgroundColor="transparent">
|
<MobileCard borderColor={border} backgroundColor="transparent">
|
||||||
<Text fontSize="$sm" color={muted}>
|
<Text fontSize="$sm" color={muted}>
|
||||||
{t(
|
{t(
|
||||||
@@ -1559,7 +1559,7 @@ export default function MobileEventControlRoomPage() {
|
|||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard>
|
<MobileCard>
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$xs" fontWeight="800" color={text}>
|
<Text fontSize="$xs" fontWeight="800" color={text}>
|
||||||
{t('liveShowQueue.filterLabel', 'Live status')}
|
{t('liveShowQueue.filterLabel', 'Live status')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1578,7 +1578,7 @@ export default function MobileEventControlRoomPage() {
|
|||||||
value={liveStatusFilter}
|
value={liveStatusFilter}
|
||||||
onValueChange={(value: string) => value && setLiveStatusFilter(value as LiveShowQueueStatus)}
|
onValueChange={(value: string) => value && setLiveStatusFilter(value as LiveShowQueueStatus)}
|
||||||
>
|
>
|
||||||
<XStack space="$1.5">
|
<XStack gap="$1.5">
|
||||||
{LIVE_STATUS_OPTIONS.map((option) => {
|
{LIVE_STATUS_OPTIONS.map((option) => {
|
||||||
const active = option.value === liveStatusFilter;
|
const active = option.value === liveStatusFilter;
|
||||||
const count = liveCounts[option.value] ?? 0;
|
const count = liveCounts[option.value] ?? 0;
|
||||||
@@ -1595,7 +1595,7 @@ export default function MobileEventControlRoomPage() {
|
|||||||
paddingHorizontal="$3"
|
paddingHorizontal="$3"
|
||||||
pressStyle={{ opacity: 0.85 }}
|
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}>
|
<Text fontSize="$xs" fontWeight="700" color={active ? '#fff' : muted}>
|
||||||
{t(option.labelKey, option.fallback)}
|
{t(option.labelKey, option.fallback)}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1631,13 +1631,13 @@ export default function MobileEventControlRoomPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{liveLoading && livePage === 1 ? (
|
{liveLoading && livePage === 1 ? (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
{Array.from({ length: 3 }).map((_, idx) => (
|
{Array.from({ length: 3 }).map((_, idx) => (
|
||||||
<SkeletonCard key={`live-skeleton-${idx}`} height={120} />
|
<SkeletonCard key={`live-skeleton-${idx}`} height={120} />
|
||||||
))}
|
))}
|
||||||
</YStack>
|
</YStack>
|
||||||
) : livePhotos.length === 0 ? (
|
) : livePhotos.length === 0 ? (
|
||||||
<MobileCard alignItems="center" justifyContent="center" space="$2">
|
<MobileCard alignItems="center" justifyContent="center" gap="$2">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||||
{t('controlRoom.emptyLive', 'No photos waiting for Live Show.')}
|
{t('controlRoom.emptyLive', 'No photos waiting for Live Show.')}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -377,7 +377,7 @@ export default function MobileEventFormPage() {
|
|||||||
|
|
||||||
const requiredLabel = React.useCallback(
|
const requiredLabel = React.useCallback(
|
||||||
(label: string) => (
|
(label: string) => (
|
||||||
<XStack alignItems="center" space="$1">
|
<XStack alignItems="center" gap="$1">
|
||||||
<Text fontSize="$sm" fontWeight="800" color={text}>
|
<Text fontSize="$sm" fontWeight="800" color={text}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -407,7 +407,7 @@ export default function MobileEventFormPage() {
|
|||||||
<ContextHelpLink slug="event-settings" />
|
<ContextHelpLink slug="event-settings" />
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<MobileField label={requiredLabel(t('eventForm.fields.name.label', 'Event name'))}>
|
<MobileField label={requiredLabel(t('eventForm.fields.name.label', 'Event name'))}>
|
||||||
<MobileInput
|
<MobileInput
|
||||||
type="text"
|
type="text"
|
||||||
@@ -471,7 +471,7 @@ export default function MobileEventFormPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<MobileField label={requiredLabel(t('eventForm.fields.date.label', 'Date & time'))}>
|
<MobileField label={requiredLabel(t('eventForm.fields.date.label', 'Date & time'))}>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<MobileDateInput
|
<MobileDateInput
|
||||||
value={extractDateValue(form.date)}
|
value={extractDateValue(form.date)}
|
||||||
onChange={handleDateChange}
|
onChange={handleDateChange}
|
||||||
@@ -511,7 +511,7 @@ export default function MobileEventFormPage() {
|
|||||||
</MobileField>
|
</MobileField>
|
||||||
|
|
||||||
<MobileField label={t('eventForm.fields.location.label', 'Location')}>
|
<MobileField label={t('eventForm.fields.location.label', 'Location')}>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<MobileInput
|
<MobileInput
|
||||||
type="text"
|
type="text"
|
||||||
value={form.location}
|
value={form.location}
|
||||||
@@ -524,7 +524,7 @@ export default function MobileEventFormPage() {
|
|||||||
</MobileField>
|
</MobileField>
|
||||||
|
|
||||||
<MobileField label={t('eventForm.fields.publish.label', 'Publish immediately')}>
|
<MobileField label={t('eventForm.fields.publish.label', 'Publish immediately')}>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Switch
|
<Switch
|
||||||
checked={form.published}
|
checked={form.published}
|
||||||
onCheckedChange={(checked: boolean) =>
|
onCheckedChange={(checked: boolean) =>
|
||||||
@@ -543,7 +543,7 @@ export default function MobileEventFormPage() {
|
|||||||
</MobileField>
|
</MobileField>
|
||||||
|
|
||||||
<MobileField label={t('eventForm.fields.tasksMode.label', 'Photo tasks & challenges')}>
|
<MobileField label={t('eventForm.fields.tasksMode.label', 'Photo tasks & challenges')}>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Switch
|
<Switch
|
||||||
checked={form.tasksEnabled}
|
checked={form.tasksEnabled}
|
||||||
onCheckedChange={(checked: boolean) =>
|
onCheckedChange={(checked: boolean) =>
|
||||||
@@ -574,7 +574,7 @@ export default function MobileEventFormPage() {
|
|||||||
</MobileField>
|
</MobileField>
|
||||||
|
|
||||||
<MobileField label={t('eventForm.fields.uploadVisibility.label', 'Uploads visible immediately')}>
|
<MobileField label={t('eventForm.fields.uploadVisibility.label', 'Uploads visible immediately')}>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Switch
|
<Switch
|
||||||
checked={form.autoApproveUploads}
|
checked={form.autoApproveUploads}
|
||||||
onCheckedChange={(checked: boolean) =>
|
onCheckedChange={(checked: boolean) =>
|
||||||
@@ -605,7 +605,7 @@ export default function MobileEventFormPage() {
|
|||||||
</MobileField>
|
</MobileField>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<YStack space="$2" paddingBottom="$10">
|
<YStack gap="$2" paddingBottom="$10">
|
||||||
{!isEdit ? (
|
{!isEdit ? (
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={t('eventForm.actions.create', 'Create event')}
|
label={t('eventForm.actions.create', 'Create event')}
|
||||||
|
|||||||
@@ -204,11 +204,11 @@ export default function MobileEventGuestNotificationsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<YStack ref={formRef}>
|
<YStack ref={formRef}>
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('guestMessages.composeTitle', 'Send a message')}
|
{t('guestMessages.composeTitle', 'Send a message')}
|
||||||
</Text>
|
</Text>
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<MobileField label={t('guestMessages.form.title', 'Title')}>
|
<MobileField label={t('guestMessages.form.title', 'Title')}>
|
||||||
<MobileInput
|
<MobileInput
|
||||||
type="text"
|
type="text"
|
||||||
@@ -244,7 +244,7 @@ export default function MobileEventGuestNotificationsPage() {
|
|||||||
</MobileField>
|
</MobileField>
|
||||||
) : null}
|
) : null}
|
||||||
<MobileField label={t('guestMessages.form.cta', 'CTA (optional)')}>
|
<MobileField label={t('guestMessages.form.cta', 'CTA (optional)')}>
|
||||||
<YStack space="$1.5">
|
<YStack gap="$1.5">
|
||||||
<MobileInput
|
<MobileInput
|
||||||
type="text"
|
type="text"
|
||||||
value={form.cta_label}
|
value={form.cta_label}
|
||||||
@@ -262,7 +262,7 @@ export default function MobileEventGuestNotificationsPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</YStack>
|
</YStack>
|
||||||
</MobileField>
|
</MobileField>
|
||||||
<XStack space="$2">
|
<XStack gap="$2">
|
||||||
<MobileField label={t('guestMessages.form.expiresIn', 'Expires in (minutes)')}>
|
<MobileField label={t('guestMessages.form.expiresIn', 'Expires in (minutes)')}>
|
||||||
<MobileInput
|
<MobileInput
|
||||||
type="number"
|
type="number"
|
||||||
@@ -301,7 +301,7 @@ export default function MobileEventGuestNotificationsPage() {
|
|||||||
</MobileCard>
|
</MobileCard>
|
||||||
</YStack>
|
</YStack>
|
||||||
|
|
||||||
<MobileCard space="$2">
|
<MobileCard gap="$2">
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('guestMessages.historyTitle', 'Recent messages')}
|
{t('guestMessages.historyTitle', 'Recent messages')}
|
||||||
@@ -312,13 +312,13 @@ export default function MobileEventGuestNotificationsPage() {
|
|||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
{Array.from({ length: 3 }).map((_, idx) => (
|
{Array.from({ length: 3 }).map((_, idx) => (
|
||||||
<SkeletonCard key={`s-${idx}`} height={72} />
|
<SkeletonCard key={`s-${idx}`} height={72} />
|
||||||
))}
|
))}
|
||||||
</YStack>
|
</YStack>
|
||||||
) : history.length === 0 ? (
|
) : history.length === 0 ? (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{t('guestMessages.emptyTitle', 'Send your first guest message')}
|
{t('guestMessages.emptyTitle', 'Send your first guest message')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -332,14 +332,14 @@ export default function MobileEventGuestNotificationsPage() {
|
|||||||
/>
|
/>
|
||||||
</YStack>
|
</YStack>
|
||||||
) : (
|
) : (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
{history.map((item) => (
|
{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">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
||||||
{item.title || t('guestMessages.history.untitled', 'Untitled')}
|
{item.title || t('guestMessages.history.untitled', 'Untitled')}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack space="$1.5" alignItems="center">
|
<XStack gap="$1.5" alignItems="center">
|
||||||
<PillBadge tone={item.status === 'active' ? 'success' : 'muted'}>
|
<PillBadge tone={item.status === 'active' ? 'success' : 'muted'}>
|
||||||
{t(`guestMessages.status.${item.status}`, item.status)}
|
{t(`guestMessages.status.${item.status}`, item.status)}
|
||||||
</PillBadge>
|
</PillBadge>
|
||||||
@@ -354,11 +354,11 @@ export default function MobileEventGuestNotificationsPage() {
|
|||||||
{item.body ?? t('guestMessages.history.noBody', 'No body provided.')}
|
{item.body ?? t('guestMessages.history.noBody', 'No body provided.')}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<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>
|
<PillBadge tone="muted">{t(`guestMessages.type.${item.type}`, item.type)}</PillBadge>
|
||||||
{item.target_identifier ? (
|
{item.target_identifier ? (
|
||||||
<PillBadge tone="muted">
|
<PillBadge tone="muted">
|
||||||
<XStack alignItems="center" space="$1.5">
|
<XStack alignItems="center" gap="$1.5">
|
||||||
<User size={12} color={muted} />
|
<User size={12} color={muted} />
|
||||||
<Text fontSize="$xs" fontWeight="700" color={muted}>
|
<Text fontSize="$xs" fontWeight="700" color={muted}>
|
||||||
{item.target_identifier}
|
{item.target_identifier}
|
||||||
@@ -367,7 +367,7 @@ export default function MobileEventGuestNotificationsPage() {
|
|||||||
</PillBadge>
|
</PillBadge>
|
||||||
) : (
|
) : (
|
||||||
<PillBadge tone="muted">
|
<PillBadge tone="muted">
|
||||||
<XStack alignItems="center" space="$1.5">
|
<XStack alignItems="center" gap="$1.5">
|
||||||
<Users size={12} color={muted} />
|
<Users size={12} color={muted} />
|
||||||
<Text fontSize="$xs" fontWeight="700" color={muted}>
|
<Text fontSize="$xs" fontWeight="700" color={muted}>
|
||||||
{t('guestMessages.audience.all', 'All guests')}
|
{t('guestMessages.audience.all', 'All guests')}
|
||||||
|
|||||||
@@ -285,15 +285,15 @@ export default function MobileEventLiveShowSettingsPage() {
|
|||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
{Array.from({ length: 3 }).map((_, idx) => (
|
{Array.from({ length: 3 }).map((_, idx) => (
|
||||||
<SkeletonCard key={`ls-skel-${idx}`} height={110} />
|
<SkeletonCard key={`ls-skel-${idx}`} height={110} />
|
||||||
))}
|
))}
|
||||||
</YStack>
|
</YStack>
|
||||||
) : (
|
) : (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<MobileCard space="$2">
|
<MobileCard gap="$2">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Link2 size={18} color={text} />
|
<Link2 size={18} color={text} />
|
||||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
{t('liveShowSettings.link.title', 'Live Show link')}
|
{t('liveShowSettings.link.title', 'Live Show link')}
|
||||||
@@ -313,7 +313,7 @@ export default function MobileEventLiveShowSettingsPage() {
|
|||||||
{t('liveShowSettings.link.empty', 'No Live Show link available.')}
|
{t('liveShowSettings.link.empty', 'No Live Show link available.')}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<XStack space="$2" marginTop="$2" alignItems="center" flexWrap="nowrap">
|
<XStack gap="$2" marginTop="$2" alignItems="center" flexWrap="nowrap">
|
||||||
<IconAction
|
<IconAction
|
||||||
label={t('liveShowSettings.link.copy', 'Copy')}
|
label={t('liveShowSettings.link.copy', 'Copy')}
|
||||||
disabled={!liveShowLink?.url}
|
disabled={!liveShowLink?.url}
|
||||||
@@ -344,7 +344,7 @@ export default function MobileEventLiveShowSettingsPage() {
|
|||||||
</IconAction>
|
</IconAction>
|
||||||
</XStack>
|
</XStack>
|
||||||
{liveShowLink?.qr_code_data_url ? (
|
{liveShowLink?.qr_code_data_url ? (
|
||||||
<XStack space="$2" alignItems="center" marginTop="$2" flexWrap="wrap">
|
<XStack gap="$2" alignItems="center" marginTop="$2" flexWrap="wrap">
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => downloadQr(liveShowLink.qr_code_data_url!, 'live-show-qr.png')}
|
onPress={() => downloadQr(liveShowLink.qr_code_data_url!, 'live-show-qr.png')}
|
||||||
title={t('liveShowSettings.link.downloadQr', 'Download QR')}
|
title={t('liveShowSettings.link.downloadQr', 'Download QR')}
|
||||||
@@ -373,8 +373,8 @@ export default function MobileEventLiveShowSettingsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$2">
|
<MobileCard gap="$2">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Settings size={18} color={text} />
|
<Settings size={18} color={text} />
|
||||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
{t('liveShowSettings.title', 'Live Show settings')}
|
{t('liveShowSettings.title', 'Live Show settings')}
|
||||||
@@ -385,7 +385,7 @@ export default function MobileEventLiveShowSettingsPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<Text fontSize="$sm" fontWeight="800" color={text}>
|
<Text fontSize="$sm" fontWeight="800" color={text}>
|
||||||
{t('liveShowSettings.sections.moderation', 'Moderation')}
|
{t('liveShowSettings.sections.moderation', 'Moderation')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -423,7 +423,7 @@ export default function MobileEventLiveShowSettingsPage() {
|
|||||||
</MobileField>
|
</MobileField>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<Text fontSize="$sm" fontWeight="800" color={text}>
|
<Text fontSize="$sm" fontWeight="800" color={text}>
|
||||||
{t('liveShowSettings.sections.playback', 'Playback')}
|
{t('liveShowSettings.sections.playback', 'Playback')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -478,7 +478,7 @@ export default function MobileEventLiveShowSettingsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<Text fontSize="$sm" fontWeight="800" color={text}>
|
<Text fontSize="$sm" fontWeight="800" color={text}>
|
||||||
{t('liveShowSettings.sections.effects', 'Effects & layout')}
|
{t('liveShowSettings.sections.effects', 'Effects & layout')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -661,7 +661,7 @@ function EffectSlider({
|
|||||||
const { text, muted, primary, border, surface } = useAdminTheme();
|
const { text, muted, primary, border, surface } = useAdminTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack space="$1.5">
|
<YStack gap="$1.5">
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -163,11 +163,11 @@ export default function MobileEventMembersPage() {
|
|||||||
<ContextHelpLink slug="event-team-invites" />
|
<ContextHelpLink slug="event-team-invites" />
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('events.members.inviteTitle', 'Invite Member')}
|
{t('events.members.inviteTitle', 'Invite Member')}
|
||||||
</Text>
|
</Text>
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<MobileField label={t('events.members.name', 'Name')}>
|
<MobileField label={t('events.members.name', 'Name')}>
|
||||||
<MobileInput
|
<MobileInput
|
||||||
type="text"
|
type="text"
|
||||||
@@ -223,11 +223,11 @@ export default function MobileEventMembersPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{members.length > 0 ? (
|
{members.length > 0 ? (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$xs" fontWeight="700" color={muted}>
|
<Text fontSize="$xs" fontWeight="700" color={muted}>
|
||||||
{t('events.members.filters.statusLabel', 'Status')}
|
{t('events.members.filters.statusLabel', 'Status')}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack space="$2" flexWrap="wrap">
|
<XStack gap="$2" flexWrap="wrap">
|
||||||
{statusOptions.map((option) => {
|
{statusOptions.map((option) => {
|
||||||
const isActive = statusFilter === option.key;
|
const isActive = statusFilter === option.key;
|
||||||
return (
|
return (
|
||||||
@@ -251,7 +251,7 @@ export default function MobileEventMembersPage() {
|
|||||||
<Text fontSize="$xs" fontWeight="700" color={muted}>
|
<Text fontSize="$xs" fontWeight="700" color={muted}>
|
||||||
{t('events.members.filters.roleLabel', 'Role')}
|
{t('events.members.filters.roleLabel', 'Role')}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack space="$2" flexWrap="wrap">
|
<XStack gap="$2" flexWrap="wrap">
|
||||||
{roleOptions.map((option) => {
|
{roleOptions.map((option) => {
|
||||||
const isActive = roleFilter === option.key;
|
const isActive = roleFilter === option.key;
|
||||||
return (
|
return (
|
||||||
@@ -275,18 +275,18 @@ export default function MobileEventMembersPage() {
|
|||||||
</YStack>
|
</YStack>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('events.members.listTitle', 'Team & Guests')}
|
{t('events.members.listTitle', 'Team & Guests')}
|
||||||
</Text>
|
</Text>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
{Array.from({ length: 4 }).map((_, idx) => (
|
{Array.from({ length: 4 }).map((_, idx) => (
|
||||||
<SkeletonCard key={`m-${idx}`} height={70} />
|
<SkeletonCard key={`m-${idx}`} height={70} />
|
||||||
))}
|
))}
|
||||||
</YStack>
|
</YStack>
|
||||||
) : members.length === 0 ? (
|
) : members.length === 0 ? (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{t('events.members.emptyTitle', 'Invite your team')}
|
{t('events.members.emptyTitle', 'Invite your team')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -295,9 +295,9 @@ export default function MobileEventMembersPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</YStack>
|
</YStack>
|
||||||
) : (
|
) : (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
{filteredMembers.length === 0 ? (
|
{filteredMembers.length === 0 ? (
|
||||||
<YStack space="$1.5" padding="$2">
|
<YStack gap="$1.5" padding="$2">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{t('events.members.emptyFilteredTitle', 'No matching members')}
|
{t('events.members.emptyFilteredTitle', 'No matching members')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -322,14 +322,14 @@ export default function MobileEventMembersPage() {
|
|||||||
return (
|
return (
|
||||||
<MobileCard key={member.id} padding="$3" borderColor={border}>
|
<MobileCard key={member.id} padding="$3" borderColor={border}>
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<YStack space="$1">
|
<YStack gap="$1">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{member.name || member.email || t('events.members.fallbackName', 'Guest')}
|
{member.name || member.email || t('events.members.fallbackName', 'Guest')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{member.email ?? ''}
|
{member.email ?? ''}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack space="$1.5" alignItems="center">
|
<XStack gap="$1.5" alignItems="center">
|
||||||
<PillBadge tone={statusInfo.tone}>
|
<PillBadge tone={statusInfo.tone}>
|
||||||
{statusInfo.label}
|
{statusInfo.label}
|
||||||
</PillBadge>
|
</PillBadge>
|
||||||
@@ -340,7 +340,7 @@ export default function MobileEventMembersPage() {
|
|||||||
</PillBadge>
|
</PillBadge>
|
||||||
</XStack>
|
</XStack>
|
||||||
</YStack>
|
</YStack>
|
||||||
<XStack space="$2">
|
<XStack gap="$2">
|
||||||
<Pressable
|
<Pressable
|
||||||
aria-label={t('events.members.copyEmailLabel', 'Copy email')}
|
aria-label={t('events.members.copyEmailLabel', 'Copy email')}
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
|
|||||||
@@ -191,15 +191,15 @@ export default function MobileEventPhotoboothPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
{Array.from({ length: 3 }).map((_, idx) => (
|
{Array.from({ length: 3 }).map((_, idx) => (
|
||||||
<SkeletonCard key={`ph-skel-${idx}`} height={110} />
|
<SkeletonCard key={`ph-skel-${idx}`} height={110} />
|
||||||
))}
|
))}
|
||||||
</YStack>
|
</YStack>
|
||||||
) : (
|
) : (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<YStack space="$1">
|
<YStack gap="$1">
|
||||||
<Text fontSize="$sm" fontWeight="800" color={text}>
|
<Text fontSize="$sm" fontWeight="800" color={text}>
|
||||||
{t('photobooth.steps.activate.title', '1. Photobooth aktivieren')}
|
{t('photobooth.steps.activate.title', '1. Photobooth aktivieren')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -207,7 +207,7 @@ export default function MobileEventPhotoboothPage() {
|
|||||||
{t('photobooth.steps.activate.description', 'Schalte den Upload-Zugang fuer dieses Event frei.')}
|
{t('photobooth.steps.activate.description', 'Schalte den Upload-Zugang fuer dieses Event frei.')}
|
||||||
</Text>
|
</Text>
|
||||||
</YStack>
|
</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'}>
|
<PillBadge tone={isActive ? 'success' : 'warning'}>
|
||||||
{isActive ? t('photobooth.status.badgeActive', 'ACTIVE') : t('photobooth.status.badgeInactive', 'INACTIVE')}
|
{isActive ? t('photobooth.status.badgeActive', 'ACTIVE') : t('photobooth.status.badgeInactive', 'INACTIVE')}
|
||||||
</PillBadge>
|
</PillBadge>
|
||||||
@@ -215,7 +215,7 @@ export default function MobileEventPhotoboothPage() {
|
|||||||
{t('photobooth.mode.active', 'Current: {{mode}}', { mode: modeLabel })}
|
{t('photobooth.mode.active', 'Current: {{mode}}', { mode: modeLabel })}
|
||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
<XStack space="$2" marginTop="$2">
|
<XStack gap="$2" marginTop="$2">
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={isActive ? t('photobooth.actions.disable', 'Disable uploads') : t('photobooth.actions.enable', 'Enable uploads')}
|
label={isActive ? t('photobooth.actions.disable', 'Disable uploads') : t('photobooth.actions.enable', 'Enable uploads')}
|
||||||
onPress={() => (isActive ? handleDisable() : handleEnable())}
|
onPress={() => (isActive ? handleDisable() : handleEnable())}
|
||||||
@@ -237,8 +237,8 @@ export default function MobileEventPhotoboothPage() {
|
|||||||
</XStack>
|
</XStack>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<YStack space="$1">
|
<YStack gap="$1">
|
||||||
<Text fontSize="$sm" fontWeight="800" color={text}>
|
<Text fontSize="$sm" fontWeight="800" color={text}>
|
||||||
{t('photobooth.steps.download.title', '2. Uploader App herunterladen')}
|
{t('photobooth.steps.download.title', '2. Uploader App herunterladen')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -249,7 +249,7 @@ export default function MobileEventPhotoboothPage() {
|
|||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</YStack>
|
</YStack>
|
||||||
<XStack space="$2" marginTop="$2" flexWrap="wrap">
|
<XStack gap="$2" marginTop="$2" flexWrap="wrap">
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={t('photobooth.uploaderDownload.actionWindows', 'Uploader herunterladen (Windows)')}
|
label={t('photobooth.uploaderDownload.actionWindows', 'Uploader herunterladen (Windows)')}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@@ -278,7 +278,7 @@ export default function MobileEventPhotoboothPage() {
|
|||||||
fullWidth={false}
|
fullWidth={false}
|
||||||
/>
|
/>
|
||||||
</XStack>
|
</XStack>
|
||||||
<XStack space="$2" marginTop="$2">
|
<XStack gap="$2" marginTop="$2">
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={
|
label={
|
||||||
sendingEmail
|
sendingEmail
|
||||||
@@ -294,8 +294,8 @@ export default function MobileEventPhotoboothPage() {
|
|||||||
</XStack>
|
</XStack>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<YStack space="$1">
|
<YStack gap="$1">
|
||||||
<Text fontSize="$sm" fontWeight="800" color={text}>
|
<Text fontSize="$sm" fontWeight="800" color={text}>
|
||||||
{t('photobooth.steps.access.title', '3. Verbindungscode erstellen')}
|
{t('photobooth.steps.access.title', '3. Verbindungscode erstellen')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -303,7 +303,7 @@ export default function MobileEventPhotoboothPage() {
|
|||||||
{t('photobooth.steps.access.description', 'Der Code verbindet die App sicher mit deinem Event.')}
|
{t('photobooth.steps.access.description', 'Der Code verbindet die App sicher mit deinem Event.')}
|
||||||
</Text>
|
</Text>
|
||||||
</YStack>
|
</YStack>
|
||||||
<XStack space="$2" marginTop="$2">
|
<XStack gap="$2" marginTop="$2">
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={
|
label={
|
||||||
connectLoading
|
connectLoading
|
||||||
@@ -326,7 +326,7 @@ export default function MobileEventPhotoboothPage() {
|
|||||||
fullWidth={false}
|
fullWidth={false}
|
||||||
/>
|
/>
|
||||||
</XStack>
|
</XStack>
|
||||||
<YStack space="$2" marginTop="$2">
|
<YStack gap="$2" marginTop="$2">
|
||||||
{connectCode ? (
|
{connectCode ? (
|
||||||
<CredentialRow label={t('photobooth.connectCode.label', 'Connect code')} value={connectCode} border={border} />
|
<CredentialRow label={t('photobooth.connectCode.label', 'Connect code')} value={connectCode} border={border} />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -338,7 +338,7 @@ export default function MobileEventPhotoboothPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{showCredentials ? (
|
{showCredentials ? (
|
||||||
<YStack space="$1">
|
<YStack gap="$1">
|
||||||
<CredentialRow label={t('photobooth.credentials.postUrl', 'Upload URL')} value={uploadUrl ?? '—'} border={border} />
|
<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.username', 'Username')} value={username ?? '—'} border={border} />
|
||||||
<CredentialRow label={t('photobooth.credentials.password', 'Password')} value={password ?? '—'} border={border} masked />
|
<CredentialRow label={t('photobooth.credentials.password', 'Password')} value={password ?? '—'} border={border} masked />
|
||||||
@@ -354,11 +354,11 @@ export default function MobileEventPhotoboothPage() {
|
|||||||
</YStack>
|
</YStack>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$2">
|
<MobileCard gap="$2">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||||
{t('photobooth.status.heading', 'Status')}
|
{t('photobooth.status.heading', 'Status')}
|
||||||
</Text>
|
</Text>
|
||||||
<YStack space="$1">
|
<YStack gap="$1">
|
||||||
<StatusRow icon={<ShieldCheck size={16} color={text} />} label={t('photobooth.status.mode', 'Mode')} value={modeLabel} />
|
<StatusRow icon={<ShieldCheck size={16} color={text} />} label={t('photobooth.status.mode', 'Mode')} value={modeLabel} />
|
||||||
<StatusRow
|
<StatusRow
|
||||||
icon={<PlugZap size={16} color={text} />}
|
icon={<PlugZap size={16} color={text} />}
|
||||||
@@ -437,7 +437,7 @@ function StatusRow({ icon, label, value }: { icon: React.ReactNode; label: strin
|
|||||||
const { text } = useAdminTheme();
|
const { text } = useAdminTheme();
|
||||||
return (
|
return (
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
{icon}
|
{icon}
|
||||||
<Text fontSize="$sm" color={text}>
|
<Text fontSize="$sm" color={text}>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ export default function MobileEventRecapPage() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<MobileShell activeTab="home" title={t('events.recap.title', 'Event Recap')} onBack={back}>
|
<MobileShell activeTab="home" title={t('events.recap.title', 'Event Recap')} onBack={back}>
|
||||||
<YStack space="$3">
|
<YStack gap="$3">
|
||||||
<SkeletonCard height={120} />
|
<SkeletonCard height={120} />
|
||||||
<SkeletonCard height={200} />
|
<SkeletonCard height={200} />
|
||||||
<SkeletonCard height={150} />
|
<SkeletonCard height={150} />
|
||||||
@@ -207,8 +207,8 @@ export default function MobileEventRecapPage() {
|
|||||||
title={t('events.recap.title', 'Event Recap')}
|
title={t('events.recap.title', 'Event Recap')}
|
||||||
onBack={back}
|
onBack={back}
|
||||||
>
|
>
|
||||||
<YStack space="$4">
|
<YStack gap="$4">
|
||||||
<XStack space="$2">
|
<XStack gap="$2">
|
||||||
<TabButton
|
<TabButton
|
||||||
label={t('events.recap.tabs.overview', 'Overview')}
|
label={t('events.recap.tabs.overview', 'Overview')}
|
||||||
active={activeTab === 'overview'}
|
active={activeTab === 'overview'}
|
||||||
@@ -227,10 +227,10 @@ export default function MobileEventRecapPage() {
|
|||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
{activeTab === 'overview' ? (
|
{activeTab === 'overview' ? (
|
||||||
<YStack space="$4">
|
<YStack gap="$4">
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<YStack space="$1">
|
<YStack gap="$1">
|
||||||
<Text fontSize="$xl" fontWeight="800" color={textStrong}>
|
<Text fontSize="$xl" fontWeight="800" color={textStrong}>
|
||||||
{t('events.recap.completedTitle', 'Event abgeschlossen')}
|
{t('events.recap.completedTitle', 'Event abgeschlossen')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -248,8 +248,8 @@ export default function MobileEventRecapPage() {
|
|||||||
</XStack>
|
</XStack>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Share2 size={18} color={primary} />
|
<Share2 size={18} color={primary} />
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('events.recap.shareGuests', 'Gäste-Galerie teilen')}
|
{t('events.recap.shareGuests', 'Gäste-Galerie teilen')}
|
||||||
@@ -260,7 +260,7 @@ export default function MobileEventRecapPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{guestLink ? (
|
{guestLink ? (
|
||||||
<YStack space="$2" marginTop="$1">
|
<YStack gap="$2" marginTop="$1">
|
||||||
<XStack
|
<XStack
|
||||||
backgroundColor={border}
|
backgroundColor={border}
|
||||||
padding="$3"
|
padding="$3"
|
||||||
@@ -292,7 +292,7 @@ export default function MobileEventRecapPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{guestLink && activeInvite?.qr_code_data_url ? (
|
{guestLink && activeInvite?.qr_code_data_url ? (
|
||||||
<YStack alignItems="center" space="$2" marginTop="$2">
|
<YStack alignItems="center" gap="$2" marginTop="$2">
|
||||||
<YStack
|
<YStack
|
||||||
padding="$2"
|
padding="$2"
|
||||||
backgroundColor="white"
|
backgroundColor="white"
|
||||||
@@ -315,15 +315,15 @@ export default function MobileEventRecapPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Users size={18} color={primary} />
|
<Users size={18} color={primary} />
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('events.recap.settings', 'Nachlauf-Optionen')}
|
{t('events.recap.settings', 'Nachlauf-Optionen')}
|
||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<YStack space="$1.5">
|
<YStack gap="$1.5">
|
||||||
<ToggleOption
|
<ToggleOption
|
||||||
label={t('events.recap.allowDownloads', 'Gäste dürfen Fotos laden')}
|
label={t('events.recap.allowDownloads', 'Gäste dürfen Fotos laden')}
|
||||||
value={Boolean(event.settings?.guest_downloads_enabled)}
|
value={Boolean(event.settings?.guest_downloads_enabled)}
|
||||||
@@ -337,8 +337,8 @@ export default function MobileEventRecapPage() {
|
|||||||
</YStack>
|
</YStack>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Sparkles size={18} color={primary} />
|
<Sparkles size={18} color={primary} />
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('events.recap.addons', 'Galerie verlängern')}
|
{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.')}
|
{t('events.recap.addonBody', 'Die Online-Zeit deiner Galerie neigt sich dem Ende? Hier kannst du sie verlängern.')}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
{addons
|
{addons
|
||||||
.filter((a) => a.key === 'gallery_extension')
|
.filter((a) => a.key === 'gallery_extension')
|
||||||
.map((addon) => (
|
.map((addon) => (
|
||||||
@@ -365,9 +365,9 @@ export default function MobileEventRecapPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{activeTab === 'engagement' ? (
|
{activeTab === 'engagement' ? (
|
||||||
<YStack space="$4">
|
<YStack gap="$4">
|
||||||
{engagementLoading ? (
|
{engagementLoading ? (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<SkeletonCard height={140} />
|
<SkeletonCard height={140} />
|
||||||
<SkeletonCard height={180} />
|
<SkeletonCard height={180} />
|
||||||
<SkeletonCard height={180} />
|
<SkeletonCard height={180} />
|
||||||
@@ -387,9 +387,9 @@ export default function MobileEventRecapPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
) : (
|
) : (
|
||||||
<YStack space="$4">
|
<YStack gap="$4">
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<TrendingUp size={18} color={primary} />
|
<TrendingUp size={18} color={primary} />
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('events.recap.engagement.title', 'Guest engagement')}
|
{t('events.recap.engagement.title', 'Guest engagement')}
|
||||||
@@ -418,8 +418,8 @@ export default function MobileEventRecapPage() {
|
|||||||
</XStack>
|
</XStack>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Trophy size={18} color={primary} />
|
<Trophy size={18} color={primary} />
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('events.recap.engagement.leaderboards.uploadsTitle', 'Top contributors')}
|
{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.')}
|
{t('events.recap.engagement.leaderboards.uploadsEmpty', 'No uploads yet.')}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<YStack space="$1.5" marginTop="$1">
|
<YStack gap="$1.5" marginTop="$1">
|
||||||
{engagement.leaderboards.uploads.slice(0, 5).map((entry, index) => (
|
{engagement.leaderboards.uploads.slice(0, 5).map((entry, index) => (
|
||||||
<LeaderboardRow
|
<LeaderboardRow
|
||||||
key={`${entry.guest}-${entry.photos}-${index}`}
|
key={`${entry.guest}-${entry.photos}-${index}`}
|
||||||
@@ -443,8 +443,8 @@ export default function MobileEventRecapPage() {
|
|||||||
)}
|
)}
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Heart size={18} color={primary} />
|
<Heart size={18} color={primary} />
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('events.recap.engagement.leaderboards.likesTitle', 'Most liked')}
|
{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.')}
|
{t('events.recap.engagement.leaderboards.likesEmpty', 'No likes yet.')}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<YStack space="$1.5" marginTop="$1">
|
<YStack gap="$1.5" marginTop="$1">
|
||||||
{engagement.leaderboards.likes.slice(0, 5).map((entry, index) => (
|
{engagement.leaderboards.likes.slice(0, 5).map((entry, index) => (
|
||||||
<LeaderboardRow
|
<LeaderboardRow
|
||||||
key={`${entry.guest}-${entry.likes}-${index}`}
|
key={`${entry.guest}-${entry.likes}-${index}`}
|
||||||
@@ -468,15 +468,15 @@ export default function MobileEventRecapPage() {
|
|||||||
)}
|
)}
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Sparkles size={18} color={primary} />
|
<Sparkles size={18} color={primary} />
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('events.recap.engagement.highlightsTitle', 'Highlights')}
|
{t('events.recap.engagement.highlightsTitle', 'Highlights')}
|
||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
<YStack space="$2" marginTop="$1">
|
<YStack gap="$2" marginTop="$1">
|
||||||
<XStack space="$2" alignItems="center">
|
<XStack gap="$2" alignItems="center">
|
||||||
<YStack
|
<YStack
|
||||||
width={72}
|
width={72}
|
||||||
height={72}
|
height={72}
|
||||||
@@ -519,7 +519,7 @@ export default function MobileEventRecapPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<YStack space="$1">
|
<YStack gap="$1">
|
||||||
<Text fontSize="$sm" fontWeight="600" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="600" color={textStrong}>
|
||||||
{t('events.recap.engagement.timeline', 'Uploads over time')}
|
{t('events.recap.engagement.timeline', 'Uploads over time')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -528,7 +528,7 @@ export default function MobileEventRecapPage() {
|
|||||||
{t('events.recap.engagement.timelineEmpty', 'No timeline data yet.')}
|
{t('events.recap.engagement.timelineEmpty', 'No timeline data yet.')}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<YStack space="$1">
|
<YStack gap="$1">
|
||||||
{engagement.highlights.timeline.slice(-5).map((point) => (
|
{engagement.highlights.timeline.slice(-5).map((point) => (
|
||||||
<XStack key={point.date} alignItems="center" justifyContent="space-between">
|
<XStack key={point.date} alignItems="center" justifyContent="space-between">
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
@@ -553,7 +553,7 @@ export default function MobileEventRecapPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{activeTab === 'compliance' ? (
|
{activeTab === 'compliance' ? (
|
||||||
<YStack space="$4">
|
<YStack gap="$4">
|
||||||
<DataExportsPanel variant="recap" event={event} />
|
<DataExportsPanel variant="recap" event={event} />
|
||||||
</YStack>
|
</YStack>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -643,7 +643,7 @@ function LeaderboardRow({ rank, name, value }: { rank: number; name: string; val
|
|||||||
borderColor={border}
|
borderColor={border}
|
||||||
backgroundColor={surfaceMuted}
|
backgroundColor={surfaceMuted}
|
||||||
>
|
>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Text fontSize="$xs" color={muted} fontWeight="700">
|
<Text fontSize="$xs" color={muted} fontWeight="700">
|
||||||
#{rank}
|
#{rank}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -588,8 +588,8 @@ export default function MobileEventTasksPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const taskPanel = assignedTasks.length === 0 ? (
|
const taskPanel = assignedTasks.length === 0 ? (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<MobileCard space="$2">
|
<MobileCard gap="$2">
|
||||||
<Text fontSize={13} fontWeight="700" color={text}>
|
<Text fontSize={13} fontWeight="700" color={text}>
|
||||||
{t('events.tasks.emptyTitle', 'No photo tasks yet')}
|
{t('events.tasks.emptyTitle', 'No photo tasks yet')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -602,7 +602,7 @@ export default function MobileEventTasksPage() {
|
|||||||
{limitReachedHint ? ` ${limitReachedHint}` : ''}
|
{limitReachedHint ? ` ${limitReachedHint}` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
<XStack space="$2">
|
<XStack gap="$2">
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={t('events.tasks.emptyActionTask', 'Add photo task')}
|
label={t('events.tasks.emptyActionTask', 'Add photo task')}
|
||||||
onPress={() => setShowTaskSheet(true)}
|
onPress={() => setShowTaskSheet(true)}
|
||||||
@@ -631,7 +631,7 @@ export default function MobileEventTasksPage() {
|
|||||||
setShowTaskSheet(true);
|
setShowTaskSheet(true);
|
||||||
}}
|
}}
|
||||||
title={
|
title={
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<YStack
|
<YStack
|
||||||
width={28}
|
width={28}
|
||||||
height={28}
|
height={28}
|
||||||
@@ -669,7 +669,7 @@ export default function MobileEventTasksPage() {
|
|||||||
setActiveTab('collections');
|
setActiveTab('collections');
|
||||||
}}
|
}}
|
||||||
title={
|
title={
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<YStack
|
<YStack
|
||||||
width={28}
|
width={28}
|
||||||
height={28}
|
height={28}
|
||||||
@@ -698,9 +698,9 @@ export default function MobileEventTasksPage() {
|
|||||||
</YGroup>
|
</YGroup>
|
||||||
</YStack>
|
</YStack>
|
||||||
) : (
|
) : (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<XStack alignItems="baseline" justifyContent="space-between" flexWrap="wrap" space="$2">
|
<XStack alignItems="baseline" justifyContent="space-between" flexWrap="wrap" gap="$2">
|
||||||
<XStack alignItems="baseline" space="$2" flexWrap="wrap">
|
<XStack alignItems="baseline" gap="$2" flexWrap="wrap">
|
||||||
<Text fontSize="$sm" fontWeight="800" color={text}>
|
<Text fontSize="$sm" fontWeight="800" color={text}>
|
||||||
{t('events.tasks.assignedTitle', 'Task list')}
|
{t('events.tasks.assignedTitle', 'Task list')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -718,11 +718,11 @@ export default function MobileEventTasksPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</XStack>
|
</XStack>
|
||||||
{selectionMode ? (
|
{selectionMode ? (
|
||||||
<MobileCard padding="$2.5" space="$2">
|
<MobileCard padding="$2.5" gap="$2">
|
||||||
<Text fontSize={12} fontWeight="700" color={text}>
|
<Text fontSize={12} fontWeight="700" color={text}>
|
||||||
{t('events.tasks.selectionCount', '{{count}} ausgewählt', { count: selectedTaskIds.size })}
|
{t('events.tasks.selectionCount', '{{count}} ausgewählt', { count: selectedTaskIds.size })}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack space="$2">
|
<XStack gap="$2">
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={t('events.tasks.bulkRemove', 'Auswahl löschen')}
|
label={t('events.tasks.bulkRemove', 'Auswahl löschen')}
|
||||||
tone="danger"
|
tone="danger"
|
||||||
@@ -751,7 +751,7 @@ export default function MobileEventTasksPage() {
|
|||||||
onPointerLeave={cancelLongPress}
|
onPointerLeave={cancelLongPress}
|
||||||
onPointerCancel={cancelLongPress}
|
onPointerCancel={cancelLongPress}
|
||||||
title={
|
title={
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
{selectionMode ? (
|
{selectionMode ? (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
size="$3"
|
size="$3"
|
||||||
@@ -779,7 +779,7 @@ export default function MobileEventTasksPage() {
|
|||||||
}
|
}
|
||||||
iconAfter={
|
iconAfter={
|
||||||
selectionMode ? null : (
|
selectionMode ? null : (
|
||||||
<XStack space="$2" alignItems="center">
|
<XStack gap="$2" alignItems="center">
|
||||||
{task.emotion ? (
|
{task.emotion ? (
|
||||||
<Tag label={task.emotion.name ?? ''} color={task.emotion.color ?? text} />
|
<Tag label={task.emotion.name ?? ''} color={task.emotion.color ?? text} />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -811,7 +811,7 @@ export default function MobileEventTasksPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const libraryPanel = (
|
const libraryPanel = (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<XStack alignItems="center" justifyContent="space-between" flexWrap="wrap">
|
<XStack alignItems="center" justifyContent="space-between" flexWrap="wrap">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||||
{t('events.tasks.tabs.library', 'Task Library')}
|
{t('events.tasks.tabs.library', 'Task Library')}
|
||||||
@@ -865,7 +865,7 @@ export default function MobileEventTasksPage() {
|
|||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
iconAfter={
|
iconAfter={
|
||||||
<XStack space="$1.5" alignItems="center">
|
<XStack gap="$1.5" alignItems="center">
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (!canAddTasks) {
|
if (!canAddTasks) {
|
||||||
@@ -875,7 +875,7 @@ export default function MobileEventTasksPage() {
|
|||||||
quickAssign(task.id);
|
quickAssign(task.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<XStack alignItems="center" space="$1">
|
<XStack alignItems="center" gap="$1">
|
||||||
<Plus size={14} color={canAddTasks ? primary : muted} />
|
<Plus size={14} color={canAddTasks ? primary : muted} />
|
||||||
<Text fontSize={12} fontWeight="600" color={canAddTasks ? primary : muted}>
|
<Text fontSize={12} fontWeight="600" color={canAddTasks ? primary : muted}>
|
||||||
{assigningId === task.id ? t('common.processing', '...') : t('events.tasks.add', 'Add')}
|
{assigningId === task.id ? t('common.processing', '...') : t('events.tasks.add', 'Add')}
|
||||||
@@ -896,7 +896,7 @@ export default function MobileEventTasksPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const collectionsPanel = (
|
const collectionsPanel = (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{t('events.tasks.importHint', 'Use predefined packs for your event type.')}
|
{t('events.tasks.importHint', 'Use predefined packs for your event type.')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -937,7 +937,7 @@ export default function MobileEventTasksPage() {
|
|||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
iconAfter={
|
iconAfter={
|
||||||
<XStack space="$1.5" alignItems="center">
|
<XStack gap="$1.5" alignItems="center">
|
||||||
<Button
|
<Button
|
||||||
size="$2"
|
size="$2"
|
||||||
backgroundColor={withAlpha(primary, 0.12)}
|
backgroundColor={withAlpha(primary, 0.12)}
|
||||||
@@ -969,7 +969,7 @@ export default function MobileEventTasksPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const emotionsPanel = (
|
const emotionsPanel = (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<XStack alignItems="center" justifyContent="space-between" flexWrap="wrap">
|
<XStack alignItems="center" justifyContent="space-between" flexWrap="wrap">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||||
{t('events.tasks.tabs.emotions', 'Emotions')}
|
{t('events.tasks.tabs.emotions', 'Emotions')}
|
||||||
@@ -1002,12 +1002,12 @@ export default function MobileEventTasksPage() {
|
|||||||
hoverTheme
|
hoverTheme
|
||||||
pressTheme
|
pressTheme
|
||||||
title={
|
title={
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Tag label={emotion.name ?? ''} color={emotion.color ?? border} />
|
<Tag label={emotion.name ?? ''} color={emotion.color ?? border} />
|
||||||
</XStack>
|
</XStack>
|
||||||
}
|
}
|
||||||
iconAfter={
|
iconAfter={
|
||||||
<XStack space="$2">
|
<XStack gap="$2">
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setEditingEmotion(emotion);
|
setEditingEmotion(emotion);
|
||||||
@@ -1039,7 +1039,7 @@ export default function MobileEventTasksPage() {
|
|||||||
title={t('events.tasks.title', 'Photo tasks for guests')}
|
title={t('events.tasks.title', 'Photo tasks for guests')}
|
||||||
onBack={back}
|
onBack={back}
|
||||||
headerActions={
|
headerActions={
|
||||||
<XStack space="$2">
|
<XStack gap="$2">
|
||||||
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
|
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
|
||||||
<RefreshCcw size={18} color={text} />
|
<RefreshCcw size={18} color={text} />
|
||||||
</HeaderActionButton>
|
</HeaderActionButton>
|
||||||
@@ -1061,7 +1061,7 @@ export default function MobileEventTasksPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
{Array.from({ length: 4 }).map((_, idx) => (
|
{Array.from({ length: 4 }).map((_, idx) => (
|
||||||
<SkeletonCard key={`tsk-${idx}`} height={70} />
|
<SkeletonCard key={`tsk-${idx}`} height={70} />
|
||||||
))}
|
))}
|
||||||
@@ -1074,7 +1074,7 @@ export default function MobileEventTasksPage() {
|
|||||||
backgroundColor={surface}
|
backgroundColor={surface}
|
||||||
padding="$3"
|
padding="$3"
|
||||||
>
|
>
|
||||||
<YStack space="$3">
|
<YStack gap="$3">
|
||||||
<Card
|
<Card
|
||||||
borderRadius={18}
|
borderRadius={18}
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
@@ -1082,8 +1082,8 @@ export default function MobileEventTasksPage() {
|
|||||||
backgroundColor={surfaceMuted}
|
backgroundColor={surfaceMuted}
|
||||||
padding="$3"
|
padding="$3"
|
||||||
>
|
>
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<XStack alignItems="center" justifyContent="space-between" space="$2">
|
<XStack alignItems="center" justifyContent="space-between" gap="$2">
|
||||||
<Text fontSize="$xs" fontWeight="700" color={text}>
|
<Text fontSize="$xs" fontWeight="700" color={text}>
|
||||||
{t('events.tasks.toggle.title', 'Photo tasks for guests')}
|
{t('events.tasks.toggle.title', 'Photo tasks for guests')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1121,7 +1121,7 @@ export default function MobileEventTasksPage() {
|
|||||||
<Text fontSize="$xs" fontWeight="700" color={text}>
|
<Text fontSize="$xs" fontWeight="700" color={text}>
|
||||||
{t('events.tasks.toggle.switchLabel', 'Photo task mode')}
|
{t('events.tasks.toggle.switchLabel', 'Photo task mode')}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack alignItems="center" space="$1.5">
|
<XStack alignItems="center" gap="$1.5">
|
||||||
<Switch
|
<Switch
|
||||||
size="$3"
|
size="$3"
|
||||||
checked={tasksEnabled}
|
checked={tasksEnabled}
|
||||||
@@ -1205,7 +1205,7 @@ export default function MobileEventTasksPage() {
|
|||||||
|
|
||||||
<Tabs.Content value="assigned" paddingTop="$2">
|
<Tabs.Content value="assigned" paddingTop="$2">
|
||||||
<Card borderRadius={18} borderWidth={1} borderColor={border} backgroundColor={surface} padding="$3">
|
<Card borderRadius={18} borderWidth={1} borderColor={border} backgroundColor={surface} padding="$3">
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Card
|
<Card
|
||||||
borderRadius={16}
|
borderRadius={16}
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
@@ -1213,7 +1213,7 @@ export default function MobileEventTasksPage() {
|
|||||||
backgroundColor={surfaceMuted}
|
backgroundColor={surfaceMuted}
|
||||||
padding="$2.5"
|
padding="$2.5"
|
||||||
>
|
>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<XStack flex={1}>
|
<XStack flex={1}>
|
||||||
<MobileInput
|
<MobileInput
|
||||||
type="search"
|
type="search"
|
||||||
@@ -1226,7 +1226,7 @@ export default function MobileEventTasksPage() {
|
|||||||
<Pressable onPress={() => setShowEmotionFilterSheet(true)}>
|
<Pressable onPress={() => setShowEmotionFilterSheet(true)}>
|
||||||
<XStack
|
<XStack
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
space="$1.5"
|
gap="$1.5"
|
||||||
paddingVertical="$2"
|
paddingVertical="$2"
|
||||||
paddingHorizontal="$3"
|
paddingHorizontal="$3"
|
||||||
borderRadius={14}
|
borderRadius={14}
|
||||||
@@ -1286,7 +1286,7 @@ export default function MobileEventTasksPage() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
{!canAddTasks ? (
|
{!canAddTasks ? (
|
||||||
<Text fontSize={12} fontWeight="600" color={dangerText}>
|
<Text fontSize={12} fontWeight="600" color={dangerText}>
|
||||||
{limitReachedMessage}
|
{limitReachedMessage}
|
||||||
@@ -1338,7 +1338,7 @@ export default function MobileEventTasksPage() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
{!canAddTasks ? (
|
{!canAddTasks ? (
|
||||||
<Text fontSize={12} fontWeight="600" color={dangerText}>
|
<Text fontSize={12} fontWeight="600" color={dangerText}>
|
||||||
{limitReachedMessage}
|
{limitReachedMessage}
|
||||||
@@ -1381,7 +1381,7 @@ export default function MobileEventTasksPage() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<MobileField label={t('events.tasks.emotionName', 'Name')}>
|
<MobileField label={t('events.tasks.emotionName', 'Name')}>
|
||||||
<MobileInput
|
<MobileInput
|
||||||
type="text"
|
type="text"
|
||||||
@@ -1416,8 +1416,8 @@ export default function MobileEventTasksPage() {
|
|||||||
setShowEmotionFilterSheet(false);
|
setShowEmotionFilterSheet(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<RadioGroup.Item value="">
|
<RadioGroup.Item value="">
|
||||||
<RadioGroup.Indicator />
|
<RadioGroup.Indicator />
|
||||||
</RadioGroup.Item>
|
</RadioGroup.Item>
|
||||||
@@ -1426,7 +1426,7 @@ export default function MobileEventTasksPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
{emotions.map((emotion) => (
|
{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.Item value={String(emotion.id)}>
|
||||||
<RadioGroup.Indicator />
|
<RadioGroup.Indicator />
|
||||||
</RadioGroup.Item>
|
</RadioGroup.Item>
|
||||||
@@ -1458,7 +1458,7 @@ export default function MobileEventTasksPage() {
|
|||||||
maxWidth={420}
|
maxWidth={420}
|
||||||
width="90%"
|
width="90%"
|
||||||
>
|
>
|
||||||
<YStack space="$3">
|
<YStack gap="$3">
|
||||||
<AlertDialog.Title>
|
<AlertDialog.Title>
|
||||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
{t('events.tasks.removeTitle', 'Remove photo task?')}
|
{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.')}
|
: t('events.tasks.removeBodyFallback', 'This will remove the photo task from the event.')}
|
||||||
</Text>
|
</Text>
|
||||||
</AlertDialog.Description>
|
</AlertDialog.Description>
|
||||||
<XStack space="$2" justifyContent="flex-end">
|
<XStack gap="$2" justifyContent="flex-end">
|
||||||
<AlertDialog.Cancel asChild>
|
<AlertDialog.Cancel asChild>
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={t('common.cancel', 'Cancel')}
|
label={t('common.cancel', 'Cancel')}
|
||||||
@@ -1513,7 +1513,7 @@ export default function MobileEventTasksPage() {
|
|||||||
maxWidth={420}
|
maxWidth={420}
|
||||||
width="90%"
|
width="90%"
|
||||||
>
|
>
|
||||||
<YStack space="$3">
|
<YStack gap="$3">
|
||||||
<AlertDialog.Title>
|
<AlertDialog.Title>
|
||||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
{t('events.tasks.bulkRemoveTitle', 'Auswahl löschen')}
|
{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.')}
|
{t('events.tasks.bulkRemoveBody', 'This will remove the selected photo tasks from the event.')}
|
||||||
</Text>
|
</Text>
|
||||||
</AlertDialog.Description>
|
</AlertDialog.Description>
|
||||||
<XStack space="$2" justifyContent="flex-end">
|
<XStack gap="$2" justifyContent="flex-end">
|
||||||
<AlertDialog.Cancel asChild>
|
<AlertDialog.Cancel asChild>
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={t('common.cancel', 'Cancel')}
|
label={t('common.cancel', 'Cancel')}
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export default function MobileEventsPage() {
|
|||||||
shadowRadius={16}
|
shadowRadius={16}
|
||||||
shadowOffset={{ width: 0, height: 10 }}
|
shadowOffset={{ width: 0, height: 10 }}
|
||||||
>
|
>
|
||||||
<YStack space="$2.5">
|
<YStack gap="$2.5">
|
||||||
<XStack
|
<XStack
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
paddingHorizontal="$3"
|
paddingHorizontal="$3"
|
||||||
@@ -165,7 +165,7 @@ export default function MobileEventsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
{Array.from({ length: 3 }).map((_, idx) => (
|
{Array.from({ length: 3 }).map((_, idx) => (
|
||||||
<SkeletonCard key={`sk-${idx}`} height={90} />
|
<SkeletonCard key={`sk-${idx}`} height={90} />
|
||||||
))}
|
))}
|
||||||
@@ -182,7 +182,7 @@ export default function MobileEventsPage() {
|
|||||||
shadowRadius={16}
|
shadowRadius={16}
|
||||||
shadowOffset={{ width: 0, height: 10 }}
|
shadowOffset={{ width: 0, height: 10 }}
|
||||||
>
|
>
|
||||||
<YStack space="$2" alignItems="center">
|
<YStack gap="$2" alignItems="center">
|
||||||
<Text fontSize="$md" fontWeight="700">
|
<Text fontSize="$md" fontWeight="700">
|
||||||
{t('events.list.title')}
|
{t('events.list.title')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -263,7 +263,7 @@ function EventsList({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack space="$3">
|
<YStack gap="$3">
|
||||||
{filteredEvents.length === 0 ? (
|
{filteredEvents.length === 0 ? (
|
||||||
<Card
|
<Card
|
||||||
borderRadius={22}
|
borderRadius={22}
|
||||||
@@ -276,7 +276,7 @@ function EventsList({
|
|||||||
shadowRadius={14}
|
shadowRadius={14}
|
||||||
shadowOffset={{ width: 0, height: 8 }}
|
shadowOffset={{ width: 0, height: 8 }}
|
||||||
>
|
>
|
||||||
<YStack space="$2" alignItems="center">
|
<YStack gap="$2" alignItems="center">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||||
{t('events.list.empty.filtered')}
|
{t('events.list.empty.filtered')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -303,7 +303,7 @@ function EventsList({
|
|||||||
shadowRadius={14}
|
shadowRadius={14}
|
||||||
shadowOffset={{ width: 0, height: 8 }}
|
shadowOffset={{ width: 0, height: 8 }}
|
||||||
>
|
>
|
||||||
<YStack space="$2.5">
|
<YStack gap="$2.5">
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<Text fontSize="$xs" fontWeight="800" color={text}>
|
<Text fontSize="$xs" fontWeight="800" color={text}>
|
||||||
{t('events.workspace.fields.status')}
|
{t('events.workspace.fields.status')}
|
||||||
@@ -326,7 +326,7 @@ function EventsList({
|
|||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onValueChange={(value: string) => value && onStatusChange(value as EventStatusKey)}
|
onValueChange={(value: string) => value && onStatusChange(value as EventStatusKey)}
|
||||||
>
|
>
|
||||||
<XStack space="$1.5">
|
<XStack gap="$1.5">
|
||||||
{filters.map((filter) => {
|
{filters.map((filter) => {
|
||||||
const active = filter.key === statusFilter;
|
const active = filter.key === statusFilter;
|
||||||
return (
|
return (
|
||||||
@@ -340,7 +340,7 @@ function EventsList({
|
|||||||
paddingVertical="$1.5"
|
paddingVertical="$1.5"
|
||||||
paddingHorizontal="$3"
|
paddingHorizontal="$3"
|
||||||
>
|
>
|
||||||
<XStack alignItems="center" space="$1.5">
|
<XStack alignItems="center" gap="$1.5">
|
||||||
<Text fontSize="$xs" fontWeight="700" color={active ? primary : muted}>
|
<Text fontSize="$xs" fontWeight="700" color={active ? primary : muted}>
|
||||||
{filter.label}
|
{filter.label}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -407,7 +407,7 @@ function EventsList({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
iconAfter={
|
iconAfter={
|
||||||
<XStack alignItems="center" space="$1.5">
|
<XStack alignItems="center" gap="$1.5">
|
||||||
<Text fontSize="$xs" color={primary} fontWeight="700">
|
<Text fontSize="$xs" color={primary} fontWeight="700">
|
||||||
{t('events.list.actions.open')}
|
{t('events.list.actions.open')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -448,12 +448,12 @@ function EventListItem({
|
|||||||
const locale = i18n.language;
|
const locale = i18n.language;
|
||||||
const stats = buildEventListStats(event);
|
const stats = buildEventListStats(event);
|
||||||
return (
|
return (
|
||||||
<YStack space="$1.5">
|
<YStack gap="$1.5">
|
||||||
<XStack alignItems="center" justifyContent="space-between" space="$2">
|
<XStack alignItems="center" justifyContent="space-between" gap="$2">
|
||||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
{renderName(event.name, t)}
|
{renderName(event.name, t)}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack alignItems="center" space="$1.5">
|
<XStack alignItems="center" gap="$1.5">
|
||||||
<PillBadge tone={statusTone}>{statusLabel}</PillBadge>
|
<PillBadge tone={statusTone}>{statusLabel}</PillBadge>
|
||||||
{onEdit ? (
|
{onEdit ? (
|
||||||
<Pressable onPress={() => onEdit(event.slug)}>
|
<Pressable onPress={() => onEdit(event.slug)}>
|
||||||
@@ -464,21 +464,21 @@ function EventListItem({
|
|||||||
) : null}
|
) : null}
|
||||||
</XStack>
|
</XStack>
|
||||||
</XStack>
|
</XStack>
|
||||||
<XStack alignItems="center" space="$2" flexWrap="wrap">
|
<XStack alignItems="center" gap="$2" flexWrap="wrap">
|
||||||
<XStack alignItems="center" space="$1.5">
|
<XStack alignItems="center" gap="$1.5">
|
||||||
<CalendarDays size={12} color={subtle} />
|
<CalendarDays size={12} color={subtle} />
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{formatDate(event.event_date, t, locale)}
|
{formatDate(event.event_date, t, locale)}
|
||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
<XStack alignItems="center" space="$1.5">
|
<XStack alignItems="center" gap="$1.5">
|
||||||
<MapPin size={12} color={subtle} />
|
<MapPin size={12} color={subtle} />
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{resolveLocation(event, t)}
|
{resolveLocation(event, t)}
|
||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
</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={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={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} />
|
<EventStatChip icon={Sparkles} label={t('events.list.stats.tasks')} value={stats.tasks} muted={subtle} />
|
||||||
@@ -499,7 +499,7 @@ function EventStatChip({
|
|||||||
muted: string;
|
muted: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<XStack alignItems="center" space="$1">
|
<XStack alignItems="center" gap="$1">
|
||||||
<Icon size={12} color={muted} />
|
<Icon size={12} color={muted} />
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{value} {label}
|
{value} {label}
|
||||||
|
|||||||
@@ -76,10 +76,10 @@ export default function ForgotPasswordPage() {
|
|||||||
paddingVertical="$5"
|
paddingVertical="$5"
|
||||||
style={{ ...safeAreaStyle, background: ADMIN_GRADIENTS.loginBackground }}
|
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)">
|
<MobileCard backgroundColor="rgba(15, 23, 42, 0.6)" borderColor="rgba(255,255,255,0.12)">
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<XStack
|
<XStack
|
||||||
width={42}
|
width={42}
|
||||||
height={42}
|
height={42}
|
||||||
@@ -103,7 +103,7 @@ export default function ForgotPasswordPage() {
|
|||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard backgroundColor={surface} borderColor={border}>
|
<MobileCard backgroundColor={surface} borderColor={border}>
|
||||||
<YStack space="$3">
|
<YStack gap="$3">
|
||||||
<MobileField label={t('login.email', 'Email address')}>
|
<MobileField label={t('login.email', 'Email address')}>
|
||||||
<MobileInput
|
<MobileInput
|
||||||
type="email"
|
type="email"
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function MobileHelpArticlePage() {
|
|||||||
return (
|
return (
|
||||||
<MobileShell activeTab="profile" title={article?.title ?? t('common.help', 'Help')} onBack={back}>
|
<MobileShell activeTab="profile" title={article?.title ?? t('common.help', 'Help')} onBack={back}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<SkeletonCard height={120} />
|
<SkeletonCard height={120} />
|
||||||
<SkeletonCard height={160} />
|
<SkeletonCard height={160} />
|
||||||
</YStack>
|
</YStack>
|
||||||
@@ -42,7 +42,7 @@ export default function MobileHelpArticlePage() {
|
|||||||
|
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<MobileCard>
|
<MobileCard>
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={theme.textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={theme.textStrong}>
|
||||||
{t('dashboard:help.error', 'Help could not be loaded.')}
|
{t('dashboard:help.error', 'Help could not be loaded.')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -56,9 +56,9 @@ export default function MobileHelpArticlePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!isLoading && article ? (
|
{!isLoading && article ? (
|
||||||
<YStack space="$3">
|
<YStack gap="$3">
|
||||||
<MobileCard>
|
<MobileCard>
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$lg" fontWeight="800" color={theme.textStrong}>
|
<Text fontSize="$lg" fontWeight="800" color={theme.textStrong}>
|
||||||
{article.title}
|
{article.title}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -81,7 +81,7 @@ export default function MobileHelpArticlePage() {
|
|||||||
|
|
||||||
{article.related && article.related.length > 0 ? (
|
{article.related && article.related.length > 0 ? (
|
||||||
<MobileCard>
|
<MobileCard>
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={theme.textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={theme.textStrong}>
|
||||||
{t('help.article.relatedTitle', 'Weitere Artikel')}
|
{t('help.article.relatedTitle', 'Weitere Artikel')}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default function MobileHelpCenterPage() {
|
|||||||
return (
|
return (
|
||||||
<MobileShell activeTab="profile" title={t('common.help', 'Help')} onBack={back}>
|
<MobileShell activeTab="profile" title={t('common.help', 'Help')} onBack={back}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<SkeletonCard height={120} />
|
<SkeletonCard height={120} />
|
||||||
<SkeletonCard height={120} />
|
<SkeletonCard height={120} />
|
||||||
</YStack>
|
</YStack>
|
||||||
@@ -49,7 +49,7 @@ export default function MobileHelpCenterPage() {
|
|||||||
|
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<MobileCard>
|
<MobileCard>
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={theme.textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={theme.textStrong}>
|
||||||
{t('dashboard:help.error', 'Help could not be loaded.')}
|
{t('dashboard:help.error', 'Help could not be loaded.')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -63,7 +63,7 @@ export default function MobileHelpCenterPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!isLoading && !isError ? (
|
{!isLoading && !isError ? (
|
||||||
<YStack space="$3">
|
<YStack gap="$3">
|
||||||
<HelpSection
|
<HelpSection
|
||||||
title={t('dashboard:help.title', 'FAQ')}
|
title={t('dashboard:help.title', 'FAQ')}
|
||||||
icon={HelpCircle}
|
icon={HelpCircle}
|
||||||
@@ -100,8 +100,8 @@ function HelpSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileCard padding="$0">
|
<MobileCard padding="$0">
|
||||||
<YStack padding="$3" space="$2">
|
<YStack padding="$3" gap="$2">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
{IconCmp ? (
|
{IconCmp ? (
|
||||||
<XStack
|
<XStack
|
||||||
width={28}
|
width={28}
|
||||||
|
|||||||
@@ -202,9 +202,9 @@ export default function MobileLoginPage() {
|
|||||||
paddingVertical="$5"
|
paddingVertical="$5"
|
||||||
style={{ ...safeAreaStyle, background: ADMIN_GRADIENTS.loginBackground }}
|
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)">
|
<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
|
<XStack
|
||||||
width={56}
|
width={56}
|
||||||
height={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} />
|
<img src="/logo-transparent-md.png" alt={t('auth.logoAlt', 'Fotospiel')} width={40} height={40} />
|
||||||
</XStack>
|
</XStack>
|
||||||
<YStack alignItems="center" space="$1">
|
<YStack alignItems="center" gap="$1">
|
||||||
<Text fontSize="$xl" fontWeight="800" color="white" textAlign="center">
|
<Text fontSize="$xl" fontWeight="800" color="white" textAlign="center">
|
||||||
{t('login.panel_title', 'Fotospiel.App Event Login')}
|
{t('login.panel_title', 'Fotospiel.App Event Login')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -230,7 +230,7 @@ export default function MobileLoginPage() {
|
|||||||
|
|
||||||
<MobileCard backgroundColor={surface} borderColor={border}>
|
<MobileCard backgroundColor={surface} borderColor={border}>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<YStack space="$3">
|
<YStack gap="$3">
|
||||||
{oauthMessage ? (
|
{oauthMessage ? (
|
||||||
<YStack
|
<YStack
|
||||||
borderRadius={12}
|
borderRadius={12}
|
||||||
@@ -313,7 +313,7 @@ export default function MobileLoginPage() {
|
|||||||
pressStyle={{ opacity: 0.9 }}
|
pressStyle={{ opacity: 0.9 }}
|
||||||
style={{ boxShadow: '0 12px 24px rgba(255, 90, 95, 0.25)' }}
|
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' : ''} />
|
<Loader2 size={16} className={isSubmitting ? 'animate-spin' : ''} />
|
||||||
<Text fontSize="$sm" color="white" fontWeight="800">
|
<Text fontSize="$sm" color="white" fontWeight="800">
|
||||||
{isSubmitting ? t('login.loading', 'Anmeldung …') : t('login.submit', 'Anmelden')}
|
{isSubmitting ? t('login.loading', 'Anmeldung …') : t('login.submit', 'Anmelden')}
|
||||||
@@ -332,7 +332,7 @@ export default function MobileLoginPage() {
|
|||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
color={text}
|
color={text}
|
||||||
>
|
>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
{isRedirectingToGoogle ? (
|
{isRedirectingToGoogle ? (
|
||||||
<Loader2 size={16} className="animate-spin" />
|
<Loader2 size={16} className="animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
@@ -355,7 +355,7 @@ export default function MobileLoginPage() {
|
|||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
color={text}
|
color={text}
|
||||||
>
|
>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
{isRedirectingToFacebook ? (
|
{isRedirectingToFacebook ? (
|
||||||
<Loader2 size={16} className="animate-spin" />
|
<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">
|
<Text fontSize="$xs" color={muted} textAlign="center">
|
||||||
{t('login.support', 'Fragen? Schreib uns an support@fotospiel.de oder antworte direkt auf deine Einladung.')}
|
{t('login.support', 'Fragen? Schreib uns an support@fotospiel.de oder antworte direkt auf deine Einladung.')}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function LoginStartPage(): React.ReactElement {
|
|||||||
shadowRadius={16}
|
shadowRadius={16}
|
||||||
shadowOffset={{ width: 0, height: 10 }}
|
shadowOffset={{ width: 0, height: 10 }}
|
||||||
>
|
>
|
||||||
<YStack alignItems="center" space="$2">
|
<YStack alignItems="center" gap="$2">
|
||||||
<Spinner size="small" color={textStrong} />
|
<Spinner size="small" color={textStrong} />
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{t('redirecting', 'Redirecting to login …')}
|
{t('redirecting', 'Redirecting to login …')}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function LogoutPage() {
|
|||||||
shadowRadius={16}
|
shadowRadius={16}
|
||||||
shadowOffset={{ width: 0, height: 10 }}
|
shadowOffset={{ width: 0, height: 10 }}
|
||||||
>
|
>
|
||||||
<YStack alignItems="center" space="$2">
|
<YStack alignItems="center" gap="$2">
|
||||||
<Spinner size="small" color={textStrong} />
|
<Spinner size="small" color={textStrong} />
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
Abmeldung wird vorbereitet ...
|
Abmeldung wird vorbereitet ...
|
||||||
|
|||||||
@@ -86,13 +86,13 @@ function NotificationSwipeRow({ item, onOpen, onMarkRead, children }: Notificati
|
|||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
style={{ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0 }}
|
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} />
|
<Check size={16} color={markText} />
|
||||||
<Text fontSize="$xs" fontWeight="700" color={markText}>
|
<Text fontSize="$xs" fontWeight="700" color={markText}>
|
||||||
{item.is_read ? t('notificationLogs.read', 'Read') : t('notificationLogs.markRead', 'Mark read')}
|
{item.is_read ? t('notificationLogs.read', 'Read') : t('notificationLogs.markRead', 'Mark read')}
|
||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Text fontSize="$xs" fontWeight="700" color={detailText}>
|
<Text fontSize="$xs" fontWeight="700" color={detailText}>
|
||||||
Details
|
Details
|
||||||
</Text>
|
</Text>
|
||||||
@@ -508,7 +508,7 @@ export default function MobileNotificationsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{showFilterNotice ? (
|
{showFilterNotice ? (
|
||||||
<MobileCard space="$2">
|
<MobileCard gap="$2">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||||
{t('notificationLogs.filterEmpty', 'No notifications for this event.')}
|
{t('notificationLogs.filterEmpty', 'No notifications for this event.')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -524,7 +524,7 @@ export default function MobileNotificationsPage() {
|
|||||||
</MobileCard>
|
</MobileCard>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<XStack space="$2" marginBottom="$2">
|
<XStack gap="$2" marginBottom="$2">
|
||||||
<MobileSelect
|
<MobileSelect
|
||||||
value={statusParam}
|
value={statusParam}
|
||||||
onChange={(e) => updateFilters({ status: e.target.value })}
|
onChange={(e) => updateFilters({ status: e.target.value })}
|
||||||
@@ -552,7 +552,7 @@ export default function MobileNotificationsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</XStack>
|
</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: 'all', label: t('notificationLogs.scope.all', 'All scopes') },
|
||||||
{ key: 'photos', label: t('notificationLogs.scope.photos', 'Photos') },
|
{ key: 'photos', label: t('notificationLogs.scope.photos', 'Photos') },
|
||||||
@@ -585,13 +585,13 @@ export default function MobileNotificationsPage() {
|
|||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
{Array.from({ length: 4 }).map((_, idx) => (
|
{Array.from({ length: 4 }).map((_, idx) => (
|
||||||
<SkeletonCard key={`al-${idx}`} height={70} />
|
<SkeletonCard key={`al-${idx}`} height={70} />
|
||||||
))}
|
))}
|
||||||
</YStack>
|
</YStack>
|
||||||
) : statusFiltered.length === 0 ? (
|
) : statusFiltered.length === 0 ? (
|
||||||
<MobileCard alignItems="center" justifyContent="center" space="$2">
|
<MobileCard alignItems="center" justifyContent="center" gap="$2">
|
||||||
<Bell size={24} color={subtle} />
|
<Bell size={24} color={subtle} />
|
||||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||||
{t('mobileNotifications.emptyTitle', 'All caught up')}
|
{t('mobileNotifications.emptyTitle', 'All caught up')}
|
||||||
@@ -607,7 +607,7 @@ export default function MobileNotificationsPage() {
|
|||||||
/>
|
/>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
) : (
|
) : (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
{events.length ? (
|
{events.length ? (
|
||||||
<Pressable onPress={() => setShowEventPicker(true)}>
|
<Pressable onPress={() => setShowEventPicker(true)}>
|
||||||
<Text fontSize="$sm" color={primary} fontWeight="700">
|
<Text fontSize="$sm" color={primary} fontWeight="700">
|
||||||
@@ -616,12 +616,12 @@ export default function MobileNotificationsPage() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
{grouped.map((group) => (
|
{grouped.map((group) => (
|
||||||
<YStack key={group.scope} space="$2">
|
<YStack key={group.scope} gap="$2">
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<Text fontSize="$xs" fontWeight="700" color={muted}>
|
<Text fontSize="$xs" fontWeight="700" color={muted}>
|
||||||
{t(`notificationLogs.scope.${group.scope}`, group.scope)}
|
{t(`notificationLogs.scope.${group.scope}`, group.scope)}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack space="$2" alignItems="center">
|
<XStack gap="$2" alignItems="center">
|
||||||
{group.unread > 0 ? (
|
{group.unread > 0 ? (
|
||||||
<Pressable onPress={() => markGroupRead(group)}>
|
<Pressable onPress={() => markGroupRead(group)}>
|
||||||
<Text fontSize="$xs" color={primary} fontWeight="700">
|
<Text fontSize="$xs" color={primary} fontWeight="700">
|
||||||
@@ -646,8 +646,8 @@ export default function MobileNotificationsPage() {
|
|||||||
onOpen={openNotification}
|
onOpen={openNotification}
|
||||||
onMarkRead={markNotificationRead}
|
onMarkRead={markNotificationRead}
|
||||||
>
|
>
|
||||||
<MobileCard space="$2" borderColor={item.is_read ? border : primary}>
|
<MobileCard gap="$2" borderColor={item.is_read ? border : primary}>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<XStack
|
<XStack
|
||||||
width={36}
|
width={36}
|
||||||
height={36}
|
height={36}
|
||||||
@@ -658,7 +658,7 @@ export default function MobileNotificationsPage() {
|
|||||||
>
|
>
|
||||||
<Bell size={18} color={item.tone === 'warning' ? warningIcon : infoIcon} />
|
<Bell size={18} color={item.tone === 'warning' ? warningIcon : infoIcon} />
|
||||||
</XStack>
|
</XStack>
|
||||||
<YStack space="$0.5" flex={1}>
|
<YStack gap="$0.5" flex={1}>
|
||||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||||
{item.title}
|
{item.title}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -699,14 +699,14 @@ export default function MobileNotificationsPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{selectedNotification ? (
|
{selectedNotification ? (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$sm" color={text} fontWeight="700">
|
<Text fontSize="$sm" color={text} fontWeight="700">
|
||||||
{selectedNotification.title}
|
{selectedNotification.title}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fontSize="$sm" color={muted}>
|
<Text fontSize="$sm" color={muted}>
|
||||||
{selectedNotification.body}
|
{selectedNotification.body}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack space="$2" flexWrap="wrap" style={{ rowGap: 8 }}>
|
<XStack gap="$2" flexWrap="wrap" style={{ rowGap: 8 }}>
|
||||||
<PillBadge tone={selectedNotification.tone === 'warning' ? 'warning' : 'muted'}>
|
<PillBadge tone={selectedNotification.tone === 'warning' ? 'warning' : 'muted'}>
|
||||||
{selectedNotification.scope}
|
{selectedNotification.scope}
|
||||||
</PillBadge>
|
</PillBadge>
|
||||||
@@ -725,7 +725,7 @@ export default function MobileNotificationsPage() {
|
|||||||
title={t('mobileNotifications.filterByEvent', 'Nach Event filtern')}
|
title={t('mobileNotifications.filterByEvent', 'Nach Event filtern')}
|
||||||
footer={null}
|
footer={null}
|
||||||
>
|
>
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
{events.length === 0 ? (
|
{events.length === 0 ? (
|
||||||
<Text fontSize="$sm" color={muted}>
|
<Text fontSize="$sm" color={muted}>
|
||||||
{t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
|
{t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default function MobilePackageShopPage() {
|
|||||||
onBack={() => navigate(-1)}
|
onBack={() => navigate(-1)}
|
||||||
activeTab="profile"
|
activeTab="profile"
|
||||||
>
|
>
|
||||||
<YStack space="$3">
|
<YStack gap="$3">
|
||||||
<SkeletonCard height={150} />
|
<SkeletonCard height={150} />
|
||||||
<SkeletonCard height={150} />
|
<SkeletonCard height={150} />
|
||||||
</YStack>
|
</YStack>
|
||||||
@@ -125,10 +125,10 @@ export default function MobilePackageShopPage() {
|
|||||||
onBack={() => navigate(-1)}
|
onBack={() => navigate(-1)}
|
||||||
activeTab="profile"
|
activeTab="profile"
|
||||||
>
|
>
|
||||||
<YStack space="$4">
|
<YStack gap="$4">
|
||||||
{catalogType !== 'reseller' && recommendedFeature && (
|
{catalogType !== 'reseller' && recommendedFeature && (
|
||||||
<MobileCard borderColor={primary} backgroundColor={accentSoft} space="$2" padding="$3">
|
<MobileCard borderColor={primary} backgroundColor={accentSoft} gap="$2" padding="$3">
|
||||||
<XStack space="$2" alignItems="center">
|
<XStack gap="$2" alignItems="center">
|
||||||
<Sparkles size={16} color={primary} />
|
<Sparkles size={16} color={primary} />
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{t('shop.recommendationTitle', 'Recommended for you')}
|
{t('shop.recommendationTitle', 'Recommended for you')}
|
||||||
@@ -149,7 +149,7 @@ export default function MobilePackageShopPage() {
|
|||||||
</YStack>
|
</YStack>
|
||||||
|
|
||||||
{packageEntries.length > 1 ? (
|
{packageEntries.length > 1 ? (
|
||||||
<XStack space="$2" paddingHorizontal="$2">
|
<XStack gap="$2" paddingHorizontal="$2">
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={t('shop.compare.toggleCards', 'Cards')}
|
label={t('shop.compare.toggleCards', 'Cards')}
|
||||||
tone={viewMode === 'cards' ? 'primary' : 'ghost'}
|
tone={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||||
@@ -167,7 +167,7 @@ export default function MobilePackageShopPage() {
|
|||||||
</XStack>
|
</XStack>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<YStack space="$3">
|
<YStack gap="$3">
|
||||||
{viewMode === 'compare' ? (
|
{viewMode === 'compare' ? (
|
||||||
<PackageShopCompareView
|
<PackageShopCompareView
|
||||||
entries={packageEntries}
|
entries={packageEntries}
|
||||||
@@ -234,14 +234,14 @@ function PackageShopCard({
|
|||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
borderColor={isRecommended ? primary : (isActive ? '$green8' : border)}
|
borderColor={isRecommended ? primary : (isActive ? '$green8' : border)}
|
||||||
borderWidth={isRecommended || isActive ? 2 : 1}
|
borderWidth={isRecommended || isActive ? 2 : 1}
|
||||||
space="$3"
|
gap="$3"
|
||||||
pressStyle={handlePress ? { backgroundColor: accentSoft } : undefined}
|
pressStyle={handlePress ? { backgroundColor: accentSoft } : undefined}
|
||||||
backgroundColor={isActive ? '$green1' : undefined}
|
backgroundColor={isActive ? '$green1' : undefined}
|
||||||
style={{ opacity: isSubdued ? 0.6 : 1 }}
|
style={{ opacity: isSubdued ? 0.6 : 1 }}
|
||||||
>
|
>
|
||||||
<XStack justifyContent="space-between" alignItems="flex-start">
|
<XStack justifyContent="space-between" alignItems="flex-start">
|
||||||
<YStack space="$1">
|
<YStack gap="$1">
|
||||||
<XStack space="$2" alignItems="center">
|
<XStack gap="$2" alignItems="center">
|
||||||
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
|
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
|
||||||
{pkg.name}
|
{pkg.name}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -255,7 +255,7 @@ function PackageShopCard({
|
|||||||
{!isResellerCatalog && isActive ? <PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge> : null}
|
{!isResellerCatalog && isActive ? <PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge> : null}
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<XStack space="$2" alignItems="center">
|
<XStack gap="$2" alignItems="center">
|
||||||
<Text fontSize="$md" color={primary} fontWeight="700">
|
<Text fontSize="$md" color={primary} fontWeight="700">
|
||||||
{new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(pkg.price)}
|
{new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(pkg.price)}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -271,7 +271,7 @@ function PackageShopCard({
|
|||||||
</YStack>
|
</YStack>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<YStack space="$1.5">
|
<YStack gap="$1.5">
|
||||||
{isResellerCatalog ? (
|
{isResellerCatalog ? (
|
||||||
<>
|
<>
|
||||||
{includedTierLabel ? (
|
{includedTierLabel ? (
|
||||||
@@ -333,7 +333,7 @@ function PackageShopCard({
|
|||||||
function FeatureRow({ label }: { label: string }) {
|
function FeatureRow({ label }: { label: string }) {
|
||||||
const { textStrong, primary } = useAdminTheme();
|
const { textStrong, primary } = useAdminTheme();
|
||||||
return (
|
return (
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Check size={14} color={primary} />
|
<Check size={14} color={primary} />
|
||||||
<Text fontSize="$sm" color={textStrong}>{label}</Text>
|
<Text fontSize="$sm" color={textStrong}>{label}</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
@@ -411,8 +411,8 @@ function PackageShopCompareView({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileCard space="$3" borderColor={border}>
|
<MobileCard gap="$3" borderColor={border}>
|
||||||
<YStack space="$1">
|
<YStack gap="$1">
|
||||||
<Text fontSize="$md" fontWeight="700" color={textStrong}>
|
<Text fontSize="$md" fontWeight="700" color={textStrong}>
|
||||||
{t('shop.compare.title', 'Compare plans')}
|
{t('shop.compare.title', 'Compare plans')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -422,7 +422,7 @@ function PackageShopCompareView({
|
|||||||
</YStack>
|
</YStack>
|
||||||
|
|
||||||
<XStack style={{ overflowX: 'auto' }}>
|
<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) => (
|
{rows.map((row) => (
|
||||||
<XStack key={row.id} borderBottomWidth={1} borderColor={border}>
|
<XStack key={row.id} borderBottomWidth={1} borderColor={border}>
|
||||||
<YStack
|
<YStack
|
||||||
@@ -443,11 +443,11 @@ function PackageShopCompareView({
|
|||||||
if (row.id === 'meta.plan') {
|
if (row.id === 'meta.plan') {
|
||||||
const statusLabel = getPackageStatusLabel({ t, isActive: entry.isActive, owned: entry.owned });
|
const statusLabel = getPackageStatusLabel({ t, isActive: entry.isActive, owned: entry.owned });
|
||||||
content = (
|
content = (
|
||||||
<YStack space="$1">
|
<YStack gap="$1">
|
||||||
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
||||||
{entry.pkg.name}
|
{entry.pkg.name}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack space="$1.5" flexWrap="wrap">
|
<XStack gap="$1.5" flexWrap="wrap">
|
||||||
{entry.isRecommended ? (
|
{entry.isRecommended ? (
|
||||||
<PillBadge tone="warning">{t('shop.badges.recommended', 'Recommended')}</PillBadge>
|
<PillBadge tone="warning">{t('shop.badges.recommended', 'Recommended')}</PillBadge>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -492,7 +492,7 @@ function PackageShopCompareView({
|
|||||||
} else if (row.type === 'feature') {
|
} else if (row.type === 'feature') {
|
||||||
const enabled = getEnabledPackageFeatures(entry.pkg).includes(row.featureKey);
|
const enabled = getEnabledPackageFeatures(entry.pkg).includes(row.featureKey);
|
||||||
content = (
|
content = (
|
||||||
<XStack alignItems="center" space="$1.5">
|
<XStack alignItems="center" gap="$1.5">
|
||||||
{enabled ? (
|
{enabled ? (
|
||||||
<Check size={16} color={primary} />
|
<Check size={16} color={primary} />
|
||||||
) : (
|
) : (
|
||||||
@@ -607,8 +607,8 @@ function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () =>
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileShell title={t('shop.confirmTitle', 'Confirm Purchase')} onBack={onCancel} activeTab="profile">
|
<MobileShell title={t('shop.confirmTitle', 'Confirm Purchase')} onBack={onCancel} activeTab="profile">
|
||||||
<YStack space="$4">
|
<YStack gap="$4">
|
||||||
<MobileCard space="$2" borderColor={border}>
|
<MobileCard gap="$2" borderColor={border}>
|
||||||
<Text fontSize="$sm" color={muted}>{subtitle}</Text>
|
<Text fontSize="$sm" color={muted}>{subtitle}</Text>
|
||||||
<Text fontSize="$xl" fontWeight="800" color={textStrong}>{pkg.name}</Text>
|
<Text fontSize="$xl" fontWeight="800" color={textStrong}>{pkg.name}</Text>
|
||||||
<Text fontSize="$lg" color={primary} fontWeight="700">
|
<Text fontSize="$lg" color={primary} fontWeight="700">
|
||||||
@@ -616,13 +616,13 @@ function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () =>
|
|||||||
</Text>
|
</Text>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3" borderColor={border}>
|
<MobileCard gap="$3" borderColor={border}>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<ShieldCheck size={18} color={textStrong} />
|
<ShieldCheck size={18} color={textStrong} />
|
||||||
<Text fontSize="$md" fontWeight="700" color={textStrong}>{t('shop.legalTitle', 'Terms & Conditions')}</Text>
|
<Text fontSize="$md" fontWeight="700" color={textStrong}>{t('shop.legalTitle', 'Terms & Conditions')}</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<XStack space="$3" alignItems="flex-start">
|
<XStack gap="$3" alignItems="flex-start">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="agb"
|
id="agb"
|
||||||
size="$4"
|
size="$4"
|
||||||
@@ -638,7 +638,7 @@ function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () =>
|
|||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<XStack space="$3" alignItems="flex-start">
|
<XStack gap="$3" alignItems="flex-start">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="withdrawal"
|
id="withdrawal"
|
||||||
size="$4"
|
size="$4"
|
||||||
@@ -655,7 +655,7 @@ function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () =>
|
|||||||
</XStack>
|
</XStack>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={busy ? t('shop.processing', 'Processing...') : t('shop.payNow', 'Pay Now')}
|
label={busy ? t('shop.processing', 'Processing...') : t('shop.payNow', 'Pay Now')}
|
||||||
onPress={handleCheckout}
|
onPress={handleCheckout}
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ export default function MobileProfileAccountPage() {
|
|||||||
onBack={back}
|
onBack={back}
|
||||||
>
|
>
|
||||||
{brandingTabEnabled ? (
|
{brandingTabEnabled ? (
|
||||||
<XStack space="$2">
|
<XStack gap="$2">
|
||||||
<TabButton
|
<TabButton
|
||||||
label={t('profile.tabs.account', 'Account')}
|
label={t('profile.tabs.account', 'Account')}
|
||||||
active={activeTab === 'account'}
|
active={activeTab === 'account'}
|
||||||
@@ -330,7 +330,7 @@ export default function MobileProfileAccountPage() {
|
|||||||
</MobileCard>
|
</MobileCard>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
{t('profile.branding.title', 'Standard-Branding')}
|
{t('profile.branding.title', 'Standard-Branding')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -339,11 +339,11 @@ export default function MobileProfileAccountPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
{t('profile.branding.theme', 'Theme')}
|
{t('profile.branding.theme', 'Theme')}
|
||||||
</Text>
|
</Text>
|
||||||
<YStack space="$3">
|
<YStack gap="$3">
|
||||||
<MobileField label={t('events.branding.mode', 'Theme')}>
|
<MobileField label={t('events.branding.mode', 'Theme')}>
|
||||||
<MobileSelect
|
<MobileSelect
|
||||||
value={brandingForm.mode}
|
value={brandingForm.mode}
|
||||||
@@ -369,11 +369,11 @@ export default function MobileProfileAccountPage() {
|
|||||||
</YStack>
|
</YStack>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
{t('events.branding.colors', 'Colors')}
|
{t('events.branding.colors', 'Colors')}
|
||||||
</Text>
|
</Text>
|
||||||
<YStack space="$3">
|
<YStack gap="$3">
|
||||||
<ColorField
|
<ColorField
|
||||||
label={t('events.branding.primary', 'Primary Color')}
|
label={t('events.branding.primary', 'Primary Color')}
|
||||||
value={brandingForm.primary}
|
value={brandingForm.primary}
|
||||||
@@ -401,11 +401,11 @@ export default function MobileProfileAccountPage() {
|
|||||||
</YStack>
|
</YStack>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
{t('events.branding.fonts', 'Fonts')}
|
{t('events.branding.fonts', 'Fonts')}
|
||||||
</Text>
|
</Text>
|
||||||
<YStack space="$3">
|
<YStack gap="$3">
|
||||||
<MobileField label={t('events.branding.headingFont', 'Headline Font')}>
|
<MobileField label={t('events.branding.headingFont', 'Headline Font')}>
|
||||||
<MobileInput
|
<MobileInput
|
||||||
value={brandingForm.headingFont}
|
value={brandingForm.headingFont}
|
||||||
@@ -444,8 +444,8 @@ export default function MobileProfileAccountPage() {
|
|||||||
</MobileCard>
|
</MobileCard>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<XStack alignItems="center" space="$3">
|
<XStack alignItems="center" gap="$3">
|
||||||
<XStack
|
<XStack
|
||||||
width={48}
|
width={48}
|
||||||
height={48}
|
height={48}
|
||||||
@@ -456,7 +456,7 @@ export default function MobileProfileAccountPage() {
|
|||||||
>
|
>
|
||||||
<User size={20} color={primary} />
|
<User size={20} color={primary} />
|
||||||
</XStack>
|
</XStack>
|
||||||
<YStack space="$1">
|
<YStack gap="$1">
|
||||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
{form.name || profile?.email || t('profile.title', 'Profil')}
|
{form.name || profile?.email || t('profile.title', 'Profil')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -465,7 +465,7 @@ export default function MobileProfileAccountPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</YStack>
|
</YStack>
|
||||||
</XStack>
|
</XStack>
|
||||||
<XStack alignItems="center" space="$2" flexWrap="wrap">
|
<XStack alignItems="center" gap="$2" flexWrap="wrap">
|
||||||
{profile?.email_verified ? (
|
{profile?.email_verified ? (
|
||||||
<CheckCircle2 size={14} color={subtle} />
|
<CheckCircle2 size={14} color={subtle} />
|
||||||
) : (
|
) : (
|
||||||
@@ -480,8 +480,8 @@ export default function MobileProfileAccountPage() {
|
|||||||
</XStack>
|
</XStack>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<User size={16} color={text} />
|
<User size={16} color={text} />
|
||||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
{t('profile.sections.account.heading', 'Account-Informationen')}
|
{t('profile.sections.account.heading', 'Account-Informationen')}
|
||||||
@@ -495,7 +495,7 @@ export default function MobileProfileAccountPage() {
|
|||||||
{t('profile.loading', 'Lädt ...')}
|
{t('profile.loading', 'Lädt ...')}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<YStack space="$3">
|
<YStack gap="$3">
|
||||||
<MobileField label={t('profile.fields.name', 'Anzeigename')}>
|
<MobileField label={t('profile.fields.name', 'Anzeigename')}>
|
||||||
<MobileInput
|
<MobileInput
|
||||||
value={form.name}
|
value={form.name}
|
||||||
@@ -535,8 +535,8 @@ export default function MobileProfileAccountPage() {
|
|||||||
)}
|
)}
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Lock size={16} color={text} />
|
<Lock size={16} color={text} />
|
||||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
{t('profile.sections.password.heading', 'Passwort ändern')}
|
{t('profile.sections.password.heading', 'Passwort ändern')}
|
||||||
@@ -545,7 +545,7 @@ export default function MobileProfileAccountPage() {
|
|||||||
<Text fontSize="$sm" color={muted}>
|
<Text fontSize="$sm" color={muted}>
|
||||||
{t('profile.sections.password.description', 'Wähle ein sicheres Passwort, um deinen Admin-Zugang zu schützen.')}
|
{t('profile.sections.password.description', 'Wähle ein sicheres Passwort, um deinen Admin-Zugang zu schützen.')}
|
||||||
</Text>
|
</Text>
|
||||||
<YStack space="$3">
|
<YStack gap="$3">
|
||||||
<MobileField label={t('profile.fields.currentPassword', 'Aktuelles Passwort')}>
|
<MobileField label={t('profile.fields.currentPassword', 'Aktuelles Passwort')}>
|
||||||
<MobileInput
|
<MobileInput
|
||||||
value={form.currentPassword}
|
value={form.currentPassword}
|
||||||
@@ -625,11 +625,11 @@ function ColorField({
|
|||||||
}) {
|
}) {
|
||||||
const { text, muted } = useAdminTheme();
|
const { text, muted } = useAdminTheme();
|
||||||
return (
|
return (
|
||||||
<YStack space="$2" opacity={disabled ? 0.6 : 1}>
|
<YStack gap="$2" opacity={disabled ? 0.6 : 1}>
|
||||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<MobileColorInput
|
<MobileColorInput
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => onChange(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
|||||||
@@ -68,13 +68,13 @@ export default function MobileProfilePage() {
|
|||||||
shadowRadius={16}
|
shadowRadius={16}
|
||||||
shadowOffset={{ width: 0, height: 10 }}
|
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 size="$7" borderRadius={20} backgroundColor={avatarBg}>
|
||||||
<Avatar.Fallback>
|
<Avatar.Fallback>
|
||||||
<User size={28} color={primary} />
|
<User size={28} color={primary} />
|
||||||
</Avatar.Fallback>
|
</Avatar.Fallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<YStack space="$0.5" alignItems="center">
|
<YStack gap="$0.5" alignItems="center">
|
||||||
<Text fontSize="$md" fontWeight="800" color={textColor}>
|
<Text fontSize="$md" fontWeight="800" color={textColor}>
|
||||||
{name}
|
{name}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -101,7 +101,7 @@ export default function MobileProfilePage() {
|
|||||||
shadowRadius={16}
|
shadowRadius={16}
|
||||||
shadowOffset={{ width: 0, height: 10 }}
|
shadowOffset={{ width: 0, height: 10 }}
|
||||||
>
|
>
|
||||||
<YStack space="$2.5">
|
<YStack gap="$2.5">
|
||||||
<XStack
|
<XStack
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
paddingHorizontal="$3"
|
paddingHorizontal="$3"
|
||||||
@@ -210,7 +210,7 @@ export default function MobileProfilePage() {
|
|||||||
shadowRadius={16}
|
shadowRadius={16}
|
||||||
shadowOffset={{ width: 0, height: 10 }}
|
shadowOffset={{ width: 0, height: 10 }}
|
||||||
>
|
>
|
||||||
<YStack space="$2.5">
|
<YStack gap="$2.5">
|
||||||
<XStack
|
<XStack
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
paddingHorizontal="$3"
|
paddingHorizontal="$3"
|
||||||
@@ -230,7 +230,7 @@ export default function MobileProfilePage() {
|
|||||||
paddingVertical="$2"
|
paddingVertical="$2"
|
||||||
paddingHorizontal="$3"
|
paddingHorizontal="$3"
|
||||||
title={
|
title={
|
||||||
<XStack space="$2" alignItems="center">
|
<XStack gap="$2" alignItems="center">
|
||||||
<Globe size={16} color={muted} />
|
<Globe size={16} color={muted} />
|
||||||
<Text fontSize="$sm" color={textColor}>
|
<Text fontSize="$sm" color={textColor}>
|
||||||
{t('mobileProfile.language', 'Language')}
|
{t('mobileProfile.language', 'Language')}
|
||||||
@@ -259,7 +259,7 @@ export default function MobileProfilePage() {
|
|||||||
paddingVertical="$2"
|
paddingVertical="$2"
|
||||||
paddingHorizontal="$3"
|
paddingHorizontal="$3"
|
||||||
title={
|
title={
|
||||||
<XStack space="$2" alignItems="center">
|
<XStack gap="$2" alignItems="center">
|
||||||
<Moon size={16} color={muted} />
|
<Moon size={16} color={muted} />
|
||||||
<Text fontSize="$sm" color={textColor}>
|
<Text fontSize="$sm" color={textColor}>
|
||||||
{t('mobileProfile.theme', 'Theme')}
|
{t('mobileProfile.theme', 'Theme')}
|
||||||
@@ -295,8 +295,8 @@ export default function MobileProfilePage() {
|
|||||||
shadowRadius={12}
|
shadowRadius={12}
|
||||||
shadowOffset={{ width: 0, height: 6 }}
|
shadowOffset={{ width: 0, height: 6 }}
|
||||||
>
|
>
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<XStack
|
<XStack
|
||||||
width={36}
|
width={36}
|
||||||
height={36}
|
height={36}
|
||||||
|
|||||||
@@ -54,10 +54,10 @@ export default function PublicHelpPage() {
|
|||||||
paddingVertical="$4"
|
paddingVertical="$4"
|
||||||
style={{ ...safeAreaStyle, background: ADMIN_GRADIENTS.loginBackground }}
|
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)">
|
<MobileCard backgroundColor="rgba(15, 23, 42, 0.55)" borderColor="rgba(255,255,255,0.08)">
|
||||||
<YStack space="$3">
|
<YStack gap="$3">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<XStack
|
<XStack
|
||||||
width={42}
|
width={42}
|
||||||
height={42}
|
height={42}
|
||||||
@@ -82,8 +82,8 @@ export default function PublicHelpPage() {
|
|||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard backgroundColor={surface} borderColor={border}>
|
<MobileCard backgroundColor={surface} borderColor={border}>
|
||||||
<YStack space="$3">
|
<YStack gap="$3">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<XStack
|
<XStack
|
||||||
width={36}
|
width={36}
|
||||||
height={36}
|
height={36}
|
||||||
@@ -98,7 +98,7 @@ export default function PublicHelpPage() {
|
|||||||
{t('login.help_faq_title', 'Haeufige Fragen vor dem Login')}
|
{t('login.help_faq_title', 'Haeufige Fragen vor dem Login')}
|
||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
{faqItems.map((item, index) => (
|
{faqItems.map((item, index) => (
|
||||||
<YStack
|
<YStack
|
||||||
key={`${item.question}-${index}`}
|
key={`${item.question}-${index}`}
|
||||||
@@ -107,7 +107,7 @@ export default function PublicHelpPage() {
|
|||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
borderColor={border}
|
borderColor={border}
|
||||||
backgroundColor="rgba(255,255,255,0.6)"
|
backgroundColor="rgba(255,255,255,0.6)"
|
||||||
space="$1"
|
gap="$1"
|
||||||
>
|
>
|
||||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||||
{item.question}
|
{item.question}
|
||||||
@@ -122,8 +122,8 @@ export default function PublicHelpPage() {
|
|||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard backgroundColor={surface} borderColor={border}>
|
<MobileCard backgroundColor={surface} borderColor={border}>
|
||||||
<YStack space="$3">
|
<YStack gap="$3">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<XStack
|
<XStack
|
||||||
width={36}
|
width={36}
|
||||||
height={36}
|
height={36}
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ export default function MobileQrLayoutCustomizePage() {
|
|||||||
|
|
||||||
<Stepper current={step} onStepChange={setStep} />
|
<Stepper current={step} onStepChange={setStep} />
|
||||||
|
|
||||||
<MobileCard space="$2" marginTop="$2">
|
<MobileCard gap="$2" marginTop="$2">
|
||||||
{step === 'background' && (
|
{step === 'background' && (
|
||||||
<BackgroundStep
|
<BackgroundStep
|
||||||
onBack={back}
|
onBack={back}
|
||||||
@@ -330,8 +330,8 @@ function Stepper({ current, onStepChange }: { current: Step; onStepChange: (step
|
|||||||
const progress = ((currentIndex + 1) / steps.length) * 100;
|
const progress = ((currentIndex + 1) / steps.length) * 100;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack space="$2" marginTop="$2">
|
<YStack gap="$2" marginTop="$2">
|
||||||
<XStack space="$2" alignItems="center">
|
<XStack gap="$2" alignItems="center">
|
||||||
{steps.map((step, idx) => {
|
{steps.map((step, idx) => {
|
||||||
const active = step.key === current;
|
const active = step.key === current;
|
||||||
const completed = idx < currentIndex;
|
const completed = idx < currentIndex;
|
||||||
@@ -800,10 +800,10 @@ function BackgroundStep({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack space="$3" marginTop="$2">
|
<YStack gap="$3" marginTop="$2">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Pressable onPress={onBack}>
|
<Pressable onPress={onBack}>
|
||||||
<XStack alignItems="center" space="$1">
|
<XStack alignItems="center" gap="$1">
|
||||||
<ArrowLeft size={16} color={textStrong} />
|
<ArrowLeft size={16} color={textStrong} />
|
||||||
<Text fontSize="$sm" color={textStrong}>
|
<Text fontSize="$sm" color={textStrong}>
|
||||||
{t('common.back', 'Zurück')}
|
{t('common.back', 'Zurück')}
|
||||||
@@ -813,7 +813,7 @@ function BackgroundStep({
|
|||||||
<PillBadge tone="muted">{formatLabel}</PillBadge>
|
<PillBadge tone="muted">{formatLabel}</PillBadge>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{disablePresets
|
{disablePresets
|
||||||
? t('events.qr.backgroundPickerFoldable', 'Hintergrund für A5 (Gradient/Farbe)')
|
? t('events.qr.backgroundPickerFoldable', 'Hintergrund für A5 (Gradient/Farbe)')
|
||||||
@@ -859,7 +859,7 @@ function BackgroundStep({
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<YStack space="$2" marginTop="$2">
|
<YStack gap="$2" marginTop="$2">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{t('events.qr.gradients', 'Gradienten')}
|
{t('events.qr.gradients', 'Gradienten')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -894,7 +894,7 @@ function BackgroundStep({
|
|||||||
</XStack>
|
</XStack>
|
||||||
</YStack>
|
</YStack>
|
||||||
|
|
||||||
<YStack space="$2" marginTop="$2">
|
<YStack gap="$2" marginTop="$2">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{t('events.qr.colors', 'Vollfarbe')}
|
{t('events.qr.colors', 'Vollfarbe')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -966,10 +966,10 @@ function TextStep({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack space="$3" marginTop="$2">
|
<YStack gap="$3" marginTop="$2">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Pressable onPress={onBack}>
|
<Pressable onPress={onBack}>
|
||||||
<XStack alignItems="center" space="$1">
|
<XStack alignItems="center" gap="$1">
|
||||||
<ArrowLeft size={16} color={textStrong} />
|
<ArrowLeft size={16} color={textStrong} />
|
||||||
<Text fontSize="$sm" color={textStrong}>
|
<Text fontSize="$sm" color={textStrong}>
|
||||||
{t('common.back', 'Zurück')}
|
{t('common.back', 'Zurück')}
|
||||||
@@ -979,7 +979,7 @@ function TextStep({
|
|||||||
<PillBadge tone="muted">{t('events.qr.textStep', 'Texte & Hinweise')}</PillBadge>
|
<PillBadge tone="muted">{t('events.qr.textStep', 'Texte & Hinweise')}</PillBadge>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{t('events.qr.textFields', 'Texte')}
|
{t('events.qr.textFields', 'Texte')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1000,12 +1000,12 @@ function TextStep({
|
|||||||
/>
|
/>
|
||||||
</YStack>
|
</YStack>
|
||||||
|
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{t('events.qr.instructions', 'Anleitung')}
|
{t('events.qr.instructions', 'Anleitung')}
|
||||||
</Text>
|
</Text>
|
||||||
{textFields.instructions.map((item, idx) => (
|
{textFields.instructions.map((item, idx) => (
|
||||||
<XStack key={idx} alignItems="center" space="$2">
|
<XStack key={idx} alignItems="center" gap="$2">
|
||||||
<MobileTextArea
|
<MobileTextArea
|
||||||
value={item}
|
value={item}
|
||||||
onChange={(event) => updateInstruction(idx, event.target.value)}
|
onChange={(event) => updateInstruction(idx, event.target.value)}
|
||||||
@@ -1196,10 +1196,10 @@ function PreviewStep({
|
|||||||
const qrImageSrc = qrPngUrl ?? qrUrl ?? '';
|
const qrImageSrc = qrPngUrl ?? qrUrl ?? '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack space="$3" marginTop="$2">
|
<YStack gap="$3" marginTop="$2">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Pressable onPress={onBack}>
|
<Pressable onPress={onBack}>
|
||||||
<XStack alignItems="center" space="$1">
|
<XStack alignItems="center" gap="$1">
|
||||||
<ArrowLeft size={16} color={textStrong} />
|
<ArrowLeft size={16} color={textStrong} />
|
||||||
<Text fontSize="$sm" color={textStrong}>
|
<Text fontSize="$sm" color={textStrong}>
|
||||||
{t('common.back', 'Zurück')}
|
{t('common.back', 'Zurück')}
|
||||||
@@ -1211,7 +1211,7 @@ function PreviewStep({
|
|||||||
</PillBadge>
|
</PillBadge>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{t('events.qr.preview', 'Vorschau')}
|
{t('events.qr.preview', 'Vorschau')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1243,7 +1243,7 @@ function PreviewStep({
|
|||||||
|
|
||||||
<LayoutControls slots={resolvedSlots} slotOverrides={slotOverrides} onUpdateSlot={onUpdateSlot} tenantFonts={tenantFonts} qrUrl={qrImageSrc} />
|
<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
|
<CTAButton
|
||||||
label={t('events.qr.exportPdf', 'Export PDF')}
|
label={t('events.qr.exportPdf', 'Export PDF')}
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
@@ -1318,7 +1318,7 @@ function LayoutControls({
|
|||||||
const decimals = step % 1 === 0 ? 0 : Math.min(4, `${step}`.split('.')[1]?.length ?? 1);
|
const decimals = step % 1 === 0 ? 0 : Math.min(4, `${step}`.split('.')[1]?.length ?? 1);
|
||||||
const formatValue = (val: number) => val.toFixed(decimals);
|
const formatValue = (val: number) => val.toFixed(decimals);
|
||||||
return (
|
return (
|
||||||
<XStack space="$1" alignItems="center">
|
<XStack gap="$1" alignItems="center">
|
||||||
<Pressable onPress={dec}>
|
<Pressable onPress={dec}>
|
||||||
<XStack width={36} height={36} borderRadius={10} alignItems="center" justifyContent="center" borderWidth={1} borderColor={border} backgroundColor={surface}>
|
<XStack width={36} height={36} borderRadius={10} alignItems="center" justifyContent="center" borderWidth={1} borderColor={border} backgroundColor={surface}>
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
@@ -1398,13 +1398,13 @@ function LayoutControls({
|
|||||||
</XStack>
|
</XStack>
|
||||||
</Accordion.Trigger>
|
</Accordion.Trigger>
|
||||||
<Accordion.Content {...({ paddingTop: "$2" } as any)}>
|
<Accordion.Content {...({ paddingTop: "$2" } as any)}>
|
||||||
<YStack space="$2" padding="$2" borderWidth={1} borderColor={border} borderRadius={12}>
|
<YStack gap="$2" padding="$2" borderWidth={1} borderColor={border} borderRadius={12}>
|
||||||
<XStack space="$3">
|
<XStack gap="$3">
|
||||||
<YStack flex={1} space="$1">
|
<YStack flex={1} gap="$1">
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{t('events.qr.positionX', 'X (%)')}
|
{t('events.qr.positionX', 'X (%)')}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack space="$2" alignItems="center">
|
<XStack gap="$2" alignItems="center">
|
||||||
<StepperInput
|
<StepperInput
|
||||||
value={currentX * 100}
|
value={currentX * 100}
|
||||||
min={0}
|
min={0}
|
||||||
@@ -1428,7 +1428,7 @@ function LayoutControls({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</XStack>
|
</XStack>
|
||||||
</YStack>
|
</YStack>
|
||||||
<YStack flex={1} space="$1">
|
<YStack flex={1} gap="$1">
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{t('events.qr.positionY', 'Y (%)')}
|
{t('events.qr.positionY', 'Y (%)')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1440,7 +1440,7 @@ function LayoutControls({
|
|||||||
onChange={(val) => onPercentChange('y')(val)}
|
onChange={(val) => onPercentChange('y')(val)}
|
||||||
/>
|
/>
|
||||||
</YStack>
|
</YStack>
|
||||||
<YStack flex={1} space="$1">
|
<YStack flex={1} gap="$1">
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{t('events.qr.width', 'Breite (%)')}
|
{t('events.qr.width', 'Breite (%)')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1454,14 +1454,14 @@ function LayoutControls({
|
|||||||
</YStack>
|
</YStack>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<XStack space="$3">
|
<XStack gap="$3">
|
||||||
<YStack flex={1} space="$1">
|
<YStack flex={1} gap="$1">
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{t('events.qr.fontSize', 'Font Size (px)')}
|
{t('events.qr.fontSize', 'Font Size (px)')}
|
||||||
</Text>
|
</Text>
|
||||||
<StepperInput value={override.fontSize ?? slot.fontSize ?? 16} min={8} max={200} step={1} onChange={(val) => onFontSizeChange(val)} />
|
<StepperInput value={override.fontSize ?? slot.fontSize ?? 16} min={8} max={200} step={1} onChange={(val) => onFontSizeChange(val)} />
|
||||||
</YStack>
|
</YStack>
|
||||||
<YStack flex={1} space="$1">
|
<YStack flex={1} gap="$1">
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{t('events.qr.fontFamily', 'Font Family')}
|
{t('events.qr.fontFamily', 'Font Family')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1477,12 +1477,12 @@ function LayoutControls({
|
|||||||
))}
|
))}
|
||||||
</MobileSelect>
|
</MobileSelect>
|
||||||
</YStack>
|
</YStack>
|
||||||
<YStack flex={1} space="$1">
|
<YStack flex={1} gap="$1">
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{t('events.qr.fontColor', 'Schriftfarbe')}
|
{t('events.qr.fontColor', 'Schriftfarbe')}
|
||||||
</Text>
|
</Text>
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<XStack space="$2" alignItems="center">
|
<XStack gap="$2" alignItems="center">
|
||||||
<Pressable onPress={() => setOpenColorSlot(openColorSlot === slotKey ? null : slotKey)}>
|
<Pressable onPress={() => setOpenColorSlot(openColorSlot === slotKey ? null : slotKey)}>
|
||||||
<XStack
|
<XStack
|
||||||
width={48}
|
width={48}
|
||||||
@@ -1536,7 +1536,7 @@ function LayoutControls({
|
|||||||
onChange={(val) => onUpdateSlot(slotKey, { color: val })}
|
onChange={(val) => onUpdateSlot(slotKey, { color: val })}
|
||||||
style={{ width: 240, height: 200 }}
|
style={{ width: 240, height: 200 }}
|
||||||
/>
|
/>
|
||||||
<XStack space="$2" justifyContent="flex-end">
|
<XStack gap="$2" justifyContent="flex-end">
|
||||||
<Pressable onPress={() => setOpenColorSlot(null)}>
|
<Pressable onPress={() => setOpenColorSlot(null)}>
|
||||||
<XStack
|
<XStack
|
||||||
paddingHorizontal="$3"
|
paddingHorizontal="$3"
|
||||||
@@ -1560,8 +1560,8 @@ function LayoutControls({
|
|||||||
</YStack>
|
</YStack>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<XStack space="$2">
|
<XStack gap="$2">
|
||||||
<YStack flex={1} space="$1">
|
<YStack flex={1} gap="$1">
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{t('events.qr.align', 'Align')}
|
{t('events.qr.align', 'Align')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1574,7 +1574,7 @@ function LayoutControls({
|
|||||||
<option value="right">{t('common.right', 'Rechts')}</option>
|
<option value="right">{t('common.right', 'Rechts')}</option>
|
||||||
</MobileSelect>
|
</MobileSelect>
|
||||||
</YStack>
|
</YStack>
|
||||||
<YStack flex={1} space="$1">
|
<YStack flex={1} gap="$1">
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{t('events.qr.lineHeight', 'Line Height')}
|
{t('events.qr.lineHeight', 'Line Height')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1606,7 +1606,7 @@ function LayoutControls({
|
|||||||
const accordionDefaults = ['headline'];
|
const accordionDefaults = ['headline'];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack space="$3" marginTop="$2">
|
<YStack gap="$3" marginTop="$2">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{t('events.qr.layoutControls', 'Layout & Schrift')}
|
{t('events.qr.layoutControls', 'Layout & Schrift')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1627,9 +1627,9 @@ function LayoutControls({
|
|||||||
</XStack>
|
</XStack>
|
||||||
</Accordion.Trigger>
|
</Accordion.Trigger>
|
||||||
<Accordion.Content {...({ paddingTop: "$2" } as any)}>
|
<Accordion.Content {...({ paddingTop: "$2" } as any)}>
|
||||||
<YStack space="$2" padding="$2" borderWidth={1} borderColor={border} borderRadius={12}>
|
<YStack gap="$2" padding="$2" borderWidth={1} borderColor={border} borderRadius={12}>
|
||||||
<XStack space="$2">
|
<XStack gap="$2">
|
||||||
<YStack flex={1} space="$1">
|
<YStack flex={1} gap="$1">
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{t('events.qr.positionX', 'X (%)')}
|
{t('events.qr.positionX', 'X (%)')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1641,7 +1641,7 @@ function LayoutControls({
|
|||||||
onChange={(val) => onQrPercentChange('x')(val)}
|
onChange={(val) => onQrPercentChange('x')(val)}
|
||||||
/>
|
/>
|
||||||
</YStack>
|
</YStack>
|
||||||
<YStack flex={1} space="$1">
|
<YStack flex={1} gap="$1">
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{t('events.qr.positionY', 'Y (%)')}
|
{t('events.qr.positionY', 'Y (%)')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1653,7 +1653,7 @@ function LayoutControls({
|
|||||||
onChange={(val) => onQrPercentChange('y')(val)}
|
onChange={(val) => onQrPercentChange('y')(val)}
|
||||||
/>
|
/>
|
||||||
</YStack>
|
</YStack>
|
||||||
<YStack flex={1} space="$1">
|
<YStack flex={1} gap="$1">
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{t('events.qr.size', 'Größe (%)')}
|
{t('events.qr.size', 'Größe (%)')}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export default function MobileQrPrintPage() {
|
|||||||
<ContextHelpLink slug="guest-access-qr" />
|
<ContextHelpLink slug="guest-access-qr" />
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<MobileCard space="$3" alignItems="center">
|
<MobileCard gap="$3" alignItems="center">
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('events.qr.heroTitle', 'Entrance QR Code')}
|
{t('events.qr.heroTitle', 'Entrance QR Code')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -148,7 +148,7 @@ export default function MobileQrPrintPage() {
|
|||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{t('events.qr.description', 'Scan to access the event guest app.')}
|
{t('events.qr.description', 'Scan to access the event guest app.')}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack space="$2" width="100%" marginTop="$2">
|
<XStack gap="$2" width="100%" marginTop="$2">
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={t('events.qr.download', 'Download')}
|
label={t('events.qr.download', 'Download')}
|
||||||
fullWidth={false}
|
fullWidth={false}
|
||||||
@@ -191,7 +191,7 @@ export default function MobileQrPrintPage() {
|
|||||||
</XStack>
|
</XStack>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$2">
|
<MobileCard gap="$2">
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{t('events.qr.step1', 'Schritt 1: Format wählen')}
|
{t('events.qr.step1', 'Schritt 1: Format wählen')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -226,7 +226,7 @@ export default function MobileQrPrintPage() {
|
|||||||
/>
|
/>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$2">
|
<MobileCard gap="$2">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{t('events.qr.createLink', 'Neuen QR-Link erstellen')}
|
{t('events.qr.createLink', 'Neuen QR-Link erstellen')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -299,7 +299,7 @@ function FormatSelection({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack space="$2" marginTop="$2">
|
<YStack gap="$2" marginTop="$2">
|
||||||
{cards.map((card) => {
|
{cards.map((card) => {
|
||||||
const isSelected = selectedFormat === card.key;
|
const isSelected = selectedFormat === card.key;
|
||||||
return (
|
return (
|
||||||
@@ -314,15 +314,15 @@ function FormatSelection({
|
|||||||
borderWidth={isSelected ? 2 : 1}
|
borderWidth={isSelected ? 2 : 1}
|
||||||
backgroundColor={isSelected ? accentSoft : surface}
|
backgroundColor={isSelected ? accentSoft : surface}
|
||||||
>
|
>
|
||||||
<XStack alignItems="center" justifyContent="space-between" space="$3">
|
<XStack alignItems="center" justifyContent="space-between" gap="$3">
|
||||||
<YStack space="$1" flex={1}>
|
<YStack gap="$1" flex={1}>
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{card.title}
|
{card.title}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{card.subtitle}
|
{card.subtitle}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack space="$2" alignItems="center" flexWrap="wrap">
|
<XStack gap="$2" alignItems="center" flexWrap="wrap">
|
||||||
{card.badges.map((badge) => (
|
{card.badges.map((badge) => (
|
||||||
<PillBadge tone="muted" key={badge}>
|
<PillBadge tone="muted" key={badge}>
|
||||||
{badge}
|
{badge}
|
||||||
@@ -377,10 +377,10 @@ function BackgroundStep({
|
|||||||
const formatLabel = isFoldable ? 'A4 Landscape (Double/Mirror)' : 'A4 Portrait';
|
const formatLabel = isFoldable ? 'A4 Landscape (Double/Mirror)' : 'A4 Portrait';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack space="$3" marginTop="$2">
|
<YStack gap="$3" marginTop="$2">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Pressable onPress={onBack}>
|
<Pressable onPress={onBack}>
|
||||||
<XStack alignItems="center" space="$1">
|
<XStack alignItems="center" gap="$1">
|
||||||
<ArrowLeft size={16} color={textStrong} />
|
<ArrowLeft size={16} color={textStrong} />
|
||||||
<Text fontSize="$sm" color={textStrong}>
|
<Text fontSize="$sm" color={textStrong}>
|
||||||
{t('common.back', 'Zurück')}
|
{t('common.back', 'Zurück')}
|
||||||
@@ -392,7 +392,7 @@ function BackgroundStep({
|
|||||||
</PillBadge>
|
</PillBadge>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{t(
|
{t(
|
||||||
'events.qr.backgroundPicker',
|
'events.qr.backgroundPicker',
|
||||||
@@ -488,10 +488,10 @@ function TextStep({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack space="$3" marginTop="$2">
|
<YStack gap="$3" marginTop="$2">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Pressable onPress={onBack}>
|
<Pressable onPress={onBack}>
|
||||||
<XStack alignItems="center" space="$1">
|
<XStack alignItems="center" gap="$1">
|
||||||
<ArrowLeft size={16} color={textStrong} />
|
<ArrowLeft size={16} color={textStrong} />
|
||||||
<Text fontSize="$sm" color={textStrong}>
|
<Text fontSize="$sm" color={textStrong}>
|
||||||
{t('common.back', 'Zurück')}
|
{t('common.back', 'Zurück')}
|
||||||
@@ -501,7 +501,7 @@ function TextStep({
|
|||||||
<PillBadge tone="muted">{t('events.qr.textStep', 'Texte & Hinweise')}</PillBadge>
|
<PillBadge tone="muted">{t('events.qr.textStep', 'Texte & Hinweise')}</PillBadge>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{t('events.qr.textFields', 'Texte')}
|
{t('events.qr.textFields', 'Texte')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -522,12 +522,12 @@ function TextStep({
|
|||||||
/>
|
/>
|
||||||
</YStack>
|
</YStack>
|
||||||
|
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{t('events.qr.instructions', 'Anleitung')}
|
{t('events.qr.instructions', 'Anleitung')}
|
||||||
</Text>
|
</Text>
|
||||||
{textFields.instructions.map((item, idx) => (
|
{textFields.instructions.map((item, idx) => (
|
||||||
<XStack key={idx} alignItems="center" space="$2">
|
<XStack key={idx} alignItems="center" gap="$2">
|
||||||
<MobileInput
|
<MobileInput
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
placeholder={t('events.qr.instructionsPlaceholder', 'Schritt hinzufügen')}
|
placeholder={t('events.qr.instructionsPlaceholder', 'Schritt hinzufügen')}
|
||||||
@@ -580,10 +580,10 @@ function PreviewStep({
|
|||||||
const previewBody = layout?.preview?.text ?? text;
|
const previewBody = layout?.preview?.text ?? text;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack space="$3" marginTop="$2">
|
<YStack gap="$3" marginTop="$2">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Pressable onPress={onBack}>
|
<Pressable onPress={onBack}>
|
||||||
<XStack alignItems="center" space="$1">
|
<XStack alignItems="center" gap="$1">
|
||||||
<ArrowLeft size={16} color={textStrong} />
|
<ArrowLeft size={16} color={textStrong} />
|
||||||
<Text fontSize="$sm" color={textStrong}>
|
<Text fontSize="$sm" color={textStrong}>
|
||||||
{t('common.back', 'Zurück')}
|
{t('common.back', 'Zurück')}
|
||||||
@@ -597,7 +597,7 @@ function PreviewStep({
|
|||||||
) : null}
|
) : null}
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{t('events.qr.preview', 'Vorschau')}
|
{t('events.qr.preview', 'Vorschau')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -632,7 +632,7 @@ function PreviewStep({
|
|||||||
{textFields.description}
|
{textFields.description}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
<YStack space="$1">
|
<YStack gap="$1">
|
||||||
{textFields.instructions.filter((i) => i.trim().length > 0).map((item, idx) => (
|
{textFields.instructions.filter((i) => i.trim().length > 0).map((item, idx) => (
|
||||||
<Text key={idx} fontSize="$xs" color={previewBody}>
|
<Text key={idx} fontSize="$xs" color={previewBody}>
|
||||||
• {item}
|
• {item}
|
||||||
@@ -660,7 +660,7 @@ function PreviewStep({
|
|||||||
</YStack>
|
</YStack>
|
||||||
</YStack>
|
</YStack>
|
||||||
|
|
||||||
<XStack space="$2">
|
<XStack gap="$2">
|
||||||
<CTAButton label={t('events.qr.exportPdf', 'Export PDF')} onPress={() => onExport('pdf')} />
|
<CTAButton label={t('events.qr.exportPdf', 'Export PDF')} onPress={() => onExport('pdf')} />
|
||||||
<CTAButton label={t('events.qr.exportPng', 'Export PNG')} onPress={() => onExport('png')} />
|
<CTAButton label={t('events.qr.exportPng', 'Export PNG')} onPress={() => onExport('png')} />
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|||||||
@@ -110,10 +110,10 @@ export default function ResetPasswordPage() {
|
|||||||
paddingVertical="$5"
|
paddingVertical="$5"
|
||||||
style={{ ...safeAreaStyle, background: ADMIN_GRADIENTS.loginBackground }}
|
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)">
|
<MobileCard backgroundColor="rgba(15, 23, 42, 0.6)" borderColor="rgba(255,255,255,0.12)">
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<XStack
|
<XStack
|
||||||
width={42}
|
width={42}
|
||||||
height={42}
|
height={42}
|
||||||
@@ -137,7 +137,7 @@ export default function ResetPasswordPage() {
|
|||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard backgroundColor={surface} borderColor={border}>
|
<MobileCard backgroundColor={surface} borderColor={border}>
|
||||||
<YStack space="$3">
|
<YStack gap="$3">
|
||||||
<MobileField label={t('login.email', 'Email address')} error={fieldErrors.email?.[0]}>
|
<MobileField label={t('login.email', 'Email address')} error={fieldErrors.email?.[0]}>
|
||||||
<MobileInput
|
<MobileInput
|
||||||
type="email"
|
type="email"
|
||||||
|
|||||||
@@ -210,8 +210,8 @@ export default function MobileSettingsPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Shield size={18} color={text} />
|
<Shield size={18} color={text} />
|
||||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
{t('mobileSettings.accountTitle', 'Account')}
|
{t('mobileSettings.accountTitle', 'Account')}
|
||||||
@@ -223,14 +223,14 @@ export default function MobileSettingsPage() {
|
|||||||
{user?.tenant_id ? (
|
{user?.tenant_id ? (
|
||||||
<PillBadge tone="muted">{t('mobileSettings.tenantBadge', 'Account #{{id}}', { id: user.tenant_id })}</PillBadge>
|
<PillBadge tone="muted">{t('mobileSettings.tenantBadge', 'Account #{{id}}', { id: user.tenant_id })}</PillBadge>
|
||||||
) : null}
|
) : 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.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') })} />
|
<CTAButton label={t('settings.session.logout', 'Abmelden')} tone="ghost" onPress={() => logout({ redirect: adminPath('/logout') })} />
|
||||||
</XStack>
|
</XStack>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Bell size={18} color={text} />
|
<Bell size={18} color={text} />
|
||||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
{t('mobileSettings.notificationsTitle', 'Notifications')}
|
{t('mobileSettings.notificationsTitle', 'Notifications')}
|
||||||
@@ -316,14 +316,14 @@ export default function MobileSettingsPage() {
|
|||||||
{pushState.error}
|
{pushState.error}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
<XStack space="$2">
|
<XStack gap="$2">
|
||||||
<CTAButton label={saving ? t('common.processing', '...') : t('settings.notifications.actions.save', 'Speichern')} onPress={() => handleSave()} />
|
<CTAButton label={saving ? t('common.processing', '...') : t('settings.notifications.actions.save', 'Speichern')} onPress={() => handleSave()} />
|
||||||
<CTAButton label={t('common.reset', 'Reset')} tone="ghost" onPress={() => handleReset()} />
|
<CTAButton label={t('common.reset', 'Reset')} tone="ghost" onPress={() => handleReset()} />
|
||||||
</XStack>
|
</XStack>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Smartphone size={18} color={text} />
|
<Smartphone size={18} color={text} />
|
||||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
{t('mobileSettings.deviceTitle', 'Device & permissions')}
|
{t('mobileSettings.deviceTitle', 'Device & permissions')}
|
||||||
@@ -337,9 +337,9 @@ export default function MobileSettingsPage() {
|
|||||||
{t('mobileSettings.deviceLoading', 'Checking device status ...')}
|
{t('mobileSettings.deviceLoading', 'Checking device status ...')}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<XStack alignItems="center" justifyContent="space-between" 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}>
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||||
{t('mobileSettings.deviceStatus.notifications.label', 'Notifications')}
|
{t('mobileSettings.deviceStatus.notifications.label', 'Notifications')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -352,7 +352,7 @@ export default function MobileSettingsPage() {
|
|||||||
</PillBadge>
|
</PillBadge>
|
||||||
</XStack>
|
</XStack>
|
||||||
<XStack alignItems="center" justifyContent="space-between" 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}>
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||||
{t('mobileSettings.deviceStatus.camera.label', 'Camera')}
|
{t('mobileSettings.deviceStatus.camera.label', 'Camera')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -365,7 +365,7 @@ export default function MobileSettingsPage() {
|
|||||||
</PillBadge>
|
</PillBadge>
|
||||||
</XStack>
|
</XStack>
|
||||||
<XStack alignItems="center" justifyContent="space-between" 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}>
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||||
{t('mobileSettings.deviceStatus.storage.label', 'Offline storage')}
|
{t('mobileSettings.deviceStatus.storage.label', 'Offline storage')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -378,7 +378,7 @@ export default function MobileSettingsPage() {
|
|||||||
</PillBadge>
|
</PillBadge>
|
||||||
</XStack>
|
</XStack>
|
||||||
<XStack alignItems="center" justifyContent="space-between" 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}>
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||||
{t('mobileSettings.deviceStatus.connection.label', 'Connection')}
|
{t('mobileSettings.deviceStatus.connection.label', 'Connection')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -404,8 +404,8 @@ export default function MobileSettingsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Sparkles size={18} color={text} />
|
<Sparkles size={18} color={text} />
|
||||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
{t('mobileSettings.experienceTitle', 'Experience')}
|
{t('mobileSettings.experienceTitle', 'Experience')}
|
||||||
@@ -414,7 +414,7 @@ export default function MobileSettingsPage() {
|
|||||||
<Text fontSize="$sm" color={muted}>
|
<Text fontSize="$sm" color={muted}>
|
||||||
{t('mobileSettings.experienceBody', 'Replay the quick tour or re-enable the install banner.')}
|
{t('mobileSettings.experienceBody', 'Replay the quick tour or re-enable the install banner.')}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack space="$2">
|
<XStack gap="$2">
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={t('mobileSettings.experienceReplay', 'Replay quick tour')}
|
label={t('mobileSettings.experienceReplay', 'Replay quick tour')}
|
||||||
onPress={handleReplayTour}
|
onPress={handleReplayTour}
|
||||||
@@ -430,8 +430,8 @@ export default function MobileSettingsPage() {
|
|||||||
</XStack>
|
</XStack>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<User size={18} color={text} />
|
<User size={18} color={text} />
|
||||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
{t('settings.appearance.title', 'Darstellung')}
|
{t('settings.appearance.title', 'Darstellung')}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function MobileTasksTabPage() {
|
|||||||
if (activeEvent?.slug && !tasksEnabled) {
|
if (activeEvent?.slug && !tasksEnabled) {
|
||||||
return (
|
return (
|
||||||
<MobileShell activeTab="tasks" title={t('events.tasks.title', 'Photo tasks')}>
|
<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}>
|
<Text fontSize="$lg" fontWeight="800" color={text}>
|
||||||
{t('events.tasks.disabledTitle', 'Photo task mode is off for this event')}
|
{t('events.tasks.disabledTitle', 'Photo task mode is off for this event')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -44,7 +44,7 @@ export default function MobileTasksTabPage() {
|
|||||||
if (!hasEvents) {
|
if (!hasEvents) {
|
||||||
return (
|
return (
|
||||||
<MobileShell activeTab="tasks" title={t('events.tasks.title', 'Photo tasks')}>
|
<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}>
|
<Text fontSize="$lg" fontWeight="800" color={text}>
|
||||||
{t('events.tasks.emptyTitle', 'Create an event first')}
|
{t('events.tasks.emptyTitle', 'Create an event first')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -64,7 +64,7 @@ export default function MobileTasksTabPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileShell activeTab="tasks" title={t('events.tasks.title', 'Photo tasks')}>
|
<MobileShell activeTab="tasks" title={t('events.tasks.title', 'Photo tasks')}>
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$sm" color={text} fontWeight="700">
|
<Text fontSize="$sm" color={text} fontWeight="700">
|
||||||
{t('events.tasks.pickEvent', 'Pick an event to manage photo tasks')}
|
{t('events.tasks.pickEvent', 'Pick an event to manage photo tasks')}
|
||||||
</Text>
|
</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">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<YStack space="$1">
|
<YStack gap="$1">
|
||||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
{resolveEventDisplayName(event)}
|
{resolveEventDisplayName(event)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function MobileUploadsTabPage() {
|
|||||||
shadowRadius={16}
|
shadowRadius={16}
|
||||||
shadowOffset={{ width: 0, height: 10 }}
|
shadowOffset={{ width: 0, height: 10 }}
|
||||||
>
|
>
|
||||||
<YStack space="$2.5">
|
<YStack gap="$2.5">
|
||||||
<XStack
|
<XStack
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
paddingHorizontal="$3"
|
paddingHorizontal="$3"
|
||||||
@@ -82,7 +82,7 @@ export default function MobileUploadsTabPage() {
|
|||||||
shadowRadius={16}
|
shadowRadius={16}
|
||||||
shadowOffset={{ width: 0, height: 10 }}
|
shadowOffset={{ width: 0, height: 10 }}
|
||||||
>
|
>
|
||||||
<YStack space="$2.5">
|
<YStack gap="$2.5">
|
||||||
<XStack
|
<XStack
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
paddingHorizontal="$3"
|
paddingHorizontal="$3"
|
||||||
@@ -105,7 +105,7 @@ export default function MobileUploadsTabPage() {
|
|||||||
paddingVertical="$2"
|
paddingVertical="$2"
|
||||||
paddingHorizontal="$3"
|
paddingHorizontal="$3"
|
||||||
title={
|
title={
|
||||||
<YStack space="$1">
|
<YStack gap="$1">
|
||||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
{resolveEventDisplayName(event)}
|
{resolveEventDisplayName(event)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
|
|||||||
<YStack
|
<YStack
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
space="$1"
|
gap="$1"
|
||||||
minHeight={50}
|
minHeight={50}
|
||||||
style={{
|
style={{
|
||||||
transform: isPressed ? 'scale(0.92)' : (activeState ? 'scale(1.05)' : 'scale(1)'),
|
transform: isPressed ? 'scale(0.92)' : (activeState ? 'scale(1.05)' : 'scale(1)'),
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function ContextHelpLink({ slug, label }: ContextHelpLinkProps) {
|
|||||||
>
|
>
|
||||||
<XStack
|
<XStack
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
space="$1.5"
|
gap="$1.5"
|
||||||
paddingHorizontal="$2.5"
|
paddingHorizontal="$2.5"
|
||||||
paddingVertical="$1.5"
|
paddingVertical="$1.5"
|
||||||
borderRadius={999}
|
borderRadius={999}
|
||||||
|
|||||||
@@ -41,14 +41,14 @@ export function EventSwitcherSheet({
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
snapPoints={[65]}
|
snapPoints={[65]}
|
||||||
>
|
>
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
{events.map((event) => {
|
{events.map((event) => {
|
||||||
const isActive = event.slug === activeSlug;
|
const isActive = event.slug === activeSlug;
|
||||||
return (
|
return (
|
||||||
<Pressable key={event.slug} onPress={() => handleSelect(event.slug)}>
|
<Pressable key={event.slug} onPress={() => handleSelect(event.slug)}>
|
||||||
<XStack
|
<XStack
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
space="$3"
|
gap="$3"
|
||||||
padding="$3"
|
padding="$3"
|
||||||
borderRadius={14}
|
borderRadius={14}
|
||||||
backgroundColor={isActive ? theme.surfaceMuted : 'transparent'}
|
backgroundColor={isActive ? theme.surfaceMuted : 'transparent'}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function MobileField({ label, hint, error, children }: FieldProps) {
|
|||||||
const { text, muted, danger } = useAdminTheme();
|
const { text, muted, danger } = useAdminTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack space="$1.5">
|
<YStack gap="$1.5">
|
||||||
{typeof label === 'string' || typeof label === 'number' ? (
|
{typeof label === 'string' || typeof label === 'number' ? (
|
||||||
<Text fontSize="$sm" fontWeight="800" color={text}>
|
<Text fontSize="$sm" fontWeight="800" color={text}>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export function LegalConsentSheet({
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={copy?.title ?? t('events.legalConsent.title', 'Before purchase')}
|
title={copy?.title ?? t('events.legalConsent.title', 'Before purchase')}
|
||||||
footer={
|
footer={
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
{error ? (
|
{error ? (
|
||||||
<Text fontSize="$sm" color={danger}>
|
<Text fontSize="$sm" color={danger}>
|
||||||
{error}
|
{error}
|
||||||
@@ -97,12 +97,12 @@ export function LegalConsentSheet({
|
|||||||
</YStack>
|
</YStack>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$sm" color={text}>
|
<Text fontSize="$sm" color={text}>
|
||||||
{copy?.description ?? t('events.legalConsent.description', 'Please confirm the legal notes before buying an add-on.')}
|
{copy?.description ?? t('events.legalConsent.description', 'Please confirm the legal notes before buying an add-on.')}
|
||||||
</Text>
|
</Text>
|
||||||
{requireTerms ? (
|
{requireTerms ? (
|
||||||
<XStack space="$3" alignItems="flex-start">
|
<XStack gap="$3" alignItems="flex-start">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="legal-terms"
|
id="legal-terms"
|
||||||
size="$4"
|
size="$4"
|
||||||
@@ -130,7 +130,7 @@ export function LegalConsentSheet({
|
|||||||
</XStack>
|
</XStack>
|
||||||
) : null}
|
) : null}
|
||||||
{requireWaiver ? (
|
{requireWaiver ? (
|
||||||
<XStack space="$3" alignItems="flex-start">
|
<XStack gap="$3" alignItems="flex-start">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="legal-waiver"
|
id="legal-waiver"
|
||||||
size="$4"
|
size="$4"
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ export function LimitWarnings({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
{warnings.map((warning) => (
|
{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">
|
<Text fontSize="$sm" color={textColor} fontWeight="700">
|
||||||
{warning.message}
|
{warning.message}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -100,7 +100,7 @@ function MobileAddonsPicker({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<XStack space="$2" alignItems="center">
|
<XStack gap="$2" alignItems="center">
|
||||||
<MobileSelect
|
<MobileSelect
|
||||||
value={selected}
|
value={selected}
|
||||||
onChange={(event) => setSelected(event.target.value)}
|
onChange={(event) => setSelected(event.target.value)}
|
||||||
|
|||||||
@@ -33,13 +33,13 @@ export function MobileInstallBanner({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileCard
|
<MobileCard
|
||||||
space={isCompact ? '$1.5' : '$2'}
|
gap={isCompact ? '$1.5' : '$2'}
|
||||||
borderColor={border}
|
borderColor={border}
|
||||||
backgroundColor={surfaceMuted}
|
backgroundColor={surfaceMuted}
|
||||||
padding={isCompact ? '$2' : '$3'}
|
padding={isCompact ? '$2' : '$3'}
|
||||||
>
|
>
|
||||||
<XStack alignItems="center" justifyContent="space-between" gap="$2">
|
<XStack alignItems="center" justifyContent="space-between" gap="$2">
|
||||||
<XStack alignItems="center" space="$2" flex={1}>
|
<XStack alignItems="center" gap="$2" flex={1}>
|
||||||
<XStack
|
<XStack
|
||||||
width={isCompact ? 32 : 36}
|
width={isCompact ? 32 : 36}
|
||||||
height={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} />}
|
{isPrompt ? <Download size={16} color={primary} /> : <Share2 size={16} color={primary} />}
|
||||||
</XStack>
|
</XStack>
|
||||||
<YStack flex={1} space="$0.5">
|
<YStack flex={1} gap="$0.5">
|
||||||
<Text fontSize={isCompact ? '$xs' : '$sm'} fontWeight="800" color={textStrong}>
|
<Text fontSize={isCompact ? '$xs' : '$sm'} fontWeight="800" color={textStrong}>
|
||||||
{t('installBanner.title', 'Install Fotospiel Admin')}
|
{t('installBanner.title', 'Install Fotospiel Admin')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -61,7 +61,7 @@ export function MobileInstallBanner({
|
|||||||
</Text>
|
</Text>
|
||||||
</YStack>
|
</YStack>
|
||||||
</XStack>
|
</XStack>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
{isPrompt && onInstall && isCompact ? (
|
{isPrompt && onInstall && isCompact ? (
|
||||||
<Pressable onPress={onInstall}>
|
<Pressable onPress={onInstall}>
|
||||||
<Text fontSize={10} fontWeight="700" color={primary}>
|
<Text fontSize={10} fontWeight="700" color={primary}>
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
|||||||
paddingVertical="$1.5"
|
paddingVertical="$1.5"
|
||||||
borderRadius={999}
|
borderRadius={999}
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
space="$1.5"
|
gap="$1.5"
|
||||||
maxWidth={220}
|
maxWidth={220}
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
borderColor="rgba(255, 255, 255, 0.08)"
|
borderColor="rgba(255, 255, 255, 0.08)"
|
||||||
@@ -201,7 +201,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
|||||||
|
|
||||||
const headerBackButton = onBack ? (
|
const headerBackButton = onBack ? (
|
||||||
<HeaderActionButton onPress={onBack} ariaLabel={t('actions.back', 'Back')}>
|
<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} />
|
<ChevronLeft size={28} color="white" strokeWidth={2.5} />
|
||||||
</XStack>
|
</XStack>
|
||||||
</HeaderActionButton>
|
</HeaderActionButton>
|
||||||
@@ -214,7 +214,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
|||||||
);
|
);
|
||||||
|
|
||||||
const headerActionsRow = (
|
const headerActionsRow = (
|
||||||
<XStack alignItems="center" space="$2.5">
|
<XStack alignItems="center" gap="$2.5">
|
||||||
{showQr ? (
|
{showQr ? (
|
||||||
<HeaderActionButton
|
<HeaderActionButton
|
||||||
onPress={() => navigate(adminPath(`/mobile/events/${effectiveActive?.slug}/qr`))}
|
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}
|
borderWidth={1} borderColor={actionBorder}
|
||||||
>
|
>
|
||||||
{user?.avatar_url ? (
|
{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">
|
<Text fontSize="$xs" fontWeight="700" color="white">
|
||||||
{user?.name?.charAt(0).toUpperCase() ?? 'U'}
|
{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)',
|
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}
|
{headerBackButton}
|
||||||
|
|
||||||
<XStack flex={1} justifyContent="center" alignItems="center">
|
<XStack flex={1} justifyContent="center" alignItems="center">
|
||||||
@@ -332,7 +332,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
|||||||
flex={1}
|
flex={1}
|
||||||
padding="$4"
|
padding="$4"
|
||||||
paddingBottom="$10"
|
paddingBottom="$10"
|
||||||
space="$3"
|
gap="$3"
|
||||||
width="100%"
|
width="100%"
|
||||||
maxWidth={800}
|
maxWidth={800}
|
||||||
style={{ paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 96px)' }}
|
style={{ paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 96px)' }}
|
||||||
@@ -345,7 +345,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
|||||||
</XStack>
|
</XStack>
|
||||||
) : null}
|
) : null}
|
||||||
{queuedPhotoCount > 0 ? (
|
{queuedPhotoCount > 0 ? (
|
||||||
<MobileCard space="$2">
|
<MobileCard gap="$2">
|
||||||
<Text fontSize="$sm" fontWeight="700" color={theme.textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={theme.textStrong}>
|
||||||
{t('status.queueTitle', 'Photo actions pending')}
|
{t('status.queueTitle', 'Photo actions pending')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -365,7 +365,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
|||||||
</MobileCard>
|
</MobileCard>
|
||||||
) : null}
|
) : null}
|
||||||
{subtitle ? (
|
{subtitle ? (
|
||||||
<YStack space="$1">
|
<YStack gap="$1">
|
||||||
{title ? (
|
{title ? (
|
||||||
<Text fontSize="$lg" fontWeight="800" color={theme.textStrong}>
|
<Text fontSize="$lg" fontWeight="800" color={theme.textStrong}>
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function OnboardingShell({
|
|||||||
paddingHorizontal="$5"
|
paddingHorizontal="$5"
|
||||||
paddingTop="$5"
|
paddingTop="$5"
|
||||||
paddingBottom="$6"
|
paddingBottom="$6"
|
||||||
space="$4"
|
gap="$4"
|
||||||
style={{
|
style={{
|
||||||
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 20px)',
|
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 20px)',
|
||||||
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 24px)',
|
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 24px)',
|
||||||
@@ -54,7 +54,7 @@ export function OnboardingShell({
|
|||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
{onBack ? (
|
{onBack ? (
|
||||||
<Pressable onPress={onBack} aria-label={resolvedBackLabel}>
|
<Pressable onPress={onBack} aria-label={resolvedBackLabel}>
|
||||||
<XStack alignItems="center" space="$1.5">
|
<XStack alignItems="center" gap="$1.5">
|
||||||
<ChevronLeft size={22} color={text} />
|
<ChevronLeft size={22} color={text} />
|
||||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||||
{resolvedBackLabel}
|
{resolvedBackLabel}
|
||||||
@@ -86,7 +86,7 @@ export function OnboardingShell({
|
|||||||
shadowOpacity={0.06}
|
shadowOpacity={0.06}
|
||||||
shadowRadius={14}
|
shadowRadius={14}
|
||||||
shadowOffset={{ width: 0, height: 8 }}
|
shadowOffset={{ width: 0, height: 8 }}
|
||||||
space="$2"
|
gap="$2"
|
||||||
>
|
>
|
||||||
{eyebrow ? (
|
{eyebrow ? (
|
||||||
<Text fontSize="$xs" fontWeight="700" color={muted} textTransform="uppercase" letterSpacing={0.6}>
|
<Text fontSize="$xs" fontWeight="700" color={muted} textTransform="uppercase" letterSpacing={0.6}>
|
||||||
@@ -103,7 +103,7 @@ export function OnboardingShell({
|
|||||||
) : null}
|
) : null}
|
||||||
</YStack>
|
</YStack>
|
||||||
|
|
||||||
<YStack space="$4">{children}</YStack>
|
<YStack gap="$4">{children}</YStack>
|
||||||
{footer ? <YStack marginTop="$2">{footer}</YStack> : null}
|
{footer ? <YStack marginTop="$2">{footer}</YStack> : null}
|
||||||
</YStack>
|
</YStack>
|
||||||
</YStack>
|
</YStack>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function MobileCard({
|
|||||||
shadowRadius={16}
|
shadowRadius={16}
|
||||||
shadowOffset={{ width: 0, height: 10 }}
|
shadowOffset={{ width: 0, height: 10 }}
|
||||||
padding="$3.5"
|
padding="$3.5"
|
||||||
space="$2"
|
gap="$2"
|
||||||
style={{
|
style={{
|
||||||
...style,
|
...style,
|
||||||
}}
|
}}
|
||||||
@@ -138,7 +138,7 @@ export function CTAButton({
|
|||||||
backgroundColor={backgroundColor}
|
backgroundColor={backgroundColor}
|
||||||
borderWidth={isPrimary || isDanger ? 0 : 2}
|
borderWidth={isPrimary || isDanger ? 0 : 2}
|
||||||
borderColor={borderColor}
|
borderColor={borderColor}
|
||||||
space="$2"
|
gap="$2"
|
||||||
style={primaryStyle}
|
style={primaryStyle}
|
||||||
>
|
>
|
||||||
{iconLeft}
|
{iconLeft}
|
||||||
@@ -169,7 +169,7 @@ export function KpiTile({
|
|||||||
const iconColor = color || primary;
|
const iconColor = color || primary;
|
||||||
|
|
||||||
return (
|
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
|
<XStack
|
||||||
width={28}
|
width={28}
|
||||||
height={28}
|
height={28}
|
||||||
@@ -181,7 +181,7 @@ export function KpiTile({
|
|||||||
<IconCmp size={14} color={iconColor} />
|
<IconCmp size={14} color={iconColor} />
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<YStack space="$0">
|
<YStack gap="$0">
|
||||||
<Text fontSize="$xl" fontWeight="900" color={textStrong} letterSpacing={-0.5} lineHeight="$xl">
|
<Text fontSize="$xl" fontWeight="900" color={textStrong} letterSpacing={-0.5} lineHeight="$xl">
|
||||||
{value}
|
{value}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -237,7 +237,7 @@ export function KpiStrip({
|
|||||||
minWidth={150}
|
minWidth={150}
|
||||||
maxWidth={220}
|
maxWidth={220}
|
||||||
>
|
>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Text
|
<Text
|
||||||
fontSize={32}
|
fontSize={32}
|
||||||
fontWeight="900"
|
fontWeight="900"
|
||||||
@@ -248,7 +248,7 @@ export function KpiStrip({
|
|||||||
{item.value}
|
{item.value}
|
||||||
</Text>
|
</Text>
|
||||||
<Separator vertical backgroundColor={separatorColor} height={32} marginHorizontal="$1.5" />
|
<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
|
<XStack
|
||||||
width={28}
|
width={28}
|
||||||
height={28}
|
height={28}
|
||||||
@@ -339,7 +339,7 @@ export function ActionTile({
|
|||||||
style={tileStyle}
|
style={tileStyle}
|
||||||
borderRadius={isCluster ? 14 : 16}
|
borderRadius={isCluster ? 14 : 16}
|
||||||
padding="$3"
|
padding="$3"
|
||||||
space="$2.5"
|
gap="$2.5"
|
||||||
backgroundColor={glassSurface ?? backgroundColor}
|
backgroundColor={glassSurface ?? backgroundColor}
|
||||||
borderWidth={2}
|
borderWidth={2}
|
||||||
borderColor={borderColor}
|
borderColor={borderColor}
|
||||||
@@ -405,7 +405,7 @@ export function FloatingActionButton({
|
|||||||
borderRadius={999}
|
borderRadius={999}
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
space="$2"
|
gap="$2"
|
||||||
backgroundColor={primary}
|
backgroundColor={primary}
|
||||||
shadowColor={shadow}
|
shadowColor={shadow}
|
||||||
shadowOpacity={0.2}
|
shadowOpacity={0.2}
|
||||||
|
|||||||
@@ -41,10 +41,10 @@ export function MobileScaffold({ title, onBack, rightSlot, children, footer }: M
|
|||||||
WebkitBackdropFilter: 'blur(12px)',
|
WebkitBackdropFilter: 'blur(12px)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
{onBack ? (
|
{onBack ? (
|
||||||
<Pressable onPress={onBack}>
|
<Pressable onPress={onBack}>
|
||||||
<XStack alignItems="center" space="$1.5">
|
<XStack alignItems="center" gap="$1.5">
|
||||||
<ChevronLeft size={18} color={primary} />
|
<ChevronLeft size={18} color={primary} />
|
||||||
<Text fontSize="$sm" color={primary} fontWeight="600">
|
<Text fontSize="$sm" color={primary} fontWeight="600">
|
||||||
{t('actions.back', 'Back')}
|
{t('actions.back', 'Back')}
|
||||||
@@ -63,7 +63,7 @@ export function MobileScaffold({ title, onBack, rightSlot, children, footer }: M
|
|||||||
</XStack>
|
</XStack>
|
||||||
</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}
|
{children}
|
||||||
</YStack>
|
</YStack>
|
||||||
|
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ export function SetupChecklist({
|
|||||||
const content = (
|
const content = (
|
||||||
<YStack>
|
<YStack>
|
||||||
<Pressable onPress={() => setCollapsed(!collapsed)}>
|
<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" 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}>
|
<Text fontSize="$xs" fontWeight="700" color={theme.muted} textTransform="uppercase" letterSpacing={1}>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -51,7 +51,7 @@ export function SetupChecklist({
|
|||||||
)}
|
)}
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Text fontSize="$xs" color={theme.muted} fontWeight="600">
|
<Text fontSize="$xs" color={theme.muted} fontWeight="600">
|
||||||
{completedCount}/{steps.length}
|
{completedCount}/{steps.length}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -89,7 +89,7 @@ export function SetupChecklist({
|
|||||||
backgroundColor={isNext ? theme.surfaceMuted : 'transparent'}
|
backgroundColor={isNext ? theme.surfaceMuted : 'transparent'}
|
||||||
onPress={() => navigate(adminPath(step.targetPath))}
|
onPress={() => navigate(adminPath(step.targetPath))}
|
||||||
title={
|
title={
|
||||||
<XStack alignItems="center" space="$2.5">
|
<XStack alignItems="center" gap="$2.5">
|
||||||
{step.isComplete ? (
|
{step.isComplete ? (
|
||||||
<CheckCircle2 size={18} color={theme.successText} />
|
<CheckCircle2 size={18} color={theme.successText} />
|
||||||
) : isNext ? (
|
) : isNext ? (
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function MobileSheet({
|
|||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
{...({ contentContainerStyle: { paddingBottom: 6 } } as any)}
|
{...({ contentContainerStyle: { paddingBottom: 6 } } as any)}
|
||||||
>
|
>
|
||||||
<YStack space={contentSpacing}>
|
<YStack gap={contentSpacing}>
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
|
|||||||
borderLeftWidth={1}
|
borderLeftWidth={1}
|
||||||
borderColor={theme.border}
|
borderColor={theme.border}
|
||||||
padding="$4"
|
padding="$4"
|
||||||
space="$3"
|
gap="$3"
|
||||||
style={{
|
style={{
|
||||||
transform: open ? 'translateX(0)' : 'translateX(100%)',
|
transform: open ? 'translateX(0)' : 'translateX(100%)',
|
||||||
transition: 'transform 220ms ease',
|
transition: 'transform 220ms ease',
|
||||||
@@ -133,7 +133,7 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<XStack alignItems="center" space="$3">
|
<XStack alignItems="center" gap="$3">
|
||||||
<XStack
|
<XStack
|
||||||
width={48}
|
width={48}
|
||||||
height={48}
|
height={48}
|
||||||
@@ -158,7 +158,7 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
|
|||||||
|
|
||||||
<Separator backgroundColor={theme.border} opacity={0.6} />
|
<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}>
|
<Text fontSize="$xs" fontWeight="700" color={theme.muted} textTransform="uppercase" letterSpacing={1}>
|
||||||
{t('mobileProfile.settings', 'Einstellungen')}
|
{t('mobileProfile.settings', 'Einstellungen')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -172,7 +172,7 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
|
|||||||
paddingHorizontal="$3"
|
paddingHorizontal="$3"
|
||||||
onPress={() => handleNavigate(item.path)}
|
onPress={() => handleNavigate(item.path)}
|
||||||
title={
|
title={
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<XStack
|
<XStack
|
||||||
width={28}
|
width={28}
|
||||||
height={28}
|
height={28}
|
||||||
@@ -197,7 +197,7 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
|
|||||||
</YGroup>
|
</YGroup>
|
||||||
</YStack>
|
</YStack>
|
||||||
|
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<Text fontSize="$xs" fontWeight="700" color={theme.muted} textTransform="uppercase" letterSpacing={1}>
|
<Text fontSize="$xs" fontWeight="700" color={theme.muted} textTransform="uppercase" letterSpacing={1}>
|
||||||
{t('settings.appearance.title', 'Darstellung')}
|
{t('settings.appearance.title', 'Darstellung')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -207,7 +207,7 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
|
|||||||
paddingVertical="$2"
|
paddingVertical="$2"
|
||||||
paddingHorizontal="$3"
|
paddingHorizontal="$3"
|
||||||
title={
|
title={
|
||||||
<XStack space="$2" alignItems="center">
|
<XStack gap="$2" alignItems="center">
|
||||||
<Text fontSize="$sm" color={theme.textStrong}>
|
<Text fontSize="$sm" color={theme.textStrong}>
|
||||||
{t('mobileProfile.language', 'Sprache')}
|
{t('mobileProfile.language', 'Sprache')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -235,7 +235,7 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
|
|||||||
paddingVertical="$2"
|
paddingVertical="$2"
|
||||||
paddingHorizontal="$3"
|
paddingHorizontal="$3"
|
||||||
title={
|
title={
|
||||||
<XStack space="$2" alignItems="center">
|
<XStack gap="$2" alignItems="center">
|
||||||
<Text fontSize="$sm" color={theme.textStrong}>
|
<Text fontSize="$sm" color={theme.textStrong}>
|
||||||
{t('mobileProfile.theme', 'Dark Mode')}
|
{t('mobileProfile.theme', 'Dark Mode')}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default function WelcomeEventPage() {
|
|||||||
onSkip={handleSkip}
|
onSkip={handleSkip}
|
||||||
skipLabel={t('layout.jumpToDashboard', 'Jump to dashboard')}
|
skipLabel={t('layout.jumpToDashboard', 'Jump to dashboard')}
|
||||||
>
|
>
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<Text fontSize="$sm" fontWeight="800">
|
<Text fontSize="$sm" fontWeight="800">
|
||||||
{t('eventSetup.step.title', 'Event setup in minutes')}
|
{t('eventSetup.step.title', 'Event setup in minutes')}
|
||||||
</Text>
|
</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.',
|
'We guide you through name, date, mood, and tasks. Afterwards you can moderate photos and support guests live.',
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<FeatureRow
|
<FeatureRow
|
||||||
icon={Sparkles}
|
icon={Sparkles}
|
||||||
title={t('eventSetup.tiles.story.title', 'Story & mood')}
|
title={t('eventSetup.tiles.story.title', 'Story & mood')}
|
||||||
@@ -79,7 +79,7 @@ export default function WelcomeEventPage() {
|
|||||||
</YStack>
|
</YStack>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$2">
|
<MobileCard gap="$2">
|
||||||
<Text fontSize="$sm" fontWeight="800">
|
<Text fontSize="$sm" fontWeight="800">
|
||||||
{t('eventSetup.cta.heading', 'Ready for your first event?')}
|
{t('eventSetup.cta.heading', 'Ready for your first event?')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -95,7 +95,7 @@ export default function WelcomeEventPage() {
|
|||||||
/>
|
/>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={t('eventSetup.actions.dashboard.button', 'Open dashboard')}
|
label={t('eventSetup.actions.dashboard.button', 'Open dashboard')}
|
||||||
tone="ghost"
|
tone="ghost"
|
||||||
@@ -121,7 +121,7 @@ function FeatureRow({
|
|||||||
body: string;
|
body: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<XStack
|
<XStack
|
||||||
width={34}
|
width={34}
|
||||||
height={34}
|
height={34}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default function WelcomeLandingPage() {
|
|||||||
onSkip={handleSkip}
|
onSkip={handleSkip}
|
||||||
skipLabel={t('layout.jumpToDashboard', 'Jump to dashboard')}
|
skipLabel={t('layout.jumpToDashboard', 'Jump to dashboard')}
|
||||||
>
|
>
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<PillBadge tone="muted">{t('hero.eyebrow', 'Your event, your stage')}</PillBadge>
|
<PillBadge tone="muted">{t('hero.eyebrow', 'Your event, your stage')}</PillBadge>
|
||||||
<Text fontSize="$lg" fontWeight="900">
|
<Text fontSize="$lg" fontWeight="900">
|
||||||
{t('hero.title', 'Design the next Fotospiel experience')}
|
{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.',
|
'In just a few steps you guide guests through a magical photo journey – complete with storytelling, tasks, and a moderated gallery.',
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack space="$2" flexWrap="wrap">
|
<XStack gap="$2" flexWrap="wrap">
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={
|
label={
|
||||||
shouldGoBilling
|
shouldGoBilling
|
||||||
@@ -80,7 +80,7 @@ export default function WelcomeLandingPage() {
|
|||||||
</XStack>
|
</XStack>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<YStack space="$3">
|
<YStack gap="$3">
|
||||||
<FeatureCard
|
<FeatureCard
|
||||||
icon={ImageIcon}
|
icon={ImageIcon}
|
||||||
title={t('highlights.gallery.title', 'Premium guest gallery')}
|
title={t('highlights.gallery.title', 'Premium guest gallery')}
|
||||||
@@ -117,9 +117,9 @@ function FeatureCard({
|
|||||||
badge?: string;
|
badge?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<MobileCard space="$2">
|
<MobileCard gap="$2">
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<XStack
|
<XStack
|
||||||
width={36}
|
width={36}
|
||||||
height={36}
|
height={36}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export default function WelcomePackagesPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
) : (
|
) : (
|
||||||
<YStack space="$3">
|
<YStack gap="$3">
|
||||||
{packages?.map((pkg) => (
|
{packages?.map((pkg) => (
|
||||||
<PackageCard
|
<PackageCard
|
||||||
key={pkg.id}
|
key={pkg.id}
|
||||||
@@ -97,7 +97,7 @@ export default function WelcomePackagesPage() {
|
|||||||
</YStack>
|
</YStack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MobileCard space="$2">
|
<MobileCard gap="$2">
|
||||||
<Text fontSize="$sm" fontWeight="800">
|
<Text fontSize="$sm" fontWeight="800">
|
||||||
{t('packages.step.title', 'Activate the right plan')}
|
{t('packages.step.title', 'Activate the right plan')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -106,7 +106,7 @@ export default function WelcomePackagesPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<XStack space="$2">
|
<XStack gap="$2">
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={t('packages.cta.summary.button', 'Continue to summary')}
|
label={t('packages.cta.summary.button', 'Continue to summary')}
|
||||||
onPress={() => navigate(ADMIN_WELCOME_SUMMARY_PATH)}
|
onPress={() => navigate(ADMIN_WELCOME_SUMMARY_PATH)}
|
||||||
@@ -143,9 +143,9 @@ function PackageCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={onSelect}>
|
<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" 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">
|
<XStack width={36} height={36} borderRadius={12} backgroundColor={accentSoft} alignItems="center" justifyContent="center">
|
||||||
<PackageIcon size={18} color={primary} />
|
<PackageIcon size={18} color={primary} />
|
||||||
</XStack>
|
</XStack>
|
||||||
@@ -162,7 +162,7 @@ function PackageCard({
|
|||||||
{selected ? t('packages.card.selected', 'Selected') : t('packages.card.select', 'Select package')}
|
{selected ? t('packages.card.selected', 'Selected') : t('packages.card.select', 'Select package')}
|
||||||
</PillBadge>
|
</PillBadge>
|
||||||
</XStack>
|
</XStack>
|
||||||
<XStack flexWrap="wrap" space="$2">
|
<XStack flexWrap="wrap" gap="$2">
|
||||||
{badges.map((badge) => (
|
{badges.map((badge) => (
|
||||||
<PillBadge key={badge as any} tone="muted">
|
<PillBadge key={badge as any} tone="muted">
|
||||||
{badge as any}
|
{badge as any}
|
||||||
@@ -170,7 +170,7 @@ function PackageCard({
|
|||||||
))}
|
))}
|
||||||
</XStack>
|
</XStack>
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<XStack alignItems="center" space="$1">
|
<XStack alignItems="center" gap="$1">
|
||||||
<Check size={14} color={primary} />
|
<Check size={14} color={primary} />
|
||||||
<Text fontSize="$xs" color={primary} fontWeight="700">
|
<Text fontSize="$xs" color={primary} fontWeight="700">
|
||||||
{t('packages.card.selected', 'Selected')}
|
{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)} />
|
<CTAButton label={t('summary.footer.back', 'Back to package selection')} onPress={() => navigate(ADMIN_WELCOME_PACKAGES_PATH)} />
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
) : (
|
) : (
|
||||||
<MobileCard space="$3">
|
<MobileCard gap="$3">
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<XStack
|
<XStack
|
||||||
width={36}
|
width={36}
|
||||||
height={36}
|
height={36}
|
||||||
@@ -123,7 +123,7 @@ export default function WelcomeSummaryPage() {
|
|||||||
</PillBadge>
|
</PillBadge>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<YStack space="$2">
|
<YStack gap="$2">
|
||||||
<SummaryRow
|
<SummaryRow
|
||||||
label={t('summary.details.section.photosTitle', 'Photos & gallery')}
|
label={t('summary.details.section.photosTitle', 'Photos & gallery')}
|
||||||
value={t('summary.details.section.photosValue', {
|
value={t('summary.details.section.photosValue', {
|
||||||
@@ -148,7 +148,7 @@ export default function WelcomeSummaryPage() {
|
|||||||
</YStack>
|
</YStack>
|
||||||
|
|
||||||
{resolvedPackage.active ? (
|
{resolvedPackage.active ? (
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<CheckCircle2 size={18} color={ADMIN_COLORS.success} />
|
<CheckCircle2 size={18} color={ADMIN_COLORS.success} />
|
||||||
<Text fontSize="$sm" color={ADMIN_COLORS.success} fontWeight="700">
|
<Text fontSize="$sm" color={ADMIN_COLORS.success} fontWeight="700">
|
||||||
{t('summary.details.section.statusActive', 'Already purchased')}
|
{t('summary.details.section.statusActive', 'Already purchased')}
|
||||||
@@ -158,11 +158,11 @@ export default function WelcomeSummaryPage() {
|
|||||||
</MobileCard>
|
</MobileCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MobileCard space="$2">
|
<MobileCard gap="$2">
|
||||||
<Text fontSize="$sm" fontWeight="800">
|
<Text fontSize="$sm" fontWeight="800">
|
||||||
{t('summary.nextStepsTitle', 'Next steps')}
|
{t('summary.nextStepsTitle', 'Next steps')}
|
||||||
</Text>
|
</Text>
|
||||||
<YStack space="$1">
|
<YStack gap="$1">
|
||||||
{(t('summary.nextSteps', {
|
{(t('summary.nextSteps', {
|
||||||
returnObjects: true,
|
returnObjects: true,
|
||||||
defaultValue: [
|
defaultValue: [
|
||||||
@@ -171,7 +171,7 @@ export default function WelcomeSummaryPage() {
|
|||||||
'Check your event slots before go-live and share your guest link.',
|
'Check your event slots before go-live and share your guest link.',
|
||||||
],
|
],
|
||||||
}) as string[]).map((item) => (
|
}) as string[]).map((item) => (
|
||||||
<XStack key={item} space="$2">
|
<XStack key={item} gap="$2">
|
||||||
<Text fontSize="$xs" color={ADMIN_COLORS.textMuted}>
|
<Text fontSize="$xs" color={ADMIN_COLORS.textMuted}>
|
||||||
•
|
•
|
||||||
</Text>
|
</Text>
|
||||||
@@ -183,7 +183,7 @@ export default function WelcomeSummaryPage() {
|
|||||||
</YStack>
|
</YStack>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<XStack space="$2">
|
<XStack gap="$2">
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={t('summary.cta.billing.button', 'Go to billing')}
|
label={t('summary.cta.billing.button', 'Go to billing')}
|
||||||
tone="ghost"
|
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