feat(feedback): emoji reactions on photos (#839) (#855)

* feat(feedback): emoji reactions on photos (#839)

Per-photo emoji reactions from a fixed curated set (❤️ 😂 😍 👏 🎉),
one reaction per guest per photo — same emoji toggles off, another
switches in place. Stored as feedback_type='reaction' rows with per-guest
scoping identical to likes (guest_id when present, device hash otherwise).

- migration 164: allow_reactions toggle (default on, still gated by the
  opt-in feedback_enabled master switch), photo_feedback.reaction value
  column, denormalized photos.reaction_count
- emoji whitelist enforced in the route validator AND the service
  (shared constants/reactions.js, mirrored in the frontend)
- per-emoji tallies + my_feedback.reaction in the photo feedback
  endpoint; hidden-by-moderator reactions leave all counts
- reactions ride the existing rate limiting (like-tier), guest identity
  modes, and moderation actions; long + pivot exports carry the emoji
- gallery: reaction bar in the photo feedback panel (grid lightbox);
  admin: allow_reactions toggle next to likes, analytics tile,
  create/duplicate event paths
- i18n for all 8 locales; 9 service-level tests

* fix(feedback): reach reactions without comments; numeric analytics totals (#839)

- the lightbox feedback-panel toggle was gated on allow_comments only —
  with comments off the new reaction bar was unreachable; the gate now
  opens for comments OR reactions
- the analytics summary now coerces Postgres string counts to numbers:
  total_feedback concatenated instead of adding ("00006")

* fix(feedback): harden reactions from review round 1 (#839)

- per-emoji tallies are gated on show_feedback_to_guests — with sharing
  off a guest sees only their own selection, no aggregate counts
- reaction toggle/switch operate on the guest-scoped row SET, so rows
  duplicated by the (like-parity) check-then-insert race collapse on the
  next interaction instead of counting twice
- rate-limit defaults merge UNDER the persisted settings object —
  stored rows predating the reaction key otherwise dropped it to the
  generic 100/h fallback
- optimistic revert uses the pre-mutation value via mutation context;
  the onError closure sees the post-optimistic render, so the old
  revert froze the wrong state on failed toggles

* fix(feedback): review round 2 — hide reaction_count with sharing off, admin list shows emoji (#839)

- summary.reaction_count is gated on show_feedback_to_guests like the
  per-emoji map, keeping the "no aggregates while sharing is off"
  promise consistent
- the admin feedback list renders the reaction emoji on reaction rows
  and the type filter gains a Reactions option (7 locales; es has no
  types block and falls back to EN defaults)

* fix(feedback): register reaction activity types with translated labels (#839)

photo_reaction / guest_feedback_reaction are logged by the submission
paths but were absent from the frontend activity-type union and the
admin.activities label maps — the recent-activity feed would have shown
the raw identifiers. All 8 locales.

* feat(feedback): reactions in guest CRM and the premium gallery layout (#839)

- guest CRM: per-guest reaction counts in the list aggregation and a
  Reacted tab (photo grid with emoji badges) + stats card in the guest
  detail modal; picks/aggregate/exports stay selection-only by design
- premium layout: its own yet-another-react-lightbox now gets a fixed
  reaction-bar overlay (per-photo fetch, optimistic switch) — reactions
  were otherwise unreachable in this layout since it bypasses the
  shared PhotoLightbox
- allowReactions threaded through the layout feedbackOptions; guest
  i18n keys for the 7 locales that carry the guests block

* fix(feedback): portal the premium reaction bar to document.body (#839)

Inside the layout tree an ancestor stacking context (framer-motion
transforms) painted the bar under yarl's body-level portal — visible
but unclickable, every tap landed on the slide image. As a direct body
child the z-index 10000 genuinely wins over yarl's 9999. Verified by
clicking through in the running app.

---------

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-07-22 20:59:43 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent f8a95d29d2
commit 3d6c9848dc
36 changed files with 885 additions and 54 deletions
+3
View File
@@ -165,6 +165,7 @@ module.exports = (router) => {
allow_likes = true,
allow_comments = true,
allow_favorites = true,
allow_reactions = true,
require_name_email = false,
moderate_comments = true,
show_feedback_to_guests = true,
@@ -490,6 +491,7 @@ module.exports = (router) => {
allow_likes: formatBoolean(allow_likes),
allow_comments: formatBoolean(allow_comments),
allow_favorites: formatBoolean(allow_favorites),
allow_reactions: formatBoolean(allow_reactions),
require_name_email: formatBoolean(require_name_email),
moderate_comments: formatBoolean(moderate_comments),
show_feedback_to_guests: formatBoolean(show_feedback_to_guests),
@@ -1111,6 +1113,7 @@ module.exports = (router) => {
allow_likes: sourceFeedback.allow_likes,
allow_comments: sourceFeedback.allow_comments,
allow_favorites: sourceFeedback.allow_favorites,
allow_reactions: sourceFeedback.allow_reactions,
require_name_email: sourceFeedback.require_name_email,
moderate_comments: sourceFeedback.moderate_comments,
show_feedback_to_guests: sourceFeedback.show_feedback_to_guests,
+14 -9
View File
@@ -233,17 +233,22 @@ router.get('/events/:eventId/feedback-analytics',
.count('* as count')
.first();
// Number() everywhere: Postgres COUNT() comes back as a string, and
// string + string in the total_feedback sum concatenates ("00006").
const counts = {
total_ratings: Number(summaryData.stats?.total_ratings) || 0,
total_likes: Number(summaryData.stats?.total_likes) || 0,
total_comments: Number(summaryData.stats?.total_comments) || 0,
total_favorites: Number(summaryData.stats?.total_favorites) || 0,
total_reactions: Number(summaryData.stats?.total_reactions) || 0,
};
const summary = {
average_rating: parseFloat(avgRatingResult?.average_rating || 0),
total_ratings: summaryData.stats?.total_ratings || 0,
total_likes: summaryData.stats?.total_likes || 0,
total_comments: summaryData.stats?.total_comments || 0,
total_favorites: summaryData.stats?.total_favorites || 0,
pending_moderation: pendingModeration?.count || 0,
total_feedback: (summaryData.stats?.total_ratings || 0) +
(summaryData.stats?.total_likes || 0) +
(summaryData.stats?.total_comments || 0) +
(summaryData.stats?.total_favorites || 0)
...counts,
pending_moderation: Number(pendingModeration?.count) || 0,
total_feedback: counts.total_ratings + counts.total_likes +
counts.total_comments + counts.total_favorites +
counts.total_reactions
};
// Get top-rated photos
+7
View File
@@ -78,6 +78,7 @@ router.get(
db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'favorite\' THEN 1 END) AS favorites'),
db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'comment\' THEN 1 END) AS comments'),
db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'rating\' THEN 1 END) AS ratings'),
db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'reaction\' THEN 1 END) AS reactions'),
db.raw('COUNT(DISTINCT photo_feedback.photo_id) AS distinct_photos')
)
.orderBy('gallery_guests.created_at', 'desc');
@@ -89,6 +90,7 @@ router.get(
favorites: parseInt(r.favorites, 10) || 0,
comments: parseInt(r.comments, 10) || 0,
ratings: parseInt(r.ratings, 10) || 0,
reactions: parseInt(r.reactions, 10) || 0,
distinct_photos: parseInt(r.distinct_photos, 10) || 0,
},
}));
@@ -401,6 +403,7 @@ router.get(
'photo_feedback.feedback_type',
'photo_feedback.rating',
'photo_feedback.comment_text',
'photo_feedback.reaction',
'photo_feedback.created_at',
'photos.id as photo_id',
'photos.filename',
@@ -423,6 +426,7 @@ router.get(
favorited: [],
rated: [],
commented: [],
reacted: [],
};
for (const row of feedback) {
if (row.feedback_type === 'like') {
@@ -437,6 +441,8 @@ router.get(
comment: row.comment_text,
created_at: row.created_at,
});
} else if (row.feedback_type === 'reaction') {
selections.reacted.push({ photo: photoFor(row), reaction: row.reaction });
}
}
@@ -448,6 +454,7 @@ router.get(
favorites: selections.favorited.length,
comments: selections.commented.length,
ratings: selections.rated.length,
reactions: selections.reacted.length,
},
},
selections,
+1
View File
@@ -1956,6 +1956,7 @@ router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) =>
allow_likes: settings.allow_likes,
allow_comments: settings.allow_comments,
allow_favorites: settings.allow_favorites,
allow_reactions: settings.allow_reactions,
show_feedback_to_guests: settings.show_feedback_to_guests,
require_name_email: settings.require_name_email || false,
identity_mode: settings.identity_mode || 'simple'
+17 -3
View File
@@ -31,6 +31,7 @@ router.get('/:slug/feedback-settings',
allow_likes: Boolean(settings.allow_likes),
allow_comments: Boolean(settings.allow_comments),
allow_favorites: Boolean(settings.allow_favorites),
allow_reactions: Boolean(settings.allow_reactions),
require_name_email: Boolean(settings.require_name_email),
show_feedback_to_guests: Boolean(settings.show_feedback_to_guests),
identity_mode: settings.identity_mode || 'simple',
@@ -117,6 +118,9 @@ router.get('/:slug/photos/:photoId/feedback',
.then(r => r.count),
like_count: photo.like_count || 0,
favorite_count: photo.favorite_count || 0,
// Gated like the per-emoji map below — aggregate reaction data is
// a new surface, kept fully hidden while sharing is off.
reaction_count: settings.show_feedback_to_guests ? (photo.reaction_count || 0) : 0,
comment_count: await db('photo_feedback')
.where({
photo_id: photoId,
@@ -128,10 +132,17 @@ router.get('/:slug/photos/:photoId/feedback',
.first()
.then(r => r.count)
},
// Per-emoji tallies for the reaction bar (#839). Gated on
// show_feedback_to_guests: with sharing off a guest sees only their
// own selection (my_feedback.reaction below), no aggregate counts.
reactions: settings.show_feedback_to_guests
? await feedbackService.getPhotoReactionCounts(photoId)
: {},
my_feedback: {
rating: guestFeedback.find(f => f.feedback_type === 'rating')?.rating,
liked: !!guestFeedback.find(f => f.feedback_type === 'like'),
favorited: !!guestFeedback.find(f => f.feedback_type === 'favorite')
favorited: !!guestFeedback.find(f => f.feedback_type === 'favorite'),
reaction: guestFeedback.find(f => f.feedback_type === 'reaction')?.reaction || null
}
});
} catch (error) {
@@ -181,7 +192,8 @@ router.post('/:slug/photos/:photoId/feedback',
rating: settings.allow_ratings,
like: settings.allow_likes,
comment: settings.allow_comments,
favorite: settings.allow_favorites
favorite: settings.allow_favorites,
reaction: settings.allow_reactions
};
if (!typeAllowed[feedbackType]) {
@@ -227,6 +239,7 @@ router.post('/:slug/photos/:photoId/feedback',
feedback_type: feedbackType,
rating: req.body.rating,
comment_text: req.body.comment_text,
reaction: req.body.reaction,
guest_name: req.guest?.name ?? req.body.guest_name,
guest_email: req.guest?.email ?? req.body.guest_email,
guest_id: req.guest?.id ?? null,
@@ -346,7 +359,8 @@ router.get('/:slug/feedback-summary',
allow_ratings: settings.allow_ratings,
allow_likes: settings.allow_likes,
allow_comments: settings.allow_comments,
allow_favorites: settings.allow_favorites
allow_favorites: settings.allow_favorites,
allow_reactions: settings.allow_reactions
},
summary: guestSummary
});