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,240 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for the per-guest favorite/like cap (#655).
|
||||||
|
*
|
||||||
|
* Pins the contract of `feedbackService.submitFeedback` around the cap:
|
||||||
|
* - null / 0 cap means unlimited (back-compat for installs that don't
|
||||||
|
* enable the feature).
|
||||||
|
* - At-cap ADD returns `{ limit_reached, limit, current_count }` rather
|
||||||
|
* than inserting — the route layer translates that into the structured
|
||||||
|
* 403 the UI listens for.
|
||||||
|
* - Toggle-off (un-favoriting) is ALWAYS allowed, regardless of cap state.
|
||||||
|
* A guest at 10/10 can still free a slot.
|
||||||
|
* - Limit reduction (admin lowers 20 → 10 while a guest has 15 already)
|
||||||
|
* grandfathers existing rows — new adds blocked, removals always allowed.
|
||||||
|
* - Caps are per-feedback-type: filling the favorite quota doesn't block
|
||||||
|
* likes on the same photo, and vice versa.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
process.env.TEST_DATABASE_PATH = path.join(
|
||||||
|
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-feedback-limit-')), 'db.sqlite',
|
||||||
|
);
|
||||||
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'feedback-limit-test-secret';
|
||||||
|
|
||||||
|
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||||
|
|
||||||
|
const feedbackService = require('../../src/services/feedbackService');
|
||||||
|
|
||||||
|
const EVENT_SLUG = 'cap-test-event';
|
||||||
|
const GUEST_A = 'guest-a-identifier';
|
||||||
|
const GUEST_B = 'guest-b-identifier';
|
||||||
|
|
||||||
|
let db;
|
||||||
|
let cleanup;
|
||||||
|
let eventId;
|
||||||
|
let photoIds;
|
||||||
|
|
||||||
|
async function setEventFeedbackSettings(overrides) {
|
||||||
|
const base = {
|
||||||
|
feedback_enabled: 1,
|
||||||
|
allow_ratings: 1,
|
||||||
|
allow_likes: 1,
|
||||||
|
allow_comments: 0,
|
||||||
|
allow_favorites: 1,
|
||||||
|
require_name_email: 0,
|
||||||
|
moderate_comments: 0,
|
||||||
|
show_feedback_to_guests: 1,
|
||||||
|
identity_mode: 'simple',
|
||||||
|
max_favorites_per_guest: null,
|
||||||
|
max_likes_per_guest: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
const existing = await db('event_feedback_settings').where('event_id', eventId).first();
|
||||||
|
if (existing) {
|
||||||
|
await db('event_feedback_settings').where('event_id', eventId).update(base);
|
||||||
|
} else {
|
||||||
|
await db('event_feedback_settings').insert({
|
||||||
|
event_id: eventId,
|
||||||
|
...base,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function favorite(photoId, guestIdentifier = GUEST_A) {
|
||||||
|
return feedbackService.submitFeedback(photoId, eventId, {
|
||||||
|
feedback_type: 'favorite',
|
||||||
|
ip_address: '127.0.0.1',
|
||||||
|
user_agent: 'jest',
|
||||||
|
}, guestIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function like(photoId, guestIdentifier = GUEST_A) {
|
||||||
|
return feedbackService.submitFeedback(photoId, eventId, {
|
||||||
|
feedback_type: 'like',
|
||||||
|
ip_address: '127.0.0.1',
|
||||||
|
user_agent: 'jest',
|
||||||
|
}, guestIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ db, cleanup } = await bootCrmDb());
|
||||||
|
await seedMinimal(db);
|
||||||
|
const inserted = await db('events').insert({
|
||||||
|
slug: EVENT_SLUG,
|
||||||
|
event_type: 'wedding',
|
||||||
|
event_name: 'Cap Test',
|
||||||
|
event_date: '2026-06-22',
|
||||||
|
host_email: '[email protected]',
|
||||||
|
admin_email: '[email protected]',
|
||||||
|
password_hash: 'x',
|
||||||
|
share_link: `/gallery/${EVENT_SLUG}/share`,
|
||||||
|
share_token: 'cap-test-share',
|
||||||
|
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||||
|
is_active: 1,
|
||||||
|
is_archived: 0,
|
||||||
|
is_draft: 0,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
}).returning('id');
|
||||||
|
eventId = inserted[0]?.id ?? inserted[0];
|
||||||
|
|
||||||
|
// Seed 15 photos so we can test caps comfortably up to that count.
|
||||||
|
photoIds = [];
|
||||||
|
for (let i = 1; i <= 15; i += 1) {
|
||||||
|
const r = await db('photos').insert({
|
||||||
|
event_id: eventId,
|
||||||
|
filename: `photo-${i}.jpg`,
|
||||||
|
path: `events/cap/${i}.jpg`,
|
||||||
|
type: 'individual',
|
||||||
|
uploaded_at: new Date().toISOString(),
|
||||||
|
}).returning('id');
|
||||||
|
photoIds.push(r[0]?.id ?? r[0]);
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await db('photo_feedback').where('event_id', eventId).del();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('per-guest favorite cap (#655)', () => {
|
||||||
|
test('null cap = unlimited (back-compat for installs without #655)', async () => {
|
||||||
|
await setEventFeedbackSettings({ max_favorites_per_guest: null });
|
||||||
|
for (const id of photoIds.slice(0, 12)) {
|
||||||
|
const r = await favorite(id);
|
||||||
|
expect(r.limit_reached).toBeFalsy();
|
||||||
|
expect(r.created).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cap = 0 also = unlimited (UI convenience for "no limit")', async () => {
|
||||||
|
await setEventFeedbackSettings({ max_favorites_per_guest: 0 });
|
||||||
|
for (const id of photoIds.slice(0, 12)) {
|
||||||
|
const r = await favorite(id);
|
||||||
|
expect(r.limit_reached).toBeFalsy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cap = 10: favorites 1..10 succeed, 11 returns limit_reached', async () => {
|
||||||
|
await setEventFeedbackSettings({ max_favorites_per_guest: 10 });
|
||||||
|
for (const id of photoIds.slice(0, 10)) {
|
||||||
|
const r = await favorite(id);
|
||||||
|
expect(r.created).toBe(true);
|
||||||
|
}
|
||||||
|
const r11 = await favorite(photoIds[10]);
|
||||||
|
expect(r11.limit_reached).toBe(true);
|
||||||
|
expect(r11.limit).toBe(10);
|
||||||
|
expect(r11.current_count).toBe(10);
|
||||||
|
expect(r11.feedback_type).toBe('favorite');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggle-off at the cap frees a slot (un-favoriting always allowed)', async () => {
|
||||||
|
await setEventFeedbackSettings({ max_favorites_per_guest: 5 });
|
||||||
|
for (const id of photoIds.slice(0, 5)) {
|
||||||
|
await favorite(id);
|
||||||
|
}
|
||||||
|
const blocked = await favorite(photoIds[5]);
|
||||||
|
expect(blocked.limit_reached).toBe(true);
|
||||||
|
|
||||||
|
// Un-favorite one — toggle off path returns { removed: true }
|
||||||
|
const removed = await favorite(photoIds[0]);
|
||||||
|
expect(removed.removed).toBe(true);
|
||||||
|
|
||||||
|
// Now the previously-blocked slot fits
|
||||||
|
const after = await favorite(photoIds[5]);
|
||||||
|
expect(after.created).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('limit reduction grandfathers existing rows; new adds blocked', async () => {
|
||||||
|
await setEventFeedbackSettings({ max_favorites_per_guest: 10 });
|
||||||
|
for (const id of photoIds.slice(0, 10)) {
|
||||||
|
await favorite(id);
|
||||||
|
}
|
||||||
|
// Admin lowers the cap to 5 while the guest already has 10
|
||||||
|
await setEventFeedbackSettings({ max_favorites_per_guest: 5 });
|
||||||
|
// Existing 10 stay
|
||||||
|
const count = await db('photo_feedback')
|
||||||
|
.where({ event_id: eventId, feedback_type: 'favorite', guest_identifier: GUEST_A })
|
||||||
|
.count('* as c').first();
|
||||||
|
expect(parseInt(count.c, 10)).toBe(10);
|
||||||
|
// New adds blocked
|
||||||
|
const blocked = await favorite(photoIds[10]);
|
||||||
|
expect(blocked.limit_reached).toBe(true);
|
||||||
|
expect(blocked.limit).toBe(5);
|
||||||
|
expect(blocked.current_count).toBe(10);
|
||||||
|
// Removals still allowed
|
||||||
|
const removed = await favorite(photoIds[0]);
|
||||||
|
expect(removed.removed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cap is per-guest: guest B is unaffected by guest A hitting the cap', async () => {
|
||||||
|
await setEventFeedbackSettings({ max_favorites_per_guest: 3 });
|
||||||
|
for (const id of photoIds.slice(0, 3)) {
|
||||||
|
await favorite(id, GUEST_A);
|
||||||
|
}
|
||||||
|
expect((await favorite(photoIds[3], GUEST_A)).limit_reached).toBe(true);
|
||||||
|
|
||||||
|
// Guest B starts at 0
|
||||||
|
for (const id of photoIds.slice(0, 3)) {
|
||||||
|
const r = await favorite(id, GUEST_B);
|
||||||
|
expect(r.created).toBe(true);
|
||||||
|
}
|
||||||
|
expect((await favorite(photoIds[3], GUEST_B)).limit_reached).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('per-guest like cap (#655)', () => {
|
||||||
|
test('favorite cap does NOT block likes on the same photo (per-type)', async () => {
|
||||||
|
await setEventFeedbackSettings({
|
||||||
|
max_favorites_per_guest: 3,
|
||||||
|
max_likes_per_guest: null,
|
||||||
|
});
|
||||||
|
for (const id of photoIds.slice(0, 3)) {
|
||||||
|
await favorite(id);
|
||||||
|
}
|
||||||
|
expect((await favorite(photoIds[3])).limit_reached).toBe(true);
|
||||||
|
|
||||||
|
// Likes still unlimited
|
||||||
|
for (const id of photoIds.slice(0, 10)) {
|
||||||
|
const r = await like(id);
|
||||||
|
expect(r.created).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('like cap returns LIKE_LIMIT_REACHED-shaped payload', async () => {
|
||||||
|
await setEventFeedbackSettings({ max_likes_per_guest: 2 });
|
||||||
|
await like(photoIds[0]);
|
||||||
|
await like(photoIds[1]);
|
||||||
|
const r = await like(photoIds[2]);
|
||||||
|
expect(r.limit_reached).toBe(true);
|
||||||
|
expect(r.feedback_type).toBe('like');
|
||||||
|
expect(r.limit).toBe(2);
|
||||||
|
expect(r.current_count).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Migration 141: Per-guest favorite + like caps (#655).
|
||||||
|
*
|
||||||
|
* Reporter wants to cap how many photos a guest can favorite per event —
|
||||||
|
* the classic photographer-culling workflow ("pick your top 10 for the
|
||||||
|
* album"). Currently the photographer has to enforce this with a verbal
|
||||||
|
* instruction; this column lets the gallery enforce it server-side so
|
||||||
|
* the 11th favorite click returns a clear "limit reached" response.
|
||||||
|
*
|
||||||
|
* Two columns, one per feedback type: favorites + likes. Both nullable +
|
||||||
|
* additive — null/0 = unlimited, preserving current behaviour for every
|
||||||
|
* existing install with no operator action. The route layer enforces in
|
||||||
|
* `feedbackService.submitFeedback` (on the INSERT branch only, so a
|
||||||
|
* guest at the cap can still toggle off an existing favorite and free a
|
||||||
|
* slot). Limit *reduction* (e.g. admin lowers 20 → 10) grandfathers any
|
||||||
|
* over-cap rows already in place — new adds blocked, removals always
|
||||||
|
* allowed — to avoid surprising bulk-deletes on the admin save.
|
||||||
|
*
|
||||||
|
* Hooks into the existing per-event `event_feedback_settings` table
|
||||||
|
* alongside `allow_favorites` / `allow_likes`, so the admin surface is
|
||||||
|
* the same Event → Feedback settings card.
|
||||||
|
*/
|
||||||
|
exports.up = async function (knex) {
|
||||||
|
if (!(await knex.schema.hasTable('event_feedback_settings'))) return;
|
||||||
|
const hasFav = await knex.schema.hasColumn('event_feedback_settings', 'max_favorites_per_guest');
|
||||||
|
const hasLike = await knex.schema.hasColumn('event_feedback_settings', 'max_likes_per_guest');
|
||||||
|
if (hasFav && hasLike) return;
|
||||||
|
await knex.schema.alterTable('event_feedback_settings', (table) => {
|
||||||
|
if (!hasFav) table.integer('max_favorites_per_guest').nullable();
|
||||||
|
if (!hasLike) table.integer('max_likes_per_guest').nullable();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function (knex) {
|
||||||
|
if (!(await knex.schema.hasTable('event_feedback_settings'))) return;
|
||||||
|
const hasFav = await knex.schema.hasColumn('event_feedback_settings', 'max_favorites_per_guest');
|
||||||
|
const hasLike = await knex.schema.hasColumn('event_feedback_settings', 'max_likes_per_guest');
|
||||||
|
if (!hasFav && !hasLike) return;
|
||||||
|
await knex.schema.alterTable('event_feedback_settings', (table) => {
|
||||||
|
if (hasFav) table.dropColumn('max_favorites_per_guest');
|
||||||
|
if (hasLike) table.dropColumn('max_likes_per_guest');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -34,7 +34,12 @@ router.get('/:slug/feedback-settings',
|
|||||||
allow_favorites: Boolean(settings.allow_favorites),
|
allow_favorites: Boolean(settings.allow_favorites),
|
||||||
require_name_email: Boolean(settings.require_name_email),
|
require_name_email: Boolean(settings.require_name_email),
|
||||||
show_feedback_to_guests: Boolean(settings.show_feedback_to_guests),
|
show_feedback_to_guests: Boolean(settings.show_feedback_to_guests),
|
||||||
identity_mode: settings.identity_mode || 'simple'
|
identity_mode: settings.identity_mode || 'simple',
|
||||||
|
// Per-guest caps (#655). The UI uses these to disable the heart /
|
||||||
|
// thumbs-up at the limit and render an "8 / 10" counter. null = no
|
||||||
|
// cap; positive integer = enforced.
|
||||||
|
max_favorites_per_guest: settings.max_favorites_per_guest || null,
|
||||||
|
max_likes_per_guest: settings.max_likes_per_guest || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
res.json(guestSettings);
|
res.json(guestSettings);
|
||||||
@@ -263,6 +268,23 @@ router.post('/:slug/photos/:photoId/feedback',
|
|||||||
guestIdentifier
|
guestIdentifier
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Per-guest cap reached (#655). Surface as a structured 403 so the
|
||||||
|
// frontend can show an explicit popup with the actual cap value and
|
||||||
|
// remaining-slots count, rather than a generic toast. Code is the
|
||||||
|
// stable contract the UI listens for.
|
||||||
|
if (result && result.limit_reached) {
|
||||||
|
const code = result.feedback_type === 'favorite'
|
||||||
|
? 'FAVORITE_LIMIT_REACHED'
|
||||||
|
: 'LIKE_LIMIT_REACHED';
|
||||||
|
return res.status(403).json({
|
||||||
|
error: `${result.feedback_type === 'favorite' ? 'Favorite' : 'Like'} limit reached`,
|
||||||
|
code,
|
||||||
|
limit: result.limit,
|
||||||
|
current_count: result.current_count,
|
||||||
|
feedback_type: result.feedback_type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Log activity
|
// Log activity
|
||||||
await logActivity(`guest_feedback_${feedbackType}`, {
|
await logActivity(`guest_feedback_${feedbackType}`, {
|
||||||
photo_id: photoId,
|
photo_id: photoId,
|
||||||
@@ -365,6 +387,10 @@ router.get('/:slug/my-feedback',
|
|||||||
)
|
)
|
||||||
.orderBy('photo_feedback.created_at', 'desc');
|
.orderBy('photo_feedback.created_at', 'desc');
|
||||||
|
|
||||||
|
// Array-shaped response preserved for back-compat with existing
|
||||||
|
// consumers (`GalleryView` iterates the array directly). The per-event
|
||||||
|
// caps for #655 are exposed via the `/feedback-settings` endpoint;
|
||||||
|
// the running counts can be derived client-side from this array.
|
||||||
res.json(myFeedback);
|
res.json(myFeedback);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error getting user feedback:', error);
|
logger.error('Error getting user feedback:', error);
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ class FeedbackService {
|
|||||||
require_name_email: false,
|
require_name_email: false,
|
||||||
moderate_comments: true,
|
moderate_comments: true,
|
||||||
show_feedback_to_guests: true,
|
show_feedback_to_guests: true,
|
||||||
identity_mode: 'simple'
|
identity_mode: 'simple',
|
||||||
|
max_favorites_per_guest: null,
|
||||||
|
max_likes_per_guest: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +34,10 @@ class FeedbackService {
|
|||||||
if (!settings.identity_mode) {
|
if (!settings.identity_mode) {
|
||||||
settings.identity_mode = 'simple';
|
settings.identity_mode = 'simple';
|
||||||
}
|
}
|
||||||
|
// Per-guest caps (#655). NULL on existing rows = unlimited; the route
|
||||||
|
// layer treats null/0/missing identically.
|
||||||
|
settings.max_favorites_per_guest = settings.max_favorites_per_guest ?? null;
|
||||||
|
settings.max_likes_per_guest = settings.max_likes_per_guest ?? null;
|
||||||
return settings;
|
return settings;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error getting feedback settings:', error);
|
logger.error('Error getting feedback settings:', error);
|
||||||
@@ -76,6 +82,24 @@ class FeedbackService {
|
|||||||
/**
|
/**
|
||||||
* Submit feedback for a photo
|
* Submit feedback for a photo
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Count how many existing feedback rows of `feedback_type` a single guest
|
||||||
|
* has on a given event, matching the same guest-key shape submitFeedback's
|
||||||
|
* duplicate-check uses (guest_id when present, fall back to
|
||||||
|
* guest_identifier). Used for the per-guest favorite/like caps (#655).
|
||||||
|
*/
|
||||||
|
async countGuestFeedback(eventId, feedbackType, guestId, guestIdentifier) {
|
||||||
|
const query = db('photo_feedback')
|
||||||
|
.where({ event_id: eventId, feedback_type: feedbackType });
|
||||||
|
if (guestId) {
|
||||||
|
query.where('guest_id', guestId);
|
||||||
|
} else {
|
||||||
|
query.where('guest_identifier', guestIdentifier);
|
||||||
|
}
|
||||||
|
const result = await query.count('* as count').first();
|
||||||
|
return parseInt(result?.count, 10) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
async submitFeedback(photoId, eventId, feedbackData, guestIdentifier) {
|
async submitFeedback(photoId, eventId, feedbackData, guestIdentifier) {
|
||||||
try {
|
try {
|
||||||
const { feedback_type, rating, comment_text, guest_name, guest_email, ip_address, user_agent, guest_id } = feedbackData;
|
const { feedback_type, rating, comment_text, guest_name, guest_email, ip_address, user_agent, guest_id } = feedbackData;
|
||||||
@@ -116,7 +140,9 @@ class FeedbackService {
|
|||||||
return { id: existing.id, updated: true };
|
return { id: existing.id, updated: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// For likes and favorites, toggle off if already exists
|
// For likes and favorites, toggle off if already exists.
|
||||||
|
// Toggle-off always allowed — the cap below is on adds only, so a
|
||||||
|
// guest at the limit can still free a slot by un-favoriting (#655).
|
||||||
if (feedback_type === 'like' || feedback_type === 'favorite') {
|
if (feedback_type === 'like' || feedback_type === 'favorite') {
|
||||||
await db('photo_feedback')
|
await db('photo_feedback')
|
||||||
.where('id', existing.id)
|
.where('id', existing.id)
|
||||||
@@ -130,6 +156,32 @@ class FeedbackService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-guest cap enforcement (#655). Only checked on ADD; toggle-off is
|
||||||
|
// always allowed. NULL or 0 stored in the column means "unlimited" —
|
||||||
|
// the photographer hasn't opted in to a cap for this event.
|
||||||
|
if (feedback_type === 'favorite' || feedback_type === 'like') {
|
||||||
|
const settings = await this.getEventFeedbackSettings(eventId);
|
||||||
|
const cap = feedback_type === 'favorite'
|
||||||
|
? settings.max_favorites_per_guest
|
||||||
|
: settings.max_likes_per_guest;
|
||||||
|
if (cap && cap > 0) {
|
||||||
|
const currentCount = await this.countGuestFeedback(
|
||||||
|
eventId, feedback_type, guest_id, guestIdentifier,
|
||||||
|
);
|
||||||
|
if (currentCount >= cap) {
|
||||||
|
// Don't insert; surface a structured payload so the route layer
|
||||||
|
// can return a 403 with `code` + `limit` + `current_count` and
|
||||||
|
// the UI can render an explicit popup with the cap value.
|
||||||
|
return {
|
||||||
|
limit_reached: true,
|
||||||
|
feedback_type,
|
||||||
|
limit: cap,
|
||||||
|
current_count: currentCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Insert new feedback
|
// Insert new feedback
|
||||||
const result = await db('photo_feedback').insert({
|
const result = await db('photo_feedback').insert({
|
||||||
photo_id: photoId,
|
photo_id: photoId,
|
||||||
|
|||||||
@@ -187,7 +187,18 @@ const validateFeedbackSettings = [
|
|||||||
body('moderate_comments').optional().isBoolean(),
|
body('moderate_comments').optional().isBoolean(),
|
||||||
body('show_feedback_to_guests').optional().isBoolean(),
|
body('show_feedback_to_guests').optional().isBoolean(),
|
||||||
body('identity_mode').optional().isIn(['simple', 'guest'])
|
body('identity_mode').optional().isIn(['simple', 'guest'])
|
||||||
.withMessage('identity_mode must be "simple" or "guest"')
|
.withMessage('identity_mode must be "simple" or "guest"'),
|
||||||
|
// Per-guest caps (#655). null / 0 = unlimited; positive integers enforced.
|
||||||
|
// Upper bound is intentionally generous — operators occasionally run
|
||||||
|
// "everyone, pick everything you like" galleries.
|
||||||
|
body('max_favorites_per_guest')
|
||||||
|
.optional({ nullable: true })
|
||||||
|
.custom((v) => v === null || (Number.isInteger(v) && v >= 0 && v <= 10000))
|
||||||
|
.withMessage('max_favorites_per_guest must be null or an integer between 0 and 10000'),
|
||||||
|
body('max_likes_per_guest')
|
||||||
|
.optional({ nullable: true })
|
||||||
|
.custom((v) => v === null || (Number.isInteger(v) && v >= 0 && v <= 10000))
|
||||||
|
.withMessage('max_likes_per_guest must be null or an integer between 0 and 10000'),
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ interface FeedbackSettings {
|
|||||||
rate_limit_window_minutes?: number;
|
rate_limit_window_minutes?: number;
|
||||||
rate_limit_max_requests?: number;
|
rate_limit_max_requests?: number;
|
||||||
identity_mode?: 'simple' | 'guest';
|
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> = ({
|
export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||||
@@ -219,6 +222,69 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</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" />
|
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-4" />
|
||||||
|
|
||||||
{/* Privacy & Moderation */}
|
{/* 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 { toast } from 'react-toastify';
|
||||||
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
|
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
|
||||||
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
||||||
|
import { useFeedbackLimitModal } from '../../hooks/useFeedbackLimitModal';
|
||||||
|
|
||||||
interface PhotoFavoritesProps {
|
interface PhotoFavoritesProps {
|
||||||
photoId: string;
|
photoId: string;
|
||||||
@@ -29,6 +30,7 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const guestIdentity = useGuestIdentityOptional();
|
const guestIdentity = useGuestIdentityOptional();
|
||||||
|
const { modal: limitModal, handleError: handleLimitError } = useFeedbackLimitModal();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [animating, setAnimating] = useState(false);
|
const [animating, setAnimating] = useState(false);
|
||||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||||
@@ -58,6 +60,8 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
|
|||||||
if (onFavoriteChange) {
|
if (onFavoriteChange) {
|
||||||
onFavoriteChange(isFavorited);
|
onFavoriteChange(isFavorited);
|
||||||
}
|
}
|
||||||
|
// Per-guest cap reached (#655) — surface the modal instead of a toast.
|
||||||
|
if (handleLimitError(error)) return;
|
||||||
if (error.response?.status === 429) {
|
if (error.response?.status === 429) {
|
||||||
toast.error(t('feedback.rateLimited', 'Please wait before favoriting again'));
|
toast.error(t('feedback.rateLimited', 'Please wait before favoriting again'));
|
||||||
} else {
|
} else {
|
||||||
@@ -130,6 +134,7 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
|
|||||||
onSubmit={handleIdentitySubmit}
|
onSubmit={handleIdentitySubmit}
|
||||||
feedbackType={t('feedback.favorite', 'favorite')}
|
feedbackType={t('feedback.favorite', 'favorite')}
|
||||||
/>
|
/>
|
||||||
|
{limitModal}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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';
|
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
||||||
|
import { useFeedbackLimitModal } from '../../hooks/useFeedbackLimitModal';
|
||||||
|
|
||||||
interface PhotoLightboxProps {
|
interface PhotoLightboxProps {
|
||||||
photos: Photo[];
|
photos: Photo[];
|
||||||
@@ -91,6 +92,9 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'rating'; rating?: number }>(null);
|
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'rating'; rating?: number }>(null);
|
||||||
const guestIdentity = useGuestIdentityOptional();
|
const guestIdentity = useGuestIdentityOptional();
|
||||||
const isGuestMode = guestIdentity?.identityMode === 'guest';
|
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(() => {
|
useEffect(() => {
|
||||||
const onResize = () => setIsSmallScreen(window.innerWidth < 640);
|
const onResize = () => setIsSmallScreen(window.innerWidth < 640);
|
||||||
@@ -257,6 +261,8 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
});
|
});
|
||||||
if (onFeedbackChange) onFeedbackChange();
|
if (onFeedbackChange) onFeedbackChange();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Per-guest cap reached (#655) surfaces the shared modal.
|
||||||
|
if (handleLimitError(err)) return;
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.warn('Like submit failed', err);
|
console.warn('Like submit failed', err);
|
||||||
}
|
}
|
||||||
@@ -270,6 +276,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
setShowIdentityModal(true);
|
setShowIdentityModal(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
|
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
|
||||||
feedback_type: 'like',
|
feedback_type: 'like',
|
||||||
guest_name: savedIdentity?.name,
|
guest_name: savedIdentity?.name,
|
||||||
@@ -280,6 +287,11 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
setLikeCount(c => Math.max(0, c + (next ? 1 : -1)));
|
setLikeCount(c => Math.max(0, c + (next ? 1 : -1)));
|
||||||
return next;
|
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) => {
|
const submitRating = async (value: number) => {
|
||||||
@@ -978,12 +990,17 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
setSavedIdentity({ name, email });
|
setSavedIdentity({ name, email });
|
||||||
setShowIdentityModal(false);
|
setShowIdentityModal(false);
|
||||||
if (pendingAction?.type === 'like') {
|
if (pendingAction?.type === 'like') {
|
||||||
|
try {
|
||||||
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
|
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
|
||||||
feedback_type: 'like',
|
feedback_type: 'like',
|
||||||
guest_name: name,
|
guest_name: name,
|
||||||
guest_email: email,
|
guest_email: email,
|
||||||
});
|
});
|
||||||
setMyLiked(true);
|
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) {
|
} else if (pendingAction?.type === 'rating' && pendingAction.rating) {
|
||||||
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
|
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
|
||||||
feedback_type: 'rating',
|
feedback_type: 'rating',
|
||||||
@@ -997,6 +1014,9 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
}}
|
}}
|
||||||
feedbackType={pendingAction?.type === 'rating' ? 'rating' : 'like'}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { feedbackService } from '../../services/feedback.service';
|
|||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
|
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
|
||||||
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
||||||
|
import { useFeedbackLimitModal } from '../../hooks/useFeedbackLimitModal';
|
||||||
|
|
||||||
interface PhotoLikesProps {
|
interface PhotoLikesProps {
|
||||||
photoId: string;
|
photoId: string;
|
||||||
@@ -29,6 +30,7 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const guestIdentity = useGuestIdentityOptional();
|
const guestIdentity = useGuestIdentityOptional();
|
||||||
|
const { modal: limitModal, handleError: handleLimitError } = useFeedbackLimitModal();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [animating, setAnimating] = useState(false);
|
const [animating, setAnimating] = useState(false);
|
||||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||||
@@ -58,6 +60,8 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
|
|||||||
if (onLikeChange) {
|
if (onLikeChange) {
|
||||||
onLikeChange(isLiked);
|
onLikeChange(isLiked);
|
||||||
}
|
}
|
||||||
|
// Per-guest cap reached (#655) — surface the modal instead of a toast.
|
||||||
|
if (handleLimitError(error)) return;
|
||||||
if (error.response?.status === 429) {
|
if (error.response?.status === 429) {
|
||||||
toast.error(t('feedback.rateLimited', 'Please wait before liking again'));
|
toast.error(t('feedback.rateLimited', 'Please wait before liking again'));
|
||||||
} else {
|
} else {
|
||||||
@@ -138,6 +142,7 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
|
|||||||
onSubmit={handleIdentitySubmit}
|
onSubmit={handleIdentitySubmit}
|
||||||
feedbackType={t('feedback.like', 'like')}
|
feedbackType={t('feedback.like', 'like')}
|
||||||
/>
|
/>
|
||||||
|
{limitModal}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -3037,6 +3037,12 @@
|
|||||||
"commentsDesc": "Textkommentare auf Fotos",
|
"commentsDesc": "Textkommentare auf Fotos",
|
||||||
"favorites": "Favoriten",
|
"favorites": "Favoriten",
|
||||||
"favoritesDesc": "Fotos als Favoriten markieren",
|
"favoritesDesc": "Fotos als Favoriten markieren",
|
||||||
|
"perGuestLimits": "Limits pro Gast",
|
||||||
|
"perGuestLimitsDesc": "Begrenzen, wie viele Fotos jeder Gast favorisieren oder liken kann — praktisch für „Wählen Sie Ihre Top-N fürs Album"-Abläufe. 0 = unbegrenzt. Eine Senkung unter den aktuellen Stand eines Gasts lässt vorhandene Einträge bestehen; nur neue Hinzufügungen werden blockiert, bis er welche entfernt.",
|
||||||
|
"maxFavoritesPerGuest": "Max. Favoriten pro Gast",
|
||||||
|
"maxFavoritesPerGuestHint": "0 = unbegrenzt",
|
||||||
|
"maxLikesPerGuest": "Max. Likes pro Gast",
|
||||||
|
"maxLikesPerGuestHint": "0 = unbegrenzt",
|
||||||
"identityMode": "Identitätsmodus",
|
"identityMode": "Identitätsmodus",
|
||||||
"identityModeSimple": "Einfaches Feedback",
|
"identityModeSimple": "Einfaches Feedback",
|
||||||
"identityModeSimpleDesc": "Anonym, gerätebasiert. Alle Besucher auf demselben Gerät teilen den Zustand.",
|
"identityModeSimpleDesc": "Anonym, gerätebasiert. Alle Besucher auf demselben Gerät teilen den Zustand.",
|
||||||
@@ -3111,6 +3117,14 @@
|
|||||||
"unlike": "Nicht mehr mögen",
|
"unlike": "Nicht mehr mögen",
|
||||||
"like": "Gefällt mir",
|
"like": "Gefällt mir",
|
||||||
"favoriteError": "Favorit konnte nicht aktualisiert werden",
|
"favoriteError": "Favorit konnte nicht aktualisiert werden",
|
||||||
|
"limit": {
|
||||||
|
"favoriteTitle": "Favoritenlimit erreicht",
|
||||||
|
"favoriteBody": "Sie können in dieser Galerie bis zu {{limit}} Fotos favorisieren. Entfernen Sie einen, um einen neuen hinzuzufügen.",
|
||||||
|
"likeTitle": "Like-Limit erreicht",
|
||||||
|
"likeBody": "Sie können in dieser Galerie bis zu {{limit}} Fotos liken. Entfernen Sie einen, um einen neuen hinzuzufügen.",
|
||||||
|
"counter": "{{current}} von {{limit}} verwendet",
|
||||||
|
"ok": "Verstanden"
|
||||||
|
},
|
||||||
"unfavorite": "Aus Favoriten entfernen",
|
"unfavorite": "Aus Favoriten entfernen",
|
||||||
"favorite": "Zu Favoriten hinzufügen",
|
"favorite": "Zu Favoriten hinzufügen",
|
||||||
"invalidEmail": "Ungültige E-Mail-Adresse",
|
"invalidEmail": "Ungültige E-Mail-Adresse",
|
||||||
|
|||||||
@@ -3058,6 +3058,12 @@
|
|||||||
"commentsDesc": "Text comments on photos",
|
"commentsDesc": "Text comments on photos",
|
||||||
"favorites": "Favorites",
|
"favorites": "Favorites",
|
||||||
"favoritesDesc": "Mark photos as favorites",
|
"favoritesDesc": "Mark photos as favorites",
|
||||||
|
"perGuestLimits": "Per-guest limits",
|
||||||
|
"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.",
|
||||||
|
"maxFavoritesPerGuest": "Max favorites per guest",
|
||||||
|
"maxFavoritesPerGuestHint": "0 = unlimited",
|
||||||
|
"maxLikesPerGuest": "Max likes per guest",
|
||||||
|
"maxLikesPerGuestHint": "0 = unlimited",
|
||||||
"identityMode": "Identity Mode",
|
"identityMode": "Identity Mode",
|
||||||
"identityModeSimple": "Simple feedback",
|
"identityModeSimple": "Simple feedback",
|
||||||
"identityModeSimpleDesc": "Anonymous, device-based. All visitors on the same device share state.",
|
"identityModeSimpleDesc": "Anonymous, device-based. All visitors on the same device share state.",
|
||||||
@@ -3134,6 +3140,14 @@
|
|||||||
"favoriteError": "Failed to update favorite",
|
"favoriteError": "Failed to update favorite",
|
||||||
"unfavorite": "Remove from favorites",
|
"unfavorite": "Remove from favorites",
|
||||||
"favorite": "Add to favorites",
|
"favorite": "Add to favorites",
|
||||||
|
"limit": {
|
||||||
|
"favoriteTitle": "Favorite limit reached",
|
||||||
|
"favoriteBody": "You can favorite up to {{limit}} photos in this gallery. Remove one to add a new one.",
|
||||||
|
"likeTitle": "Like limit reached",
|
||||||
|
"likeBody": "You can like up to {{limit}} photos in this gallery. Remove one to add a new one.",
|
||||||
|
"counter": "{{current}} of {{limit}} used",
|
||||||
|
"ok": "Got it"
|
||||||
|
},
|
||||||
"invalidEmail": "Invalid email address",
|
"invalidEmail": "Invalid email address",
|
||||||
"identityRequired": "Your Information Required",
|
"identityRequired": "Your Information Required",
|
||||||
"identityReason": "Please provide your name and email to submit {{type}}.",
|
"identityReason": "Please provide your name and email to submit {{type}}.",
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ export interface FeedbackSettings {
|
|||||||
rate_limit_window_minutes?: number;
|
rate_limit_window_minutes?: number;
|
||||||
rate_limit_max_requests?: number;
|
rate_limit_max_requests?: number;
|
||||||
identity_mode?: IdentityMode;
|
identity_mode?: IdentityMode;
|
||||||
|
// Per-guest caps (#655). null or 0 = unlimited (preserves current
|
||||||
|
// behaviour for installs that haven't enabled the cap). Positive
|
||||||
|
// integers are enforced server-side — adds beyond the cap return a
|
||||||
|
// structured 403 with `code: 'FAVORITE_LIMIT_REACHED'` /
|
||||||
|
// `'LIKE_LIMIT_REACHED'`, surfaced to the guest as a modal.
|
||||||
|
max_favorites_per_guest?: number | null;
|
||||||
|
max_likes_per_guest?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PhotoFeedback {
|
export interface PhotoFeedback {
|
||||||
|
|||||||
Reference in New Issue
Block a user