diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index f2d0aab..d002c8b 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -6,6 +6,7 @@ use App\Models\Event; use App\Models\EventJoinToken; use App\Models\EventMediaAsset; use App\Models\Photo; +use App\Models\PhotoShareLink; use App\Services\Analytics\JoinTokenAnalyticsRecorder; use App\Services\EventJoinTokenService; use App\Services\Packages\PackageLimitEvaluator; @@ -585,6 +586,18 @@ class EventPublicController extends BaseController ); } + private function makeShareAssetUrl(PhotoShareLink $shareLink, string $variant): string + { + return URL::temporarySignedRoute( + 'api.v1.photo-shares.asset', + now()->addSeconds(self::SIGNED_URL_TTL_SECONDS), + [ + 'slug' => $shareLink->slug, + 'variant' => $variant, + ] + ); + } + public function gallery(Request $request, string $token) { $locale = $request->query('locale', app()->getLocale()); @@ -677,6 +690,170 @@ class EventPublicController extends BaseController ]); } + public function createShareLink(Request $request, string $token, Photo $photo) + { + $resolved = $this->resolvePublishedEvent($request, $token, ['id']); + + if ($resolved instanceof JsonResponse) { + return $resolved; + } + + /** @var array{0: object{id:int}} $resolved */ + [$eventRecord] = $resolved; + + if ((int) $photo->event_id !== (int) $eventRecord->id) { + return ApiError::response( + 'photo_not_shareable', + 'Photo Not Shareable', + 'The selected photo cannot be shared at this time.', + Response::HTTP_NOT_FOUND, + [ + 'photo_id' => $photo->id, + 'event_id' => $eventRecord->id, + ] + ); + } + + $deviceId = trim((string) ($request->header('X-Device-Id') ?? $request->input('device_id', ''))); + $ttlHours = max(1, (int) config('share-links.ttl_hours', 48)); + + $existing = PhotoShareLink::query() + ->where('photo_id', $photo->id) + ->when($deviceId !== '', fn ($query) => $query->where('created_by_device_id', $deviceId)) + ->where('expires_at', '>', now()) + ->latest('id') + ->first(); + + if ($existing && ! $existing->isExpired()) { + $shareLink = $existing; + } else { + $shareLink = PhotoShareLink::create([ + 'photo_id' => $photo->id, + 'slug' => PhotoShareLink::generateSlug(), + 'expires_at' => now()->addHours($ttlHours), + 'created_by_device_id' => $deviceId ?: null, + 'created_ip' => $request->ip(), + ]); + } + + return response()->json([ + 'slug' => $shareLink->slug, + 'expires_at' => $shareLink->expires_at?->toIso8601String(), + 'url' => url("/share/{$shareLink->slug}"), + ])->header('Cache-Control', 'no-store'); + } + + public function shareLink(Request $request, string $slug) + { + $shareLink = PhotoShareLink::with(['photo.event', 'photo.emotion', 'photo.task']) + ->where('slug', $slug) + ->first(); + + if (! $shareLink || $shareLink->isExpired()) { + return ApiError::response( + 'share_link_expired', + 'Link Expired', + 'This shared photo link is no longer available.', + Response::HTTP_GONE, + ['slug' => $slug] + ); + } + + $shareLink->forceFill(['last_accessed_at' => now()])->save(); + + $photo = $shareLink->photo; + $event = $photo->event; + + if (! $event || $photo->status !== 'approved') { + return ApiError::response( + 'photo_not_shareable', + 'Photo Not Shareable', + 'The shared photo is no longer available.', + Response::HTTP_NOT_FOUND, + ['slug' => $slug] + ); + } + + $taskTitle = null; + + if ($photo->task) { + $taskTitle = $this->translateLocalized($photo->task->title, app()->getLocale(), ''); + if ($taskTitle === '') { + $taskTitle = null; + } + } + + $photoResource = [ + 'id' => $photo->id, + 'title' => $taskTitle, + 'emotion' => $photo->emotion ? [ + 'name' => $photo->emotion->name, + 'emoji' => $photo->emotion->emoji, + ] : null, + 'likes_count' => $photo->likes()->count(), + 'image_urls' => [ + 'thumbnail' => $this->makeShareAssetUrl($shareLink, 'thumbnail'), + 'full' => $this->makeShareAssetUrl($shareLink, 'full'), + ], + ]; + + return response()->json([ + 'slug' => $shareLink->slug, + 'expires_at' => $shareLink->expires_at?->toIso8601String(), + 'photo' => $photoResource, + 'event' => $event ? [ + 'id' => $event->id, + 'name' => $event->name, + 'city' => $event->city, + ] : null, + ])->header('Cache-Control', 'no-store'); + } + + public function shareLinkAsset(Request $request, string $slug, string $variant) + { + if (! in_array($variant, ['thumbnail', 'full'], true)) { + return ApiError::response( + 'invalid_variant', + 'Invalid Variant', + 'The requested asset variant is not supported.', + Response::HTTP_BAD_REQUEST + ); + } + + $shareLink = PhotoShareLink::with(['photo.mediaAsset', 'photo.event.tenant']) + ->where('slug', $slug) + ->first(); + + if (! $shareLink || $shareLink->isExpired()) { + return ApiError::response( + 'share_link_expired', + 'Link Expired', + 'This shared photo link is no longer available.', + Response::HTTP_GONE, + ['slug' => $slug] + ); + } + + $photo = $shareLink->photo; + $event = $photo->event; + + if (! $event || $photo->status !== 'approved') { + return ApiError::response( + 'photo_not_shareable', + 'Photo Not Shareable', + 'The shared photo is no longer available.', + Response::HTTP_NOT_FOUND, + ['slug' => $slug] + ); + } + + $variantPreference = $variant === 'thumbnail' + ? ['thumbnail', 'original'] + : ['original']; + + return $this->streamGalleryPhoto($event, $photo, $variantPreference, 'inline'); + } + public function galleryPhotoAsset(Request $request, string $token, int $photo, string $variant) { $resolved = $this->resolveGalleryEvent($request, $token); diff --git a/app/Models/Photo.php b/app/Models/Photo.php index eb649f5..eed2032 100644 --- a/app/Models/Photo.php +++ b/app/Models/Photo.php @@ -76,6 +76,11 @@ class Photo extends Model return $this->hasMany(PhotoLike::class); } + public function shareLinks(): HasMany + { + return $this->hasMany(PhotoShareLink::class); + } + public static function supportsFilenameColumn(): bool { return static::hasColumn('filename'); diff --git a/app/Models/PhotoShareLink.php b/app/Models/PhotoShareLink.php new file mode 100644 index 0000000..3bbf8f3 --- /dev/null +++ b/app/Models/PhotoShareLink.php @@ -0,0 +1,51 @@ + 'datetime', + 'last_accessed_at' => 'datetime', + ]; + + protected static function booted(): void + { + static::creating(function (PhotoShareLink $link) { + if (! $link->slug) { + $link->slug = static::generateSlug(); + } + }); + } + + public static function generateSlug(): string + { + return Str::lower(Str::random(40)); + } + + public function photo(): BelongsTo + { + return $this->belongsTo(Photo::class); + } + + public function isExpired(): bool + { + return $this->expires_at !== null && $this->expires_at->isPast(); + } +} diff --git a/config/share-links.php b/config/share-links.php new file mode 100644 index 0000000..6f6e8f3 --- /dev/null +++ b/config/share-links.php @@ -0,0 +1,5 @@ + env('PHOTO_SHARE_LINK_TTL_HOURS', 48), +]; diff --git a/database/factories/PhotoShareLinkFactory.php b/database/factories/PhotoShareLinkFactory.php new file mode 100644 index 0000000..1a41b65 --- /dev/null +++ b/database/factories/PhotoShareLinkFactory.php @@ -0,0 +1,29 @@ + + */ +class PhotoShareLinkFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'photo_id' => Photo::factory(), + 'slug' => Str::lower(Str::random(32)), + 'expires_at' => now()->addDay(), + 'created_by_device_id' => $this->faker->unique()->uuid(), + 'created_ip' => $this->faker->ipv4(), + ]; + } +} diff --git a/database/migrations/2025_11_10_215620_create_photo_share_links_table.php b/database/migrations/2025_11_10_215620_create_photo_share_links_table.php new file mode 100644 index 0000000..2a4162d --- /dev/null +++ b/database/migrations/2025_11_10_215620_create_photo_share_links_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('photo_id')->constrained()->cascadeOnDelete(); + $table->string('slug', 64)->unique(); + $table->string('created_by_device_id')->nullable(); + $table->string('created_ip', 45)->nullable(); + $table->timestamp('expires_at'); + $table->timestamp('last_accessed_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('photo_share_links'); + } +}; diff --git a/resources/js/guest/components/BottomNav.tsx b/resources/js/guest/components/BottomNav.tsx index e130f2c..d229d39 100644 --- a/resources/js/guest/components/BottomNav.tsx +++ b/resources/js/guest/components/BottomNav.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { NavLink, useParams, useLocation } from 'react-router-dom'; -import { CheckSquare, GalleryHorizontal, Home, Trophy } from 'lucide-react'; +import { NavLink, useParams, useLocation, Link } from 'react-router-dom'; +import { CheckSquare, GalleryHorizontal, Home, Trophy, Camera } from 'lucide-react'; import { useEventData } from '../hooks/useEventData'; import { useTranslation } from '../i18n/useTranslation'; import { useEventBranding } from '../context/EventBrandingContext'; @@ -58,40 +58,61 @@ export default function BottomNav() { tasks: t('navigation.tasks'), achievements: t('navigation.achievements'), gallery: t('navigation.gallery'), + upload: t('home.actions.items.upload.label'), }; const isHomeActive = currentPath === base || currentPath === `/${token}`; - const isTasksActive = currentPath.startsWith(`${base}/tasks`) || currentPath === `${base}/upload`; + const isTasksActive = currentPath.startsWith(`${base}/tasks`); const isAchievementsActive = currentPath.startsWith(`${base}/achievements`); const isGalleryActive = currentPath.startsWith(`${base}/gallery`) || currentPath.startsWith(`${base}/photos`); + const isUploadActive = currentPath.startsWith(`${base}/upload`); return ( -
-
- -
- - {labels.home} -
-
- -
- - {labels.tasks} -
-
- -
- - {labels.achievements} -
-
- -
- - {labels.gallery} -
-
+
+
+
+ +
+ + {labels.home} +
+
+ +
+ + {labels.tasks} +
+
+
+ + + + + +
+ +
+ + {labels.achievements} +
+
+ +
+ + {labels.gallery} +
+
+
); diff --git a/resources/js/guest/components/EmotionPicker.tsx b/resources/js/guest/components/EmotionPicker.tsx index cc39159..f768491 100644 --- a/resources/js/guest/components/EmotionPicker.tsx +++ b/resources/js/guest/components/EmotionPicker.tsx @@ -73,30 +73,17 @@ export default function EmotionPicker({ onSelect }: EmotionPickerProps) { } }; - if (loading) { - return ( -
-
Lade Emotionen...
+ const content = ( +
+
+

+ Wie fühlst du dich? + (optional) +

+ {loading && Lade Emotionen…}
- ); - } - if (error) { - return ( -
-
{error}
-
- ); - } - - return ( -
-

- Wie fühlst du dich? - (optional) -

- -
+
{emotions.map((emotion) => { // Localize name and description if they are JSON const localize = (value: string | object, defaultValue: string = ''): string => { @@ -113,25 +100,26 @@ export default function EmotionPicker({ onSelect }: EmotionPickerProps) { const localizedName = localize(emotion.name, emotion.name); const localizedDescription = localize(emotion.description || '', ''); - return ( - + ); })}
@@ -151,4 +139,18 @@ export default function EmotionPicker({ onSelect }: EmotionPickerProps) {
); + + if (error) { + return ( +
+ {error} +
+ ); + } + + return ( +
+ {content} +
+ ); } diff --git a/resources/js/guest/components/FiltersBar.tsx b/resources/js/guest/components/FiltersBar.tsx index 20055b9..22c5606 100644 --- a/resources/js/guest/components/FiltersBar.tsx +++ b/resources/js/guest/components/FiltersBar.tsx @@ -1,17 +1,40 @@ import React from 'react'; -import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; +import { cn } from '@/lib/utils'; +import { Sparkles, Flame, UserRound, Camera } from 'lucide-react'; export type GalleryFilter = 'latest' | 'popular' | 'mine' | 'photobooth'; -export default function FiltersBar({ value, onChange }: { value: GalleryFilter; onChange: (v: GalleryFilter) => void }) { +const filterConfig: Array<{ value: GalleryFilter; label: string; icon: React.ReactNode }> = [ + { value: 'latest', label: 'Neueste', icon: }, + { value: 'popular', label: 'Beliebt', icon: }, + { value: 'mine', label: 'Meine', icon: }, + { value: 'photobooth', label: 'Fotobox', icon: }, +]; + +export default function FiltersBar({ value, onChange, className }: { value: GalleryFilter; onChange: (v: GalleryFilter) => void; className?: string }) { return ( -
- v && onChange(v as GalleryFilter)}> - Neueste - Beliebt - Meine - Fotobox - +
+ {filterConfig.map((filter) => ( + + ))}
); } diff --git a/resources/js/guest/components/GalleryPreview.tsx b/resources/js/guest/components/GalleryPreview.tsx index 4f60759..ccaf3a4 100644 --- a/resources/js/guest/components/GalleryPreview.tsx +++ b/resources/js/guest/components/GalleryPreview.tsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'; import { Card, CardContent } from '@/components/ui/card'; import { getDeviceId } from '../lib/device'; import { usePollGalleryDelta } from '../polling/usePollGalleryDelta'; +import { Heart } from 'lucide-react'; type Props = { token: string }; @@ -50,56 +51,45 @@ export default function GalleryPreview({ token }: Props) { return 'Abendstimmung'; } + const filters: { value: PreviewFilter; label: string }[] = [ + { value: 'latest', label: 'Newest' }, + { value: 'popular', label: 'Popular' }, + { value: 'mine', label: 'My Photos' }, + { value: 'photobooth', label: 'Fotobox' }, + ]; + return ( -
-
-
- - - - +
+
+
+

Live-Galerie

+

Alle Uploads auf einen Blick

- + Alle ansehen →
+
+ {filters.map((filter) => ( + + ))} +
+ {loading &&

Lädt…

} {!loading && items.length === 0 && ( @@ -108,31 +98,31 @@ export default function GalleryPreview({ token }: Props) { )} -
+ +
{items.map((p: any) => ( - -
- {p.title - {/* Photo Title */} -
-
- {p.title || getPhotoTitle(p)} -
- {p.likes_count > 0 && ( -
- ❤️ {p.likes_count} -
- )} + + {p.title +
+
+

{p.title || getPhotoTitle(p)}

+
+ + {p.likes_count ?? 0}
))}
-
+
); } diff --git a/resources/js/guest/i18n/messages.ts b/resources/js/guest/i18n/messages.ts index b290ea8..9cbc35d 100644 --- a/resources/js/guest/i18n/messages.ts +++ b/resources/js/guest/i18n/messages.ts @@ -209,6 +209,21 @@ export const messages: Record = { emptyDescription: 'Sobald Fotos freigegeben sind, erscheinen sie hier.', lightboxGuestFallback: 'Gast', }, + share: { + title: 'Geteiltes Foto', + defaultEvent: 'Ein besonderer Moment', + button: 'Teilen', + copyLink: 'Link kopieren', + copySuccess: 'Link kopiert!', + copyError: 'Link konnte nicht kopiert werden.', + manualPrompt: 'Link kopieren', + openEvent: 'Event öffnen', + loading: 'Moment wird geladen...', + expiredTitle: 'Link abgelaufen', + expiredDescription: 'Dieser Link ist nicht mehr verfügbar.', + shareText: 'Schau dir diesen Moment bei Fotospiel an.', + error: 'Teilen fehlgeschlagen', + }, uploadQueue: { title: 'Uploads', description: 'Warteschlange mit Fortschritt und erneuten Versuchen; Hintergrund-Sync umschalten.', @@ -662,6 +677,21 @@ export const messages: Record = { emptyDescription: 'Once photos are approved they will appear here.', lightboxGuestFallback: 'Guest', }, + share: { + title: 'Shared photo', + defaultEvent: 'A special moment', + button: 'Share', + copyLink: 'Copy link', + copySuccess: 'Link copied!', + copyError: 'Link could not be copied.', + manualPrompt: 'Copy link', + openEvent: 'Open event', + loading: 'Loading moment...', + expiredTitle: 'Link expired', + expiredDescription: 'This link is no longer available.', + shareText: 'Check out this moment on Fotospiel.', + error: 'Share failed', + }, uploadQueue: { title: 'Uploads', description: 'Queue with progress/retry and background sync toggle.', diff --git a/resources/js/guest/lib/sharePhoto.ts b/resources/js/guest/lib/sharePhoto.ts new file mode 100644 index 0000000..6ee32c8 --- /dev/null +++ b/resources/js/guest/lib/sharePhoto.ts @@ -0,0 +1,59 @@ +import { createPhotoShareLink } from '../services/photosApi'; + +type ShareOptions = { + token: string; + photoId: number; + title?: string; + text?: string; +}; + +async function copyToClipboard(text: string): Promise { + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { + // ignore and fallback + } + + try { + const input = document.createElement('input'); + input.value = text; + document.body.appendChild(input); + input.select(); + document.execCommand('copy'); + document.body.removeChild(input); + return true; + } catch { + return false; + } +} + +export async function sharePhotoLink(options: ShareOptions): Promise<{ url: string; method: 'native' | 'clipboard' | 'manual' }> +{ + const payload = await createPhotoShareLink(options.token, options.photoId); + const shareData: ShareData = { + title: options.title ?? 'Fotospiel Moment', + text: options.text ?? '', + url: payload.url, + }; + + if (navigator.share && (!navigator.canShare || navigator.canShare(shareData))) { + try { + await navigator.share(shareData); + return { url: payload.url, method: 'native' }; + } catch (error: any) { + if (error?.name === 'AbortError') { + return { url: payload.url, method: 'native' }; + } + // fall through to clipboard + } + } + + if (await copyToClipboard(payload.url)) { + return { url: payload.url, method: 'clipboard' }; + } + + return { url: payload.url, method: 'manual' }; +} diff --git a/resources/js/guest/pages/AchievementsPage.tsx b/resources/js/guest/pages/AchievementsPage.tsx index 7808208..36c71fe 100644 --- a/resources/js/guest/pages/AchievementsPage.tsx +++ b/resources/js/guest/pages/AchievementsPage.tsx @@ -43,10 +43,6 @@ function formatRelativeTime(input: string): string { return `vor ${days} Tagen`; } -function badgeVariant(earned: boolean): string { - return earned ? 'bg-emerald-500/10 text-emerald-500 border border-emerald-500/30' : 'bg-muted text-muted-foreground'; -} - function Leaderboard({ title, icon: Icon, entries, emptyCopy }: { title: string; icon: React.ElementType; entries: LeaderboardEntry[]; emptyCopy: string }) { return ( @@ -83,6 +79,17 @@ function Leaderboard({ title, icon: Icon, entries, emptyCopy }: { title: string; ); } +function progressMeta(badge: AchievementBadge) { + const target = badge.target ?? 0; + const progress = badge.progress ?? 0; + const ratio = target > 0 ? Math.min(1, progress / target) : 0; + return { + progress, + target, + ratio: badge.earned ? 1 : ratio, + }; +} + function BadgesGrid({ badges }: { badges: AchievementBadge[] }) { if (badges.length === 0) { return ( @@ -104,21 +111,43 @@ function BadgesGrid({ badges }: { badges: AchievementBadge[] }) { Badges Dein Fortschritt bei den verfügbaren Erfolgen. - - {badges.map((badge) => ( -
-
-
-

{badge.title}

-

{badge.description}

+ + {badges.map((badge) => { + const { ratio } = progressMeta(badge); + const percentage = Math.round(ratio * 100); + return ( +
+
+
+

{badge.title}

+

{badge.description}

+
+ + + +
+
+
+
+
+

+ {badge.earned ? 'Freigeschaltet 🎉' : `Fortschritt: ${badge.progress}/${badge.target}`} +

-
-
- {badge.earned ? 'Abgeschlossen' : `Fortschritt: ${badge.progress}/${badge.target}`} -
-
- ))} + ); + })}
); diff --git a/resources/js/guest/pages/GalleryPage.tsx b/resources/js/guest/pages/GalleryPage.tsx index 92be075..5b5b8b7 100644 --- a/resources/js/guest/pages/GalleryPage.tsx +++ b/resources/js/guest/pages/GalleryPage.tsx @@ -2,22 +2,38 @@ import React, { useEffect, useState } from 'react'; import { Page } from './_util'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { usePollGalleryDelta } from '../polling/usePollGalleryDelta'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Alert, AlertDescription } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; import FiltersBar, { type GalleryFilter } from '../components/FiltersBar'; -import { Heart, Users, Image as ImageIcon, Camera, Package as PackageIcon, AlertTriangle } from 'lucide-react'; +import { Heart, Image as ImageIcon, Share2 } from 'lucide-react'; import { likePhoto } from '../services/photosApi'; import PhotoLightbox from './PhotoLightbox'; -import { fetchEvent, getEventPackage, fetchStats, type EventData, type EventPackage, type EventStats } from '../services/eventApi'; +import { fetchEvent, fetchStats, type EventData, type EventStats } from '../services/eventApi'; import { useTranslation } from '../i18n/useTranslation'; +import { sharePhotoLink } from '../lib/sharePhoto'; +import { useToast } from '../components/ToastHost'; const allowedGalleryFilters: GalleryFilter[] = ['latest', 'popular', 'mine', 'photobooth']; const parseGalleryFilter = (value: string | null): GalleryFilter => allowedGalleryFilters.includes(value as GalleryFilter) ? (value as GalleryFilter) : 'latest'; +const normalizeImageUrl = (src?: string | null) => { + if (!src) { + return ''; + } + + if (/^https?:/i.test(src)) { + return src; + } + + let cleanPath = src.replace(/^\/+/g, '').replace(/\/+/g, '/'); + if (!cleanPath.startsWith('storage/')) { + cleanPath = `storage/${cleanPath}`; + } + + return `/${cleanPath}`.replace(/\/+/g, '/'); +}; + export default function GalleryPage() { const { token } = useParams<{ token?: string }>(); const navigate = useNavigate(); @@ -30,11 +46,11 @@ export default function GalleryPage() { const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false); const [event, setEvent] = useState(null); - const [eventPackage, setEventPackage] = useState(null); const [stats, setStats] = useState(null); const [eventLoading, setEventLoading] = useState(true); const { t } = useTranslation(); - const locale = typeof window !== 'undefined' ? window.navigator.language : 'de-DE'; + const toast = useToast(); + const [shareTargetId, setShareTargetId] = React.useState(null); useEffect(() => { setFilterState(parseGalleryFilter(modeParam)); @@ -64,13 +80,11 @@ export default function GalleryPage() { const loadEventData = async () => { try { setEventLoading(true); - const [eventData, packageData, statsData] = await Promise.all([ + const [eventData, statsData] = await Promise.all([ fetchEvent(token), - getEventPackage(token), fetchStats(token), ]); setEvent(eventData); - setEventPackage(packageData); setStats(statsData); } catch (err) { console.error('Failed to load event data', err); @@ -106,67 +120,10 @@ export default function GalleryPage() { const [liked, setLiked] = React.useState>(new Set()); const [counts, setCounts] = React.useState>({}); - const photoLimits = eventPackage?.limits?.photos ?? null; - const guestLimits = eventPackage?.limits?.guests ?? null; - const galleryLimits = eventPackage?.limits?.gallery ?? null; - - const galleryCountdown = React.useMemo(() => { - if (!galleryLimits || galleryLimits.state !== 'expired') { - return null; - } - - return { - tone: 'danger' as const, - label: t('galleryCountdown.expired'), - description: t('galleryCountdown.expiredDescription'), - cta: null, - }; - }, [galleryLimits, t]); - - const handleCountdownCta = React.useCallback(() => { - if (!galleryCountdown?.cta || !token) { - return; - } - - if (galleryCountdown.cta.type === 'upload') { - navigate(`/e/${encodeURIComponent(token)}/upload`); - } - }, [galleryCountdown?.cta, navigate, token]); - - const packageWarnings = React.useMemo(() => { - const warnings: { id: string; tone: 'warning' | 'danger'; message: string }[] = []; - - if (photoLimits?.state === 'limit_reached' && typeof photoLimits.limit === 'number') { - warnings.push({ - id: 'photos-blocked', - tone: 'danger', - message: t('upload.limitReached') - .replace('{used}', `${photoLimits.used}`) - .replace('{max}', `${photoLimits.limit}`), - }); - } - - if (galleryLimits?.state === 'expired') { - warnings.push({ - id: 'gallery-expired', - tone: 'danger', - message: t('upload.errors.galleryExpired'), - }); - } - - return warnings; - }, [photoLimits, galleryLimits, t]); - - const formatDate = React.useCallback((value: string | null) => { - if (!value) return null; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return null; - try { - return new Intl.DateTimeFormat(locale, { day: '2-digit', month: 'short', year: 'numeric' }).format(date); - } catch { - return date.toISOString().slice(0, 10); - } - }, [locale]); + const totalLikes = React.useMemo( + () => photos.reduce((sum, photo: any) => sum + (photo.likes_count ?? 0), 0), + [photos], + ); async function onLike(id: number) { if (liked.has(id)) return; @@ -185,6 +142,30 @@ export default function GalleryPage() { } } + async function onShare(photo: any) { + if (!token) return; + setShareTargetId(photo.id); + try { + const result = await sharePhotoLink({ + token, + photoId: photo.id, + title: photo.task_title ?? event?.name ?? t('share.title', 'Geteiltes Foto'), + text: t('share.shareText', { event: event?.name ?? 'Fotospiel' }), + }); + + if (result.method === 'clipboard') { + toast.push({ text: t('share.copySuccess', 'Link kopiert!') }); + } else if (result.method === 'manual') { + window.prompt(t('share.manualPrompt', 'Link kopieren'), result.url); + } + } catch (error) { + console.error('share failed', error); + toast.push({ text: t('share.error', 'Teilen fehlgeschlagen'), type: 'error' }); + } finally { + setShareTargetId(null); + } + } + if (!token) { return

Event nicht gefunden.

; } @@ -195,183 +176,109 @@ export default function GalleryPage() { return ( - - -
- - - Galerie: {event?.name || 'Event'} - {galleryCountdown && ( - - {galleryCountdown.label} - - )} - - {galleryCountdown?.cta && ( - - )} -
- {galleryCountdown && ( - - {galleryCountdown.description} - - )} -
- - {packageWarnings.length > 0 && ( -
- {packageWarnings.map((warning) => ( - - - - {warning.message} - - - ))} -
- )} - -
-
- -

Online Gäste

-

{stats?.onlineGuests || 0}

-
-
- -

Gesamt Likes

-

{photos.reduce((sum, p) => sum + ((p as any).likes_count || 0), 0)}

-
-
- -

Gesamt Fotos

-

{photos.length}

-
- {eventPackage && ( -
- -

Package

-

{eventPackage.package?.name ?? '—'}

- {photoLimits?.limit ? ( - <> -
-
-
-

- {photoLimits.used} / {photoLimits.limit} Fotos -

- - ) : ( -

{t('upload.limitUnlimited')}

- )} - {guestLimits?.limit ? ( -

- Gäste: {guestLimits.used} / {guestLimits.limit} -

- ) : null} - {galleryLimits?.expires_at ? ( -

- Galerie bis {formatDate(galleryLimits.expires_at)} -

- ) : null} -
- )} -
- - - - - {newCount > 0 && ( - - - {newCount} neue Fotos verfügbar.{' '} - - - - )} - {loading &&

Lade…

} -
- {list.map((p: any) => { - // Debug: Log image URLs - const imgSrc = p.thumbnail_path || p.file_path; - - // Normalize image URL - let imageUrl = imgSrc; - let cleanPath = ''; - - if (imageUrl) { - // Remove leading/trailing slashes for processing - cleanPath = imageUrl.replace(/^\/+|\/+$/g, ''); - - // Check if path already contains storage prefix - if (cleanPath.startsWith('storage/')) { - // Already has storage prefix, just ensure it starts with / - imageUrl = `/${cleanPath}`; - } else { - // Add storage prefix - imageUrl = `/storage/${cleanPath}`; - } - - // Remove double slashes - imageUrl = imageUrl.replace(/\/+/g, '/'); - } - - // Production: avoid heavy console logging for each image - - return ( - - -
{ - const index = list.findIndex(photo => photo.id === p.id); - setCurrentPhotoIndex(index >= 0 ? index : null); - }} - className="cursor-pointer" - > - {`Foto { - (e.target as HTMLImageElement).src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIEltYWdlPC90ZXh0Pjwvc3ZnPg=='; - }} - loading="lazy" - /> -
-
- - {p.task_title && ( -
-

{p.task_title}

-
- )} - -
- - {counts[p.id] ?? (p.likes_count || 0)} +
+
+
+
+

Live-Galerie

+
+ + {event?.name ?? 'Event'}
- +

+ {photos.length} Fotos · {totalLikes} ❤️ · {stats?.onlineGuests ?? 0} Gäste online +

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

Lade…

} +
+ {list.map((p: any) => { + 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' }) + : t('gallery.justNow', 'Gerade eben'); + const likeCount = counts[p.id] ?? (p.likes_count || 0); + + const openPhoto = () => { + const index = list.findIndex((photo: any) => photo.id === p.id); + setCurrentPhotoIndex(index >= 0 ? index : null); + }; + + return ( +
{ + if (e.key === 'Enter') { + openPhoto(); + } + }} + className="group relative overflow-hidden rounded-[28px] border border-white/20 bg-gray-950 text-white shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-400" + > + {`Foto { + (e.target as HTMLImageElement).src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIEltYWdlPC90ZXh0Pjwvc3ZnPg=='; + }} + loading="lazy" + /> +
+
+ {p.task_title &&

{p.task_title}

} +
+ {createdLabel} + {p.uploader_name || 'Gast'} +
+
+
+ + +
+
); })}
diff --git a/resources/js/guest/pages/HomePage.tsx b/resources/js/guest/pages/HomePage.tsx index 06da8c1..e383e88 100644 --- a/resources/js/guest/pages/HomePage.tsx +++ b/resources/js/guest/pages/HomePage.tsx @@ -9,7 +9,7 @@ import { useGuestIdentity } from '../context/GuestIdentityContext'; import { useEventStats } from '../context/EventStatsContext'; import { useEventData } from '../hooks/useEventData'; import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress'; -import { Sparkles, UploadCloud, Images, CheckCircle2, Users, TimerReset, X } from 'lucide-react'; +import { Sparkles, UploadCloud, Images, CheckCircle2, Users, TimerReset, X, Camera, ArrowUpRight } from 'lucide-react'; import { useTranslation, type TranslateFn } from '../i18n/useTranslation'; import { useEventBranding } from '../context/EventBrandingContext'; import type { EventBranding } from '../types/event-branding'; @@ -67,25 +67,52 @@ export default function HomePage() { const accentColor = branding.primaryColor; const secondaryAccent = branding.secondaryColor; - const primaryActions = React.useMemo( + const statItems = React.useMemo( () => [ { - to: 'tasks', - label: t('home.actions.items.tasks.label'), - description: t('home.actions.items.tasks.description'), - icon: , + icon: , + label: t('home.stats.online'), + value: `${stats.onlineGuests}`, }, + { + icon: , + label: t('home.stats.tasksSolved'), + value: `${stats.tasksSolved}`, + }, + { + icon: , + label: t('home.stats.lastUpload'), + value: latestUploadText, + }, + { + icon: , + label: t('home.stats.completedTasks'), + value: `${completedCount}`, + }, + ], + [completedCount, latestUploadText, stats.onlineGuests, stats.tasksSolved, t], + ); + + const quickActions = React.useMemo( + () => [ { to: 'upload', label: t('home.actions.items.upload.label'), description: t('home.actions.items.upload.description'), - icon: , + icon: , + highlight: true, + }, + { + to: 'tasks', + label: t('home.actions.items.tasks.label'), + description: t('home.actions.items.tasks.description'), + icon: , }, { to: 'gallery', label: t('home.actions.items.gallery.label'), description: t('home.actions.items.gallery.description'), - icon: , + icon: , }, ], [t], @@ -105,7 +132,7 @@ export default function HomePage() { } return ( -
+
{heroVisible && ( )} - - - } - label={t('home.stats.online')} - value={`${stats.onlineGuests}`} - accentColor={accentColor} - /> - } - label={t('home.stats.tasksSolved')} - value={`${stats.tasksSolved}`} - accentColor={accentColor} - /> - } - label={t('home.stats.lastUpload')} - value={latestUploadText} - accentColor={accentColor} - /> - } - label={t('home.stats.completedTasks')} - value={`${completedCount}`} - accentColor={accentColor} - /> - - + -
-
-

- {t('home.actions.title')} -

- {t('home.actions.subtitle')} +
+
+
+

{t('home.actions.title')}

+

{t('home.actions.subtitle')}

+
+
-
- {primaryActions.map((action) => ( - - - -
- {action.icon} -
-
- {action.label} - {action.description} -
-
-
- +
+ {quickActions.map((action) => ( + ))}
@@ -217,6 +207,8 @@ function HeroCard({ t, branding, onDismiss, + ctaLabel, + ctaHref, }: { name: string; eventName: string; @@ -224,6 +216,8 @@ function HeroCard({ t: TranslateFn; branding: EventBranding; onDismiss: () => void; + ctaLabel?: string; + ctaHref?: string; }) { const heroTitle = t('home.hero.title').replace('{name}', name); const heroDescription = t('home.hero.description').replace('{eventName}', eventName); @@ -249,33 +243,114 @@ function HeroCard({ {t('common.actions.close')} - + {t('home.hero.subtitle')} {heroTitle}

{heroDescription}

-

{progressMessage}

+
+

{progressMessage}

+ {ctaHref && ctaLabel && ( + + )} +
); } -function StatTile({ icon, label, value, accentColor }: { icon: React.ReactNode; label: string; value: string; accentColor: string }) { +function StatsRibbon({ + items, + accentColor, + fontFamily, +}: { + items: { icon: React.ReactNode; label: string; value: string }[]; + accentColor: string; + fontFamily?: string | null; +}) { return ( -
+
- {icon} -
-
- {label} - {value} + {items.map((item) => ( +
+
+ {item.icon} +
+
+ {item.label} + {item.value} +
+
+ ))}
); } +function QuickActionCard({ + action, + accentColor, + secondaryAccent, +}: { + action: { to: string; label: string; description: string; icon: React.ReactNode; highlight?: boolean }; + accentColor: string; + secondaryAccent: string; +}) { + const highlightStyle = action.highlight + ? { + background: `linear-gradient(120deg, ${accentColor}, ${secondaryAccent})`, + color: '#fff', + } + : undefined; + + return ( + + + +
+ {action.icon} +
+
+ + {action.label} + + + {action.description} + +
+ +
+
+ + ); +} + function formatLatestUpload(isoDate: string | null, t: TranslateFn) { if (!isoDate) { return t('home.latestUpload.none'); diff --git a/resources/js/guest/pages/PhotoLightbox.tsx b/resources/js/guest/pages/PhotoLightbox.tsx index 44f0d37..101ed43 100644 --- a/resources/js/guest/pages/PhotoLightbox.tsx +++ b/resources/js/guest/pages/PhotoLightbox.tsx @@ -2,9 +2,11 @@ import React, { useState, useEffect } from 'react'; import { useParams, useLocation, useNavigate } from 'react-router-dom'; import { Dialog, DialogContent } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; -import { Heart, ChevronLeft, ChevronRight, X } from 'lucide-react'; +import { Heart, ChevronLeft, ChevronRight, X, Share2 } from 'lucide-react'; import { likePhoto } from '../services/photosApi'; import { useTranslation } from '../i18n/useTranslation'; +import { sharePhotoLink } from '../lib/sharePhoto'; +import { useToast } from '../components/ToastHost'; type Photo = { id: number; @@ -33,6 +35,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh const photoId = params.photoId; const eventToken = params.token || token; const { t } = useTranslation(); + const toast = useToast(); const [standalonePhoto, setStandalonePhoto] = useState(null); const [loading, setLoading] = useState(true); @@ -41,6 +44,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh const [taskLoading, setTaskLoading] = useState(false); const [likes, setLikes] = useState(0); const [liked, setLiked] = useState(false); + const [shareLoading, setShareLoading] = useState(false); // Determine mode and photo const isStandalone = !photos || photos.length === 0; @@ -197,6 +201,30 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh } } + async function onShare() { + if (!photo || !eventToken) return; + setShareLoading(true); + try { + const result = await sharePhotoLink({ + token: eventToken, + photoId: photo.id, + title: photo.task_title ?? task?.title ?? t('share.title', 'Geteiltes Foto'), + text: t('share.shareText', { event: '' }), + }); + + if (result.method === 'clipboard') { + toast.push({ text: t('share.copySuccess', 'Link kopiert!') }); + } else if (result.method === 'manual') { + window.prompt(t('share.manualPrompt', 'Link kopieren'), result.url); + } + } catch (error) { + console.error('share failed', error); + toast.push({ text: t('share.error', 'Teilen fehlgeschlagen'), type: 'error' }); + } finally { + setShareLoading(false); + } + } + function onOpenChange(open: boolean) { if (!open) handleClose(); } @@ -216,6 +244,15 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh {likes} +
{currentIndexVal > 0 && ( diff --git a/resources/js/guest/pages/SharedPhotoPage.tsx b/resources/js/guest/pages/SharedPhotoPage.tsx new file mode 100644 index 0000000..2a8b9e7 --- /dev/null +++ b/resources/js/guest/pages/SharedPhotoPage.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { Button } from '@/components/ui/button'; +import { fetchPhotoShare } from '../services/photosApi'; +import { useTranslation } from '../i18n/useTranslation'; +import { useToast } from '../components/ToastHost'; +import { Loader2 } from 'lucide-react'; + +interface ShareResponse { + slug: string; + expires_at?: string; + photo: { + id: number; + title?: string; + likes_count?: number; + emotion?: { name?: string; emoji?: string } | null; + image_urls: { full: string; thumbnail: string }; + }; + event?: { id: number; name?: string | null } | null; +} + +export default function SharedPhotoPage() { + const { slug } = useParams<{ slug: string }>(); + const { t } = useTranslation(); + const toast = useToast(); + const [state, setState] = React.useState<{ + loading: boolean; + error: string | null; + data: ShareResponse | null; + }>({ loading: true, error: null, data: null }); + + React.useEffect(() => { + let active = true; + if (!slug) return; + + setState({ loading: true, error: null, data: null }); + fetchPhotoShare(slug) + .then((data) => { + if (!active) return; + setState({ loading: false, error: null, data }); + }) + .catch((error: any) => { + if (!active) return; + setState({ loading: false, error: error?.message ?? t('share.error', 'Moment konnte nicht geladen werden.'), data: null }); + }); + + return () => { + active = false; + }; + }, [slug, t]); + + const handleCopy = React.useCallback(async () => { + try { + await navigator.clipboard.writeText(window.location.href); + toast.push({ text: t('share.copySuccess', 'Link kopiert!') }); + } catch { + toast.push({ text: t('share.copyError', 'Link konnte nicht kopiert werden.'), type: 'error' }); + } + }, [toast, t]); + + if (state.loading) { + return ( +
+ +

{t('share.loading', 'Moment wird geladen...')}

+
+ ); + } + + if (state.error || !state.data) { + return ( +
+

{t('share.expiredTitle', 'Link abgelaufen')}

+

{state.error ?? t('share.expiredDescription', 'Dieses Foto ist nicht mehr verfügbar.')}

+ +
+ ); + } + + const { data } = state; + + return ( +
+
+
+

{t('share.title', 'Geteiltes Foto')}

+

{data.event?.name ?? t('share.defaultEvent', 'Ein besonderer Moment')}

+ {data.photo.title && ( +

{data.photo.title}

+ )} +
+ +
+ {data.photo.title +
+ + {data.photo.emotion && ( +

+ {data.photo.emotion.emoji} {data.photo.emotion.name} +

+ )} + +
+ + +
+
+
+ ); +} diff --git a/resources/js/guest/router.tsx b/resources/js/guest/router.tsx index c92bb6b..597aafe 100644 --- a/resources/js/guest/router.tsx +++ b/resources/js/guest/router.tsx @@ -30,6 +30,7 @@ const LegalPage = React.lazy(() => import('./pages/LegalPage')); const HelpCenterPage = React.lazy(() => import('./pages/HelpCenterPage')); const HelpArticlePage = React.lazy(() => import('./pages/HelpArticlePage')); const PublicGalleryPage = React.lazy(() => import('./pages/PublicGalleryPage')); +const SharedPhotoPage = React.lazy(() => import('./pages/SharedPhotoPage')); const NotFoundPage = React.lazy(() => import('./pages/NotFoundPage')); function HomeLayout() { @@ -56,6 +57,7 @@ function HomeLayout() { export const router = createBrowserRouter([ { path: '/event', element: }, + { path: '/share/:slug', element: }, { path: '/setup/:token', element: , diff --git a/resources/js/guest/services/photosApi.ts b/resources/js/guest/services/photosApi.ts index b5dcf95..fc6d3b9 100644 --- a/resources/js/guest/services/photosApi.ts +++ b/resources/js/guest/services/photosApi.ts @@ -134,3 +134,43 @@ export async function uploadPhoto(eventToken: string, file: File, taskId?: numbe const json = await res.json(); return json.photo_id ?? json.id ?? json.data?.id ?? 0; } + +export async function createPhotoShareLink(eventToken: string, photoId: number): Promise<{ slug: string; url: string; expires_at?: string }> { + const headers = getCsrfHeaders(); + + const res = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/photos/${photoId}/share`, { + method: 'POST', + credentials: 'include', + headers, + }); + + if (!res.ok) { + let payload: any = null; + try { + payload = await res.clone().json(); + } catch {} + + const error: UploadError = new Error(payload?.error?.message ?? 'Share link creation failed'); + error.code = payload?.error?.code ?? 'share_failed'; + error.status = res.status; + throw error; + } + + return res.json(); +} + +export async function fetchPhotoShare(slug: string) { + const res = await fetch(`/api/v1/photo-shares/${encodeURIComponent(slug)}`, { + headers: { Accept: 'application/json' }, + }); + + if (!res.ok) { + const payload = await res.json().catch(() => null); + const error: UploadError = new Error(payload?.error?.message ?? 'Share link unavailable'); + error.code = payload?.error?.code ?? 'share_unavailable'; + error.status = res.status; + throw error; + } + + return res.json(); +} diff --git a/routes/api.php b/routes/api.php index fa619b5..61dd74f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,8 +1,8 @@ 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::post('/events/{token}/photos/{photo}/share', [EventPublicController::class, 'createShareLink']) + ->whereNumber('photo') + ->name('photos.share'); + Route::get('/photo-shares/{slug}', [EventPublicController::class, 'shareLink'])->name('photo-shares.show'); + Route::get('/photo-shares/{slug}/asset/{variant}', [EventPublicController::class, 'shareLinkAsset']) + ->middleware('signed') + ->name('photo-shares.asset'); Route::post('/events/{token}/upload', [EventPublicController::class, 'upload'])->name('events.upload'); Route::get('/gallery/{token}', [EventPublicController::class, 'gallery'])->name('gallery.show'); diff --git a/routes/web.php b/routes/web.php index 201891f..5150d08 100644 --- a/routes/web.php +++ b/routes/web.php @@ -276,6 +276,9 @@ Route::view('/e/{token}/{path?}', 'guest') ->where('token', '.*') ->where('path', '.*') ->name('guest.event'); +Route::view('/share/{slug}', 'guest') + ->where('slug', '[A-Za-z0-9]+') + ->name('guest.share'); Route::middleware('auth')->group(function () { Route::get('/tenant/events/{event}/photos/archive', EventPhotoArchiveController::class) ->name('tenant.events.photos.archive'); diff --git a/tests/Feature/Api/PhotoShareLinkTest.php b/tests/Feature/Api/PhotoShareLinkTest.php new file mode 100644 index 0000000..74f5119 --- /dev/null +++ b/tests/Feature/Api/PhotoShareLinkTest.php @@ -0,0 +1,86 @@ +create(); + $event = Event::factory()->for($tenant)->create(['status' => 'published']); + $photo = Photo::factory()->for($event)->create([ + 'status' => 'approved', + ]); + + $token = app(EventJoinTokenService::class)->createToken($event)->plain_token; + + $response = $this->withHeaders([ + 'X-Device-Id' => 'device-share', + 'Accept' => 'application/json', + ])->postJson("/api/v1/events/{$token}/photos/{$photo->id}/share"); + + $response->assertOk(); + $response->assertJsonStructure(['slug', 'url', 'expires_at']); + + $this->assertDatabaseHas('photo_share_links', [ + 'photo_id' => $photo->id, + 'slug' => $response->json('slug'), + ]); + } + + public function test_share_link_cannot_be_created_for_unrelated_photo(): void + { + $event = Event::factory()->create(['status' => 'published']); + $otherEvent = Event::factory()->create(['status' => 'published']); + $photo = Photo::factory()->for($otherEvent)->create(['status' => 'approved']); + + $token = app(EventJoinTokenService::class)->createToken($event)->plain_token; + + $response = $this->withHeaders([ + 'X-Device-Id' => 'device-share', + 'Accept' => 'application/json', + ])->postJson("/api/v1/events/{$token}/photos/{$photo->id}/share"); + + $response->assertNotFound(); + } + + public function test_share_payload_exposes_public_photo_data(): void + { + $tenant = Tenant::factory()->create(); + $event = Event::factory()->for($tenant)->create(['status' => 'published']); + $task = Task::factory()->for($tenant)->create(); + $photo = Photo::factory()->for($event)->create([ + 'status' => 'approved', + 'task_id' => $task->id, + ]); + + $share = PhotoShareLink::factory()->for($photo)->create([ + 'expires_at' => now()->addDay(), + ]); + + $response = $this->getJson("/api/v1/photo-shares/{$share->slug}"); + + $response->assertOk(); + $response->assertJsonPath('photo.id', $photo->id); + $response->assertJsonPath('photo.image_urls.full', fn ($value) => str_contains($value, '/photo-shares/')); + $response->assertJsonPath('event.id', $event->id); + } +}