49c77785e7
Mirror to GitHub / mirror (push) Successful in 45s
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Failing after 1m50s
Version and Release / version-bump (push) Successful in 1m1s
Version and Release / trigger-drone (push) Successful in 3s
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 { AdminAuthenticatedImage } from '../../components/admin/AdminAuthenticatedImage';
|
|
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 && (
|
|
<div className="w-16 h-16 overflow-hidden rounded">
|
|
<AdminAuthenticatedImage
|
|
src={`/api/admin/photos/${eventId}/thumbnail/${item.photo_id}`}
|
|
alt={item.filename || 'Photo'}
|
|
className="w-16 h-16 object-cover rounded"
|
|
/>
|
|
</div>
|
|
)}
|
|
<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>
|
|
);
|
|
};
|