f26becad1d
- 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>
531 lines
22 KiB
TypeScript
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>
|
|
);
|
|
}; |