From fa630e335d365e97ea99d87253526b33579d6cc6 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Thu, 5 Feb 2026 15:09:19 +0100 Subject: [PATCH] Update guest PWA v2 UI and likes --- app/Filament/Resources/EventResource.php | 63 ++ .../Controllers/Api/EventPublicController.php | 48 ++ app/Http/Middleware/ContentSecurityPolicy.php | 6 + .../guest-v2/__tests__/ScreensCopy.test.tsx | 13 +- .../js/guest-v2/components/CompassHub.tsx | 57 +- .../js/guest-v2/screens/GalleryScreen.tsx | 697 ++++++++++++++++-- resources/js/guest-v2/screens/TasksScreen.tsx | 2 +- .../js/guest-v2/screens/UploadScreen.tsx | 169 +++-- resources/js/guest-v2/services/photosApi.ts | 2 +- resources/js/guest/i18n/messages.ts | 4 +- resources/js/guest/pages/GalleryPage.tsx | 176 ++++- .../pages/__tests__/GalleryPageHero.test.tsx | 82 +++ .../js/guest/polling/usePollGalleryDelta.ts | 2 - resources/js/guest/router.tsx | 2 - resources/js/guest/services/photosApi.ts | 44 ++ resources/lang/de/admin.php | 8 + resources/lang/en/admin.php | 8 + .../views/filament/events/join-link.blade.php | 6 + routes/api.php | 1 + tests/Feature/ContentSecurityPolicyTest.php | 20 + .../EventJoinTokenExpiryActionTest.php | 46 ++ tests/Feature/GuestJoinTokenFlowTest.php | 32 + 22 files changed, 1288 insertions(+), 200 deletions(-) create mode 100644 resources/js/guest/pages/__tests__/GalleryPageHero.test.tsx create mode 100644 tests/Feature/ContentSecurityPolicyTest.php diff --git a/app/Filament/Resources/EventResource.php b/app/Filament/Resources/EventResource.php index d13cf172..c7fe5421 100644 --- a/app/Filament/Resources/EventResource.php +++ b/app/Filament/Resources/EventResource.php @@ -28,6 +28,7 @@ use Filament\Schemas\Schema; use Filament\Tables; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Arr; use UnitEnum; class EventResource extends Resource @@ -264,6 +265,67 @@ class EventResource extends Resource ->success() ->send(); }), + Actions\Action::make('set_demo_read_only') + ->label(__('admin.events.join_link.demo_read_only_action')) + ->icon('heroicon-o-lock-closed') + ->color('gray') + ->size('xs') + ->modalHeading(function (Actions\Action $action, Event $record): string { + $token = static::resolveJoinTokenFromAction($record, $action); + + return $token + ? __('admin.events.join_link.demo_read_only_heading', [ + 'label' => $token->label ?: __('admin.events.join_link.token_default', ['id' => $token->id]), + ]) + : __('admin.events.join_link.demo_read_only_heading_fallback'); + }) + ->schema([ + Toggle::make('demo_read_only') + ->label(__('admin.events.join_link.demo_read_only_label')) + ->helperText(__('admin.events.join_link.demo_read_only_help')), + ]) + ->fillForm(function (Actions\Action $action, Event $record): array { + $token = static::resolveJoinTokenFromAction($record, $action); + + return [ + 'demo_read_only' => (bool) Arr::get($token?->metadata ?? [], 'demo_read_only', false), + ]; + }) + ->action(function (array $data, Actions\Action $action, Event $record): void { + $token = static::resolveJoinTokenFromAction($record, $action); + + if (! $token) { + Notification::make() + ->title(__('admin.events.join_link.demo_read_only_missing')) + ->danger() + ->send(); + + return; + } + + $metadata = is_array($token->metadata) ? $token->metadata : []; + $enabled = (bool) ($data['demo_read_only'] ?? false); + + if ($enabled) { + $metadata['demo_read_only'] = true; + } else { + unset($metadata['demo_read_only']); + } + + $token->metadata = empty($metadata) ? null : $metadata; + $token->save(); + + app(SuperAdminAuditLogger::class)->recordModelMutation( + 'updated', + $token, + source: static::class + ); + + Notification::make() + ->title(__('admin.events.join_link.demo_read_only_success')) + ->success() + ->send(); + }), ]) ->modalContent(function (Actions\Action $action, $record) { $tokens = $record->joinTokens() @@ -335,6 +397,7 @@ class EventResource extends Resource 'expires_at' => optional($token->expires_at)->toIso8601String(), 'revoked_at' => optional($token->revoked_at)->toIso8601String(), 'is_active' => $token->isActive(), + 'demo_read_only' => (bool) Arr::get($token->metadata ?? [], 'demo_read_only', false), 'created_at' => optional($token->created_at)->toIso8601String(), 'layouts' => $layouts, 'layouts_url' => route('api.v1.tenant.events.join-tokens.layouts.index', [ diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index ee70ebee..b27f327e 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -2985,6 +2985,54 @@ class EventPublicController extends BaseController return response()->json(['liked' => true, 'likes_count' => $count]); } + public function unlike(Request $request, int $id) + { + $deviceId = (string) $request->header('X-Device-Id', 'anon'); + $deviceId = substr(preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId), 0, 64); + if ($deviceId === '') { + $deviceId = 'anon'; + } + + $photo = DB::table('photos') + ->join('events', 'photos.event_id', '=', 'events.id') + ->where('photos.id', $id) + ->where('events.status', 'published') + ->first(['photos.id', 'photos.event_id']); + if (! $photo) { + return ApiError::response( + 'photo_not_found', + 'Photo Not Found', + 'Photo not found or event not public.', + Response::HTTP_NOT_FOUND, + ['photo_id' => $id] + ); + } + + $exists = DB::table('photo_likes')->where('photo_id', $id)->where('guest_name', $deviceId)->exists(); + if (! $exists) { + $count = (int) DB::table('photos')->where('id', $id)->value('likes_count'); + + return response()->json(['liked' => false, 'likes_count' => $count]); + } + + DB::beginTransaction(); + try { + DB::table('photo_likes')->where('photo_id', $id)->where('guest_name', $deviceId)->delete(); + DB::table('photos')->where('id', $id)->update([ + 'likes_count' => DB::raw('case when likes_count > 0 then likes_count - 1 else 0 end'), + 'updated_at' => now(), + ]); + DB::commit(); + } catch (\Throwable $e) { + DB::rollBack(); + Log::warning('unlike failed', ['error' => $e->getMessage()]); + } + + $count = (int) DB::table('photos')->where('id', $id)->value('likes_count'); + + return response()->json(['liked' => false, 'likes_count' => $count]); + } + public function upload(Request $request, string $token) { $result = $this->resolvePublishedEvent($request, $token, ['id']); diff --git a/app/Http/Middleware/ContentSecurityPolicy.php b/app/Http/Middleware/ContentSecurityPolicy.php index 3c1b0db5..1207d66a 100644 --- a/app/Http/Middleware/ContentSecurityPolicy.php +++ b/app/Http/Middleware/ContentSecurityPolicy.php @@ -81,6 +81,11 @@ class ContentSecurityPolicy 'https:', ]; + $workerSources = [ + "'self'", + 'blob:', + ]; + $paypalSources = [ 'https://www.paypal.com', 'https://www.paypalobjects.com', @@ -153,6 +158,7 @@ class ContentSecurityPolicy 'font-src' => array_unique($fontSources), 'connect-src' => array_unique($connectSources), 'media-src' => array_unique($mediaSources), + 'worker-src' => array_unique($workerSources), 'frame-src' => array_unique($frameSources), 'form-action' => ["'self'"], 'base-uri' => ["'self'"], diff --git a/resources/js/guest-v2/__tests__/ScreensCopy.test.tsx b/resources/js/guest-v2/__tests__/ScreensCopy.test.tsx index baa8d115..39a4a7fe 100644 --- a/resources/js/guest-v2/__tests__/ScreensCopy.test.tsx +++ b/resources/js/guest-v2/__tests__/ScreensCopy.test.tsx @@ -30,7 +30,7 @@ vi.mock('@tamagui/sheet', () => { vi.mock('react-router-dom', () => ({ useNavigate: () => vi.fn(), - useSearchParams: () => [new URLSearchParams()], + useSearchParams: () => [new URLSearchParams(), vi.fn()], })); vi.mock('lucide-react', () => ({ @@ -48,9 +48,14 @@ vi.mock('lucide-react', () => ({ Trophy: () => trophy, Play: () => play, Share2: () => share, + MessageSquare: () => message, + Copy: () => copy, + ChevronLeft: () => chevron-left, + ChevronRight: () => chevron-right, QrCode: () => qr, Link: () => link, Users: () => users, + Heart: () => heart, })); vi.mock('../components/AppShell', () => ({ @@ -73,6 +78,10 @@ vi.mock('@/guest/services/pendingUploadsApi', () => ({ vi.mock('../services/photosApi', () => ({ fetchGallery: vi.fn().mockResolvedValue({ data: [], next_cursor: null, latest_photo_at: null, notModified: false }), + fetchPhoto: vi.fn().mockResolvedValue(null), + likePhoto: vi.fn().mockResolvedValue(0), + unlikePhoto: vi.fn().mockResolvedValue(0), + createPhotoShareLink: vi.fn().mockResolvedValue({ url: null }), })); vi.mock('../hooks/usePollGalleryDelta', () => ({ @@ -136,7 +145,7 @@ describe('Guest v2 screens copy', () => { ); - expect(screen.getByText('Gallery')).toBeInTheDocument(); + expect(screen.getByText('Neues Foto hochladen')).toBeInTheDocument(); }); it('renders upload preview prompt', () => { diff --git a/resources/js/guest-v2/components/CompassHub.tsx b/resources/js/guest-v2/components/CompassHub.tsx index 3c4172c9..f8e9c47e 100644 --- a/resources/js/guest-v2/components/CompassHub.tsx +++ b/resources/js/guest-v2/components/CompassHub.tsx @@ -1,8 +1,10 @@ import React from 'react'; -import { YStack } from '@tamagui/stacks'; +import { XStack, YStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Button } from '@tamagui/button'; +import { X } from 'lucide-react'; import { useGuestThemeVariant } from '../lib/guestTheme'; +import { getBentoSurfaceTokens } from '../lib/bento'; export type CompassAction = { key: string; @@ -40,6 +42,10 @@ export default function CompassHub({ }: CompassHubProps) { const close = () => onOpenChange(false); const { isDark } = useGuestThemeVariant(); + const bentoSurface = getBentoSurfaceTokens(isDark); + const tileShadow = isDark + ? '0 10px 0 rgba(2, 6, 23, 0.55), 0 20px 24px rgba(2, 6, 23, 0.45)' + : '0 10px 0 rgba(15, 23, 42, 0.18), 0 18px 22px rgba(15, 23, 42, 0.16)'; const [visible, setVisible] = React.useState(open); const [closing, setClosing] = React.useState(false); @@ -86,10 +92,11 @@ export default function CompassHub({ justifyContent="center" pointerEvents="box-none" > - - - {title} - + + {quadrants.map((action, index) => ( - - Tap outside to close - diff --git a/resources/js/guest-v2/screens/GalleryScreen.tsx b/resources/js/guest-v2/screens/GalleryScreen.tsx index 2c221c6d..efb01562 100644 --- a/resources/js/guest-v2/screens/GalleryScreen.tsx +++ b/resources/js/guest-v2/screens/GalleryScreen.tsx @@ -2,17 +2,21 @@ import React from 'react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Button } from '@tamagui/button'; -import { Camera, Image as ImageIcon, Filter } from 'lucide-react'; +import { Camera, ChevronLeft, ChevronRight, Heart, Share2, Sparkles, X } from 'lucide-react'; import AppShell from '../components/AppShell'; import PhotoFrameTile from '../components/PhotoFrameTile'; +import ShareSheet from '../components/ShareSheet'; import { useEventData } from '../context/EventDataContext'; -import { fetchGallery } from '../services/photosApi'; +import { createPhotoShareLink, fetchGallery, fetchPhoto, likePhoto, unlikePhoto } from '../services/photosApi'; import { usePollGalleryDelta } from '../hooks/usePollGalleryDelta'; import { useGuestThemeVariant } from '../lib/guestTheme'; import { useTranslation } from '@/guest/i18n/useTranslation'; import { useLocale } from '@/guest/i18n/LocaleContext'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { buildEventPath } from '../lib/routes'; +import { getBentoSurfaceTokens } from '../lib/bento'; +import { usePollStats } from '../hooks/usePollStats'; +import { pushGuestToast } from '../lib/toast'; type GalleryFilter = 'latest' | 'popular' | 'mine' | 'photobooth'; @@ -25,6 +29,12 @@ type GalleryTile = { sessionId?: string | null; }; +type LightboxPhoto = { + id: number; + imageUrl: string; + likes: number; +}; + function normalizeImageUrl(src?: string | null) { if (!src) { return ''; @@ -43,20 +53,35 @@ function normalizeImageUrl(src?: string | null) { } export default function GalleryScreen() { - const { token } = useEventData(); + const { token, event } = useEventData(); const { t } = useTranslation(); const { locale } = useLocale(); const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); const { isDark } = useGuestThemeVariant(); - const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)'; - const cardShadow = isDark ? '0 18px 40px rgba(2, 6, 23, 0.4)' : '0 16px 30px rgba(15, 23, 42, 0.12)'; + const bentoSurface = getBentoSurfaceTokens(isDark); + const cardShadow = bentoSurface.shadow; + const hardShadow = isDark + ? '0 18px 0 rgba(2, 6, 23, 0.55), 0 32px 40px rgba(2, 6, 23, 0.55)' + : '0 18px 0 rgba(15, 23, 42, 0.22), 0 30px 36px rgba(15, 23, 42, 0.2)'; const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'; const mutedButtonBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'; const [photos, setPhotos] = React.useState([]); const [loading, setLoading] = React.useState(false); const { data: delta } = usePollGalleryDelta(token ?? null, { locale }); + const { stats } = usePollStats(token ?? null, 12000); const [filter, setFilter] = React.useState('latest'); const uploadPath = React.useMemo(() => buildEventPath(token ?? null, '/upload'), [token]); + const numberFormatter = React.useMemo(() => new Intl.NumberFormat(locale), [locale]); + const [lightboxPhoto, setLightboxPhoto] = React.useState(null); + const [lightboxLoading, setLightboxLoading] = React.useState(false); + const [likesById, setLikesById] = React.useState>({}); + const [shareSheet, setShareSheet] = React.useState<{ url: string | null; loading: boolean }>({ + url: null, + loading: false, + }); + const [likedIds, setLikedIds] = React.useState>(new Set()); + const touchStartX = React.useRef(null); React.useEffect(() => { if (!token) { @@ -142,6 +167,15 @@ export default function GalleryScreen() { const rightColumn = displayPhotos.filter((_, index) => index % 2 === 1); const isEmpty = !loading && displayPhotos.length === 0; const isSingle = !loading && displayPhotos.length === 1; + const selectedPhotoId = Number(searchParams.get('photo') ?? 0); + const lightboxIndex = React.useMemo(() => { + if (!selectedPhotoId) { + return -1; + } + return displayPhotos.findIndex((item) => item.id === selectedPhotoId); + }, [displayPhotos, selectedPhotoId]); + const lightboxSelected = lightboxIndex >= 0 ? displayPhotos[lightboxIndex] : null; + const lightboxOpen = Boolean(selectedPhotoId); React.useEffect(() => { if (filter === 'photobooth' && !photos.some((photo) => photo.ingestSource === 'photobooth')) { @@ -164,10 +198,17 @@ export default function GalleryScreen() { const openLightbox = React.useCallback( (photoId: number) => { if (!token) return; - navigate(buildEventPath(token, `/photo/${photoId}`)); + const next = new URLSearchParams(searchParams); + next.set('photo', String(photoId)); + setSearchParams(next, { replace: false }); }, - [navigate, token] + [searchParams, setSearchParams, token] ); + const closeLightbox = React.useCallback(() => { + const next = new URLSearchParams(searchParams); + next.delete('photo'); + setSearchParams(next, { replace: true }); + }, [searchParams, setSearchParams]); React.useEffect(() => { if (delta.photos.length === 0) { @@ -208,73 +249,340 @@ export default function GalleryScreen() { }); }, [delta.photos]); + const heroStatsLine = t( + 'galleryPage.hero.stats', + { + photoCount: numberFormatter.format(photos.length), + likeCount: numberFormatter.format(stats.likesCount ?? 0), + guestCount: numberFormatter.format(stats.onlineGuests || stats.guestCount || 0), + }, + `${numberFormatter.format(photos.length)} Fotos · ${numberFormatter.format(stats.likesCount ?? 0)} ❤️ · ${numberFormatter.format(stats.onlineGuests || stats.guestCount || 0)} Gäste online` + ); + + React.useEffect(() => { + setLikesById((prev) => { + const next = { ...prev }; + for (const photo of photos) { + if (next[photo.id] === undefined) { + next[photo.id] = photo.likes; + } + } + return next; + }); + }, [photos]); + + React.useEffect(() => { + if (!lightboxOpen) { + setLightboxPhoto(null); + setLightboxLoading(false); + return; + } + + const seed = lightboxSelected + ? { id: lightboxSelected.id, imageUrl: lightboxSelected.imageUrl, likes: lightboxSelected.likes } + : null; + if (seed) { + setLightboxPhoto(seed); + } + + let active = true; + setLightboxLoading(true); + fetchPhoto(selectedPhotoId, locale) + .then((photo) => { + if (!active || !photo) return; + const mapped = mapFullPhoto(photo as Record); + if (mapped) { + setLightboxPhoto(mapped); + setLikesById((prev) => ({ ...prev, [mapped.id]: mapped.likes })); + } + }) + .catch((error) => { + console.error('Lightbox photo load failed', error); + }) + .finally(() => { + if (active) { + setLightboxLoading(false); + } + }); + + return () => { + active = false; + }; + }, [lightboxOpen, lightboxSelected, locale, selectedPhotoId]); + + React.useEffect(() => { + if (!lightboxOpen) { + document.body.style.overflow = ''; + return; + } + document.body.style.overflow = 'hidden'; + const handleKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeLightbox(); + } + }; + window.addEventListener('keydown', handleKey); + return () => { + document.body.style.overflow = ''; + window.removeEventListener('keydown', handleKey); + }; + }, [lightboxOpen]); + + const goPrev = React.useCallback(() => { + if (lightboxIndex <= 0) return; + const prevId = displayPhotos[lightboxIndex - 1]?.id; + if (prevId) { + openLightbox(prevId); + } + }, [displayPhotos, lightboxIndex, openLightbox]); + + const goNext = React.useCallback(() => { + if (lightboxIndex < 0 || lightboxIndex >= displayPhotos.length - 1) return; + const nextId = displayPhotos[lightboxIndex + 1]?.id; + if (nextId) { + openLightbox(nextId); + } + }, [displayPhotos, lightboxIndex, openLightbox]); + + const handleLike = React.useCallback(async () => { + if (!lightboxPhoto) return; + const isLiked = likedIds.has(lightboxPhoto.id); + const current = likesById[lightboxPhoto.id] ?? lightboxPhoto.likes; + const nextCount = Math.max(0, current + (isLiked ? -1 : 1)); + setLikedIds((prev) => { + const next = new Set(prev); + if (isLiked) { + next.delete(lightboxPhoto.id); + } else { + next.add(lightboxPhoto.id); + } + return next; + }); + setLikesById((prev) => ({ ...prev, [lightboxPhoto.id]: nextCount })); + try { + const count = isLiked ? await unlikePhoto(lightboxPhoto.id) : await likePhoto(lightboxPhoto.id); + setLikesById((prev) => ({ ...prev, [lightboxPhoto.id]: count })); + } catch (error) { + console.error('Like failed', error); + setLikedIds((prev) => { + const next = new Set(prev); + if (isLiked) { + next.add(lightboxPhoto.id); + } else { + next.delete(lightboxPhoto.id); + } + return next; + }); + setLikesById((prev) => ({ ...prev, [lightboxPhoto.id]: current })); + } + }, [lightboxPhoto, likedIds, likesById]); + + const shareTitle = event?.name ?? t('share.title', 'Shared photo'); + const shareText = t('share.shareText', 'Check out this moment on Fotospiel.'); + + const openShareSheet = React.useCallback(async () => { + if (!lightboxPhoto || !token) return; + setShareSheet({ url: null, loading: true }); + try { + const payload = await createPhotoShareLink(token, lightboxPhoto.id); + const url = payload?.url ?? null; + setShareSheet({ url, loading: false }); + } catch (error) { + console.error('Share failed', error); + pushGuestToast({ text: t('share.error', 'Share failed'), type: 'error' }); + setShareSheet({ url: null, loading: false }); + } + }, [lightboxPhoto, t, token]); + + const closeShareSheet = React.useCallback(() => { + setShareSheet({ url: null, loading: false }); + }, []); + + const shareWhatsApp = React.useCallback( + (url?: string | null) => { + if (!url) return; + const waUrl = `https://wa.me/?text=${encodeURIComponent(`${shareText} ${url}`)}`; + window.open(waUrl, '_blank', 'noopener'); + closeShareSheet(); + }, + [closeShareSheet, shareText] + ); + + const shareMessages = React.useCallback( + (url?: string | null) => { + if (!url) return; + const smsUrl = `sms:?&body=${encodeURIComponent(`${shareText} ${url}`)}`; + window.open(smsUrl, '_blank', 'noopener'); + closeShareSheet(); + }, + [closeShareSheet, shareText] + ); + + const copyLink = React.useCallback( + async (url?: string | null) => { + if (!url) return; + try { + await navigator.clipboard?.writeText(url); + pushGuestToast({ text: t('share.copySuccess', 'Link copied!') }); + } catch (error) { + console.error('Copy failed', error); + pushGuestToast({ text: t('share.copyError', 'Link could not be copied.'), type: 'error' }); + } finally { + closeShareSheet(); + } + }, + [closeShareSheet, t] + ); + + const shareNative = React.useCallback( + (url?: string | null) => { + if (!url) return; + const data: ShareData = { + title: shareTitle, + text: shareText, + url, + }; + if (navigator.share && (!navigator.canShare || navigator.canShare(data))) { + navigator.share(data).catch(() => undefined); + closeShareSheet(); + return; + } + void copyLink(url); + }, + [closeShareSheet, copyLink, shareText, shareTitle] + ); + + const handleTouchStart = (event: React.TouchEvent) => { + touchStartX.current = event.touches[0]?.clientX ?? null; + }; + + const handleTouchEnd = (event: React.TouchEvent) => { + if (touchStartX.current === null) { + return; + } + const endX = event.changedTouches[0]?.clientX ?? null; + if (endX === null) { + touchStartX.current = null; + return; + } + const delta = endX - touchStartX.current; + touchStartX.current = null; + if (Math.abs(delta) < 60) { + return; + } + if (delta > 0) { + goPrev(); + return; + } + goNext(); + }; return ( - - - - - {t('galleryPage.title', 'Gallery')} - - + + + + {t('galleryPage.hero.label', 'Live-Galerie')} + + + + + {event?.name ?? t('galleryPage.hero.eventFallback', 'Euer Event')} + - - {( - [ - { value: 'latest', label: t('galleryPage.filters.latest', 'Newest') }, - { value: 'popular', label: t('galleryPage.filters.popular', 'Popular') }, - { value: 'mine', label: t('galleryPage.filters.mine', 'My photos') }, - photos.some((photo) => photo.ingestSource === 'photobooth') - ? { value: 'photobooth', label: t('galleryPage.filters.photobooth', 'Photo booth') } - : null, - ].filter(Boolean) as Array<{ value: GalleryFilter; label: string }> - ).map((chip) => ( - - ))} - + {newUploads > 0 ? ( + + + {t('galleryPage.feed.newUploads', { count: newUploads }, '{count} neue Uploads sind da.')} + + + ) : null} + + {heroStatsLine} + + + + {( + [ + { value: 'latest', label: t('galleryPage.filters.latest', 'Newest') }, + { value: 'popular', label: t('galleryPage.filters.popular', 'Popular') }, + { value: 'mine', label: t('galleryPage.filters.mine', 'My photos') }, + photos.some((photo) => photo.ingestSource === 'photobooth') + ? { value: 'photobooth', label: t('galleryPage.filters.photobooth', 'Photo booth') } + : null, + ].filter(Boolean) as Array<{ value: GalleryFilter; label: string }> + ).map((chip) => ( + + ))} + + {isEmpty ? ( + + + + {lightboxPhoto ? ( + + {t('galleryPage.photo.alt', + + + ) : ( + + {lightboxLoading ? t('galleryPage.loading', 'Loading…') : t('lightbox.errors.notFound', 'Photo not found')} + + )} + + + + {lightboxIndex >= 0 ? `${lightboxIndex + 1} / ${displayPhotos.length}` : ''} + + + + + + + + + + {lightboxPhoto + ? t( + 'galleryPage.lightbox.likes', + { count: likesById[lightboxPhoto.id] ?? lightboxPhoto.likes }, + '{count} likes' + ) + : ''} + + + + + + - + ) : null} + { + if (!open) { + closeShareSheet(); + } + }} + photoId={lightboxPhoto?.id} + eventName={event?.name ?? null} + url={shareSheet.url} + loading={shareSheet.loading} + onShareNative={() => shareNative(shareSheet.url)} + onShareWhatsApp={() => shareWhatsApp(shareSheet.url)} + onShareMessages={() => shareMessages(shareSheet.url)} + onCopyLink={() => copyLink(shareSheet.url)} + /> ); } + +function mapFullPhoto(photo: Record): LightboxPhoto | null { + const id = Number(photo.id ?? 0); + if (!id) return null; + const imageUrl = normalizeImageUrl( + (photo.full_url as string | null | undefined) + ?? (photo.file_path as string | null | undefined) + ?? (photo.thumbnail_url as string | null | undefined) + ?? (photo.thumbnail_path as string | null | undefined) + ?? (photo.url as string | null | undefined) + ?? (photo.image_url as string | null | undefined) + ); + if (!imageUrl) return null; + return { + id, + imageUrl, + likes: typeof photo.likes_count === 'number' ? photo.likes_count : 0, + }; +} diff --git a/resources/js/guest-v2/screens/TasksScreen.tsx b/resources/js/guest-v2/screens/TasksScreen.tsx index 074cc294..737e2150 100644 --- a/resources/js/guest-v2/screens/TasksScreen.tsx +++ b/resources/js/guest-v2/screens/TasksScreen.tsx @@ -367,7 +367,7 @@ export default function TasksScreen() { - {t('tasks.startTask', 'Start task')} + {t('tasks.startTask', 'Aufgabe starten')} diff --git a/resources/js/guest-v2/screens/UploadScreen.tsx b/resources/js/guest-v2/screens/UploadScreen.tsx index 4ce66ba9..3847fcb0 100644 --- a/resources/js/guest-v2/screens/UploadScreen.tsx +++ b/resources/js/guest-v2/screens/UploadScreen.tsx @@ -14,8 +14,8 @@ import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress'; import { fetchPendingUploadsSummary, type PendingUpload } from '@/guest/services/pendingUploadsApi'; import { resolveUploadErrorDialog, type UploadErrorDialog } from '@/guest/lib/uploadErrorDialog'; import { fetchTasks, type TaskItem } from '../services/tasksApi'; -import SurfaceCard from '../components/SurfaceCard'; import { pushGuestToast } from '../lib/toast'; +import { getBentoSurfaceTokens } from '../lib/bento'; function getTaskValue(task: TaskItem, key: string): string | undefined { const value = task?.[key as keyof TaskItem]; @@ -51,8 +51,12 @@ export default function UploadScreen() { const [previewFile, setPreviewFile] = React.useState(null); const [previewUrl, setPreviewUrl] = React.useState(null); const { isDark } = useGuestThemeVariant(); - const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)'; - const cardShadow = isDark ? '0 18px 40px rgba(2, 6, 23, 0.4)' : '0 16px 30px rgba(15, 23, 42, 0.12)'; + const bentoSurface = getBentoSurfaceTokens(isDark); + const cardBorder = bentoSurface.borderColor; + const cardShadow = bentoSurface.shadow; + const hardShadow = isDark + ? '0 18px 0 rgba(2, 6, 23, 0.55), 0 32px 40px rgba(2, 6, 23, 0.55)' + : '0 18px 0 rgba(15, 23, 42, 0.22), 0 30px 36px rgba(15, 23, 42, 0.2)'; const iconColor = isDark ? '#F8FAFF' : '#0F172A'; const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'; const mutedButtonBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'; @@ -448,7 +452,19 @@ export default function UploadScreen() { {taskId ? ( - + @@ -475,27 +491,21 @@ export default function UploadScreen() { {taskError} ) : null} - + ) : null} ) : null} - - {cameraState === 'preview' ? null : ( - <> - - {facingMode === 'user' ? ( - - ) : null} - - )} - + {cameraState === 'preview' ? null : ( + + + + {facingMode === 'user' ? ( + + ) : null} + + )} diff --git a/resources/js/guest-v2/services/photosApi.ts b/resources/js/guest-v2/services/photosApi.ts index 33bd3368..c2ca3fbb 100644 --- a/resources/js/guest-v2/services/photosApi.ts +++ b/resources/js/guest-v2/services/photosApi.ts @@ -1,6 +1,6 @@ import { fetchJson } from './apiClient'; import { getDeviceId } from '../lib/device'; -export { likePhoto, createPhotoShareLink, uploadPhoto } from '@/guest/services/photosApi'; +export { likePhoto, unlikePhoto, createPhotoShareLink, uploadPhoto } from '@/guest/services/photosApi'; export type GalleryPhoto = Record; diff --git a/resources/js/guest/i18n/messages.ts b/resources/js/guest/i18n/messages.ts index de4eb935..d36ec38a 100644 --- a/resources/js/guest/i18n/messages.ts +++ b/resources/js/guest/i18n/messages.ts @@ -561,7 +561,7 @@ export const messages: Record = { subtitle: 'Tippe irgendwo, um zu fokussieren.', cta: 'Jetzt aufnehmen', }, - galleryCta: 'Direkt aus Deiner Galerie hochladen', + galleryCta: 'Aus Galerie', tools: { grid: 'Raster', flash: 'Blitz', @@ -1471,7 +1471,7 @@ export const messages: Record = { subtitle: 'Tap anywhere to focus.', cta: 'Capture now', }, - galleryCta: 'Upload directly from your gallery', + galleryCta: 'From gallery', tools: { grid: 'Grid', flash: 'Flash', diff --git a/resources/js/guest/pages/GalleryPage.tsx b/resources/js/guest/pages/GalleryPage.tsx index a2c6746c..cbe50b76 100644 --- a/resources/js/guest/pages/GalleryPage.tsx +++ b/resources/js/guest/pages/GalleryPage.tsx @@ -1,10 +1,10 @@ // @ts-nocheck import React, { useEffect, useState } from 'react'; import { Page } from './_util'; -import { useNavigationType, useParams, useSearchParams } from 'react-router-dom'; +import { Link, useNavigationType, useParams, useSearchParams } from 'react-router-dom'; import { usePollGalleryDelta } from '../polling/usePollGalleryDelta'; import FiltersBar, { type GalleryFilter } from '../components/FiltersBar'; -import { Heart, Image as ImageIcon, Share2 } from 'lucide-react'; +import { Heart, Image as ImageIcon, ImagePlus, Share2, Users } from 'lucide-react'; import { motion } from 'framer-motion'; import { likePhoto } from '../services/photosApi'; import PhotoLightbox from './PhotoLightbox'; @@ -17,6 +17,7 @@ import { createPhotoShareLink } from '../services/photosApi'; import { cn } from '@/lib/utils'; import { useEventBranding } from '../context/EventBrandingContext'; import ShareSheet from '../components/ShareSheet'; +import { Button } from '@/components/ui/button'; import { FADE_SCALE, FADE_UP, @@ -27,6 +28,7 @@ import { } from '../lib/motion'; import PullToRefresh from '../components/PullToRefresh'; import { triggerHaptic } from '../lib/haptics'; +import { useEventStats } from '../context/EventStatsContext'; const allGalleryFilters: GalleryFilter[] = ['latest', 'popular', 'mine', 'photobooth']; type GalleryPhoto = { @@ -67,6 +69,7 @@ export default function GalleryPage() { const navigationType = useNavigationType(); const { t, locale } = useTranslation(); const { branding } = useEventBranding(); + const stats = useEventStats(); const { photos, loading, newCount, acknowledgeNew, refreshNow } = usePollGalleryDelta(token ?? '', locale); const [searchParams, setSearchParams] = useSearchParams(); const photoIdParam = searchParams.get('photoId'); @@ -304,6 +307,14 @@ export default function GalleryPage() { const badgeEmphasisClass = newCount > 0 ? 'border border-pink-200 bg-pink-500/15 text-pink-600' : 'border border-transparent bg-muted text-muted-foreground'; + const uploadUrl = token ? `/e/${encodeURIComponent(token)}/upload` : '/event'; + const heroStatsLine = t('galleryPage.hero.stats', { + photoCount: numberFormatter.format(list.length), + likeCount: numberFormatter.format(stats.likesCount ?? 0), + guestCount: numberFormatter.format(stats.onlineGuests || stats.guestCount || 0), + }, `${numberFormatter.format(list.length)} Fotos · ${numberFormatter.format(stats.likesCount ?? 0)} ❤️ · ${numberFormatter.format(stats.onlineGuests || stats.guestCount || 0)} Gäste online`); + const bentoShadow = + 'shadow-[10px_10px_0_rgba(15,23,42,0.85)] dark:shadow-[10px_10px_0_rgba(15,23,42,0.6)]'; return ( @@ -315,45 +326,138 @@ export default function GalleryPage() { refreshingLabel={t('common.refreshing')} > - -
-
-
- -
-
-
-

{t('galleryPage.title')}

- - {newPhotosBadgeText} + +
+
+
+
+
+
+ + + {t('galleryPage.hero.label')} +
+
+

+ {event?.name ?? t('galleryPage.hero.eventFallback')} +

+

+ {t('galleryPage.subtitle')} +

+
+
+ + {heroStatsLine} + + {newCount > 0 && ( + + {newPhotosBadgeText} + + )} +
+
+ + {newCount > 0 && ( + + )}
-

{t('galleryPage.subtitle')}

- {newCount > 0 && ( - - )} +

+ {t('galleryPage.feed.title', 'Live-Feed')} +

+

+ {t('galleryPage.feed.description', 'Alle paar Sekunden aktualisiert.')} +

+
+ {t('galleryPage.feed.newUploads', '{count} neue Uploads sind da.').replace('{count}', `${newCount}`)} + {newCount} +
+
+ +
+
+

+ Likes +

+
+ + {numberFormatter.format(stats.likesCount ?? 0)} +
+
+
+

+ {t('galleryPage.hero.statsGuests', 'Gäste online')} +

+
+ + {numberFormatter.format(stats.onlineGuests || stats.guestCount || 0)} +
+
+
+
- +
+ +
{loading && ( @@ -363,7 +467,7 @@ export default function GalleryPage() { )} - {list.map((p: GalleryPhoto) => { + {list.map((p: GalleryPhoto, idx: number) => { const imageUrl = normalizeImageUrl(p.thumbnail_path || p.file_path); const createdLabel = p.created_at ? new Date(p.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) @@ -399,11 +503,13 @@ export default function GalleryPage() { {altText} { (e.target as HTMLImageElement).src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIEltYWdlPC90ZXh0Pjwvc3ZnPg=='; }} - loading="lazy" />
diff --git a/resources/js/guest/pages/__tests__/GalleryPageHero.test.tsx b/resources/js/guest/pages/__tests__/GalleryPageHero.test.tsx new file mode 100644 index 00000000..0af3957d --- /dev/null +++ b/resources/js/guest/pages/__tests__/GalleryPageHero.test.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import GalleryPage from '../GalleryPage'; +import { LocaleProvider } from '../../i18n/LocaleContext'; + +vi.mock('../../polling/usePollGalleryDelta', () => ({ + usePollGalleryDelta: () => ({ + photos: [], + loading: false, + newCount: 0, + acknowledgeNew: vi.fn(), + refreshNow: vi.fn(), + }), +})); + +vi.mock('../../context/EventBrandingContext', () => ({ + useEventBranding: () => ({ + branding: { + primaryColor: '#FF5A5F', + secondaryColor: '#FFF8F5', + fontFamily: 'Space Grotesk, sans-serif', + buttons: { radius: 12, style: 'filled', linkColor: '#FF5A5F' }, + typography: { heading: 'Space Grotesk, sans-serif', body: 'Space Grotesk, sans-serif' }, + }, + }), +})); + +vi.mock('../../context/EventStatsContext', () => ({ + useEventStats: () => ({ + likesCount: 12, + guestCount: 5, + onlineGuests: 2, + tasksSolved: 0, + latestPhotoAt: null, + loading: false, + eventKey: 'demo', + slug: 'demo', + }), +})); + +vi.mock('../../services/eventApi', () => ({ + fetchEvent: vi.fn().mockResolvedValue({ name: 'Demo Event' }), +})); + +vi.mock('../../components/ToastHost', () => ({ + useToast: () => ({ push: vi.fn() }), +})); + +vi.mock('../../components/ShareSheet', () => ({ + default: () => null, +})); + +vi.mock('../PhotoLightbox', () => ({ + default: () => null, +})); + +vi.mock('../../components/PullToRefresh', () => ({ + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('../../components/FiltersBar', () => ({ + default: () =>
, +})); + +describe('GalleryPage hero CTA', () => { + it('links to the upload page', async () => { + render( + + + + } /> + + + + ); + + const link = await screen.findByRole('link', { name: /neues foto hochladen/i }); + expect(link).toHaveAttribute('href', '/e/demo/upload'); + }); +}); diff --git a/resources/js/guest/polling/usePollGalleryDelta.ts b/resources/js/guest/polling/usePollGalleryDelta.ts index 3d407832..13c615e1 100644 --- a/resources/js/guest/polling/usePollGalleryDelta.ts +++ b/resources/js/guest/polling/usePollGalleryDelta.ts @@ -139,7 +139,6 @@ export function usePollGalleryDelta(token: string, locale: LocaleCode) { setLoading(true); latestAt.current = null; etagRef.current = null; - setPhotos([]); void fetchDelta(); if (timer.current) window.clearInterval(timer.current); // Poll less aggressively when hidden @@ -158,7 +157,6 @@ export function usePollGalleryDelta(token: string, locale: LocaleCode) { latestAt.current = null; etagRef.current = null; setNewCount(0); - setPhotos([]); await fetchDelta(); }, [fetchDelta, token]); diff --git a/resources/js/guest/router.tsx b/resources/js/guest/router.tsx index 75f53474..378e311d 100644 --- a/resources/js/guest/router.tsx +++ b/resources/js/guest/router.tsx @@ -26,7 +26,6 @@ const TaskPickerPage = React.lazy(() => import('./pages/TaskPickerPage')); const TaskDetailPage = React.lazy(() => import('./pages/TaskDetailPage')); const UploadPage = React.lazy(() => import('./pages/UploadPage')); const UploadQueuePage = React.lazy(() => import('./pages/UploadQueuePage')); -const GalleryPage = React.lazy(() => import('./pages/GalleryPage')); const PhotoLightbox = React.lazy(() => import('./pages/PhotoLightbox')); const AchievementsPage = React.lazy(() => import('./pages/AchievementsPage')); const SlideshowPage = React.lazy(() => import('./pages/SlideshowPage')); @@ -88,7 +87,6 @@ export const router = createBrowserRouter([ { path: 'tasks/:taskId', element: }, { path: 'upload', element: }, { path: 'queue', element: }, - { path: 'gallery', element: }, { path: 'photo/:photoId', element: }, { path: 'achievements', element: }, { path: 'slideshow', element: }, diff --git a/resources/js/guest/services/photosApi.ts b/resources/js/guest/services/photosApi.ts index d4520da8..c3b0904f 100644 --- a/resources/js/guest/services/photosApi.ts +++ b/resources/js/guest/services/photosApi.ts @@ -52,6 +52,50 @@ export async function likePhoto(id: number): Promise { return json.likes_count ?? json.data?.likes_count ?? 0; } +export async function unlikePhoto(id: number): Promise { + const headers = buildCsrfHeaders(); + + const res = await fetch(`/api/v1/photos/${id}/like`, { + method: 'DELETE', + credentials: 'include', + headers: { + ...headers, + 'Content-Type': 'application/json', + }, + }); + + if (!res.ok) { + let payload: unknown = null; + try { + payload = await res.clone().json(); + } catch (error) { + console.warn('Unlike photo: failed to parse error payload', error); + } + + if (res.status === 419) { + const error: UploadError = new Error('CSRF token mismatch. Please refresh the page and try again.'); + error.code = 'csrf_mismatch'; + error.status = res.status; + throw error; + } + + const error: UploadError = new Error( + (payload as { error?: { message?: string } } | null)?.error?.message ?? `Unlike failed: ${res.status}` + ); + error.code = (payload as { error?: { code?: string } } | null)?.error?.code ?? 'unlike_failed'; + error.status = res.status; + const meta = (payload as { error?: { meta?: Record } } | null)?.error?.meta; + if (meta) { + error.meta = meta; + } + + throw error; + } + + const json = await res.json(); + return json.likes_count ?? json.data?.likes_count ?? 0; +} + type UploadOptions = { guestName?: string; onProgress?: (percent: number) => void; diff --git a/resources/lang/de/admin.php b/resources/lang/de/admin.php index 62bac7f9..fc6d8411 100644 --- a/resources/lang/de/admin.php +++ b/resources/lang/de/admin.php @@ -417,6 +417,14 @@ return [ 'extend_expiry_missing' => 'Einladung nicht gefunden.', 'extend_expiry_missing_date' => 'Bitte ein neues Ablaufdatum wählen.', 'extend_expiry_success' => 'Ablauf der Einladung aktualisiert.', + 'demo_read_only_action' => 'Demo-Modus', + 'demo_read_only_label' => 'Nur-Lesen-Demo', + 'demo_read_only_help' => 'Uploads und Schreibaktionen für diesen Link deaktivieren.', + 'demo_read_only_heading' => 'Demo-Modus für :label', + 'demo_read_only_heading_fallback' => 'Demo-Modus für Einladung', + 'demo_read_only_missing' => 'Einladung nicht gefunden.', + 'demo_read_only_success' => 'Demo-Modus aktualisiert.', + 'demo_read_only_badge' => 'Demo (nur lesen)', ], 'analytics' => [ 'success_total' => 'Erfolgreiche Zugriffe', diff --git a/resources/lang/en/admin.php b/resources/lang/en/admin.php index 817c5a6b..1b43bbe0 100644 --- a/resources/lang/en/admin.php +++ b/resources/lang/en/admin.php @@ -413,6 +413,14 @@ return [ 'extend_expiry_missing' => 'Invitation not found.', 'extend_expiry_missing_date' => 'Please select a new expiry.', 'extend_expiry_success' => 'Invitation expiry updated.', + 'demo_read_only_action' => 'Demo mode', + 'demo_read_only_label' => 'Read-only demo', + 'demo_read_only_help' => 'Disable uploads and write actions for this invitation link.', + 'demo_read_only_heading' => 'Demo mode for :label', + 'demo_read_only_heading_fallback' => 'Demo mode for invitation', + 'demo_read_only_missing' => 'Invitation not found.', + 'demo_read_only_success' => 'Demo mode updated.', + 'demo_read_only_badge' => 'Demo (read-only)', 'deprecated_notice' => 'Direct access via slug :slug has been retired. Share the invitations below or manage QR layouts in the admin app.', 'open_admin' => 'Open admin app', ], diff --git a/resources/views/filament/events/join-link.blade.php b/resources/views/filament/events/join-link.blade.php index 256839f8..ba7313db 100644 --- a/resources/views/filament/events/join-link.blade.php +++ b/resources/views/filament/events/join-link.blade.php @@ -60,6 +60,11 @@ {{ __('admin.events.join_link.token_inactive') }} @endif + @if (!empty($token['demo_read_only'])) + + {{ __('admin.events.join_link.demo_read_only_badge') }} + + @endif
@@ -78,6 +83,7 @@ @if (isset($action)) {{ $action->getModalAction('extend_join_token_expiry')(['token_id' => $token['id']]) }} + {{ $action->getModalAction('set_demo_read_only')(['token_id' => $token['id']]) }} @endif
diff --git a/routes/api.php b/routes/api.php index d28f5af4..701d19b8 100644 --- a/routes/api.php +++ b/routes/api.php @@ -193,6 +193,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::get('/events/{token}/photos', [EventPublicController::class, 'photos'])->name('events.photos'); Route::get('/photos/{id}', [EventPublicController::class, 'photo'])->name('photos.show'); Route::post('/photos/{id}/like', [EventPublicController::class, 'like'])->name('photos.like'); + Route::delete('/photos/{id}/like', [EventPublicController::class, 'unlike'])->name('photos.unlike'); Route::post('/events/{token}/photos/{photo}/share', [EventPublicController::class, 'createShareLink']) ->whereNumber('photo') ->name('photos.share'); diff --git a/tests/Feature/ContentSecurityPolicyTest.php b/tests/Feature/ContentSecurityPolicyTest.php new file mode 100644 index 00000000..933f8753 --- /dev/null +++ b/tests/Feature/ContentSecurityPolicyTest.php @@ -0,0 +1,20 @@ + false]); + + $response = $this->get('/e/test/upload'); + + $csp = $response->headers->get('Content-Security-Policy'); + + $this->assertNotNull($csp); + $this->assertStringContainsString("worker-src 'self' blob:", $csp); + } +} diff --git a/tests/Feature/EventJoinTokenExpiryActionTest.php b/tests/Feature/EventJoinTokenExpiryActionTest.php index 18314ff9..e2d547dc 100644 --- a/tests/Feature/EventJoinTokenExpiryActionTest.php +++ b/tests/Feature/EventJoinTokenExpiryActionTest.php @@ -51,6 +51,52 @@ class EventJoinTokenExpiryActionTest extends TestCase ); } + public function test_superadmin_can_toggle_demo_read_only_on_join_token(): void + { + $user = User::factory()->create(['role' => 'super_admin']); + $event = Event::factory()->create([ + 'date' => now()->addDays(10), + ]); + + $token = $event->joinTokens()->latest('id')->first(); + + $this->bootSuperAdminPanel($user); + + Livewire::test(ListEvents::class) + ->callAction( + [ + TestAction::make('join_tokens')->table($event), + TestAction::make('set_demo_read_only') + ->arguments(['token_id' => $token->id]), + ], + [ + 'demo_read_only' => true, + ] + ) + ->assertHasNoErrors(); + + $token->refresh(); + + $this->assertTrue((bool) data_get($token->metadata, 'demo_read_only', false)); + + Livewire::test(ListEvents::class) + ->callAction( + [ + TestAction::make('join_tokens')->table($event), + TestAction::make('set_demo_read_only') + ->arguments(['token_id' => $token->id]), + ], + [ + 'demo_read_only' => false, + ] + ) + ->assertHasNoErrors(); + + $token->refresh(); + + $this->assertFalse((bool) data_get($token->metadata, 'demo_read_only', false)); + } + private function bootSuperAdminPanel(User $user): void { $panel = Filament::getPanel('superadmin'); diff --git a/tests/Feature/GuestJoinTokenFlowTest.php b/tests/Feature/GuestJoinTokenFlowTest.php index e7acd99b..ca38b472 100644 --- a/tests/Feature/GuestJoinTokenFlowTest.php +++ b/tests/Feature/GuestJoinTokenFlowTest.php @@ -368,6 +368,38 @@ class GuestJoinTokenFlowTest extends TestCase $this->assertEquals(1, $photo->fresh()->likes_count); } + public function test_guest_can_unlike_photo_after_liking(): void + { + $event = $this->createPublishedEvent(); + $token = $this->tokenService->createToken($event); + + $photo = Photo::factory()->create([ + 'event_id' => $event->id, + 'likes_count' => 0, + ]); + + $this->getJson("/api/v1/events/{$token->token}"); + + $this->withHeader('X-Device-Id', 'device-like') + ->postJson("/api/v1/photos/{$photo->id}/like") + ->assertOk(); + + $response = $this->withHeader('X-Device-Id', 'device-like') + ->deleteJson("/api/v1/photos/{$photo->id}/like"); + + $response->assertOk() + ->assertJson([ + 'liked' => false, + ]); + + $this->assertDatabaseMissing('photo_likes', [ + 'photo_id' => $photo->id, + 'guest_name' => 'device-like', + ]); + + $this->assertEquals(0, $photo->fresh()->likes_count); + } + public function test_guest_cannot_access_event_with_expired_token(): void { $event = $this->createPublishedEvent();