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:
@@ -72,7 +72,7 @@ export const GuestNamePromptModal: React.FC<GuestNamePromptModalProps> = ({
|
||||
};
|
||||
|
||||
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="relative bg-surface rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
{allowCancel && (
|
||||
|
||||
@@ -86,7 +86,7 @@ export const GuestRecoveryModal: React.FC = () => {
|
||||
};
|
||||
|
||||
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="relative bg-surface rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<button
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PhotoFeedback } from './PhotoFeedback';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
|
||||
import { VideoPlayer } from './VideoPlayer';
|
||||
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
||||
|
||||
interface PhotoLightboxProps {
|
||||
photos: Photo[];
|
||||
@@ -63,6 +64,8 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'rating'; rating?: number }>(null);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const isGuestMode = guestIdentity?.identityMode === 'guest';
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => setIsSmallScreen(window.innerWidth < 640);
|
||||
@@ -203,6 +206,33 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
}, [slug, currentPhoto.id, feedbackSettings?.feedback_enabled]);
|
||||
|
||||
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;
|
||||
if (needIdentity) {
|
||||
setPendingAction({ type: 'like' });
|
||||
@@ -222,6 +252,33 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
};
|
||||
|
||||
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;
|
||||
if (needIdentity) {
|
||||
setPendingAction({ type: 'rating', rating: value });
|
||||
|
||||
@@ -34,6 +34,9 @@ interface MasonryPhotoProps {
|
||||
onQuickComment?: () => void;
|
||||
// Column width for calculating proper aspect-ratio-based height
|
||||
columnWidth?: number;
|
||||
// Optimistic "I liked this" state + callback (lifted to parent)
|
||||
liked?: boolean;
|
||||
onLikeSuccess?: () => void;
|
||||
}
|
||||
|
||||
const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
||||
@@ -49,7 +52,9 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
||||
slug,
|
||||
feedbackOptions,
|
||||
onQuickComment,
|
||||
columnWidth = 300
|
||||
columnWidth = 300,
|
||||
liked = false,
|
||||
onLikeSuccess,
|
||||
}) => {
|
||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
|
||||
@@ -150,7 +155,11 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
||||
)}
|
||||
{feedbackEnabled && feedbackOptions?.allowLikes && (
|
||||
<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) => {
|
||||
e.stopPropagation();
|
||||
if (guestIdentity?.identityMode === 'guest') {
|
||||
@@ -159,9 +168,16 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||
feedback_type: 'like',
|
||||
});
|
||||
// Optimistic UI: mark as liked immediately
|
||||
if (onLikeSuccess) onLikeSuccess();
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||
feedback_type: 'like',
|
||||
});
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Like submit failed, keeping optimistic UI', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
||||
@@ -169,16 +185,26 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
||||
setShowIdentityModal(true);
|
||||
return;
|
||||
}
|
||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||
feedback_type: 'like',
|
||||
guest_name: savedIdentity?.name,
|
||||
guest_email: savedIdentity?.email,
|
||||
});
|
||||
// Optimistic UI: mark as liked immediately
|
||||
if (onLikeSuccess) onLikeSuccess();
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||
feedback_type: 'like',
|
||||
guest_name: savedIdentity?.name,
|
||||
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"
|
||||
title="Like"
|
||||
aria-label={liked ? 'Unlike photo' : 'Like photo'}
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
@@ -193,6 +219,9 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
||||
setSavedIdentity({ name, email });
|
||||
setShowIdentityModal(false);
|
||||
if (pendingAction) {
|
||||
if (pendingAction.type === 'like' && onLikeSuccess) {
|
||||
onLikeSuccess();
|
||||
}
|
||||
await feedbackService.submitFeedback(slug!, String(pendingAction.photoId), {
|
||||
feedback_type: pendingAction.type,
|
||||
guest_name: name,
|
||||
@@ -249,6 +278,9 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [columns, setColumns] = useState(3);
|
||||
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 gutter = gallerySettings.masonryGutter || 16;
|
||||
const mode = gallerySettings.masonryMode || 'columns';
|
||||
@@ -798,6 +830,14 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
feedbackOptions={feedbackOptions}
|
||||
onQuickComment={() => onOpenPhotoWithFeedback && onOpenPhotoWithFeedback(originalIndex)}
|
||||
columnWidth={columnWidth}
|
||||
liked={likedPhotoIds.has(photo.id)}
|
||||
onLikeSuccess={() => {
|
||||
setLikedPhotoIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(photo.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user