feat: guest selections with per-person identity (#292)
Introduces a new "Per-guest selections" identity mode for event feedback, letting each visitor register under their own name so their likes/favorites/comments/ratings are tracked independently. Includes admin insights (list, per-guest detail, aggregate view, export) and advanced identity features (forget-me, email recovery, invite tokens, merge). New event-level setting - event_feedback_settings.identity_mode = 'simple' | 'guest' (default 'simple' → zero behavior change for existing events). - Admin UI radio under Feedback Settings to toggle per event. Root cause of the previous "all guests share state" bug - generateGuestIdentifier() was sha256(ip + userAgent), so every visitor on the same WiFi + similar device collided into one identity. - Now: when a verified guest JWT is present (x-guest-token header), req.guest.identifier takes precedence — per-person rate limits and per-person deduplication. Phase 1 — identity layer - Migration 078: new gallery_guests, guest_invites, guest_verification_ codes tables; identity_mode column + check constraint; nullable guest_id FK on photo_feedback. - New guest JWT type scoped to (eventId, guestId). - New middleware guestAuth.resolveGuest (non-blocking) + requireGuest. - POST /gallery/:slug/guest, GET /guest/me, DELETE /guest/me. - Gallery feedback route enforces guest identity in guest mode and reads name/email from the verified token (never from the body). - Frontend GuestIdentityContext + GuestNamePromptModal; axios interceptor injects x-guest-token on gallery API calls. - Feedback-only blocking: gallery opens freely, prompt only on first interactive feedback action. - Admin "Guests" tab (conditional on identity_mode='guest') with the AdminGuestsList component. Phase 2 — admin insights - GET /admin/events/:eventId/guests list + aggregated counts. - GET /admin/events/:eventId/guests/:guestId detail with per-type groupings; AdminGuestDetail modal with thumbnail grid + tabs. - GET /admin/events/:eventId/guests/aggregate sorted by distinct guest pick count; GuestSelectionsAggregate component. - Per-guest export (txt/csv/json) and bulk export-all ZIP. Phase 3 — polish - 3.1 Self-service forget-me link in gallery footer. - 3.2 Email-based identity recovery: POST /guest/recover sends a 6-digit code via the existing emailProcessor, POST /guest/verify exchanges it for a token (rate-limited, enumeration-safe). - 3.3 Admin invite tokens: pre-mint identities, share URLs with ?invite=, single-use redemption stripping the param from history. - 3.4 Admin merge endpoint reassigns feedback + soft-deletes sources. Shared helper - useGalleryFeedbackAction hook wraps the identity-check logic for inline like buttons across Masonry/Grid/Justified/Mosaic/Carousel/ Timeline/Premium layouts. Backwards compatibility - Existing events default to 'simple' after migration; behavior unchanged. - Legacy photo_feedback rows keep guest_id NULL; admin shows them in the generic feedback moderation view as before. - feedback_count denormalized stat now uses COALESCE(guest_id, guest_identifier) so per-guest counts are accurate without touching legacy rows. Verified end-to-end against local Docker - Migration clean on existing data. - Simple mode unchanged (no prompt, legacy flow). - Guest mode: Alice registers on click, tokens persist in sessionStorage, feedback rows carry guest_id. - Carol via invite link auto-redeems, sees Alice's "1 likes" badge. - Admin Guests tab shows both with correct counts; detail modal displays thumbnail grid with badges; aggregate view sorts by picker count (photo 227 = 2, others = 1); CSV/JSON export matches DB. - Merge Carol into Alice: feedback reassigned, Carol soft-deleted, Alice count = 4.
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Heart, Bookmark, Star, MessageCircle } from 'lucide-react';
|
||||
import { Loading } from '../common';
|
||||
import { guestsService, AdminGuest } from '../../services/guests.service';
|
||||
import { AuthenticatedImage } from '../common/AuthenticatedImage';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
|
||||
interface AdminGuestDetailProps {
|
||||
eventId: number;
|
||||
guest: AdminGuest;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Tab = 'all' | 'liked' | 'favorited' | 'rated' | 'commented';
|
||||
|
||||
export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, guest, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<Tab>('all');
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-guest-detail', eventId, guest.id],
|
||||
queryFn: () => guestsService.getGuestDetail(eventId, guest.id),
|
||||
});
|
||||
|
||||
const selections = data?.selections;
|
||||
const liked = selections?.liked || [];
|
||||
const favorited = selections?.favorited || [];
|
||||
const rated = selections?.rated || [];
|
||||
const commented = selections?.commented || [];
|
||||
|
||||
// "all" view combines the three visual selection types.
|
||||
type GridItem = { photo: { id: number; filename: string; thumbnail_url: string }; badges: string[] };
|
||||
const allItems: GridItem[] = [];
|
||||
const seen = new Map<number, GridItem>();
|
||||
const add = (photo: { id: number; filename: string; thumbnail_url: string }, badge: string) => {
|
||||
if (!seen.has(photo.id)) {
|
||||
const item: GridItem = { photo, badges: [badge] };
|
||||
seen.set(photo.id, item);
|
||||
allItems.push(item);
|
||||
} else {
|
||||
seen.get(photo.id)!.badges.push(badge);
|
||||
}
|
||||
};
|
||||
liked.forEach((p) => add(p, 'like'));
|
||||
favorited.forEach((p) => add(p, 'favorite'));
|
||||
rated.forEach((r) => add(r.photo, 'rating'));
|
||||
|
||||
const visibleItems: GridItem[] =
|
||||
tab === 'all'
|
||||
? allItems
|
||||
: tab === 'liked'
|
||||
? liked.map((p) => ({ photo: p, badges: ['like'] }))
|
||||
: tab === 'favorited'
|
||||
? favorited.map((p) => ({ photo: p, badges: ['favorite'] }))
|
||||
: tab === 'rated'
|
||||
? rated.map((r) => ({ photo: r.photo, badges: [`${r.rating}★`] }))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto p-4 pt-16">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative bg-white dark:bg-neutral-900 rounded-lg shadow-xl w-full max-w-5xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="p-4 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{guest.name}</h2>
|
||||
{guest.email && (
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">{guest.email}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="p-8">
|
||||
<Loading size="lg" text={t('admin.guests.loadingDetail', 'Loading selections...')} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-y-auto p-4">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-2 mb-4">
|
||||
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded text-center">
|
||||
<div className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{liked.length}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
|
||||
<Heart className="w-3 h-3" />
|
||||
{t('admin.guests.columns.likes', 'Likes')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded text-center">
|
||||
<div className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{favorited.length}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
|
||||
<Bookmark className="w-3 h-3" />
|
||||
{t('admin.guests.columns.favorites', 'Favorites')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded text-center">
|
||||
<div className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{rated.length}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
|
||||
<Star className="w-3 h-3" />
|
||||
{t('admin.guests.columns.ratings', 'Ratings')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded text-center">
|
||||
<div className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{commented.length}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
|
||||
<MessageCircle className="w-3 h-3" />
|
||||
{t('admin.guests.columns.comments', 'Comments')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-neutral-200 dark:border-neutral-700 mb-4">
|
||||
{(['all', 'liked', 'favorited', 'rated', 'commented'] as const).map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => setTab(k)}
|
||||
className={`px-3 py-2 text-sm font-medium border-b-2 transition ${
|
||||
tab === k
|
||||
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'border-transparent text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100'
|
||||
}`}
|
||||
>
|
||||
{t(`admin.guests.detail.${k}`, k)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{tab === 'commented' ? (
|
||||
commented.length === 0 ? (
|
||||
<div className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-8">
|
||||
{t('admin.guests.detail.noComments', 'No comments')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{commented.map((c, idx) => (
|
||||
<div key={idx} className="flex gap-3 p-3 bg-neutral-50 dark:bg-neutral-800 rounded">
|
||||
<AuthenticatedImage
|
||||
src={buildResourceUrl(c.photo.thumbnail_url)}
|
||||
alt={c.photo.filename}
|
||||
className="w-16 h-16 object-cover rounded flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{c.photo.filename} · {new Date(c.created_at).toLocaleString()}
|
||||
</div>
|
||||
<p className="text-sm text-neutral-900 dark:text-neutral-100 mt-1">{c.comment}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : visibleItems.length === 0 ? (
|
||||
<div className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-8">
|
||||
{t('admin.guests.detail.empty', 'No selections in this category')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
|
||||
{visibleItems.map((item) => (
|
||||
<div key={item.photo.id} className="relative group">
|
||||
<AuthenticatedImage
|
||||
src={buildResourceUrl(item.photo.thumbnail_url)}
|
||||
alt={item.photo.filename}
|
||||
className="w-full aspect-square object-cover rounded"
|
||||
/>
|
||||
<div className="absolute top-1 right-1 flex gap-1">
|
||||
{item.badges.map((b, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="bg-black/60 text-white text-xs px-1.5 py-0.5 rounded"
|
||||
>
|
||||
{b === 'like' ? '♥' : b === 'favorite' ? '★' : b}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent opacity-0 group-hover:opacity-100 transition-opacity text-white text-xs p-2 rounded-b">
|
||||
{item.photo.filename}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,344 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Trash2, Eye, Download, UserPlus, Grid3x3, List } from 'lucide-react';
|
||||
import { Card, Button, Loading } from '../common';
|
||||
import { guestsService, AdminGuest } from '../../services/guests.service';
|
||||
import { AdminGuestDetail } from './AdminGuestDetail';
|
||||
import { GuestSelectionsAggregate } from './GuestSelectionsAggregate';
|
||||
import { GuestInviteDialog } from './GuestInviteDialog';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface AdminGuestsListProps {
|
||||
eventId: number;
|
||||
eventName?: string;
|
||||
}
|
||||
|
||||
type View = 'list' | 'aggregate';
|
||||
|
||||
export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, eventName }) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [view, setView] = useState<View>('list');
|
||||
const [selectedGuest, setSelectedGuest] = useState<AdminGuest | null>(null);
|
||||
const [mergeMode, setMergeMode] = useState(false);
|
||||
const [mergeSelection, setMergeSelection] = useState<number[]>([]);
|
||||
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['admin-guests', eventId],
|
||||
queryFn: () => guestsService.getEventGuests(eventId),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (guestId: number) => guestsService.deleteGuest(eventId, guestId),
|
||||
onSuccess: () => {
|
||||
toast.success(t('admin.guests.deletedToast', 'Guest removed'));
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-guests', eventId] });
|
||||
},
|
||||
onError: () => toast.error(t('admin.guests.deletedError', 'Failed to remove guest')),
|
||||
});
|
||||
|
||||
const mergeMutation = useMutation({
|
||||
mutationFn: ({ keepId, mergeIds }: { keepId: number; mergeIds: number[] }) =>
|
||||
guestsService.mergeGuests(eventId, keepId, mergeIds),
|
||||
onSuccess: () => {
|
||||
toast.success(t('admin.guests.mergedToast', 'Guests merged'));
|
||||
setMergeMode(false);
|
||||
setMergeSelection([]);
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-guests', eventId] });
|
||||
},
|
||||
onError: () => toast.error(t('admin.guests.mergedError', 'Failed to merge guests')),
|
||||
});
|
||||
|
||||
const handleDelete = (guest: AdminGuest) => {
|
||||
if (window.confirm(t('admin.guests.forgetGuestConfirm', 'Remove this guest? Their picks will be anonymized but kept in aggregate totals.'))) {
|
||||
deleteMutation.mutate(guest.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async (guest: AdminGuest, format: 'txt' | 'csv' | 'json') => {
|
||||
try {
|
||||
const blob = await guestsService.exportGuest(eventId, guest.id, format);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${guest.name.replace(/[^a-zA-Z0-9_-]/g, '_')}.${format}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
toast.error(t('admin.guests.exportError', 'Export failed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportAll = async (format: 'txt' | 'csv' | 'json') => {
|
||||
try {
|
||||
const blob = await guestsService.exportAllGuests(eventId, format);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `event-${eventId}-guests.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
toast.error(t('admin.guests.exportError', 'Export failed'));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMergeSelection = (id: number) => {
|
||||
setMergeSelection((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const performMerge = () => {
|
||||
if (mergeSelection.length < 2) {
|
||||
toast.warning(t('admin.guests.mergeSelectAtLeastTwo', 'Select at least 2 guests to merge'));
|
||||
return;
|
||||
}
|
||||
const [keepId, ...mergeIds] = mergeSelection;
|
||||
const keepName = data?.guests.find((g) => g.id === keepId)?.name;
|
||||
const confirmMsg = t(
|
||||
'admin.guests.mergeConfirm',
|
||||
'Merge {{count}} guests into {{name}}? This cannot be undone.',
|
||||
{ count: mergeSelection.length, name: keepName || '#' + keepId }
|
||||
);
|
||||
if (window.confirm(confirmMsg)) {
|
||||
mergeMutation.mutate({ keepId, mergeIds });
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading size="lg" text={t('admin.guests.loading', 'Loading guests...')} />;
|
||||
}
|
||||
|
||||
const guests = data?.guests || [];
|
||||
|
||||
if (view === 'aggregate') {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setView('list')} leftIcon={<List className="w-4 h-4" />}>
|
||||
{t('admin.guests.backToList', 'Back to list')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<GuestSelectionsAggregate eventId={eventId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{t('admin.guests.title', 'Guests')} ({guests.length})
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{mergeMode ? (
|
||||
<>
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('admin.guests.mergeSelected', '{{count}} selected', { count: mergeSelection.length })}
|
||||
</span>
|
||||
<Button variant="primary" size="sm" onClick={performMerge} disabled={mergeSelection.length < 2}>
|
||||
{t('admin.guests.mergeNow', 'Merge selected')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => { setMergeMode(false); setMergeSelection([]); }}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<UserPlus className="w-4 h-4" />}
|
||||
onClick={() => setInviteDialogOpen(true)}
|
||||
>
|
||||
{t('admin.guests.createInvite', 'Create invite')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Grid3x3 className="w-4 h-4" />}
|
||||
onClick={() => setView('aggregate')}
|
||||
>
|
||||
{t('admin.guests.aggregateView', 'By popularity')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setMergeMode(true)}
|
||||
disabled={guests.length < 2}
|
||||
>
|
||||
{t('admin.guests.mergeMode', 'Merge')}
|
||||
</Button>
|
||||
<div className="relative group">
|
||||
<Button variant="outline" size="sm" leftIcon={<Download className="w-4 h-4" />}>
|
||||
{t('admin.guests.exportAll', 'Export all')}
|
||||
</Button>
|
||||
<div className="absolute right-0 top-full mt-1 hidden group-hover:block bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded shadow-lg z-10 min-w-[120px]">
|
||||
{(['csv', 'txt', 'json'] as const).map((fmt) => (
|
||||
<button
|
||||
key={fmt}
|
||||
onClick={() => handleExportAll(fmt)}
|
||||
className="block w-full text-left px-3 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700"
|
||||
>
|
||||
{fmt.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{guests.length === 0 ? (
|
||||
<Card>
|
||||
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">
|
||||
{t('admin.guests.empty', 'No guests have registered yet.')}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-neutral-50 dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<tr>
|
||||
{mergeMode && <th className="px-4 py-3 w-8" />}
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
|
||||
{t('admin.guests.columns.name', 'Name')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
|
||||
{t('admin.guests.columns.email', 'Email')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
|
||||
{t('admin.guests.columns.likes', 'Likes')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
|
||||
{t('admin.guests.columns.favorites', 'Favorites')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
|
||||
{t('admin.guests.columns.comments', 'Comments')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
|
||||
{t('admin.guests.columns.ratings', 'Ratings')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
|
||||
{t('admin.guests.columns.lastSeen', 'Last seen')}
|
||||
</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||
{guests.map((guest) => (
|
||||
<tr key={guest.id} className="hover:bg-neutral-50 dark:hover:bg-neutral-800">
|
||||
{mergeMode && (
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mergeSelection.includes(guest.id)}
|
||||
onChange={() => toggleMergeSelection(guest.id)}
|
||||
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-4 py-3 font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{guest.name}
|
||||
{guest.email_verified_at && (
|
||||
<span className="ml-2 text-xs text-green-600">✓</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{guest.email || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{guest.stats.likes}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{guest.stats.favorites}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{guest.stats.comments}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{guest.stats.ratings}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{new Date(guest.last_seen_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedGuest(guest)}
|
||||
className="p-1 text-neutral-500 hover:text-primary-600"
|
||||
title={t('admin.guests.view', 'View details')}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="relative group">
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 text-neutral-500 hover:text-primary-600"
|
||||
title={t('admin.guests.export', 'Export')}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="absolute right-0 top-full mt-1 hidden group-hover:block bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded shadow-lg z-10 min-w-[100px]">
|
||||
{(['csv', 'txt', 'json'] as const).map((fmt) => (
|
||||
<button
|
||||
key={fmt}
|
||||
onClick={() => handleExport(guest, fmt)}
|
||||
className="block w-full text-left px-3 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700"
|
||||
>
|
||||
{fmt.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(guest)}
|
||||
className="p-1 text-neutral-500 hover:text-red-600"
|
||||
title={t('admin.guests.forgetGuest', 'Remove guest')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{selectedGuest && (
|
||||
<AdminGuestDetail
|
||||
eventId={eventId}
|
||||
guest={selectedGuest}
|
||||
onClose={() => setSelectedGuest(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{inviteDialogOpen && (
|
||||
<GuestInviteDialog
|
||||
eventId={eventId}
|
||||
eventName={eventName}
|
||||
onClose={() => {
|
||||
setInviteDialogOpen(false);
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { MessageSquare, Star, Heart, Bookmark, Shield, Eye } from 'lucide-react';
|
||||
import { MessageSquare, Star, Heart, Bookmark, Shield, Eye, User, Users } from 'lucide-react';
|
||||
import { Card } from '../common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -21,6 +21,7 @@ interface FeedbackSettings {
|
||||
enable_rate_limiting: boolean;
|
||||
rate_limit_window_minutes?: number;
|
||||
rate_limit_max_requests?: number;
|
||||
identity_mode?: 'simple' | 'guest';
|
||||
}
|
||||
|
||||
export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
@@ -70,6 +71,74 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
|
||||
{settings.feedback_enabled && (
|
||||
<>
|
||||
{/* Identity Mode */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
{t('feedback.settings.identityMode', 'Identity Mode')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<label
|
||||
className={`flex items-start gap-3 p-3 rounded-lg cursor-pointer border transition ${
|
||||
(settings.identity_mode || 'simple') === 'simple'
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="identity_mode"
|
||||
value="simple"
|
||||
checked={(settings.identity_mode || 'simple') === 'simple'}
|
||||
onChange={() => onChange({ ...settings, identity_mode: 'simple' })}
|
||||
className="mt-0.5 w-4 h-4 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<User className="w-5 h-5 mt-0.5 text-neutral-600 dark:text-neutral-400" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{t('feedback.settings.identityModeSimple', 'Simple feedback')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t(
|
||||
'feedback.settings.identityModeSimpleDesc',
|
||||
'Anonymous, device-based. All visitors on the same device share state.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label
|
||||
className={`flex items-start gap-3 p-3 rounded-lg cursor-pointer border transition ${
|
||||
settings.identity_mode === 'guest'
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="identity_mode"
|
||||
value="guest"
|
||||
checked={settings.identity_mode === 'guest'}
|
||||
onChange={() => onChange({ ...settings, identity_mode: 'guest' })}
|
||||
className="mt-0.5 w-4 h-4 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<Users className="w-5 h-5 mt-0.5 text-neutral-600 dark:text-neutral-400" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{t('feedback.settings.identityModeGuest', 'Per-guest selections')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t(
|
||||
'feedback.settings.identityModeGuestDesc',
|
||||
'Each visitor enters their name. Enables per-guest tracking and admin insights.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-4" />
|
||||
|
||||
{/* Feedback Types */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Copy, Check, Trash2 } from 'lucide-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button, Input, Loading } from '../common';
|
||||
import { guestsService, GuestInvite } from '../../services/guests.service';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface GuestInviteDialogProps {
|
||||
eventId: number;
|
||||
eventName?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin dialog to create pre-minted invite tokens and list existing ones.
|
||||
* Each invite generates a unique URL that the admin can send to a specific
|
||||
* guest. Opening the URL auto-registers that guest (single use).
|
||||
*/
|
||||
export const GuestInviteDialog: React.FC<GuestInviteDialogProps> = ({ eventId, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [copiedId, setCopiedId] = useState<number | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-guest-invites', eventId],
|
||||
queryFn: () => guestsService.listInvites(eventId),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => guestsService.createInvite(eventId, { name, email: email || undefined }),
|
||||
onSuccess: () => {
|
||||
setName('');
|
||||
setEmail('');
|
||||
toast.success(t('admin.guests.inviteCreated', 'Invite created'));
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-guest-invites', eventId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-guests', eventId] });
|
||||
},
|
||||
onError: () => toast.error(t('admin.guests.inviteCreateError', 'Failed to create invite')),
|
||||
});
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: (inviteId: number) => guestsService.revokeInvite(eventId, inviteId),
|
||||
onSuccess: () => {
|
||||
toast.success(t('admin.guests.inviteRevoked', 'Invite revoked'));
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-guest-invites', eventId] });
|
||||
},
|
||||
onError: () => toast.error(t('admin.guests.inviteRevokeError', 'Failed to revoke invite')),
|
||||
});
|
||||
|
||||
const copy = (invite: GuestInvite) => {
|
||||
navigator.clipboard.writeText(invite.url).then(() => {
|
||||
setCopiedId(invite.id);
|
||||
setTimeout(() => setCopiedId(null), 1500);
|
||||
});
|
||||
};
|
||||
|
||||
const invites = data?.invites || [];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 pt-16">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative bg-white dark:bg-neutral-900 rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="p-4 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{t('admin.guests.invitesTitle', 'Guest invites')}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto p-4 space-y-4">
|
||||
{/* Create form */}
|
||||
<div className="p-4 bg-neutral-50 dark:bg-neutral-800 rounded">
|
||||
<h3 className="text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-3">
|
||||
{t('admin.guests.createInvite', 'Create invite')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3">
|
||||
<Input
|
||||
label={t('admin.guests.inviteName', 'Guest name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Alice"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="email"
|
||||
label={t('admin.guests.inviteEmail', 'Email (optional)')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={!name.trim() || createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending
|
||||
? t('common.submitting', 'Submitting...')
|
||||
: t('admin.guests.generateInvite', 'Generate invite link')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Existing invites */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-2">
|
||||
{t('admin.guests.existingInvites', 'Existing invites')}
|
||||
</h3>
|
||||
{isLoading ? (
|
||||
<Loading size="sm" />
|
||||
) : invites.length === 0 ? (
|
||||
<div className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-4">
|
||||
{t('admin.guests.noInvites', 'No invites yet')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{invites.map((invite) => (
|
||||
<div
|
||||
key={invite.id}
|
||||
className="p-3 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{invite.guest.name}
|
||||
{invite.guest.email && (
|
||||
<span className="text-neutral-500 dark:text-neutral-400 font-normal ml-2">
|
||||
· {invite.guest.email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs mt-1">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full font-medium ${
|
||||
invite.status === 'redeemed'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
|
||||
: invite.status === 'revoked'
|
||||
? 'bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300'
|
||||
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
}`}
|
||||
>
|
||||
{t(`admin.guests.inviteStatus.${invite.status}`, invite.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 truncate mt-1 font-mono">
|
||||
{invite.url}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{invite.status === 'pending' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copy(invite)}
|
||||
className="p-1.5 text-neutral-500 hover:text-primary-600"
|
||||
title={t('admin.guests.copyLink', 'Copy link')}
|
||||
>
|
||||
{copiedId === invite.id ? (
|
||||
<Check className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => revokeMutation.mutate(invite.id)}
|
||||
className="p-1.5 text-neutral-500 hover:text-red-600"
|
||||
title={t('admin.guests.revokeInvite', 'Revoke')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Users } from 'lucide-react';
|
||||
import { Card, Loading } from '../common';
|
||||
import { guestsService } from '../../services/guests.service';
|
||||
import { AuthenticatedImage } from '../common/AuthenticatedImage';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
|
||||
interface GuestSelectionsAggregateProps {
|
||||
eventId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows photos sorted by the number of distinct guests who liked or
|
||||
* favorited them. Photos with zero picks are filtered server-side.
|
||||
*/
|
||||
export const GuestSelectionsAggregate: React.FC<GuestSelectionsAggregateProps> = ({ eventId }) => {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-guests-aggregate', eventId],
|
||||
queryFn: () => guestsService.getAggregatePicks(eventId),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading size="lg" text={t('admin.guests.loading', 'Loading...')} />;
|
||||
}
|
||||
|
||||
const photos = data?.photos || [];
|
||||
|
||||
if (photos.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">
|
||||
{t('admin.guests.aggregate.empty', 'No guest picks yet.')}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t(
|
||||
'admin.guests.aggregate.description',
|
||||
'Photos sorted by how many distinct guests liked or favorited them.'
|
||||
)}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||
{photos.map((p) => (
|
||||
<div key={p.id} className="relative group">
|
||||
<AuthenticatedImage
|
||||
src={buildResourceUrl(p.thumbnail_url)}
|
||||
alt={p.filename}
|
||||
className="w-full aspect-square object-cover rounded"
|
||||
/>
|
||||
<div className="absolute top-2 right-2 bg-primary-600 text-white text-xs font-semibold px-2 py-1 rounded-full flex items-center gap-1 shadow">
|
||||
<Users className="w-3 h-3" />
|
||||
{p.picker_count}
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent opacity-0 group-hover:opacity-100 transition-opacity text-white text-xs p-2 rounded-b">
|
||||
{p.original_filename || p.filename}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -36,3 +36,7 @@ export { EventRenameDialog } from './EventRenameDialog';
|
||||
export { PhotoFilterPanel } from './PhotoFilterPanel';
|
||||
export { PhotoExportMenu } from './PhotoExportMenu';
|
||||
export { CssTemplateEditor } from './CssTemplateEditor';
|
||||
export { AdminGuestsList } from './AdminGuestsList';
|
||||
export { AdminGuestDetail } from './AdminGuestDetail';
|
||||
export { GuestSelectionsAggregate } from './GuestSelectionsAggregate';
|
||||
export { GuestInviteDialog } from './GuestInviteDialog';
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { Button } from '../common';
|
||||
import { DynamicFavicon } from '../common/DynamicFavicon';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
import type { HeaderStyleType } from '../../types/theme.types';
|
||||
|
||||
@@ -59,6 +60,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
const { theme } = useTheme();
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
|
||||
// Determine header style - use prop first (from event data), then theme, then fall back to 'standard'
|
||||
const headerStyle: HeaderStyleType = headerStyleProp || theme.headerStyle || 'standard';
|
||||
@@ -589,20 +591,36 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
</p>
|
||||
)}
|
||||
{/* Legal Links */}
|
||||
<div className="mt-4 flex items-center justify-center gap-4">
|
||||
<Link
|
||||
to="/impressum"
|
||||
<div className="mt-4 flex items-center justify-center gap-4 flex-wrap">
|
||||
<Link
|
||||
to="/impressum"
|
||||
className="text-xs text-muted-theme hover:text-theme transition-colors"
|
||||
>
|
||||
{t('legal.impressum')}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-theme">|</span>
|
||||
<Link
|
||||
to="/datenschutz"
|
||||
<Link
|
||||
to="/datenschutz"
|
||||
className="text-xs text-muted-theme hover:text-theme transition-colors"
|
||||
>
|
||||
{t('legal.datenschutz')}
|
||||
</Link>
|
||||
{guestIdentity?.identity && (
|
||||
<>
|
||||
<span className="text-xs text-muted-theme">|</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-theme hover:text-theme transition-colors"
|
||||
onClick={async () => {
|
||||
if (window.confirm(t('gallery.footer.forgetMeConfirm', 'Your name and selections will be removed from this gallery.'))) {
|
||||
await guestIdentity.forget();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('gallery.footer.forgetMe', 'Forget me ({{name}})', { name: guestIdentity.identity.name })}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -13,6 +13,9 @@ import { GalleryLayout } from './GalleryLayout';
|
||||
import { GallerySidebar } from './GallerySidebar';
|
||||
import { PhotoFilterBar } from './PhotoFilterBar';
|
||||
import { UserPhotoUpload } from './UserPhotoUpload';
|
||||
import { GuestNamePromptModal } from './GuestNamePromptModal';
|
||||
import { GuestRecoveryModal } from './GuestRecoveryModal';
|
||||
import { GuestIdentityProvider } from '../../contexts/GuestIdentityContext';
|
||||
import type { FilterType } from './GalleryFilter';
|
||||
import { analyticsService } from '../../services/analytics.service';
|
||||
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
|
||||
@@ -701,8 +704,14 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const identityMode: 'simple' | 'guest' =
|
||||
feedbackSettings?.identity_mode === 'guest' ? 'guest' : 'simple';
|
||||
|
||||
return (
|
||||
<GuestIdentityProvider slug={slug} identityMode={identityMode}>
|
||||
<>
|
||||
<GuestNamePromptModal requireEmail={!!feedbackSettings?.require_name_email} />
|
||||
<GuestRecoveryModal />
|
||||
{/* Sidebar for non-grid layouts */}
|
||||
{showSidebar ? (
|
||||
<GallerySidebar
|
||||
@@ -915,5 +924,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
)}
|
||||
</GalleryLayout>
|
||||
</>
|
||||
</GuestIdentityProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Input } from '../common';
|
||||
import { useGuestIdentity } from '../../contexts/GuestIdentityContext';
|
||||
|
||||
interface GuestNamePromptModalProps {
|
||||
requireEmail?: boolean;
|
||||
allowCancel?: boolean;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session-wide prompt shown in guest identity mode when no identity exists
|
||||
* yet. Triggered by `ensureIdentity()` on the first interactive feedback
|
||||
* attempt, or manually via `openPrompt()`.
|
||||
*
|
||||
* Includes a link to the recovery flow for users who already registered on
|
||||
* another device.
|
||||
*/
|
||||
export const GuestNamePromptModal: React.FC<GuestNamePromptModalProps> = ({
|
||||
requireEmail = false,
|
||||
allowCancel = true,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { promptOpen, closePrompt, register, openRecovery } = useGuestIdentity();
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
if (!promptOpen) return null;
|
||||
|
||||
const handleClose = () => {
|
||||
setName('');
|
||||
setEmail('');
|
||||
setErrors({});
|
||||
setSubmitError(null);
|
||||
closePrompt();
|
||||
onCancel?.();
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!name.trim()) {
|
||||
newErrors.name = t('gallery.guestPrompt.nameRequired', 'Name is required');
|
||||
}
|
||||
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
newErrors.email = t('gallery.guestPrompt.invalidEmail', 'Invalid email address');
|
||||
}
|
||||
if (requireEmail && !email.trim()) {
|
||||
newErrors.email = t('gallery.guestPrompt.emailRequired', 'Email is required');
|
||||
}
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
await register(name.trim(), email.trim() || undefined);
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
setSubmitError(error.response?.data?.error || t('gallery.guestPrompt.error', 'Registration failed'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={allowCancel ? handleClose : undefined} />
|
||||
<div className="relative bg-surface rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
{allowCancel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="absolute top-4 right-4 p-1 hover:bg-black/10 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-muted-theme" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<h2 className="text-lg font-semibold text-theme mb-2">
|
||||
{t('gallery.guestPrompt.title', "Welcome — what's your name?")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-theme mb-4">
|
||||
{t(
|
||||
'gallery.guestPrompt.description',
|
||||
'Your picks will be saved under this name so the photographer knows which photos you love.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label={t('gallery.guestPrompt.nameLabel', 'Your name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
error={errors.name}
|
||||
placeholder={t('gallery.guestPrompt.namePlaceholder', 'Enter your name')}
|
||||
autoFocus
|
||||
required
|
||||
maxLength={100}
|
||||
/>
|
||||
<Input
|
||||
type="email"
|
||||
label={
|
||||
requireEmail
|
||||
? t('gallery.guestPrompt.emailLabelRequired', 'Email')
|
||||
: t('gallery.guestPrompt.emailLabel', 'Email (optional)')
|
||||
}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
error={errors.email}
|
||||
placeholder={t('gallery.guestPrompt.emailPlaceholder', '[email protected]')}
|
||||
maxLength={255}
|
||||
/>
|
||||
|
||||
{submitError && (
|
||||
<div className="text-sm text-red-600 bg-red-50 dark:bg-red-900/20 rounded px-3 py-2">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button type="submit" variant="primary" className="flex-1" disabled={submitting}>
|
||||
{submitting
|
||||
? t('common.submitting', 'Submitting...')
|
||||
: t('gallery.guestPrompt.submit', 'Continue')}
|
||||
</Button>
|
||||
{allowCancel && (
|
||||
<Button type="button" variant="ghost" onClick={handleClose} disabled={submitting}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
closePrompt();
|
||||
openRecovery();
|
||||
}}
|
||||
className="text-sm text-primary-600 hover:underline w-full text-center pt-2"
|
||||
>
|
||||
{t('gallery.guestPrompt.alreadyHere', "I've been here before")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, ArrowLeft } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Input } from '../common';
|
||||
import { useGuestIdentity } from '../../contexts/GuestIdentityContext';
|
||||
|
||||
/**
|
||||
* Email-based identity recovery flow (Phase 3.2).
|
||||
*
|
||||
* Two steps:
|
||||
* 1) Enter email → server sends a 6-digit code.
|
||||
* 2) Enter code → server returns a guest token, identity restored.
|
||||
*
|
||||
* Opens when the user clicks "I've been here before" in the name prompt.
|
||||
*/
|
||||
export const GuestRecoveryModal: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { recoveryOpen, closeRecovery, recoverRequest, recoverVerify, openPrompt } =
|
||||
useGuestIdentity();
|
||||
|
||||
const [step, setStep] = useState<'email' | 'code'>('email');
|
||||
const [email, setEmail] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
|
||||
if (!recoveryOpen) return null;
|
||||
|
||||
const reset = () => {
|
||||
setStep('email');
|
||||
setEmail('');
|
||||
setCode('');
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
reset();
|
||||
closeRecovery();
|
||||
};
|
||||
|
||||
const backToPrompt = () => {
|
||||
reset();
|
||||
closeRecovery();
|
||||
openPrompt();
|
||||
};
|
||||
|
||||
const handleRequestCode = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
setError(t('gallery.guestRecovery.invalidEmail', 'Enter a valid email address'));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await recoverRequest(email.trim().toLowerCase());
|
||||
setInfo(t('gallery.guestRecovery.codeSent', 'Check your inbox for a verification code.'));
|
||||
setStep('code');
|
||||
} catch {
|
||||
setError(t('gallery.guestRecovery.requestError', 'Could not send code. Try again.'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!/^\d{6}$/.test(code.trim())) {
|
||||
setError(t('gallery.guestRecovery.invalidCode', 'Enter the 6-digit code'));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await recoverVerify(email.trim().toLowerCase(), code.trim());
|
||||
// Success: context clears recoveryOpen on success, component will
|
||||
// unmount naturally.
|
||||
} catch {
|
||||
setError(t('gallery.guestRecovery.verifyError', 'Invalid or expired code.'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={handleClose} />
|
||||
<div className="relative bg-surface rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="absolute top-4 right-4 p-1 hover:bg-black/10 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-muted-theme" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={backToPrompt}
|
||||
className="flex items-center gap-1 text-sm text-muted-theme hover:text-theme mb-3"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t('gallery.guestRecovery.back', 'Back')}
|
||||
</button>
|
||||
|
||||
<h2 className="text-lg font-semibold text-theme mb-2">
|
||||
{t('gallery.guestRecovery.title', 'Recover your picks')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-theme mb-4">
|
||||
{step === 'email'
|
||||
? t(
|
||||
'gallery.guestRecovery.emailStepDescription',
|
||||
'Enter the email you used before. We will send a 6-digit verification code.'
|
||||
)
|
||||
: t(
|
||||
'gallery.guestRecovery.codeStepDescription',
|
||||
'Enter the 6-digit code we sent to your email.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
{info && step === 'code' && (
|
||||
<div className="text-sm text-green-700 bg-green-50 dark:bg-green-900/20 rounded px-3 py-2 mb-3">
|
||||
{info}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 bg-red-50 dark:bg-red-900/20 rounded px-3 py-2 mb-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'email' ? (
|
||||
<form onSubmit={handleRequestCode} className="space-y-4">
|
||||
<Input
|
||||
type="email"
|
||||
label={t('gallery.guestRecovery.emailLabel', 'Email')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="[email protected]"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<Button type="submit" variant="primary" className="w-full" disabled={submitting}>
|
||||
{submitting
|
||||
? t('common.submitting', 'Submitting...')
|
||||
: t('gallery.guestRecovery.sendCode', 'Send code')}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleVerify} className="space-y-4">
|
||||
<Input
|
||||
label={t('gallery.guestRecovery.codeLabel', 'Verification code')}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="123456"
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<Button type="submit" variant="primary" className="w-full" disabled={submitting}>
|
||||
{submitting
|
||||
? t('common.submitting', 'Submitting...')
|
||||
: t('gallery.guestRecovery.verifyCode', 'Verify and continue')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { toast } from 'react-toastify';
|
||||
import { format } from 'date-fns';
|
||||
import { Button, Input } from '../common';
|
||||
import type { PhotoFeedback } from '../../services/feedback.service';
|
||||
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
||||
|
||||
interface PhotoCommentsProps {
|
||||
photoId: string;
|
||||
@@ -29,6 +30,8 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const isGuestMode = guestIdentity?.identityMode === 'guest';
|
||||
const [showCommentForm, setShowCommentForm] = useState(false);
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const [guestName, setGuestName] = useState('');
|
||||
@@ -78,7 +81,7 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmitComment = (e: React.FormEvent) => {
|
||||
const handleSubmitComment = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrors({});
|
||||
|
||||
@@ -87,7 +90,9 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
||||
if (!commentText.trim()) {
|
||||
newErrors.comment_text = t('feedback.commentRequired', 'Comment is required');
|
||||
}
|
||||
if (requireNameEmail) {
|
||||
// In guest identity mode, name/email come from the guest token — don't
|
||||
// ask for them here.
|
||||
if (requireNameEmail && !isGuestMode) {
|
||||
if (!guestName.trim()) {
|
||||
newErrors.guest_name = t('feedback.nameRequired', 'Name is required');
|
||||
}
|
||||
@@ -101,6 +106,16 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGuestMode && guestIdentity) {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
submitCommentMutation.mutate({ comment_text: commentText.trim() });
|
||||
return;
|
||||
}
|
||||
|
||||
submitCommentMutation.mutate({
|
||||
comment_text: commentText.trim(),
|
||||
guest_name: guestName.trim() || undefined,
|
||||
@@ -140,7 +155,7 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
||||
{/* Comment Form */}
|
||||
{showCommentForm && (
|
||||
<form onSubmit={handleSubmitComment} className="space-y-3 p-4 bg-surface rounded-lg border border-surface">
|
||||
{requireNameEmail && (
|
||||
{requireNameEmail && !isGuestMode && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Input
|
||||
placeholder={t('feedback.yourName', 'Your name')}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { toast } from 'react-toastify';
|
||||
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
|
||||
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
||||
|
||||
interface PhotoFavoritesProps {
|
||||
photoId: string;
|
||||
@@ -27,6 +28,7 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [animating, setAnimating] = useState(false);
|
||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||
@@ -68,9 +70,19 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
|
||||
}
|
||||
});
|
||||
|
||||
const handleFavoriteClick = () => {
|
||||
const handleFavoriteClick = async () => {
|
||||
if (!isEnabled || isSubmitting) return;
|
||||
|
||||
|
||||
if (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
submitFavoriteMutation.mutate({});
|
||||
return;
|
||||
}
|
||||
|
||||
if (requireNameEmail && !savedIdentity) {
|
||||
setShowIdentityModal(true);
|
||||
} else {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { toast } from 'react-toastify';
|
||||
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
|
||||
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
||||
|
||||
interface PhotoLikesProps {
|
||||
photoId: string;
|
||||
@@ -27,6 +28,7 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [animating, setAnimating] = useState(false);
|
||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||
@@ -68,9 +70,23 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
|
||||
}
|
||||
});
|
||||
|
||||
const handleLikeClick = () => {
|
||||
const handleLikeClick = async () => {
|
||||
if (!isEnabled || isSubmitting) return;
|
||||
|
||||
|
||||
// Guest identity mode: ensure we have a per-person guest token. The
|
||||
// server will read name/email from the token — body values are ignored.
|
||||
if (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
// User cancelled the prompt — silently abort.
|
||||
return;
|
||||
}
|
||||
submitLikeMutation.mutate({});
|
||||
return;
|
||||
}
|
||||
|
||||
// Simple mode (or no provider at all): legacy inline prompt flow.
|
||||
if (requireNameEmail && !savedIdentity) {
|
||||
setShowIdentityModal(true);
|
||||
} else {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { toast } from 'react-toastify';
|
||||
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
|
||||
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
||||
|
||||
interface PhotoRatingProps {
|
||||
photoId: string;
|
||||
@@ -31,6 +32,7 @@ export const PhotoRating: React.FC<PhotoRatingProps> = ({
|
||||
const safeAverageRating = typeof averageRating === 'number' && !isNaN(averageRating) ? averageRating : 0;
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const [hoveredRating, setHoveredRating] = useState(0);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||
@@ -72,18 +74,28 @@ export const PhotoRating: React.FC<PhotoRatingProps> = ({
|
||||
}
|
||||
});
|
||||
|
||||
const handleRatingClick = (rating: number) => {
|
||||
const handleRatingClick = async (rating: number) => {
|
||||
if (!isEnabled || isSubmitting) return;
|
||||
|
||||
|
||||
// If clicking the same rating, remove it
|
||||
const newRating = rating === currentRating ? 0 : rating;
|
||||
|
||||
|
||||
if (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
submitRatingMutation.mutate({ rating: newRating });
|
||||
return;
|
||||
}
|
||||
|
||||
if (requireNameEmail && !savedIdentity) {
|
||||
setPendingRating(newRating);
|
||||
setShowIdentityModal(true);
|
||||
} else {
|
||||
submitRatingMutation.mutate({
|
||||
rating: newRating,
|
||||
submitRatingMutation.mutate({
|
||||
rating: newRating,
|
||||
guest_name: savedIdentity?.name,
|
||||
guest_email: savedIdentity?.email
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AuthenticatedImage, Button } from '../../common';
|
||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||
import { feedbackService } from '../../../services/feedback.service';
|
||||
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
|
||||
|
||||
export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
photos,
|
||||
@@ -66,6 +67,7 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
|
||||
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
|
||||
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
|
||||
|
||||
@@ -150,6 +152,20 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setLikedIds(prev => new Set(prev).add(currentPhoto.id));
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug!, String(currentPhoto.id), {
|
||||
feedback_type: 'like',
|
||||
});
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
||||
setPendingAction({ type: 'like', photoId: currentPhoto.id });
|
||||
setShowIdentityModal(true);
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import type { Photo } from '../../../types';
|
||||
import { AuthenticatedImage } from '../../common';
|
||||
import { feedbackService } from '../../../services/feedback.service';
|
||||
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
|
||||
import { FeedbackIdentityModal } from '../FeedbackIdentityModal';
|
||||
import { galleryService } from '../../../services/gallery.service';
|
||||
import { analyticsService } from '../../../services/analytics.service';
|
||||
@@ -188,6 +189,7 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||
const [likedPhotoIds, setLikedPhotoIds] = useState<Set<number>>(new Set());
|
||||
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||
const [pendingLikePhotoId, setPendingLikePhotoId] = useState<number | null>(null);
|
||||
|
||||
@@ -237,6 +239,28 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
||||
const handleLike = useCallback(async (photo: Photo, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setLikedPhotoIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(photo.id);
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug, String(photo.id), {
|
||||
feedback_type: 'like',
|
||||
});
|
||||
onFeedbackChange?.();
|
||||
} catch (err) {
|
||||
console.warn('Like submit failed', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
||||
setPendingLikePhotoId(photo.id);
|
||||
setShowIdentityModal(true);
|
||||
@@ -260,7 +284,7 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
||||
} catch (err) {
|
||||
console.warn('Like submit failed', err);
|
||||
}
|
||||
}, [slug, savedIdentity, feedbackOptions, onFeedbackChange]);
|
||||
}, [slug, savedIdentity, feedbackOptions, onFeedbackChange, guestIdentity]);
|
||||
|
||||
const handleIdentitySubmit = useCallback(async (name: string, email: string) => {
|
||||
setSavedIdentity({ name, email });
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { AuthenticatedImage } from '../../common';
|
||||
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||
import { feedbackService } from '../../../services/feedback.service';
|
||||
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
|
||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import type { Photo } from '../../../types';
|
||||
|
||||
@@ -61,6 +62,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
onLikeSuccess
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const [overlayVisible, setOverlayVisible] = React.useState(false);
|
||||
const [isTouchDevice, setIsTouchDevice] = React.useState(false);
|
||||
const overlayTimeoutRef = React.useRef<number | null>(null);
|
||||
@@ -259,6 +261,25 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
className={`p-2 rounded-full transition-colors ${liked ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
hideOverlay();
|
||||
return;
|
||||
}
|
||||
if (onLikeSuccess) onLikeSuccess();
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||
feedback_type: 'like',
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Like submit failed, keeping optimistic UI', err);
|
||||
}
|
||||
if (onFeedbackChange) onFeedbackChange();
|
||||
hideOverlay();
|
||||
return;
|
||||
}
|
||||
if (feedbackOptions?.requireNameEmail && !savedIdentity && onRequireIdentity) {
|
||||
onRequireIdentity('like', photo.id);
|
||||
hideOverlay();
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { AuthenticatedImage } from '../../common';
|
||||
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||
import { feedbackService } from '../../../services/feedback.service';
|
||||
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
|
||||
import { buildResourceUrl } from '../../../utils/url';
|
||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import type { Photo } from '../../../types';
|
||||
@@ -81,6 +82,7 @@ const JustifiedPhoto: React.FC<JustifiedPhotoProps> = ({
|
||||
liked = false,
|
||||
onLikeSuccess,
|
||||
}) => {
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const [overlayVisible, setOverlayVisible] = useState(false);
|
||||
const [isTouchDevice, setIsTouchDevice] = useState(false);
|
||||
const overlayTimeoutRef = useRef<number | null>(null);
|
||||
@@ -301,6 +303,25 @@ const JustifiedPhoto: React.FC<JustifiedPhotoProps> = ({
|
||||
}`}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
hideOverlay();
|
||||
return;
|
||||
}
|
||||
if (onLikeSuccess) onLikeSuccess();
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||
feedback_type: 'like',
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Like submit failed, keeping optimistic UI', err);
|
||||
}
|
||||
if (onFeedbackChange) onFeedbackChange();
|
||||
hideOverlay();
|
||||
return;
|
||||
}
|
||||
if (feedbackOptions?.requireNameEmail && !savedIdentity && onRequireIdentity) {
|
||||
onRequireIdentity('like', photo.id);
|
||||
hideOverlay();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { AuthenticatedImage } from '../../common';
|
||||
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||
import { feedbackService } from '../../../services/feedback.service';
|
||||
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
|
||||
import {
|
||||
calculateJustifiedLayout,
|
||||
createJustifiedPhotos,
|
||||
@@ -53,6 +54,7 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
|
||||
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
|
||||
// Calculate height based on actual photo aspect ratio
|
||||
// This preserves the photo's natural proportions in the masonry layout
|
||||
@@ -151,6 +153,17 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||
feedback_type: 'like',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
||||
setPendingAction({ type: 'like', photoId: photo.id });
|
||||
setShowIdentityModal(true);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { AuthenticatedImage } from '../../common';
|
||||
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||
import { feedbackService } from '../../../services/feedback.service';
|
||||
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
|
||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import type { Photo } from '../../../types';
|
||||
|
||||
@@ -53,6 +54,7 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
||||
const [showIdentityModal, setShowIdentityModal] = React.useState(false);
|
||||
const [pendingAction, setPendingAction] = React.useState<null | { type: 'like'; photoId: number }>(null);
|
||||
const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null);
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const [likedLocal, setLikedLocal] = React.useState(false);
|
||||
const canComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onQuickComment);
|
||||
|
||||
@@ -108,6 +110,20 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
||||
className={`p-2 rounded-full transition-colors ${likedLocal ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setLikedLocal(true);
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||
feedback_type: 'like',
|
||||
});
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
||||
setPendingAction({ type: 'like', photoId: photo.id });
|
||||
setShowIdentityModal(true);
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import type { Photo } from '../../../types';
|
||||
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||
import { feedbackService } from '../../../services/feedback.service';
|
||||
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
|
||||
|
||||
export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
photos,
|
||||
@@ -26,6 +27,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
|
||||
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const gallerySettings = theme.gallerySettings || {};
|
||||
const grouping = gallerySettings.timelineGrouping || 'day';
|
||||
const showDates = gallerySettings.timelineShowDates !== false;
|
||||
@@ -147,6 +149,20 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
className={`p-2 rounded-full transition-colors ${likedIds.has(photo.id) ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setLikedIds(prev => new Set(prev).add(photo.id));
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||
feedback_type: 'like',
|
||||
});
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
||||
setPendingAction({ type: 'like', photoId: photo.id });
|
||||
setShowIdentityModal(true);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
inferGallerySlugFromLocation,
|
||||
resolveSlugFromRequestUrl,
|
||||
} from '../utils/galleryAuthStorage';
|
||||
import { getGuestToken } from '../utils/guestIdentityStorage';
|
||||
import { getApiBaseUrl } from '../utils/url';
|
||||
|
||||
// Maintenance mode callback
|
||||
@@ -80,6 +81,25 @@ api.interceptors.request.use(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also inject guest token (x-guest-token) for per-person identity.
|
||||
// Separate header so gallery auth and guest identity are independent.
|
||||
const guestToken = getGuestToken(slug);
|
||||
if (guestToken) {
|
||||
if (!config.headers) {
|
||||
config.headers = new AxiosHeaders();
|
||||
}
|
||||
if (config.headers instanceof AxiosHeaders) {
|
||||
if (!config.headers.get('x-guest-token')) {
|
||||
config.headers.set('x-guest-token', guestToken);
|
||||
}
|
||||
} else {
|
||||
const headersRecord = config.headers as Record<string, string | undefined>;
|
||||
if (!headersRecord['x-guest-token']) {
|
||||
headersRecord['x-guest-token'] = guestToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { guestsService, GuestIdentity } from '../services/guests.service';
|
||||
import {
|
||||
clearGuestIdentity,
|
||||
getGuestIdentity,
|
||||
storeGuestIdentity,
|
||||
} from '../utils/guestIdentityStorage';
|
||||
|
||||
type IdentityMode = 'simple' | 'guest';
|
||||
|
||||
interface GuestIdentityContextValue {
|
||||
slug: string;
|
||||
identity: GuestIdentity | null;
|
||||
identityMode: IdentityMode;
|
||||
isRequired: boolean; // true when mode='guest' AND no identity yet
|
||||
promptOpen: boolean;
|
||||
recoveryOpen: boolean;
|
||||
openPrompt: () => void;
|
||||
closePrompt: () => void;
|
||||
openRecovery: () => void;
|
||||
closeRecovery: () => void;
|
||||
register: (name: string, email?: string) => Promise<GuestIdentity>;
|
||||
recoverRequest: (email: string) => Promise<void>;
|
||||
recoverVerify: (email: string, code: string) => Promise<GuestIdentity>;
|
||||
forget: () => Promise<void>;
|
||||
/**
|
||||
* Used by feedback components. Returns the current identity, or opens the
|
||||
* prompt and waits until the user registers (or cancels, in which case it
|
||||
* throws a "user_cancelled" error).
|
||||
*/
|
||||
ensureIdentity: () => Promise<GuestIdentity>;
|
||||
}
|
||||
|
||||
const GuestIdentityContext = createContext<GuestIdentityContextValue | null>(null);
|
||||
|
||||
interface GuestIdentityProviderProps {
|
||||
slug: string;
|
||||
identityMode: IdentityMode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const GuestIdentityProvider: React.FC<GuestIdentityProviderProps> = ({
|
||||
slug,
|
||||
identityMode,
|
||||
children,
|
||||
}) => {
|
||||
const [identity, setIdentity] = useState<GuestIdentity | null>(() => getGuestIdentity(slug));
|
||||
const [promptOpen, setPromptOpen] = useState(false);
|
||||
const [recoveryOpen, setRecoveryOpen] = useState(false);
|
||||
|
||||
// Pending promise resolvers for ensureIdentity() calls waiting on prompt.
|
||||
const pendingResolvers = useRef<Array<(identity: GuestIdentity) => void>>([]);
|
||||
const pendingRejecters = useRef<Array<(reason: Error) => void>>([]);
|
||||
|
||||
// Rehydrate identity when slug changes.
|
||||
useEffect(() => {
|
||||
setIdentity(getGuestIdentity(slug));
|
||||
}, [slug]);
|
||||
|
||||
// When an invite token is present on the URL (?invite=xxx), redeem it once
|
||||
// on mount. The server returns a guest token we can persist.
|
||||
useEffect(() => {
|
||||
if (identityMode !== 'guest' || identity) return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const inviteToken = params.get('invite');
|
||||
if (!inviteToken) return;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const response = await guestsService.redeemInvite(slug, inviteToken);
|
||||
storeGuestIdentity(slug, response.guest, response.token);
|
||||
setIdentity(response.guest);
|
||||
// Strip invite param from URL to prevent re-redemption on reload.
|
||||
params.delete('invite');
|
||||
const newSearch = params.toString();
|
||||
const newUrl = window.location.pathname + (newSearch ? `?${newSearch}` : '') + window.location.hash;
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
} catch (error) {
|
||||
// Silently fail invalid invites; user will fall back to normal prompt.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Failed to redeem invite token', error);
|
||||
}
|
||||
})();
|
||||
}, [slug, identityMode, identity]);
|
||||
|
||||
const openPrompt = useCallback(() => setPromptOpen(true), []);
|
||||
const closePrompt = useCallback(() => {
|
||||
setPromptOpen(false);
|
||||
// Reject any pending ensureIdentity() promises.
|
||||
pendingRejecters.current.forEach((r) => r(new Error('user_cancelled')));
|
||||
pendingResolvers.current = [];
|
||||
pendingRejecters.current = [];
|
||||
}, []);
|
||||
|
||||
const openRecovery = useCallback(() => setRecoveryOpen(true), []);
|
||||
const closeRecovery = useCallback(() => setRecoveryOpen(false), []);
|
||||
|
||||
const register = useCallback(
|
||||
async (name: string, email?: string): Promise<GuestIdentity> => {
|
||||
const response = await guestsService.registerGuest(slug, { name, email });
|
||||
storeGuestIdentity(slug, response.guest, response.token);
|
||||
setIdentity(response.guest);
|
||||
setPromptOpen(false);
|
||||
// Resolve pending ensureIdentity() promises.
|
||||
pendingResolvers.current.forEach((r) => r(response.guest));
|
||||
pendingResolvers.current = [];
|
||||
pendingRejecters.current = [];
|
||||
return response.guest;
|
||||
},
|
||||
[slug]
|
||||
);
|
||||
|
||||
const recoverRequest = useCallback(
|
||||
async (email: string): Promise<void> => {
|
||||
await guestsService.requestRecoveryCode(slug, email);
|
||||
},
|
||||
[slug]
|
||||
);
|
||||
|
||||
const recoverVerify = useCallback(
|
||||
async (email: string, code: string): Promise<GuestIdentity> => {
|
||||
const response = await guestsService.verifyRecoveryCode(slug, email, code);
|
||||
storeGuestIdentity(slug, response.guest, response.token);
|
||||
setIdentity(response.guest);
|
||||
setPromptOpen(false);
|
||||
setRecoveryOpen(false);
|
||||
pendingResolvers.current.forEach((r) => r(response.guest));
|
||||
pendingResolvers.current = [];
|
||||
pendingRejecters.current = [];
|
||||
return response.guest;
|
||||
},
|
||||
[slug]
|
||||
);
|
||||
|
||||
const forget = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
if (identity) {
|
||||
await guestsService.forgetMe(slug);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort. Clear local state regardless.
|
||||
}
|
||||
clearGuestIdentity(slug);
|
||||
setIdentity(null);
|
||||
}, [slug, identity]);
|
||||
|
||||
const ensureIdentity = useCallback((): Promise<GuestIdentity> => {
|
||||
if (identityMode !== 'guest') {
|
||||
// In simple mode, there is no per-person identity. Return a synthetic
|
||||
// "null" identity that callers will ignore.
|
||||
return Promise.resolve({
|
||||
id: 0,
|
||||
name: '',
|
||||
email: null,
|
||||
identifier: '',
|
||||
} as GuestIdentity);
|
||||
}
|
||||
if (identity) return Promise.resolve(identity);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingResolvers.current.push(resolve);
|
||||
pendingRejecters.current.push(reject);
|
||||
setPromptOpen(true);
|
||||
});
|
||||
}, [identityMode, identity]);
|
||||
|
||||
const isRequired = identityMode === 'guest' && !identity;
|
||||
|
||||
const value = useMemo<GuestIdentityContextValue>(
|
||||
() => ({
|
||||
slug,
|
||||
identity,
|
||||
identityMode,
|
||||
isRequired,
|
||||
promptOpen,
|
||||
recoveryOpen,
|
||||
openPrompt,
|
||||
closePrompt,
|
||||
openRecovery,
|
||||
closeRecovery,
|
||||
register,
|
||||
recoverRequest,
|
||||
recoverVerify,
|
||||
forget,
|
||||
ensureIdentity,
|
||||
}),
|
||||
[
|
||||
slug,
|
||||
identity,
|
||||
identityMode,
|
||||
isRequired,
|
||||
promptOpen,
|
||||
recoveryOpen,
|
||||
openPrompt,
|
||||
closePrompt,
|
||||
openRecovery,
|
||||
closeRecovery,
|
||||
register,
|
||||
recoverRequest,
|
||||
recoverVerify,
|
||||
forget,
|
||||
ensureIdentity,
|
||||
]
|
||||
);
|
||||
|
||||
return <GuestIdentityContext.Provider value={value}>{children}</GuestIdentityContext.Provider>;
|
||||
};
|
||||
|
||||
export function useGuestIdentity(): GuestIdentityContextValue {
|
||||
const ctx = useContext(GuestIdentityContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useGuestIdentity must be used within a GuestIdentityProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe hook that returns null if no provider is present. Useful when code
|
||||
* needs to optionally tie into guest identity without crashing when used
|
||||
* outside a gallery (e.g. in admin contexts).
|
||||
*/
|
||||
export function useGuestIdentityOptional(): GuestIdentityContextValue | null {
|
||||
return useContext(GuestIdentityContext);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useCallback } from 'react';
|
||||
import { feedbackService } from '../services/feedback.service';
|
||||
import { useGuestIdentityOptional } from '../contexts/GuestIdentityContext';
|
||||
|
||||
/**
|
||||
* Shared helper used by gallery layout "quick action" buttons (like, favorite,
|
||||
* rating, etc.) to submit feedback with proper identity handling:
|
||||
*
|
||||
* - In guest identity mode: ensures the visitor has a guest token (prompts
|
||||
* if needed), then submits. Server reads name/email from the token.
|
||||
* - In simple mode with require_name_email: callers still need to show
|
||||
* their own inline FeedbackIdentityModal (we return `needsSimpleIdentity`
|
||||
* to signal this).
|
||||
* - In simple mode without require_name_email: submits directly.
|
||||
*/
|
||||
export function useGalleryFeedbackAction() {
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
|
||||
/**
|
||||
* Submit a feedback action. Returns:
|
||||
* - { submitted: true } if the submission happened.
|
||||
* - { cancelled: true } if the user cancelled the guest prompt.
|
||||
* - { needsSimpleIdentity: true } if the caller must show its own legacy
|
||||
* identity modal (simple mode with require_name_email).
|
||||
*/
|
||||
const submit = useCallback(
|
||||
async (
|
||||
slug: string,
|
||||
photoId: number | string,
|
||||
action: {
|
||||
feedback_type: 'like' | 'favorite' | 'rating' | 'comment';
|
||||
rating?: number;
|
||||
comment_text?: string;
|
||||
},
|
||||
options?: {
|
||||
requireNameEmail?: boolean;
|
||||
savedIdentity?: { name: string; email: string } | null;
|
||||
}
|
||||
): Promise<{ submitted?: boolean; cancelled?: boolean; needsSimpleIdentity?: boolean }> => {
|
||||
if (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
return { cancelled: true };
|
||||
}
|
||||
await feedbackService.submitFeedback(slug, String(photoId), action);
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
// Simple mode
|
||||
if (options?.requireNameEmail && !options.savedIdentity) {
|
||||
return { needsSimpleIdentity: true };
|
||||
}
|
||||
|
||||
await feedbackService.submitFeedback(slug, String(photoId), {
|
||||
...action,
|
||||
guest_name: options?.savedIdentity?.name,
|
||||
guest_email: options?.savedIdentity?.email,
|
||||
});
|
||||
return { submitted: true };
|
||||
},
|
||||
[guestIdentity]
|
||||
);
|
||||
|
||||
return {
|
||||
submit,
|
||||
isGuestMode: guestIdentity?.identityMode === 'guest',
|
||||
};
|
||||
}
|
||||
@@ -2320,7 +2320,12 @@
|
||||
"comments": "Comments",
|
||||
"commentsDesc": "Text comments on photos",
|
||||
"favorites": "Favorites",
|
||||
"favoritesDesc": "Mark photos as favorites"
|
||||
"favoritesDesc": "Mark photos as favorites",
|
||||
"identityMode": "Identity Mode",
|
||||
"identityModeSimple": "Simple feedback",
|
||||
"identityModeSimpleDesc": "Anonymous, device-based. All visitors on the same device share state.",
|
||||
"identityModeGuest": "Per-guest selections",
|
||||
"identityModeGuestDesc": "Each visitor enters their name. Enables per-guest tracking and admin insights."
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
|
||||
@@ -53,7 +53,7 @@ import { toast } from 'react-toastify';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu } from '../../components/admin';
|
||||
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu, AdminGuestsList } from '../../components/admin';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { publicSettingsService } from '../../services/publicSettings.service';
|
||||
@@ -232,7 +232,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
const [clientPin, setClientPin] = useState('');
|
||||
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
|
||||
const [showExternalImport, setShowExternalImport] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'photos' | 'categories'>('overview');
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'photos' | 'categories' | 'guests'>('overview');
|
||||
const [externalPath, setExternalPath] = useState<string>('');
|
||||
const [importing, setImporting] = useState<boolean>(false);
|
||||
const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null);
|
||||
@@ -912,6 +912,18 @@ export const EventDetailsPage: React.FC = () => {
|
||||
>
|
||||
{t('events.categories')}
|
||||
</button>
|
||||
{eventFeedbackSettings?.identity_mode === 'guest' && (
|
||||
<button
|
||||
onClick={() => setActiveTab('guests')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'guests'
|
||||
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||
}`}
|
||||
>
|
||||
{t('admin.events.tabs.guests', 'Guests')}
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -2104,6 +2116,11 @@ export const EventDetailsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Guests Tab (only visible when identity_mode === 'guest') */}
|
||||
{activeTab === 'guests' && eventFeedbackSettings?.identity_mode === 'guest' && (
|
||||
<AdminGuestsList eventId={parseInt(id!)} eventName={event.event_name} />
|
||||
)}
|
||||
|
||||
{/* Password Reset Modal */}
|
||||
{showPasswordReset && (
|
||||
<PasswordResetModal
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { api } from '../config/api';
|
||||
|
||||
export type IdentityMode = 'simple' | 'guest';
|
||||
|
||||
export interface FeedbackSettings {
|
||||
feedback_enabled: boolean;
|
||||
allow_ratings: boolean;
|
||||
@@ -12,6 +14,7 @@ export interface FeedbackSettings {
|
||||
enable_rate_limiting: boolean;
|
||||
rate_limit_window_minutes?: number;
|
||||
rate_limit_max_requests?: number;
|
||||
identity_mode?: IdentityMode;
|
||||
}
|
||||
|
||||
export interface PhotoFeedback {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { api } from '../config/api';
|
||||
|
||||
export interface GuestIdentity {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string | null;
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
export interface GuestRegisterResponse {
|
||||
guest: GuestIdentity;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface AdminGuestStats {
|
||||
likes: number;
|
||||
favorites: number;
|
||||
comments: number;
|
||||
ratings: number;
|
||||
distinct_photos: number;
|
||||
}
|
||||
|
||||
export interface AdminGuest {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string | null;
|
||||
created_at: string;
|
||||
last_seen_at: string;
|
||||
email_verified_at: string | null;
|
||||
is_deleted: boolean;
|
||||
stats: AdminGuestStats;
|
||||
}
|
||||
|
||||
export interface AdminGuestPhoto {
|
||||
id: number;
|
||||
filename: string;
|
||||
original_filename: string | null;
|
||||
type?: string;
|
||||
url: string;
|
||||
thumbnail_url: string;
|
||||
}
|
||||
|
||||
export interface AdminGuestSelections {
|
||||
liked: AdminGuestPhoto[];
|
||||
favorited: AdminGuestPhoto[];
|
||||
rated: Array<{ photo: AdminGuestPhoto; rating: number }>;
|
||||
commented: Array<{ photo: AdminGuestPhoto; comment: string; created_at: string }>;
|
||||
}
|
||||
|
||||
export interface AdminGuestDetail {
|
||||
guest: AdminGuest;
|
||||
selections: AdminGuestSelections;
|
||||
}
|
||||
|
||||
export interface AggregatePhoto extends AdminGuestPhoto {
|
||||
picker_count: number;
|
||||
}
|
||||
|
||||
export interface GuestInvite {
|
||||
id: number;
|
||||
token: string;
|
||||
url: string;
|
||||
created_at: string;
|
||||
redeemed_at: string | null;
|
||||
revoked_at: string | null;
|
||||
status: 'pending' | 'redeemed' | 'revoked';
|
||||
guest: { id: number; name: string; email: string | null };
|
||||
}
|
||||
|
||||
class GuestsService {
|
||||
// ===================================================================
|
||||
// Gallery-side (public) — guest identity
|
||||
// ===================================================================
|
||||
|
||||
async registerGuest(slug: string, data: { name: string; email?: string }): Promise<GuestRegisterResponse> {
|
||||
const response = await api.post(`/gallery/${slug}/guest`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getGuestMe(slug: string): Promise<{ guest: GuestIdentity }> {
|
||||
const response = await api.get(`/gallery/${slug}/guest/me`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async forgetMe(slug: string): Promise<{ success: boolean }> {
|
||||
const response = await api.delete(`/gallery/${slug}/guest/me`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async requestRecoveryCode(slug: string, email: string): Promise<{ success: boolean }> {
|
||||
const response = await api.post(`/gallery/${slug}/guest/recover`, { email });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async verifyRecoveryCode(slug: string, email: string, code: string): Promise<GuestRegisterResponse> {
|
||||
const response = await api.post(`/gallery/${slug}/guest/verify`, { email, code });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async redeemInvite(slug: string, inviteToken: string): Promise<GuestRegisterResponse> {
|
||||
const response = await api.post(`/gallery/${slug}/guest/redeem`, { inviteToken });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Admin-side
|
||||
// ===================================================================
|
||||
|
||||
async getEventGuests(eventId: number): Promise<{ guests: AdminGuest[] }> {
|
||||
const response = await api.get(`/admin/events/${eventId}/guests`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getGuestDetail(eventId: number, guestId: number): Promise<AdminGuestDetail> {
|
||||
const response = await api.get(`/admin/events/${eventId}/guests/${guestId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getAggregatePicks(eventId: number): Promise<{ photos: AggregatePhoto[] }> {
|
||||
const response = await api.get(`/admin/events/${eventId}/guests/aggregate`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteGuest(eventId: number, guestId: number): Promise<void> {
|
||||
await api.delete(`/admin/events/${eventId}/guests/${guestId}`);
|
||||
}
|
||||
|
||||
async mergeGuests(eventId: number, keepId: number, mergeIds: number[]): Promise<void> {
|
||||
await api.post(`/admin/events/${eventId}/guests/${keepId}/merge`, { mergeIds });
|
||||
}
|
||||
|
||||
async exportGuest(eventId: number, guestId: number, format: 'txt' | 'csv' | 'json'): Promise<Blob> {
|
||||
const response = await api.get(`/admin/events/${eventId}/guests/${guestId}/export`, {
|
||||
params: { format },
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async exportAllGuests(eventId: number, format: 'txt' | 'csv' | 'json'): Promise<Blob> {
|
||||
const response = await api.get(`/admin/events/${eventId}/guests/export-all`, {
|
||||
params: { format },
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async listInvites(eventId: number): Promise<{ invites: GuestInvite[] }> {
|
||||
const response = await api.get(`/admin/events/${eventId}/guests/invites`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async createInvite(eventId: number, data: { name: string; email?: string }): Promise<{ invite: GuestInvite }> {
|
||||
const response = await api.post(`/admin/events/${eventId}/guests/invites`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async revokeInvite(eventId: number, inviteId: number): Promise<void> {
|
||||
await api.delete(`/admin/events/${eventId}/guests/invites/${inviteId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const guestsService = new GuestsService();
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Per-gallery guest identity persistence.
|
||||
*
|
||||
* Stores the guest JWT and profile in sessionStorage, keyed by gallery slug,
|
||||
* so multiple open tabs of the same gallery share identity but different
|
||||
* browser contexts (and different galleries in the same context) stay
|
||||
* independent.
|
||||
*/
|
||||
|
||||
import type { GuestIdentity } from '../services/guests.service';
|
||||
|
||||
const TOKEN_KEY_PREFIX = 'guest_token_';
|
||||
const IDENTITY_KEY_PREFIX = 'guest_identity_';
|
||||
|
||||
const isBrowser = typeof window !== 'undefined';
|
||||
|
||||
const getStorage = (): Storage | null => {
|
||||
if (!isBrowser) return null;
|
||||
try {
|
||||
return window.sessionStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export function storeGuestIdentity(slug: string, identity: GuestIdentity, token: string): void {
|
||||
const storage = getStorage();
|
||||
if (!storage || !slug) return;
|
||||
storage.setItem(`${TOKEN_KEY_PREFIX}${slug}`, token);
|
||||
storage.setItem(`${IDENTITY_KEY_PREFIX}${slug}`, JSON.stringify(identity));
|
||||
}
|
||||
|
||||
export function getGuestToken(slug?: string | null): string | null {
|
||||
const storage = getStorage();
|
||||
if (!storage) return null;
|
||||
const resolvedSlug = slug || extractSlugFromLocation();
|
||||
if (!resolvedSlug) return null;
|
||||
return storage.getItem(`${TOKEN_KEY_PREFIX}${resolvedSlug}`);
|
||||
}
|
||||
|
||||
export function getGuestIdentity(slug?: string | null): GuestIdentity | null {
|
||||
const storage = getStorage();
|
||||
if (!storage) return null;
|
||||
const resolvedSlug = slug || extractSlugFromLocation();
|
||||
if (!resolvedSlug) return null;
|
||||
const raw = storage.getItem(`${IDENTITY_KEY_PREFIX}${resolvedSlug}`);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as GuestIdentity;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearGuestIdentity(slug: string): void {
|
||||
const storage = getStorage();
|
||||
if (!storage || !slug) return;
|
||||
storage.removeItem(`${TOKEN_KEY_PREFIX}${slug}`);
|
||||
storage.removeItem(`${IDENTITY_KEY_PREFIX}${slug}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the gallery slug from a request URL path like "/gallery/:slug/...".
|
||||
* Matches the axios interceptor logic in api.ts.
|
||||
*/
|
||||
export function extractGuestSlugFromUrl(url: string): string | null {
|
||||
if (!url) return null;
|
||||
const pathOnly = url.startsWith('http://') || url.startsWith('https://')
|
||||
? (() => {
|
||||
try {
|
||||
return new URL(url).pathname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
})()
|
||||
: url;
|
||||
const match = pathOnly.match(/\/gallery\/([^/?#]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
function extractSlugFromLocation(): string | null {
|
||||
if (!isBrowser) return null;
|
||||
const match = window.location.pathname.match(/\/gallery\/([^/?#]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
Reference in New Issue
Block a user