Files
picpeak/frontend/src/pages/admin/EventFeedbackPage.tsx
T
paul f26becad1d fix: resolve feedback validation issues from GitHub issue #16
- Fixed 400 Bad Request error when submitting feedback with name/email required
- Updated backend validation to properly handle empty/undefined name/email fields
- Modified frontend components to send undefined instead of empty strings when fields are not provided
- Fixed thumbnail display issue in moderation view by using correct admin API endpoints
- Updated FeedbackModerationPanel and EventFeedbackPage to display thumbnails correctly

The issue was caused by the validation logic treating empty strings differently than undefined values.
Frontend components now properly send undefined when name/email are not provided, and the backend
validation correctly handles both cases.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 17:40:23 +02:00

531 lines
22 KiB
TypeScript

import React, { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
ArrowLeft,
MessageSquare,
Star,
Heart,
TrendingUp,
Filter,
Download,
Shield,
CheckCircle,
XCircle,
Eye,
EyeOff,
Trash2
} from 'lucide-react';
import { toast } from 'react-toastify';
import { format } from 'date-fns';
import { Button, Card, Loading } from '../../components/common';
import { FeedbackSettings } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { feedbackService } from '../../services/feedback.service';
import type { PhotoFeedback, FeedbackAnalytics } from '../../services/feedback.service';
export const EventFeedbackPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<'settings' | 'feedback' | 'analytics' | 'moderation'>('settings');
const [feedbackFilter, setFeedbackFilter] = useState({
type: '',
status: '',
page: 1,
limit: 20
});
// Fetch event details
const { data: event, isLoading: eventLoading } = useQuery({
queryKey: ['event', id],
queryFn: () => eventsService.getEvent(id!),
enabled: !!id
});
// Fetch feedback settings
const { data: settings, isLoading: settingsLoading } = useQuery({
queryKey: ['feedback-settings', id],
queryFn: () => feedbackService.getEventFeedbackSettings(id!),
enabled: !!id
});
// Fetch feedback list
const { data: feedbackData, isLoading: feedbackLoading } = useQuery({
queryKey: ['event-feedback', id, feedbackFilter],
queryFn: () => feedbackService.getEventFeedback(id!, feedbackFilter),
enabled: !!id && activeTab === 'feedback'
});
// Fetch analytics
const { data: analytics, isLoading: analyticsLoading } = useQuery({
queryKey: ['feedback-analytics', id],
queryFn: () => feedbackService.getEventFeedbackAnalytics(id!),
enabled: !!id && activeTab === 'analytics'
});
// Update settings mutation
const updateSettingsMutation = useMutation({
mutationFn: (newSettings: any) => feedbackService.updateEventFeedbackSettings(id!, newSettings),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['feedback-settings', id] });
toast.success(t('feedback.settingsUpdated', 'Feedback settings updated'));
},
onError: () => {
toast.error(t('feedback.settingsUpdateError', 'Failed to update settings'));
}
});
// Moderate feedback mutation
const moderateMutation = useMutation({
mutationFn: ({ feedbackId, action }: { feedbackId: string; action: 'approve' | 'hide' | 'reject' }) =>
feedbackService.moderateFeedback(feedbackId, action),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-feedback', id] });
toast.success(t('feedback.moderated', 'Feedback moderated'));
}
});
// Delete feedback mutation
const deleteMutation = useMutation({
mutationFn: (feedbackId: string) => feedbackService.deleteFeedback(feedbackId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-feedback', id] });
toast.success(t('feedback.deleted', 'Feedback deleted'));
}
});
// Export feedback
const handleExport = async (format: 'json' | 'csv') => {
try {
const data = await feedbackService.exportEventFeedback(id!, format);
if (format === 'csv') {
const blob = new Blob([data], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `feedback-${event?.slug || id}.csv`;
a.click();
} else {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `feedback-${event?.slug || id}.json`;
a.click();
}
toast.success(t('feedback.exported', 'Feedback exported'));
} catch (error) {
toast.error(t('feedback.exportError', 'Failed to export feedback'));
}
};
if (eventLoading || settingsLoading) {
return <Loading />;
}
if (!event) {
return <div>{t('events.notFound', 'Event not found')}</div>;
}
return (
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="sm"
leftIcon={<ArrowLeft className="w-4 h-4" />}
onClick={() => navigate(`/admin/events/${id}`)}
>
{t('common.back')}
</Button>
<div>
<h1 className="text-2xl font-bold text-neutral-900">
{t('feedback.title', 'Feedback Management')}
</h1>
<p className="text-sm text-neutral-600 mt-1">
{event.event_name} {event.slug}
</p>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
leftIcon={<Download className="w-4 h-4" />}
onClick={() => handleExport('csv')}
>
{t('feedback.exportCSV', 'Export CSV')}
</Button>
<Button
variant="outline"
size="sm"
leftIcon={<Download className="w-4 h-4" />}
onClick={() => handleExport('json')}
>
{t('feedback.exportJSON', 'Export JSON')}
</Button>
</div>
</div>
{/* Tabs */}
<div className="mb-6 border-b border-neutral-200">
<nav className="-mb-px flex gap-6">
{[
{ id: 'settings', label: t('feedback.tabs.settings', 'Settings'), icon: Shield },
{ id: 'feedback', label: t('feedback.tabs.feedback', 'Feedback'), icon: MessageSquare },
{ id: 'analytics', label: t('feedback.tabs.analytics', 'Analytics'), icon: TrendingUp },
{ id: 'moderation', label: t('feedback.tabs.moderation', 'Moderation'), icon: Filter },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex items-center gap-2 px-1 py-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === tab.id
? 'border-primary-600 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
}`}
>
<tab.icon className="w-4 h-4" />
{tab.label}
</button>
))}
</nav>
</div>
{/* Content */}
{activeTab === 'settings' && settings && (
<FeedbackSettings
settings={settings}
onChange={(newSettings) => updateSettingsMutation.mutate(newSettings)}
/>
)}
{activeTab === 'feedback' && (
<div className="space-y-4">
{/* Filters */}
<Card>
<div className="p-4 flex gap-4">
<select
value={feedbackFilter.type}
onChange={(e) => setFeedbackFilter({ ...feedbackFilter, type: e.target.value, page: 1 })}
className="px-3 py-2 border border-neutral-300 rounded-lg"
>
<option value="">{t('feedback.allTypes', 'All Types')}</option>
<option value="rating">{t('feedback.types.rating', 'Ratings')}</option>
<option value="like">{t('feedback.types.like', 'Likes')}</option>
<option value="comment">{t('feedback.types.comment', 'Comments')}</option>
<option value="favorite">{t('feedback.types.favorite', 'Favorites')}</option>
</select>
<select
value={feedbackFilter.status}
onChange={(e) => setFeedbackFilter({ ...feedbackFilter, status: e.target.value, page: 1 })}
className="px-3 py-2 border border-neutral-300 rounded-lg"
>
<option value="">{t('feedback.allStatuses', 'All Statuses')}</option>
<option value="pending">{t('feedback.status.pending', 'Pending')}</option>
<option value="approved">{t('feedback.status.approved', 'Approved')}</option>
<option value="hidden">{t('feedback.status.hidden', 'Hidden')}</option>
</select>
</div>
</Card>
{/* Feedback List */}
{feedbackLoading ? (
<Loading />
) : feedbackData?.feedback?.length === 0 ? (
<Card>
<div className="p-8 text-center text-neutral-500">
{t('feedback.noFeedback', 'No feedback found')}
</div>
</Card>
) : (
<div className="space-y-2">
{feedbackData?.feedback?.map((item: PhotoFeedback) => (
<Card key={item.id} className="overflow-hidden">
<div className="p-4 flex items-start gap-4">
{item.photo_id && (
<img
src={`/api/admin/photos/${eventId}/thumbnail/${item.photo_id}`}
alt={item.filename || 'Photo'}
className="w-16 h-16 object-cover rounded"
onError={(e) => {
// Hide image if thumbnail fails to load
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
<div className="flex-1">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
{item.feedback_type === 'rating' && <Star className="w-4 h-4 text-yellow-500" />}
{item.feedback_type === 'like' && <Heart className="w-4 h-4 text-red-500" />}
{item.feedback_type === 'comment' && <MessageSquare className="w-4 h-4 text-blue-500" />}
<span className="font-medium text-sm">
{item.guest_name || t('feedback.anonymous', 'Anonymous')}
</span>
{item.guest_email && (
<span className="text-xs text-neutral-500">({item.guest_email})</span>
)}
</div>
{item.rating && (
<div className="flex gap-1 mb-1">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`w-4 h-4 ${
star <= item.rating! ? 'fill-yellow-500 text-yellow-500' : 'text-neutral-300'
}`}
/>
))}
</div>
)}
{item.comment_text && (
<p className="text-sm text-neutral-700">{item.comment_text}</p>
)}
<p className="text-xs text-neutral-500 mt-1">
{format(new Date(item.created_at), 'PPpp')}
</p>
</div>
<div className="flex items-center gap-2">
{item.feedback_type === 'comment' && !item.is_approved && (
<>
<Button
size="sm"
variant="ghost"
leftIcon={<CheckCircle className="w-4 h-4" />}
onClick={() => moderateMutation.mutate({
feedbackId: item.id.toString(),
action: 'approve'
})}
>
{t('feedback.approve', 'Approve')}
</Button>
<Button
size="sm"
variant="ghost"
leftIcon={<EyeOff className="w-4 h-4" />}
onClick={() => moderateMutation.mutate({
feedbackId: item.id.toString(),
action: 'hide'
})}
>
{t('feedback.hide', 'Hide')}
</Button>
</>
)}
{item.is_hidden && (
<Button
size="sm"
variant="ghost"
leftIcon={<Eye className="w-4 h-4" />}
onClick={() => moderateMutation.mutate({
feedbackId: item.id.toString(),
action: 'approve'
})}
>
{t('feedback.unhide', 'Unhide')}
</Button>
)}
<Button
size="sm"
variant="ghost"
leftIcon={<Trash2 className="w-4 h-4" />}
onClick={() => {
if (confirm(t('feedback.confirmDelete', 'Are you sure you want to delete this feedback?'))) {
deleteMutation.mutate(item.id.toString());
}
}}
>
{t('common.delete', 'Delete')}
</Button>
</div>
</div>
</div>
</div>
</Card>
))}
</div>
)}
{/* Pagination */}
{feedbackData?.pagination && feedbackData.pagination.pages > 1 && (
<div className="flex justify-center gap-2 mt-4">
<Button
variant="outline"
size="sm"
disabled={feedbackFilter.page === 1}
onClick={() => setFeedbackFilter({ ...feedbackFilter, page: feedbackFilter.page - 1 })}
>
{t('common.previous', 'Previous')}
</Button>
<span className="flex items-center px-3 text-sm text-neutral-600">
{t('common.pageOf', 'Page {{current}} of {{total}}', {
current: feedbackFilter.page,
total: feedbackData.pagination.pages
})}
</span>
<Button
variant="outline"
size="sm"
disabled={feedbackFilter.page === feedbackData.pagination.pages}
onClick={() => setFeedbackFilter({ ...feedbackFilter, page: feedbackFilter.page + 1 })}
>
{t('common.next', 'Next')}
</Button>
</div>
)}
</div>
)}
{activeTab === 'analytics' && (
<div className="space-y-6">
{analyticsLoading ? (
<Loading />
) : analytics ? (
<>
{/* Summary Stats */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<div className="p-6">
<div className="flex items-center gap-3 mb-2">
<Star className="w-8 h-8 text-yellow-500" />
<div>
<p className="text-2xl font-bold">{(analytics.summary.average_rating || 0).toFixed(1)}</p>
<p className="text-sm text-neutral-600">{t('feedback.avgRating', 'Average Rating')}</p>
</div>
</div>
<p className="text-xs text-neutral-500">
{t('feedback.totalRatings', '{{count}} ratings', { count: analytics.summary.total_ratings })}
</p>
</div>
</Card>
<Card>
<div className="p-6">
<div className="flex items-center gap-3 mb-2">
<Heart className="w-8 h-8 text-red-500" />
<div>
<p className="text-2xl font-bold">{analytics.summary.total_likes}</p>
<p className="text-sm text-neutral-600">{t('feedback.totalLikes', 'Total Likes')}</p>
</div>
</div>
</div>
</Card>
<Card>
<div className="p-6">
<div className="flex items-center gap-3 mb-2">
<MessageSquare className="w-8 h-8 text-blue-500" />
<div>
<p className="text-2xl font-bold">{analytics.summary.total_comments}</p>
<p className="text-sm text-neutral-600">{t('feedback.totalComments', 'Total Comments')}</p>
</div>
</div>
{analytics.summary.pending_moderation > 0 && (
<p className="text-xs text-orange-600">
{t('feedback.pendingModeration', '{{count}} pending', {
count: analytics.summary.pending_moderation
})}
</p>
)}
</div>
</Card>
<Card>
<div className="p-6">
<div className="flex items-center gap-3 mb-2">
<TrendingUp className="w-8 h-8 text-green-500" />
<div>
<p className="text-2xl font-bold">{analytics.summary.total_feedback}</p>
<p className="text-sm text-neutral-600">{t('feedback.totalInteractions', 'Total Interactions')}</p>
</div>
</div>
</div>
</Card>
</div>
{/* Top Rated Photos */}
{analytics.topRated.length > 0 && (
<Card>
<div className="p-6">
<h3 className="text-lg font-semibold mb-4">{t('feedback.topRated', 'Top Rated Photos')}</h3>
<div className="space-y-3">
{analytics.topRated.map((photo) => (
<div key={photo.id} className="flex items-center justify-between">
<span className="text-sm">{photo.filename}</span>
<div className="flex items-center gap-2">
<div className="flex gap-0.5">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`w-3 h-3 ${
star <= Math.round(photo.average_rating)
? 'fill-yellow-500 text-yellow-500'
: 'text-neutral-300'
}`}
/>
))}
</div>
<span className="text-sm text-neutral-600">
{Number(photo.average_rating).toFixed(1)} ({photo.feedback_count})
</span>
</div>
</div>
))}
</div>
</div>
</Card>
)}
{/* Recent Comments */}
{analytics.recentComments.length > 0 && (
<Card>
<div className="p-6">
<h3 className="text-lg font-semibold mb-4">{t('feedback.recentComments', 'Recent Comments')}</h3>
<div className="space-y-3">
{analytics.recentComments.map((comment, idx) => (
<div key={idx} className="border-b border-neutral-100 pb-3 last:border-0">
<p className="text-sm text-neutral-700">{comment.comment_text}</p>
<p className="text-xs text-neutral-500 mt-1">
{comment.guest_name} {comment.filename}
{format(new Date(comment.created_at), 'PP')}
</p>
</div>
))}
</div>
</div>
</Card>
)}
</>
) : null}
</div>
)}
{activeTab === 'moderation' && (
<div className="space-y-4">
<Card>
<div className="p-6">
<h3 className="text-lg font-semibold mb-4">{t('feedback.wordFilters', 'Word Filters')}</h3>
<p className="text-sm text-neutral-600">
{t('feedback.wordFiltersDesc', 'Manage blocked words for comment moderation')}
</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => navigate('/admin/settings/moderation')}
>
{t('feedback.manageFilters', 'Manage Word Filters')}
</Button>
</div>
</Card>
</div>
)}
</div>
);
};