feat: implement gallery feedback system with version tracking for backups
Gallery Feedback Features: - Add feedback system allowing ratings, likes, comments, and favorites on photos - Implement admin controls for enabling/disabling feedback per event - Add content moderation with word filters and spam detection - Implement rate limiting to prevent abuse (10 requests/15min per type) - Create comprehensive admin interface for feedback management - Add analytics dashboard for feedback insights - Export feedback data when archiving events Frontend Components: - PhotoRating: 5-star rating system with optimistic updates - PhotoLikes: Like/unlike with animation - PhotoComments: Threaded comments with moderation - PhotoFavorites: Bookmark functionality - FeedbackSettings: Admin configuration panel - EventFeedbackPage: Complete management interface Backend Implementation: - Database migration 033: 4 new tables for feedback system - RESTful API with proper authorization - Guest identification via SHA256(IP+UserAgent) - Automatic backup integration - Email notification support Backup Version Tracking: - Migration 034: Add version columns to backup tables - Track app version, Node.js version, and DB schema version - Create restore_history table for tracking restore attempts - Add version compatibility checking for safe restores - Configurable version matching requirements Security & Performance: - Input validation and sanitization - Rate limiting per feedback type - Content moderation system - Optimistic UI updates - Efficient database queries with proper indexes 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
import React from 'react';
|
||||
import { MessageSquare, Star, Heart, Bookmark, Shield, Eye } from 'lucide-react';
|
||||
import { Card } from '../common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface FeedbackSettingsProps {
|
||||
settings: FeedbackSettings;
|
||||
onChange: (settings: FeedbackSettings) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface FeedbackSettings {
|
||||
feedback_enabled: boolean;
|
||||
allow_ratings: boolean;
|
||||
allow_likes: boolean;
|
||||
allow_comments: boolean;
|
||||
allow_favorites: boolean;
|
||||
require_name_email: boolean;
|
||||
moderate_comments: boolean;
|
||||
show_feedback_to_guests: boolean;
|
||||
enable_rate_limiting: boolean;
|
||||
rate_limit_window_minutes?: number;
|
||||
rate_limit_max_requests?: number;
|
||||
}
|
||||
|
||||
export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
settings,
|
||||
onChange,
|
||||
className = ''
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleToggle = (field: keyof FeedbackSettings) => {
|
||||
onChange({
|
||||
...settings,
|
||||
[field]: !settings[field]
|
||||
});
|
||||
};
|
||||
|
||||
const handleNumberChange = (field: keyof FeedbackSettings, value: string) => {
|
||||
const numValue = parseInt(value, 10);
|
||||
if (!isNaN(numValue)) {
|
||||
onChange({
|
||||
...settings,
|
||||
[field]: numValue
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
{t('feedback.settings.title', 'Guest Feedback Settings')}
|
||||
</h2>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.feedback_enabled}
|
||||
onChange={() => handleToggle('feedback_enabled')}
|
||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-neutral-700">
|
||||
{t('feedback.settings.enableFeedback', 'Enable feedback')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{settings.feedback_enabled && (
|
||||
<>
|
||||
{/* Feedback Types */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-neutral-700">
|
||||
{t('feedback.settings.feedbackTypes', 'Feedback Types')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg cursor-pointer hover:bg-neutral-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.allow_ratings}
|
||||
onChange={() => handleToggle('allow_ratings')}
|
||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<Star className="w-5 h-5 text-neutral-600" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
{t('feedback.settings.ratings', 'Star Ratings')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
{t('feedback.settings.ratingsDesc', 'Allow guests to rate photos (1-5 stars)')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg cursor-pointer hover:bg-neutral-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.allow_likes}
|
||||
onChange={() => handleToggle('allow_likes')}
|
||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<Heart className="w-5 h-5 text-neutral-600" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
{t('feedback.settings.likes', 'Likes')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
{t('feedback.settings.likesDesc', 'Simple like/unlike functionality')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg cursor-pointer hover:bg-neutral-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.allow_comments}
|
||||
onChange={() => handleToggle('allow_comments')}
|
||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<MessageSquare className="w-5 h-5 text-neutral-600" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
{t('feedback.settings.comments', 'Comments')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
{t('feedback.settings.commentsDesc', 'Text comments on photos')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg cursor-pointer hover:bg-neutral-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.allow_favorites}
|
||||
onChange={() => handleToggle('allow_favorites')}
|
||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<Bookmark className="w-5 h-5 text-neutral-600" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
{t('feedback.settings.favorites', 'Favorites')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
{t('feedback.settings.favoritesDesc', 'Mark photos as favorites')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4" />
|
||||
|
||||
{/* Privacy & Moderation */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-neutral-700">
|
||||
{t('feedback.settings.privacyModeration', 'Privacy & Moderation')}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.require_name_email}
|
||||
onChange={() => handleToggle('require_name_email')}
|
||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
{t('feedback.settings.requireInfo', 'Require Name & Email')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
{t('feedback.settings.requireInfoDesc', 'Guests must provide name and email to leave feedback')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.moderate_comments}
|
||||
onChange={() => handleToggle('moderate_comments')}
|
||||
disabled={!settings.allow_comments}
|
||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500 disabled:opacity-50"
|
||||
/>
|
||||
<Shield className="w-5 h-5 text-neutral-600" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
{t('feedback.settings.moderateComments', 'Moderate Comments')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
{t('feedback.settings.moderateCommentsDesc', 'Comments require approval before being visible')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.show_feedback_to_guests}
|
||||
onChange={() => handleToggle('show_feedback_to_guests')}
|
||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<Eye className="w-5 h-5 text-neutral-600" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
{t('feedback.settings.showToGuests', 'Show Feedback to Guests')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
{t('feedback.settings.showToGuestsDesc', 'Other guests can see ratings, likes, and approved comments')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4" />
|
||||
|
||||
{/* Rate Limiting */}
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enable_rate_limiting}
|
||||
onChange={() => handleToggle('enable_rate_limiting')}
|
||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
{t('feedback.settings.enableRateLimiting', 'Enable Rate Limiting')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
{t('feedback.settings.rateLimitingDesc', 'Prevent spam by limiting feedback frequency')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{settings.enable_rate_limiting && (
|
||||
<div className="grid grid-cols-2 gap-4 ml-7">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-600 mb-1">
|
||||
{t('feedback.settings.timeWindow', 'Time Window (minutes)')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
value={settings.rate_limit_window_minutes || 15}
|
||||
onChange={(e) => handleNumberChange('rate_limit_window_minutes', e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-neutral-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-600 mb-1">
|
||||
{t('feedback.settings.maxRequests', 'Max Requests')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={settings.rate_limit_max_requests || 10}
|
||||
onChange={(e) => handleNumberChange('rate_limit_max_requests', e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-neutral-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -26,4 +26,5 @@ export { GalleryPreview } from './GalleryPreview';
|
||||
export { BackupDashboard } from './BackupDashboard';
|
||||
export { BackupConfiguration } from './BackupConfiguration';
|
||||
export { BackupHistory } from './BackupHistory';
|
||||
export { RestoreWizard } from './RestoreWizard';
|
||||
export { RestoreWizard } from './RestoreWizard';
|
||||
export { FeedbackSettings } from './FeedbackSettings';
|
||||
@@ -0,0 +1,250 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { MessageSquare, Send, User, Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { toast } from 'react-toastify';
|
||||
import { format } from 'date-fns';
|
||||
import { Button, Input } from '../common';
|
||||
import type { PhotoFeedback } from '../../services/feedback.service';
|
||||
|
||||
interface PhotoCommentsProps {
|
||||
photoId: string;
|
||||
gallerySlug: string;
|
||||
comments: PhotoFeedback[];
|
||||
isEnabled: boolean;
|
||||
requireNameEmail: boolean;
|
||||
showToGuests: boolean;
|
||||
onCommentAdded?: () => void;
|
||||
}
|
||||
|
||||
export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
||||
photoId,
|
||||
gallerySlug,
|
||||
comments,
|
||||
isEnabled,
|
||||
requireNameEmail,
|
||||
showToGuests,
|
||||
onCommentAdded
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [showCommentForm, setShowCommentForm] = useState(false);
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const [guestName, setGuestName] = useState('');
|
||||
const [guestEmail, setGuestEmail] = useState('');
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
||||
}
|
||||
}, [commentText]);
|
||||
|
||||
const submitCommentMutation = useMutation({
|
||||
mutationFn: (data: any) =>
|
||||
feedbackService.submitFeedback(gallerySlug, photoId, {
|
||||
feedback_type: 'comment',
|
||||
comment_text: data.comment_text,
|
||||
guest_name: data.guest_name,
|
||||
guest_email: data.guest_email
|
||||
}),
|
||||
onSuccess: (response) => {
|
||||
setCommentText('');
|
||||
setShowCommentForm(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
|
||||
|
||||
if (response.message) {
|
||||
toast.info(response.message);
|
||||
} else {
|
||||
toast.success(t('feedback.commentSubmitted', 'Comment submitted'));
|
||||
}
|
||||
|
||||
if (onCommentAdded) {
|
||||
onCommentAdded();
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error.response?.status === 429) {
|
||||
toast.error(t('feedback.rateLimited', 'Please wait before commenting again'));
|
||||
} else if (error.response?.data?.errors) {
|
||||
setErrors(error.response.data.errors);
|
||||
} else {
|
||||
toast.error(t('feedback.commentError', 'Failed to submit comment'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmitComment = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrors({});
|
||||
|
||||
// Validate
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!commentText.trim()) {
|
||||
newErrors.comment_text = t('feedback.commentRequired', 'Comment is required');
|
||||
}
|
||||
if (requireNameEmail) {
|
||||
if (!guestName.trim()) {
|
||||
newErrors.guest_name = t('feedback.nameRequired', 'Name is required');
|
||||
}
|
||||
if (!guestEmail.trim()) {
|
||||
newErrors.guest_email = t('feedback.emailRequired', 'Email is required');
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
submitCommentMutation.mutate({
|
||||
comment_text: commentText.trim(),
|
||||
guest_name: guestName.trim(),
|
||||
guest_email: guestEmail.trim()
|
||||
});
|
||||
};
|
||||
|
||||
if (!isEnabled) return null;
|
||||
|
||||
// Filter comments based on visibility settings
|
||||
const visibleComments = showToGuests
|
||||
? comments.filter(c => c.is_approved && !c.is_hidden)
|
||||
: comments.filter(c => c.is_mine);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Comments Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
{t('feedback.comments', 'Comments')}
|
||||
{visibleComments.length > 0 && (
|
||||
<span className="text-neutral-500">({visibleComments.length})</span>
|
||||
)}
|
||||
</h3>
|
||||
{!showCommentForm && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowCommentForm(true)}
|
||||
>
|
||||
{t('feedback.addComment', 'Add Comment')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comment Form */}
|
||||
{showCommentForm && (
|
||||
<form onSubmit={handleSubmitComment} className="space-y-3 p-3 bg-neutral-50 rounded-lg">
|
||||
{requireNameEmail && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
placeholder={t('feedback.yourName', 'Your name')}
|
||||
value={guestName}
|
||||
onChange={(e) => setGuestName(e.target.value)}
|
||||
error={errors.guest_name}
|
||||
size="sm"
|
||||
/>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder={t('feedback.yourEmail', 'Your email')}
|
||||
value={guestEmail}
|
||||
onChange={(e) => setGuestEmail(e.target.value)}
|
||||
error={errors.guest_email}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
placeholder={t('feedback.writeComment', 'Write a comment...')}
|
||||
className={`w-full px-3 py-2 text-sm border rounded-lg resize-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 ${
|
||||
errors.comment_text ? 'border-red-500' : 'border-neutral-300'
|
||||
}`}
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
/>
|
||||
{errors.comment_text && (
|
||||
<p className="text-xs text-red-600 mt-1">{errors.comment_text}</p>
|
||||
)}
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{commentText.length}/500
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="primary"
|
||||
leftIcon={submitCommentMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||
disabled={submitCommentMutation.isPending}
|
||||
>
|
||||
{t('feedback.submit', 'Submit')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setShowCommentForm(false);
|
||||
setCommentText('');
|
||||
setErrors({});
|
||||
}}
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Comments List */}
|
||||
{visibleComments.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{visibleComments.map((comment) => (
|
||||
<div key={comment.id} className="flex gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-neutral-200 rounded-full flex items-center justify-center">
|
||||
<User className="w-4 h-4 text-neutral-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2 mb-1">
|
||||
<span className="text-sm font-medium text-neutral-900">
|
||||
{comment.guest_name || t('feedback.anonymous', 'Anonymous')}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-500">
|
||||
{format(new Date(comment.created_at), 'PP')}
|
||||
</span>
|
||||
{comment.is_mine && !comment.is_approved && (
|
||||
<span className="text-xs text-orange-600">
|
||||
{t('feedback.pendingApproval', 'Pending approval')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-neutral-700 break-words">
|
||||
{comment.comment_text}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{visibleComments.length === 0 && !showCommentForm && (
|
||||
<p className="text-sm text-neutral-500 text-center py-4">
|
||||
{t('feedback.noComments', 'No comments yet. Be the first to comment!')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Bookmark } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface PhotoFavoritesProps {
|
||||
photoId: string;
|
||||
gallerySlug: string;
|
||||
isFavorited: boolean;
|
||||
favoriteCount: number;
|
||||
isEnabled: boolean;
|
||||
onFavoriteChange?: (favorited: boolean) => void;
|
||||
}
|
||||
|
||||
export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
|
||||
photoId,
|
||||
gallerySlug,
|
||||
isFavorited,
|
||||
favoriteCount,
|
||||
isEnabled,
|
||||
onFavoriteChange
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [animating, setAnimating] = useState(false);
|
||||
|
||||
const submitFavoriteMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
feedbackService.submitFeedback(gallerySlug, photoId, {
|
||||
feedback_type: 'favorite'
|
||||
}),
|
||||
onMutate: async () => {
|
||||
setIsSubmitting(true);
|
||||
setAnimating(true);
|
||||
// Optimistic update
|
||||
if (onFavoriteChange) {
|
||||
onFavoriteChange(!isFavorited);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
// Revert optimistic update
|
||||
if (onFavoriteChange) {
|
||||
onFavoriteChange(isFavorited);
|
||||
}
|
||||
if (error.response?.status === 429) {
|
||||
toast.error(t('feedback.rateLimited', 'Please wait before favoriting again'));
|
||||
} else {
|
||||
toast.error(t('feedback.favoriteError', 'Failed to update favorite'));
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
setTimeout(() => setAnimating(false), 300);
|
||||
}
|
||||
});
|
||||
|
||||
const handleFavoriteClick = () => {
|
||||
if (!isEnabled || isSubmitting) return;
|
||||
submitFavoriteMutation.mutate();
|
||||
};
|
||||
|
||||
if (!isEnabled) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleFavoriteClick}
|
||||
disabled={isSubmitting}
|
||||
className={`group flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
|
||||
isFavorited
|
||||
? 'bg-amber-50 text-amber-600 hover:bg-amber-100'
|
||||
: 'bg-neutral-50 text-neutral-600 hover:bg-neutral-100'
|
||||
} ${isSubmitting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
|
||||
aria-label={isFavorited ? t('feedback.unfavorite', 'Remove from favorites') : t('feedback.favorite', 'Add to favorites')}
|
||||
>
|
||||
<Bookmark
|
||||
className={`w-5 h-5 transition-all ${
|
||||
animating ? 'scale-125' : 'scale-100'
|
||||
} ${
|
||||
isFavorited ? 'fill-current' : 'group-hover:scale-110'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{favoriteCount > 0 ? favoriteCount : ''}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { PhotoRating } from './PhotoRating';
|
||||
import { PhotoLikes } from './PhotoLikes';
|
||||
import { PhotoComments } from './PhotoComments';
|
||||
import { PhotoFavorites } from './PhotoFavorites';
|
||||
import { Skeleton } from '../common';
|
||||
import type { FeedbackSettings } from '../../services/feedback.service';
|
||||
|
||||
interface PhotoFeedbackProps {
|
||||
photoId: string;
|
||||
gallerySlug: string;
|
||||
className?: string;
|
||||
showComments?: boolean;
|
||||
onFeedbackUpdate?: () => void;
|
||||
}
|
||||
|
||||
export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
||||
photoId,
|
||||
gallerySlug,
|
||||
className = '',
|
||||
showComments = true,
|
||||
onFeedbackUpdate
|
||||
}) => {
|
||||
// Fetch feedback settings for the gallery
|
||||
const { data: settings, isLoading: settingsLoading } = useQuery({
|
||||
queryKey: ['gallery-feedback-settings', gallerySlug],
|
||||
queryFn: () => feedbackService.getGalleryFeedbackSettings(gallerySlug),
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
});
|
||||
|
||||
// Fetch feedback data for the photo
|
||||
const { data: feedbackData, isLoading: feedbackLoading } = useQuery({
|
||||
queryKey: ['photo-feedback', gallerySlug, photoId],
|
||||
queryFn: () => feedbackService.getPhotoFeedback(gallerySlug, photoId),
|
||||
enabled: !!settings?.feedback_enabled,
|
||||
});
|
||||
|
||||
// Local state for optimistic updates
|
||||
const [currentRating, setCurrentRating] = useState(0);
|
||||
const [isLiked, setIsLiked] = useState(false);
|
||||
const [isFavorited, setIsFavorited] = useState(false);
|
||||
const [likeCount, setLikeCount] = useState(0);
|
||||
const [favoriteCount, setFavoriteCount] = useState(0);
|
||||
|
||||
// Update local state when data loads
|
||||
useEffect(() => {
|
||||
if (feedbackData) {
|
||||
setCurrentRating(feedbackData.my_feedback.rating || 0);
|
||||
setIsLiked(feedbackData.my_feedback.liked);
|
||||
setIsFavorited(feedbackData.my_feedback.favorited);
|
||||
setLikeCount(feedbackData.summary.like_count);
|
||||
setFavoriteCount(feedbackData.summary.favorite_count);
|
||||
}
|
||||
}, [feedbackData]);
|
||||
|
||||
// Handle optimistic updates
|
||||
const handleRatingChange = (rating: number) => {
|
||||
setCurrentRating(rating);
|
||||
if (onFeedbackUpdate) onFeedbackUpdate();
|
||||
};
|
||||
|
||||
const handleLikeChange = (liked: boolean) => {
|
||||
setIsLiked(liked);
|
||||
setLikeCount(prev => liked ? prev + 1 : Math.max(0, prev - 1));
|
||||
if (onFeedbackUpdate) onFeedbackUpdate();
|
||||
};
|
||||
|
||||
const handleFavoriteChange = (favorited: boolean) => {
|
||||
setIsFavorited(favorited);
|
||||
setFavoriteCount(prev => favorited ? prev + 1 : Math.max(0, prev - 1));
|
||||
if (onFeedbackUpdate) onFeedbackUpdate();
|
||||
};
|
||||
|
||||
if (settingsLoading) {
|
||||
return (
|
||||
<div className={`space-y-3 ${className}`}>
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!settings?.feedback_enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasAnyFeedbackType = settings.allow_ratings || settings.allow_likes ||
|
||||
settings.allow_comments || settings.allow_favorites;
|
||||
|
||||
if (!hasAnyFeedbackType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{/* Rating Section */}
|
||||
{settings.allow_ratings && (
|
||||
<PhotoRating
|
||||
photoId={photoId}
|
||||
gallerySlug={gallerySlug}
|
||||
currentRating={currentRating}
|
||||
averageRating={feedbackData?.summary.average_rating}
|
||||
totalRatings={feedbackData?.summary.total_ratings}
|
||||
isEnabled={true}
|
||||
onRatingChange={handleRatingChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
{(settings.allow_likes || settings.allow_favorites) && (
|
||||
<div className="flex items-center gap-2">
|
||||
{settings.allow_likes && (
|
||||
<PhotoLikes
|
||||
photoId={photoId}
|
||||
gallerySlug={gallerySlug}
|
||||
isLiked={isLiked}
|
||||
likeCount={likeCount}
|
||||
isEnabled={true}
|
||||
onLikeChange={handleLikeChange}
|
||||
/>
|
||||
)}
|
||||
{settings.allow_favorites && (
|
||||
<PhotoFavorites
|
||||
photoId={photoId}
|
||||
gallerySlug={gallerySlug}
|
||||
isFavorited={isFavorited}
|
||||
favoriteCount={favoriteCount}
|
||||
isEnabled={true}
|
||||
onFavoriteChange={handleFavoriteChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comments Section */}
|
||||
{settings.allow_comments && showComments && (
|
||||
<div className="border-t pt-4">
|
||||
<PhotoComments
|
||||
photoId={photoId}
|
||||
gallerySlug={gallerySlug}
|
||||
comments={feedbackData?.feedback || []}
|
||||
isEnabled={true}
|
||||
requireNameEmail={settings.require_name_email || false}
|
||||
showToGuests={settings.show_feedback_to_guests || false}
|
||||
onCommentAdded={() => {
|
||||
if (onFeedbackUpdate) onFeedbackUpdate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare } from 'lucide-react';
|
||||
import type { Photo } from '../../types';
|
||||
import { useDownloadPhoto } from '../../hooks/useGallery';
|
||||
import { AuthenticatedImage } from '../common';
|
||||
import { PhotoFeedback } from './PhotoFeedback';
|
||||
|
||||
interface PhotoLightboxProps {
|
||||
photos: Photo[];
|
||||
@@ -23,6 +24,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||
const [touchDistance, setTouchDistance] = useState<number | null>(null);
|
||||
const [showFeedback, setShowFeedback] = useState(false);
|
||||
|
||||
const downloadPhotoMutation = useDownloadPhoto();
|
||||
const currentPhoto = photos[currentIndex];
|
||||
@@ -224,6 +226,14 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
>
|
||||
<Download className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowFeedback(!showFeedback)}
|
||||
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
|
||||
aria-label="Toggle feedback"
|
||||
>
|
||||
<MessageSquare className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -259,6 +269,29 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 text-white text-sm opacity-50 pointer-events-none md:hidden z-20">
|
||||
Swipe to navigate
|
||||
</div>
|
||||
|
||||
{/* Feedback Panel */}
|
||||
{showFeedback && (
|
||||
<div className="absolute right-0 top-0 bottom-0 w-96 bg-white shadow-xl z-20 overflow-y-auto">
|
||||
<div className="sticky top-0 bg-white border-b px-4 py-3 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-neutral-900">Photo Feedback</h3>
|
||||
<button
|
||||
onClick={() => setShowFeedback(false)}
|
||||
className="p-1 hover:bg-neutral-100 rounded transition-colors"
|
||||
aria-label="Close feedback"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<PhotoFeedback
|
||||
photoId={currentPhoto.id}
|
||||
gallerySlug={slug}
|
||||
showComments={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Heart } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface PhotoLikesProps {
|
||||
photoId: string;
|
||||
gallerySlug: string;
|
||||
isLiked: boolean;
|
||||
likeCount: number;
|
||||
isEnabled: boolean;
|
||||
onLikeChange?: (liked: boolean) => void;
|
||||
}
|
||||
|
||||
export const PhotoLikes: React.FC<PhotoLikesProps> = ({
|
||||
photoId,
|
||||
gallerySlug,
|
||||
isLiked,
|
||||
likeCount,
|
||||
isEnabled,
|
||||
onLikeChange
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [animating, setAnimating] = useState(false);
|
||||
|
||||
const submitLikeMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
feedbackService.submitFeedback(gallerySlug, photoId, {
|
||||
feedback_type: 'like'
|
||||
}),
|
||||
onMutate: async () => {
|
||||
setIsSubmitting(true);
|
||||
setAnimating(true);
|
||||
// Optimistic update
|
||||
if (onLikeChange) {
|
||||
onLikeChange(!isLiked);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
// Revert optimistic update
|
||||
if (onLikeChange) {
|
||||
onLikeChange(isLiked);
|
||||
}
|
||||
if (error.response?.status === 429) {
|
||||
toast.error(t('feedback.rateLimited', 'Please wait before liking again'));
|
||||
} else {
|
||||
toast.error(t('feedback.likeError', 'Failed to update like'));
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
setTimeout(() => setAnimating(false), 300);
|
||||
}
|
||||
});
|
||||
|
||||
const handleLikeClick = () => {
|
||||
if (!isEnabled || isSubmitting) return;
|
||||
submitLikeMutation.mutate();
|
||||
};
|
||||
|
||||
if (!isEnabled) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleLikeClick}
|
||||
disabled={isSubmitting}
|
||||
className={`group flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
|
||||
isLiked
|
||||
? 'bg-red-50 text-red-600 hover:bg-red-100'
|
||||
: 'bg-neutral-50 text-neutral-600 hover:bg-neutral-100'
|
||||
} ${isSubmitting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
|
||||
aria-label={isLiked ? t('feedback.unlike', 'Unlike') : t('feedback.like', 'Like')}
|
||||
>
|
||||
<Heart
|
||||
className={`w-5 h-5 transition-all ${
|
||||
animating ? 'scale-125' : 'scale-100'
|
||||
} ${
|
||||
isLiked ? 'fill-current' : 'group-hover:scale-110'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{likeCount > 0 ? likeCount : ''}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Star } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface PhotoRatingProps {
|
||||
photoId: string;
|
||||
gallerySlug: string;
|
||||
currentRating?: number;
|
||||
averageRating?: number;
|
||||
totalRatings?: number;
|
||||
isEnabled: boolean;
|
||||
onRatingChange?: (rating: number) => void;
|
||||
}
|
||||
|
||||
export const PhotoRating: React.FC<PhotoRatingProps> = ({
|
||||
photoId,
|
||||
gallerySlug,
|
||||
currentRating = 0,
|
||||
averageRating = 0,
|
||||
totalRatings = 0,
|
||||
isEnabled,
|
||||
onRatingChange
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [hoveredRating, setHoveredRating] = useState(0);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const submitRatingMutation = useMutation({
|
||||
mutationFn: (rating: number) =>
|
||||
feedbackService.submitFeedback(gallerySlug, photoId, {
|
||||
feedback_type: 'rating',
|
||||
rating
|
||||
}),
|
||||
onMutate: async (rating) => {
|
||||
setIsSubmitting(true);
|
||||
// Optimistic update
|
||||
if (onRatingChange) {
|
||||
onRatingChange(rating);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
|
||||
toast.success(t('feedback.ratingSubmitted', 'Rating submitted'));
|
||||
},
|
||||
onError: (error: any) => {
|
||||
// Revert optimistic update
|
||||
if (onRatingChange && currentRating) {
|
||||
onRatingChange(currentRating);
|
||||
}
|
||||
if (error.response?.status === 429) {
|
||||
toast.error(t('feedback.rateLimited', 'Please wait before rating again'));
|
||||
} else {
|
||||
toast.error(t('feedback.ratingError', 'Failed to submit rating'));
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleRatingClick = (rating: number) => {
|
||||
if (!isEnabled || isSubmitting) return;
|
||||
|
||||
// If clicking the same rating, remove it
|
||||
const newRating = rating === currentRating ? 0 : rating;
|
||||
submitRatingMutation.mutate(newRating);
|
||||
};
|
||||
|
||||
if (!isEnabled) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{/* Star Rating Input */}
|
||||
<div className="flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
onClick={() => handleRatingClick(star)}
|
||||
onMouseEnter={() => setHoveredRating(star)}
|
||||
onMouseLeave={() => setHoveredRating(0)}
|
||||
disabled={isSubmitting}
|
||||
className={`p-1 transition-all ${
|
||||
isSubmitting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:scale-110'
|
||||
}`}
|
||||
aria-label={t('feedback.rateStar', 'Rate {{count}} stars', { count: star })}
|
||||
>
|
||||
<Star
|
||||
className={`w-6 h-6 transition-colors ${
|
||||
star <= (hoveredRating || currentRating)
|
||||
? 'fill-yellow-500 text-yellow-500'
|
||||
: 'text-neutral-300 hover:text-yellow-400'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Average Rating Display */}
|
||||
{totalRatings > 0 && (
|
||||
<div className="text-sm text-neutral-600">
|
||||
<span className="font-medium">{averageRating.toFixed(1)}</span>
|
||||
<span className="text-neutral-400 ml-1">
|
||||
({t('feedback.ratingsCount', '{{count}} ratings', { count: totalRatings })})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -5,4 +5,9 @@ export { ExpirationBanner } from './ExpirationBanner';
|
||||
export { CountdownTimer } from './CountdownTimer';
|
||||
export { GalleryLayout } from './GalleryLayout';
|
||||
export { PhotoFilterBar } from './PhotoFilterBar';
|
||||
export { UserPhotoUpload } from './UserPhotoUpload';
|
||||
export { UserPhotoUpload } from './UserPhotoUpload';
|
||||
export { PhotoFeedback } from './PhotoFeedback';
|
||||
export { PhotoRating } from './PhotoRating';
|
||||
export { PhotoLikes } from './PhotoLikes';
|
||||
export { PhotoComments } from './PhotoComments';
|
||||
export { PhotoFavorites } from './PhotoFavorites';
|
||||
@@ -14,7 +14,7 @@ import { addDays } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import { Button, Input, Card } from '../../components/common';
|
||||
import { ThemeCustomizerEnhanced, GalleryPreview, WelcomeMessageEditor } from '../../components/admin';
|
||||
import { ThemeCustomizerEnhanced, GalleryPreview, WelcomeMessageEditor, FeedbackSettings } from '../../components/admin';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
@@ -38,6 +38,19 @@ interface FormData {
|
||||
expires_in_days: number;
|
||||
allow_user_uploads: boolean;
|
||||
upload_category_id: number | null;
|
||||
feedback_settings: {
|
||||
feedback_enabled: boolean;
|
||||
allow_ratings: boolean;
|
||||
allow_likes: boolean;
|
||||
allow_comments: boolean;
|
||||
allow_favorites: boolean;
|
||||
require_name_email: boolean;
|
||||
moderate_comments: boolean;
|
||||
show_feedback_to_guests: boolean;
|
||||
enable_rate_limiting: boolean;
|
||||
rate_limit_window_minutes?: number;
|
||||
rate_limit_max_requests?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const EVENT_TYPE_PRESETS: Record<string, string> = {
|
||||
@@ -83,6 +96,19 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
expires_in_days: 30,
|
||||
allow_user_uploads: false,
|
||||
upload_category_id: null,
|
||||
feedback_settings: {
|
||||
feedback_enabled: false,
|
||||
allow_ratings: true,
|
||||
allow_likes: true,
|
||||
allow_comments: true,
|
||||
allow_favorites: true,
|
||||
require_name_email: false,
|
||||
moderate_comments: true,
|
||||
show_feedback_to_guests: true,
|
||||
enable_rate_limiting: true,
|
||||
rate_limit_window_minutes: 15,
|
||||
rate_limit_max_requests: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
|
||||
@@ -218,6 +244,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
expiration_days: formData.expires_in_days,
|
||||
allow_user_uploads: formData.allow_user_uploads,
|
||||
upload_category_id: formData.upload_category_id,
|
||||
feedback_settings: formData.feedback_settings,
|
||||
};
|
||||
|
||||
console.log('Submitting payload:', payload);
|
||||
@@ -553,6 +580,12 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Feedback Settings */}
|
||||
<FeedbackSettings
|
||||
settings={formData.feedback_settings}
|
||||
onChange={(settings) => setFormData(prev => ({ ...prev, feedback_settings: settings }))}
|
||||
/>
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
Upload,
|
||||
Image,
|
||||
Key,
|
||||
Mail
|
||||
Mail,
|
||||
MessageSquare
|
||||
} from 'lucide-react';
|
||||
import { parseISO, differenceInDays } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -319,6 +320,14 @@ export const EventDetailsPage: React.FC = () => {
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<MessageSquare className="w-4 h-4" />}
|
||||
onClick={() => navigate(`/admin/events/${id}/feedback`)}
|
||||
>
|
||||
{t('feedback.manage', 'Manage Feedback')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{event.share_link && (
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
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">
|
||||
<img
|
||||
src={`/thumbnails/${item.path}`}
|
||||
alt={item.filename}
|
||||
className="w-16 h-16 object-cover rounded"
|
||||
/>
|
||||
<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.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">
|
||||
{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>
|
||||
);
|
||||
};
|
||||
@@ -9,4 +9,5 @@ export { AnalyticsPage } from './AnalyticsPage';
|
||||
export { BrandingPage } from './BrandingPage';
|
||||
export { SettingsPage } from './SettingsPage';
|
||||
export { CMSPage } from './CMSPage';
|
||||
export { BackupManagement } from './BackupManagement';
|
||||
export { BackupManagement } from './BackupManagement';
|
||||
export { EventFeedbackPage } from './EventFeedbackPage';
|
||||
@@ -0,0 +1,196 @@
|
||||
import { api } from '../config/api';
|
||||
|
||||
export interface FeedbackSettings {
|
||||
feedback_enabled: boolean;
|
||||
allow_ratings: boolean;
|
||||
allow_likes: boolean;
|
||||
allow_comments: boolean;
|
||||
allow_favorites: boolean;
|
||||
require_name_email: boolean;
|
||||
moderate_comments: boolean;
|
||||
show_feedback_to_guests: boolean;
|
||||
enable_rate_limiting: boolean;
|
||||
rate_limit_window_minutes?: number;
|
||||
rate_limit_max_requests?: number;
|
||||
}
|
||||
|
||||
export interface PhotoFeedback {
|
||||
id: number;
|
||||
photo_id: number;
|
||||
event_id: number;
|
||||
feedback_type: 'rating' | 'like' | 'comment' | 'favorite';
|
||||
rating?: number;
|
||||
comment_text?: string;
|
||||
guest_name?: string;
|
||||
guest_email?: string;
|
||||
is_approved: boolean;
|
||||
is_hidden: boolean;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
filename?: string;
|
||||
path?: string;
|
||||
is_mine?: boolean;
|
||||
}
|
||||
|
||||
export interface FeedbackSummary {
|
||||
average_rating: number;
|
||||
total_ratings: number;
|
||||
like_count: number;
|
||||
favorite_count: number;
|
||||
comment_count: number;
|
||||
}
|
||||
|
||||
export interface MyFeedback {
|
||||
rating?: number;
|
||||
liked: boolean;
|
||||
favorited: boolean;
|
||||
}
|
||||
|
||||
export interface FeedbackResponse {
|
||||
feedback: PhotoFeedback[];
|
||||
summary: FeedbackSummary;
|
||||
my_feedback: MyFeedback;
|
||||
}
|
||||
|
||||
export interface FeedbackAnalytics {
|
||||
summary: {
|
||||
total_feedback: number;
|
||||
total_ratings: number;
|
||||
average_rating: number;
|
||||
total_likes: number;
|
||||
total_comments: number;
|
||||
total_favorites: number;
|
||||
pending_moderation: number;
|
||||
};
|
||||
topRated: Array<{
|
||||
id: number;
|
||||
filename: string;
|
||||
average_rating: number;
|
||||
feedback_count: number;
|
||||
like_count: number;
|
||||
}>;
|
||||
mostLiked: Array<{
|
||||
id: number;
|
||||
filename: string;
|
||||
like_count: number;
|
||||
average_rating: number;
|
||||
}>;
|
||||
recentComments: Array<{
|
||||
comment_text: string;
|
||||
guest_name: string;
|
||||
created_at: string;
|
||||
filename: string;
|
||||
}>;
|
||||
timeline: Array<{
|
||||
date: string;
|
||||
count: number;
|
||||
feedback_type: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
class FeedbackService {
|
||||
// Admin endpoints
|
||||
async getEventFeedbackSettings(eventId: string): Promise<FeedbackSettings> {
|
||||
const response = await api.get(`/admin/feedback/events/${eventId}/feedback-settings`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateEventFeedbackSettings(eventId: string, settings: FeedbackSettings): Promise<FeedbackSettings> {
|
||||
const response = await api.put(`/admin/feedback/events/${eventId}/feedback-settings`, settings);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getEventFeedback(eventId: string, params?: {
|
||||
type?: string;
|
||||
status?: string;
|
||||
photoId?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}) {
|
||||
const response = await api.get(`/admin/feedback/events/${eventId}/feedback`, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async moderateFeedback(feedbackId: string, action: 'approve' | 'hide' | 'reject') {
|
||||
const response = await api.put(`/admin/feedback/feedback/${feedbackId}/${action}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteFeedback(feedbackId: string) {
|
||||
const response = await api.delete(`/admin/feedback/feedback/${feedbackId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getEventFeedbackAnalytics(eventId: string): Promise<FeedbackAnalytics> {
|
||||
const response = await api.get(`/admin/feedback/events/${eventId}/feedback-analytics`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async exportEventFeedback(eventId: string, format: 'json' | 'csv' = 'json') {
|
||||
const response = await api.get(`/admin/feedback/events/${eventId}/feedback/export`, {
|
||||
params: { format },
|
||||
responseType: format === 'csv' ? 'blob' : 'json'
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getPendingModeration() {
|
||||
const response = await api.get('/admin/feedback/feedback/pending-moderation');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Word filter management
|
||||
async getWordFilters() {
|
||||
const response = await api.get('/admin/feedback/feedback/word-filters');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async addWordFilter(word: string, severity: 'low' | 'moderate' | 'high' = 'moderate') {
|
||||
const response = await api.post('/admin/feedback/feedback/word-filters', { word, severity });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateWordFilter(id: string, updates: { word?: string; severity?: string; is_active?: boolean }) {
|
||||
const response = await api.put(`/admin/feedback/feedback/word-filters/${id}`, updates);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteWordFilter(id: string) {
|
||||
const response = await api.delete(`/admin/feedback/feedback/word-filters/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Guest endpoints
|
||||
async getGalleryFeedbackSettings(slug: string): Promise<Partial<FeedbackSettings>> {
|
||||
const response = await api.get(`/gallery/${slug}/feedback-settings`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getPhotoFeedback(slug: string, photoId: string): Promise<FeedbackResponse> {
|
||||
const response = await api.get(`/gallery/${slug}/photos/${photoId}/feedback`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async submitFeedback(slug: string, photoId: string, feedback: {
|
||||
feedback_type: 'rating' | 'like' | 'comment' | 'favorite';
|
||||
rating?: number;
|
||||
comment_text?: string;
|
||||
guest_name?: string;
|
||||
guest_email?: string;
|
||||
}) {
|
||||
const response = await api.post(`/gallery/${slug}/photos/${photoId}/feedback`, feedback);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getGalleryFeedbackSummary(slug: string) {
|
||||
const response = await api.get(`/gallery/${slug}/feedback-summary`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getMyFeedback(slug: string) {
|
||||
const response = await api.get(`/gallery/${slug}/my-feedback`);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const feedbackService = new FeedbackService();
|
||||
@@ -7,4 +7,5 @@ export { archiveService } from './archive.service';
|
||||
export { emailService } from './email.service';
|
||||
export { settingsService } from './settings.service';
|
||||
export { cmsService } from './cms.service';
|
||||
export { notificationsService } from './notifications.service';
|
||||
export { notificationsService } from './notifications.service';
|
||||
export { feedbackService } from './feedback.service';
|
||||
Reference in New Issue
Block a user