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
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Heart, Bookmark, Star, MessageCircle } from 'lucide-react';
import { X, Heart, Bookmark, Star, MessageCircle, Smile } from 'lucide-react';
import { Loading } from '../common';
import { guestsService, AdminGuest } from '../../services/guests.service';
import { AuthenticatedImage } from '../common/AuthenticatedImage';
@@ -14,7 +14,7 @@ interface AdminGuestDetailProps {
onClose: () => void;
}
type Tab = 'all' | 'liked' | 'favorited' | 'rated' | 'commented';
type Tab = 'all' | 'liked' | 'favorited' | 'rated' | 'commented' | 'reacted';
export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, guest, onClose }) => {
const { t } = useTranslation();
@@ -31,6 +31,7 @@ export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, gue
const favorited = selections?.favorited || [];
const rated = selections?.rated || [];
const commented = selections?.commented || [];
const reacted = selections?.reacted || [];
// "all" view combines the three visual selection types.
type GridItem = { photo: { id: number; filename: string; thumbnail_url: string }; badges: string[] };
@@ -48,6 +49,7 @@ export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, gue
liked.forEach((p) => add(p, 'like'));
favorited.forEach((p) => add(p, 'favorite'));
rated.forEach((r) => add(r.photo, 'rating'));
reacted.forEach((r) => add(r.photo, r.reaction));
const visibleItems: GridItem[] =
tab === 'all'
@@ -58,6 +60,8 @@ export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, gue
? favorited.map((p) => ({ photo: p, badges: ['favorite'] }))
: tab === 'rated'
? rated.map((r) => ({ photo: r.photo, badges: [`${r.rating}`] }))
: tab === 'reacted'
? reacted.map((r) => ({ photo: r.photo, badges: [r.reaction] }))
: [];
return (
@@ -87,7 +91,7 @@ export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, gue
) : (
<div className="overflow-y-auto p-4">
{/* Stats */}
<div className="grid grid-cols-4 gap-2 mb-4">
<div className="grid grid-cols-5 gap-2 mb-4">
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded text-center">
<div className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
{liked.length}
@@ -124,11 +128,20 @@ export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, gue
{t('admin.guests.columns.comments', 'Comments')}
</div>
</div>
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded text-center">
<div className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
{reacted.length}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
<Smile className="w-3 h-3" />
{t('admin.guests.columns.reactions', 'Reactions')}
</div>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-neutral-200 dark:border-neutral-700 mb-4">
{(['all', 'liked', 'favorited', 'rated', 'commented'] as const).map((k) => (
{(['all', 'liked', 'favorited', 'rated', 'commented', 'reacted'] as const).map((k) => (
<button
key={k}
type="button"
@@ -230,6 +230,9 @@ export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, event
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.ratings', 'Ratings')}
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.reactions', 'Reactions')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.lastSeen', 'Last seen')}
</th>
@@ -270,6 +273,9 @@ export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, event
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
{guest.stats.ratings}
</td>
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
{guest.stats.reactions}
</td>
<td className="px-4 py-3 text-sm text-neutral-600 dark:text-neutral-400">
{fmtDate(guest.last_seen_at)}
</td>
@@ -1,5 +1,5 @@
import React from 'react';
import { MessageSquare, Star, Heart, Bookmark, Shield, Eye, User, Users } from 'lucide-react';
import { MessageSquare, Star, Heart, Bookmark, Shield, Eye, User, Users, Smile } from 'lucide-react';
import { Card } from '../common';
import { useTranslation } from 'react-i18next';
@@ -15,6 +15,7 @@ interface FeedbackSettings {
allow_likes: boolean;
allow_comments: boolean;
allow_favorites: boolean;
allow_reactions: boolean;
require_name_email: boolean;
moderate_comments: boolean;
show_feedback_to_guests: boolean;
@@ -219,6 +220,24 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
</div>
</div>
</label>
<label className="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-700">
<input
type="checkbox"
checked={settings.allow_reactions}
onChange={() => handleToggle('allow_reactions')}
className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/>
<Smile className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{t('feedback.settings.reactions', 'Emoji Reactions')}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('feedback.settings.reactionsDesc', 'One emoji per guest per photo (❤️ 😂 😍 👏 🎉)')}
</div>
</div>
</label>
</div>
</div>