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:
Paul Nothaft
2026-04-11 07:48:23 +02:00
parent d4b4dc628f
commit ad4e5a7506
41 changed files with 3609 additions and 66 deletions
@@ -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>
);
};
+4
View File
@@ -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';