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>
151 lines
5.0 KiB
TypeScript
151 lines
5.0 KiB
TypeScript
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';
|
|
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
|
|
|
|
interface PhotoRatingProps {
|
|
photoId: string;
|
|
gallerySlug: string;
|
|
currentRating?: number;
|
|
averageRating?: number;
|
|
totalRatings?: number;
|
|
isEnabled: boolean;
|
|
requireNameEmail?: boolean;
|
|
onRatingChange?: (rating: number) => void;
|
|
}
|
|
|
|
export const PhotoRating: React.FC<PhotoRatingProps> = ({
|
|
photoId,
|
|
gallerySlug,
|
|
currentRating = 0,
|
|
averageRating = 0,
|
|
totalRatings = 0,
|
|
isEnabled,
|
|
requireNameEmail = false,
|
|
onRatingChange
|
|
}) => {
|
|
// Ensure averageRating is a valid number
|
|
const safeAverageRating = typeof averageRating === 'number' && !isNaN(averageRating) ? averageRating : 0;
|
|
const { t } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
const [hoveredRating, setHoveredRating] = useState(0);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
|
const [pendingRating, setPendingRating] = useState(0);
|
|
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
|
|
|
const submitRatingMutation = useMutation({
|
|
mutationFn: (data: { rating: number; guest_name?: string; guest_email?: string }) =>
|
|
feedbackService.submitFeedback(gallerySlug, photoId, {
|
|
feedback_type: 'rating',
|
|
rating: data.rating,
|
|
guest_name: data.guest_name || undefined,
|
|
guest_email: data.guest_email || undefined
|
|
}),
|
|
onMutate: async (data) => {
|
|
setIsSubmitting(true);
|
|
// Optimistic update
|
|
if (onRatingChange) {
|
|
onRatingChange(data.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;
|
|
|
|
if (requireNameEmail && !savedIdentity) {
|
|
setPendingRating(newRating);
|
|
setShowIdentityModal(true);
|
|
} else {
|
|
submitRatingMutation.mutate({
|
|
rating: newRating,
|
|
guest_name: savedIdentity?.name,
|
|
guest_email: savedIdentity?.email
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleIdentitySubmit = (name: string, email: string) => {
|
|
setSavedIdentity({ name, email });
|
|
setShowIdentityModal(false);
|
|
submitRatingMutation.mutate({
|
|
rating: pendingRating,
|
|
guest_name: name,
|
|
guest_email: email
|
|
});
|
|
};
|
|
|
|
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">{safeAverageRating.toFixed(1)}</span>
|
|
<span className="text-neutral-400 ml-1">
|
|
({t('feedback.ratingsCount', '{{count}} ratings', { count: totalRatings })})
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<FeedbackIdentityModal
|
|
isOpen={showIdentityModal}
|
|
onClose={() => setShowIdentityModal(false)}
|
|
onSubmit={handleIdentitySubmit}
|
|
feedbackType={t('feedback.rating', 'rating')}
|
|
/>
|
|
</>
|
|
);
|
|
}; |