fix: guest feedback flow bugs in Masonry grid and PhotoLightbox (#292)

Fixes two bugs reported on #292 after 3.27.0-beta.0 shipped:

1. Masonry grid showed no visual feedback after liking a photo.
   MasonryGalleryLayout's Like button had no liked-state plumbing —
   the Heart icon was a static <Heart> regardless of whether the user
   had liked the photo.

2. PhotoLightbox (fullscreen view) silently failed to like photos in
   guest identity mode. submitLike() and submitRating() never called
   ensureIdentity() before firing the API request, so the first
   interaction from a fresh session hit a 401 from the server instead
   of opening the name prompt.

Root causes:

1. MasonryGalleryLayout was missing the 'liked' state pattern that
   GridGalleryLayout already uses (likedPhotoIds Set in the parent,
   passed down as a `liked` prop, updated via onLikeSuccess callback).
   The bug was invisible in simple mode (no personal state) but
   surfaced immediately in guest mode where each guest expects to see
   confirmation of their own action.

2. PhotoLightbox's submit handlers were written before the guest
   identity context existed and only checked the legacy
   require_name_email flag. They were never updated when guest mode
   landed.

Also fixed: z-index conflict where the GuestNamePromptModal (z-50)
was sitting at the same level as PhotoLightbox (z-50), so when the
prompt opened over the lightbox, the fullscreen image intercepted
pointer events and the modal's Continue button was unclickable.
Bumped both guest modals to z-[60].

Changes:

- MasonryGalleryLayout.tsx
  - MasonryPhotoProps gains `liked?: boolean` + `onLikeSuccess?: () => void`.
  - Like button: red bg + filled white Heart icon when liked; aria-label
    toggles between "Like photo"/"Unlike photo"; aria-pressed mirrors state.
  - onClick wires onLikeSuccess() for optimistic UI in both guest-mode
    and simple-mode branches plus the FeedbackIdentityModal onSubmit path.
  - Parent layout holds `likedPhotoIds: Set<number>` and passes it to
    each MasonryPhoto (matches the GridGalleryLayout pattern).

- PhotoLightbox.tsx
  - Consumes useGuestIdentityOptional(); new `isGuestMode` flag.
  - submitLike() and submitRating() get a guest-mode branch that calls
    ensureIdentity() first and submits without body guest_name/email
    (server reads from the verified token).
  - Optimistic UI updates happen after successful submit in guest mode.

- GuestNamePromptModal.tsx, GuestRecoveryModal.tsx
  - z-50 → z-[60] so they render above PhotoLightbox.

Verified end-to-end against local Docker with Playwright MCP on event
168 (Masonry Columns Test layout):

- Fresh session, click Like in Masonry grid → name prompt opens, register,
  feedback persists with guest_id, Heart button turns red with
  aria-pressed and "Unlike photo" label. Subsequent likes on other
  photos also show red state. DB confirms feedback rows.

- Fresh session, open photo in lightbox BEFORE registering → click Like,
  the name prompt correctly opens on top of the lightbox, register,
  feedback persists. Rate 4 stars → works, average 4.0 (1) displayed
  in lightbox, ★ badge appears on toggle-feedback button, grid cell
  shows "1 likes" + "Rating: 4.0" indicators after closing lightbox.

- Backend DB: gallery_guests row created, photo_feedback rows have
  correct guest_id, server reads name from verified token (body values
  ignored).

Out of scope (documented in audit, not reported by the user, no
regression from guest mode): Mosaic/Carousel/Timeline have partial
optimistic-UI issues unrelated to this report; they pre-date guest
mode and behave the same in simple mode. Leaving alone per scope
discipline.
This commit is contained in:
Paul Nothaft
2026-04-11 14:42:28 +02:00
parent 72c0c2d18e
commit 77f07e9329
4 changed files with 112 additions and 15 deletions
@@ -72,7 +72,7 @@ export const GuestNamePromptModal: React.FC<GuestNamePromptModalProps> = ({
}; };
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={allowCancel ? handleClose : undefined} /> <div className="fixed inset-0 bg-black bg-opacity-50" onClick={allowCancel ? handleClose : undefined} />
<div className="relative bg-surface rounded-lg shadow-xl max-w-md w-full p-6"> <div className="relative bg-surface rounded-lg shadow-xl max-w-md w-full p-6">
{allowCancel && ( {allowCancel && (
@@ -86,7 +86,7 @@ export const GuestRecoveryModal: React.FC = () => {
}; };
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={handleClose} /> <div className="fixed inset-0 bg-black bg-opacity-50" onClick={handleClose} />
<div className="relative bg-surface rounded-lg shadow-xl max-w-md w-full p-6"> <div className="relative bg-surface rounded-lg shadow-xl max-w-md w-full p-6">
<button <button
@@ -8,6 +8,7 @@ import { PhotoFeedback } from './PhotoFeedback';
import { feedbackService } from '../../services/feedback.service'; import { feedbackService } from '../../services/feedback.service';
import { FeedbackIdentityModal } from './FeedbackIdentityModal'; import { FeedbackIdentityModal } from './FeedbackIdentityModal';
import { VideoPlayer } from './VideoPlayer'; import { VideoPlayer } from './VideoPlayer';
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
interface PhotoLightboxProps { interface PhotoLightboxProps {
photos: Photo[]; photos: Photo[];
@@ -63,6 +64,8 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
const [showIdentityModal, setShowIdentityModal] = useState(false); const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'rating'; rating?: number }>(null); const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'rating'; rating?: number }>(null);
const [imageLoaded, setImageLoaded] = useState(false); const [imageLoaded, setImageLoaded] = useState(false);
const guestIdentity = useGuestIdentityOptional();
const isGuestMode = guestIdentity?.identityMode === 'guest';
useEffect(() => { useEffect(() => {
const onResize = () => setIsSmallScreen(window.innerWidth < 640); const onResize = () => setIsSmallScreen(window.innerWidth < 640);
@@ -203,6 +206,33 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
}, [slug, currentPhoto.id, feedbackSettings?.feedback_enabled]); }, [slug, currentPhoto.id, feedbackSettings?.feedback_enabled]);
const submitLike = async () => { const submitLike = async () => {
// Guest identity mode: ensure we have a per-person guest token. The
// server reads name/email from the token — body values are ignored.
if (isGuestMode && guestIdentity) {
try {
await guestIdentity.ensureIdentity();
} catch {
// User cancelled the prompt — abort silently.
return;
}
try {
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
feedback_type: 'like',
});
setMyLiked(prev => {
const next = !prev;
setLikeCount(c => Math.max(0, c + (next ? 1 : -1)));
return next;
});
if (onFeedbackChange) onFeedbackChange();
} catch (err) {
// eslint-disable-next-line no-console
console.warn('Like submit failed', err);
}
return;
}
// Simple mode: legacy inline identity modal flow.
const needIdentity = feedbackSettings?.require_name_email && !savedIdentity; const needIdentity = feedbackSettings?.require_name_email && !savedIdentity;
if (needIdentity) { if (needIdentity) {
setPendingAction({ type: 'like' }); setPendingAction({ type: 'like' });
@@ -222,6 +252,33 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
}; };
const submitRating = async (value: number) => { const submitRating = async (value: number) => {
// Guest identity mode.
if (isGuestMode && guestIdentity) {
try {
await guestIdentity.ensureIdentity();
} catch {
return;
}
try {
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
feedback_type: 'rating',
rating: value,
});
setMyRating(value);
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 {}
if (onFeedbackChange) onFeedbackChange();
} catch (err) {
// eslint-disable-next-line no-console
console.warn('Rating submit failed', err);
}
return;
}
// Simple mode: legacy inline identity modal flow.
const needIdentity = feedbackSettings?.require_name_email && !savedIdentity; const needIdentity = feedbackSettings?.require_name_email && !savedIdentity;
if (needIdentity) { if (needIdentity) {
setPendingAction({ type: 'rating', rating: value }); setPendingAction({ type: 'rating', rating: value });
@@ -34,6 +34,9 @@ interface MasonryPhotoProps {
onQuickComment?: () => void; onQuickComment?: () => void;
// Column width for calculating proper aspect-ratio-based height // Column width for calculating proper aspect-ratio-based height
columnWidth?: number; columnWidth?: number;
// Optimistic "I liked this" state + callback (lifted to parent)
liked?: boolean;
onLikeSuccess?: () => void;
} }
const MasonryPhoto: React.FC<MasonryPhotoProps> = ({ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
@@ -49,7 +52,9 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
slug, slug,
feedbackOptions, feedbackOptions,
onQuickComment, onQuickComment,
columnWidth = 300 columnWidth = 300,
liked = false,
onLikeSuccess,
}) => { }) => {
const [showIdentityModal, setShowIdentityModal] = useState(false); const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null); const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
@@ -150,7 +155,11 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
)} )}
{feedbackEnabled && feedbackOptions?.allowLikes && ( {feedbackEnabled && feedbackOptions?.allowLikes && (
<button <button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors" className={`p-2 rounded-full transition-colors ${
liked
? 'bg-red-500/90 hover:bg-red-500'
: 'bg-white/90 hover:bg-white'
}`}
onClick={async (e) => { onClick={async (e) => {
e.stopPropagation(); e.stopPropagation();
if (guestIdentity?.identityMode === 'guest') { if (guestIdentity?.identityMode === 'guest') {
@@ -159,9 +168,16 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
} catch { } catch {
return; return;
} }
// Optimistic UI: mark as liked immediately
if (onLikeSuccess) onLikeSuccess();
try {
await feedbackService.submitFeedback(slug!, String(photo.id), { await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like', feedback_type: 'like',
}); });
} catch (err) {
// eslint-disable-next-line no-console
console.warn('Like submit failed, keeping optimistic UI', err);
}
return; return;
} }
if (feedbackOptions?.requireNameEmail && !savedIdentity) { if (feedbackOptions?.requireNameEmail && !savedIdentity) {
@@ -169,16 +185,26 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
setShowIdentityModal(true); setShowIdentityModal(true);
return; return;
} }
// Optimistic UI: mark as liked immediately
if (onLikeSuccess) onLikeSuccess();
try {
await feedbackService.submitFeedback(slug!, String(photo.id), { await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like', feedback_type: 'like',
guest_name: savedIdentity?.name, guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email, guest_email: savedIdentity?.email,
}); });
} catch (err) {
// eslint-disable-next-line no-console
console.warn('Like submit failed, keeping optimistic UI', err);
}
}} }}
aria-label="Like photo" aria-label={liked ? 'Unlike photo' : 'Like photo'}
title="Like" aria-pressed={liked}
title={liked ? 'Unlike' : 'Like'}
> >
<Heart className="w-5 h-5 text-neutral-800" /> <Heart
className={`w-5 h-5 ${liked ? 'text-white fill-white' : 'text-neutral-800'}`}
/>
</button> </button>
)} )}
</> </>
@@ -193,6 +219,9 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
setSavedIdentity({ name, email }); setSavedIdentity({ name, email });
setShowIdentityModal(false); setShowIdentityModal(false);
if (pendingAction) { if (pendingAction) {
if (pendingAction.type === 'like' && onLikeSuccess) {
onLikeSuccess();
}
await feedbackService.submitFeedback(slug!, String(pendingAction.photoId), { await feedbackService.submitFeedback(slug!, String(pendingAction.photoId), {
feedback_type: pendingAction.type, feedback_type: pendingAction.type,
guest_name: name, guest_name: name,
@@ -249,6 +278,9 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [columns, setColumns] = useState(3); const [columns, setColumns] = useState(3);
const [containerWidth, setContainerWidth] = useState(0); const [containerWidth, setContainerWidth] = useState(0);
// Optimistic "I liked this" state — lifted here so it survives re-renders
// of individual MasonryPhoto components during layout reflow/resize.
const [likedPhotoIds, setLikedPhotoIds] = useState<Set<number>>(new Set());
const gallerySettings = theme.gallerySettings || {}; const gallerySettings = theme.gallerySettings || {};
const gutter = gallerySettings.masonryGutter || 16; const gutter = gallerySettings.masonryGutter || 16;
const mode = gallerySettings.masonryMode || 'columns'; const mode = gallerySettings.masonryMode || 'columns';
@@ -798,6 +830,14 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
feedbackOptions={feedbackOptions} feedbackOptions={feedbackOptions}
onQuickComment={() => onOpenPhotoWithFeedback && onOpenPhotoWithFeedback(originalIndex)} onQuickComment={() => onOpenPhotoWithFeedback && onOpenPhotoWithFeedback(originalIndex)}
columnWidth={columnWidth} columnWidth={columnWidth}
liked={likedPhotoIds.has(photo.id)}
onLikeSuccess={() => {
setLikedPhotoIds((prev) => {
const next = new Set(prev);
next.add(photo.id);
return next;
});
}}
/> />
); );
})} })}