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
@@ -0,0 +1,55 @@
/**
* Emoji reactions on photos (#839).
*
* - event_feedback_settings.allow_reactions: per-event toggle next to
* allow_likes / allow_ratings / allow_comments. Defaults TRUE for parity
* with the sibling toggles — the master feedback_enabled gate (default
* false, opt-in per event) still decides whether any feedback UI shows.
* - photo_feedback.reaction: the emoji value for feedback_type='reaction'
* rows (validated against the fixed set in constants/reactions.js).
* - photos.reaction_count: denormalized total, maintained by
* updatePhotoFeedbackStats alongside like_count / favorite_count.
*/
exports.up = async function (knex) {
const hasAllowReactions = await knex.schema.hasColumn('event_feedback_settings', 'allow_reactions');
if (!hasAllowReactions) {
await knex.schema.alterTable('event_feedback_settings', (table) => {
table.boolean('allow_reactions').defaultTo(true);
});
}
const hasReaction = await knex.schema.hasColumn('photo_feedback', 'reaction');
if (!hasReaction) {
await knex.schema.alterTable('photo_feedback', (table) => {
// 16 chars: emoji are multi-byte/multi-codepoint (variation selectors),
// but well under 16 characters each.
table.string('reaction', 16);
});
}
const hasReactionCount = await knex.schema.hasColumn('photos', 'reaction_count');
if (!hasReactionCount) {
await knex.schema.alterTable('photos', (table) => {
table.integer('reaction_count').defaultTo(0);
});
}
};
exports.down = async function (knex) {
if (await knex.schema.hasColumn('photos', 'reaction_count')) {
await knex.schema.alterTable('photos', (table) => {
table.dropColumn('reaction_count');
});
}
if (await knex.schema.hasColumn('photo_feedback', 'reaction')) {
await knex.schema.alterTable('photo_feedback', (table) => {
table.dropColumn('reaction');
});
}
if (await knex.schema.hasColumn('event_feedback_settings', 'allow_reactions')) {
await knex.schema.alterTable('event_feedback_settings', (table) => {
table.dropColumn('allow_reactions');
});
}
};