* feat(feedback): let guests remove their star rating (#884) Clicking your current rating again clears it. rating: 0 is the wire contract: the validator now accepts 0, and the service deletes the guest's rating row (instead of storing a 0 that would drag the photo average down) and recalculates photo stats. The lightbox stars send 0 on a same-star click; PhotoRating already did, but the backend rejected it with a 400 until now. * fix(feedback): harden the rating-clear path (#884 review round) External review follow-ups: numerically normalize the clear sentinel so a numeric-string "0" can't slip into the update/insert paths (validator now also toInt()s), delete the full guest-scoped rating set on clear so racy duplicate rows can't survive in the average (same defense as the reaction path), and refresh the visible average/count after the identity-modal submit path like the direct paths do. * fix(feedback): round-2 review fixes for rating clear (#884) - Clear sentinel matches only an explicit 0 / "0" — malformed input (undefined, NaN, garbage strings) can no longer delete a rating. - Lightbox survives the photo list shrinking while open (clearing your rating under the Rated filter drops the photo on refetch): index is re-anchored and the lightbox closes when the list empties, instead of crashing on an out-of-range index. - Story layout gets the same same-star-to-clear behavior, keyed off the session-local my-rating map, and an explicit 0 no longer falls back to displaying the photo average. * fix(feedback): refresh guest-scoped caches after rating changes (#884 review round 3) - GalleryView's onFeedbackChange now also invalidates ['my-feedback', slug]: in guest identity mode the Rated/Liked filter membership and chip counts come from that query (#538), so a cleared rating never left the Rated filter until the 30s staleTime lapsed. - PhotoRating invalidates gallery-photos + my-feedback on success: the parent refetch fires optimistically in onMutate and could capture pre-mutation state, with nothing refreshing after the server accepted. --------- Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
435c558704
commit
6a048d08bd
@@ -783,7 +783,13 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
photos={filteredPhotos}
|
||||
slug={slug}
|
||||
categoryId={selectedCategoryId}
|
||||
onFeedbackChange={() => refetch()}
|
||||
onFeedbackChange={() => {
|
||||
refetch();
|
||||
// Guest-mode Rated/Liked filter membership + chip counts come
|
||||
// from my-feedback, not the photo rows (#538) — refresh it too
|
||||
// so e.g. a cleared rating (#884) leaves the Rated filter.
|
||||
queryClient.invalidateQueries({ queryKey: ['my-feedback', slug] });
|
||||
}}
|
||||
heroPhotoOverride={staticHeroPhoto}
|
||||
feedbackEnabled={feedbackEnabled}
|
||||
feedbackOptions={{
|
||||
@@ -1030,7 +1036,13 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
photos={filteredPhotos}
|
||||
slug={slug}
|
||||
categoryId={selectedCategoryId}
|
||||
onFeedbackChange={() => refetch()}
|
||||
onFeedbackChange={() => {
|
||||
refetch();
|
||||
// Guest-mode Rated/Liked filter membership + chip counts come
|
||||
// from my-feedback, not the photo rows (#538) — refresh it too
|
||||
// so e.g. a cleared rating (#884) leaves the Rated filter.
|
||||
queryClient.invalidateQueries({ queryKey: ['my-feedback', slug] });
|
||||
}}
|
||||
heroPhotoOverride={staticHeroPhoto}
|
||||
feedbackEnabled={feedbackEnabled}
|
||||
feedbackOptions={{
|
||||
|
||||
@@ -118,6 +118,16 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Keep the index valid when the photo list shrinks while open (see
|
||||
// currentPhoto fallback below).
|
||||
useEffect(() => {
|
||||
if (photos.length === 0) {
|
||||
onClose();
|
||||
} else if (currentIndex > photos.length - 1) {
|
||||
setCurrentIndex(photos.length - 1);
|
||||
}
|
||||
}, [photos.length, currentIndex]);
|
||||
|
||||
|
||||
// Save-aware download. On mobile (where Web Share + files is supported)
|
||||
// this opens the OS share sheet so "Save to Photos" actually lands in
|
||||
@@ -125,7 +135,12 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
// otherwise have to chain Files → unzip → save (#531). Desktop and
|
||||
// unsupported browsers fall through to a regular <a download>.
|
||||
const downloadPhotoMutation = useSavePhotoToDevice();
|
||||
const currentPhoto = photos[currentIndex];
|
||||
// Fall back to the last photo when the list shrinks under us: clearing
|
||||
// your rating under the "Rated" feedback filter (#884) — like unliking
|
||||
// under "Likes" — refetches the gallery and can drop the current photo,
|
||||
// leaving currentIndex past the end. The effect below re-syncs the
|
||||
// index (or closes the lightbox when nothing is left).
|
||||
const currentPhoto = photos[currentIndex] ?? photos[photos.length - 1];
|
||||
// Per-category download permission (#640). AND'd with the event-level
|
||||
// allowDownloads — disabling at either level hides the download button.
|
||||
// Defaults true for uncategorised photos and pre-migration-135 categories.
|
||||
@@ -241,7 +256,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
if (!feedbackSettings?.feedback_enabled) return;
|
||||
if (!feedbackSettings?.feedback_enabled || !currentPhoto) return;
|
||||
const data = await feedbackService.getPhotoFeedback(slug, String(currentPhoto.id));
|
||||
if (!mounted) return;
|
||||
setMyLiked(!!data.my_feedback.liked);
|
||||
@@ -254,7 +269,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
}
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, [slug, currentPhoto.id, feedbackSettings?.feedback_enabled]);
|
||||
}, [slug, currentPhoto?.id, feedbackSettings?.feedback_enabled]);
|
||||
|
||||
const submitLike = async () => {
|
||||
// Guest identity mode: ensure we have a per-person guest token. The
|
||||
@@ -629,6 +644,12 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
`fixed inset-0 bg-black z-50 flex items-center justify-center protected-image protection-${protectionLevel}` :
|
||||
'fixed inset-0 bg-black z-50 flex items-center justify-center';
|
||||
|
||||
// Empty list (last photo dropped out of the current filter): the effect
|
||||
// above is about to close the lightbox — render nothing meanwhile.
|
||||
if (!currentPhoto) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const desktopFeedbackWidth = 416; // 26rem; keep in sync with panel width
|
||||
const isDesktopFeedback = showFeedback && !isSmallScreen;
|
||||
|
||||
@@ -777,10 +798,12 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
{[1,2,3,4,5].map((i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => submitRating(i)}
|
||||
// Clicking the current rating again clears it (#884) —
|
||||
// 0 tells the backend to delete the guest's rating.
|
||||
onClick={() => submitRating(i === myRating ? 0 : i)}
|
||||
className="p-1"
|
||||
aria-label={`Rate ${i} star${i>1?'s':''}`}
|
||||
title={`Rate ${i}`}
|
||||
aria-label={i === myRating ? 'Remove rating' : `Rate ${i} star${i>1?'s':''}`}
|
||||
title={i === myRating ? 'Remove rating' : `Rate ${i}`}
|
||||
>
|
||||
<Star className={`w-5 h-5 ${myRating >= i ? 'text-yellow-400 fill-yellow-400' : 'text-white/70'}`} />
|
||||
</button>
|
||||
@@ -1041,7 +1064,9 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
// Per-guest cap reached (#655) on the post-identity-modal submit.
|
||||
if (!handleLimitError(err)) throw err;
|
||||
}
|
||||
} else if (pendingAction?.type === 'rating' && pendingAction.rating) {
|
||||
} else if (pendingAction?.type === 'rating' && typeof pendingAction.rating === 'number') {
|
||||
// Explicit number check above: a pending rating of 0 (= clear
|
||||
// my rating, #884) must still be submitted.
|
||||
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
|
||||
feedback_type: 'rating',
|
||||
rating: pendingAction.rating,
|
||||
@@ -1049,6 +1074,14 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
guest_email: email,
|
||||
});
|
||||
setMyRating(pendingAction.rating);
|
||||
// Refresh the visible average/count — parity with the direct
|
||||
// submit paths, and required for a clear (#884) so the old
|
||||
// average doesn't linger until the photo is reopened.
|
||||
try {
|
||||
const fresh = await feedbackService.getPhotoFeedback(slug, String(currentPhoto.id));
|
||||
setAvgRating(Number(fresh.summary?.average_rating) || 0);
|
||||
setTotalRatings(Number(fresh.summary?.total_ratings) || 0);
|
||||
} catch {}
|
||||
}
|
||||
// Sync gallery photo list (feedback filter chips) — parity with
|
||||
// the direct submit paths.
|
||||
|
||||
@@ -56,6 +56,12 @@ export const PhotoRating: React.FC<PhotoRatingProps> = ({
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
|
||||
// The parent refetch fired optimistically in onMutate, i.e. possibly
|
||||
// before the server applied the change — refresh gallery-level state
|
||||
// again now that it has, so the average/Rated-filter membership
|
||||
// reflect the accepted value (matters most for a clear, #884).
|
||||
queryClient.invalidateQueries({ queryKey: ['gallery-photos', gallerySlug] });
|
||||
queryClient.invalidateQueries({ queryKey: ['my-feedback', gallerySlug] });
|
||||
toast.success(t('feedback.ratingSubmitted', 'Rating submitted'));
|
||||
},
|
||||
onError: (error: any) => {
|
||||
|
||||
@@ -207,15 +207,21 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
|
||||
const handleRate = useCallback(async (rating: number) => {
|
||||
if (!selectedPhotoForFeedback) return;
|
||||
|
||||
// Clicking the star you already gave clears the rating (#884) —
|
||||
// 0 tells the backend to delete it. Session-local `ratings` is the
|
||||
// source of truth for "my rating" here, never the photo's average.
|
||||
const current = ratings[selectedPhotoForFeedback.id] || 0;
|
||||
const next = rating === current ? 0 : rating;
|
||||
|
||||
setRatings(prev => ({
|
||||
...prev,
|
||||
[selectedPhotoForFeedback.id]: rating
|
||||
[selectedPhotoForFeedback.id]: next
|
||||
}));
|
||||
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug, String(selectedPhotoForFeedback.id), {
|
||||
feedback_type: 'rating',
|
||||
rating: rating,
|
||||
rating: next,
|
||||
guest_name: savedIdentity?.name,
|
||||
guest_email: savedIdentity?.email,
|
||||
});
|
||||
@@ -223,7 +229,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
|
||||
} catch (err) {
|
||||
console.warn('Rating submit failed', err);
|
||||
}
|
||||
}, [selectedPhotoForFeedback, slug, savedIdentity, onFeedbackChange]);
|
||||
}, [selectedPhotoForFeedback, ratings, slug, savedIdentity, onFeedbackChange]);
|
||||
|
||||
const handleDownloadAll = useCallback(async () => {
|
||||
toast.info(t('gallery.downloading', { count: photos.length }));
|
||||
@@ -388,7 +394,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
|
||||
onClose={handleCloseFeedback}
|
||||
photo={selectedPhotoForFeedback}
|
||||
comments={selectedPhotoForFeedback ? (comments[selectedPhotoForFeedback.id] || []) : []}
|
||||
rating={selectedPhotoForFeedback ? (ratings[selectedPhotoForFeedback.id] || selectedPhotoForFeedback.average_rating || 0) : 0}
|
||||
rating={selectedPhotoForFeedback ? (ratings[selectedPhotoForFeedback.id] ?? (selectedPhotoForFeedback.average_rating || 0)) : 0}
|
||||
onAddComment={handleAddComment}
|
||||
onRate={handleRate}
|
||||
requireNameEmail={feedbackOptions?.requireNameEmail}
|
||||
|
||||
Reference in New Issue
Block a user