Aufgabenkarten in der Gäste-pwa als swipe-barer Stapel umgesetzt. Sofortiges Freigeben von Foto-Uploads als Event-Einstellung implementiert.

This commit is contained in:
Codex Agent
2025-12-16 15:30:52 +01:00
parent f2473c6f6d
commit 9e4e9a0d87
19 changed files with 22590 additions and 21687 deletions

View File

@@ -490,7 +490,7 @@ class EventPublicController extends BaseController
])
->orderByRaw('COALESCE(event_task_collection.sort_order, event_task.sort_order, tasks.sort_order, 0)')
->orderBy('tasks.sort_order')
->limit(20)
->distinct('tasks.id')
->get();
$tasks = $rows->map(function ($row) use ($fallbacks) {
@@ -659,6 +659,22 @@ class EventPublicController extends BaseController
return $trimmed;
}
private function buildDeterministicSeed(?string $identifier): int
{
if ($identifier === null || trim($identifier) === '') {
return random_int(1, PHP_INT_MAX);
}
$hash = substr(sha1($identifier), 0, 8);
$seed = hexdec($hash);
if (! is_numeric($seed) || $seed <= 0) {
$seed = abs(crc32($identifier));
}
return max(1, (int) $seed);
}
private function buildAchievementsPayload(int $eventId, ?string $guestIdentifier, array $fallbacks): array
{
$totalPhotos = (int) DB::table('photos')->where('event_id', $eventId)->count();
@@ -1757,6 +1773,7 @@ class EventPublicController extends BaseController
'join_token' => $joinToken?->token,
'photobooth_enabled' => (bool) $event->photobooth_enabled,
'branding' => $branding,
'guest_upload_visibility' => Arr::get($event->settings ?? [], 'guest_upload_visibility', 'review'),
])->header('Cache-Control', 'no-store');
}
@@ -2443,7 +2460,27 @@ class EventPublicController extends BaseController
});
$tasks = $cached['tasks'];
$etag = $cached['hash'] ?? sha1(json_encode($tasks));
$baseHash = $cached['hash'] ?? sha1(json_encode($tasks));
$page = max(1, (int) $request->query('page', 1));
$perPage = max(1, min(100, (int) $request->query('per_page', 20)));
// Shuffle per request for unpredictability; stable when seeded by guest/device or explicit seed.
$seedParam = $request->query('seed');
$guestIdentifier = $this->determineGuestIdentifier($request);
$seedValue = is_numeric($seedParam)
? (int) $seedParam
: $this->buildDeterministicSeed(($guestIdentifier ? $guestIdentifier.'|' : '').(string) $event->id);
$randomizer = new \Random\Randomizer(new \Random\Engine\Mt19937($seedValue));
$shuffled = $randomizer->shuffleArray($tasks);
$total = count($shuffled);
$offset = ($page - 1) * $perPage;
$data = array_slice($shuffled, $offset, $perPage);
$lastPage = (int) ceil($total / $perPage);
$hasMore = $page < $lastPage;
$etag = sha1($baseHash . ':' . $page . ':' . $perPage . ':' . $seedValue);
$reqEtag = $request->headers->get('If-None-Match');
if ($reqEtag && $reqEtag === $etag) {
@@ -2454,7 +2491,19 @@ class EventPublicController extends BaseController
->header('ETag', $etag);
}
return response()->json($tasks)
$payload = [
'data' => $data,
'meta' => [
'total' => $total,
'per_page' => $perPage,
'current_page' => $page,
'last_page' => $lastPage,
'has_more' => $hasMore,
'seed' => $seedValue,
],
];
return response()->json($payload)
->header('Cache-Control', 'public, max-age=120')
->header('ETag', $etag)
->header('Vary', 'Accept-Language, X-Locale')
@@ -2629,6 +2678,8 @@ class EventPublicController extends BaseController
'eventPackages.package',
'storageAssignments.storageTarget',
])->findOrFail($eventId);
$uploadVisibility = Arr::get($eventModel->settings ?? [], 'guest_upload_visibility', 'review');
$autoApproveUploads = $uploadVisibility === 'immediate';
$tenantModel = $eventModel->tenant;
@@ -2744,7 +2795,7 @@ class EventPublicController extends BaseController
'thumbnail_path' => $thumbUrl,
'likes_count' => 0,
'ingest_source' => Photo::SOURCE_GUEST_PWA,
'status' => 'pending',
'status' => $autoApproveUploads ? 'approved' : 'pending',
// Handle emotion_id: prefer explicit ID, fallback to slug lookup, then default
'emotion_id' => $this->resolveEmotionId($validated, $eventId),
@@ -2827,10 +2878,14 @@ class EventPublicController extends BaseController
$this->packageUsageTracker->recordPhotoUsage($eventPackage, $previousUsed, 1);
}
$message = $autoApproveUploads
? 'Photo uploaded and visible.'
: 'Photo uploaded and pending review.';
$response = response()->json([
'id' => $photoId,
'status' => 'pending',
'message' => 'Photo uploaded and pending review.',
'status' => $autoApproveUploads ? 'approved' : 'pending',
'message' => $message,
], 201);
$this->recordTokenEvent(

View File

@@ -45,6 +45,7 @@ class EventStoreRequest extends FormRequest
'settings.branding' => ['nullable', 'array'],
'settings.branding.*' => ['nullable'],
'settings.engagement_mode' => ['nullable', Rule::in(['tasks', 'photo_only'])],
'settings.guest_upload_visibility' => ['nullable', Rule::in(['review', 'immediate'])],
];
}

View File

@@ -0,0 +1,148 @@
'use client';
import React from "react"
import { motion } from "framer-motion"
import { cn } from "@repo/shadcn-ui/lib/utils"
export interface BackgroundBeamsProps {
className?: string;
}
export const BackgroundBeams = React.memo(
({ className }: BackgroundBeamsProps) => {
const paths = [
"M-380 -189C-380 -189 -312 216 152 343C616 470 684 875 684 875",
"M-373 -197C-373 -197 -305 208 159 335C623 462 691 867 691 867",
"M-366 -205C-366 -205 -298 200 166 327C630 454 698 859 698 859",
"M-359 -213C-359 -213 -291 192 173 319C637 446 705 851 705 851",
"M-352 -221C-352 -221 -284 184 180 311C644 438 712 843 712 843",
"M-345 -229C-345 -229 -277 176 187 303C651 430 719 835 719 835",
"M-338 -237C-338 -237 -270 168 194 295C658 422 726 827 726 827",
"M-331 -245C-331 -245 -263 160 201 287C665 414 733 819 733 819",
"M-324 -253C-324 -253 -256 152 208 279C672 406 740 811 740 811",
"M-317 -261C-317 -261 -249 144 215 271C679 398 747 803 747 803",
"M-310 -269C-310 -269 -242 136 222 263C686 390 754 795 754 795",
"M-303 -277C-303 -277 -235 128 229 255C693 382 761 787 761 787",
"M-296 -285C-296 -285 -228 120 236 247C700 374 768 779 768 779",
"M-289 -293C-289 -293 -221 112 243 239C707 366 775 771 775 771",
"M-282 -301C-282 -301 -214 104 250 231C714 358 782 763 782 763",
"M-275 -309C-275 -309 -207 96 257 223C721 350 789 755 789 755",
"M-268 -317C-268 -317 -200 88 264 215C728 342 796 747 796 747",
"M-261 -325C-261 -325 -193 80 271 207C735 334 803 739 803 739",
"M-254 -333C-254 -333 -186 72 278 199C742 326 810 731 810 731",
"M-247 -341C-247 -341 -179 64 285 191C749 318 817 723 817 723",
"M-240 -349C-240 -349 -172 56 292 183C756 310 824 715 824 715",
"M-233 -357C-233 -357 -165 48 299 175C763 302 831 707 831 707",
"M-226 -365C-226 -365 -158 40 306 167C770 294 838 699 838 699",
"M-219 -373C-219 -373 -151 32 313 159C777 286 845 691 845 691",
"M-212 -381C-212 -381 -144 24 320 151C784 278 852 683 852 683",
"M-205 -389C-205 -389 -137 16 327 143C791 270 859 675 859 675",
"M-198 -397C-198 -397 -130 8 334 135C798 262 866 667 866 667",
"M-191 -405C-191 -405 -123 0 341 127C805 254 873 659 873 659",
"M-184 -413C-184 -413 -116 -8 348 119C812 246 880 651 880 651",
"M-177 -421C-177 -421 -109 -16 355 111C819 238 887 643 887 643",
"M-170 -429C-170 -429 -102 -24 362 103C826 230 894 635 894 635",
"M-163 -437C-163 -437 -95 -32 369 95C833 222 901 627 901 627",
"M-156 -445C-156 -445 -88 -40 376 87C840 214 908 619 908 619",
"M-149 -453C-149 -453 -81 -48 383 79C847 206 915 611 915 611",
"M-142 -461C-142 -461 -74 -56 390 71C854 198 922 603 922 603",
"M-135 -469C-135 -469 -67 -64 397 63C861 190 929 595 929 595",
"M-128 -477C-128 -477 -60 -72 404 55C868 182 936 587 936 587",
"M-121 -485C-121 -485 -53 -80 411 47C875 174 943 579 943 579",
"M-114 -493C-114 -493 -46 -88 418 39C882 166 950 571 950 571",
"M-107 -501C-107 -501 -39 -96 425 31C889 158 957 563 957 563",
"M-100 -509C-100 -509 -32 -104 432 23C896 150 964 555 964 555",
"M-93 -517C-93 -517 -25 -112 439 15C903 142 971 547 971 547",
"M-86 -525C-86 -525 -18 -120 446 7C910 134 978 539 978 539",
"M-79 -533C-79 -533 -11 -128 453 -1C917 126 985 531 985 531",
"M-72 -541C-72 -541 -4 -136 460 -9C924 118 992 523 992 523",
"M-65 -549C-65 -549 3 -144 467 -17C931 110 999 515 999 515",
"M-58 -557C-58 -557 10 -152 474 -25C938 102 1006 507 1006 507",
"M-51 -565C-51 -565 17 -160 481 -33C945 94 1013 499 1013 499",
"M-44 -573C-44 -573 24 -168 488 -41C952 86 1020 491 1020 491",
"M-37 -581C-37 -581 31 -176 495 -49C959 78 1027 483 1027 483",
]
return (
<div
className={cn(
"absolute h-full w-full inset-0 [mask-size:40px] [mask-repeat:no-repeat] flex items-center justify-center",
className,
)}
>
<svg
className="z-0 h-full w-full pointer-events-none absolute"
width="100%"
height="100%"
viewBox="0 0 696 316"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M-380 -189C-380 -189 -312 216 152 343C616 470 684 875 684 875M-373 -197C-373 -197 -305 208 159 335C623 462 691 867 691 867M-366 -205C-366 -205 -298 200 166 327C630 454 698 859 698 859M-359 -213C-359 -213 -291 192 173 319C637 446 705 851 705 851M-352 -221C-352 -221 -284 184 180 311C644 438 712 843 712 843M-345 -229C-345 -229 -277 176 187 303C651 430 719 835 719 835M-338 -237C-338 -237 -270 168 194 295C658 422 726 827 726 827M-331 -245C-331 -245 -263 160 201 287C665 414 733 819 733 819M-324 -253C-324 -253 -256 152 208 279C672 406 740 811 740 811M-317 -261C-317 -261 -249 144 215 271C679 398 747 803 747 803M-310 -269C-310 -269 -242 136 222 263C686 390 754 795 754 795M-303 -277C-303 -277 -235 128 229 255C693 382 761 787 761 787M-296 -285C-296 -285 -228 120 236 247C700 374 768 779 768 779M-289 -293C-289 -293 -221 112 243 239C707 366 775 771 775 771M-282 -301C-282 -301 -214 104 250 231C714 358 782 763 782 763M-275 -309C-275 -309 -207 96 257 223C721 350 789 755 789 755M-268 -317C-268 -317 -200 88 264 215C728 342 796 747 796 747M-261 -325C-261 -325 -193 80 271 207C735 334 803 739 803 739M-254 -333C-254 -333 -186 72 278 199C742 326 810 731 810 731M-247 -341C-247 -341 -179 64 285 191C749 318 817 723 817 723M-240 -349C-240 -349 -172 56 292 183C756 310 824 715 824 715M-233 -357C-233 -357 -165 48 299 175C763 302 831 707 831 707M-226 -365C-226 -365 -158 40 306 167C770 294 838 699 838 699M-219 -373C-219 -373 -151 32 313 159C777 286 845 691 845 691M-212 -381C-212 -381 -144 24 320 151C784 278 852 683 852 683M-205 -389C-205 -389 -137 16 327 143C791 270 859 675 859 675M-198 -397C-198 -397 -130 8 334 135C798 262 866 667 866 667M-191 -405C-191 -405 -123 0 341 127C805 254 873 659 873 659M-184 -413C-184 -413 -116 -8 348 119C812 246 880 651 880 651M-177 -421C-177 -421 -109 -16 355 111C819 238 887 643 887 643M-170 -429C-170 -429 -102 -24 362 103C826 230 894 635 894 635M-163 -437C-163 -437 -95 -32 369 95C833 222 901 627 901 627M-156 -445C-156 -445 -88 -40 376 87C840 214 908 619 908 619M-149 -453C-149 -453 -81 -48 383 79C847 206 915 611 915 611M-142 -461C-142 -461 -74 -56 390 71C854 198 922 603 922 603M-135 -469C-135 -469 -67 -64 397 63C861 190 929 595 929 595M-128 -477C-128 -477 -60 -72 404 55C868 182 936 587 936 587M-121 -485C-121 -485 -53 -80 411 47C875 174 943 579 943 579M-114 -493C-114 -493 -46 -88 418 39C882 166 950 571 950 571M-107 -501C-107 -501 -39 -96 425 31C889 158 957 563 957 563M-100 -509C-100 -509 -32 -104 432 23C896 150 964 555 964 555M-93 -517C-93 -517 -25 -112 439 15C903 142 971 547 971 547M-86 -525C-86 -525 -18 -120 446 7C910 134 978 539 978 539M-79 -533C-79 -533 -11 -128 453 -1C917 126 985 531 985 531M-72 -541C-72 -541 -4 -136 460 -9C924 118 992 523 992 523M-65 -549C-65 -549 3 -144 467 -17C931 110 999 515 999 515M-58 -557C-58 -557 10 -152 474 -25C938 102 1006 507 1006 507M-51 -565C-51 -565 17 -160 481 -33C945 94 1013 499 1013 499M-44 -573C-44 -573 24 -168 488 -41C952 86 1020 491 1020 491M-37 -581C-37 -581 31 -176 495 -49C959 78 1027 483 1027 483M-30 -589C-30 -589 38 -184 502 -57C966 70 1034 475 1034 475M-23 -597C-23 -597 45 -192 509 -65C973 62 1041 467 1041 467M-16 -605C-16 -605 52 -200 516 -73C980 54 1048 459 1048 459M-9 -613C-9 -613 59 -208 523 -81C987 46 1055 451 1055 451M-2 -621C-2 -621 66 -216 530 -89C994 38 1062 443 1062 443M5 -629C5 -629 73 -224 537 -97C1001 30 1069 435 1069 435M12 -637C12 -637 80 -232 544 -105C1008 22 1076 427 1076 427M19 -645C19 -645 87 -240 551 -113C1015 14 1083 419 1083 419"
stroke="url(#paint0_radial_242_278)"
strokeOpacity="0.05"
strokeWidth="0.5"
/>
{paths.map((path, index) => (
<motion.path
key={`path-${index}`}
d={path}
stroke={`url(#linearGradient-${index})`}
strokeOpacity="0.4"
strokeWidth="0.5"
/>
))}
<defs>
{paths.map((path, index) => (
<motion.linearGradient
id={`linearGradient-${index}`}
key={`gradient-${index}`}
initial={{
x1: "0%",
x2: "0%",
y1: "0%",
y2: "0%",
}}
animate={{
x1: ["0%", "100%"],
x2: ["0%", "95%"],
y1: ["0%", "100%"],
y2: ["0%", `${93 + Math.random() * 8}%`],
}}
transition={{
duration: Math.random() * 10 + 10,
ease: "easeInOut",
repeat: Infinity,
delay: Math.random() * 10,
}}
>
<stop stopColor="#18CCFC" stopOpacity="0" />
<stop stopColor="#18CCFC" />
<stop offset="32.5%" stopColor="#6344F5" />
<stop offset="100%" stopColor="#AE48FF" stopOpacity="0" />
</motion.linearGradient>
))}
<radialGradient
id="paint0_radial_242_278"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(352 34) rotate(90) scale(555 1560.62)"
>
<stop offset="0.0666667" stopColor="var(--neutral-300)" />
<stop offset="0.243243" stopColor="var(--neutral-300)" />
<stop offset="0.43594" stopColor="white" stopOpacity="0" />
</radialGradient>
</defs>
</svg>
</div>
)
},
)
BackgroundBeams.displayName = "BackgroundBeams"

42292
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,122 +1,132 @@
{
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"build:ssr": "vite build && vite build --ssr",
"dev": "vite",
"extract:i18n": "i18next-scanner",
"format": "prettier --write resources/",
"format:check": "prettier --check resources/",
"lint": "eslint . --fix",
"types": "tsc --noEmit",
"test:e2e": "npm run test:ui",
"test:ui": "playwright test",
"test:ui:purchase": "playwright test --project=purchase",
"test:ui:auth": "playwright test --project=auth",
"test:ui:admin": "playwright test --project=admin",
"test:ui:guest": "playwright test --project=guest",
"test:unit": "vitest run"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@laravel/vite-plugin-wayfinder": "^0.1.7",
"@playwright/test": "^1.57.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/fabric": "^5.3.10",
"@types/node": "^22.19.2",
"baseline-browser-mapping": "^2.9.6",
"dotenv": "^16.6.1",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"i18next-scanner": "^4.6.0",
"jsdom": "^25.0.1",
"playwright": "^1.55.1",
"prettier": "^3.7.4",
"shadcn": "^3.5.2",
"typescript-eslint": "^8.49.0",
"vitest": "^2.1.9"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@headlessui/react": "^2.2.9",
"@inertiajs/react": "^2.2.21",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-spring/web": "^10.0.3",
"@stripe/stripe-js": "^8.5.3",
"@tailwindcss/vite": "^4.1.17",
"@tamagui/button": "~1.139.2",
"@tamagui/config": "~1.139.2",
"@tamagui/font": "~1.139.3",
"@tamagui/group": "~1.139.2",
"@tamagui/list-item": "~1.139.2",
"@tamagui/radio-group": "~1.139.2",
"@tamagui/stacks": "~1.139.2",
"@tamagui/switch": "~1.139.2",
"@tamagui/text": "~1.139.2",
"@tamagui/themes": "~1.139.2",
"@tamagui/vite-plugin": "~1.139.2",
"@tanstack/react-query": "^5.90.12",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@use-gesture/react": "^10.3.1",
"@vitejs/plugin-react": "^4.7.0",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"embla-carousel": "^8.6.0",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"fabric": "^6.9.0",
"globals": "^15.15.0",
"html5-qrcode": "^2.3.8",
"i18next": "^25.7.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"laravel-vite-plugin": "^2.0.1",
"lucide-react": "^0.475.0",
"pdf-lib": "^1.17.1",
"react": "^19.2.1",
"react-colorful": "^5.6.1",
"react-dom": "^19.2.1",
"react-hot-toast": "^2.6.0",
"react-i18next": "^16.4.1",
"react-router-dom": "^7.10.1",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.0.0",
"tailwindcss-animate": "^1.0.7",
"tamagui": "^1.139.3",
"typescript": "^5.9.3",
"vite": "^7.2.7"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "4.9.5",
"@tailwindcss/oxide-linux-x64-gnu": "^4.0.1",
"lightningcss-linux-x64-gnu": "^1.29.1"
},
"resolutions": {
"react-native-web": "^0.19.12"
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"build:ssr": "vite build && vite build --ssr",
"dev": "vite",
"extract:i18n": "i18next-scanner",
"format": "prettier --write resources/",
"format:check": "prettier --check resources/",
"lint": "eslint . --fix",
"types": "tsc --noEmit",
"test:e2e": "npm run test:ui",
"test:ui": "playwright test",
"test:ui:purchase": "playwright test --project=purchase",
"test:ui:auth": "playwright test --project=auth",
"test:ui:admin": "playwright test --project=admin",
"test:ui:guest": "playwright test --project=guest",
"test:unit": "vitest run"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@laravel/vite-plugin-wayfinder": "^0.1.7",
"@playwright/test": "^1.57.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/fabric": "^5.3.10",
"@types/node": "^22.19.2",
"baseline-browser-mapping": "^2.9.6",
"dotenv": "^16.6.1",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"i18next-scanner": "^4.6.0",
"jsdom": "^25.0.1",
"playwright": "^1.55.1",
"prettier": "^3.7.4",
"shadcn": "^3.5.2",
"typescript-eslint": "^8.49.0",
"vitest": "^2.1.9"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@headlessui/react": "^2.2.9",
"@inertiajs/react": "^2.2.21",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-spring/web": "^10.0.3",
"@stripe/stripe-js": "^8.5.3",
"@tailwindcss/vite": "^4.1.17",
"@tamagui/button": "~1.139.2",
"@tamagui/config": "~1.139.2",
"@tamagui/font": "~1.139.3",
"@tamagui/group": "~1.139.2",
"@tamagui/list-item": "~1.139.2",
"@tamagui/radio-group": "~1.139.2",
"@tamagui/stacks": "~1.139.2",
"@tamagui/switch": "~1.139.2",
"@tamagui/text": "~1.139.2",
"@tamagui/themes": "~1.139.2",
"@tamagui/vite-plugin": "~1.139.2",
"@tanstack/react-query": "^5.90.12",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@use-gesture/react": "^10.3.1",
"@vitejs/plugin-react": "^4.7.0",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"embla-carousel": "^8.6.0",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"fabric": "^6.9.0",
"framer-motion": "^12.23.26",
"globals": "^15.15.0",
"html5-qrcode": "^2.3.8",
"i18next": "^25.7.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"laravel-vite-plugin": "^2.0.1",
"lucide-react": "^0.475.0",
"pdf-lib": "^1.17.1",
"react": "^19.2.1",
"react-colorful": "^5.6.1",
"react-dom": "^19.2.1",
"react-hot-toast": "^2.6.0",
"react-i18next": "^16.4.1",
"react-router-dom": "^7.10.1",
"swiper": "^12.0.3",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.0.0",
"tailwindcss-animate": "^1.0.7",
"tamagui": "^1.139.3",
"typescript": "^5.9.3",
"vite": "^7.2.7"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "4.9.5",
"@tailwindcss/oxide-linux-x64-gnu": "^4.0.1",
"lightningcss-linux-x64-gnu": "^1.29.1"
},
"resolutions": {
"react-native-web": "^0.19.12"
},
"overrides": {
"react-tinder-card": {
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><rect width="100%" height="100%" fill="#e8e8f2"/><path fill="#ecc94b" d="m23.222 25.097-3.266-2.056-3.219 2.058.983-3.847-3.042-2.503 3.936-.18 1.52-3.668 1.342 3.578 3.846.312-2.996 2.505z"/><path fill="#f44034" d="m.133-5.1-1.52 3.668-3.935.18 3.043 2.504-.985 3.848 3.221-2.06 3.264 2.057-.895-3.803L5.322-1.21l-3.845-.312zm40 0-1.52 3.668-3.935.18 3.043 2.504-.985 3.848 3.221-2.059 3.264 2.057-.895-3.803 2.996-2.504-3.845-.312zm-40 40-1.52 3.668-3.935.18 3.043 2.504-.985 3.848 3.221-2.059 3.264 2.057-.895-3.803 2.996-2.504-3.845-.312zm40 0-1.52 3.668-3.935.18 3.043 2.504-.985 3.848 3.221-2.059 3.264 2.057-.895-3.803 2.996-2.504-3.845-.312z"/></svg>

After

Width:  |  Height:  |  Size: 721 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="56.915" height="30"><rect width="100%" height="100%" fill="#e1e1ea"/><path fill="#ee2f69" d="M10.023 0c1.263 1.051 2.418 2.246 3.592 3.462 1.874 1.944 3.808 3.938 6.287 5.404-.94.552-1.8 1.18-2.606 1.856-.844-.785-1.66-1.625-2.452-2.444C11.22 4.525 7.476.646 0 .645v1.71c6.752.001 10.089 3.451 13.615 7.107.771.8 1.568 1.619 2.397 2.401a62 62 0 0 0-1.785 1.776C10.785 10.099 7.056 6.646 0 6.645v1.708c6.38.002 9.706 3.085 13.038 6.513a51 51 0 0 1-1.878 1.86C8.773 14.73 5.373 12.646 0 12.646v1.707c4.679.001 7.63 1.687 9.86 3.514-.97.793-2.009 1.5-3.173 2.066C4.652 19.07 2.46 18.646 0 18.646v1.706c1.494 0 2.872.171 4.17.512-1.24.332-2.61.517-4.17.517v1.71c7.477-.001 11.22-3.881 14.842-7.63 3.527-3.654 6.864-7.106 13.615-7.106s10.084 3.452 13.612 7.106c3.622 3.75 7.363 7.63 14.842 7.63h.004v-1.71h-.006c-1.56 0-2.932-.186-4.171-.517 1.294-.34 2.675-.512 4.17-.512h.007v-1.706h-.004c-2.466 0-4.654.427-6.686 1.287-1.164-.567-2.206-1.273-3.175-2.066 2.23-1.827 5.182-3.514 9.86-3.514h.005v-1.708h-.004c-5.375 0-8.777 2.084-11.16 4.081a50 50 0 0 1-1.88-1.86c3.33-3.425 6.657-6.513 13.04-6.513h.004V6.647h-.004c-7.052 0-10.785 3.449-14.23 6.99a54 54 0 0 0-1.786-1.774 73 73 0 0 0 2.397-2.4c3.528-3.658 6.864-7.108 13.619-7.108h.004V.645c-7.479 0-11.225 3.88-14.848 7.633-.793.819-1.606 1.66-2.45 2.444a19.4 19.4 0 0 0-2.612-1.86c2.482-1.461 4.415-3.46 6.293-5.404C44.472 2.243 45.628 1.051 46.89 0h-2.564a56 56 0 0 0-1.644 1.638A57 57 0 0 0 41.04 0h-2.563c1.058.878 2.037 1.854 3.017 2.865a57 57 0 0 1-1.877 1.864C37.23 2.732 33.83.647 28.457.647c-5.375 0-8.776 2.085-11.163 4.082a58 58 0 0 1-1.879-1.864c.98-1.01 1.957-1.988 3.016-2.865H15.87a56 56 0 0 0-1.642 1.638A58 58 0 0 0 12.583 0zm18.432 2.355c4.678 0 7.63 1.684 9.86 3.511-.967.79-2.003 1.49-3.167 2.061-1.871-.796-4.05-1.281-6.693-1.282-2.65 0-4.825.486-6.696 1.282-1.164-.567-2.198-1.272-3.165-2.057 2.23-1.83 5.18-3.515 9.861-3.515m.002 10.29c-7.479 0-11.224 3.879-14.847 7.628-2.134 2.213-4.16 4.306-6.916 5.651a15.8 15.8 0 0 0-3.792-1.063l-.134-.022q-.406-.061-.827-.101l-.143-.011a31 31 0 0 0-.703-.052l-.234-.009A17 17 0 0 0 0 24.644v1.708q.393.001.775.019l.211.01q.318.018.636.045c.041.004.089.005.13.009q.374.036.737.088.07.014.143.024.333.05.655.116l.083.014q.37.079.735.171.027.009.053.017.753.197 1.466.475h.007a13.4 13.4 0 0 1 1.789.847h.004c.864.484 1.71 1.079 2.591 1.813h2.568q-.072-.068-.141-.136c.833-.782 1.624-1.603 2.396-2.402 3.531-3.657 6.868-7.108 13.62-7.108 6.75 0 10.083 3.453 13.61 7.106a70 70 0 0 0 2.401 2.408q-.074.067-.141.132h2.562c2.534-2.11 5.516-3.646 10.02-3.646h.005v-1.71h-.002c-2.646 0-4.825.489-6.697 1.28-2.756-1.349-4.781-3.438-6.918-5.651-3.62-3.752-7.366-7.628-14.84-7.628zm-.002 1.708c6.751 0 10.084 3.453 13.616 7.107 1.875 1.942 3.806 3.94 6.288 5.405-.938.554-1.8 1.182-2.608 1.86-.847-.788-1.664-1.632-2.455-2.452-3.62-3.749-7.366-7.63-14.84-7.63-7.478 0-11.225 3.881-14.845 7.63a62 62 0 0 1-2.455 2.449 19.3 19.3 0 0 0-2.606-1.857c2.478-1.465 4.411-3.46 6.287-5.404 3.53-3.657 6.864-7.108 13.618-7.108m-.001 10.291c-5.953 0-9.538 2.46-12.581 5.356h2.556c2.534-2.11 5.52-3.648 10.027-3.648 4.504 0 7.485 1.538 10.018 3.648h2.56c-3.038-2.895-6.628-5.356-12.58-5.356"/></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -547,3 +547,17 @@ h4,
overscroll-behavior: contain;
background-color: #000;
}
@theme inline {
--animate-spin:
spin 2s linear infinite;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -77,7 +77,10 @@ export type TenantEvent = {
active_invites_count?: number;
total_invites_count?: number;
engagement_mode?: 'tasks' | 'photo_only';
settings?: Record<string, unknown> & { engagement_mode?: 'tasks' | 'photo_only' };
settings?: Record<string, unknown> & {
engagement_mode?: 'tasks' | 'photo_only';
guest_upload_visibility?: 'review' | 'immediate';
};
package?: {
id: number | string | null;
name: string | null;
@@ -1482,6 +1485,22 @@ export async function updatePhotoVisibility(slug: string, id: number, visible: b
return normalizePhoto(data.data);
}
export async function updatePhotoStatus(
slug: string,
id: number,
status: 'pending' | 'approved' | 'rejected'
): Promise<TenantPhoto> {
const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ status }),
});
const data = await jsonOrThrow<PhotoResponse>(response, 'Failed to update photo status');
return normalizePhoto(data.data);
}
export async function toggleEvent(slug: string): Promise<TenantEvent> {
const response = await authorizedFetch(`${eventEndpoint(slug)}/toggle`, { method: 'POST' });
const data = await jsonOrThrow<{ message: string; data: JsonValue }>(response, 'Failed to toggle event');

View File

@@ -19,6 +19,7 @@ type FormState = {
description: string;
location: string;
published: boolean;
autoApproveUploads: boolean;
};
export default function MobileEventFormPage() {
@@ -35,6 +36,7 @@ export default function MobileEventFormPage() {
description: '',
location: '',
published: false,
autoApproveUploads: true,
});
const [eventTypes, setEventTypes] = React.useState<TenantEventType[]>([]);
const [typesLoading, setTypesLoading] = React.useState(false);
@@ -55,6 +57,8 @@ export default function MobileEventFormPage() {
description: typeof data.description === 'string' ? data.description : '',
location: resolveLocation(data),
published: data.status === 'published',
autoApproveUploads:
(data.settings?.guest_upload_visibility as string | undefined) === 'immediate',
});
setError(null);
} catch (err) {
@@ -88,24 +92,30 @@ export default function MobileEventFormPage() {
async function handleSubmit() {
setSaving(true);
setError(null);
try {
if (isEdit && slug) {
await updateEvent(slug, {
name: form.name,
event_date: form.date || undefined,
event_type_id: form.eventTypeId ?? undefined,
status: form.published ? 'published' : 'draft',
settings: { location: form.location },
});
navigate(adminPath(`/mobile/events/${slug}`));
} else {
const payload = {
name: form.name || t('eventForm.fields.name.fallback', 'Event'),
try {
if (isEdit && slug) {
await updateEvent(slug, {
name: form.name,
event_date: form.date || undefined,
event_type_id: form.eventTypeId ?? undefined,
status: form.published ? 'published' : 'draft',
settings: {
location: form.location,
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
},
});
navigate(adminPath(`/mobile/events/${slug}`));
} else {
const payload = {
name: form.name || t('eventForm.fields.name.fallback', 'Event'),
slug: `${Date.now()}`,
event_type_id: form.eventTypeId ?? undefined,
event_date: form.date || undefined,
status: (form.published ? 'published' : 'draft') as const,
settings: { location: form.location },
settings: {
location: form.location,
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
},
};
const { event } = await createEvent(payload as any);
navigate(adminPath(`/mobile/events/${event.slug}`));
@@ -217,6 +227,37 @@ export default function MobileEventFormPage() {
</XStack>
<Text fontSize="$xs" color="#6b7280">{t('eventForm.fields.publish.help', 'Enable if guests should see the event right away. You can change the status later.')}</Text>
</Field>
<Field label={t('eventForm.fields.uploadVisibility.label', 'Uploads visible immediately')}>
<XStack alignItems="center" space="$2">
<Switch
checked={form.autoApproveUploads}
onCheckedChange={(checked) =>
setForm((prev) => ({ ...prev, autoApproveUploads: Boolean(checked) }))
}
size="$3"
aria-label={t('eventForm.fields.uploadVisibility.label', 'Uploads visible immediately')}
>
<Switch.Thumb />
</Switch>
<Text fontSize="$sm" color="#111827">
{form.autoApproveUploads
? t('common:states.enabled', 'Enabled')
: t('common:states.disabled', 'Disabled')}
</Text>
</XStack>
<Text fontSize="$xs" color="#6b7280">
{form.autoApproveUploads
? t(
'eventForm.fields.uploadVisibility.helpOn',
'Neue Gast-Uploads erscheinen sofort in der Galerie (Security-Scan läuft im Hintergrund).',
)
: t(
'eventForm.fields.uploadVisibility.helpOff',
'Uploads werden zunächst geprüft und erscheinen nach Freigabe.',
)}
</Text>
</Field>
</MobileCard>
<YStack space="$2">

View File

@@ -12,6 +12,7 @@ import {
updatePhotoVisibility,
featurePhoto,
unfeaturePhoto,
updatePhotoStatus,
TenantPhoto,
EventAddonCatalogItem,
createEventAddonCheckout,
@@ -30,7 +31,7 @@ import { buildLimitWarnings } from '../lib/limitWarnings';
import { adminPath } from '../constants';
import { scopeDefaults, selectAddonKeyForScope } from './addons';
type FilterKey = 'all' | 'featured' | 'hidden';
type FilterKey = 'all' | 'featured' | 'hidden' | 'pending';
export default function MobileEventPhotosPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
@@ -92,12 +93,18 @@ export default function MobileEventPhotosPage() {
setLoading(true);
setError(null);
try {
const status =
filter === 'hidden' || onlyHidden
? 'hidden'
: filter === 'pending'
? 'pending'
: undefined;
const result = await getEventPhotos(slug, {
page,
perPage: 20,
sort: 'desc',
featured: filter === 'featured' || onlyFeatured,
status: filter === 'hidden' || onlyHidden ? 'hidden' : undefined,
status,
search: search || undefined,
});
setPhotos((prev) => (page === 1 ? result.photos : [...prev, ...result.photos]));
@@ -184,6 +191,24 @@ export default function MobileEventPhotosPage() {
}
}
async function approvePhoto(photo: TenantPhoto) {
if (!slug) return;
setBusyId(photo.id);
try {
const updated = await updatePhotoStatus(slug, photo.id, 'approved');
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? updated : p)));
setLightbox((prev) => (prev && prev.id === photo.id ? updated : prev));
toast.success(t('mobilePhotos.approveSuccess', 'Photo approved'));
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('mobilePhotos.approveFailed', 'Freigabe fehlgeschlagen.')));
toast.error(t('mobilePhotos.approveFailed', 'Freigabe fehlgeschlagen.'));
}
} finally {
setBusyId(null);
}
}
return (
<MobileShell
activeTab="uploads"
@@ -219,8 +244,8 @@ export default function MobileEventPhotosPage() {
style={{ ...baseInputStyle, marginBottom: 12 }}
/>
<XStack space="$2">
{(['all', 'featured', 'hidden'] as FilterKey[]).map((key) => (
<XStack space="$2" flexWrap="wrap">
{(['all', 'featured', 'pending', 'hidden'] as FilterKey[]).map((key) => (
<Pressable key={key} onPress={() => setFilter(key)} style={{ flex: 1 }}>
<MobileCard
backgroundColor={filter === key ? infoBg : surface}
@@ -232,7 +257,9 @@ export default function MobileEventPhotosPage() {
? t('common.all', 'All')
: key === 'featured'
? t('photos.filters.featured', 'Featured')
: t('photos.filters.hidden', 'Hidden')}
: key === 'pending'
? t('photos.filters.pending', 'Pending')
: t('photos.filters.hidden', 'Hidden')}
</Text>
</MobileCard>
</Pressable>
@@ -283,6 +310,9 @@ export default function MobileEventPhotosPage() {
/>
<XStack position="absolute" top={6} left={6} space="$1">
{photo.is_featured ? <PillBadge tone="warning">{t('photos.filters.featured', 'Featured')}</PillBadge> : null}
{photo.status === 'pending' ? (
<PillBadge tone="warning">{t('photos.filters.pending', 'Pending')}</PillBadge>
) : null}
{photo.status === 'hidden' ? <PillBadge tone="muted">{t('photos.filters.hidden', 'Hidden')}</PillBadge> : null}
</XStack>
</YStack>
@@ -317,8 +347,25 @@ export default function MobileEventPhotosPage() {
<XStack space="$2" alignItems="center">
<PillBadge tone="muted">{lightbox.uploader_name || t('events.members.roles.guest', 'Guest')}</PillBadge>
<PillBadge tone="muted"> {lightbox.likes_count ?? 0}</PillBadge>
{lightbox.status === 'pending' ? (
<PillBadge tone="warning">{t('photos.filters.pending', 'Pending')}</PillBadge>
) : null}
{lightbox.status === 'hidden' ? (
<PillBadge tone="muted">{t('photos.filters.hidden', 'Hidden')}</PillBadge>
) : null}
</XStack>
<XStack space="$2" flexWrap="wrap">
{lightbox.status === 'pending' ? (
<CTAButton
label={
busyId === lightbox.id
? t('common.processing', '...')
: t('photos.actions.approve', 'Approve')
}
onPress={() => approvePhoto(lightbox)}
style={{ flex: 1, minWidth: 140 }}
/>
) : null}
<CTAButton
label={
busyId === lightbox.id

View File

@@ -0,0 +1,148 @@
'use client';
import React from "react"
import { motion } from "framer-motion"
import { cn } from "@/lib/utils"
export interface BackgroundBeamsProps {
className?: string;
}
export const BackgroundBeams = React.memo(
({ className }: BackgroundBeamsProps) => {
const paths = [
"M-380 -189C-380 -189 -312 216 152 343C616 470 684 875 684 875",
"M-373 -197C-373 -197 -305 208 159 335C623 462 691 867 691 867",
"M-366 -205C-366 -205 -298 200 166 327C630 454 698 859 698 859",
"M-359 -213C-359 -213 -291 192 173 319C637 446 705 851 705 851",
"M-352 -221C-352 -221 -284 184 180 311C644 438 712 843 712 843",
"M-345 -229C-345 -229 -277 176 187 303C651 430 719 835 719 835",
"M-338 -237C-338 -237 -270 168 194 295C658 422 726 827 726 827",
"M-331 -245C-331 -245 -263 160 201 287C665 414 733 819 733 819",
"M-324 -253C-324 -253 -256 152 208 279C672 406 740 811 740 811",
"M-317 -261C-317 -261 -249 144 215 271C679 398 747 803 747 803",
"M-310 -269C-310 -269 -242 136 222 263C686 390 754 795 754 795",
"M-303 -277C-303 -277 -235 128 229 255C693 382 761 787 761 787",
"M-296 -285C-296 -285 -228 120 236 247C700 374 768 779 768 779",
"M-289 -293C-289 -293 -221 112 243 239C707 366 775 771 775 771",
"M-282 -301C-282 -301 -214 104 250 231C714 358 782 763 782 763",
"M-275 -309C-275 -309 -207 96 257 223C721 350 789 755 789 755",
"M-268 -317C-268 -317 -200 88 264 215C728 342 796 747 796 747",
"M-261 -325C-261 -325 -193 80 271 207C735 334 803 739 803 739",
"M-254 -333C-254 -333 -186 72 278 199C742 326 810 731 810 731",
"M-247 -341C-247 -341 -179 64 285 191C749 318 817 723 817 723",
"M-240 -349C-240 -349 -172 56 292 183C756 310 824 715 824 715",
"M-233 -357C-233 -357 -165 48 299 175C763 302 831 707 831 707",
"M-226 -365C-226 -365 -158 40 306 167C770 294 838 699 838 699",
"M-219 -373C-219 -373 -151 32 313 159C777 286 845 691 845 691",
"M-212 -381C-212 -381 -144 24 320 151C784 278 852 683 852 683",
"M-205 -389C-205 -389 -137 16 327 143C791 270 859 675 859 675",
"M-198 -397C-198 -397 -130 8 334 135C798 262 866 667 866 667",
"M-191 -405C-191 -405 -123 0 341 127C805 254 873 659 873 659",
"M-184 -413C-184 -413 -116 -8 348 119C812 246 880 651 880 651",
"M-177 -421C-177 -421 -109 -16 355 111C819 238 887 643 887 643",
"M-170 -429C-170 -429 -102 -24 362 103C826 230 894 635 894 635",
"M-163 -437C-163 -437 -95 -32 369 95C833 222 901 627 901 627",
"M-156 -445C-156 -445 -88 -40 376 87C840 214 908 619 908 619",
"M-149 -453C-149 -453 -81 -48 383 79C847 206 915 611 915 611",
"M-142 -461C-142 -461 -74 -56 390 71C854 198 922 603 922 603",
"M-135 -469C-135 -469 -67 -64 397 63C861 190 929 595 929 595",
"M-128 -477C-128 -477 -60 -72 404 55C868 182 936 587 936 587",
"M-121 -485C-121 -485 -53 -80 411 47C875 174 943 579 943 579",
"M-114 -493C-114 -493 -46 -88 418 39C882 166 950 571 950 571",
"M-107 -501C-107 -501 -39 -96 425 31C889 158 957 563 957 563",
"M-100 -509C-100 -509 -32 -104 432 23C896 150 964 555 964 555",
"M-93 -517C-93 -517 -25 -112 439 15C903 142 971 547 971 547",
"M-86 -525C-86 -525 -18 -120 446 7C910 134 978 539 978 539",
"M-79 -533C-79 -533 -11 -128 453 -1C917 126 985 531 985 531",
"M-72 -541C-72 -541 -4 -136 460 -9C924 118 992 523 992 523",
"M-65 -549C-65 -549 3 -144 467 -17C931 110 999 515 999 515",
"M-58 -557C-58 -557 10 -152 474 -25C938 102 1006 507 1006 507",
"M-51 -565C-51 -565 17 -160 481 -33C945 94 1013 499 1013 499",
"M-44 -573C-44 -573 24 -168 488 -41C952 86 1020 491 1020 491",
"M-37 -581C-37 -581 31 -176 495 -49C959 78 1027 483 1027 483",
]
return (
<div
className={cn(
"absolute h-full w-full inset-0 [mask-size:40px] [mask-repeat:no-repeat] flex items-center justify-center",
className,
)}
>
<svg
className="z-0 h-full w-full pointer-events-none absolute"
width="100%"
height="100%"
viewBox="0 0 696 316"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M-380 -189C-380 -189 -312 216 152 343C616 470 684 875 684 875M-373 -197C-373 -197 -305 208 159 335C623 462 691 867 691 867M-366 -205C-366 -205 -298 200 166 327C630 454 698 859 698 859M-359 -213C-359 -213 -291 192 173 319C637 446 705 851 705 851M-352 -221C-352 -221 -284 184 180 311C644 438 712 843 712 843M-345 -229C-345 -229 -277 176 187 303C651 430 719 835 719 835M-338 -237C-338 -237 -270 168 194 295C658 422 726 827 726 827M-331 -245C-331 -245 -263 160 201 287C665 414 733 819 733 819M-324 -253C-324 -253 -256 152 208 279C672 406 740 811 740 811M-317 -261C-317 -261 -249 144 215 271C679 398 747 803 747 803M-310 -269C-310 -269 -242 136 222 263C686 390 754 795 754 795M-303 -277C-303 -277 -235 128 229 255C693 382 761 787 761 787M-296 -285C-296 -285 -228 120 236 247C700 374 768 779 768 779M-289 -293C-289 -293 -221 112 243 239C707 366 775 771 775 771M-282 -301C-282 -301 -214 104 250 231C714 358 782 763 782 763M-275 -309C-275 -309 -207 96 257 223C721 350 789 755 789 755M-268 -317C-268 -317 -200 88 264 215C728 342 796 747 796 747M-261 -325C-261 -325 -193 80 271 207C735 334 803 739 803 739M-254 -333C-254 -333 -186 72 278 199C742 326 810 731 810 731M-247 -341C-247 -341 -179 64 285 191C749 318 817 723 817 723M-240 -349C-240 -349 -172 56 292 183C756 310 824 715 824 715M-233 -357C-233 -357 -165 48 299 175C763 302 831 707 831 707M-226 -365C-226 -365 -158 40 306 167C770 294 838 699 838 699M-219 -373C-219 -373 -151 32 313 159C777 286 845 691 845 691M-212 -381C-212 -381 -144 24 320 151C784 278 852 683 852 683M-205 -389C-205 -389 -137 16 327 143C791 270 859 675 859 675M-198 -397C-198 -397 -130 8 334 135C798 262 866 667 866 667M-191 -405C-191 -405 -123 0 341 127C805 254 873 659 873 659M-184 -413C-184 -413 -116 -8 348 119C812 246 880 651 880 651M-177 -421C-177 -421 -109 -16 355 111C819 238 887 643 887 643M-170 -429C-170 -429 -102 -24 362 103C826 230 894 635 894 635M-163 -437C-163 -437 -95 -32 369 95C833 222 901 627 901 627M-156 -445C-156 -445 -88 -40 376 87C840 214 908 619 908 619M-149 -453C-149 -453 -81 -48 383 79C847 206 915 611 915 611M-142 -461C-142 -461 -74 -56 390 71C854 198 922 603 922 603M-135 -469C-135 -469 -67 -64 397 63C861 190 929 595 929 595M-128 -477C-128 -477 -60 -72 404 55C868 182 936 587 936 587M-121 -485C-121 -485 -53 -80 411 47C875 174 943 579 943 579M-114 -493C-114 -493 -46 -88 418 39C882 166 950 571 950 571M-107 -501C-107 -501 -39 -96 425 31C889 158 957 563 957 563M-100 -509C-100 -509 -32 -104 432 23C896 150 964 555 964 555M-93 -517C-93 -517 -25 -112 439 15C903 142 971 547 971 547M-86 -525C-86 -525 -18 -120 446 7C910 134 978 539 978 539M-79 -533C-79 -533 -11 -128 453 -1C917 126 985 531 985 531M-72 -541C-72 -541 -4 -136 460 -9C924 118 992 523 992 523M-65 -549C-65 -549 3 -144 467 -17C931 110 999 515 999 515M-58 -557C-58 -557 10 -152 474 -25C938 102 1006 507 1006 507M-51 -565C-51 -565 17 -160 481 -33C945 94 1013 499 1013 499M-44 -573C-44 -573 24 -168 488 -41C952 86 1020 491 1020 491M-37 -581C-37 -581 31 -176 495 -49C959 78 1027 483 1027 483"
stroke="url(#paint0_radial_242_278)"
strokeOpacity="0.05"
strokeWidth="0.5"
/>
{paths.map((path, index) => (
<motion.path
key={`path-${index}`}
d={path}
stroke={`url(#linearGradient-${index})`}
strokeOpacity="0.4"
strokeWidth="0.5"
/>
))}
<defs>
{paths.map((path, index) => (
<motion.linearGradient
id={`linearGradient-${index}`}
key={`gradient-${index}`}
initial={{
x1: "0%",
x2: "0%",
y1: "0%",
y2: "0%",
}}
animate={{
x1: ["0%", "100%"],
x2: ["0%", "95%"],
y1: ["0%", "100%"],
y2: ["0%", `${93 + Math.random() * 8}%`],
}}
transition={{
duration: Math.random() * 10 + 10,
ease: "easeInOut",
repeat: Infinity,
delay: Math.random() * 10,
}}
>
<stop stopColor="var(--beam-primary, #18CCFC)" stopOpacity="0" />
<stop stopColor="var(--beam-primary, #18CCFC)" />
<stop offset="32.5%" stopColor="var(--beam-secondary, #6344F5)" />
<stop offset="100%" stopColor="var(--beam-secondary, #AE48FF)" stopOpacity="0" />
</motion.linearGradient>
))}
<radialGradient
id="paint0_radial_242_278"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(352 34) rotate(90) scale(555 1560.62)"
>
<stop offset="0.0666667" stopColor="var(--neutral-300)" />
<stop offset="0.243243" stopColor="var(--neutral-300)" />
<stop offset="0.43594" stopColor="white" stopOpacity="0" />
</radialGradient>
</defs>
</svg>
</div>
)
},
)
BackgroundBeams.displayName = "BackgroundBeams"

View File

@@ -0,0 +1,150 @@
import { useCallback, useState } from 'react';
import { compressPhoto, formatBytes } from '../lib/image';
import { uploadPhoto, type UploadError } from '../services/photosApi';
import { useGuestIdentity } from '../context/GuestIdentityContext';
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
import { resolveUploadErrorDialog, type UploadErrorDialog } from '../lib/uploadErrorDialog';
type DirectUploadResult = {
success: boolean;
photoId?: number;
warning?: string | null;
error?: string | null;
dialog?: UploadErrorDialog | null;
};
type UseDirectUploadOptions = {
eventToken: string;
taskId?: number | null;
emotionSlug?: string;
onCompleted?: (photoId: number) => void;
};
export function useDirectUpload({ eventToken, taskId, emotionSlug, onCompleted }: UseDirectUploadOptions) {
const { name } = useGuestIdentity();
const { markCompleted } = useGuestTaskProgress(eventToken);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [warning, setWarning] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [errorDialog, setErrorDialog] = useState<UploadErrorDialog | null>(null);
const [canUpload, setCanUpload] = useState(true);
const reset = useCallback(() => {
setProgress(0);
setWarning(null);
setError(null);
setErrorDialog(null);
}, []);
const preparePhoto = useCallback(async (file: File) => {
reset();
let prepared = file;
try {
prepared = await compressPhoto(file, {
maxEdge: 2400,
targetBytes: 4_000_000,
qualityStart: 0.82,
});
if (prepared.size < file.size - 50_000) {
const saved = formatBytes(file.size - prepared.size);
setWarning(`Wir haben dein Foto verkleinert, damit der Upload schneller klappt. Eingespart: ${saved}`);
}
} catch (err) {
console.warn('Direct upload: optimization failed, using original', err);
setWarning('Optimierung nicht möglich wir laden das Original hoch.');
}
if (prepared.size > 12_000_000) {
setError('Das Foto war zu groß. Bitte erneut versuchen wir verkleinern es automatisch.');
return { ok: false as const };
}
return { ok: true as const, prepared };
}, [reset]);
const upload = useCallback(
async (file: File): Promise<DirectUploadResult> => {
if (!canUpload || uploading) return { success: false, warning, error };
const preparedResult = await preparePhoto(file);
if (!preparedResult.ok) {
return { success: false, warning, error };
}
const prepared = preparedResult.prepared;
setUploading(true);
setProgress(2);
setError(null);
setErrorDialog(null);
try {
const photoId = await uploadPhoto(eventToken, prepared, taskId ?? undefined, emotionSlug || undefined, {
maxRetries: 2,
guestName: name || undefined,
onProgress: (percent) => {
setProgress(Math.max(10, Math.min(98, percent)));
},
onRetry: (attempt) => {
setWarning(`Verbindung holperig neuer Versuch (${attempt}).`);
},
});
setProgress(100);
if (taskId) {
markCompleted(taskId);
}
try {
const raw = localStorage.getItem('my-photo-ids');
const arr: number[] = raw ? JSON.parse(raw) : [];
if (photoId && !arr.includes(photoId)) {
localStorage.setItem('my-photo-ids', JSON.stringify([photoId, ...arr]));
}
} catch (persistErr) {
console.warn('Direct upload: persist my-photo-ids failed', persistErr);
}
onCompleted?.(photoId);
return { success: true, photoId, warning };
} catch (err) {
console.error('Direct upload failed', err);
const uploadErr = err as UploadError;
const meta = uploadErr.meta as Record<string, unknown> | undefined;
const dialog = resolveUploadErrorDialog(uploadErr.code, meta, (v: string) => v);
setErrorDialog(dialog);
setError(dialog?.description ?? uploadErr.message ?? 'Upload fehlgeschlagen.');
setWarning(null);
if (
uploadErr.code === 'photo_limit_exceeded'
|| uploadErr.code === 'upload_device_limit'
|| uploadErr.code === 'event_package_missing'
|| uploadErr.code === 'event_not_found'
|| uploadErr.code === 'gallery_expired'
) {
setCanUpload(false);
}
if (uploadErr.status === 422 || uploadErr.code === 'validation_error') {
setWarning('Das Foto war zu groß. Bitte erneut versuchen wir verkleinern es automatisch.');
}
return { success: false, warning, error: dialog?.description ?? uploadErr.message, dialog };
} finally {
setUploading(false);
setProgress((p) => (p === 100 ? p : 0));
}
},
[canUpload, emotionSlug, eventToken, markCompleted, name, preparePhoto, taskId, uploading, warning, onCompleted]
);
return {
upload,
uploading,
progress,
warning,
error,
errorDialog,
reset,
};
}

View File

@@ -0,0 +1,128 @@
export type EmotionTheme = {
gradientClass: string;
gradientBackground: string;
suggestionGradient: string;
suggestionBorder: string;
};
export type EmotionIdentity = {
slug?: string | null;
name?: string | null;
};
const themeFreude: EmotionTheme = {
gradientClass: 'from-amber-300 via-orange-400 to-rose-500',
gradientBackground: 'linear-gradient(135deg, #fde68a, #fb923c, #fb7185)',
suggestionGradient: 'from-amber-50 via-white to-orange-50 dark:from-amber-500/20 dark:via-gray-900 dark:to-orange-500/10',
suggestionBorder: 'border-amber-200/60 dark:border-amber-400/30',
};
const themeLiebe: EmotionTheme = {
gradientClass: 'from-rose-400 via-fuchsia-500 to-purple-600',
gradientBackground: 'linear-gradient(135deg, #fb7185, #ec4899, #9333ea)',
suggestionGradient: 'from-rose-50 via-white to-fuchsia-50 dark:from-rose-500/20 dark:via-gray-900 dark:to-fuchsia-500/10',
suggestionBorder: 'border-rose-200/60 dark:border-rose-400/30',
};
const themeEkstase: EmotionTheme = {
gradientClass: 'from-fuchsia-400 via-purple-500 to-indigo-500',
gradientBackground: 'linear-gradient(135deg, #f472b6, #a855f7, #6366f1)',
suggestionGradient: 'from-fuchsia-50 via-white to-indigo-50 dark:from-fuchsia-500/20 dark:via-gray-900 dark:to-indigo-500/10',
suggestionBorder: 'border-fuchsia-200/60 dark:border-fuchsia-400/30',
};
const themeEntspannt: EmotionTheme = {
gradientClass: 'from-teal-300 via-emerald-400 to-cyan-500',
gradientBackground: 'linear-gradient(135deg, #5eead4, #34d399, #22d3ee)',
suggestionGradient: 'from-teal-50 via-white to-emerald-50 dark:from-teal-500/20 dark:via-gray-900 dark:to-emerald-500/10',
suggestionBorder: 'border-emerald-200/60 dark:border-emerald-400/30',
};
const themeBesinnlich: EmotionTheme = {
gradientClass: 'from-slate-500 via-blue-500 to-indigo-600',
gradientBackground: 'linear-gradient(135deg, #64748b, #3b82f6, #4f46e5)',
suggestionGradient: 'from-slate-50 via-white to-blue-50 dark:from-slate-600/20 dark:via-gray-900 dark:to-blue-500/10',
suggestionBorder: 'border-slate-200/60 dark:border-slate-500/30',
};
const themeUeberraschung: EmotionTheme = {
gradientClass: 'from-indigo-300 via-violet-500 to-rose-500',
gradientBackground: 'linear-gradient(135deg, #a5b4fc, #a855f7, #fb7185)',
suggestionGradient: 'from-indigo-50 via-white to-violet-50 dark:from-indigo-500/20 dark:via-gray-900 dark:to-violet-500/10',
suggestionBorder: 'border-indigo-200/60 dark:border-indigo-400/30',
};
const themeDefault: EmotionTheme = {
gradientClass: 'from-pink-500 via-purple-500 to-indigo-600',
gradientBackground: 'linear-gradient(135deg, #ec4899, #a855f7, #4f46e5)',
suggestionGradient: 'from-pink-50 via-white to-indigo-50 dark:from-pink-500/20 dark:via-gray-900 dark:to-indigo-500/10',
suggestionBorder: 'border-pink-200/60 dark:border-pink-400/30',
};
const EMOTION_THEMES: Record<string, EmotionTheme> = {
freude: themeFreude,
happy: themeFreude,
liebe: themeLiebe,
romance: themeLiebe,
romantik: themeLiebe,
nostalgie: themeEntspannt,
relaxed: themeEntspannt,
ruehrung: themeBesinnlich,
traurigkeit: themeBesinnlich,
teamgeist: themeFreude,
gemeinschaft: themeFreude,
ueberraschung: themeUeberraschung,
surprise: themeUeberraschung,
ekstase: themeEkstase,
excited: themeEkstase,
besinnlichkeit: themeBesinnlich,
sad: themeBesinnlich,
default: themeDefault,
};
const EMOTION_ICONS: Record<string, string> = {
freude: '😊',
happy: '😊',
liebe: '❤️',
romantik: '💞',
nostalgie: '📼',
ruehrung: '🥲',
teamgeist: '🤝',
ueberraschung: '😲',
surprise: '😲',
ekstase: '🤩',
besinnlichkeit: '🕯️',
};
function sluggify(value?: string | null): string {
return (value ?? '')
.toString()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '')
.trim();
}
function resolveEmotionKey(identity?: EmotionIdentity | null): string {
if (!identity) return 'default';
const nameKey = sluggify(identity.name);
if (nameKey && EMOTION_THEMES[nameKey]) {
return nameKey;
}
const slugKey = sluggify(identity.slug);
if (slugKey && EMOTION_THEMES[slugKey]) {
return slugKey;
}
return 'default';
}
export function getEmotionTheme(identity?: EmotionIdentity | null): EmotionTheme {
const key = resolveEmotionKey(identity);
return EMOTION_THEMES[key] ?? themeDefault;
}
export function getEmotionIcon(identity?: EmotionIdentity | null): string {
const key = resolveEmotionKey(identity);
return EMOTION_ICONS[key] ?? '✨';
}

View File

@@ -2,18 +2,28 @@
import { Link, useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Progress } from '@/components/ui/progress';
import { Skeleton } from '@/components/ui/skeleton';
import { Separator } from '@/components/ui/separator';
import EmotionPicker from '../components/EmotionPicker';
import GalleryPreview from '../components/GalleryPreview';
import { useGuestIdentity } from '../context/GuestIdentityContext';
import { useEventData } from '../hooks/useEventData';
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
import { Sparkles, UploadCloud, X, RefreshCw } from 'lucide-react';
import { Sparkles, UploadCloud, X, RefreshCw, Timer } from 'lucide-react';
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
import { useEventBranding } from '../context/EventBrandingContext';
import type { EventBranding } from '../types/event-branding';
import { animated, useSpring } from '@react-spring/web';
import { useGesture } from '@use-gesture/react';
import { Swiper, SwiperSlide } from 'swiper/react';
import { EffectCards } from 'swiper/modules';
import 'swiper/css';
import 'swiper/css/effect-cards';
import { getEmotionIcon, getEmotionTheme, type EmotionIdentity } from '../lib/emotionTheme';
import { getDeviceId } from '../lib/device';
import { useDirectUpload } from '../hooks/useDirectUpload';
import { useNavigate } from 'react-router-dom';
export default function HomePage() {
const { token } = useParams<{ token: string }>();
@@ -60,87 +70,200 @@ export default function HomePage() {
const eventNameDisplay = event?.name ?? t('home.hero.defaultEventName');
const accentColor = branding.primaryColor;
const secondaryAccent = branding.secondaryColor;
const uploadsRequireApproval =
(event?.guest_upload_visibility as 'immediate' | 'review' | undefined) !== 'immediate';
const [missionDeck, setMissionDeck] = React.useState<MissionPreview[]>([]);
const [missionPool, setMissionPool] = React.useState<MissionPreview[]>([]);
const [missionLoading, setMissionLoading] = React.useState(false);
const missionPoolRef = React.useRef<MissionPreview[]>([]);
const drawRandom = React.useCallback((excludeIds: Set<number>) => {
const pool = missionPoolRef.current.filter((item) => !excludeIds.has(item.id));
if (!pool.length) return null;
return pool[Math.floor(Math.random() * pool.length)];
}, []);
const resetDeck = React.useCallback(() => {
const pool = missionPoolRef.current;
if (!pool.length) {
setMissionDeck([]);
return;
}
const shuffled = [...pool].sort(() => Math.random() - 0.5);
setMissionDeck(shuffled.slice(0, 4));
}, []);
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
const [hasMore, setHasMore] = React.useState(true);
const [page, setPage] = React.useState(1);
const [swipeCount, setSwipeCount] = React.useState(0);
const seenIdsRef = React.useRef<Set<number>>(new Set());
const poolIndexRef = React.useRef(0);
const sliderStateKey = token ? `missionSliderIndex:${token}` : null;
const swiperRef = React.useRef<any>(null);
const advanceDeck = React.useCallback(() => {
setMissionDeck((prev) => {
if (!prev.length) return prev;
const [, ...rest] = prev;
const exclude = new Set(rest.map((r) => r.id));
const nextCandidate = drawRandom(exclude);
const replenished = nextCandidate ? [...rest, nextCandidate] : rest;
return replenished;
});
}, [drawRandom]);
if (swiperRef.current) {
swiperRef.current.slideNext();
}
}, []);
React.useEffect(() => {
if (!token) return;
let cancelled = false;
async function loadMissions() {
setMissionLoading(true);
try {
const safeToken = token ?? '';
const response = await fetch(
`/api/v1/events/${encodeURIComponent(safeToken)}/tasks?locale=${encodeURIComponent(locale)}`,
{
headers: {
Accept: 'application/json',
'X-Locale': locale,
},
const normalizeTasks = React.useCallback(
(tasks: Record<string, unknown>[]): MissionPreview[] =>
tasks.map((task) => ({
id: Number(task.id),
title: typeof task.title === 'string' ? task.title : 'Mission',
description: typeof task.description === 'string' ? task.description : '',
duration: typeof task.duration === 'number' ? task.duration : 3,
emotion: (task.emotion as EmotionIdentity) ?? null,
})),
[]
);
const mergeIntoPool = React.useCallback(
(incoming: MissionPreview[]) => {
const slugTitle = (title: string) =>
title
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
setMissionPool((prev) => {
const byId = new Map<number, MissionPreview>();
const byTitle = new Map<string, MissionPreview>();
const addCandidate = (candidate: MissionPreview) => {
if (byId.has(candidate.id)) return;
const titleKey = slugTitle(candidate.title);
if (byTitle.has(titleKey)) return;
byId.set(candidate.id, candidate);
byTitle.set(titleKey, candidate);
};
prev.forEach(addCandidate);
incoming.forEach(addCandidate);
return Array.from(byId.values());
});
},
[]
);
const fetchTasksPage = React.useCallback(
async (pageToFetch: number, isInitial: boolean = false) => {
if (!token) return;
if (isInitial) {
setMissionLoading(true);
}
);
setIsLoadingMore(true);
try {
const perPage = 20;
const response = await fetch(
`/api/v1/events/${encodeURIComponent(token)}/tasks?page=${pageToFetch}&per_page=${perPage}&locale=${encodeURIComponent(locale)}`,
{
headers: {
Accept: 'application/json',
'X-Locale': locale,
'X-Device-Id': getDeviceId(),
},
}
);
if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.');
const payload = await response.json();
if (cancelled) return;
if (Array.isArray(payload) && payload.length) {
missionPoolRef.current = payload.map((task: Record<string, unknown>) => ({
id: Number(task.id),
title: typeof task.title === 'string' ? task.title : 'Mission',
description: typeof task.description === 'string' ? task.description : '',
duration: typeof task.duration === 'number' ? task.duration : 3,
emotion: task.emotion ?? null,
}));
resetDeck();
let items: Record<string, unknown>[] = [];
let hasMoreFlag = false;
let nextPage = pageToFetch + 1;
if (Array.isArray(payload)) {
items = payload;
hasMoreFlag = false;
} else if (payload && Array.isArray(payload.tasks)) {
items = payload.tasks;
hasMoreFlag = false;
} else if (payload && Array.isArray(payload.data)) {
items = payload.data;
if (payload.meta?.current_page && payload.meta?.last_page) {
hasMoreFlag = payload.meta.current_page < payload.meta.last_page;
nextPage = (payload.meta.current_page as number) + 1;
} else if (payload.next_page_url !== undefined) {
hasMoreFlag = Boolean(payload.next_page_url);
} else {
hasMoreFlag = items.length > 0;
}
} else {
missionPoolRef.current = [];
setMissionDeck([]);
hasMoreFlag = false;
}
const normalized = normalizeTasks(items);
const deduped = normalized.filter((task) => {
if (seenIdsRef.current.has(task.id)) return false;
seenIdsRef.current.add(task.id);
return true;
});
if (deduped.length) {
mergeIntoPool(deduped);
}
setHasMore(hasMoreFlag);
if (hasMoreFlag) {
setPage(nextPage);
}
} catch (err) {
if (!cancelled) {
console.warn('Mission preview failed', err);
missionPoolRef.current = [];
setMissionDeck([]);
}
console.warn('Mission fetch failed', err);
setHasMore(false);
} finally {
if (!cancelled) {
setIsLoadingMore(false);
if (isInitial) {
setMissionLoading(false);
}
}
},
[locale, normalizeTasks, token]
);
React.useEffect(() => {
if (!token) return;
seenIdsRef.current = new Set();
setMissionDeck([]);
setMissionPool([]);
setPage(1);
setHasMore(true);
// restore persisted slider position for this event
let restoredIndex = 0;
if (sliderStateKey && typeof window !== 'undefined') {
try {
const stored = window.sessionStorage.getItem(sliderStateKey);
if (stored) {
const parsed = Number(stored);
if (!Number.isNaN(parsed) && parsed >= 0) {
restoredIndex = parsed;
}
}
} catch {
restoredIndex = 0;
}
}
loadMissions();
return () => {
cancelled = true;
};
}, [resetDeck, token, locale]);
poolIndexRef.current = restoredIndex;
fetchTasksPage(1, true);
}, [fetchTasksPage, locale, sliderStateKey, token]);
React.useEffect(() => {
if (missionPool.length === 0) return;
if (poolIndexRef.current >= missionPool.length) {
poolIndexRef.current = poolIndexRef.current % missionPool.length;
}
setMissionDeck(missionPool);
}, [missionPool]);
React.useEffect(() => {
if (!swiperRef.current) return;
if (!missionDeck.length) return;
const target = poolIndexRef.current % missionDeck.length;
const inst = swiperRef.current;
if (typeof inst.slideToLoop === 'function') {
inst.slideToLoop(target, 0);
} else if (typeof inst.slideTo === 'function') {
inst.slideTo(target, 0);
}
}, [missionDeck.length]);
React.useEffect(() => {
if (missionLoading) return;
if (!hasMore || isLoadingMore) return;
// Prefetch when we are within 6 items of the end of the current pool
const remaining = missionPool.length - poolIndexRef.current;
if (remaining <= 6) {
fetchTasksPage(page);
}
}, [fetchTasksPage, hasMore, isLoadingMore, missionLoading, missionPool.length, page]);
if (!token) {
return null;
@@ -189,9 +312,28 @@ export default function HomePage() {
mission={missionDeck[0] ?? null}
loading={missionLoading}
onAdvance={advanceDeck}
stack={missionDeck.slice(0, 3)}
stack={missionDeck.slice(1)}
initialIndex={poolIndexRef.current}
onIndexChange={(idx, total) => {
poolIndexRef.current = idx % Math.max(1, total);
if (sliderStateKey && typeof window !== 'undefined') {
try {
window.sessionStorage.setItem(sliderStateKey, String(poolIndexRef.current));
} catch {
// ignore storage errors
}
}
}}
swiperRef={swiperRef}
/>
<UploadActionCard
token={token}
accentColor={accentColor}
secondaryAccent={secondaryAccent}
radius={radius}
bodyFont={bodyFont}
requiresApproval={uploadsRequireApproval}
/>
<UploadActionCard token={token} accentColor={accentColor} secondaryAccent={secondaryAccent} radius={radius} bodyFont={bodyFont} />
<EmotionActionCard />
</div>
</section>
@@ -273,7 +415,7 @@ type MissionPreview = {
title: string;
description?: string;
duration?: number;
emotion?: { name?: string; slug?: string } | null;
emotion?: EmotionIdentity | null;
};
function MissionActionCard({
@@ -282,231 +424,200 @@ function MissionActionCard({
loading,
onAdvance,
stack,
initialIndex,
onIndexChange,
swiperRef,
}: {
token: string;
mission: MissionPreview | null;
loading: boolean;
onAdvance: () => void;
stack: MissionPreview[];
initialIndex: number;
onIndexChange: (index: number, total: number) => void;
swiperRef: React.MutableRefObject<any>;
}) {
const { branding } = useEventBranding();
const radius = branding.buttons?.radius ?? 12;
const primary = branding.buttons?.primary ?? branding.primaryColor;
const secondary = branding.buttons?.secondary ?? branding.secondaryColor;
const buttonStyle = branding.buttons?.style ?? 'filled';
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const textColor = '#1f2937';
const subTextColor = '#334155';
const swipeThreshold = 120;
const stackLayers = stack.slice(1, 4);
const cards = mission ? [mission, ...stack] : stack;
const shellRadius = `${radius + 10}px`;
const cardStyle: React.CSSProperties = {
borderRadius: `${radius + 8}px`,
backgroundColor: '#fcf7ef',
backgroundImage: `linear-gradient(0deg, ${primary}33, ${primary}22), url(/patterns/rays-sunburst.svg)`,
backgroundBlendMode: 'multiply, normal',
backgroundSize: '330% 330%',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
filter: 'contrast(1.12) saturate(1.1)',
border: `1px solid ${primary}26`,
boxShadow: `0 12px 28px ${primary}22, 0 2px 6px ${primary}1f`,
fontFamily: bodyFont,
position: 'relative',
overflow: 'hidden',
};
const renderCardContent = (card: MissionPreview | null) => {
const theme = getEmotionTheme(card?.emotion ?? null);
const emotionIcon = getEmotionIcon(card?.emotion ?? null);
const durationMinutes = card?.duration ?? 3;
const progressValue = Math.min(100, Math.max(20, (durationMinutes / 8) * 100));
const titleFont = headingFont ? { fontFamily: headingFont } : undefined;
const gradientBackground = card ? theme.gradientBackground : `linear-gradient(135deg, ${primary}, ${secondary})`;
const [{ x, y, rotateZ, rotateY, rotateX, scale, opacity }, api] = useSpring(() => ({
x: 0,
y: 0,
rotateZ: 0,
rotateY: 0,
rotateX: 0,
scale: 1,
opacity: 1,
config: { tension: 320, friction: 26 },
}));
return (
<div
className="relative isolate overflow-hidden"
style={{
borderRadius: shellRadius,
background: gradientBackground,
boxShadow: `0 18px 50px ${primary}35, 0 6px 18px ${primary}22`,
fontFamily: bodyFont,
}}
>
<div
className="absolute inset-0 opacity-25"
style={{
backgroundImage:
'linear-gradient(90deg, rgba(255,255,255,0.18) 1px, transparent 1px), linear-gradient(0deg, rgba(255,255,255,0.18) 1px, transparent 1px)',
backgroundSize: '26px 26px',
}}
/>
<div className="absolute -left-12 -top-10 h-32 w-32 rounded-full bg-white/40 blur-3xl" />
<div className="absolute -right-10 bottom-0 h-28 w-28 rounded-full bg-white/25 blur-3xl" />
React.useEffect(() => {
api.start({ x: 0, y: 0, rotateZ: 0, rotateY: 0, rotateX: 0, scale: 1, opacity: 1, immediate: false });
}, [mission?.id, api]);
const bind = useGesture(
{
onDrag: ({ active, movement: [mx, my], velocity: [vx], direction: [dx], cancel }) => {
if (active && Math.abs(mx) > swipeThreshold) {
cancel?.();
api.start({
x: dx > 0 ? 520 : -520,
y: my,
rotateZ: dx > 0 ? 12 : -12,
rotateY: dx > 0 ? 18 : -18,
rotateX: -my / 10,
opacity: 0,
scale: 1,
immediate: false,
onRest: () => {
onAdvance();
api.start({ x: 0, y: 0, rotateZ: 0, rotateY: 0, rotateX: 0, opacity: 1, scale: 1, immediate: false });
},
});
return;
}
api.start({
x: mx,
y: my,
rotateZ: mx / 18,
rotateY: mx / 28,
rotateX: -my / 36,
scale: active ? 1.02 : 1,
opacity: 1,
immediate: false,
});
},
onDragEnd: () => {
api.start({ x: 0, y: 0, rotateZ: 0, rotateY: 0, rotateX: 0, scale: 1, opacity: 1, immediate: false });
},
},
{
drag: {
filterTaps: true,
bounds: { left: -200, right: 200, top: -120, bottom: 120 },
rubberband: true,
},
}
);
return (
<Card className="border-0 shadow-none bg-transparent" style={{ fontFamily: bodyFont }}>
<CardContent className="px-0 py-0">
<div className="relative min-h-[280px]">
{stackLayers.map((item, index) => {
const depth = index + 1;
const scaleDown = 1 - depth * 0.03;
const translateY = depth * 12;
const fade = Math.max(0.25, 0.55 - depth * 0.08);
return (
<div
key={item.id ?? index}
className="absolute inset-0 pointer-events-none"
style={{
...cardStyle,
transform: `translateY(${translateY}px) scale(${scaleDown})`,
opacity: fade,
filter: 'brightness(0.96) contrast(0.98)',
}}
aria-hidden
/>
);
})}
<animated.div
className="relative overflow-hidden touch-pan-y"
style={{
...cardStyle,
x,
y,
rotateZ,
rotateY,
rotateX,
scale,
opacity,
transformOrigin: 'center center',
willChange: 'transform',
}}
{...bind()}
>
<div
className="absolute inset-x-0 top-0 h-12"
style={{
background: `linear-gradient(90deg, ${secondary}80, ${primary}cc)`,
filter: 'saturate(1.05)',
}}
aria-hidden
/>
<div className="relative z-10 flex flex-col gap-3 px-5 pb-4 pt-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="flex h-11 w-11 items-center justify-center rounded-2xl bg-white/90 text-foreground shadow-sm"
style={{ borderRadius: `${radius - 2}px` }}
<div className="relative z-10 m-3 rounded-2xl border border-white/35 bg-white/80 px-4 py-4 shadow-lg backdrop-blur-xl">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="relative">
<div className="absolute inset-0 rounded-full bg-white/60 blur-xl" />
<Avatar className="relative h-12 w-12 border border-white/50 bg-white/80 shadow-md">
<AvatarFallback className="text-xl">{emotionIcon}</AvatarFallback>
</Avatar>
</div>
<div className="space-y-1">
<Badge
className="border-0 text-[11px] font-semibold uppercase tracking-wide text-white shadow"
style={{ backgroundImage: gradientBackground }}
>
<Sparkles className="h-5 w-5" aria-hidden />
</div>
<div>
<p
className="text-xs font-semibold uppercase tracking-wide"
style={{ ...(headingFont ? { fontFamily: headingFont } : {}), color: subTextColor }}
>
Fotoaufgabe
</p>
<p className="text-sm" style={{ color: subTextColor }}>
Wir haben schon etwas für dich vorbereitet.
</p>
{card?.emotion?.name ?? 'Fotoaufgabe'}
</Badge>
<div className="flex items-center gap-2 text-xs font-medium text-slate-600">
<Sparkles className="h-4 w-4 text-amber-500" aria-hidden />
<span>Deine nächste Foto-Aufgabe wartet</span>
</div>
</div>
</div>
{mission ? (
<div className="space-y-2">
<div
className="rounded-[14px] py-3 text-center"
style={{
background: 'rgba(255,255,255,0.6)',
paddingLeft: '30px',
paddingRight: '30px',
}}
>
<p
className="text-lg font-semibold leading-snug"
style={{ ...(headingFont ? { fontFamily: headingFont } : {}), color: textColor }}
>
{mission.title}
</p>
{mission.description && (
<p className="text-sm leading-relaxed" style={{ color: subTextColor }}>
{mission.description}
</p>
)}
</div>
</div>
) : (
<p className="text-sm text-center" style={{ color: subTextColor }}>
Ziehe deine erste Mission im Aufgaben-Tab oder wähle eine Stimmung.
</p>
)}
<div className="grid grid-cols-[2fr_1fr] gap-2 pt-1">
<Button asChild className="w-full" style={{ borderRadius: `${radius}px` }}>
<Link
to={
mission
? `/e/${encodeURIComponent(token)}/upload?task=${mission.id}`
: `/e/${encodeURIComponent(token)}/tasks`
}
>
Aufgabe starten
</Link>
</Button>
<Button
type="button"
variant="secondary"
className="w-full"
onClick={onShuffle}
disabled={loading}
style={{
borderRadius: `${radius}px`,
backgroundColor: secondary,
color: '#ffffff',
}}
>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} aria-hidden />
Andere Aufgabe
</Button>
<div className="flex items-center gap-1 rounded-full border border-white/70 bg-white/80 px-3 py-1 text-xs font-semibold text-slate-700 shadow-sm">
<Timer className="mr-1 h-3.5 w-3.5 text-slate-500" aria-hidden />
<span>ca. {durationMinutes} min</span>
</div>
</div>
</animated.div>
<div className="mt-4 text-center">
{card ? (
<p
className="text-2xl font-semibold leading-tight text-slate-900 line-clamp-2"
style={{ ...titleFont, textShadow: '0 6px 18px rgba(15,23,42,0.28)' }}
>
{card.title}
</p>
) : loading ? (
<div className="space-y-2">
<Skeleton className="mx-auto h-6 w-3/4" />
<Skeleton className="mx-auto h-4 w-full" />
<Skeleton className="mx-auto h-4 w-5/6" />
</div>
) : (
<p className="text-sm text-slate-600">Ziehe deine erste Mission oder wähle eine Stimmung.</p>
)}
</div>
<div className="mt-4 space-y-2">
<Progress
value={card ? progressValue : 35}
className="h-2 overflow-hidden bg-white/70"
/>
<div className="flex items-center justify-between text-[11px] font-semibold uppercase tracking-wide text-slate-500">
<span>Mission bereit</span>
<span>{card ? 'Tippe zum Start' : 'Neue Mission lädt'}</span>
</div>
</div>
<div className="mt-4 grid grid-cols-[2fr_1fr] gap-2">
<Button
asChild
className="w-full text-white shadow-lg"
style={{
borderRadius: `${radius}px`,
background: `linear-gradient(120deg, ${primary}, ${secondary})`,
boxShadow: `0 12px 28px ${primary}25`,
}}
>
<Link
to={
card
? `/e/${encodeURIComponent(token)}/upload?task=${card.id}`
: `/e/${encodeURIComponent(token)}/tasks`
}
>
Aufgabe starten
</Link>
</Button>
<Button
type="button"
variant="secondary"
className="w-full border border-slate-200 bg-white/80 text-slate-800 shadow-sm backdrop-blur"
onClick={onAdvance}
disabled={loading}
style={{
borderRadius: `${radius}px`,
}}
>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} aria-hidden />
Andere
</Button>
</div>
</div>
</div>
);
};
const slides = cards.length ? cards : [mission ?? null];
const initialSlide = Math.min(initialIndex, Math.max(0, slides.length - 1));
return (
<Card className="border-0 bg-transparent shadow-none" style={{ fontFamily: bodyFont }}>
<CardContent className="px-0 py-0">
<div className="relative min-h-[280px] px-2">
<Swiper
effect="cards"
modules={[EffectCards]}
cardsEffect={{
perSlideRotate: 2,
perSlideOffset: 4,
slideShadows: false,
}}
slidesPerView={1}
grabCursor
allowTouchMove
loop={slides.length > 1}
initialSlide={initialSlide}
onSwiper={(instance) => {
swiperRef.current = instance;
if (initialSlide > 0) {
instance.slideToLoop ? instance.slideToLoop(initialSlide, 0) : instance.slideTo(initialSlide, 0);
}
}}
onSlideChange={(instance) => {
const realIndex = typeof instance.realIndex === 'number' ? instance.realIndex : instance.activeIndex ?? 0;
onIndexChange(realIndex, slides.length);
}}
className="!pb-2"
style={{ paddingLeft: '0.25rem', paddingRight: '0.25rem' }}
>
{slides.map((card, index) => {
const key = `card-${card?.id ?? 'x'}-${index}`;
return (
<SwiperSlide key={key} className="!h-auto">
<div className="mx-auto w-full max-w-[calc(100vw-2rem)]">
{renderCardContent(card)}
</div>
</SwiperSlide>
);
})}
</Swiper>
</div>
</CardContent>
</Card>
);
@@ -534,13 +645,58 @@ function UploadActionCard({
secondaryAccent,
radius,
bodyFont,
requiresApproval,
}: {
token: string;
accentColor: string;
secondaryAccent: string;
radius: number;
bodyFont?: string;
requiresApproval: boolean;
}) {
const inputRef = React.useRef<HTMLInputElement | null>(null);
const [busy, setBusy] = React.useState(false);
const [message, setMessage] = React.useState<string | null>(null);
const navigate = useNavigate();
const { upload, uploading, error, warning, progress, reset } = useDirectUpload({
eventToken: token,
taskId: undefined,
emotionSlug: undefined,
onCompleted: () => {
setMessage(null);
navigate(`/e/${encodeURIComponent(token)}/gallery`);
},
});
const onPick = React.useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setBusy(true);
setMessage(null);
try {
await upload(file);
} finally {
setBusy(false);
}
if (inputRef.current) {
inputRef.current.value = '';
}
},
[upload]
);
React.useEffect(() => {
if (error) {
setMessage(error);
} else if (warning) {
setMessage(warning);
} else {
setMessage(null);
}
}, [error, warning]);
return (
<Card
className="overflow-hidden border border-muted/30 shadow-sm"
@@ -550,28 +706,48 @@ function UploadActionCard({
fontFamily: bodyFont,
}}
>
<CardContent className="flex flex-col gap-1.5 py-[4px]">
<div className="flex items-center gap-3">
<div className="rounded-2xl p-3" style={{ background: `${accentColor}15` }}>
<UploadCloud className="h-5 w-5" aria-hidden style={{ color: accentColor }} />
</div>
<div>
<p className="text-lg font-semibold text-foreground">Direkt hochladen</p>
<p className="text-sm text-muted-foreground">Kamera öffnen oder ein Foto aus deiner Galerie wählen.</p>
</div>
</div>
<CardContent className="flex flex-col gap-2 py-[4px]">
<Button
asChild
className="text-white"
type="button"
className="text-white justify-center"
style={{
borderRadius: `${radius}px`,
background: `linear-gradient(120deg, ${accentColor}, ${secondaryAccent})`,
boxShadow: `0 12px 28px ${accentColor}25`,
}}
disabled={busy || uploading}
onClick={() => {
reset();
setMessage(null);
inputRef.current?.click();
}}
>
<Link to={`/e/${encodeURIComponent(token)}/upload`}>Foto hochladen</Link>
<span className="flex items-center gap-2">
<UploadCloud className={`h-5 w-5 ${busy || uploading ? 'animate-pulse' : ''}`} aria-hidden />
<span>{busy || uploading ? 'Lädt …' : 'Direkt hochladen'}</span>
</span>
</Button>
<p className="text-xs text-muted-foreground">Offline möglich wir laden später hoch.</p>
<input
ref={inputRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={onPick}
/>
<p className="text-xs text-muted-foreground">
Kamera öffnen oder ein Foto aus deiner Galerie wählen. Offline möglich wir laden später hoch.
</p>
{requiresApproval ? (
<p className="text-xs font-medium text-amber-700 dark:text-amber-300">
Deine Fotos werden kurz geprüft und erscheinen danach in der Galerie.
</p>
) : null}
{message && (
<p className="text-xs font-medium text-amber-600 dark:text-amber-400">
{message} {progress > 0 && progress < 100 ? `(${Math.round(progress)}%)` : ''}
</p>
)}
</CardContent>
</Card>
);

View File

@@ -8,6 +8,7 @@ import { useTranslation } from '../i18n/useTranslation';
import { useToast } from '../components/ToastHost';
import ShareSheet from '../components/ShareSheet';
import { useEventBranding } from '../context/EventBrandingContext';
import { getDeviceId } from '../lib/device';
type Photo = {
id: number;
@@ -162,12 +163,20 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
headers: {
Accept: 'application/json',
'X-Locale': locale,
'X-Device-Id': getDeviceId(),
},
}
);
if (res.ok) {
const tasks = (await res.json()) as Task[];
const foundTask = tasks.find((t) => t.id === taskId);
const payload = (await res.json()) as unknown;
const tasks = Array.isArray(payload)
? payload
: Array.isArray((payload as any)?.data)
? (payload as any).data
: Array.isArray((payload as any)?.tasks)
? (payload as any).tasks
: [];
const foundTask = (tasks as Task[]).find((t) => t.id === taskId);
if (foundTask) {
setTask({
id: foundTask.id,

View File

@@ -10,6 +10,13 @@ import { cn } from '@/lib/utils';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useEventBranding } from '../context/EventBrandingContext';
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
import {
getEmotionIcon,
getEmotionTheme,
type EmotionIdentity,
type EmotionTheme,
} from '../lib/emotionTheme';
import { getDeviceId } from '../lib/device';
interface Task {
id: number;
@@ -39,128 +46,6 @@ type EventPhoto = {
const SWIPE_THRESHOLD_PX = 40;
const SIMILAR_PHOTO_LIMIT = 6;
type EmotionTheme = {
gradientClass: string;
gradientBackground: string;
suggestionGradient: string;
suggestionBorder: string;
};
type EmotionIdentity = {
slug?: string | null;
name?: string | null;
};
const themeFreude: EmotionTheme = {
gradientClass: 'from-amber-300 via-orange-400 to-rose-500',
gradientBackground: 'linear-gradient(135deg, #fde68a, #fb923c, #fb7185)',
suggestionGradient: 'from-amber-50 via-white to-orange-50 dark:from-amber-500/20 dark:via-gray-900 dark:to-orange-500/10',
suggestionBorder: 'border-amber-200/60 dark:border-amber-400/30',
};
const themeLiebe: EmotionTheme = {
gradientClass: 'from-rose-400 via-fuchsia-500 to-purple-600',
gradientBackground: 'linear-gradient(135deg, #fb7185, #ec4899, #9333ea)',
suggestionGradient: 'from-rose-50 via-white to-fuchsia-50 dark:from-rose-500/20 dark:via-gray-900 dark:to-fuchsia-500/10',
suggestionBorder: 'border-rose-200/60 dark:border-rose-400/30',
};
const themeEkstase: EmotionTheme = {
gradientClass: 'from-fuchsia-400 via-purple-500 to-indigo-500',
gradientBackground: 'linear-gradient(135deg, #f472b6, #a855f7, #6366f1)',
suggestionGradient: 'from-fuchsia-50 via-white to-indigo-50 dark:from-fuchsia-500/20 dark:via-gray-900 dark:to-indigo-500/10',
suggestionBorder: 'border-fuchsia-200/60 dark:border-fuchsia-400/30',
};
const themeEntspannt: EmotionTheme = {
gradientClass: 'from-teal-300 via-emerald-400 to-cyan-500',
gradientBackground: 'linear-gradient(135deg, #5eead4, #34d399, #22d3ee)',
suggestionGradient: 'from-teal-50 via-white to-emerald-50 dark:from-teal-500/20 dark:via-gray-900 dark:to-emerald-500/10',
suggestionBorder: 'border-emerald-200/60 dark:border-emerald-400/30',
};
const themeBesinnlich: EmotionTheme = {
gradientClass: 'from-slate-500 via-blue-500 to-indigo-600',
gradientBackground: 'linear-gradient(135deg, #64748b, #3b82f6, #4f46e5)',
suggestionGradient: 'from-slate-50 via-white to-blue-50 dark:from-slate-600/20 dark:via-gray-900 dark:to-blue-500/10',
suggestionBorder: 'border-slate-200/60 dark:border-slate-500/30',
};
const themeUeberraschung: EmotionTheme = {
gradientClass: 'from-indigo-300 via-violet-500 to-rose-500',
gradientBackground: 'linear-gradient(135deg, #a5b4fc, #a855f7, #fb7185)',
suggestionGradient: 'from-indigo-50 via-white to-violet-50 dark:from-indigo-500/20 dark:via-gray-900 dark:to-violet-500/10',
suggestionBorder: 'border-indigo-200/60 dark:border-indigo-400/30',
};
const themeDefault: EmotionTheme = {
gradientClass: 'from-pink-500 via-purple-500 to-indigo-600',
gradientBackground: 'linear-gradient(135deg, #ec4899, #a855f7, #4f46e5)',
suggestionGradient: 'from-pink-50 via-white to-indigo-50 dark:from-pink-500/20 dark:via-gray-900 dark:to-indigo-500/10',
suggestionBorder: 'border-pink-200/60 dark:border-pink-400/30',
};
const EMOTION_THEMES: Record<string, EmotionTheme> = {
freude: themeFreude,
happy: themeFreude,
liebe: themeLiebe,
romance: themeLiebe,
romantik: themeLiebe,
nostalgie: themeEntspannt,
relaxed: themeEntspannt,
ruehrung: themeBesinnlich,
traurigkeit: themeBesinnlich,
teamgeist: themeFreude,
gemeinschaft: themeFreude,
ueberraschung: themeUeberraschung,
surprise: themeUeberraschung,
ekstase: themeEkstase,
excited: themeEkstase,
besinnlichkeit: themeBesinnlich,
sad: themeBesinnlich,
default: themeDefault,
};
const EMOTION_ICONS: Record<string, string> = {
freude: '😊',
happy: '😊',
liebe: '❤️',
romantik: '💞',
nostalgie: '📼',
ruehrung: '🥲',
teamgeist: '🤝',
ueberraschung: '😲',
surprise: '😲',
ekstase: '🤩',
besinnlichkeit: '🕯️',
};
function sluggify(value?: string | null): string {
return (value ?? '')
.toString()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '')
.trim();
}
function resolveEmotionKey(identity?: EmotionIdentity | null): string {
if (!identity) return 'default';
const nameKey = sluggify(identity.name);
if (nameKey && EMOTION_THEMES[nameKey]) {
return nameKey;
}
const slugKey = sluggify(identity.slug);
if (slugKey && EMOTION_THEMES[slugKey]) {
return slugKey;
}
return 'default';
}
function getEmotionTheme(identity?: EmotionIdentity | null): EmotionTheme {
const key = resolveEmotionKey(identity);
return EMOTION_THEMES[key] ?? themeDefault;
}
function getEmotionIcon(identity?: EmotionIdentity | null): string {
const key = resolveEmotionKey(identity);
return EMOTION_ICONS[key] ?? '✨';
}
export default function TaskPickerPage() {
const { token } = useParams<{ token: string }>();
@@ -218,6 +103,7 @@ export default function TaskPickerPage() {
const headers: HeadersInit = {
Accept: 'application/json',
'X-Locale': locale,
'X-Device-Id': getDeviceId(),
};
if (cached?.etag) {
@@ -235,14 +121,17 @@ export default function TaskPickerPage() {
if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.');
const payload = await response.json();
if (Array.isArray(payload)) {
const entry = { data: payload, etag: response.headers.get('ETag') };
tasksCacheRef.current.set(cacheKey, entry);
setTasks(payload);
} else {
tasksCacheRef.current.set(cacheKey, { data: [], etag: response.headers.get('ETag') });
setTasks([]);
}
const taskList = Array.isArray(payload)
? payload
: Array.isArray(payload?.data)
? payload.data
: Array.isArray(payload?.tasks)
? payload.tasks
: [];
const entry = { data: taskList, etag: response.headers.get('ETag') };
tasksCacheRef.current.set(cacheKey, entry);
setTasks(taskList);
} catch (err) {
console.error('Failed to load tasks', err);
setError(err instanceof Error ? err.message : 'Unbekannter Fehler');

View File

@@ -39,6 +39,7 @@ import { useEventStats } from '../context/EventStatsContext';
import { useEventBranding } from '../context/EventBrandingContext';
import { compressPhoto, formatBytes } from '../lib/image';
import { useGuestIdentity } from '../context/GuestIdentityContext';
import { useEventData } from '../hooks/useEventData';
interface Task {
id: number;
@@ -120,10 +121,13 @@ export default function UploadPage() {
const { t, locale } = useTranslation();
const stats = useEventStats();
const { branding } = useEventBranding();
const { event } = useEventData();
const radius = branding.buttons?.radius ?? 12;
const buttonStyle = branding.buttons?.style ?? 'filled';
const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor;
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const uploadsRequireApproval =
(event?.guest_upload_visibility as 'immediate' | 'review' | undefined) !== 'immediate';
const taskIdParam = searchParams.get('task');
const emotionSlug = searchParams.get('emotion') || '';
@@ -253,12 +257,19 @@ const [canUpload, setCanUpload] = useState(true);
headers: {
Accept: 'application/json',
'X-Locale': locale,
'X-Device-Id': getDeviceId(),
},
}
);
if (!res.ok) throw new Error('Tasks konnten nicht geladen werden');
const payload = (await res.json()) as unknown;
const entries = Array.isArray(payload) ? payload.filter(isTaskPayload) : [];
const entries = Array.isArray(payload)
? payload.filter(isTaskPayload)
: Array.isArray((payload as any)?.data)
? (payload as any).data.filter(isTaskPayload)
: Array.isArray((payload as any)?.tasks)
? (payload as any).tasks.filter(isTaskPayload)
: [];
const found = entries.find((entry) => entry.id === currentTaskId) ?? null;
if (!active) return;
@@ -1103,6 +1114,12 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
>
{taskFloatingCard}
{heroOverlay}
{uploadsRequireApproval ? (
<div className="mx-4 rounded-xl border border-amber-300/70 bg-amber-50/80 p-3 text-amber-900 shadow-sm backdrop-blur dark:border-amber-400/40 dark:bg-amber-500/10 dark:text-amber-50">
<p className="text-sm font-semibold">{t('upload.review.noticeTitle', 'Uploads werden geprüft')}</p>
<p className="text-xs">{t('upload.review.noticeBody', 'Dein Foto erscheint, sobald es freigegeben wurde.')}</p>
</div>
) : null}
<section
className="relative flex flex-col overflow-hidden border border-white/10 bg-black text-white shadow-2xl"
style={{ borderRadius: radius }}

View File

@@ -64,6 +64,7 @@ export interface EventData {
icon: string | null;
};
branding?: EventBrandingPayload | null;
guest_upload_visibility?: 'immediate' | 'review';
}
export interface PackageData {
@@ -265,6 +266,8 @@ export async function fetchEvent(eventKey: string): Promise<EventData> {
default_locale: typeof json?.default_locale === 'string' && json.default_locale.trim() !== ''
? json.default_locale
: DEFAULT_LOCALE,
guest_upload_visibility:
json?.guest_upload_visibility === 'immediate' ? 'immediate' : 'review',
};
if (json?.type) {