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>
@@ -721,6 +721,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
allowFavorites: !!feedbackSettings?.allow_favorites,
allowRatings: !!feedbackSettings?.allow_ratings,
allowComments: !!feedbackSettings?.allow_comments,
allowReactions: !!feedbackSettings?.allow_reactions,
requireNameEmail: !!feedbackSettings?.require_name_email,
}}
isSelectionMode={isSelectionMode}
@@ -967,6 +968,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
allowFavorites: !!feedbackSettings?.allow_favorites,
allowRatings: !!feedbackSettings?.allow_ratings,
allowComments: !!feedbackSettings?.allow_comments,
allowReactions: !!feedbackSettings?.allow_reactions,
requireNameEmail: !!feedbackSettings?.require_name_email,
}}
isSelectionMode={isSelectionMode}
@@ -4,6 +4,7 @@ import { feedbackService } from '../../services/feedback.service';
import { PhotoRating } from './PhotoRating';
import { PhotoLikes } from './PhotoLikes';
import { PhotoFavorites } from './PhotoFavorites';
import { PhotoReactions } from './PhotoReactions';
import { PhotoComments } from './PhotoComments';
import { Skeleton } from '../common';
@@ -45,6 +46,8 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
const [likeCount, setLikeCount] = useState(0);
const [isFavorited, setIsFavorited] = useState(false);
const [favoriteCount, setFavoriteCount] = useState(0);
const [myReaction, setMyReaction] = useState<string | null>(null);
const [reactionCounts, setReactionCounts] = useState<Record<string, number>>({});
// Update local state when data loads
useEffect(() => {
@@ -54,6 +57,8 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
setLikeCount(Number(feedbackData.summary.like_count) || 0);
setIsFavorited(Boolean(feedbackData.my_feedback.favorited));
setFavoriteCount(Number(feedbackData.summary.favorite_count) || 0);
setMyReaction(feedbackData.my_feedback.reaction || null);
setReactionCounts(feedbackData.reactions || {});
}
}, [feedbackData]);
@@ -75,6 +80,18 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
if (onFeedbackUpdate) onFeedbackUpdate();
};
// Optimistic reaction switch: decrement the old emoji, increment the new.
const handleReactionChange = (reaction: string | null) => {
setReactionCounts(prev => {
const next = { ...prev };
if (myReaction) next[myReaction] = Math.max(0, (next[myReaction] || 0) - 1);
if (reaction) next[reaction] = (next[reaction] || 0) + 1;
return next;
});
setMyReaction(reaction);
if (onFeedbackUpdate) onFeedbackUpdate();
};
if (settingsLoading) {
return (
<div className={`space-y-3 ${className}`}>
@@ -89,7 +106,8 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
}
const hasAnyFeedbackType = settings.allow_ratings || settings.allow_likes ||
settings.allow_comments || settings.allow_favorites;
settings.allow_comments || settings.allow_favorites ||
settings.allow_reactions;
if (!hasAnyFeedbackType) {
return null;
@@ -140,6 +158,19 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
</div>
)}
{/* Emoji reactions (#839) */}
{settings.allow_reactions && (
<PhotoReactions
photoId={photoId}
gallerySlug={gallerySlug}
myReaction={myReaction}
reactionCounts={reactionCounts}
isEnabled={true}
requireNameEmail={settings.require_name_email || false}
onReactionChange={handleReactionChange}
/>
)}
{/* Comments Section */}
{settings.allow_comments && showComments && (
<div className="border-t pt-4">
@@ -79,6 +79,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
allow_likes?: boolean;
allow_ratings?: boolean;
allow_comments?: boolean;
allow_reactions?: boolean;
show_feedback_to_guests?: boolean;
require_name_email?: boolean;
} | null>(null);
@@ -750,12 +751,11 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
</div>
)}
{/* Feedback button with indicator. Gated on allow_comments
because likes/ratings already have their own dedicated
toolbar buttons above — this MessageSquare button only
opens the comments panel, so it has nothing to do when
comments are off (#518). */}
{feedbackEnabled && feedbackSettings?.allow_comments && (
{/* Feedback button with indicator. Likes/ratings have their
own dedicated toolbar buttons above, so this panel toggle
only has work to do when comments (#518) or the emoji
reaction bar (#839) live inside the panel. */}
{feedbackEnabled && (feedbackSettings?.allow_comments || feedbackSettings?.allow_reactions) && (
<button
onClick={() => {
setShowFeedback(!showFeedback);
@@ -0,0 +1,150 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { feedbackService, REACTION_EMOJIS } from '../../services/feedback.service';
import { toast } from 'react-toastify';
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
interface PhotoReactionsProps {
photoId: string;
gallerySlug: string;
/** The guest's current reaction emoji, or null. */
myReaction: string | null;
/** Per-emoji visible counts, e.g. { '❤️': 3 }. */
reactionCounts: Record<string, number>;
isEnabled: boolean;
requireNameEmail?: boolean;
onReactionChange?: (reaction: string | null) => void;
}
// Emoji reaction bar (#839): one reaction per guest per photo, changeable —
// tapping the current emoji removes it, tapping another switches. Fixed
// curated set; identity handling mirrors PhotoLikes (guest-token mode vs.
// legacy inline name/email prompt).
export const PhotoReactions: React.FC<PhotoReactionsProps> = ({
photoId,
gallerySlug,
myReaction,
reactionCounts,
isEnabled,
requireNameEmail = false,
onReactionChange
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const guestIdentity = useGuestIdentityOptional();
const [isSubmitting, setIsSubmitting] = useState(false);
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const [pendingEmoji, setPendingEmoji] = useState<string | null>(null);
const submitReactionMutation = useMutation({
mutationFn: (data: { emoji: string; guest_name?: string; guest_email?: string }) =>
feedbackService.submitFeedback(gallerySlug, photoId, {
feedback_type: 'reaction',
reaction: data.emoji,
guest_name: data.guest_name || undefined,
guest_email: data.guest_email || undefined
}),
onMutate: async (data) => {
setIsSubmitting(true);
// Optimistic update: same emoji toggles off, another switches. The
// PRE-mutation value must travel via the mutation context — the
// onError closure sees the post-optimistic render, so reading
// `myReaction` there would "revert" to the already-wrong state.
const previousReaction = myReaction;
if (onReactionChange) {
onReactionChange(data.emoji === previousReaction ? null : data.emoji);
}
return { previousReaction };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
},
onError: (_error, _data, context) => {
if (onReactionChange) {
onReactionChange(context?.previousReaction ?? null); // revert optimistic update
}
toast.error(t('feedback.reactionError', 'Failed to update reaction'));
},
onSettled: () => {
setIsSubmitting(false);
}
});
const handleReactionClick = async (emoji: string) => {
if (!isEnabled || isSubmitting) return;
// Guest identity mode: ensure a per-person guest token; the server reads
// name/email from the token — body values are ignored.
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
return; // user cancelled the prompt
}
submitReactionMutation.mutate({ emoji });
return;
}
// Simple mode: legacy inline prompt flow.
if (requireNameEmail && !savedIdentity) {
setPendingEmoji(emoji);
setShowIdentityModal(true);
} else {
submitReactionMutation.mutate({
emoji,
...(savedIdentity ? { guest_name: savedIdentity.name, guest_email: savedIdentity.email } : {})
});
}
};
const handleIdentitySubmit = (name: string, email: string) => {
setSavedIdentity({ name, email });
setShowIdentityModal(false);
if (pendingEmoji) {
submitReactionMutation.mutate({ emoji: pendingEmoji, guest_name: name, guest_email: email });
setPendingEmoji(null);
}
};
if (!isEnabled) return null;
return (
<>
<div className="flex flex-wrap items-center gap-1.5" role="group" aria-label={t('feedback.reactions', 'Reactions')}>
{REACTION_EMOJIS.map((emoji) => {
const count = reactionCounts[emoji] || 0;
const isMine = myReaction === emoji;
return (
<button
key={emoji}
type="button"
onClick={() => handleReactionClick(emoji)}
disabled={isSubmitting}
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-full text-sm transition-all ${
isMine
? 'bg-primary-100 dark:bg-primary-900/40 ring-1 ring-primary-500 scale-105'
: 'bg-surface text-muted-theme hover:bg-black/10 hover:scale-105'
} ${isSubmitting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
aria-pressed={isMine}
aria-label={isMine
? t('feedback.removeReaction', 'Remove {{emoji}} reaction', { emoji })
: t('feedback.reactWith', 'React with {{emoji}}', { emoji })}
>
<span className="text-base leading-none">{emoji}</span>
{count > 0 && <span className="font-medium">{count}</span>}
</button>
);
})}
</div>
<FeedbackIdentityModal
isOpen={showIdentityModal}
onClose={() => { setShowIdentityModal(false); setPendingEmoji(null); }}
onSubmit={handleIdentitySubmit}
feedbackType={t('feedback.reaction', 'reaction')}
/>
</>
);
};
@@ -29,6 +29,7 @@ export interface BaseGalleryLayoutProps {
allowFavorites?: boolean;
allowRatings?: boolean;
allowComments?: boolean;
allowReactions?: boolean;
requireNameEmail?: boolean;
};
// Logout callback for full-page layouts
@@ -408,3 +408,17 @@
.gallery-premium-animate-in {
animation: galleryPremiumFadeIn 0.4s ease forwards;
}
/* Emoji reaction bar over the lightbox (#839). Sits above YARL's container
(z-index 9999) and clear of the bottom thumbnail strip. */
.gallery-premium-lightbox-reactions {
position: fixed;
left: 50%;
transform: translateX(-50%);
bottom: 122px;
z-index: 10000;
background: rgba(20, 20, 20, 0.75);
border-radius: 9999px;
padding: 6px 10px;
backdrop-filter: blur(6px);
}
@@ -1,4 +1,5 @@
import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import { MasonryPhotoAlbum } from 'react-photo-album';
import 'react-photo-album/masonry.css';
import Lightbox from 'yet-another-react-lightbox';
@@ -19,6 +20,7 @@ import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
import { AuthenticatedImage } from '../../common';
import { feedbackService } from '../../../services/feedback.service';
import { PhotoReactions } from '../PhotoReactions';
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
import { FeedbackIdentityModal } from '../FeedbackIdentityModal';
import { galleryService } from '../../../services/gallery.service';
@@ -208,6 +210,14 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
likedSeededRef.current = true;
}, [photos]);
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
// Emoji reactions (#839) inside the premium lightbox. This layout uses
// yet-another-react-lightbox instead of the shared PhotoLightbox, so the
// reaction bar is a fixed overlay fed by its own per-photo fetch.
const [reactionState, setReactionState] = useState<{
photoId: number;
mine: string | null;
counts: Record<string, number>;
} | null>(null);
const guestIdentity = useGuestIdentityOptional();
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingLikePhotoId, setPendingLikePhotoId] = useState<number | null>(null);
@@ -229,6 +239,41 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
return photos.filter(photo => photo.category_name === activeCategory);
}, [photos, activeCategory]);
const currentLightboxPhoto = lightboxIndex >= 0 ? filteredPhotos[lightboxIndex] : null;
const reactionsActive = feedbackEnabled && !!feedbackOptions?.allowReactions;
// Fetch the current photo's reaction tallies + my selection when the
// lightbox lands on it. Optimistic updates below keep it fresh in place.
useEffect(() => {
if (!currentLightboxPhoto || !reactionsActive) {
setReactionState(null);
return undefined;
}
let alive = true;
feedbackService.getPhotoFeedback(slug, String(currentLightboxPhoto.id))
.then((d) => {
if (!alive) return;
setReactionState({
photoId: currentLightboxPhoto.id,
mine: d.my_feedback.reaction || null,
counts: d.reactions || {},
});
})
.catch(() => { /* bar simply stays hidden for this photo */ });
return () => { alive = false; };
}, [currentLightboxPhoto?.id, reactionsActive, slug]);
const handleReactionChange = useCallback((next: string | null) => {
setReactionState((prev) => {
if (!prev) return prev;
const counts = { ...prev.counts };
if (prev.mine) counts[prev.mine] = Math.max(0, (counts[prev.mine] || 0) - 1);
if (next) counts[next] = (counts[next] || 0) + 1;
return { ...prev, mine: next, counts };
});
onFeedbackChange?.();
}, [onFeedbackChange]);
// Get hero photo
const heroPhoto = heroPhotoOverride || photos[0];
@@ -587,6 +632,26 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
}}
/>
{/* Emoji reaction bar over the lightbox (#839). Portaled to
document.body: inside the layout tree an ancestor stacking context
(framer-motion transforms) would paint it UNDER yarl's body-level
portal and its backdrop would swallow every click. As a body child
the z-index 10000 genuinely beats yarl's 9999. */}
{currentLightboxPhoto && reactionsActive && reactionState?.photoId === currentLightboxPhoto.id && createPortal(
<div className="gallery-premium-lightbox-reactions">
<PhotoReactions
photoId={String(currentLightboxPhoto.id)}
gallerySlug={slug}
myReaction={reactionState.mine}
reactionCounts={reactionState.counts}
isEnabled={true}
requireNameEmail={!!feedbackOptions?.requireNameEmail}
onReactionChange={handleReactionChange}
/>
</div>,
document.body
)}
{/* Identity Modal */}
<FeedbackIdentityModal
isOpen={showIdentityModal}
+14 -1
View File
@@ -2728,6 +2728,7 @@
"favorites": "Favoriten",
"comments": "Kommentare",
"ratings": "Bewertungen",
"reactions": "Reaktionen",
"lastSeen": "Zuletzt gesehen"
},
"view": "Details anzeigen",
@@ -2741,6 +2742,7 @@
"liked": "Gemocht",
"favorited": "Favorisiert",
"rated": "Bewertet",
"reacted": "Reagiert",
"commented": "Kommentiert"
},
"inviteStatus": {
@@ -2796,7 +2798,9 @@
"event_renamed": "Ereignis umbenannt: {{eventName}}",
"photo_rating": "Foto bewertet in {{eventName}}",
"photo_comment": "Foto kommentiert in {{eventName}}",
"photo_reaction": "Foto-Reaktion in {{eventName}}",
"guest_feedback_like": "Gast hat ein Foto geliked in {{eventName}}",
"guest_feedback_reaction": "Gast hat auf ein Foto in {{eventName}} reagiert",
"guest_feedback_favorite": "Gast hat ein Foto als Favorit markiert in {{eventName}}",
"guest_feedback_rating": "Gast hat ein Foto bewertet in {{eventName}}",
"guest_feedback_comment": "Gast hat ein Foto kommentiert in {{eventName}}",
@@ -3337,6 +3341,12 @@
"patternDescription": "Passwörter werden mit Ihrem Veranstaltungsnamen und -datum generiert. Beispiel: \"Location2024$August\" für bessere Sicherheit und Merkbarkeit."
},
"feedback": {
"reaction": "Reaktion",
"reactions": "Reaktionen",
"reactWith": "Mit {{emoji}} reagieren",
"removeReaction": "{{emoji}}-Reaktion entfernen",
"reactionError": "Reaktion konnte nicht aktualisiert werden",
"totalReactions": "Reaktionen gesamt",
"comments": "Kommentare",
"addComment": "Kommentar hinzufügen",
"yourName": "Ihr Name",
@@ -3365,6 +3375,8 @@
"comments": "Kommentare",
"commentsDesc": "Textkommentare auf Fotos",
"favorites": "Favoriten",
"reactions": "Emoji-Reaktionen",
"reactionsDesc": "Ein Emoji pro Gast und Foto (❤️ 😂 😍 👏 🎉)",
"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.",
@@ -3409,7 +3421,8 @@
"rating": "Bewertungen",
"like": "Likes",
"comment": "Kommentare",
"favorite": "Favoriten"
"favorite": "Favoriten",
"reaction": "Reaktionen"
},
"allStatuses": "Alle Status",
"status": {
+14 -1
View File
@@ -2306,6 +2306,7 @@
"favorites": "Favorites",
"comments": "Comments",
"ratings": "Ratings",
"reactions": "Reactions",
"lastSeen": "Last seen"
},
"view": "View details",
@@ -2319,6 +2320,7 @@
"liked": "Liked",
"favorited": "Favorited",
"rated": "Rated",
"reacted": "Reacted",
"commented": "Commented"
},
"inviteStatus": {
@@ -2351,7 +2353,9 @@
"photo_favorite": "Photo favorited in {{eventName}}",
"photo_rating": "Photo rated in {{eventName}}",
"photo_comment": "Photo commented in {{eventName}}",
"photo_reaction": "Photo reaction in {{eventName}}",
"guest_feedback_like": "Guest liked a photo in {{eventName}}",
"guest_feedback_reaction": "Guest reacted to a photo in {{eventName}}",
"guest_feedback_favorite": "Guest favorited a photo in {{eventName}}",
"guest_feedback_rating": "Guest rated a photo in {{eventName}}",
"guest_feedback_comment": "Guest commented on a photo in {{eventName}}",
@@ -3358,6 +3362,12 @@
"patternDescription": "Passwords are generated using your event name and date. Example: \"Venue2024$August\" for better security and memorability."
},
"feedback": {
"reaction": "reaction",
"reactions": "Reactions",
"reactWith": "React with {{emoji}}",
"removeReaction": "Remove {{emoji}} reaction",
"reactionError": "Failed to update reaction",
"totalReactions": "Total Reactions",
"comments": "Comments",
"addComment": "Add Comment",
"yourName": "Your name",
@@ -3386,6 +3396,8 @@
"comments": "Comments",
"commentsDesc": "Text comments on photos",
"favorites": "Favorites",
"reactions": "Emoji Reactions",
"reactionsDesc": "One emoji per guest per photo (❤️ 😂 😍 👏 🎉)",
"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.",
@@ -3430,7 +3442,8 @@
"rating": "Ratings",
"like": "Likes",
"comment": "Comments",
"favorite": "Favorites"
"favorite": "Favorites",
"reaction": "Reactions"
},
"allStatuses": "All Statuses",
"status": {
+10
View File
@@ -1492,7 +1492,9 @@
"photo_favorite": "Foto añadida a favoritos en {{eventName}}",
"photo_rating": "Foto valorada en {{eventName}}",
"photo_comment": "Comentario en foto en {{eventName}}",
"photo_reaction": "Reacción a una foto en {{eventName}}",
"guest_feedback_like": "Invitado marcó me gusta en {{eventName}}",
"guest_feedback_reaction": "Invitado reaccionó a una foto en {{eventName}}",
"guest_feedback_favorite": "Invitado añadió a favoritos en {{eventName}}",
"guest_feedback_rating": "Invitado valoró una foto en {{eventName}}",
"guest_feedback_comment": "Invitado comentó en una foto en {{eventName}}",
@@ -2348,6 +2350,12 @@
"patternDescription": "Las contraseñas se generan usando el nombre y la fecha del evento. Ejemplo: \"Venue2024$August\" para mayor seguridad y memorización."
},
"feedback": {
"reaction": "reacción",
"reactions": "Reacciones",
"reactWith": "Reaccionar con {{emoji}}",
"removeReaction": "Quitar la reacción {{emoji}}",
"reactionError": "No se pudo actualizar la reacción",
"totalReactions": "Reacciones totales",
"comments": "Comentarios",
"addComment": "Añadir comentario",
"yourName": "Tu nombre",
@@ -2387,6 +2395,8 @@
"comments": "Comentarios",
"commentsDesc": "Comentarios de texto en fotos",
"favorites": "Favoritos",
"reactions": "Reacciones con emojis",
"reactionsDesc": "Un emoji por invitado y foto (❤️ 😂 😍 👏 🎉)",
"favoritesDesc": "Marcar fotos como favoritas",
"identityMode": "Modo de identidad",
"identityModeSimple": "Feedback simple",
+14 -1
View File
@@ -1699,6 +1699,7 @@
"favorites": "Favoris",
"comments": "Commentaires",
"ratings": "Notes",
"reactions": "Réactions",
"lastSeen": "Dernière visite"
},
"view": "Voir les détails",
@@ -1712,6 +1713,7 @@
"liked": "Aimés",
"favorited": "Favoris",
"rated": "Notés",
"reacted": "A réagi",
"commented": "Commentés"
},
"inviteStatus": {
@@ -1744,7 +1746,9 @@
"photo_favorite": "Photo marquée comme favori dans {{eventName}}",
"photo_rating": "Photo notée dans {{eventName}}",
"photo_comment": "Commentaire ajouté sur une photo dans {{eventName}}",
"photo_reaction": "Réaction à une photo dans {{eventName}}",
"guest_feedback_like": "Un invité a aimé une photo dans {{eventName}}",
"guest_feedback_reaction": "Un invité a réagi à une photo dans {{eventName}}",
"guest_feedback_favorite": "Un invité a marqué une photo comme favori dans {{eventName}}",
"guest_feedback_rating": "Un invité a noté une photo dans {{eventName}}",
"guest_feedback_comment": "Un invité a commenté une photo dans {{eventName}}",
@@ -2496,6 +2500,12 @@
"patternDescription": "Les mots de passe sont générés à partir du nom de votre événement et de la date. Exemple : \"Lieu2024$Août\" pour une meilleure sécurité et mémorisation."
},
"feedback": {
"reaction": "réaction",
"reactions": "Réactions",
"reactWith": "Réagir avec {{emoji}}",
"removeReaction": "Retirer la réaction {{emoji}}",
"reactionError": "Impossible de mettre à jour la réaction",
"totalReactions": "Total des réactions",
"comments": "Commentaires",
"addComment": "Ajouter un commentaire",
"yourName": "Votre nom",
@@ -2524,6 +2534,8 @@
"comments": "Commentaires",
"commentsDesc": "Commentaires textuels sur les photos",
"favorites": "Favoris",
"reactions": "Réactions emoji",
"reactionsDesc": "Un emoji par invité et par photo (❤️ 😂 😍 👏 🎉)",
"favoritesDesc": "Marquer les photos comme favoris",
"identityMode": "Mode d'identité",
"identityModeSimple": "Commentaires simples",
@@ -2562,7 +2574,8 @@
"rating": "Notes",
"like": "J'aimes",
"comment": "Commentaires",
"favorite": "Favoris"
"favorite": "Favoris",
"reaction": "Réactions"
},
"allStatuses": "Tous statuts",
"status": {
+14 -1
View File
@@ -1688,6 +1688,7 @@
"favorites": "Favorieten",
"comments": "Opmerkingen",
"ratings": "Beoordelingen",
"reactions": "Reacties",
"lastSeen": "Laatste bezoek"
},
"view": "Details bekijken",
@@ -1701,6 +1702,7 @@
"liked": "Geliked",
"favorited": "Favoriet",
"rated": "Beoordeeld",
"reacted": "Gereageerd",
"commented": "Becommentarieerd"
},
"inviteStatus": {
@@ -1756,7 +1758,9 @@
"event_renamed": "Evenement hernoemd: {{eventName}}",
"photo_rating": "Foto beoordeeld in {{eventName}}",
"photo_comment": "Foto becommentarieerd in {{eventName}}",
"photo_reaction": "Fotoreactie in {{eventName}}",
"guest_feedback_like": "Gast heeft een foto geliked in {{eventName}}",
"guest_feedback_reaction": "Gast reageerde op een foto in {{eventName}}",
"guest_feedback_favorite": "Gast heeft een foto als favoriet gemarkeerd in {{eventName}}",
"guest_feedback_rating": "Gast heeft een foto beoordeeld in {{eventName}}",
"guest_feedback_comment": "Gast heeft een opmerking geplaatst op een foto in {{eventName}}",
@@ -2485,6 +2489,12 @@
"patternDescription": "Wachtwoorden worden gegenereerd met uw evenementnaam en datum. Voorbeeld: \"Locatie2024$Augustus\" voor betere beveiliging en herkenbaarheid."
},
"feedback": {
"reaction": "reactie",
"reactions": "Reacties",
"reactWith": "Reageer met {{emoji}}",
"removeReaction": "{{emoji}}-reactie verwijderen",
"reactionError": "Reactie kon niet worden bijgewerkt",
"totalReactions": "Totaal reacties",
"comments": "Opmerkingen",
"addComment": "Opmerking toevoegen",
"yourName": "Uw naam",
@@ -2513,6 +2523,8 @@
"comments": "Opmerkingen",
"commentsDesc": "Tekstopmerkingen bij foto's",
"favorites": "Favorieten",
"reactions": "Emoji-reacties",
"reactionsDesc": "Eén emoji per gast per foto (❤️ 😂 😍 👏 🎉)",
"favoritesDesc": "Foto's als favoriet markeren",
"identityMode": "Identiteitsmodus",
"identityModeSimple": "Eenvoudige feedback",
@@ -2551,7 +2563,8 @@
"rating": "Beoordelingen",
"like": "Likes",
"comment": "Opmerkingen",
"favorite": "Favorieten"
"favorite": "Favorieten",
"reaction": "Reacties"
},
"allStatuses": "Alle statussen",
"status": {
+14 -1
View File
@@ -1713,6 +1713,7 @@
"favorites": "Favoritos",
"comments": "Comentários",
"ratings": "Avaliações",
"reactions": "Reações",
"lastSeen": "Última visita"
},
"view": "Ver detalhes",
@@ -1726,6 +1727,7 @@
"liked": "Gostou",
"favorited": "Favorito",
"rated": "Avaliado",
"reacted": "Reagiu",
"commented": "Comentado"
},
"inviteStatus": {
@@ -1781,7 +1783,9 @@
"event_renamed": "Evento renomeado: {{eventName}}",
"photo_rating": "Foto avaliada em {{eventName}}",
"photo_comment": "Foto comentada em {{eventName}}",
"photo_reaction": "Reação a uma foto em {{eventName}}",
"guest_feedback_like": "Convidado curtiu uma foto em {{eventName}}",
"guest_feedback_reaction": "Convidado reagiu a uma foto em {{eventName}}",
"guest_feedback_favorite": "Convidado adicionou uma foto aos favoritos em {{eventName}}",
"guest_feedback_rating": "Convidado avaliou uma foto em {{eventName}}",
"guest_feedback_comment": "Convidado comentou uma foto em {{eventName}}",
@@ -2510,6 +2514,12 @@
"patternDescription": "As senhas são geradas usando o nome e data do evento. Ex: \"Evento2024$Agosto\" para melhor memorização."
},
"feedback": {
"reaction": "reação",
"reactions": "Reações",
"reactWith": "Reagir com {{emoji}}",
"removeReaction": "Remover a reação {{emoji}}",
"reactionError": "Não foi possível atualizar a reação",
"totalReactions": "Total de reações",
"comments": "Comentários",
"addComment": "Adicionar Comentário",
"yourName": "Seu nome",
@@ -2538,6 +2548,8 @@
"comments": "Comentários",
"commentsDesc": "Comentários em texto nas fotos",
"favorites": "Favoritos",
"reactions": "Reações com emojis",
"reactionsDesc": "Um emoji por convidado por foto (❤️ 😂 😍 👏 🎉)",
"favoritesDesc": "Marcar fotos como favoritas",
"identityMode": "Modo de identidade",
"identityModeSimple": "Feedback simples",
@@ -2576,7 +2588,8 @@
"rating": "Avaliações",
"like": "Gostos",
"comment": "Comentários",
"favorite": "Favoritos"
"favorite": "Favoritos",
"reaction": "Reações"
},
"allStatuses": "Todos os estados",
"status": {
+14 -1
View File
@@ -1738,6 +1738,7 @@
"favorites": "Избранное",
"comments": "Комментарии",
"ratings": "Оценки",
"reactions": "Реакции",
"lastSeen": "Последний визит"
},
"view": "Подробнее",
@@ -1751,6 +1752,7 @@
"liked": "Понравилось",
"favorited": "В избранном",
"rated": "Оценено",
"reacted": "Отреагировал",
"commented": "Прокомментировано"
},
"inviteStatus": {
@@ -1806,7 +1808,9 @@
"event_renamed": "Событие переименовано: {{eventName}}",
"photo_rating": "Фото оценено в {{eventName}}",
"photo_comment": "Фото прокомментировано в {{eventName}}",
"photo_reaction": "Реакция на фото в {{eventName}}",
"guest_feedback_like": "Гость поставил лайк фото в {{eventName}}",
"guest_feedback_reaction": "Гость отреагировал на фото в {{eventName}}",
"guest_feedback_favorite": "Гость добавил фото в избранное в {{eventName}}",
"guest_feedback_rating": "Гость оценил фото в {{eventName}}",
"guest_feedback_comment": "Гость прокомментировал фото в {{eventName}}",
@@ -2535,6 +2539,12 @@
"patternDescription": "Пароли генерируются на основе названия и даты события. Пример: «Место2024$Август» для лучшей запоминаемости и безопасности."
},
"feedback": {
"reaction": "реакция",
"reactions": "Реакции",
"reactWith": "Отреагировать {{emoji}}",
"removeReaction": "Убрать реакцию {{emoji}}",
"reactionError": "Не удалось обновить реакцию",
"totalReactions": "Всего реакций",
"comments": "Комментарии",
"addComment": "Добавить комментарий",
"yourName": "Ваше имя",
@@ -2563,6 +2573,8 @@
"comments": "Комментарии",
"commentsDesc": "Текстовые комментарии к фото",
"favorites": "Избранное",
"reactions": "Эмодзи-реакции",
"reactionsDesc": "Один эмодзи на гостя для каждого фото (❤️ 😂 😍 👏 🎉)",
"favoritesDesc": "Отмечать фото как избранные",
"identityMode": "Режим идентификации",
"identityModeSimple": "Простые отзывы",
@@ -2601,7 +2613,8 @@
"rating": "Оценки",
"like": "Лайки",
"comment": "Комментарии",
"favorite": "Избранное"
"favorite": "Избранное",
"reaction": "Реакции"
},
"allStatuses": "Все статусы",
"status": {
+14 -1
View File
@@ -1688,6 +1688,7 @@
"favorites": "Priljubljene",
"comments": "Komentarji",
"ratings": "Ocene",
"reactions": "Odzivi",
"lastSeen": "Nazadnje viden"
},
"view": "Ogled podrobnosti",
@@ -1701,6 +1702,7 @@
"liked": "Všečkano",
"favorited": "Dodano med priljubljene",
"rated": "Ocenjeno",
"reacted": "Odzval se",
"commented": "Komentirano"
},
"inviteStatus": {
@@ -1733,7 +1735,9 @@
"photo_favorite": "Fotografija dodana med priljubljene v {{eventName}}",
"photo_rating": "Fotografija ocenjena v {{eventName}}",
"photo_comment": "Fotografija komentirana v {{eventName}}",
"photo_reaction": "Odziv na fotografijo v {{eventName}}",
"guest_feedback_like": "Gost je všečkal fotografijo v {{eventName}}",
"guest_feedback_reaction": "Gost se je odzval na fotografijo v {{eventName}}",
"guest_feedback_favorite": "Gost je dodal fotografijo med priljubljene v {{eventName}}",
"guest_feedback_rating": "Gost je ocenil fotografijo v {{eventName}}",
"guest_feedback_comment": "Gost je komentiral fotografijo v {{eventName}}",
@@ -2485,6 +2489,12 @@
"patternDescription": "Gesla se ustvarijo z uporabo imena in datuma dogodka. Primer: »Lokacija2024$Avgust« za boljšo varnost in lažje pomnjenje."
},
"feedback": {
"reaction": "odziv",
"reactions": "Odzivi",
"reactWith": "Odzovi se z {{emoji}}",
"removeReaction": "Odstrani odziv {{emoji}}",
"reactionError": "Odziva ni bilo mogoče posodobiti",
"totalReactions": "Skupaj odzivov",
"comments": "Komentarji",
"addComment": "Dodaj komentar",
"yourName": "Vaše ime",
@@ -2513,6 +2523,8 @@
"comments": "Komentarji",
"commentsDesc": "Besedilni komentarji na fotografijah",
"favorites": "Priljubljene",
"reactions": "Emoji odzivi",
"reactionsDesc": "En emoji na gosta na fotografijo (❤️ 😂 😍 👏 🎉)",
"favoritesDesc": "Označi fotografije kot priljubljene",
"identityMode": "Način identitete",
"identityModeSimple": "Preproste povratne informacije",
@@ -2551,7 +2563,8 @@
"rating": "Ocene",
"like": "Všečki",
"comment": "Komentarji",
"favorite": "Priljubljene"
"favorite": "Priljubljene",
"reaction": "Odzivi"
},
"allStatuses": "Vsa stanja",
"status": {
@@ -64,6 +64,7 @@ interface FormData {
allow_likes: boolean;
allow_comments: boolean;
allow_favorites: boolean;
allow_reactions: boolean;
require_name_email: boolean;
moderate_comments: boolean;
show_feedback_to_guests: boolean;
@@ -132,6 +133,7 @@ export const CreateEventPage: React.FC = () => {
allow_likes: true,
allow_comments: true,
allow_favorites: true,
allow_reactions: true,
require_name_email: false,
moderate_comments: true,
show_feedback_to_guests: true,
@@ -482,6 +484,7 @@ export const CreateEventPage: React.FC = () => {
allow_likes: feedbackSettings.allow_likes,
allow_comments: feedbackSettings.allow_comments,
allow_favorites: feedbackSettings.allow_favorites,
allow_reactions: feedbackSettings.allow_reactions,
require_name_email: feedbackSettings.require_name_email,
moderate_comments: feedbackSettings.moderate_comments,
show_feedback_to_guests: feedbackSettings.show_feedback_to_guests,
@@ -45,6 +45,7 @@ export const EventDetailsPage: React.FC = () => {
allow_likes: true,
allow_comments: true,
allow_favorites: true,
allow_reactions: true,
require_name_email: false,
moderate_comments: true,
show_feedback_to_guests: true,
+17 -1
View File
@@ -6,6 +6,7 @@ import {
MessageSquare,
Star,
Heart,
Smile,
TrendingUp,
Filter,
Download,
@@ -249,6 +250,7 @@ export const EventFeedbackPage: React.FC = () => {
<option value="like">{t('feedback.types.like', 'Likes')}</option>
<option value="comment">{t('feedback.types.comment', 'Comments')}</option>
<option value="favorite">{t('feedback.types.favorite', 'Favorites')}</option>
<option value="reaction">{t('feedback.types.reaction', 'Reactions')}</option>
</select>
<select
value={feedbackFilter.status}
@@ -293,6 +295,9 @@ export const EventFeedbackPage: React.FC = () => {
{item.feedback_type === 'rating' && <Star className="w-4 h-4 text-yellow-500" />}
{item.feedback_type === 'like' && <Heart className="w-4 h-4 text-red-500" />}
{item.feedback_type === 'comment' && <MessageSquare className="w-4 h-4 text-blue-500" />}
{item.feedback_type === 'reaction' && item.reaction && (
<span className="text-base leading-none">{item.reaction}</span>
)}
<span className="font-medium text-sm">
{item.guest_name || t('feedback.anonymous', 'Anonymous')}
</span>
@@ -422,7 +427,7 @@ export const EventFeedbackPage: React.FC = () => {
) : analytics ? (
<>
{/* Summary Stats */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<Card>
<div className="p-6">
<div className="flex items-center gap-3 mb-2">
@@ -448,6 +453,17 @@ export const EventFeedbackPage: React.FC = () => {
</div>
</div>
</Card>
<Card>
<div className="p-6">
<div className="flex items-center gap-3 mb-2">
<Smile className="w-8 h-8 text-amber-500" />
<div>
<p className="text-2xl font-bold">{analytics.summary.total_reactions || 0}</p>
<p className="text-sm text-neutral-600">{t('feedback.totalReactions', 'Total Reactions')}</p>
</div>
</div>
</div>
</Card>
<Card>
<div className="p-6">
<div className="flex items-center gap-3 mb-2">
+2
View File
@@ -116,10 +116,12 @@ export type ActivityType =
| "photo_favorite"
| "photo_rating"
| "photo_comment"
| "photo_reaction"
| "guest_feedback_like"
| "guest_feedback_favorite"
| "guest_feedback_rating"
| "guest_feedback_comment"
| "guest_feedback_reaction"
| "word_filter_added"
| "external_import_completed"
| "bulk_archive_completed"
+1
View File
@@ -36,6 +36,7 @@ interface CreateEventData {
allow_likes?: boolean;
allow_comments?: boolean;
allow_favorites?: boolean;
allow_reactions?: boolean;
require_name_email?: boolean;
moderate_comments?: boolean;
show_feedback_to_guests?: boolean;
+15 -2
View File
@@ -2,12 +2,18 @@ import { api } from '../config/api';
export type IdentityMode = 'simple' | 'guest';
// Emoji reactions (#839): the fixed curated set. Mirrored in
// backend/src/constants/reactions.js — update both together.
export const REACTION_EMOJIS = ['❤️', '😂', '😍', '👏', '🎉'] as const;
export type ReactionEmoji = (typeof REACTION_EMOJIS)[number];
export interface FeedbackSettings {
feedback_enabled: boolean;
allow_ratings: boolean;
allow_likes: boolean;
allow_comments: boolean;
allow_favorites: boolean;
allow_reactions: boolean;
require_name_email: boolean;
moderate_comments: boolean;
show_feedback_to_guests: boolean;
@@ -28,10 +34,11 @@ export interface PhotoFeedback {
id: number;
photo_id: number;
event_id: number;
feedback_type: 'rating' | 'like' | 'comment' | 'favorite';
feedback_type: 'rating' | 'like' | 'comment' | 'favorite' | 'reaction';
rating?: number;
comment_text?: string;
comment?: string;
reaction?: string;
guest_name?: string;
guest_email?: string;
is_approved: boolean;
@@ -49,6 +56,7 @@ export interface FeedbackSummary {
total_ratings: number;
like_count: number;
favorite_count: number;
reaction_count?: number;
comment_count: number;
}
@@ -56,11 +64,14 @@ export interface MyFeedback {
rating?: number;
liked: boolean;
favorited: boolean;
reaction?: string | null;
}
export interface FeedbackResponse {
feedback: PhotoFeedback[];
summary: FeedbackSummary;
/** Per-emoji tallies for the reaction bar (#839), e.g. { '❤️': 3 }. */
reactions?: Record<string, number>;
my_feedback: MyFeedback;
pagination?: {
page: number;
@@ -77,6 +88,7 @@ export interface FeedbackAnalytics {
total_likes: number;
total_comments: number;
total_favorites: number;
total_reactions?: number;
pending_moderation: number;
};
topRated: Array<{
@@ -198,9 +210,10 @@ class FeedbackService {
}
async submitFeedback(slug: string, photoId: string, feedback: {
feedback_type: 'rating' | 'like' | 'comment' | 'favorite';
feedback_type: 'rating' | 'like' | 'comment' | 'favorite' | 'reaction';
rating?: number;
comment_text?: string;
reaction?: string;
guest_name?: string;
guest_email?: string;
}) {
+2
View File
@@ -17,6 +17,7 @@ export interface AdminGuestStats {
favorites: number;
comments: number;
ratings: number;
reactions: number;
distinct_photos: number;
}
@@ -45,6 +46,7 @@ export interface AdminGuestSelections {
favorited: AdminGuestPhoto[];
rated: Array<{ photo: AdminGuestPhoto; rating: number }>;
commented: Array<{ photo: AdminGuestPhoto; comment: string; created_at: string }>;
reacted: Array<{ photo: AdminGuestPhoto; reaction: string }>;
}
export interface AdminGuestDetail {