* 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:
co-authored by
Paul Nothaft
parent
f8a95d29d2
commit
3d6c9848dc
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user