* 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
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* Emoji reactions (#839) — pins the contract of the `reaction` feedback type:
|
||||||
|
* - only emojis from the fixed curated set are accepted
|
||||||
|
* - one reaction per guest per photo: same emoji again toggles OFF,
|
||||||
|
* a different emoji SWITCHES the existing row (never a second row)
|
||||||
|
* - per-guest scoping mirrors likes: guest_id when present, else the
|
||||||
|
* device-hash guest_identifier — two token-guests on one device react
|
||||||
|
* independently
|
||||||
|
* - denormalized photos.reaction_count and the per-emoji tallies follow
|
||||||
|
* visibility: hidden-by-moderator reactions disappear from both
|
||||||
|
* - the long and pivoted exports carry the reaction
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
process.env.TEST_DATABASE_PATH = path.join(
|
||||||
|
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-feedback-reactions-')), 'db.sqlite',
|
||||||
|
);
|
||||||
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'feedback-reactions-test-secret';
|
||||||
|
|
||||||
|
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||||
|
|
||||||
|
const feedbackService = require('../../src/services/feedbackService');
|
||||||
|
const { REACTION_EMOJIS } = require('../../src/constants/reactions');
|
||||||
|
|
||||||
|
const EVENT_SLUG = 'reactions-test-event';
|
||||||
|
const GUEST_A = 'guest-a-identifier';
|
||||||
|
const GUEST_B = 'guest-b-identifier';
|
||||||
|
|
||||||
|
let db;
|
||||||
|
let cleanup;
|
||||||
|
let eventId;
|
||||||
|
let photoIds;
|
||||||
|
|
||||||
|
async function react(photoId, emoji, { guestIdentifier = GUEST_A, guestId = null } = {}) {
|
||||||
|
return feedbackService.submitFeedback(photoId, eventId, {
|
||||||
|
feedback_type: 'reaction',
|
||||||
|
reaction: emoji,
|
||||||
|
guest_id: guestId,
|
||||||
|
ip_address: '127.0.0.1',
|
||||||
|
user_agent: 'jest',
|
||||||
|
}, guestIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reactionCountOf(photoId) {
|
||||||
|
const row = await db('photos').where('id', photoId).first();
|
||||||
|
return Number(row.reaction_count) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ db, cleanup } = await bootCrmDb());
|
||||||
|
await seedMinimal(db);
|
||||||
|
const inserted = await db('events').insert({
|
||||||
|
slug: EVENT_SLUG,
|
||||||
|
event_type: 'wedding',
|
||||||
|
event_name: 'Reactions Test',
|
||||||
|
event_date: '2026-07-20',
|
||||||
|
host_email: '[email protected]',
|
||||||
|
admin_email: '[email protected]',
|
||||||
|
password_hash: 'x',
|
||||||
|
share_link: `/gallery/${EVENT_SLUG}/share`,
|
||||||
|
share_token: 'reactions-test-share',
|
||||||
|
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||||
|
is_active: 1,
|
||||||
|
is_archived: 0,
|
||||||
|
is_draft: 0,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
}).returning('id');
|
||||||
|
eventId = inserted[0]?.id ?? inserted[0];
|
||||||
|
|
||||||
|
photoIds = [];
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const photo = await db('photos').insert({
|
||||||
|
event_id: eventId,
|
||||||
|
filename: `photo-${i}.jpg`,
|
||||||
|
path: `events/reactions/${i}.jpg`,
|
||||||
|
type: 'individual',
|
||||||
|
uploaded_at: new Date().toISOString(),
|
||||||
|
}).returning('id');
|
||||||
|
photoIds.push(photo[0]?.id ?? photo[0]);
|
||||||
|
}
|
||||||
|
}, 120000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (cleanup) await cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reaction submission (#839)', () => {
|
||||||
|
it('rejects emojis outside the curated set', async () => {
|
||||||
|
await expect(react(photoIds[0], '🦄')).rejects.toThrow('Invalid reaction');
|
||||||
|
await expect(react(photoIds[0], undefined)).rejects.toThrow('Invalid reaction');
|
||||||
|
expect(await reactionCountOf(photoIds[0])).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a reaction row and maintains the denormalized count', async () => {
|
||||||
|
const result = await react(photoIds[0], '❤️');
|
||||||
|
expect(result.created).toBe(true);
|
||||||
|
|
||||||
|
const row = await db('photo_feedback')
|
||||||
|
.where({ photo_id: photoIds[0], feedback_type: 'reaction' })
|
||||||
|
.first();
|
||||||
|
expect(row.reaction).toBe('❤️');
|
||||||
|
expect(await reactionCountOf(photoIds[0])).toBe(1);
|
||||||
|
expect(await feedbackService.getPhotoReactionCounts(photoIds[0])).toEqual({ '❤️': 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches to another emoji in place — never a second row per guest', async () => {
|
||||||
|
const result = await react(photoIds[0], '🎉');
|
||||||
|
expect(result.updated).toBe(true);
|
||||||
|
|
||||||
|
const rows = await db('photo_feedback')
|
||||||
|
.where({ photo_id: photoIds[0], feedback_type: 'reaction' });
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].reaction).toBe('🎉');
|
||||||
|
expect(await feedbackService.getPhotoReactionCounts(photoIds[0])).toEqual({ '🎉': 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tallies different guests per emoji', async () => {
|
||||||
|
await react(photoIds[0], '🎉', { guestIdentifier: GUEST_B });
|
||||||
|
expect(await feedbackService.getPhotoReactionCounts(photoIds[0])).toEqual({ '🎉': 2 });
|
||||||
|
expect(await reactionCountOf(photoIds[0])).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles off with the same emoji', async () => {
|
||||||
|
const result = await react(photoIds[0], '🎉');
|
||||||
|
expect(result.removed).toBe(true);
|
||||||
|
expect(await feedbackService.getPhotoReactionCounts(photoIds[0])).toEqual({ '🎉': 1 }); // GUEST_B remains
|
||||||
|
expect(await reactionCountOf(photoIds[0])).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scopes per guest_id when present — two token-guests on one device stay independent', async () => {
|
||||||
|
const first = await react(photoIds[1], '😍', { guestIdentifier: GUEST_A, guestId: 101 });
|
||||||
|
const second = await react(photoIds[1], '👏', { guestIdentifier: GUEST_A, guestId: 102 });
|
||||||
|
expect(first.created).toBe(true);
|
||||||
|
expect(second.created).toBe(true); // NOT treated as guest 101's switch
|
||||||
|
expect(await feedbackService.getPhotoReactionCounts(photoIds[1])).toEqual({ '😍': 1, '👏': 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts every emoji of the curated set', async () => {
|
||||||
|
for (const emoji of REACTION_EMOJIS) {
|
||||||
|
const res = await react(photoIds[2], emoji, { guestIdentifier: `guest-${emoji}` });
|
||||||
|
expect(res.created).toBe(true);
|
||||||
|
}
|
||||||
|
const counts = await feedbackService.getPhotoReactionCounts(photoIds[2]);
|
||||||
|
expect(Object.keys(counts)).toHaveLength(REACTION_EMOJIS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hidden reactions leave both the per-emoji tallies and reaction_count', async () => {
|
||||||
|
const row = await db('photo_feedback')
|
||||||
|
.where({ photo_id: photoIds[0], feedback_type: 'reaction' })
|
||||||
|
.first();
|
||||||
|
await feedbackService.moderateFeedback(row.id, 'hide', 1);
|
||||||
|
|
||||||
|
expect(await feedbackService.getPhotoReactionCounts(photoIds[0])).toEqual({});
|
||||||
|
expect(await reactionCountOf(photoIds[0])).toBe(0);
|
||||||
|
|
||||||
|
await feedbackService.moderateFeedback(row.id, 'approve', 1);
|
||||||
|
expect(await reactionCountOf(photoIds[0])).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggle and switch collapse racy duplicate rows for the same guest', async () => {
|
||||||
|
// Simulate the check-then-insert race: two rows for one guest+photo.
|
||||||
|
const mk = (emoji) => ({
|
||||||
|
photo_id: photoIds[1], event_id: eventId, feedback_type: 'reaction',
|
||||||
|
reaction: emoji, guest_identifier: 'dup-guest', is_approved: true, is_hidden: false,
|
||||||
|
created_at: new Date(), updated_at: new Date(),
|
||||||
|
});
|
||||||
|
await db('photo_feedback').insert([mk('❤️'), mk('❤️')]);
|
||||||
|
|
||||||
|
// Switching converges to exactly ONE row with the new emoji…
|
||||||
|
const switched = await react(photoIds[1], '🎉', { guestIdentifier: 'dup-guest' });
|
||||||
|
expect(switched.updated).toBe(true);
|
||||||
|
let rows = await db('photo_feedback')
|
||||||
|
.where({ photo_id: photoIds[1], feedback_type: 'reaction', guest_identifier: 'dup-guest' });
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].reaction).toBe('🎉');
|
||||||
|
|
||||||
|
// …and toggle-off removes the full guest-scoped set.
|
||||||
|
await db('photo_feedback').insert(mk('🎉'));
|
||||||
|
const removed = await react(photoIds[1], '🎉', { guestIdentifier: 'dup-guest' });
|
||||||
|
expect(removed.removed).toBe(true);
|
||||||
|
rows = await db('photo_feedback')
|
||||||
|
.where({ photo_id: photoIds[1], feedback_type: 'reaction', guest_identifier: 'dup-guest' });
|
||||||
|
expect(rows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('summary and exports carry reactions', async () => {
|
||||||
|
const summary = await feedbackService.getEventFeedbackSummary(eventId);
|
||||||
|
expect(Number(summary.stats.total_reactions)).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const longRows = await feedbackService.exportEventFeedback(eventId);
|
||||||
|
const longReaction = longRows.find((r) => r.feedback_type === 'reaction');
|
||||||
|
expect(longReaction.reaction).toBeTruthy();
|
||||||
|
|
||||||
|
const pivotRows = await feedbackService.exportEventFeedbackPivoted(eventId);
|
||||||
|
const pivotWithReaction = pivotRows.find((r) => r.reaction);
|
||||||
|
expect(REACTION_EMOJIS).toContain(pivotWithReaction.reaction);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Emoji reactions (#839): the fixed, curated reaction set. Guests pick ONE
|
||||||
|
* of these per photo (changeable). Kept as a shared constant so the
|
||||||
|
* validator, the service and the export layer can never drift apart.
|
||||||
|
*
|
||||||
|
* Mirrored in frontend/src/services/feedback.service.ts (REACTION_EMOJIS) —
|
||||||
|
* update both together.
|
||||||
|
*/
|
||||||
|
const REACTION_EMOJIS = ['❤️', '😂', '😍', '👏', '🎉'];
|
||||||
|
|
||||||
|
module.exports = { REACTION_EMOJIS };
|
||||||
@@ -34,20 +34,26 @@ async function getRateLimitSettings() {
|
|||||||
.where('setting_key', 'feedback_rate_limits')
|
.where('setting_key', 'feedback_rate_limits')
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (settings && settings.setting_value) {
|
// Defaults FIRST, stored values override: persisted rows predate newer
|
||||||
// setting_value is already a JSON object in PostgreSQL
|
// action types (`reaction`, #839) — returning the stored object alone
|
||||||
return typeof settings.setting_value === 'string'
|
// would silently drop their intended defaults to the generic 100/h.
|
||||||
? JSON.parse(settings.setting_value)
|
const defaults = {
|
||||||
: settings.setting_value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default settings
|
|
||||||
return {
|
|
||||||
rating: { max: 100, window: 3600 }, // 100 ratings per hour
|
rating: { max: 100, window: 3600 }, // 100 ratings per hour
|
||||||
comment: { max: 20, window: 3600 }, // 20 comments per hour
|
comment: { max: 20, window: 3600 }, // 20 comments per hour
|
||||||
like: { max: 200, window: 3600 }, // 200 likes per hour
|
like: { max: 200, window: 3600 }, // 200 likes per hour
|
||||||
favorite: { max: 100, window: 3600 } // 100 favorites per hour
|
favorite: { max: 100, window: 3600 }, // 100 favorites per hour
|
||||||
|
reaction: { max: 200, window: 3600 } // reactions churn like likes (#839)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (settings && settings.setting_value) {
|
||||||
|
// setting_value is already a JSON object in PostgreSQL
|
||||||
|
const stored = typeof settings.setting_value === 'string'
|
||||||
|
? JSON.parse(settings.setting_value)
|
||||||
|
: settings.setting_value;
|
||||||
|
return { ...defaults, ...stored };
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaults;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error getting rate limit settings:', error);
|
logger.error('Error getting rate limit settings:', error);
|
||||||
// Return defaults on error
|
// Return defaults on error
|
||||||
@@ -55,7 +61,8 @@ async function getRateLimitSettings() {
|
|||||||
rating: { max: 100, window: 3600 },
|
rating: { max: 100, window: 3600 },
|
||||||
comment: { max: 20, window: 3600 },
|
comment: { max: 20, window: 3600 },
|
||||||
like: { max: 200, window: 3600 },
|
like: { max: 200, window: 3600 },
|
||||||
favorite: { max: 100, window: 3600 }
|
favorite: { max: 100, window: 3600 },
|
||||||
|
reaction: { max: 200, window: 3600 }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ module.exports = (router) => {
|
|||||||
allow_likes = true,
|
allow_likes = true,
|
||||||
allow_comments = true,
|
allow_comments = true,
|
||||||
allow_favorites = true,
|
allow_favorites = true,
|
||||||
|
allow_reactions = true,
|
||||||
require_name_email = false,
|
require_name_email = false,
|
||||||
moderate_comments = true,
|
moderate_comments = true,
|
||||||
show_feedback_to_guests = true,
|
show_feedback_to_guests = true,
|
||||||
@@ -490,6 +491,7 @@ module.exports = (router) => {
|
|||||||
allow_likes: formatBoolean(allow_likes),
|
allow_likes: formatBoolean(allow_likes),
|
||||||
allow_comments: formatBoolean(allow_comments),
|
allow_comments: formatBoolean(allow_comments),
|
||||||
allow_favorites: formatBoolean(allow_favorites),
|
allow_favorites: formatBoolean(allow_favorites),
|
||||||
|
allow_reactions: formatBoolean(allow_reactions),
|
||||||
require_name_email: formatBoolean(require_name_email),
|
require_name_email: formatBoolean(require_name_email),
|
||||||
moderate_comments: formatBoolean(moderate_comments),
|
moderate_comments: formatBoolean(moderate_comments),
|
||||||
show_feedback_to_guests: formatBoolean(show_feedback_to_guests),
|
show_feedback_to_guests: formatBoolean(show_feedback_to_guests),
|
||||||
@@ -1111,6 +1113,7 @@ module.exports = (router) => {
|
|||||||
allow_likes: sourceFeedback.allow_likes,
|
allow_likes: sourceFeedback.allow_likes,
|
||||||
allow_comments: sourceFeedback.allow_comments,
|
allow_comments: sourceFeedback.allow_comments,
|
||||||
allow_favorites: sourceFeedback.allow_favorites,
|
allow_favorites: sourceFeedback.allow_favorites,
|
||||||
|
allow_reactions: sourceFeedback.allow_reactions,
|
||||||
require_name_email: sourceFeedback.require_name_email,
|
require_name_email: sourceFeedback.require_name_email,
|
||||||
moderate_comments: sourceFeedback.moderate_comments,
|
moderate_comments: sourceFeedback.moderate_comments,
|
||||||
show_feedback_to_guests: sourceFeedback.show_feedback_to_guests,
|
show_feedback_to_guests: sourceFeedback.show_feedback_to_guests,
|
||||||
|
|||||||
@@ -233,17 +233,22 @@ router.get('/events/:eventId/feedback-analytics',
|
|||||||
.count('* as count')
|
.count('* as count')
|
||||||
.first();
|
.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 = {
|
const summary = {
|
||||||
average_rating: parseFloat(avgRatingResult?.average_rating || 0),
|
average_rating: parseFloat(avgRatingResult?.average_rating || 0),
|
||||||
total_ratings: summaryData.stats?.total_ratings || 0,
|
...counts,
|
||||||
total_likes: summaryData.stats?.total_likes || 0,
|
pending_moderation: Number(pendingModeration?.count) || 0,
|
||||||
total_comments: summaryData.stats?.total_comments || 0,
|
total_feedback: counts.total_ratings + counts.total_likes +
|
||||||
total_favorites: summaryData.stats?.total_favorites || 0,
|
counts.total_comments + counts.total_favorites +
|
||||||
pending_moderation: pendingModeration?.count || 0,
|
counts.total_reactions
|
||||||
total_feedback: (summaryData.stats?.total_ratings || 0) +
|
|
||||||
(summaryData.stats?.total_likes || 0) +
|
|
||||||
(summaryData.stats?.total_comments || 0) +
|
|
||||||
(summaryData.stats?.total_favorites || 0)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get top-rated photos
|
// 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 = \'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 = \'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 = \'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')
|
db.raw('COUNT(DISTINCT photo_feedback.photo_id) AS distinct_photos')
|
||||||
)
|
)
|
||||||
.orderBy('gallery_guests.created_at', 'desc');
|
.orderBy('gallery_guests.created_at', 'desc');
|
||||||
@@ -89,6 +90,7 @@ router.get(
|
|||||||
favorites: parseInt(r.favorites, 10) || 0,
|
favorites: parseInt(r.favorites, 10) || 0,
|
||||||
comments: parseInt(r.comments, 10) || 0,
|
comments: parseInt(r.comments, 10) || 0,
|
||||||
ratings: parseInt(r.ratings, 10) || 0,
|
ratings: parseInt(r.ratings, 10) || 0,
|
||||||
|
reactions: parseInt(r.reactions, 10) || 0,
|
||||||
distinct_photos: parseInt(r.distinct_photos, 10) || 0,
|
distinct_photos: parseInt(r.distinct_photos, 10) || 0,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -401,6 +403,7 @@ router.get(
|
|||||||
'photo_feedback.feedback_type',
|
'photo_feedback.feedback_type',
|
||||||
'photo_feedback.rating',
|
'photo_feedback.rating',
|
||||||
'photo_feedback.comment_text',
|
'photo_feedback.comment_text',
|
||||||
|
'photo_feedback.reaction',
|
||||||
'photo_feedback.created_at',
|
'photo_feedback.created_at',
|
||||||
'photos.id as photo_id',
|
'photos.id as photo_id',
|
||||||
'photos.filename',
|
'photos.filename',
|
||||||
@@ -423,6 +426,7 @@ router.get(
|
|||||||
favorited: [],
|
favorited: [],
|
||||||
rated: [],
|
rated: [],
|
||||||
commented: [],
|
commented: [],
|
||||||
|
reacted: [],
|
||||||
};
|
};
|
||||||
for (const row of feedback) {
|
for (const row of feedback) {
|
||||||
if (row.feedback_type === 'like') {
|
if (row.feedback_type === 'like') {
|
||||||
@@ -437,6 +441,8 @@ router.get(
|
|||||||
comment: row.comment_text,
|
comment: row.comment_text,
|
||||||
created_at: row.created_at,
|
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,
|
favorites: selections.favorited.length,
|
||||||
comments: selections.commented.length,
|
comments: selections.commented.length,
|
||||||
ratings: selections.rated.length,
|
ratings: selections.rated.length,
|
||||||
|
reactions: selections.reacted.length,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
selections,
|
selections,
|
||||||
|
|||||||
@@ -1956,6 +1956,7 @@ router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) =>
|
|||||||
allow_likes: settings.allow_likes,
|
allow_likes: settings.allow_likes,
|
||||||
allow_comments: settings.allow_comments,
|
allow_comments: settings.allow_comments,
|
||||||
allow_favorites: settings.allow_favorites,
|
allow_favorites: settings.allow_favorites,
|
||||||
|
allow_reactions: settings.allow_reactions,
|
||||||
show_feedback_to_guests: settings.show_feedback_to_guests,
|
show_feedback_to_guests: settings.show_feedback_to_guests,
|
||||||
require_name_email: settings.require_name_email || false,
|
require_name_email: settings.require_name_email || false,
|
||||||
identity_mode: settings.identity_mode || 'simple'
|
identity_mode: settings.identity_mode || 'simple'
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ router.get('/:slug/feedback-settings',
|
|||||||
allow_likes: Boolean(settings.allow_likes),
|
allow_likes: Boolean(settings.allow_likes),
|
||||||
allow_comments: Boolean(settings.allow_comments),
|
allow_comments: Boolean(settings.allow_comments),
|
||||||
allow_favorites: Boolean(settings.allow_favorites),
|
allow_favorites: Boolean(settings.allow_favorites),
|
||||||
|
allow_reactions: Boolean(settings.allow_reactions),
|
||||||
require_name_email: Boolean(settings.require_name_email),
|
require_name_email: Boolean(settings.require_name_email),
|
||||||
show_feedback_to_guests: Boolean(settings.show_feedback_to_guests),
|
show_feedback_to_guests: Boolean(settings.show_feedback_to_guests),
|
||||||
identity_mode: settings.identity_mode || 'simple',
|
identity_mode: settings.identity_mode || 'simple',
|
||||||
@@ -117,6 +118,9 @@ router.get('/:slug/photos/:photoId/feedback',
|
|||||||
.then(r => r.count),
|
.then(r => r.count),
|
||||||
like_count: photo.like_count || 0,
|
like_count: photo.like_count || 0,
|
||||||
favorite_count: photo.favorite_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')
|
comment_count: await db('photo_feedback')
|
||||||
.where({
|
.where({
|
||||||
photo_id: photoId,
|
photo_id: photoId,
|
||||||
@@ -128,10 +132,17 @@ router.get('/:slug/photos/:photoId/feedback',
|
|||||||
.first()
|
.first()
|
||||||
.then(r => r.count)
|
.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: {
|
my_feedback: {
|
||||||
rating: guestFeedback.find(f => f.feedback_type === 'rating')?.rating,
|
rating: guestFeedback.find(f => f.feedback_type === 'rating')?.rating,
|
||||||
liked: !!guestFeedback.find(f => f.feedback_type === 'like'),
|
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) {
|
} catch (error) {
|
||||||
@@ -181,7 +192,8 @@ router.post('/:slug/photos/:photoId/feedback',
|
|||||||
rating: settings.allow_ratings,
|
rating: settings.allow_ratings,
|
||||||
like: settings.allow_likes,
|
like: settings.allow_likes,
|
||||||
comment: settings.allow_comments,
|
comment: settings.allow_comments,
|
||||||
favorite: settings.allow_favorites
|
favorite: settings.allow_favorites,
|
||||||
|
reaction: settings.allow_reactions
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!typeAllowed[feedbackType]) {
|
if (!typeAllowed[feedbackType]) {
|
||||||
@@ -227,6 +239,7 @@ router.post('/:slug/photos/:photoId/feedback',
|
|||||||
feedback_type: feedbackType,
|
feedback_type: feedbackType,
|
||||||
rating: req.body.rating,
|
rating: req.body.rating,
|
||||||
comment_text: req.body.comment_text,
|
comment_text: req.body.comment_text,
|
||||||
|
reaction: req.body.reaction,
|
||||||
guest_name: req.guest?.name ?? req.body.guest_name,
|
guest_name: req.guest?.name ?? req.body.guest_name,
|
||||||
guest_email: req.guest?.email ?? req.body.guest_email,
|
guest_email: req.guest?.email ?? req.body.guest_email,
|
||||||
guest_id: req.guest?.id ?? null,
|
guest_id: req.guest?.id ?? null,
|
||||||
@@ -346,7 +359,8 @@ router.get('/:slug/feedback-summary',
|
|||||||
allow_ratings: settings.allow_ratings,
|
allow_ratings: settings.allow_ratings,
|
||||||
allow_likes: settings.allow_likes,
|
allow_likes: settings.allow_likes,
|
||||||
allow_comments: settings.allow_comments,
|
allow_comments: settings.allow_comments,
|
||||||
allow_favorites: settings.allow_favorites
|
allow_favorites: settings.allow_favorites,
|
||||||
|
allow_reactions: settings.allow_reactions
|
||||||
},
|
},
|
||||||
summary: guestSummary
|
summary: guestSummary
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
const { REACTION_EMOJIS } = require('../constants/reactions');
|
||||||
|
|
||||||
class FeedbackService {
|
class FeedbackService {
|
||||||
/**
|
/**
|
||||||
@@ -21,6 +22,7 @@ class FeedbackService {
|
|||||||
allow_likes: true,
|
allow_likes: true,
|
||||||
allow_comments: false,
|
allow_comments: false,
|
||||||
allow_favorites: true,
|
allow_favorites: true,
|
||||||
|
allow_reactions: true,
|
||||||
require_name_email: false,
|
require_name_email: false,
|
||||||
moderate_comments: true,
|
moderate_comments: true,
|
||||||
show_feedback_to_guests: true,
|
show_feedback_to_guests: true,
|
||||||
@@ -102,13 +104,19 @@ class FeedbackService {
|
|||||||
|
|
||||||
async submitFeedback(photoId, eventId, feedbackData, guestIdentifier) {
|
async submitFeedback(photoId, eventId, feedbackData, guestIdentifier) {
|
||||||
try {
|
try {
|
||||||
const { feedback_type, rating, comment_text, guest_name, guest_email, ip_address, user_agent, guest_id } = feedbackData;
|
const { feedback_type, rating, comment_text, reaction, guest_name, guest_email, ip_address, user_agent, guest_id } = feedbackData;
|
||||||
|
|
||||||
// Validate feedback type
|
// Validate feedback type
|
||||||
if (!['rating', 'like', 'comment', 'favorite'].includes(feedback_type)) {
|
if (!['rating', 'like', 'comment', 'favorite', 'reaction'].includes(feedback_type)) {
|
||||||
throw new Error('Invalid feedback type');
|
throw new Error('Invalid feedback type');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reactions come from a fixed curated set — anything else is rejected
|
||||||
|
// (the route validator enforces this too; this is the last line).
|
||||||
|
if (feedback_type === 'reaction' && !REACTION_EMOJIS.includes(reaction)) {
|
||||||
|
throw new Error('Invalid reaction');
|
||||||
|
}
|
||||||
|
|
||||||
// Check if similar feedback already exists (prevent duplicates).
|
// Check if similar feedback already exists (prevent duplicates).
|
||||||
// When a per-person guest_id is present, scope the check to that guest
|
// When a per-person guest_id is present, scope the check to that guest
|
||||||
// so two guests on the same device can independently like a photo.
|
// so two guests on the same device can independently like a photo.
|
||||||
@@ -140,6 +148,41 @@ class FeedbackService {
|
|||||||
return { id: existing.id, updated: true };
|
return { id: existing.id, updated: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One reaction per guest per photo, changeable (#839): the same
|
||||||
|
// emoji again toggles it off; a different one switches the row.
|
||||||
|
// Toggle/switch act on the full guest-scoped QUERY, not existing.id:
|
||||||
|
// like the sibling like path, the check-then-insert above can race
|
||||||
|
// into duplicate rows — operating on the set makes the next
|
||||||
|
// interaction collapse them instead of leaving a phantom count.
|
||||||
|
const reactionScope = () => {
|
||||||
|
const q = db('photo_feedback').where({
|
||||||
|
photo_id: photoId,
|
||||||
|
event_id: eventId,
|
||||||
|
feedback_type: 'reaction',
|
||||||
|
});
|
||||||
|
if (guest_id) q.where('guest_id', guest_id);
|
||||||
|
else q.where('guest_identifier', guestIdentifier);
|
||||||
|
return q;
|
||||||
|
};
|
||||||
|
if (feedback_type === 'reaction') {
|
||||||
|
if (existing.reaction === reaction) {
|
||||||
|
await reactionScope().delete();
|
||||||
|
await this.updatePhotoFeedbackStats(photoId);
|
||||||
|
return { removed: true };
|
||||||
|
}
|
||||||
|
// Converge to exactly one row: drop any racy duplicates, then
|
||||||
|
// switch the surviving row's emoji.
|
||||||
|
await reactionScope().whereNot('id', existing.id).delete();
|
||||||
|
await db('photo_feedback')
|
||||||
|
.where('id', existing.id)
|
||||||
|
.update({
|
||||||
|
reaction,
|
||||||
|
updated_at: new Date()
|
||||||
|
});
|
||||||
|
await this.updatePhotoFeedbackStats(photoId);
|
||||||
|
return { id: existing.id, updated: true };
|
||||||
|
}
|
||||||
|
|
||||||
// For likes and favorites, toggle off if already exists.
|
// For likes and favorites, toggle off if already exists.
|
||||||
// Toggle-off always allowed — the cap below is on adds only, so a
|
// Toggle-off always allowed — the cap below is on adds only, so a
|
||||||
// guest at the limit can still free a slot by un-favoriting (#655).
|
// guest at the limit can still free a slot by un-favoriting (#655).
|
||||||
@@ -189,6 +232,7 @@ class FeedbackService {
|
|||||||
feedback_type,
|
feedback_type,
|
||||||
rating: feedback_type === 'rating' ? rating : null,
|
rating: feedback_type === 'rating' ? rating : null,
|
||||||
comment_text: feedback_type === 'comment' ? comment_text : null,
|
comment_text: feedback_type === 'comment' ? comment_text : null,
|
||||||
|
reaction: feedback_type === 'reaction' ? reaction : null,
|
||||||
guest_name,
|
guest_name,
|
||||||
guest_email,
|
guest_email,
|
||||||
guest_identifier: guestIdentifier,
|
guest_identifier: guestIdentifier,
|
||||||
@@ -241,7 +285,7 @@ class FeedbackService {
|
|||||||
|
|
||||||
const feedback = await query
|
const feedback = await query
|
||||||
.orderBy('created_at', 'desc')
|
.orderBy('created_at', 'desc')
|
||||||
.select('id', 'feedback_type', 'rating', 'comment_text', 'guest_name', 'created_at', 'is_approved', 'is_hidden');
|
.select('id', 'feedback_type', 'rating', 'comment_text', 'reaction', 'guest_name', 'created_at', 'is_approved', 'is_hidden');
|
||||||
|
|
||||||
return feedback;
|
return feedback;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -257,7 +301,7 @@ class FeedbackService {
|
|||||||
try {
|
try {
|
||||||
const photos = await db('photos')
|
const photos = await db('photos')
|
||||||
.where('event_id', eventId)
|
.where('event_id', eventId)
|
||||||
.select('id', 'filename', 'feedback_count', 'like_count', 'average_rating', 'favorite_count')
|
.select('id', 'filename', 'feedback_count', 'like_count', 'average_rating', 'favorite_count', 'reaction_count')
|
||||||
.orderBy('average_rating', 'desc')
|
.orderBy('average_rating', 'desc')
|
||||||
.orderBy('like_count', 'desc');
|
.orderBy('like_count', 'desc');
|
||||||
|
|
||||||
@@ -268,7 +312,8 @@ class FeedbackService {
|
|||||||
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_ratings', ['rating']),
|
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_ratings', ['rating']),
|
||||||
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_likes', ['like']),
|
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_likes', ['like']),
|
||||||
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_comments', ['comment']),
|
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_comments', ['comment']),
|
||||||
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_favorites', ['favorite'])
|
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_favorites', ['favorite']),
|
||||||
|
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_reactions', ['reaction'])
|
||||||
)
|
)
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
@@ -282,6 +327,31 @@ class FeedbackService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-emoji reaction counts for one photo (#839): { '❤️': 3, '👏': 1 }.
|
||||||
|
* Only visible rows count — hidden-by-moderator reactions disappear from
|
||||||
|
* the tallies the same way hidden likes leave like_count.
|
||||||
|
*/
|
||||||
|
async getPhotoReactionCounts(photoId) {
|
||||||
|
try {
|
||||||
|
const rows = await db('photo_feedback')
|
||||||
|
.where({ photo_id: photoId, feedback_type: 'reaction' })
|
||||||
|
.where('is_hidden', false)
|
||||||
|
.groupBy('reaction')
|
||||||
|
.select('reaction')
|
||||||
|
.count('id as count');
|
||||||
|
|
||||||
|
const counts = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.reaction) counts[row.reaction] = Number(row.count) || 0;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting photo reaction counts:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update photo feedback statistics
|
* Update photo feedback statistics
|
||||||
*/
|
*/
|
||||||
@@ -295,6 +365,7 @@ class FeedbackService {
|
|||||||
db.raw('COUNT(CASE WHEN feedback_type = ? AND is_approved = ? THEN 1 END) as comment_count', ['comment', formatBoolean(true)]),
|
db.raw('COUNT(CASE WHEN feedback_type = ? AND is_approved = ? THEN 1 END) as comment_count', ['comment', formatBoolean(true)]),
|
||||||
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as like_count', ['like']),
|
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as like_count', ['like']),
|
||||||
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as favorite_count', ['favorite']),
|
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as favorite_count', ['favorite']),
|
||||||
|
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as reaction_count', ['reaction']),
|
||||||
db.raw('AVG(CASE WHEN feedback_type = ? THEN rating END) as average_rating', ['rating']),
|
db.raw('AVG(CASE WHEN feedback_type = ? THEN rating END) as average_rating', ['rating']),
|
||||||
db.raw('COUNT(DISTINCT COALESCE(CAST(guest_id AS VARCHAR), guest_identifier)) as feedback_count')
|
db.raw('COUNT(DISTINCT COALESCE(CAST(guest_id AS VARCHAR), guest_identifier)) as feedback_count')
|
||||||
)
|
)
|
||||||
@@ -307,7 +378,8 @@ class FeedbackService {
|
|||||||
feedback_count: stats.feedback_count || 0,
|
feedback_count: stats.feedback_count || 0,
|
||||||
like_count: stats.like_count || 0,
|
like_count: stats.like_count || 0,
|
||||||
average_rating: stats.average_rating || 0,
|
average_rating: stats.average_rating || 0,
|
||||||
favorite_count: stats.favorite_count || 0
|
favorite_count: stats.favorite_count || 0,
|
||||||
|
reaction_count: stats.reaction_count || 0
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error updating photo feedback stats:', error);
|
logger.error('Error updating photo feedback stats:', error);
|
||||||
@@ -443,6 +515,7 @@ class FeedbackService {
|
|||||||
'photo_feedback.feedback_type',
|
'photo_feedback.feedback_type',
|
||||||
'photo_feedback.rating',
|
'photo_feedback.rating',
|
||||||
'photo_feedback.comment_text',
|
'photo_feedback.comment_text',
|
||||||
|
'photo_feedback.reaction',
|
||||||
'photo_feedback.guest_name',
|
'photo_feedback.guest_name',
|
||||||
'photo_feedback.guest_email',
|
'photo_feedback.guest_email',
|
||||||
'photo_feedback.created_at'
|
'photo_feedback.created_at'
|
||||||
@@ -480,6 +553,7 @@ class FeedbackService {
|
|||||||
'photo_feedback.feedback_type',
|
'photo_feedback.feedback_type',
|
||||||
'photo_feedback.rating',
|
'photo_feedback.rating',
|
||||||
'photo_feedback.comment_text',
|
'photo_feedback.comment_text',
|
||||||
|
'photo_feedback.reaction',
|
||||||
'photo_feedback.guest_name',
|
'photo_feedback.guest_name',
|
||||||
'photo_feedback.guest_email',
|
'photo_feedback.guest_email',
|
||||||
'photo_feedback.guest_identifier',
|
'photo_feedback.guest_identifier',
|
||||||
@@ -505,6 +579,7 @@ class FeedbackService {
|
|||||||
is_liked: false,
|
is_liked: false,
|
||||||
star_rating: '',
|
star_rating: '',
|
||||||
comment: '',
|
comment: '',
|
||||||
|
reaction: '',
|
||||||
latest_at: row.created_at,
|
latest_at: row.created_at,
|
||||||
};
|
};
|
||||||
byKey.set(key, entry);
|
byKey.set(key, entry);
|
||||||
@@ -531,6 +606,9 @@ class FeedbackService {
|
|||||||
entry.comment = row.comment_text;
|
entry.comment = row.comment_text;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'reaction':
|
||||||
|
if (row.reaction) entry.reaction = row.reaction;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
// Unknown feedback type — ignore so a future type doesn't break the export.
|
// Unknown feedback type — ignore so a future type doesn't break the export.
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const { body, param, validationResult } = require('express-validator');
|
const { body, param, validationResult } = require('express-validator');
|
||||||
const validator = require('validator');
|
const validator = require('validator');
|
||||||
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('./emailNormalization');
|
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('./emailNormalization');
|
||||||
|
const { REACTION_EMOJIS } = require('../constants/reactions');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validation rules for feedback submission
|
* Validation rules for feedback submission
|
||||||
@@ -133,7 +134,7 @@ function getValidationRules(feedbackType) {
|
|||||||
*/
|
*/
|
||||||
const validateFeedbackSubmission = [
|
const validateFeedbackSubmission = [
|
||||||
body('feedback_type')
|
body('feedback_type')
|
||||||
.isIn(['rating', 'like', 'comment', 'favorite'])
|
.isIn(['rating', 'like', 'comment', 'favorite', 'reaction'])
|
||||||
.withMessage('Invalid feedback type'),
|
.withMessage('Invalid feedback type'),
|
||||||
|
|
||||||
// Conditional validation based on feedback type
|
// Conditional validation based on feedback type
|
||||||
@@ -142,6 +143,12 @@ const validateFeedbackSubmission = [
|
|||||||
.isInt({ min: 1, max: 5 })
|
.isInt({ min: 1, max: 5 })
|
||||||
.withMessage('Rating must be between 1 and 5'),
|
.withMessage('Rating must be between 1 and 5'),
|
||||||
|
|
||||||
|
// Reactions (#839): fixed curated set only — no free-form emoji.
|
||||||
|
body('reaction')
|
||||||
|
.if(body('feedback_type').equals('reaction'))
|
||||||
|
.custom((value) => REACTION_EMOJIS.includes(value))
|
||||||
|
.withMessage('Invalid reaction'),
|
||||||
|
|
||||||
body('comment_text')
|
body('comment_text')
|
||||||
.if(body('feedback_type').equals('comment'))
|
.if(body('feedback_type').equals('comment'))
|
||||||
.trim()
|
.trim()
|
||||||
@@ -183,6 +190,7 @@ const validateFeedbackSettings = [
|
|||||||
body('allow_likes').optional().isBoolean(),
|
body('allow_likes').optional().isBoolean(),
|
||||||
body('allow_comments').optional().isBoolean(),
|
body('allow_comments').optional().isBoolean(),
|
||||||
body('allow_favorites').optional().isBoolean(),
|
body('allow_favorites').optional().isBoolean(),
|
||||||
|
body('allow_reactions').optional().isBoolean(),
|
||||||
body('require_name_email').optional().isBoolean(),
|
body('require_name_email').optional().isBoolean(),
|
||||||
body('moderate_comments').optional().isBoolean(),
|
body('moderate_comments').optional().isBoolean(),
|
||||||
body('show_feedback_to_guests').optional().isBoolean(),
|
body('show_feedback_to_guests').optional().isBoolean(),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { Loading } from '../common';
|
||||||
import { guestsService, AdminGuest } from '../../services/guests.service';
|
import { guestsService, AdminGuest } from '../../services/guests.service';
|
||||||
import { AuthenticatedImage } from '../common/AuthenticatedImage';
|
import { AuthenticatedImage } from '../common/AuthenticatedImage';
|
||||||
@@ -14,7 +14,7 @@ interface AdminGuestDetailProps {
|
|||||||
onClose: () => void;
|
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 }) => {
|
export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, guest, onClose }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -31,6 +31,7 @@ export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, gue
|
|||||||
const favorited = selections?.favorited || [];
|
const favorited = selections?.favorited || [];
|
||||||
const rated = selections?.rated || [];
|
const rated = selections?.rated || [];
|
||||||
const commented = selections?.commented || [];
|
const commented = selections?.commented || [];
|
||||||
|
const reacted = selections?.reacted || [];
|
||||||
|
|
||||||
// "all" view combines the three visual selection types.
|
// "all" view combines the three visual selection types.
|
||||||
type GridItem = { photo: { id: number; filename: string; thumbnail_url: string }; badges: string[] };
|
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'));
|
liked.forEach((p) => add(p, 'like'));
|
||||||
favorited.forEach((p) => add(p, 'favorite'));
|
favorited.forEach((p) => add(p, 'favorite'));
|
||||||
rated.forEach((r) => add(r.photo, 'rating'));
|
rated.forEach((r) => add(r.photo, 'rating'));
|
||||||
|
reacted.forEach((r) => add(r.photo, r.reaction));
|
||||||
|
|
||||||
const visibleItems: GridItem[] =
|
const visibleItems: GridItem[] =
|
||||||
tab === 'all'
|
tab === 'all'
|
||||||
@@ -58,6 +60,8 @@ export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, gue
|
|||||||
? favorited.map((p) => ({ photo: p, badges: ['favorite'] }))
|
? favorited.map((p) => ({ photo: p, badges: ['favorite'] }))
|
||||||
: tab === 'rated'
|
: tab === 'rated'
|
||||||
? rated.map((r) => ({ photo: r.photo, badges: [`${r.rating}★`] }))
|
? rated.map((r) => ({ photo: r.photo, badges: [`${r.rating}★`] }))
|
||||||
|
: tab === 'reacted'
|
||||||
|
? reacted.map((r) => ({ photo: r.photo, badges: [r.reaction] }))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -87,7 +91,7 @@ export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, gue
|
|||||||
) : (
|
) : (
|
||||||
<div className="overflow-y-auto p-4">
|
<div className="overflow-y-auto p-4">
|
||||||
{/* Stats */}
|
{/* 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="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">
|
<div className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
{liked.length}
|
{liked.length}
|
||||||
@@ -124,11 +128,20 @@ export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, gue
|
|||||||
{t('admin.guests.columns.comments', 'Comments')}
|
{t('admin.guests.columns.comments', 'Comments')}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-1 border-b border-neutral-200 dark:border-neutral-700 mb-4">
|
<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
|
<button
|
||||||
key={k}
|
key={k}
|
||||||
type="button"
|
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">
|
<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')}
|
{t('admin.guests.columns.ratings', 'Ratings')}
|
||||||
</th>
|
</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">
|
<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')}
|
{t('admin.guests.columns.lastSeen', 'Last seen')}
|
||||||
</th>
|
</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">
|
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
|
||||||
{guest.stats.ratings}
|
{guest.stats.ratings}
|
||||||
</td>
|
</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">
|
<td className="px-4 py-3 text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
{fmtDate(guest.last_seen_at)}
|
{fmtDate(guest.last_seen_at)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
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 { Card } from '../common';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ interface FeedbackSettings {
|
|||||||
allow_likes: boolean;
|
allow_likes: boolean;
|
||||||
allow_comments: boolean;
|
allow_comments: boolean;
|
||||||
allow_favorites: boolean;
|
allow_favorites: boolean;
|
||||||
|
allow_reactions: boolean;
|
||||||
require_name_email: boolean;
|
require_name_email: boolean;
|
||||||
moderate_comments: boolean;
|
moderate_comments: boolean;
|
||||||
show_feedback_to_guests: boolean;
|
show_feedback_to_guests: boolean;
|
||||||
@@ -219,6 +220,24 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -721,6 +721,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
allowFavorites: !!feedbackSettings?.allow_favorites,
|
allowFavorites: !!feedbackSettings?.allow_favorites,
|
||||||
allowRatings: !!feedbackSettings?.allow_ratings,
|
allowRatings: !!feedbackSettings?.allow_ratings,
|
||||||
allowComments: !!feedbackSettings?.allow_comments,
|
allowComments: !!feedbackSettings?.allow_comments,
|
||||||
|
allowReactions: !!feedbackSettings?.allow_reactions,
|
||||||
requireNameEmail: !!feedbackSettings?.require_name_email,
|
requireNameEmail: !!feedbackSettings?.require_name_email,
|
||||||
}}
|
}}
|
||||||
isSelectionMode={isSelectionMode}
|
isSelectionMode={isSelectionMode}
|
||||||
@@ -967,6 +968,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
allowFavorites: !!feedbackSettings?.allow_favorites,
|
allowFavorites: !!feedbackSettings?.allow_favorites,
|
||||||
allowRatings: !!feedbackSettings?.allow_ratings,
|
allowRatings: !!feedbackSettings?.allow_ratings,
|
||||||
allowComments: !!feedbackSettings?.allow_comments,
|
allowComments: !!feedbackSettings?.allow_comments,
|
||||||
|
allowReactions: !!feedbackSettings?.allow_reactions,
|
||||||
requireNameEmail: !!feedbackSettings?.require_name_email,
|
requireNameEmail: !!feedbackSettings?.require_name_email,
|
||||||
}}
|
}}
|
||||||
isSelectionMode={isSelectionMode}
|
isSelectionMode={isSelectionMode}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { feedbackService } from '../../services/feedback.service';
|
|||||||
import { PhotoRating } from './PhotoRating';
|
import { PhotoRating } from './PhotoRating';
|
||||||
import { PhotoLikes } from './PhotoLikes';
|
import { PhotoLikes } from './PhotoLikes';
|
||||||
import { PhotoFavorites } from './PhotoFavorites';
|
import { PhotoFavorites } from './PhotoFavorites';
|
||||||
|
import { PhotoReactions } from './PhotoReactions';
|
||||||
import { PhotoComments } from './PhotoComments';
|
import { PhotoComments } from './PhotoComments';
|
||||||
import { Skeleton } from '../common';
|
import { Skeleton } from '../common';
|
||||||
|
|
||||||
@@ -45,6 +46,8 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
|||||||
const [likeCount, setLikeCount] = useState(0);
|
const [likeCount, setLikeCount] = useState(0);
|
||||||
const [isFavorited, setIsFavorited] = useState(false);
|
const [isFavorited, setIsFavorited] = useState(false);
|
||||||
const [favoriteCount, setFavoriteCount] = useState(0);
|
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
|
// Update local state when data loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -54,6 +57,8 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
|||||||
setLikeCount(Number(feedbackData.summary.like_count) || 0);
|
setLikeCount(Number(feedbackData.summary.like_count) || 0);
|
||||||
setIsFavorited(Boolean(feedbackData.my_feedback.favorited));
|
setIsFavorited(Boolean(feedbackData.my_feedback.favorited));
|
||||||
setFavoriteCount(Number(feedbackData.summary.favorite_count) || 0);
|
setFavoriteCount(Number(feedbackData.summary.favorite_count) || 0);
|
||||||
|
setMyReaction(feedbackData.my_feedback.reaction || null);
|
||||||
|
setReactionCounts(feedbackData.reactions || {});
|
||||||
}
|
}
|
||||||
}, [feedbackData]);
|
}, [feedbackData]);
|
||||||
|
|
||||||
@@ -75,6 +80,18 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
|||||||
if (onFeedbackUpdate) onFeedbackUpdate();
|
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) {
|
if (settingsLoading) {
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-3 ${className}`}>
|
<div className={`space-y-3 ${className}`}>
|
||||||
@@ -89,7 +106,8 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasAnyFeedbackType = settings.allow_ratings || settings.allow_likes ||
|
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) {
|
if (!hasAnyFeedbackType) {
|
||||||
return null;
|
return null;
|
||||||
@@ -140,6 +158,19 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
|||||||
</div>
|
</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 */}
|
{/* Comments Section */}
|
||||||
{settings.allow_comments && showComments && (
|
{settings.allow_comments && showComments && (
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
allow_likes?: boolean;
|
allow_likes?: boolean;
|
||||||
allow_ratings?: boolean;
|
allow_ratings?: boolean;
|
||||||
allow_comments?: boolean;
|
allow_comments?: boolean;
|
||||||
|
allow_reactions?: boolean;
|
||||||
show_feedback_to_guests?: boolean;
|
show_feedback_to_guests?: boolean;
|
||||||
require_name_email?: boolean;
|
require_name_email?: boolean;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
@@ -750,12 +751,11 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Feedback button with indicator. Gated on allow_comments
|
{/* Feedback button with indicator. Likes/ratings have their
|
||||||
because likes/ratings already have their own dedicated
|
own dedicated toolbar buttons above, so this panel toggle
|
||||||
toolbar buttons above — this MessageSquare button only
|
only has work to do when comments (#518) or the emoji
|
||||||
opens the comments panel, so it has nothing to do when
|
reaction bar (#839) live inside the panel. */}
|
||||||
comments are off (#518). */}
|
{feedbackEnabled && (feedbackSettings?.allow_comments || feedbackSettings?.allow_reactions) && (
|
||||||
{feedbackEnabled && feedbackSettings?.allow_comments && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowFeedback(!showFeedback);
|
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;
|
allowFavorites?: boolean;
|
||||||
allowRatings?: boolean;
|
allowRatings?: boolean;
|
||||||
allowComments?: boolean;
|
allowComments?: boolean;
|
||||||
|
allowReactions?: boolean;
|
||||||
requireNameEmail?: boolean;
|
requireNameEmail?: boolean;
|
||||||
};
|
};
|
||||||
// Logout callback for full-page layouts
|
// Logout callback for full-page layouts
|
||||||
|
|||||||
@@ -408,3 +408,17 @@
|
|||||||
.gallery-premium-animate-in {
|
.gallery-premium-animate-in {
|
||||||
animation: galleryPremiumFadeIn 0.4s ease forwards;
|
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 React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { MasonryPhotoAlbum } from 'react-photo-album';
|
import { MasonryPhotoAlbum } from 'react-photo-album';
|
||||||
import 'react-photo-album/masonry.css';
|
import 'react-photo-album/masonry.css';
|
||||||
import Lightbox from 'yet-another-react-lightbox';
|
import Lightbox from 'yet-another-react-lightbox';
|
||||||
@@ -19,6 +20,7 @@ import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
|||||||
import type { Photo } from '../../../types';
|
import type { Photo } from '../../../types';
|
||||||
import { AuthenticatedImage } from '../../common';
|
import { AuthenticatedImage } from '../../common';
|
||||||
import { feedbackService } from '../../../services/feedback.service';
|
import { feedbackService } from '../../../services/feedback.service';
|
||||||
|
import { PhotoReactions } from '../PhotoReactions';
|
||||||
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
|
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
|
||||||
import { FeedbackIdentityModal } from '../FeedbackIdentityModal';
|
import { FeedbackIdentityModal } from '../FeedbackIdentityModal';
|
||||||
import { galleryService } from '../../../services/gallery.service';
|
import { galleryService } from '../../../services/gallery.service';
|
||||||
@@ -208,6 +210,14 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
|||||||
likedSeededRef.current = true;
|
likedSeededRef.current = true;
|
||||||
}, [photos]);
|
}, [photos]);
|
||||||
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
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 guestIdentity = useGuestIdentityOptional();
|
||||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||||
const [pendingLikePhotoId, setPendingLikePhotoId] = useState<number | null>(null);
|
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);
|
return photos.filter(photo => photo.category_name === activeCategory);
|
||||||
}, [photos, 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
|
// Get hero photo
|
||||||
const heroPhoto = heroPhotoOverride || photos[0];
|
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 */}
|
{/* Identity Modal */}
|
||||||
<FeedbackIdentityModal
|
<FeedbackIdentityModal
|
||||||
isOpen={showIdentityModal}
|
isOpen={showIdentityModal}
|
||||||
|
|||||||
@@ -2728,6 +2728,7 @@
|
|||||||
"favorites": "Favoriten",
|
"favorites": "Favoriten",
|
||||||
"comments": "Kommentare",
|
"comments": "Kommentare",
|
||||||
"ratings": "Bewertungen",
|
"ratings": "Bewertungen",
|
||||||
|
"reactions": "Reaktionen",
|
||||||
"lastSeen": "Zuletzt gesehen"
|
"lastSeen": "Zuletzt gesehen"
|
||||||
},
|
},
|
||||||
"view": "Details anzeigen",
|
"view": "Details anzeigen",
|
||||||
@@ -2741,6 +2742,7 @@
|
|||||||
"liked": "Gemocht",
|
"liked": "Gemocht",
|
||||||
"favorited": "Favorisiert",
|
"favorited": "Favorisiert",
|
||||||
"rated": "Bewertet",
|
"rated": "Bewertet",
|
||||||
|
"reacted": "Reagiert",
|
||||||
"commented": "Kommentiert"
|
"commented": "Kommentiert"
|
||||||
},
|
},
|
||||||
"inviteStatus": {
|
"inviteStatus": {
|
||||||
@@ -2796,7 +2798,9 @@
|
|||||||
"event_renamed": "Ereignis umbenannt: {{eventName}}",
|
"event_renamed": "Ereignis umbenannt: {{eventName}}",
|
||||||
"photo_rating": "Foto bewertet in {{eventName}}",
|
"photo_rating": "Foto bewertet in {{eventName}}",
|
||||||
"photo_comment": "Foto kommentiert 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_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_favorite": "Gast hat ein Foto als Favorit markiert in {{eventName}}",
|
||||||
"guest_feedback_rating": "Gast hat ein Foto bewertet in {{eventName}}",
|
"guest_feedback_rating": "Gast hat ein Foto bewertet in {{eventName}}",
|
||||||
"guest_feedback_comment": "Gast hat ein Foto kommentiert 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."
|
"patternDescription": "Passwörter werden mit Ihrem Veranstaltungsnamen und -datum generiert. Beispiel: \"Location2024$August\" für bessere Sicherheit und Merkbarkeit."
|
||||||
},
|
},
|
||||||
"feedback": {
|
"feedback": {
|
||||||
|
"reaction": "Reaktion",
|
||||||
|
"reactions": "Reaktionen",
|
||||||
|
"reactWith": "Mit {{emoji}} reagieren",
|
||||||
|
"removeReaction": "{{emoji}}-Reaktion entfernen",
|
||||||
|
"reactionError": "Reaktion konnte nicht aktualisiert werden",
|
||||||
|
"totalReactions": "Reaktionen gesamt",
|
||||||
"comments": "Kommentare",
|
"comments": "Kommentare",
|
||||||
"addComment": "Kommentar hinzufügen",
|
"addComment": "Kommentar hinzufügen",
|
||||||
"yourName": "Ihr Name",
|
"yourName": "Ihr Name",
|
||||||
@@ -3365,6 +3375,8 @@
|
|||||||
"comments": "Kommentare",
|
"comments": "Kommentare",
|
||||||
"commentsDesc": "Textkommentare auf Fotos",
|
"commentsDesc": "Textkommentare auf Fotos",
|
||||||
"favorites": "Favoriten",
|
"favorites": "Favoriten",
|
||||||
|
"reactions": "Emoji-Reaktionen",
|
||||||
|
"reactionsDesc": "Ein Emoji pro Gast und Foto (❤️ 😂 😍 👏 🎉)",
|
||||||
"favoritesDesc": "Fotos als Favoriten markieren",
|
"favoritesDesc": "Fotos als Favoriten markieren",
|
||||||
"perGuestLimits": "Limits pro Gast",
|
"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.",
|
"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",
|
"rating": "Bewertungen",
|
||||||
"like": "Likes",
|
"like": "Likes",
|
||||||
"comment": "Kommentare",
|
"comment": "Kommentare",
|
||||||
"favorite": "Favoriten"
|
"favorite": "Favoriten",
|
||||||
|
"reaction": "Reaktionen"
|
||||||
},
|
},
|
||||||
"allStatuses": "Alle Status",
|
"allStatuses": "Alle Status",
|
||||||
"status": {
|
"status": {
|
||||||
|
|||||||
@@ -2306,6 +2306,7 @@
|
|||||||
"favorites": "Favorites",
|
"favorites": "Favorites",
|
||||||
"comments": "Comments",
|
"comments": "Comments",
|
||||||
"ratings": "Ratings",
|
"ratings": "Ratings",
|
||||||
|
"reactions": "Reactions",
|
||||||
"lastSeen": "Last seen"
|
"lastSeen": "Last seen"
|
||||||
},
|
},
|
||||||
"view": "View details",
|
"view": "View details",
|
||||||
@@ -2319,6 +2320,7 @@
|
|||||||
"liked": "Liked",
|
"liked": "Liked",
|
||||||
"favorited": "Favorited",
|
"favorited": "Favorited",
|
||||||
"rated": "Rated",
|
"rated": "Rated",
|
||||||
|
"reacted": "Reacted",
|
||||||
"commented": "Commented"
|
"commented": "Commented"
|
||||||
},
|
},
|
||||||
"inviteStatus": {
|
"inviteStatus": {
|
||||||
@@ -2351,7 +2353,9 @@
|
|||||||
"photo_favorite": "Photo favorited in {{eventName}}",
|
"photo_favorite": "Photo favorited in {{eventName}}",
|
||||||
"photo_rating": "Photo rated in {{eventName}}",
|
"photo_rating": "Photo rated in {{eventName}}",
|
||||||
"photo_comment": "Photo commented 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_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_favorite": "Guest favorited a photo in {{eventName}}",
|
||||||
"guest_feedback_rating": "Guest rated a photo in {{eventName}}",
|
"guest_feedback_rating": "Guest rated a photo in {{eventName}}",
|
||||||
"guest_feedback_comment": "Guest commented on 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."
|
"patternDescription": "Passwords are generated using your event name and date. Example: \"Venue2024$August\" for better security and memorability."
|
||||||
},
|
},
|
||||||
"feedback": {
|
"feedback": {
|
||||||
|
"reaction": "reaction",
|
||||||
|
"reactions": "Reactions",
|
||||||
|
"reactWith": "React with {{emoji}}",
|
||||||
|
"removeReaction": "Remove {{emoji}} reaction",
|
||||||
|
"reactionError": "Failed to update reaction",
|
||||||
|
"totalReactions": "Total Reactions",
|
||||||
"comments": "Comments",
|
"comments": "Comments",
|
||||||
"addComment": "Add Comment",
|
"addComment": "Add Comment",
|
||||||
"yourName": "Your name",
|
"yourName": "Your name",
|
||||||
@@ -3386,6 +3396,8 @@
|
|||||||
"comments": "Comments",
|
"comments": "Comments",
|
||||||
"commentsDesc": "Text comments on photos",
|
"commentsDesc": "Text comments on photos",
|
||||||
"favorites": "Favorites",
|
"favorites": "Favorites",
|
||||||
|
"reactions": "Emoji Reactions",
|
||||||
|
"reactionsDesc": "One emoji per guest per photo (❤️ 😂 😍 👏 🎉)",
|
||||||
"favoritesDesc": "Mark photos as favorites",
|
"favoritesDesc": "Mark photos as favorites",
|
||||||
"perGuestLimits": "Per-guest limits",
|
"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.",
|
"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",
|
"rating": "Ratings",
|
||||||
"like": "Likes",
|
"like": "Likes",
|
||||||
"comment": "Comments",
|
"comment": "Comments",
|
||||||
"favorite": "Favorites"
|
"favorite": "Favorites",
|
||||||
|
"reaction": "Reactions"
|
||||||
},
|
},
|
||||||
"allStatuses": "All Statuses",
|
"allStatuses": "All Statuses",
|
||||||
"status": {
|
"status": {
|
||||||
|
|||||||
@@ -1492,7 +1492,9 @@
|
|||||||
"photo_favorite": "Foto añadida a favoritos en {{eventName}}",
|
"photo_favorite": "Foto añadida a favoritos en {{eventName}}",
|
||||||
"photo_rating": "Foto valorada en {{eventName}}",
|
"photo_rating": "Foto valorada en {{eventName}}",
|
||||||
"photo_comment": "Comentario en foto 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_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_favorite": "Invitado añadió a favoritos en {{eventName}}",
|
||||||
"guest_feedback_rating": "Invitado valoró una foto en {{eventName}}",
|
"guest_feedback_rating": "Invitado valoró una foto en {{eventName}}",
|
||||||
"guest_feedback_comment": "Invitado comentó en 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."
|
"patternDescription": "Las contraseñas se generan usando el nombre y la fecha del evento. Ejemplo: \"Venue2024$August\" para mayor seguridad y memorización."
|
||||||
},
|
},
|
||||||
"feedback": {
|
"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",
|
"comments": "Comentarios",
|
||||||
"addComment": "Añadir comentario",
|
"addComment": "Añadir comentario",
|
||||||
"yourName": "Tu nombre",
|
"yourName": "Tu nombre",
|
||||||
@@ -2387,6 +2395,8 @@
|
|||||||
"comments": "Comentarios",
|
"comments": "Comentarios",
|
||||||
"commentsDesc": "Comentarios de texto en fotos",
|
"commentsDesc": "Comentarios de texto en fotos",
|
||||||
"favorites": "Favoritos",
|
"favorites": "Favoritos",
|
||||||
|
"reactions": "Reacciones con emojis",
|
||||||
|
"reactionsDesc": "Un emoji por invitado y foto (❤️ 😂 😍 👏 🎉)",
|
||||||
"favoritesDesc": "Marcar fotos como favoritas",
|
"favoritesDesc": "Marcar fotos como favoritas",
|
||||||
"identityMode": "Modo de identidad",
|
"identityMode": "Modo de identidad",
|
||||||
"identityModeSimple": "Feedback simple",
|
"identityModeSimple": "Feedback simple",
|
||||||
|
|||||||
@@ -1699,6 +1699,7 @@
|
|||||||
"favorites": "Favoris",
|
"favorites": "Favoris",
|
||||||
"comments": "Commentaires",
|
"comments": "Commentaires",
|
||||||
"ratings": "Notes",
|
"ratings": "Notes",
|
||||||
|
"reactions": "Réactions",
|
||||||
"lastSeen": "Dernière visite"
|
"lastSeen": "Dernière visite"
|
||||||
},
|
},
|
||||||
"view": "Voir les détails",
|
"view": "Voir les détails",
|
||||||
@@ -1712,6 +1713,7 @@
|
|||||||
"liked": "Aimés",
|
"liked": "Aimés",
|
||||||
"favorited": "Favoris",
|
"favorited": "Favoris",
|
||||||
"rated": "Notés",
|
"rated": "Notés",
|
||||||
|
"reacted": "A réagi",
|
||||||
"commented": "Commentés"
|
"commented": "Commentés"
|
||||||
},
|
},
|
||||||
"inviteStatus": {
|
"inviteStatus": {
|
||||||
@@ -1744,7 +1746,9 @@
|
|||||||
"photo_favorite": "Photo marquée comme favori dans {{eventName}}",
|
"photo_favorite": "Photo marquée comme favori dans {{eventName}}",
|
||||||
"photo_rating": "Photo notée dans {{eventName}}",
|
"photo_rating": "Photo notée dans {{eventName}}",
|
||||||
"photo_comment": "Commentaire ajouté sur une photo 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_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_favorite": "Un invité a marqué une photo comme favori dans {{eventName}}",
|
||||||
"guest_feedback_rating": "Un invité a noté une photo dans {{eventName}}",
|
"guest_feedback_rating": "Un invité a noté une photo dans {{eventName}}",
|
||||||
"guest_feedback_comment": "Un invité a commenté 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."
|
"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": {
|
"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",
|
"comments": "Commentaires",
|
||||||
"addComment": "Ajouter un commentaire",
|
"addComment": "Ajouter un commentaire",
|
||||||
"yourName": "Votre nom",
|
"yourName": "Votre nom",
|
||||||
@@ -2524,6 +2534,8 @@
|
|||||||
"comments": "Commentaires",
|
"comments": "Commentaires",
|
||||||
"commentsDesc": "Commentaires textuels sur les photos",
|
"commentsDesc": "Commentaires textuels sur les photos",
|
||||||
"favorites": "Favoris",
|
"favorites": "Favoris",
|
||||||
|
"reactions": "Réactions emoji",
|
||||||
|
"reactionsDesc": "Un emoji par invité et par photo (❤️ 😂 😍 👏 🎉)",
|
||||||
"favoritesDesc": "Marquer les photos comme favoris",
|
"favoritesDesc": "Marquer les photos comme favoris",
|
||||||
"identityMode": "Mode d'identité",
|
"identityMode": "Mode d'identité",
|
||||||
"identityModeSimple": "Commentaires simples",
|
"identityModeSimple": "Commentaires simples",
|
||||||
@@ -2562,7 +2574,8 @@
|
|||||||
"rating": "Notes",
|
"rating": "Notes",
|
||||||
"like": "J'aimes",
|
"like": "J'aimes",
|
||||||
"comment": "Commentaires",
|
"comment": "Commentaires",
|
||||||
"favorite": "Favoris"
|
"favorite": "Favoris",
|
||||||
|
"reaction": "Réactions"
|
||||||
},
|
},
|
||||||
"allStatuses": "Tous statuts",
|
"allStatuses": "Tous statuts",
|
||||||
"status": {
|
"status": {
|
||||||
|
|||||||
@@ -1688,6 +1688,7 @@
|
|||||||
"favorites": "Favorieten",
|
"favorites": "Favorieten",
|
||||||
"comments": "Opmerkingen",
|
"comments": "Opmerkingen",
|
||||||
"ratings": "Beoordelingen",
|
"ratings": "Beoordelingen",
|
||||||
|
"reactions": "Reacties",
|
||||||
"lastSeen": "Laatste bezoek"
|
"lastSeen": "Laatste bezoek"
|
||||||
},
|
},
|
||||||
"view": "Details bekijken",
|
"view": "Details bekijken",
|
||||||
@@ -1701,6 +1702,7 @@
|
|||||||
"liked": "Geliked",
|
"liked": "Geliked",
|
||||||
"favorited": "Favoriet",
|
"favorited": "Favoriet",
|
||||||
"rated": "Beoordeeld",
|
"rated": "Beoordeeld",
|
||||||
|
"reacted": "Gereageerd",
|
||||||
"commented": "Becommentarieerd"
|
"commented": "Becommentarieerd"
|
||||||
},
|
},
|
||||||
"inviteStatus": {
|
"inviteStatus": {
|
||||||
@@ -1756,7 +1758,9 @@
|
|||||||
"event_renamed": "Evenement hernoemd: {{eventName}}",
|
"event_renamed": "Evenement hernoemd: {{eventName}}",
|
||||||
"photo_rating": "Foto beoordeeld in {{eventName}}",
|
"photo_rating": "Foto beoordeeld in {{eventName}}",
|
||||||
"photo_comment": "Foto becommentarieerd 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_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_favorite": "Gast heeft een foto als favoriet gemarkeerd in {{eventName}}",
|
||||||
"guest_feedback_rating": "Gast heeft een foto beoordeeld 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}}",
|
"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."
|
"patternDescription": "Wachtwoorden worden gegenereerd met uw evenementnaam en datum. Voorbeeld: \"Locatie2024$Augustus\" voor betere beveiliging en herkenbaarheid."
|
||||||
},
|
},
|
||||||
"feedback": {
|
"feedback": {
|
||||||
|
"reaction": "reactie",
|
||||||
|
"reactions": "Reacties",
|
||||||
|
"reactWith": "Reageer met {{emoji}}",
|
||||||
|
"removeReaction": "{{emoji}}-reactie verwijderen",
|
||||||
|
"reactionError": "Reactie kon niet worden bijgewerkt",
|
||||||
|
"totalReactions": "Totaal reacties",
|
||||||
"comments": "Opmerkingen",
|
"comments": "Opmerkingen",
|
||||||
"addComment": "Opmerking toevoegen",
|
"addComment": "Opmerking toevoegen",
|
||||||
"yourName": "Uw naam",
|
"yourName": "Uw naam",
|
||||||
@@ -2513,6 +2523,8 @@
|
|||||||
"comments": "Opmerkingen",
|
"comments": "Opmerkingen",
|
||||||
"commentsDesc": "Tekstopmerkingen bij foto's",
|
"commentsDesc": "Tekstopmerkingen bij foto's",
|
||||||
"favorites": "Favorieten",
|
"favorites": "Favorieten",
|
||||||
|
"reactions": "Emoji-reacties",
|
||||||
|
"reactionsDesc": "Eén emoji per gast per foto (❤️ 😂 😍 👏 🎉)",
|
||||||
"favoritesDesc": "Foto's als favoriet markeren",
|
"favoritesDesc": "Foto's als favoriet markeren",
|
||||||
"identityMode": "Identiteitsmodus",
|
"identityMode": "Identiteitsmodus",
|
||||||
"identityModeSimple": "Eenvoudige feedback",
|
"identityModeSimple": "Eenvoudige feedback",
|
||||||
@@ -2551,7 +2563,8 @@
|
|||||||
"rating": "Beoordelingen",
|
"rating": "Beoordelingen",
|
||||||
"like": "Likes",
|
"like": "Likes",
|
||||||
"comment": "Opmerkingen",
|
"comment": "Opmerkingen",
|
||||||
"favorite": "Favorieten"
|
"favorite": "Favorieten",
|
||||||
|
"reaction": "Reacties"
|
||||||
},
|
},
|
||||||
"allStatuses": "Alle statussen",
|
"allStatuses": "Alle statussen",
|
||||||
"status": {
|
"status": {
|
||||||
|
|||||||
@@ -1713,6 +1713,7 @@
|
|||||||
"favorites": "Favoritos",
|
"favorites": "Favoritos",
|
||||||
"comments": "Comentários",
|
"comments": "Comentários",
|
||||||
"ratings": "Avaliações",
|
"ratings": "Avaliações",
|
||||||
|
"reactions": "Reações",
|
||||||
"lastSeen": "Última visita"
|
"lastSeen": "Última visita"
|
||||||
},
|
},
|
||||||
"view": "Ver detalhes",
|
"view": "Ver detalhes",
|
||||||
@@ -1726,6 +1727,7 @@
|
|||||||
"liked": "Gostou",
|
"liked": "Gostou",
|
||||||
"favorited": "Favorito",
|
"favorited": "Favorito",
|
||||||
"rated": "Avaliado",
|
"rated": "Avaliado",
|
||||||
|
"reacted": "Reagiu",
|
||||||
"commented": "Comentado"
|
"commented": "Comentado"
|
||||||
},
|
},
|
||||||
"inviteStatus": {
|
"inviteStatus": {
|
||||||
@@ -1781,7 +1783,9 @@
|
|||||||
"event_renamed": "Evento renomeado: {{eventName}}",
|
"event_renamed": "Evento renomeado: {{eventName}}",
|
||||||
"photo_rating": "Foto avaliada em {{eventName}}",
|
"photo_rating": "Foto avaliada em {{eventName}}",
|
||||||
"photo_comment": "Foto comentada 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_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_favorite": "Convidado adicionou uma foto aos favoritos em {{eventName}}",
|
||||||
"guest_feedback_rating": "Convidado avaliou uma foto em {{eventName}}",
|
"guest_feedback_rating": "Convidado avaliou uma foto em {{eventName}}",
|
||||||
"guest_feedback_comment": "Convidado comentou 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."
|
"patternDescription": "As senhas são geradas usando o nome e data do evento. Ex: \"Evento2024$Agosto\" para melhor memorização."
|
||||||
},
|
},
|
||||||
"feedback": {
|
"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",
|
"comments": "Comentários",
|
||||||
"addComment": "Adicionar Comentário",
|
"addComment": "Adicionar Comentário",
|
||||||
"yourName": "Seu nome",
|
"yourName": "Seu nome",
|
||||||
@@ -2538,6 +2548,8 @@
|
|||||||
"comments": "Comentários",
|
"comments": "Comentários",
|
||||||
"commentsDesc": "Comentários em texto nas fotos",
|
"commentsDesc": "Comentários em texto nas fotos",
|
||||||
"favorites": "Favoritos",
|
"favorites": "Favoritos",
|
||||||
|
"reactions": "Reações com emojis",
|
||||||
|
"reactionsDesc": "Um emoji por convidado por foto (❤️ 😂 😍 👏 🎉)",
|
||||||
"favoritesDesc": "Marcar fotos como favoritas",
|
"favoritesDesc": "Marcar fotos como favoritas",
|
||||||
"identityMode": "Modo de identidade",
|
"identityMode": "Modo de identidade",
|
||||||
"identityModeSimple": "Feedback simples",
|
"identityModeSimple": "Feedback simples",
|
||||||
@@ -2576,7 +2588,8 @@
|
|||||||
"rating": "Avaliações",
|
"rating": "Avaliações",
|
||||||
"like": "Gostos",
|
"like": "Gostos",
|
||||||
"comment": "Comentários",
|
"comment": "Comentários",
|
||||||
"favorite": "Favoritos"
|
"favorite": "Favoritos",
|
||||||
|
"reaction": "Reações"
|
||||||
},
|
},
|
||||||
"allStatuses": "Todos os estados",
|
"allStatuses": "Todos os estados",
|
||||||
"status": {
|
"status": {
|
||||||
|
|||||||
@@ -1738,6 +1738,7 @@
|
|||||||
"favorites": "Избранное",
|
"favorites": "Избранное",
|
||||||
"comments": "Комментарии",
|
"comments": "Комментарии",
|
||||||
"ratings": "Оценки",
|
"ratings": "Оценки",
|
||||||
|
"reactions": "Реакции",
|
||||||
"lastSeen": "Последний визит"
|
"lastSeen": "Последний визит"
|
||||||
},
|
},
|
||||||
"view": "Подробнее",
|
"view": "Подробнее",
|
||||||
@@ -1751,6 +1752,7 @@
|
|||||||
"liked": "Понравилось",
|
"liked": "Понравилось",
|
||||||
"favorited": "В избранном",
|
"favorited": "В избранном",
|
||||||
"rated": "Оценено",
|
"rated": "Оценено",
|
||||||
|
"reacted": "Отреагировал",
|
||||||
"commented": "Прокомментировано"
|
"commented": "Прокомментировано"
|
||||||
},
|
},
|
||||||
"inviteStatus": {
|
"inviteStatus": {
|
||||||
@@ -1806,7 +1808,9 @@
|
|||||||
"event_renamed": "Событие переименовано: {{eventName}}",
|
"event_renamed": "Событие переименовано: {{eventName}}",
|
||||||
"photo_rating": "Фото оценено в {{eventName}}",
|
"photo_rating": "Фото оценено в {{eventName}}",
|
||||||
"photo_comment": "Фото прокомментировано в {{eventName}}",
|
"photo_comment": "Фото прокомментировано в {{eventName}}",
|
||||||
|
"photo_reaction": "Реакция на фото в {{eventName}}",
|
||||||
"guest_feedback_like": "Гость поставил лайк фото в {{eventName}}",
|
"guest_feedback_like": "Гость поставил лайк фото в {{eventName}}",
|
||||||
|
"guest_feedback_reaction": "Гость отреагировал на фото в {{eventName}}",
|
||||||
"guest_feedback_favorite": "Гость добавил фото в избранное в {{eventName}}",
|
"guest_feedback_favorite": "Гость добавил фото в избранное в {{eventName}}",
|
||||||
"guest_feedback_rating": "Гость оценил фото в {{eventName}}",
|
"guest_feedback_rating": "Гость оценил фото в {{eventName}}",
|
||||||
"guest_feedback_comment": "Гость прокомментировал фото в {{eventName}}",
|
"guest_feedback_comment": "Гость прокомментировал фото в {{eventName}}",
|
||||||
@@ -2535,6 +2539,12 @@
|
|||||||
"patternDescription": "Пароли генерируются на основе названия и даты события. Пример: «Место2024$Август» для лучшей запоминаемости и безопасности."
|
"patternDescription": "Пароли генерируются на основе названия и даты события. Пример: «Место2024$Август» для лучшей запоминаемости и безопасности."
|
||||||
},
|
},
|
||||||
"feedback": {
|
"feedback": {
|
||||||
|
"reaction": "реакция",
|
||||||
|
"reactions": "Реакции",
|
||||||
|
"reactWith": "Отреагировать {{emoji}}",
|
||||||
|
"removeReaction": "Убрать реакцию {{emoji}}",
|
||||||
|
"reactionError": "Не удалось обновить реакцию",
|
||||||
|
"totalReactions": "Всего реакций",
|
||||||
"comments": "Комментарии",
|
"comments": "Комментарии",
|
||||||
"addComment": "Добавить комментарий",
|
"addComment": "Добавить комментарий",
|
||||||
"yourName": "Ваше имя",
|
"yourName": "Ваше имя",
|
||||||
@@ -2563,6 +2573,8 @@
|
|||||||
"comments": "Комментарии",
|
"comments": "Комментарии",
|
||||||
"commentsDesc": "Текстовые комментарии к фото",
|
"commentsDesc": "Текстовые комментарии к фото",
|
||||||
"favorites": "Избранное",
|
"favorites": "Избранное",
|
||||||
|
"reactions": "Эмодзи-реакции",
|
||||||
|
"reactionsDesc": "Один эмодзи на гостя для каждого фото (❤️ 😂 😍 👏 🎉)",
|
||||||
"favoritesDesc": "Отмечать фото как избранные",
|
"favoritesDesc": "Отмечать фото как избранные",
|
||||||
"identityMode": "Режим идентификации",
|
"identityMode": "Режим идентификации",
|
||||||
"identityModeSimple": "Простые отзывы",
|
"identityModeSimple": "Простые отзывы",
|
||||||
@@ -2601,7 +2613,8 @@
|
|||||||
"rating": "Оценки",
|
"rating": "Оценки",
|
||||||
"like": "Лайки",
|
"like": "Лайки",
|
||||||
"comment": "Комментарии",
|
"comment": "Комментарии",
|
||||||
"favorite": "Избранное"
|
"favorite": "Избранное",
|
||||||
|
"reaction": "Реакции"
|
||||||
},
|
},
|
||||||
"allStatuses": "Все статусы",
|
"allStatuses": "Все статусы",
|
||||||
"status": {
|
"status": {
|
||||||
|
|||||||
@@ -1688,6 +1688,7 @@
|
|||||||
"favorites": "Priljubljene",
|
"favorites": "Priljubljene",
|
||||||
"comments": "Komentarji",
|
"comments": "Komentarji",
|
||||||
"ratings": "Ocene",
|
"ratings": "Ocene",
|
||||||
|
"reactions": "Odzivi",
|
||||||
"lastSeen": "Nazadnje viden"
|
"lastSeen": "Nazadnje viden"
|
||||||
},
|
},
|
||||||
"view": "Ogled podrobnosti",
|
"view": "Ogled podrobnosti",
|
||||||
@@ -1701,6 +1702,7 @@
|
|||||||
"liked": "Všečkano",
|
"liked": "Všečkano",
|
||||||
"favorited": "Dodano med priljubljene",
|
"favorited": "Dodano med priljubljene",
|
||||||
"rated": "Ocenjeno",
|
"rated": "Ocenjeno",
|
||||||
|
"reacted": "Odzval se",
|
||||||
"commented": "Komentirano"
|
"commented": "Komentirano"
|
||||||
},
|
},
|
||||||
"inviteStatus": {
|
"inviteStatus": {
|
||||||
@@ -1733,7 +1735,9 @@
|
|||||||
"photo_favorite": "Fotografija dodana med priljubljene v {{eventName}}",
|
"photo_favorite": "Fotografija dodana med priljubljene v {{eventName}}",
|
||||||
"photo_rating": "Fotografija ocenjena v {{eventName}}",
|
"photo_rating": "Fotografija ocenjena v {{eventName}}",
|
||||||
"photo_comment": "Fotografija komentirana 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_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_favorite": "Gost je dodal fotografijo med priljubljene v {{eventName}}",
|
||||||
"guest_feedback_rating": "Gost je ocenil fotografijo v {{eventName}}",
|
"guest_feedback_rating": "Gost je ocenil fotografijo v {{eventName}}",
|
||||||
"guest_feedback_comment": "Gost je komentiral 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."
|
"patternDescription": "Gesla se ustvarijo z uporabo imena in datuma dogodka. Primer: »Lokacija2024$Avgust« za boljšo varnost in lažje pomnjenje."
|
||||||
},
|
},
|
||||||
"feedback": {
|
"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",
|
"comments": "Komentarji",
|
||||||
"addComment": "Dodaj komentar",
|
"addComment": "Dodaj komentar",
|
||||||
"yourName": "Vaše ime",
|
"yourName": "Vaše ime",
|
||||||
@@ -2513,6 +2523,8 @@
|
|||||||
"comments": "Komentarji",
|
"comments": "Komentarji",
|
||||||
"commentsDesc": "Besedilni komentarji na fotografijah",
|
"commentsDesc": "Besedilni komentarji na fotografijah",
|
||||||
"favorites": "Priljubljene",
|
"favorites": "Priljubljene",
|
||||||
|
"reactions": "Emoji odzivi",
|
||||||
|
"reactionsDesc": "En emoji na gosta na fotografijo (❤️ 😂 😍 👏 🎉)",
|
||||||
"favoritesDesc": "Označi fotografije kot priljubljene",
|
"favoritesDesc": "Označi fotografije kot priljubljene",
|
||||||
"identityMode": "Način identitete",
|
"identityMode": "Način identitete",
|
||||||
"identityModeSimple": "Preproste povratne informacije",
|
"identityModeSimple": "Preproste povratne informacije",
|
||||||
@@ -2551,7 +2563,8 @@
|
|||||||
"rating": "Ocene",
|
"rating": "Ocene",
|
||||||
"like": "Všečki",
|
"like": "Všečki",
|
||||||
"comment": "Komentarji",
|
"comment": "Komentarji",
|
||||||
"favorite": "Priljubljene"
|
"favorite": "Priljubljene",
|
||||||
|
"reaction": "Odzivi"
|
||||||
},
|
},
|
||||||
"allStatuses": "Vsa stanja",
|
"allStatuses": "Vsa stanja",
|
||||||
"status": {
|
"status": {
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ interface FormData {
|
|||||||
allow_likes: boolean;
|
allow_likes: boolean;
|
||||||
allow_comments: boolean;
|
allow_comments: boolean;
|
||||||
allow_favorites: boolean;
|
allow_favorites: boolean;
|
||||||
|
allow_reactions: boolean;
|
||||||
require_name_email: boolean;
|
require_name_email: boolean;
|
||||||
moderate_comments: boolean;
|
moderate_comments: boolean;
|
||||||
show_feedback_to_guests: boolean;
|
show_feedback_to_guests: boolean;
|
||||||
@@ -132,6 +133,7 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
allow_likes: true,
|
allow_likes: true,
|
||||||
allow_comments: true,
|
allow_comments: true,
|
||||||
allow_favorites: true,
|
allow_favorites: true,
|
||||||
|
allow_reactions: true,
|
||||||
require_name_email: false,
|
require_name_email: false,
|
||||||
moderate_comments: true,
|
moderate_comments: true,
|
||||||
show_feedback_to_guests: true,
|
show_feedback_to_guests: true,
|
||||||
@@ -482,6 +484,7 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
allow_likes: feedbackSettings.allow_likes,
|
allow_likes: feedbackSettings.allow_likes,
|
||||||
allow_comments: feedbackSettings.allow_comments,
|
allow_comments: feedbackSettings.allow_comments,
|
||||||
allow_favorites: feedbackSettings.allow_favorites,
|
allow_favorites: feedbackSettings.allow_favorites,
|
||||||
|
allow_reactions: feedbackSettings.allow_reactions,
|
||||||
require_name_email: feedbackSettings.require_name_email,
|
require_name_email: feedbackSettings.require_name_email,
|
||||||
moderate_comments: feedbackSettings.moderate_comments,
|
moderate_comments: feedbackSettings.moderate_comments,
|
||||||
show_feedback_to_guests: feedbackSettings.show_feedback_to_guests,
|
show_feedback_to_guests: feedbackSettings.show_feedback_to_guests,
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
allow_likes: true,
|
allow_likes: true,
|
||||||
allow_comments: true,
|
allow_comments: true,
|
||||||
allow_favorites: true,
|
allow_favorites: true,
|
||||||
|
allow_reactions: true,
|
||||||
require_name_email: false,
|
require_name_email: false,
|
||||||
moderate_comments: true,
|
moderate_comments: true,
|
||||||
show_feedback_to_guests: true,
|
show_feedback_to_guests: true,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Star,
|
Star,
|
||||||
Heart,
|
Heart,
|
||||||
|
Smile,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Filter,
|
Filter,
|
||||||
Download,
|
Download,
|
||||||
@@ -249,6 +250,7 @@ export const EventFeedbackPage: React.FC = () => {
|
|||||||
<option value="like">{t('feedback.types.like', 'Likes')}</option>
|
<option value="like">{t('feedback.types.like', 'Likes')}</option>
|
||||||
<option value="comment">{t('feedback.types.comment', 'Comments')}</option>
|
<option value="comment">{t('feedback.types.comment', 'Comments')}</option>
|
||||||
<option value="favorite">{t('feedback.types.favorite', 'Favorites')}</option>
|
<option value="favorite">{t('feedback.types.favorite', 'Favorites')}</option>
|
||||||
|
<option value="reaction">{t('feedback.types.reaction', 'Reactions')}</option>
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
value={feedbackFilter.status}
|
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 === '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 === '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 === '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">
|
<span className="font-medium text-sm">
|
||||||
{item.guest_name || t('feedback.anonymous', 'Anonymous')}
|
{item.guest_name || t('feedback.anonymous', 'Anonymous')}
|
||||||
</span>
|
</span>
|
||||||
@@ -422,7 +427,7 @@ export const EventFeedbackPage: React.FC = () => {
|
|||||||
) : analytics ? (
|
) : analytics ? (
|
||||||
<>
|
<>
|
||||||
{/* Summary Stats */}
|
{/* 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>
|
<Card>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
@@ -448,6 +453,17 @@ export const EventFeedbackPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</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>
|
<Card>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
|||||||
@@ -116,10 +116,12 @@ export type ActivityType =
|
|||||||
| "photo_favorite"
|
| "photo_favorite"
|
||||||
| "photo_rating"
|
| "photo_rating"
|
||||||
| "photo_comment"
|
| "photo_comment"
|
||||||
|
| "photo_reaction"
|
||||||
| "guest_feedback_like"
|
| "guest_feedback_like"
|
||||||
| "guest_feedback_favorite"
|
| "guest_feedback_favorite"
|
||||||
| "guest_feedback_rating"
|
| "guest_feedback_rating"
|
||||||
| "guest_feedback_comment"
|
| "guest_feedback_comment"
|
||||||
|
| "guest_feedback_reaction"
|
||||||
| "word_filter_added"
|
| "word_filter_added"
|
||||||
| "external_import_completed"
|
| "external_import_completed"
|
||||||
| "bulk_archive_completed"
|
| "bulk_archive_completed"
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ interface CreateEventData {
|
|||||||
allow_likes?: boolean;
|
allow_likes?: boolean;
|
||||||
allow_comments?: boolean;
|
allow_comments?: boolean;
|
||||||
allow_favorites?: boolean;
|
allow_favorites?: boolean;
|
||||||
|
allow_reactions?: boolean;
|
||||||
require_name_email?: boolean;
|
require_name_email?: boolean;
|
||||||
moderate_comments?: boolean;
|
moderate_comments?: boolean;
|
||||||
show_feedback_to_guests?: boolean;
|
show_feedback_to_guests?: boolean;
|
||||||
|
|||||||
@@ -2,12 +2,18 @@ import { api } from '../config/api';
|
|||||||
|
|
||||||
export type IdentityMode = 'simple' | 'guest';
|
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 {
|
export interface FeedbackSettings {
|
||||||
feedback_enabled: boolean;
|
feedback_enabled: boolean;
|
||||||
allow_ratings: boolean;
|
allow_ratings: boolean;
|
||||||
allow_likes: boolean;
|
allow_likes: boolean;
|
||||||
allow_comments: boolean;
|
allow_comments: boolean;
|
||||||
allow_favorites: boolean;
|
allow_favorites: boolean;
|
||||||
|
allow_reactions: boolean;
|
||||||
require_name_email: boolean;
|
require_name_email: boolean;
|
||||||
moderate_comments: boolean;
|
moderate_comments: boolean;
|
||||||
show_feedback_to_guests: boolean;
|
show_feedback_to_guests: boolean;
|
||||||
@@ -28,10 +34,11 @@ export interface PhotoFeedback {
|
|||||||
id: number;
|
id: number;
|
||||||
photo_id: number;
|
photo_id: number;
|
||||||
event_id: number;
|
event_id: number;
|
||||||
feedback_type: 'rating' | 'like' | 'comment' | 'favorite';
|
feedback_type: 'rating' | 'like' | 'comment' | 'favorite' | 'reaction';
|
||||||
rating?: number;
|
rating?: number;
|
||||||
comment_text?: string;
|
comment_text?: string;
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
reaction?: string;
|
||||||
guest_name?: string;
|
guest_name?: string;
|
||||||
guest_email?: string;
|
guest_email?: string;
|
||||||
is_approved: boolean;
|
is_approved: boolean;
|
||||||
@@ -49,6 +56,7 @@ export interface FeedbackSummary {
|
|||||||
total_ratings: number;
|
total_ratings: number;
|
||||||
like_count: number;
|
like_count: number;
|
||||||
favorite_count: number;
|
favorite_count: number;
|
||||||
|
reaction_count?: number;
|
||||||
comment_count: number;
|
comment_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,11 +64,14 @@ export interface MyFeedback {
|
|||||||
rating?: number;
|
rating?: number;
|
||||||
liked: boolean;
|
liked: boolean;
|
||||||
favorited: boolean;
|
favorited: boolean;
|
||||||
|
reaction?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeedbackResponse {
|
export interface FeedbackResponse {
|
||||||
feedback: PhotoFeedback[];
|
feedback: PhotoFeedback[];
|
||||||
summary: FeedbackSummary;
|
summary: FeedbackSummary;
|
||||||
|
/** Per-emoji tallies for the reaction bar (#839), e.g. { '❤️': 3 }. */
|
||||||
|
reactions?: Record<string, number>;
|
||||||
my_feedback: MyFeedback;
|
my_feedback: MyFeedback;
|
||||||
pagination?: {
|
pagination?: {
|
||||||
page: number;
|
page: number;
|
||||||
@@ -77,6 +88,7 @@ export interface FeedbackAnalytics {
|
|||||||
total_likes: number;
|
total_likes: number;
|
||||||
total_comments: number;
|
total_comments: number;
|
||||||
total_favorites: number;
|
total_favorites: number;
|
||||||
|
total_reactions?: number;
|
||||||
pending_moderation: number;
|
pending_moderation: number;
|
||||||
};
|
};
|
||||||
topRated: Array<{
|
topRated: Array<{
|
||||||
@@ -198,9 +210,10 @@ class FeedbackService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async submitFeedback(slug: string, photoId: string, feedback: {
|
async submitFeedback(slug: string, photoId: string, feedback: {
|
||||||
feedback_type: 'rating' | 'like' | 'comment' | 'favorite';
|
feedback_type: 'rating' | 'like' | 'comment' | 'favorite' | 'reaction';
|
||||||
rating?: number;
|
rating?: number;
|
||||||
comment_text?: string;
|
comment_text?: string;
|
||||||
|
reaction?: string;
|
||||||
guest_name?: string;
|
guest_name?: string;
|
||||||
guest_email?: string;
|
guest_email?: string;
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface AdminGuestStats {
|
|||||||
favorites: number;
|
favorites: number;
|
||||||
comments: number;
|
comments: number;
|
||||||
ratings: number;
|
ratings: number;
|
||||||
|
reactions: number;
|
||||||
distinct_photos: number;
|
distinct_photos: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ export interface AdminGuestSelections {
|
|||||||
favorited: AdminGuestPhoto[];
|
favorited: AdminGuestPhoto[];
|
||||||
rated: Array<{ photo: AdminGuestPhoto; rating: number }>;
|
rated: Array<{ photo: AdminGuestPhoto; rating: number }>;
|
||||||
commented: Array<{ photo: AdminGuestPhoto; comment: string; created_at: string }>;
|
commented: Array<{ photo: AdminGuestPhoto; comment: string; created_at: string }>;
|
||||||
|
reacted: Array<{ photo: AdminGuestPhoto; reaction: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminGuestDetail {
|
export interface AdminGuestDetail {
|
||||||
|
|||||||
Reference in New Issue
Block a user