feat(feedback): per-guest favorite + like caps with mobile-friendly limit modal (#655)
Reporter @Duecki1 wants to stop telling guests "pick only 5 photos" by
hand. Per-event cap, enforced server-side, with a clear popup when the
11th click would exceed the limit. Per-guest scope matches the "every
couple picks their top 10" mental model; per-gallery aggregate is
explicitly NOT in scope (creates weird "first 10 visitors use up all
slots" race conditions).
## Schema (migration 141)
Two nullable columns on `event_feedback_settings`:
- `max_favorites_per_guest`
- `max_likes_per_guest`
null / 0 = unlimited (preserves current behaviour for every existing
install — operator must opt in). Both shipped together because the
code path is identical; photographers can cap either, both, or neither.
## Backend
- `feedbackService.submitFeedback` cap check on the INSERT branch only.
Toggle-off (un-favoriting) is always allowed, so a guest at 10/10
can free a slot by un-clicking an existing favorite.
- New `countGuestFeedback(eventId, type, guestId, guestIdentifier)` —
matches the exact same guest-key shape the existing duplicate-check
uses (guest_id when present, fallback to guest_identifier in simple
identity mode).
- Limit reduction grandfathers: admin lowering 20→10 keeps existing
rows in place; new adds blocked until the guest removes some.
- Route layer (`galleryFeedback.js` POST) translates a `limit_reached`
service-return into a structured 403 with `code:
'FAVORITE_LIMIT_REACHED'` / `'LIKE_LIMIT_REACHED'`, `limit`, and
`current_count`. Stable UI contract.
- `feedback-settings` GET exposes the caps so the gallery UI can
optionally render a counter near the heart icon (UI extension TBD;
the modal alone is the contract this PR commits to).
- `feedbackValidation`: range guard `0..10000`, null allowed,
per-field error messages.
## Frontend — the popup
New `FeedbackLimitReachedModal` component renders via a `createPortal`
to `document.body` so it escapes any lightbox / sticky parent stacking
context and reliably sits above everything else.
Mobile-first responsive:
- `items-end sm:items-center` — slides up from the bottom on phones
(native action-sheet feel), centers on desktop (familiar modal).
- `w-full sm:max-w-md` — full-width on phones, clamps to 420px on
desktop.
- `rounded-2xl sm:rounded-xl` — more rounded on phones for the
sheet feel.
- `pb-[env(safe-area-inset-bottom)]` — respects the iOS home indicator
and Android gesture bar.
- `z-[60]` — above the lightbox's z-50.
Title + body + "8 of 10 used" pill + "Got it" button. Backdrop click +
Escape both dismiss. Focus management lands on the OK button so
keyboard / screen-reader users can dismiss immediately.
New `useFeedbackLimitModal()` hook is the shared API: components
on every submit-feedback site wire `onError: (err) => handleError(err)`
and render `{limitModal}` in their JSX. Returns `true` from
`handleError` when the error is a structured cap-reached 403 (so the
caller can skip its generic error toast). PhotoFavorites + PhotoLikes
+ PhotoLightbox all wire through the hook — every favorite/like submit
path is covered, including the lightbox's three different submit
sites (guest mode, simple mode, post-identity-modal-confirm).
## Admin UI
`FeedbackSettings` card gets a new "Per-guest limits" section that
only renders when at least one of `allow_favorites` / `allow_likes` is
on. Two numeric inputs (0 / empty = unlimited) side-by-side on
desktop, stacked on mobile. Hint text covers the limit-reduction
grandfathering semantics so admins aren't surprised.
## i18n
EN + DE for:
- Modal title + body (parameterized with `{{limit}}`)
- Counter pill (parameterized with `{{current}}` / `{{limit}}`)
- OK button label
- Admin field labels + hints + section header + grandfathering note
## Tests
**Backend** (`__tests__/utils/feedbackPerGuestLimit.test.js`, 8 cases):
- null cap → unlimited (back-compat)
- 0 cap → unlimited (UI convenience)
- cap=10: rows 1-10 succeed, 11 returns limit_reached
- toggle-off frees a slot at the cap
- limit reduction grandfathers existing rows
- per-guest scope: guest A's cap doesn't affect guest B
- favorite cap doesn't block likes (per-type)
- like cap returns LIKE_LIMIT_REACHED-shaped payload
**Frontend** (`__tests__/useFeedbackLimitModal.test.ts`, 7 cases):
- Non-axios errors → null
- Non-403 axios errors → null
- 403 with wrong code → null
- FAVORITE_LIMIT_REACHED parsed
- LIKE_LIMIT_REACHED parsed
- Falls back to code-implied type when feedback_type missing
- Missing numeric fields → 0 (not NaN)
All 15 pass. tsc --noEmit clean. eslint clean on changed files.
Closes #655.
This commit is contained in:
@@ -22,6 +22,9 @@ interface FeedbackSettings {
|
||||
rate_limit_window_minutes?: number;
|
||||
rate_limit_max_requests?: number;
|
||||
identity_mode?: 'simple' | 'guest';
|
||||
// Per-guest caps (#655). null/0 = unlimited.
|
||||
max_favorites_per_guest?: number | null;
|
||||
max_likes_per_guest?: number | null;
|
||||
}
|
||||
|
||||
export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
@@ -219,6 +222,69 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Per-guest caps (#655). Two numeric inputs; 0 / empty = unlimited.
|
||||
Only renders when the matching toggle is on — the cap is
|
||||
meaningless if the type itself is disabled. */}
|
||||
{(settings.allow_favorites || settings.allow_likes) && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
{t('feedback.settings.perGuestLimits', 'Per-guest limits')}
|
||||
</h3>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t(
|
||||
'feedback.settings.perGuestLimitsDesc',
|
||||
'Cap how many photos each guest can favorite or like — useful for "pick your top N for the album" workflows. Leave at 0 for no limit. Lowering a cap below an existing guest\'s count keeps their existing rows; only new adds are blocked until they remove some.',
|
||||
)}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{settings.allow_favorites && (
|
||||
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg">
|
||||
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1">
|
||||
{t('feedback.settings.maxFavoritesPerGuest', 'Max favorites per guest')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10000}
|
||||
step={1}
|
||||
value={settings.max_favorites_per_guest ?? 0}
|
||||
onChange={(e) => onChange({
|
||||
...settings,
|
||||
max_favorites_per_guest: Math.max(0, parseInt(e.target.value, 10) || 0),
|
||||
})}
|
||||
className="w-32 px-2 py-1 text-sm border border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('feedback.settings.maxFavoritesPerGuestHint', '0 = unlimited')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{settings.allow_likes && (
|
||||
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg">
|
||||
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1">
|
||||
{t('feedback.settings.maxLikesPerGuest', 'Max likes per guest')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10000}
|
||||
step={1}
|
||||
value={settings.max_likes_per_guest ?? 0}
|
||||
onChange={(e) => onChange({
|
||||
...settings,
|
||||
max_likes_per_guest: Math.max(0, parseInt(e.target.value, 10) || 0),
|
||||
})}
|
||||
className="w-32 px-2 py-1 text-sm border border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('feedback.settings.maxLikesPerGuestHint', '0 = unlimited')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-4" />
|
||||
|
||||
{/* Privacy & Moderation */}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Heart, Bookmark, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Per-guest cap modal (#655). Shown when the guest clicks the heart or
|
||||
* thumbs-up on a photo that would exceed the per-event cap set by the
|
||||
* photographer. Mobile-first responsive: full-width card with safe inset
|
||||
* on phones, 420px centered card on desktop. High z-index because the
|
||||
* lightbox sits at z-50; we render above at z-[60].
|
||||
*
|
||||
* Single OK button rather than confirm/cancel — this is an
|
||||
* acknowledgement, not a decision. Backdrop click + Escape both dismiss
|
||||
* for users who want to recover quickly.
|
||||
*/
|
||||
export interface FeedbackLimitReachedModalProps {
|
||||
open: boolean;
|
||||
feedbackType: 'favorite' | 'like';
|
||||
limit: number;
|
||||
currentCount: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const FeedbackLimitReachedModal: React.FC<FeedbackLimitReachedModalProps> = ({
|
||||
open,
|
||||
feedbackType,
|
||||
limit,
|
||||
currentCount,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const okButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
// Focus the OK button so keyboard / screen-reader users can dismiss
|
||||
// straight away with Enter or Space.
|
||||
okButtonRef.current?.focus();
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const isFavorite = feedbackType === 'favorite';
|
||||
const Icon = isFavorite ? Bookmark : Heart;
|
||||
const title = isFavorite
|
||||
? t('feedback.limit.favoriteTitle', 'Favorite limit reached')
|
||||
: t('feedback.limit.likeTitle', 'Like limit reached');
|
||||
const body = isFavorite
|
||||
? t(
|
||||
'feedback.limit.favoriteBody',
|
||||
'You can favorite up to {{limit}} photos in this gallery. Remove one to add a new one.',
|
||||
{ limit },
|
||||
)
|
||||
: t(
|
||||
'feedback.limit.likeBody',
|
||||
'You can like up to {{limit}} photos in this gallery. Remove one to add a new one.',
|
||||
{ limit },
|
||||
);
|
||||
|
||||
const node = (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="feedback-limit-title"
|
||||
className="fixed inset-0 z-[60] flex items-end sm:items-center justify-center p-3 sm:p-4 bg-black/60"
|
||||
onClick={(e) => {
|
||||
// Backdrop click only — don't dismiss when clicking inside the card.
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="
|
||||
w-full sm:max-w-md
|
||||
bg-white dark:bg-neutral-900
|
||||
rounded-2xl sm:rounded-xl
|
||||
shadow-2xl
|
||||
border border-neutral-200 dark:border-neutral-700
|
||||
overflow-hidden
|
||||
animate-[slide-up_0.2s_ease-out]
|
||||
pb-[env(safe-area-inset-bottom)]
|
||||
"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-3 p-5 sm:p-6">
|
||||
<div
|
||||
className={`
|
||||
flex-shrink-0 w-11 h-11 sm:w-12 sm:h-12 rounded-full
|
||||
flex items-center justify-center
|
||||
${isFavorite ? 'bg-amber-100 dark:bg-amber-900/40' : 'bg-rose-100 dark:bg-rose-900/40'}
|
||||
`}
|
||||
>
|
||||
<Icon
|
||||
className={`w-6 h-6 ${isFavorite ? 'text-amber-600 dark:text-amber-300' : 'text-rose-600 dark:text-rose-300'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2
|
||||
id="feedback-limit-title"
|
||||
className="text-base sm:text-lg font-semibold text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-300 leading-relaxed">
|
||||
{body}
|
||||
</p>
|
||||
<p className="mt-3 inline-flex items-center gap-1.5 text-xs font-medium text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-800 rounded-full px-3 py-1">
|
||||
{t('feedback.limit.counter', '{{current}} of {{limit}} used', {
|
||||
current: currentCount,
|
||||
limit,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-shrink-0 p-1 text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 rounded transition-colors"
|
||||
aria-label={t('common.close', 'Close')}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 sm:px-6 pb-5 sm:pb-6 flex justify-end">
|
||||
<button
|
||||
ref={okButtonRef}
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="
|
||||
w-full sm:w-auto px-5 py-2.5 rounded-lg text-sm font-medium
|
||||
bg-accent-dark text-white hover:opacity-90
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-accent-dark focus-visible:ring-offset-2
|
||||
transition-opacity
|
||||
"
|
||||
>
|
||||
{t('feedback.limit.ok', 'Got it')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Portal to body so the modal escapes any lightbox / sticky parent stacking
|
||||
// context and reliably sits above everything else.
|
||||
return createPortal(node, document.body);
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { feedbackService } from '../../services/feedback.service';
|
||||
import { toast } from 'react-toastify';
|
||||
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
|
||||
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
||||
import { useFeedbackLimitModal } from '../../hooks/useFeedbackLimitModal';
|
||||
|
||||
interface PhotoFavoritesProps {
|
||||
photoId: string;
|
||||
@@ -29,6 +30,7 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const { modal: limitModal, handleError: handleLimitError } = useFeedbackLimitModal();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [animating, setAnimating] = useState(false);
|
||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||
@@ -58,6 +60,8 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
|
||||
if (onFavoriteChange) {
|
||||
onFavoriteChange(isFavorited);
|
||||
}
|
||||
// Per-guest cap reached (#655) — surface the modal instead of a toast.
|
||||
if (handleLimitError(error)) return;
|
||||
if (error.response?.status === 429) {
|
||||
toast.error(t('feedback.rateLimited', 'Please wait before favoriting again'));
|
||||
} else {
|
||||
@@ -130,6 +134,7 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
|
||||
onSubmit={handleIdentitySubmit}
|
||||
feedbackType={t('feedback.favorite', 'favorite')}
|
||||
/>
|
||||
{limitModal}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import { feedbackService } from '../../services/feedback.service';
|
||||
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
|
||||
import { VideoPlayer } from './VideoPlayer';
|
||||
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
||||
import { useFeedbackLimitModal } from '../../hooks/useFeedbackLimitModal';
|
||||
|
||||
interface PhotoLightboxProps {
|
||||
photos: Photo[];
|
||||
@@ -91,6 +92,9 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'rating'; rating?: number }>(null);
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const isGuestMode = guestIdentity?.identityMode === 'guest';
|
||||
// Per-guest cap modal (#655) — shared across every submitFeedback call site
|
||||
// in the lightbox (guest mode, simple mode, identity-modal-confirm path).
|
||||
const { modal: limitModal, handleError: handleLimitError } = useFeedbackLimitModal();
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => setIsSmallScreen(window.innerWidth < 640);
|
||||
@@ -257,6 +261,8 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
});
|
||||
if (onFeedbackChange) onFeedbackChange();
|
||||
} catch (err) {
|
||||
// Per-guest cap reached (#655) surfaces the shared modal.
|
||||
if (handleLimitError(err)) return;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Like submit failed', err);
|
||||
}
|
||||
@@ -270,16 +276,22 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
setShowIdentityModal(true);
|
||||
return;
|
||||
}
|
||||
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
|
||||
feedback_type: 'like',
|
||||
guest_name: savedIdentity?.name,
|
||||
guest_email: savedIdentity?.email,
|
||||
});
|
||||
setMyLiked(prev => {
|
||||
const next = !prev;
|
||||
setLikeCount(c => Math.max(0, c + (next ? 1 : -1)));
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
|
||||
feedback_type: 'like',
|
||||
guest_name: savedIdentity?.name,
|
||||
guest_email: savedIdentity?.email,
|
||||
});
|
||||
setMyLiked(prev => {
|
||||
const next = !prev;
|
||||
setLikeCount(c => Math.max(0, c + (next ? 1 : -1)));
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
if (handleLimitError(err)) return;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Like submit failed', err);
|
||||
}
|
||||
};
|
||||
|
||||
const submitRating = async (value: number) => {
|
||||
@@ -978,12 +990,17 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
setSavedIdentity({ name, email });
|
||||
setShowIdentityModal(false);
|
||||
if (pendingAction?.type === 'like') {
|
||||
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
|
||||
feedback_type: 'like',
|
||||
guest_name: name,
|
||||
guest_email: email,
|
||||
});
|
||||
setMyLiked(true);
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
|
||||
feedback_type: 'like',
|
||||
guest_name: name,
|
||||
guest_email: email,
|
||||
});
|
||||
setMyLiked(true);
|
||||
} catch (err) {
|
||||
// Per-guest cap reached (#655) on the post-identity-modal submit.
|
||||
if (!handleLimitError(err)) throw err;
|
||||
}
|
||||
} else if (pendingAction?.type === 'rating' && pendingAction.rating) {
|
||||
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
|
||||
feedback_type: 'rating',
|
||||
@@ -997,6 +1014,9 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
}}
|
||||
feedbackType={pendingAction?.type === 'rating' ? 'rating' : 'like'}
|
||||
/>
|
||||
{/* Per-guest cap modal (#655). Single instance fires for any of the
|
||||
lightbox's submitFeedback paths via the shared hook. */}
|
||||
{limitModal}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { feedbackService } from '../../services/feedback.service';
|
||||
import { toast } from 'react-toastify';
|
||||
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
|
||||
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
||||
import { useFeedbackLimitModal } from '../../hooks/useFeedbackLimitModal';
|
||||
|
||||
interface PhotoLikesProps {
|
||||
photoId: string;
|
||||
@@ -29,6 +30,7 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const { modal: limitModal, handleError: handleLimitError } = useFeedbackLimitModal();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [animating, setAnimating] = useState(false);
|
||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||
@@ -58,6 +60,8 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
|
||||
if (onLikeChange) {
|
||||
onLikeChange(isLiked);
|
||||
}
|
||||
// Per-guest cap reached (#655) — surface the modal instead of a toast.
|
||||
if (handleLimitError(error)) return;
|
||||
if (error.response?.status === 429) {
|
||||
toast.error(t('feedback.rateLimited', 'Please wait before liking again'));
|
||||
} else {
|
||||
@@ -138,6 +142,7 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
|
||||
onSubmit={handleIdentitySubmit}
|
||||
feedbackType={t('feedback.like', 'like')}
|
||||
/>
|
||||
{limitModal}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user