fix(feedback): three guest-mode bugs reported in #538
Picks up three of the four bugs from @Tietge86's report. Bug 4
(recovery flow) needs network-tab data from the reporter before a fix
makes sense; commented on the issue asking for the HTTP status from
POST /gallery/:slug/guest/recover.
Bug 1 — "Liked" filter empty in guest identity mode (GalleryView.tsx)
The feedback filter was scoping by `photo.like_count > 0`, which is
the global aggregate across all guests. In guest identity mode the
filter intent is "show MY picks", so a guest who'd liked photos that
nobody else had touched got an empty grid.
Fix: pull the current guest's interactions from /my-feedback (already
keyed by x-guest-token in the api interceptor) into per-type
photo-id Sets and filter against those when identity_mode === 'guest'.
Falls back to the aggregate-count check in simple mode where there's
no per-person identity to scope by. Same per-guest scoping applied to
the chip-count labels ("Liked (N)" etc.) so the chip number matches
what the filter actually surfaces — otherwise the chip says one
count globally and the filter shows a different (smaller) one, which
is the same UX cliff #538 originally surfaced.
The /my-feedback query is gated on isGuestIdentityMode (not on
filterType being feedback-related) so the chip counts are populated
on first render. One extra request per gallery load in guest mode;
payload is tiny.
Bug 2 — Liked state on PhotoLikes button invisible
bg-red-50 text-red-600 is barely visible against most themes,
especially dark + brand-coloured backgrounds. Switch to the same
filled state the lightbox toolbar already uses
(bg-red-500/80 text-white) so the like registers visually.
Heart icon's fill-current was already there for the liked state —
unchanged.
Bug 3 — Aggregate like count leaks in lightbox toolbar
PhotoLightbox.tsx rendered {likeCount} unconditionally next to the
inline heart button. When the admin has show_feedback_to_guests off,
guests still saw how many other guests had liked a photo (the count
is an admin-only metric in that mode). Gate the span on
feedbackSettings?.show_feedback_to_guests, matching how the rest of
the lightbox toolbar treats that toggle. Also added show_feedback_to_guests
to the local feedbackSettings TS type (backend already returns it).
Tests: tsc --noEmit clean. No new unit tests — bug 1 is data-flow
plumbing best validated via manual / e2e (the existing my-feedback
endpoint and feedbackService are already covered upstream); bugs 2/3
are CSS and a conditional render.
Refs: #538 (bugs 1, 2, 3 of 4)
This commit is contained in:
@@ -23,6 +23,7 @@ import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
|
|||||||
import { api } from '../../config/api';
|
import { api } from '../../config/api';
|
||||||
import { Upload, Menu, Eye, EyeOff, Shield } from 'lucide-react';
|
import { Upload, Menu, Eye, EyeOff, Shield } from 'lucide-react';
|
||||||
import { galleryService } from '../../services/gallery.service';
|
import { galleryService } from '../../services/gallery.service';
|
||||||
|
import { feedbackService } from '../../services/feedback.service';
|
||||||
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
|
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
|
||||||
import { useGalleryCustomCss } from '../../hooks/useGalleryCustomCss';
|
import { useGalleryCustomCss } from '../../hooks/useGalleryCustomCss';
|
||||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||||
@@ -229,6 +230,46 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
}
|
}
|
||||||
}, [feedbackSettings]);
|
}, [feedbackSettings]);
|
||||||
|
|
||||||
|
// In guest identity mode both the "Liked / Favorited / Rated /
|
||||||
|
// Commented" filters AND the matching chip-count labels need to scope
|
||||||
|
// to the *current guest's* interactions, not the global aggregates on
|
||||||
|
// each photo row (#538 bug 1). Pull the current guest's feedback via
|
||||||
|
// /my-feedback (already keyed by x-guest-token in the api interceptor)
|
||||||
|
// and build per-type photo-id sets so both consumers below can do
|
||||||
|
// O(1) lookups.
|
||||||
|
//
|
||||||
|
// Always-on in guest mode (not gated on the active filter) because
|
||||||
|
// the chip counts render whether or not a feedback filter is selected
|
||||||
|
// — gating on filterType would leave "Liked (0)" stale until the user
|
||||||
|
// clicks the chip, which is the same UX cliff bug 1 was reporting.
|
||||||
|
const isGuestIdentityMode = feedbackSettings?.identity_mode === 'guest';
|
||||||
|
const { data: myFeedbackRows } = useQuery<Array<{
|
||||||
|
photo_id: number;
|
||||||
|
feedback_type: 'like' | 'favorite' | 'rating' | 'comment';
|
||||||
|
}>>({
|
||||||
|
queryKey: ['my-feedback', slug],
|
||||||
|
queryFn: () => feedbackService.getMyFeedback(slug),
|
||||||
|
enabled: isGuestIdentityMode && !!slug,
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const myFeedbackPhotoIds = useMemo(() => {
|
||||||
|
const sets = {
|
||||||
|
liked: new Set<number>(),
|
||||||
|
favorited: new Set<number>(),
|
||||||
|
rated: new Set<number>(),
|
||||||
|
commented: new Set<number>(),
|
||||||
|
};
|
||||||
|
if (!myFeedbackRows) return sets;
|
||||||
|
for (const row of myFeedbackRows) {
|
||||||
|
if (row.feedback_type === 'like') sets.liked.add(row.photo_id);
|
||||||
|
else if (row.feedback_type === 'favorite') sets.favorited.add(row.photo_id);
|
||||||
|
else if (row.feedback_type === 'rating') sets.rated.add(row.photo_id);
|
||||||
|
else if (row.feedback_type === 'comment') sets.commented.add(row.photo_id);
|
||||||
|
}
|
||||||
|
return sets;
|
||||||
|
}, [myFeedbackRows]);
|
||||||
|
|
||||||
// Apply branding settings
|
// Apply branding settings
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settingsData) {
|
if (settingsData) {
|
||||||
@@ -444,19 +485,33 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply feedback filter
|
// Apply feedback filter. In guest identity mode the filter has to
|
||||||
|
// scope to the *current guest's* interactions (#538 bug 1) — the
|
||||||
|
// aggregate counts on each photo row are global across all guests,
|
||||||
|
// which gave an empty grid when the guest had liked photos that
|
||||||
|
// nobody else had touched. Falls back to the aggregate-count check
|
||||||
|
// in simple/non-guest mode where there's no per-person identity to
|
||||||
|
// scope by.
|
||||||
switch (filterType) {
|
switch (filterType) {
|
||||||
case 'liked':
|
case 'liked':
|
||||||
photos = photos.filter(photo => (photo.like_count || 0) > 0);
|
photos = isGuestIdentityMode
|
||||||
|
? photos.filter(photo => myFeedbackPhotoIds.liked.has(photo.id))
|
||||||
|
: photos.filter(photo => (photo.like_count || 0) > 0);
|
||||||
break;
|
break;
|
||||||
case 'favorited':
|
case 'favorited':
|
||||||
photos = photos.filter(photo => (photo.favorite_count || 0) > 0);
|
photos = isGuestIdentityMode
|
||||||
|
? photos.filter(photo => myFeedbackPhotoIds.favorited.has(photo.id))
|
||||||
|
: photos.filter(photo => (photo.favorite_count || 0) > 0);
|
||||||
break;
|
break;
|
||||||
case 'rated':
|
case 'rated':
|
||||||
photos = photos.filter(photo => (photo.average_rating || 0) > 0 || (photo.total_ratings || 0) > 0);
|
photos = isGuestIdentityMode
|
||||||
|
? photos.filter(photo => myFeedbackPhotoIds.rated.has(photo.id))
|
||||||
|
: photos.filter(photo => (photo.average_rating || 0) > 0 || (photo.total_ratings || 0) > 0);
|
||||||
break;
|
break;
|
||||||
case 'commented':
|
case 'commented':
|
||||||
photos = photos.filter(photo => (photo.comment_count || 0) > 0);
|
photos = isGuestIdentityMode
|
||||||
|
? photos.filter(photo => myFeedbackPhotoIds.commented.has(photo.id))
|
||||||
|
: photos.filter(photo => (photo.comment_count || 0) > 0);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
@@ -502,22 +557,29 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return photos;
|
return photos;
|
||||||
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug, filterType, mediaFilter]);
|
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug, filterType, mediaFilter, isGuestIdentityMode, myFeedbackPhotoIds]);
|
||||||
|
|
||||||
const likeCount = useMemo(
|
// Counts shown in the filter chips ("Liked (N)", etc.). In guest
|
||||||
() => data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0,
|
// mode these need to mirror the per-guest filter behaviour above —
|
||||||
[data?.photos]
|
// otherwise the chip says "Liked (5)" globally but clicking it
|
||||||
);
|
// surfaces 3 (the guest's own subset), which is the same confusing
|
||||||
|
// mismatch #538 reported for the filter itself. Fall back to the
|
||||||
|
// global aggregate in simple mode where no per-person identity
|
||||||
|
// exists.
|
||||||
|
const likeCount = useMemo(() => {
|
||||||
|
if (isGuestIdentityMode) return myFeedbackPhotoIds.liked.size;
|
||||||
|
return data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0;
|
||||||
|
}, [data?.photos, isGuestIdentityMode, myFeedbackPhotoIds]);
|
||||||
|
|
||||||
const favoriteCount = useMemo(
|
const favoriteCount = useMemo(() => {
|
||||||
() => data?.photos?.filter(p => (p.favorite_count ?? 0) > 0).length || 0,
|
if (isGuestIdentityMode) return myFeedbackPhotoIds.favorited.size;
|
||||||
[data?.photos]
|
return data?.photos?.filter(p => (p.favorite_count ?? 0) > 0).length || 0;
|
||||||
);
|
}, [data?.photos, isGuestIdentityMode, myFeedbackPhotoIds]);
|
||||||
|
|
||||||
const ratedCount = useMemo(
|
const ratedCount = useMemo(() => {
|
||||||
() => data?.photos?.filter(p => (p.total_ratings || 0) > 0 || (p.average_rating || 0) > 0).length || 0,
|
if (isGuestIdentityMode) return myFeedbackPhotoIds.rated.size;
|
||||||
[data?.photos]
|
return data?.photos?.filter(p => (p.total_ratings || 0) > 0 || (p.average_rating || 0) > 0).length || 0;
|
||||||
);
|
}, [data?.photos, isGuestIdentityMode, myFeedbackPhotoIds]);
|
||||||
|
|
||||||
// Check if downloads are allowed (both event setting and not expired)
|
// Check if downloads are allowed (both event setting and not expired)
|
||||||
const allowDownloads = !isExpired && (data?.event?.allow_downloads === true);
|
const allowDownloads = !isExpired && (data?.event?.allow_downloads === true);
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
allow_likes?: boolean;
|
allow_likes?: boolean;
|
||||||
allow_ratings?: boolean;
|
allow_ratings?: boolean;
|
||||||
allow_comments?: boolean;
|
allow_comments?: boolean;
|
||||||
|
show_feedback_to_guests?: boolean;
|
||||||
require_name_email?: boolean;
|
require_name_email?: boolean;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [myLiked, setMyLiked] = useState<boolean>(false);
|
const [myLiked, setMyLiked] = useState<boolean>(false);
|
||||||
@@ -663,7 +664,13 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
>
|
>
|
||||||
<Heart className={`w-5 h-5 ${myLiked ? 'text-white' : 'text-white'}`} />
|
<Heart className={`w-5 h-5 ${myLiked ? 'text-white' : 'text-white'}`} />
|
||||||
</button>
|
</button>
|
||||||
<span className="text-white text-xs min-w-[1.5rem] text-center select-none">{likeCount}</span>
|
{/* Aggregate like count is admin-only when the admin
|
||||||
|
has hidden feedback from guests (#538 bug 3). Without
|
||||||
|
this gate, a guest could see how many other guests
|
||||||
|
liked a photo even with show_feedback_to_guests off. */}
|
||||||
|
{feedbackSettings?.show_feedback_to_guests && (
|
||||||
|
<span className="text-white text-xs min-w-[1.5rem] text-center select-none">{likeCount}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -111,8 +111,12 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
|
|||||||
onClick={handleLikeClick}
|
onClick={handleLikeClick}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className={`group flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
|
className={`group flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
|
||||||
isLiked
|
// Filled solid-red state matches the lightbox toolbar (#538 bug
|
||||||
? 'bg-red-50 text-red-600 hover:bg-red-100'
|
// 2). The previous `bg-red-50 text-red-600` was almost invisible
|
||||||
|
// — particularly on dark themes and coloured gallery backgrounds
|
||||||
|
// — so the user couldn't tell the like had registered.
|
||||||
|
isLiked
|
||||||
|
? 'bg-red-500/80 text-white hover:bg-red-500'
|
||||||
: 'bg-surface text-muted-theme hover:bg-black/10'
|
: 'bg-surface text-muted-theme hover:bg-black/10'
|
||||||
} ${isSubmitting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
|
} ${isSubmitting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
|
||||||
aria-label={isLiked ? t('feedback.unlike', 'Unlike') : t('feedback.like', 'Like')}
|
aria-label={isLiked ? t('feedback.unlike', 'Unlike') : t('feedback.like', 'Like')}
|
||||||
|
|||||||
Reference in New Issue
Block a user