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:
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseFeedbackLimitError } from '../useFeedbackLimitModal';
|
||||
|
||||
/**
|
||||
* Tests for the error parser that decides when to surface the per-guest
|
||||
* limit modal (#655). Hook integration is exercised via the gallery UI;
|
||||
* here we pin the contract that drives `handleError`.
|
||||
*/
|
||||
|
||||
describe('parseFeedbackLimitError', () => {
|
||||
it('returns null for non-axios errors', () => {
|
||||
expect(parseFeedbackLimitError(new Error('boom'))).toBeNull();
|
||||
expect(parseFeedbackLimitError(null)).toBeNull();
|
||||
expect(parseFeedbackLimitError(undefined)).toBeNull();
|
||||
expect(parseFeedbackLimitError({})).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-403 axios errors', () => {
|
||||
const err = { response: { status: 500, data: { code: 'FAVORITE_LIMIT_REACHED' } } };
|
||||
expect(parseFeedbackLimitError(err)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for 403s without the expected code', () => {
|
||||
const err = { response: { status: 403, data: { error: 'Forbidden' } } };
|
||||
expect(parseFeedbackLimitError(err)).toBeNull();
|
||||
const err2 = { response: { status: 403, data: { code: 'OTHER_FORBIDDEN' } } };
|
||||
expect(parseFeedbackLimitError(err2)).toBeNull();
|
||||
});
|
||||
|
||||
it('parses FAVORITE_LIMIT_REACHED', () => {
|
||||
const err = {
|
||||
response: {
|
||||
status: 403,
|
||||
data: {
|
||||
code: 'FAVORITE_LIMIT_REACHED',
|
||||
limit: 10,
|
||||
current_count: 10,
|
||||
feedback_type: 'favorite',
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(parseFeedbackLimitError(err)).toEqual({
|
||||
feedbackType: 'favorite',
|
||||
limit: 10,
|
||||
currentCount: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses LIKE_LIMIT_REACHED', () => {
|
||||
const err = {
|
||||
response: {
|
||||
status: 403,
|
||||
data: {
|
||||
code: 'LIKE_LIMIT_REACHED',
|
||||
limit: 5,
|
||||
current_count: 5,
|
||||
feedback_type: 'like',
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(parseFeedbackLimitError(err)).toEqual({
|
||||
feedbackType: 'like',
|
||||
limit: 5,
|
||||
currentCount: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the code-implied type when feedback_type is missing', () => {
|
||||
const err = {
|
||||
response: {
|
||||
status: 403,
|
||||
data: { code: 'FAVORITE_LIMIT_REACHED', limit: 3, current_count: 3 },
|
||||
},
|
||||
};
|
||||
expect(parseFeedbackLimitError(err)?.feedbackType).toBe('favorite');
|
||||
});
|
||||
|
||||
it('treats missing numeric fields as 0 rather than NaN', () => {
|
||||
const err = { response: { status: 403, data: { code: 'FAVORITE_LIMIT_REACHED' } } };
|
||||
expect(parseFeedbackLimitError(err)).toEqual({
|
||||
feedbackType: 'favorite',
|
||||
limit: 0,
|
||||
currentCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import {
|
||||
FeedbackLimitReachedModal,
|
||||
} from '../components/gallery/FeedbackLimitReachedModal';
|
||||
|
||||
/**
|
||||
* Per-guest favorite/like cap (#655). Components that submit feedback wrap
|
||||
* their mutation's onError with `handleError(err)` from this hook; when the
|
||||
* backend returns the structured 403 (`code: 'FAVORITE_LIMIT_REACHED'` /
|
||||
* `'LIKE_LIMIT_REACHED'`), the shared modal renders with the current count
|
||||
* and the configured limit.
|
||||
*
|
||||
* Usage:
|
||||
* const { modal, handleError } = useFeedbackLimitModal();
|
||||
* useMutation({ onError: (err) => { if (!handleError(err)) toast.error(...); } });
|
||||
* return (<> {modal} <YourButton /> </>);
|
||||
*
|
||||
* handleError returns true when the error was a limit-reached 403 (so the
|
||||
* caller can skip its generic error toast). Returns false for any other
|
||||
* error shape so existing toast/error paths still fire.
|
||||
*/
|
||||
|
||||
interface LimitState {
|
||||
open: boolean;
|
||||
feedbackType: 'favorite' | 'like';
|
||||
limit: number;
|
||||
currentCount: number;
|
||||
}
|
||||
|
||||
function parseFeedbackLimitError(error: unknown): { feedbackType: 'favorite' | 'like'; limit: number; currentCount: number } | null {
|
||||
const axiosErr = error as {
|
||||
response?: {
|
||||
status?: number;
|
||||
data?: {
|
||||
code?: string;
|
||||
limit?: number;
|
||||
current_count?: number;
|
||||
feedback_type?: 'favorite' | 'like';
|
||||
};
|
||||
};
|
||||
};
|
||||
const data = axiosErr?.response?.data;
|
||||
if (axiosErr?.response?.status !== 403 || !data) return null;
|
||||
if (data.code !== 'FAVORITE_LIMIT_REACHED' && data.code !== 'LIKE_LIMIT_REACHED') {
|
||||
return null;
|
||||
}
|
||||
const feedbackType: 'favorite' | 'like' = data.code === 'FAVORITE_LIMIT_REACHED' ? 'favorite' : 'like';
|
||||
return {
|
||||
feedbackType: data.feedback_type === 'like' || data.feedback_type === 'favorite'
|
||||
? data.feedback_type
|
||||
: feedbackType,
|
||||
limit: typeof data.limit === 'number' ? data.limit : 0,
|
||||
currentCount: typeof data.current_count === 'number' ? data.current_count : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function useFeedbackLimitModal() {
|
||||
const [state, setState] = useState<LimitState | null>(null);
|
||||
|
||||
const handleError = useCallback((error: unknown): boolean => {
|
||||
const parsed = parseFeedbackLimitError(error);
|
||||
if (!parsed) return false;
|
||||
setState({ open: true, ...parsed });
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setState((prev) => (prev ? { ...prev, open: false } : prev));
|
||||
}, []);
|
||||
|
||||
const modal = state ? (
|
||||
<FeedbackLimitReachedModal
|
||||
open={state.open}
|
||||
feedbackType={state.feedbackType}
|
||||
limit={state.limit}
|
||||
currentCount={state.currentCount}
|
||||
onClose={close}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return { handleError, modal };
|
||||
}
|
||||
|
||||
// Exported for unit tests; the hook above is the consumer-facing API.
|
||||
export { parseFeedbackLimitError };
|
||||
Reference in New Issue
Block a user