Merge pull request #295 from the-luap/feat/guest-selections
feat: guest selections with per-person identity (#292)
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Add guest identity layer for per-person photo selections (issue #292).
|
||||
*
|
||||
* Adds:
|
||||
* - gallery_guests — persistent guest profiles per event
|
||||
* - guest_invites — pre-minted invite tokens (Phase 3.3)
|
||||
* - guest_verification_codes — email-based identity recovery (Phase 3.2)
|
||||
* - event_feedback_settings.identity_mode ('simple' | 'guest', default 'simple')
|
||||
* - photo_feedback.guest_id FK — links feedback to gallery_guests (nullable)
|
||||
*
|
||||
* All changes are additive. Existing events default to 'simple' mode so behavior
|
||||
* is unchanged. Legacy photo_feedback rows keep NULL guest_id.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// 1. gallery_guests — persistent per-person identity within an event.
|
||||
const hasGalleryGuests = await knex.schema.hasTable('gallery_guests');
|
||||
if (!hasGalleryGuests) {
|
||||
await knex.schema.createTable('gallery_guests', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('event_id').notNullable().references('id').inTable('events').onDelete('CASCADE');
|
||||
table.string('name', 100).notNullable();
|
||||
table.string('email', 255);
|
||||
table.string('identifier', 64).notNullable(); // UUIDv4 issued server-side
|
||||
table.string('ip_address_last', 45);
|
||||
table.text('user_agent_last');
|
||||
table.timestamp('email_verified_at');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('last_seen_at').defaultTo(knex.fn.now());
|
||||
table.boolean('is_deleted').defaultTo(false);
|
||||
|
||||
table.unique(['event_id', 'identifier']);
|
||||
table.index(['event_id']);
|
||||
table.index(['event_id', 'email']);
|
||||
});
|
||||
}
|
||||
|
||||
// 2. guest_invites — pre-minted one-time-use tokens for invited guests.
|
||||
const hasGuestInvites = await knex.schema.hasTable('guest_invites');
|
||||
if (!hasGuestInvites) {
|
||||
await knex.schema.createTable('guest_invites', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('event_id').notNullable().references('id').inTable('events').onDelete('CASCADE');
|
||||
table.integer('guest_id').notNullable().references('id').inTable('gallery_guests').onDelete('CASCADE');
|
||||
table.string('token', 64).notNullable().unique();
|
||||
table.integer('created_by_admin_id').references('id').inTable('admin_users');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('redeemed_at');
|
||||
table.timestamp('revoked_at');
|
||||
|
||||
table.index(['event_id']);
|
||||
table.index(['guest_id']);
|
||||
});
|
||||
}
|
||||
|
||||
// 3. guest_verification_codes — short-lived codes for email-based recovery.
|
||||
const hasGuestVerificationCodes = await knex.schema.hasTable('guest_verification_codes');
|
||||
if (!hasGuestVerificationCodes) {
|
||||
await knex.schema.createTable('guest_verification_codes', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('event_id').notNullable().references('id').inTable('events').onDelete('CASCADE');
|
||||
table.string('email', 255).notNullable();
|
||||
table.string('code_hash', 128).notNullable(); // bcrypt hash of 6-digit code
|
||||
table.integer('attempts').defaultTo(0);
|
||||
table.timestamp('expires_at').notNullable();
|
||||
table.timestamp('consumed_at');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
|
||||
table.index(['event_id', 'email']);
|
||||
table.index(['expires_at']);
|
||||
});
|
||||
}
|
||||
|
||||
// 4. event_feedback_settings.identity_mode
|
||||
const hasIdentityMode = await knex.schema.hasColumn('event_feedback_settings', 'identity_mode');
|
||||
if (!hasIdentityMode) {
|
||||
await knex.schema.alterTable('event_feedback_settings', (table) => {
|
||||
table.string('identity_mode', 16).notNullable().defaultTo('simple');
|
||||
});
|
||||
if (knex.client.config.client === 'pg') {
|
||||
await knex.raw(`
|
||||
ALTER TABLE event_feedback_settings
|
||||
ADD CONSTRAINT event_feedback_settings_identity_mode_check
|
||||
CHECK (identity_mode IN ('simple','guest'))
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. photo_feedback.guest_id FK
|
||||
const hasGuestIdColumn = await knex.schema.hasColumn('photo_feedback', 'guest_id');
|
||||
if (!hasGuestIdColumn) {
|
||||
await knex.schema.alterTable('photo_feedback', (table) => {
|
||||
table.integer('guest_id').references('id').inTable('gallery_guests').onDelete('SET NULL');
|
||||
table.index(['guest_id']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
const hasGuestIdColumn = await knex.schema.hasColumn('photo_feedback', 'guest_id');
|
||||
if (hasGuestIdColumn) {
|
||||
await knex.schema.alterTable('photo_feedback', (table) => {
|
||||
table.dropColumn('guest_id');
|
||||
});
|
||||
}
|
||||
|
||||
if (knex.client.config.client === 'pg') {
|
||||
await knex.raw('ALTER TABLE event_feedback_settings DROP CONSTRAINT IF EXISTS event_feedback_settings_identity_mode_check');
|
||||
}
|
||||
const hasIdentityMode = await knex.schema.hasColumn('event_feedback_settings', 'identity_mode');
|
||||
if (hasIdentityMode) {
|
||||
await knex.schema.alterTable('event_feedback_settings', (table) => {
|
||||
table.dropColumn('identity_mode');
|
||||
});
|
||||
}
|
||||
|
||||
await knex.schema.dropTableIfExists('guest_verification_codes');
|
||||
await knex.schema.dropTableIfExists('guest_invites');
|
||||
await knex.schema.dropTableIfExists('gallery_guests');
|
||||
};
|
||||
@@ -512,12 +512,14 @@ app.use('/api/auth', authRoutes);
|
||||
// Gallery routes - main routes first, then feedback routes
|
||||
app.use('/api/gallery', galleryRoutes);
|
||||
app.use('/api/gallery', require('./src/routes/galleryFeedback'));
|
||||
app.use('/api/gallery', require('./src/routes/galleryGuests'));
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/admin/auth', adminAuthRoutes);
|
||||
app.use('/api/admin/system', require('./src/routes/adminSystem'));
|
||||
app.use('/api/admin/backup', require('./src/routes/adminBackup'));
|
||||
app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup'));
|
||||
app.use('/api/admin/feedback', require('./src/routes/adminFeedback'));
|
||||
app.use('/api/admin', require('./src/routes/adminGuests'));
|
||||
app.use('/api/admin/image-security', require('./src/routes/adminImageSecurity'));
|
||||
app.use('/api/admin/thumbnails', require('./src/routes/adminThumbnails'));
|
||||
app.use('/api/admin/photos', require('./src/routes/adminPhotoDimensions'));
|
||||
|
||||
@@ -3,9 +3,20 @@ const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Generate a unique identifier for the guest
|
||||
* Generate a unique identifier for the guest.
|
||||
*
|
||||
* In guest identity mode, `req.guest.identifier` is a server-issued UUID
|
||||
* unique per person per event (set by the resolveGuest middleware). When
|
||||
* present it takes precedence, so rate limits and deduplication become
|
||||
* per-person instead of per-device.
|
||||
*
|
||||
* In simple (legacy) mode, the identifier falls back to a hash of IP + UA,
|
||||
* matching prior behavior.
|
||||
*/
|
||||
function generateGuestIdentifier(req) {
|
||||
if (req.guest && req.guest.identifier) {
|
||||
return req.guest.identifier;
|
||||
}
|
||||
const ip = req.ip || req.connection.remoteAddress || 'unknown';
|
||||
const userAgent = req.headers['user-agent'] || 'unknown';
|
||||
return crypto
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { getGuestTokenFromRequest } = require('../utils/tokenUtils');
|
||||
|
||||
/**
|
||||
* Non-blocking middleware. Reads an optional guest token from the request and,
|
||||
* if present and valid, populates req.guest with { id, identifier, name, eventId }.
|
||||
*
|
||||
* If the token is missing, malformed, or expired → req.guest = null and the
|
||||
* request continues. Downstream handlers (e.g. feedback submission) enforce
|
||||
* presence explicitly based on event feedback settings (identity_mode).
|
||||
*/
|
||||
async function resolveGuest(req, res, next) {
|
||||
try {
|
||||
const slug = req.params?.slug;
|
||||
const token = getGuestTokenFromRequest(req, slug);
|
||||
if (!token) {
|
||||
req.guest = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
const verified = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
issuer: 'picpeak-auth',
|
||||
complete: true,
|
||||
});
|
||||
decoded = verified.payload;
|
||||
} catch (err) {
|
||||
// Invalid or expired guest tokens are silently ignored so that public
|
||||
// gallery browsing continues to work even if the token is stale.
|
||||
logger.debug('Invalid guest token', { reason: err.message });
|
||||
req.guest = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
if (decoded.type !== 'guest') {
|
||||
req.guest = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
// Verify the guest row still exists and is not soft-deleted.
|
||||
const guest = await db('gallery_guests')
|
||||
.where({ id: decoded.guestId, event_id: decoded.eventId, is_deleted: false })
|
||||
.first();
|
||||
|
||||
if (!guest) {
|
||||
req.guest = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
req.guest = {
|
||||
id: guest.id,
|
||||
eventId: guest.event_id,
|
||||
identifier: guest.identifier,
|
||||
name: guest.name,
|
||||
email: guest.email || null,
|
||||
};
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
logger.error('resolveGuest middleware error', { error: error.message });
|
||||
req.guest = null;
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking middleware that 401s if no guest identity was resolved.
|
||||
* Use this on endpoints that require a valid guest session.
|
||||
*/
|
||||
function requireGuest(req, res, next) {
|
||||
if (!req.guest) {
|
||||
return res.status(401).json({ error: 'Guest identity required' });
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a new guest JWT. Scoped to a specific event and guest row.
|
||||
* Expiry matches the gallery token default (24h).
|
||||
*/
|
||||
function signGuestToken({ guestId, eventId, identifier, name }, expiresIn = '24h') {
|
||||
return jwt.sign(
|
||||
{
|
||||
type: 'guest',
|
||||
guestId,
|
||||
eventId,
|
||||
identifier,
|
||||
name,
|
||||
},
|
||||
process.env.JWT_SECRET,
|
||||
{
|
||||
issuer: 'picpeak-auth',
|
||||
expiresIn,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveGuest,
|
||||
requireGuest,
|
||||
signGuestToken,
|
||||
};
|
||||
@@ -0,0 +1,609 @@
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const archiver = require('archiver');
|
||||
const router = express.Router();
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const feedbackService = require('../services/feedbackService');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL || '';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async function loadGuestOr404(eventId, guestId, res) {
|
||||
const guest = await db('gallery_guests')
|
||||
.where({ id: guestId, event_id: eventId, is_deleted: false })
|
||||
.first();
|
||||
if (!guest) {
|
||||
res.status(404).json({ error: 'Guest not found' });
|
||||
return null;
|
||||
}
|
||||
return guest;
|
||||
}
|
||||
|
||||
function serializeGuest(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
created_at: row.created_at,
|
||||
last_seen_at: row.last_seen_at,
|
||||
email_verified_at: row.email_verified_at,
|
||||
is_deleted: row.is_deleted,
|
||||
};
|
||||
}
|
||||
|
||||
function escapeCsvCell(value) {
|
||||
const str = value == null ? '' : String(value);
|
||||
if (/[,"\n\r]/.test(str)) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// GET /admin/events/:eventId/guests — list guests with aggregated counts
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.get(
|
||||
'/events/:eventId/guests',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
|
||||
const rows = await db('gallery_guests')
|
||||
.leftJoin('photo_feedback', function () {
|
||||
this.on('photo_feedback.guest_id', '=', 'gallery_guests.id');
|
||||
})
|
||||
.where('gallery_guests.event_id', eventId)
|
||||
.where('gallery_guests.is_deleted', false)
|
||||
.groupBy('gallery_guests.id')
|
||||
.select(
|
||||
'gallery_guests.id',
|
||||
'gallery_guests.name',
|
||||
'gallery_guests.email',
|
||||
'gallery_guests.created_at',
|
||||
'gallery_guests.last_seen_at',
|
||||
'gallery_guests.email_verified_at',
|
||||
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'like' THEN 1 END) AS likes"),
|
||||
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'favorite' THEN 1 END) AS favorites"),
|
||||
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'comment' THEN 1 END) AS comments"),
|
||||
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'rating' THEN 1 END) AS ratings"),
|
||||
db.raw('COUNT(DISTINCT photo_feedback.photo_id) AS distinct_photos')
|
||||
)
|
||||
.orderBy('gallery_guests.created_at', 'desc');
|
||||
|
||||
const guests = rows.map((r) => ({
|
||||
...serializeGuest(r),
|
||||
stats: {
|
||||
likes: parseInt(r.likes, 10) || 0,
|
||||
favorites: parseInt(r.favorites, 10) || 0,
|
||||
comments: parseInt(r.comments, 10) || 0,
|
||||
ratings: parseInt(r.ratings, 10) || 0,
|
||||
distinct_photos: parseInt(r.distinct_photos, 10) || 0,
|
||||
},
|
||||
}));
|
||||
|
||||
res.json({ guests });
|
||||
} catch (error) {
|
||||
logger.error('Error listing guests:', error);
|
||||
res.status(500).json({ error: 'Failed to list guests' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// GET /admin/events/:eventId/guests/aggregate — photos sorted by distinct
|
||||
// guest pick count (Phase 2 aggregate view)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.get(
|
||||
'/events/:eventId/guests/aggregate',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
|
||||
const photos = await db('photos')
|
||||
.leftJoin('photo_feedback', function () {
|
||||
this.on('photo_feedback.photo_id', '=', 'photos.id')
|
||||
.andOn(db.raw("photo_feedback.feedback_type IN ('like','favorite')"))
|
||||
.andOnNotNull('photo_feedback.guest_id');
|
||||
})
|
||||
.where('photos.event_id', eventId)
|
||||
.groupBy('photos.id')
|
||||
.select(
|
||||
'photos.id',
|
||||
'photos.filename',
|
||||
'photos.original_filename',
|
||||
db.raw('COUNT(DISTINCT photo_feedback.guest_id) AS picker_count')
|
||||
)
|
||||
.orderBy('picker_count', 'desc')
|
||||
.orderBy('photos.id', 'desc');
|
||||
|
||||
res.json({
|
||||
photos: photos
|
||||
.filter((p) => parseInt(p.picker_count, 10) > 0)
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
filename: p.filename,
|
||||
original_filename: p.original_filename,
|
||||
url: `/admin/photos/${eventId}/photo/${p.id}`,
|
||||
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${p.id}`,
|
||||
picker_count: parseInt(p.picker_count, 10),
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching aggregate view:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch aggregate view' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// GET /admin/events/:eventId/guests/invites — list pre-minted invites
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.get(
|
||||
'/events/:eventId/guests/invites',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
|
||||
const rows = await db('guest_invites')
|
||||
.leftJoin('gallery_guests', 'gallery_guests.id', 'guest_invites.guest_id')
|
||||
.where('guest_invites.event_id', eventId)
|
||||
.select(
|
||||
'guest_invites.id',
|
||||
'guest_invites.token',
|
||||
'guest_invites.created_at',
|
||||
'guest_invites.redeemed_at',
|
||||
'guest_invites.revoked_at',
|
||||
'gallery_guests.id as guest_id',
|
||||
'gallery_guests.name as guest_name',
|
||||
'gallery_guests.email as guest_email'
|
||||
)
|
||||
.orderBy('guest_invites.created_at', 'desc');
|
||||
|
||||
const invites = rows.map((r) => ({
|
||||
id: r.id,
|
||||
token: r.token,
|
||||
url: `${FRONTEND_URL}/gallery/${event.slug}?invite=${r.token}`,
|
||||
created_at: r.created_at,
|
||||
redeemed_at: r.redeemed_at,
|
||||
revoked_at: r.revoked_at,
|
||||
status: r.revoked_at ? 'revoked' : r.redeemed_at ? 'redeemed' : 'pending',
|
||||
guest: {
|
||||
id: r.guest_id,
|
||||
name: r.guest_name,
|
||||
email: r.guest_email,
|
||||
},
|
||||
}));
|
||||
|
||||
res.json({ invites });
|
||||
} catch (error) {
|
||||
logger.error('Error listing invites:', error);
|
||||
res.status(500).json({ error: 'Failed to list invites' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// POST /admin/events/:eventId/guests/invites — create guest + invite
|
||||
// Body: { name, email? }
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.post(
|
||||
'/events/:eventId/guests/invites',
|
||||
adminAuth,
|
||||
requirePermission('events.edit'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const name = String(req.body?.name || '').trim().slice(0, 100);
|
||||
const email = String(req.body?.email || '').trim().slice(0, 255).toLowerCase();
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Name is required' });
|
||||
}
|
||||
|
||||
const identifier = crypto.randomUUID();
|
||||
const inviteToken = crypto.randomBytes(24).toString('hex');
|
||||
|
||||
let guestId;
|
||||
let inviteId;
|
||||
await db.transaction(async (trx) => {
|
||||
const [guestRow] = await trx('gallery_guests')
|
||||
.insert({
|
||||
event_id: eventId,
|
||||
name,
|
||||
email: email || null,
|
||||
identifier,
|
||||
})
|
||||
.returning(['id']);
|
||||
guestId = guestRow.id;
|
||||
|
||||
const [inviteRow] = await trx('guest_invites')
|
||||
.insert({
|
||||
event_id: eventId,
|
||||
guest_id: guestId,
|
||||
token: inviteToken,
|
||||
created_by_admin_id: req.admin.id,
|
||||
})
|
||||
.returning(['id']);
|
||||
inviteId = inviteRow.id;
|
||||
});
|
||||
|
||||
await logActivity(
|
||||
'guest_invite_created',
|
||||
{ event_id: eventId, guest_id: guestId, invite_id: inviteId },
|
||||
eventId,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
res.json({
|
||||
invite: {
|
||||
id: inviteId,
|
||||
token: inviteToken,
|
||||
url: `${FRONTEND_URL}/gallery/${event.slug}?invite=${inviteToken}`,
|
||||
status: 'pending',
|
||||
guest: { id: guestId, name, email: email || null },
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error creating invite:', error);
|
||||
res.status(500).json({ error: 'Failed to create invite' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// DELETE /admin/events/:eventId/guests/invites/:inviteId — revoke
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.delete(
|
||||
'/events/:eventId/guests/invites/:inviteId',
|
||||
adminAuth,
|
||||
requirePermission('events.edit'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId, inviteId } = req.params;
|
||||
const updated = await db('guest_invites')
|
||||
.where({ id: inviteId, event_id: eventId })
|
||||
.whereNull('revoked_at')
|
||||
.update({ revoked_at: db.fn.now() });
|
||||
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: 'Invite not found or already revoked' });
|
||||
}
|
||||
|
||||
await logActivity(
|
||||
'guest_invite_revoked',
|
||||
{ event_id: eventId, invite_id: inviteId },
|
||||
eventId,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Error revoking invite:', error);
|
||||
res.status(500).json({ error: 'Failed to revoke invite' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// GET /admin/events/:eventId/guests/export-all — ZIP of per-guest exports
|
||||
// Query: format=txt|csv|json (default: csv)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.get(
|
||||
'/events/:eventId/guests/export-all',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const format = ['txt', 'csv', 'json'].includes(req.query.format) ? req.query.format : 'csv';
|
||||
|
||||
const guests = await db('gallery_guests')
|
||||
.where({ event_id: eventId, is_deleted: false })
|
||||
.select('id', 'name', 'email');
|
||||
|
||||
if (guests.length === 0) {
|
||||
return res.status(404).json({ error: 'No guests to export' });
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="event-${eventId}-guests.zip"`
|
||||
);
|
||||
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
archive.on('error', (err) => {
|
||||
logger.error('Archive error:', err);
|
||||
res.status(500).end();
|
||||
});
|
||||
archive.pipe(res);
|
||||
|
||||
for (const g of guests) {
|
||||
const selections = await db('photo_feedback')
|
||||
.join('photos', 'photo_feedback.photo_id', 'photos.id')
|
||||
.where('photo_feedback.guest_id', g.id)
|
||||
.whereIn('photo_feedback.feedback_type', ['like', 'favorite'])
|
||||
.select('photos.filename', 'photos.original_filename', 'photo_feedback.feedback_type');
|
||||
|
||||
const safeName = g.name.replace(/[^a-zA-Z0-9_-]/g, '_') || `guest_${g.id}`;
|
||||
const filename = `${safeName}.${format}`;
|
||||
|
||||
let body;
|
||||
if (format === 'json') {
|
||||
body = JSON.stringify({ guest: g, selections }, null, 2);
|
||||
} else if (format === 'csv') {
|
||||
const header = 'filename,original_filename,feedback_type';
|
||||
const rows = selections.map(
|
||||
(s) =>
|
||||
`${escapeCsvCell(s.filename)},${escapeCsvCell(s.original_filename)},${escapeCsvCell(s.feedback_type)}`
|
||||
);
|
||||
body = [header, ...rows].join('\n');
|
||||
} else {
|
||||
// txt — just filenames
|
||||
body = selections.map((s) => s.original_filename || s.filename).join('\n');
|
||||
}
|
||||
archive.append(body, { name: filename });
|
||||
}
|
||||
|
||||
await archive.finalize();
|
||||
} catch (error) {
|
||||
logger.error('Error exporting all guests:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Failed to export guests' });
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// GET /admin/events/:eventId/guests/:guestId — guest detail with selections
|
||||
// (Phase 2)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.get(
|
||||
'/events/:eventId/guests/:guestId',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId, guestId } = req.params;
|
||||
const guest = await loadGuestOr404(eventId, guestId, res);
|
||||
if (!guest) return;
|
||||
|
||||
const feedback = await db('photo_feedback')
|
||||
.join('photos', 'photo_feedback.photo_id', 'photos.id')
|
||||
.where('photo_feedback.guest_id', guestId)
|
||||
.select(
|
||||
'photo_feedback.id as feedback_id',
|
||||
'photo_feedback.feedback_type',
|
||||
'photo_feedback.rating',
|
||||
'photo_feedback.comment_text',
|
||||
'photo_feedback.created_at',
|
||||
'photos.id as photo_id',
|
||||
'photos.filename',
|
||||
'photos.original_filename',
|
||||
'photos.type'
|
||||
)
|
||||
.orderBy('photo_feedback.created_at', 'desc');
|
||||
|
||||
const photoFor = (row) => ({
|
||||
id: row.photo_id,
|
||||
filename: row.filename,
|
||||
original_filename: row.original_filename,
|
||||
type: row.type,
|
||||
url: `/admin/photos/${eventId}/photo/${row.photo_id}`,
|
||||
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${row.photo_id}`,
|
||||
});
|
||||
|
||||
const selections = {
|
||||
liked: [],
|
||||
favorited: [],
|
||||
rated: [],
|
||||
commented: [],
|
||||
};
|
||||
for (const row of feedback) {
|
||||
if (row.feedback_type === 'like') {
|
||||
selections.liked.push(photoFor(row));
|
||||
} else if (row.feedback_type === 'favorite') {
|
||||
selections.favorited.push(photoFor(row));
|
||||
} else if (row.feedback_type === 'rating') {
|
||||
selections.rated.push({ photo: photoFor(row), rating: row.rating });
|
||||
} else if (row.feedback_type === 'comment') {
|
||||
selections.commented.push({
|
||||
photo: photoFor(row),
|
||||
comment: row.comment_text,
|
||||
created_at: row.created_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
guest: {
|
||||
...serializeGuest(guest),
|
||||
stats: {
|
||||
likes: selections.liked.length,
|
||||
favorites: selections.favorited.length,
|
||||
comments: selections.commented.length,
|
||||
ratings: selections.rated.length,
|
||||
},
|
||||
},
|
||||
selections,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching guest detail:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch guest detail' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// GET /admin/events/:eventId/guests/:guestId/export — per-guest export
|
||||
// Query: format=txt|csv|json
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.get(
|
||||
'/events/:eventId/guests/:guestId/export',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId, guestId } = req.params;
|
||||
const format = ['txt', 'csv', 'json'].includes(req.query.format) ? req.query.format : 'txt';
|
||||
const guest = await loadGuestOr404(eventId, guestId, res);
|
||||
if (!guest) return;
|
||||
|
||||
const selections = await db('photo_feedback')
|
||||
.join('photos', 'photo_feedback.photo_id', 'photos.id')
|
||||
.where('photo_feedback.guest_id', guestId)
|
||||
.whereIn('photo_feedback.feedback_type', ['like', 'favorite'])
|
||||
.select('photos.filename', 'photos.original_filename', 'photo_feedback.feedback_type');
|
||||
|
||||
const safeName = guest.name.replace(/[^a-zA-Z0-9_-]/g, '_') || `guest_${guest.id}`;
|
||||
const filename = `${safeName}.${format}`;
|
||||
|
||||
if (format === 'json') {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
return res.send(JSON.stringify({ guest: serializeGuest(guest), selections }, null, 2));
|
||||
}
|
||||
if (format === 'csv') {
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
const header = 'filename,original_filename,feedback_type';
|
||||
const rows = selections.map(
|
||||
(s) =>
|
||||
`${escapeCsvCell(s.filename)},${escapeCsvCell(s.original_filename)},${escapeCsvCell(s.feedback_type)}`
|
||||
);
|
||||
return res.send([header, ...rows].join('\n'));
|
||||
}
|
||||
// txt — one filename per line
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
return res.send(selections.map((s) => s.original_filename || s.filename).join('\n'));
|
||||
} catch (error) {
|
||||
logger.error('Error exporting guest:', error);
|
||||
res.status(500).json({ error: 'Failed to export guest' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// DELETE /admin/events/:eventId/guests/:guestId — anonymize (soft delete)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.delete(
|
||||
'/events/:eventId/guests/:guestId',
|
||||
adminAuth,
|
||||
requirePermission('events.edit'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId, guestId } = req.params;
|
||||
const guest = await loadGuestOr404(eventId, guestId, res);
|
||||
if (!guest) return;
|
||||
|
||||
const result = await feedbackService.anonymizeGuestFeedback(guestId);
|
||||
|
||||
await db('gallery_guests').where({ id: guestId }).update({
|
||||
is_deleted: true,
|
||||
name: 'Removed',
|
||||
email: null,
|
||||
last_seen_at: db.fn.now(),
|
||||
});
|
||||
|
||||
await logActivity(
|
||||
'guest_deleted',
|
||||
{ event_id: eventId, guest_id: guestId, anonymized: result.anonymized },
|
||||
eventId,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ success: true, ...result });
|
||||
} catch (error) {
|
||||
logger.error('Error deleting guest:', error);
|
||||
res.status(500).json({ error: 'Failed to delete guest' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// POST /admin/events/:eventId/guests/:keepId/merge — merge guests (Phase 3.4)
|
||||
// Body: { mergeIds: number[] }
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.post(
|
||||
'/events/:eventId/guests/:keepId/merge',
|
||||
adminAuth,
|
||||
requirePermission('events.edit'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId, keepId } = req.params;
|
||||
const mergeIds = Array.isArray(req.body?.mergeIds) ? req.body.mergeIds : [];
|
||||
|
||||
if (mergeIds.length === 0) {
|
||||
return res.status(400).json({ error: 'mergeIds is required' });
|
||||
}
|
||||
if (mergeIds.includes(Number(keepId))) {
|
||||
return res.status(400).json({ error: 'Cannot merge a guest into itself' });
|
||||
}
|
||||
|
||||
// Sanity check: all guests belong to this event.
|
||||
const all = await db('gallery_guests')
|
||||
.whereIn('id', [Number(keepId), ...mergeIds.map(Number)])
|
||||
.where({ event_id: eventId });
|
||||
if (all.length !== mergeIds.length + 1) {
|
||||
return res.status(400).json({ error: 'All guests must belong to the same event' });
|
||||
}
|
||||
|
||||
const result = await feedbackService.mergeGuestFeedback(Number(keepId), mergeIds.map(Number));
|
||||
|
||||
// Soft-delete the merged (source) guests.
|
||||
await db('gallery_guests')
|
||||
.whereIn('id', mergeIds.map(Number))
|
||||
.update({ is_deleted: true, last_seen_at: db.fn.now() });
|
||||
|
||||
await logActivity(
|
||||
'guest_merged',
|
||||
{ event_id: eventId, keep_id: keepId, merged_ids: mergeIds },
|
||||
eventId,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ success: true, ...result });
|
||||
} catch (error) {
|
||||
logger.error('Error merging guests:', error);
|
||||
res.status(500).json({ error: 'Failed to merge guests' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1199,10 +1199,12 @@ router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) =>
|
||||
res.json({
|
||||
feedback_enabled: settings.feedback_enabled || false,
|
||||
allow_ratings: settings.allow_ratings,
|
||||
allow_likes: settings.allow_likes,
|
||||
allow_likes: settings.allow_likes,
|
||||
allow_comments: settings.allow_comments,
|
||||
allow_favorites: settings.allow_favorites,
|
||||
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,
|
||||
identity_mode: settings.identity_mode || 'simple'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching feedback settings:', error);
|
||||
|
||||
@@ -3,6 +3,7 @@ const router = express.Router();
|
||||
const { photoAuth } = require('../middleware/photoAuth');
|
||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
const { feedbackRateLimit, generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
|
||||
const { resolveGuest } = require('../middleware/guestAuth');
|
||||
const feedbackService = require('../services/feedbackService');
|
||||
const feedbackModeration = require('../services/feedbackModeration');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
@@ -22,7 +23,7 @@ router.get('/:slug/feedback-settings',
|
||||
try {
|
||||
const event = req.event;
|
||||
const settings = await feedbackService.getEventFeedbackSettings(event.id);
|
||||
|
||||
|
||||
// Only send relevant settings to guests
|
||||
// Convert SQLite boolean values (0/1) to proper booleans
|
||||
const guestSettings = {
|
||||
@@ -32,9 +33,10 @@ router.get('/:slug/feedback-settings',
|
||||
allow_comments: Boolean(settings.allow_comments),
|
||||
allow_favorites: Boolean(settings.allow_favorites),
|
||||
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'
|
||||
};
|
||||
|
||||
|
||||
res.json(guestSettings);
|
||||
} catch (error) {
|
||||
logger.error('Error getting feedback settings:', error);
|
||||
@@ -46,6 +48,7 @@ router.get('/:slug/feedback-settings',
|
||||
// Get feedback for a specific photo
|
||||
router.get('/:slug/photos/:photoId/feedback',
|
||||
verifyGalleryAccess,
|
||||
resolveGuest,
|
||||
validatePhotoId,
|
||||
checkValidation,
|
||||
async (req, res) => {
|
||||
@@ -137,6 +140,7 @@ router.get('/:slug/photos/:photoId/feedback',
|
||||
// Submit feedback for a photo
|
||||
router.post('/:slug/photos/:photoId/feedback',
|
||||
verifyGalleryAccess,
|
||||
resolveGuest,
|
||||
validatePhotoId,
|
||||
validateFeedbackSubmission,
|
||||
checkValidation,
|
||||
@@ -144,15 +148,28 @@ router.post('/:slug/photos/:photoId/feedback',
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
const event = req.event;
|
||||
const guestIdentifier = generateGuestIdentifier(req);
|
||||
|
||||
// Get feedback settings
|
||||
|
||||
// Get feedback settings first so we can enforce identity_mode.
|
||||
const settings = await feedbackService.getEventFeedbackSettings(event.id);
|
||||
|
||||
|
||||
if (!settings.feedback_enabled) {
|
||||
return res.status(403).json({ error: 'Feedback is not enabled for this event' });
|
||||
}
|
||||
|
||||
|
||||
// In guest identity mode, a valid guest token is required. The server
|
||||
// never trusts guest_name/guest_email from the body in this mode — it
|
||||
// reads them from the verified token via req.guest.
|
||||
if (settings.identity_mode === 'guest') {
|
||||
if (!req.guest || req.guest.eventId !== event.id) {
|
||||
return res.status(401).json({
|
||||
error: 'Guest identity required',
|
||||
code: 'GUEST_IDENTITY_REQUIRED'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const guestIdentifier = generateGuestIdentifier(req);
|
||||
|
||||
// Check if specific feedback type is allowed
|
||||
const feedbackType = req.body.feedback_type;
|
||||
const typeAllowed = {
|
||||
@@ -161,29 +178,32 @@ router.post('/:slug/photos/:photoId/feedback',
|
||||
comment: settings.allow_comments,
|
||||
favorite: settings.allow_favorites
|
||||
};
|
||||
|
||||
|
||||
if (!typeAllowed[feedbackType]) {
|
||||
return res.status(403).json({ error: `${feedbackType} feedback is not enabled` });
|
||||
}
|
||||
|
||||
|
||||
// Verify photo belongs to event
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: event.id })
|
||||
.first();
|
||||
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Validate guest requirements
|
||||
const guestValidation = await validateGuestRequirements(settings, req.body);
|
||||
if (!guestValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Guest information required',
|
||||
errors: guestValidation.errors
|
||||
});
|
||||
|
||||
// Validate guest requirements only in simple mode. In guest mode, the
|
||||
// identity is already provided via the token and verified above.
|
||||
if (settings.identity_mode !== 'guest') {
|
||||
const guestValidation = await validateGuestRequirements(settings, req.body);
|
||||
if (!guestValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Guest information required',
|
||||
errors: guestValidation.errors
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Apply rate limiting based on feedback type
|
||||
const rateLimitMiddleware = feedbackRateLimit(feedbackType);
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -192,17 +212,19 @@ router.post('/:slug/photos/:photoId/feedback',
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// If we got here and response was sent (rate limited), return
|
||||
if (res.headersSent) return;
|
||||
|
||||
// Prepare feedback data
|
||||
|
||||
// Prepare feedback data. In guest mode, use the verified token as the
|
||||
// source of truth for name/email — never the body.
|
||||
const feedbackData = {
|
||||
feedback_type: feedbackType,
|
||||
rating: req.body.rating,
|
||||
comment_text: req.body.comment_text,
|
||||
guest_name: req.body.guest_name,
|
||||
guest_email: req.body.guest_email,
|
||||
guest_name: req.guest?.name ?? req.body.guest_name,
|
||||
guest_email: req.guest?.email ?? req.body.guest_email,
|
||||
guest_id: req.guest?.id ?? null,
|
||||
ip_address: req.ip || req.connection.remoteAddress,
|
||||
user_agent: (req.headers['user-agent'] || '').replace(/[<>&"']/g, '').substring(0, 255),
|
||||
moderate_comments: settings.moderate_comments
|
||||
@@ -316,22 +338,32 @@ router.get('/:slug/feedback-summary',
|
||||
// Get user's own feedback for all photos
|
||||
router.get('/:slug/my-feedback',
|
||||
verifyGalleryAccess,
|
||||
resolveGuest,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const event = req.event;
|
||||
const guestIdentifier = generateGuestIdentifier(req);
|
||||
|
||||
const myFeedback = await db('photo_feedback')
|
||||
|
||||
const query = db('photo_feedback')
|
||||
.join('photos', 'photo_feedback.photo_id', 'photos.id')
|
||||
.where('photo_feedback.event_id', event.id)
|
||||
.where('photo_feedback.guest_identifier', guestIdentifier)
|
||||
.where('photo_feedback.event_id', event.id);
|
||||
|
||||
// Prefer guest_id lookup when a verified guest token is present
|
||||
// (per-person identity). Fall back to the device hash otherwise.
|
||||
if (req.guest?.id) {
|
||||
query.where('photo_feedback.guest_id', req.guest.id);
|
||||
} else {
|
||||
const guestIdentifier = generateGuestIdentifier(req);
|
||||
query.where('photo_feedback.guest_identifier', guestIdentifier);
|
||||
}
|
||||
|
||||
const myFeedback = await query
|
||||
.select(
|
||||
'photo_feedback.*',
|
||||
'photos.filename',
|
||||
'photos.path'
|
||||
)
|
||||
.orderBy('photo_feedback.created_at', 'desc');
|
||||
|
||||
|
||||
res.json(myFeedback);
|
||||
} catch (error) {
|
||||
logger.error('Error getting user feedback:', error);
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const router = express.Router();
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
const { resolveGuest, requireGuest, signGuestToken } = require('../middleware/guestAuth');
|
||||
const feedbackService = require('../services/feedbackService');
|
||||
const guestRecovery = require('../services/guestRecoveryService');
|
||||
|
||||
const MAX_NAME_LEN = 100;
|
||||
const MAX_EMAIL_LEN = 255;
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
// In-memory rate limit for guest registration (20 per hour per IP). Simple
|
||||
// sliding window; on process restart the counters reset which is acceptable.
|
||||
const registrationAttempts = new Map();
|
||||
const REGISTRATION_WINDOW_MS = 60 * 60 * 1000;
|
||||
const REGISTRATION_MAX = 20;
|
||||
|
||||
function checkRegistrationRate(ip) {
|
||||
const now = Date.now();
|
||||
const entry = registrationAttempts.get(ip) || { count: 0, windowStart: now };
|
||||
if (now - entry.windowStart > REGISTRATION_WINDOW_MS) {
|
||||
entry.count = 0;
|
||||
entry.windowStart = now;
|
||||
}
|
||||
entry.count += 1;
|
||||
registrationAttempts.set(ip, entry);
|
||||
return entry.count <= REGISTRATION_MAX;
|
||||
}
|
||||
|
||||
function sanitizeName(value) {
|
||||
if (typeof value !== 'string') return '';
|
||||
// Strip HTML/control chars, collapse whitespace.
|
||||
const cleaned = value
|
||||
.replace(/[<>&"']/g, '')
|
||||
.replace(/[\u0000-\u001F\u007F]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return cleaned.slice(0, MAX_NAME_LEN);
|
||||
}
|
||||
|
||||
function sanitizeEmail(value) {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value.trim().slice(0, MAX_EMAIL_LEN).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /gallery/:slug/guest
|
||||
* Body: { name, email? }
|
||||
*
|
||||
* Registers a new per-person guest identity for this gallery. Returns a JWT
|
||||
* that the frontend must send as the x-guest-token header on subsequent
|
||||
* feedback requests.
|
||||
*/
|
||||
router.post('/:slug/guest', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
const ip = req.ip || req.connection.remoteAddress || 'unknown';
|
||||
if (!checkRegistrationRate(ip)) {
|
||||
return res.status(429).json({ error: 'Too many registration attempts' });
|
||||
}
|
||||
|
||||
const event = req.event;
|
||||
const settings = await feedbackService.getEventFeedbackSettings(event.id);
|
||||
|
||||
// Guest registration is only meaningful when feedback is enabled.
|
||||
if (!settings.feedback_enabled) {
|
||||
return res.status(403).json({ error: 'Feedback is not enabled for this gallery' });
|
||||
}
|
||||
|
||||
const name = sanitizeName(req.body?.name);
|
||||
if (!name || name.length < 1) {
|
||||
return res.status(400).json({ error: 'Name is required', field: 'name' });
|
||||
}
|
||||
|
||||
let email = sanitizeEmail(req.body?.email);
|
||||
if (email && !EMAIL_REGEX.test(email)) {
|
||||
return res.status(400).json({ error: 'Invalid email format', field: 'email' });
|
||||
}
|
||||
if (settings.require_name_email && !email) {
|
||||
return res.status(400).json({ error: 'Email is required', field: 'email' });
|
||||
}
|
||||
|
||||
const identifier = crypto.randomUUID();
|
||||
const userAgent = (req.headers['user-agent'] || '').substring(0, 500);
|
||||
|
||||
const [row] = await db('gallery_guests')
|
||||
.insert({
|
||||
event_id: event.id,
|
||||
name,
|
||||
email: email || null,
|
||||
identifier,
|
||||
ip_address_last: ip.substring(0, 45),
|
||||
user_agent_last: userAgent,
|
||||
})
|
||||
.returning(['id', 'name', 'email', 'identifier', 'created_at']);
|
||||
|
||||
const token = signGuestToken({
|
||||
guestId: row.id,
|
||||
eventId: event.id,
|
||||
identifier: row.identifier,
|
||||
name: row.name,
|
||||
});
|
||||
|
||||
logger.info('Guest registered', {
|
||||
eventId: event.id,
|
||||
guestId: row.id,
|
||||
name: row.name,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
guest: {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
identifier: row.identifier,
|
||||
},
|
||||
token,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Guest registration failed', { error: error.message });
|
||||
return res.status(500).json({ error: 'Failed to register guest' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /gallery/:slug/guest/me
|
||||
* Returns the current guest profile from a valid guest token. 401 otherwise.
|
||||
*/
|
||||
router.get('/:slug/guest/me', verifyGalleryAccess, resolveGuest, requireGuest, async (req, res) => {
|
||||
try {
|
||||
if (req.guest.eventId !== req.event.id) {
|
||||
return res.status(403).json({ error: 'Guest token does not match gallery' });
|
||||
}
|
||||
|
||||
// Update last_seen_at on each profile fetch (cheap and useful for admin).
|
||||
await db('gallery_guests')
|
||||
.where({ id: req.guest.id })
|
||||
.update({
|
||||
last_seen_at: db.fn.now(),
|
||||
ip_address_last: (req.ip || '').substring(0, 45),
|
||||
user_agent_last: (req.headers['user-agent'] || '').substring(0, 500),
|
||||
});
|
||||
|
||||
return res.json({
|
||||
guest: {
|
||||
id: req.guest.id,
|
||||
name: req.guest.name,
|
||||
email: req.guest.email,
|
||||
identifier: req.guest.identifier,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Guest profile fetch failed', { error: error.message });
|
||||
return res.status(500).json({ error: 'Failed to fetch guest profile' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /gallery/:slug/guest/me
|
||||
*
|
||||
* "Forget me" — soft-deletes the guest row and anonymizes their feedback so
|
||||
* aggregate counts remain stable but personal data is removed.
|
||||
*/
|
||||
router.delete('/:slug/guest/me', verifyGalleryAccess, resolveGuest, requireGuest, async (req, res) => {
|
||||
try {
|
||||
if (req.guest.eventId !== req.event.id) {
|
||||
return res.status(403).json({ error: 'Guest token does not match gallery' });
|
||||
}
|
||||
|
||||
await feedbackService.anonymizeGuestFeedback(req.guest.id);
|
||||
|
||||
await db('gallery_guests')
|
||||
.where({ id: req.guest.id })
|
||||
.update({
|
||||
is_deleted: true,
|
||||
name: 'Removed',
|
||||
email: null,
|
||||
last_seen_at: db.fn.now(),
|
||||
});
|
||||
|
||||
logger.info('Guest self-forgot', {
|
||||
eventId: req.event.id,
|
||||
guestId: req.guest.id,
|
||||
});
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Guest forget-me failed', { error: error.message });
|
||||
return res.status(500).json({ error: 'Failed to forget guest' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 3.2 — Email-based identity recovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Simple in-memory rate limit for recover/verify (5 per hour per IP).
|
||||
const recoveryAttempts = new Map();
|
||||
const VERIFY_WINDOW_MS = 60 * 60 * 1000;
|
||||
const VERIFY_MAX = 20;
|
||||
function checkRecoveryRate(ip) {
|
||||
const now = Date.now();
|
||||
const entry = recoveryAttempts.get(ip) || { count: 0, windowStart: now };
|
||||
if (now - entry.windowStart > VERIFY_WINDOW_MS) {
|
||||
entry.count = 0;
|
||||
entry.windowStart = now;
|
||||
}
|
||||
entry.count += 1;
|
||||
recoveryAttempts.set(ip, entry);
|
||||
return entry.count <= VERIFY_MAX;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /gallery/:slug/guest/recover
|
||||
* Body: { email }
|
||||
*
|
||||
* Sends a 6-digit code to the email if it matches an existing guest. Returns
|
||||
* 200 regardless of whether a matching guest exists (prevents enumeration).
|
||||
*/
|
||||
router.post('/:slug/guest/recover', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
const ip = req.ip || 'unknown';
|
||||
if (!checkRecoveryRate(ip)) {
|
||||
return res.status(429).json({ error: 'Too many recovery attempts' });
|
||||
}
|
||||
|
||||
const email = sanitizeEmail(req.body?.email);
|
||||
if (!email || !EMAIL_REGEX.test(email)) {
|
||||
// Still return 200 to avoid leaking validity of the email field.
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
const event = req.event;
|
||||
const settings = await feedbackService.getEventFeedbackSettings(event.id);
|
||||
if (!settings.feedback_enabled || settings.identity_mode !== 'guest') {
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
const guest = await db('gallery_guests')
|
||||
.where({ event_id: event.id, email, is_deleted: false })
|
||||
.first();
|
||||
|
||||
if (guest) {
|
||||
try {
|
||||
const code = await guestRecovery.createCode(event.id, email);
|
||||
await guestRecovery.sendRecoveryEmail(email, code, event.event_name || 'your gallery');
|
||||
} catch (sendError) {
|
||||
logger.error('Failed to send recovery email', { error: sendError.message });
|
||||
// Still return 200 so clients can't distinguish failures.
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Guest recovery request failed', { error: error.message });
|
||||
return res.json({ success: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /gallery/:slug/guest/verify
|
||||
* Body: { email, code }
|
||||
*
|
||||
* Exchanges a valid verification code for a guest token. Reuses the existing
|
||||
* guest row associated with the email (the guest continues where they left
|
||||
* off, cross-device).
|
||||
*/
|
||||
router.post('/:slug/guest/verify', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
const ip = req.ip || 'unknown';
|
||||
if (!checkRecoveryRate(ip)) {
|
||||
return res.status(429).json({ error: 'Too many verification attempts' });
|
||||
}
|
||||
|
||||
const email = sanitizeEmail(req.body?.email);
|
||||
const code = String(req.body?.code || '').trim();
|
||||
if (!email || !code) {
|
||||
return res.status(400).json({ error: 'Email and code are required' });
|
||||
}
|
||||
|
||||
const event = req.event;
|
||||
const verifyResult = await guestRecovery.verifyCode(event.id, email, code);
|
||||
if (!verifyResult.ok) {
|
||||
return res.status(401).json({ error: 'Invalid or expired code', reason: verifyResult.reason });
|
||||
}
|
||||
|
||||
const guest = await db('gallery_guests')
|
||||
.where({ event_id: event.id, email, is_deleted: false })
|
||||
.first();
|
||||
if (!guest) {
|
||||
return res.status(404).json({ error: 'Guest not found' });
|
||||
}
|
||||
|
||||
await db('gallery_guests')
|
||||
.where({ id: guest.id })
|
||||
.update({
|
||||
email_verified_at: guest.email_verified_at || db.fn.now(),
|
||||
last_seen_at: db.fn.now(),
|
||||
ip_address_last: (req.ip || '').substring(0, 45),
|
||||
});
|
||||
|
||||
const token = signGuestToken({
|
||||
guestId: guest.id,
|
||||
eventId: event.id,
|
||||
identifier: guest.identifier,
|
||||
name: guest.name,
|
||||
});
|
||||
|
||||
logger.info('Guest recovered via email', { eventId: event.id, guestId: guest.id });
|
||||
|
||||
return res.json({
|
||||
guest: {
|
||||
id: guest.id,
|
||||
name: guest.name,
|
||||
email: guest.email,
|
||||
identifier: guest.identifier,
|
||||
},
|
||||
token,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Guest verify failed', { error: error.message });
|
||||
return res.status(500).json({ error: 'Failed to verify code' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 3.3 — Invite token redemption
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* POST /gallery/:slug/guest/redeem
|
||||
* Body: { inviteToken }
|
||||
*
|
||||
* Redeems a pre-minted invite token (created by admin). Single use.
|
||||
*/
|
||||
router.post('/:slug/guest/redeem', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
const inviteToken = String(req.body?.inviteToken || '').trim();
|
||||
if (!inviteToken) {
|
||||
return res.status(400).json({ error: 'Invite token required' });
|
||||
}
|
||||
|
||||
const event = req.event;
|
||||
|
||||
const result = await db.transaction(async (trx) => {
|
||||
const invite = await trx('guest_invites')
|
||||
.where({ token: inviteToken, event_id: event.id })
|
||||
.first();
|
||||
if (!invite) return { error: 'not_found' };
|
||||
if (invite.revoked_at) return { error: 'revoked' };
|
||||
if (invite.redeemed_at) return { error: 'already_redeemed' };
|
||||
|
||||
const guest = await trx('gallery_guests')
|
||||
.where({ id: invite.guest_id, is_deleted: false })
|
||||
.first();
|
||||
if (!guest) return { error: 'guest_missing' };
|
||||
|
||||
await trx('guest_invites')
|
||||
.where({ id: invite.id })
|
||||
.update({ redeemed_at: trx.fn.now() });
|
||||
|
||||
await trx('gallery_guests')
|
||||
.where({ id: guest.id })
|
||||
.update({
|
||||
last_seen_at: trx.fn.now(),
|
||||
ip_address_last: (req.ip || '').substring(0, 45),
|
||||
user_agent_last: (req.headers['user-agent'] || '').substring(0, 500),
|
||||
});
|
||||
|
||||
return { guest };
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
const statusMap = {
|
||||
not_found: 404,
|
||||
revoked: 410,
|
||||
already_redeemed: 409,
|
||||
guest_missing: 404,
|
||||
};
|
||||
return res.status(statusMap[result.error] || 400).json({ error: result.error });
|
||||
}
|
||||
|
||||
const token = signGuestToken({
|
||||
guestId: result.guest.id,
|
||||
eventId: event.id,
|
||||
identifier: result.guest.identifier,
|
||||
name: result.guest.name,
|
||||
});
|
||||
|
||||
logger.info('Invite redeemed', { eventId: event.id, guestId: result.guest.id });
|
||||
|
||||
return res.json({
|
||||
guest: {
|
||||
id: result.guest.id,
|
||||
name: result.guest.name,
|
||||
email: result.guest.email,
|
||||
identifier: result.guest.identifier,
|
||||
},
|
||||
token,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Invite redemption failed', { error: error.message });
|
||||
return res.status(500).json({ error: 'Failed to redeem invite' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -23,10 +23,15 @@ class FeedbackService {
|
||||
allow_favorites: true,
|
||||
require_name_email: false,
|
||||
moderate_comments: true,
|
||||
show_feedback_to_guests: true
|
||||
show_feedback_to_guests: true,
|
||||
identity_mode: 'simple'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Back-compat: rows created before migration 078 have NULL identity_mode.
|
||||
if (!settings.identity_mode) {
|
||||
settings.identity_mode = 'simple';
|
||||
}
|
||||
return settings;
|
||||
} catch (error) {
|
||||
logger.error('Error getting feedback settings:', error);
|
||||
@@ -73,23 +78,29 @@ class FeedbackService {
|
||||
*/
|
||||
async submitFeedback(photoId, eventId, feedbackData, guestIdentifier) {
|
||||
try {
|
||||
const { feedback_type, rating, comment_text, guest_name, guest_email, ip_address, user_agent } = feedbackData;
|
||||
const { feedback_type, rating, comment_text, guest_name, guest_email, ip_address, user_agent, guest_id } = feedbackData;
|
||||
|
||||
// Validate feedback type
|
||||
if (!['rating', 'like', 'comment', 'favorite'].includes(feedback_type)) {
|
||||
throw new Error('Invalid feedback type');
|
||||
}
|
||||
|
||||
// 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
|
||||
// so two guests on the same device can independently like a photo.
|
||||
if (feedback_type !== 'comment') {
|
||||
const existing = await db('photo_feedback')
|
||||
const duplicateQuery = db('photo_feedback')
|
||||
.where({
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
feedback_type,
|
||||
guest_identifier: guestIdentifier
|
||||
})
|
||||
.first();
|
||||
});
|
||||
if (guest_id) {
|
||||
duplicateQuery.where('guest_id', guest_id);
|
||||
} else {
|
||||
duplicateQuery.where('guest_identifier', guestIdentifier);
|
||||
}
|
||||
const existing = await duplicateQuery.first();
|
||||
|
||||
if (existing) {
|
||||
if (feedback_type === 'rating' && rating !== existing.rating) {
|
||||
@@ -129,6 +140,7 @@ class FeedbackService {
|
||||
guest_name,
|
||||
guest_email,
|
||||
guest_identifier: guestIdentifier,
|
||||
guest_id: guest_id || null,
|
||||
ip_address,
|
||||
user_agent,
|
||||
is_approved: feedback_type !== 'comment' || !feedbackData.moderate_comments,
|
||||
@@ -232,7 +244,7 @@ class FeedbackService {
|
||||
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('AVG(CASE WHEN feedback_type = ? THEN rating END) as average_rating', ['rating']),
|
||||
db.raw('COUNT(DISTINCT guest_identifier) as feedback_count')
|
||||
db.raw('COUNT(DISTINCT COALESCE(CAST(guest_id AS VARCHAR), guest_identifier)) as feedback_count')
|
||||
)
|
||||
.first();
|
||||
|
||||
@@ -460,6 +472,75 @@ class FeedbackService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Anonymize feedback belonging to a guest — sets guest_id to NULL on all
|
||||
* their feedback rows and clears guest_name/guest_email for privacy, then
|
||||
* recomputes denormalized photo counts on affected photos.
|
||||
*
|
||||
* Used by self-service "forget me" and admin guest deletion.
|
||||
*/
|
||||
async anonymizeGuestFeedback(guestId) {
|
||||
try {
|
||||
const affected = await db('photo_feedback')
|
||||
.where('guest_id', guestId)
|
||||
.select('photo_id');
|
||||
const photoIds = [...new Set(affected.map((r) => r.photo_id))];
|
||||
|
||||
await db('photo_feedback')
|
||||
.where('guest_id', guestId)
|
||||
.update({
|
||||
guest_id: null,
|
||||
guest_name: null,
|
||||
guest_email: null,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
for (const pid of photoIds) {
|
||||
await this.updatePhotoFeedbackStats(pid);
|
||||
}
|
||||
|
||||
return { anonymized: affected.length, photos: photoIds.length };
|
||||
} catch (error) {
|
||||
logger.error('Error anonymizing guest feedback:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge feedback rows from sourceGuestIds into keepGuestId. Used by admin
|
||||
* guest merge and email-based identity recovery when a user re-registers.
|
||||
* Recomputes denormalized counts on affected photos.
|
||||
*/
|
||||
async mergeGuestFeedback(keepGuestId, sourceGuestIds) {
|
||||
try {
|
||||
const sources = (sourceGuestIds || []).filter((id) => id && id !== keepGuestId);
|
||||
if (sources.length === 0) {
|
||||
return { merged: 0, photos: 0 };
|
||||
}
|
||||
|
||||
const affected = await db('photo_feedback')
|
||||
.whereIn('guest_id', sources)
|
||||
.select('photo_id');
|
||||
const photoIds = [...new Set(affected.map((r) => r.photo_id))];
|
||||
|
||||
await db('photo_feedback')
|
||||
.whereIn('guest_id', sources)
|
||||
.update({
|
||||
guest_id: keepGuestId,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
for (const pid of photoIds) {
|
||||
await this.updatePhotoFeedbackStats(pid);
|
||||
}
|
||||
|
||||
return { merged: affected.length, photos: photoIds.length };
|
||||
} catch (error) {
|
||||
logger.error('Error merging guest feedback:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new FeedbackService();
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Guest identity recovery service (Phase 3.2).
|
||||
*
|
||||
* Sends a short-lived 6-digit verification code to a guest's email address
|
||||
* so they can re-link their identity across devices. The code is stored as
|
||||
* a bcrypt hash in `guest_verification_codes` with a 15-minute expiry.
|
||||
*
|
||||
* Uses the email transporter from emailProcessor — no new template row is
|
||||
* needed; the email body is built inline so this works out of the box.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { initializeTransporter, wrapEmailHtml } = require('./emailProcessor');
|
||||
|
||||
const CODE_TTL_MS = 15 * 60 * 1000;
|
||||
const MAX_ATTEMPTS = 5;
|
||||
|
||||
function generateCode() {
|
||||
// 6 digits, zero-padded.
|
||||
return String(crypto.randomInt(0, 1_000_000)).padStart(6, '0');
|
||||
}
|
||||
|
||||
async function createCode(eventId, email) {
|
||||
const code = generateCode();
|
||||
const codeHash = await bcrypt.hash(code, 10);
|
||||
const expiresAt = new Date(Date.now() + CODE_TTL_MS);
|
||||
|
||||
// Invalidate any previous unconsumed codes for this email+event.
|
||||
await db('guest_verification_codes')
|
||||
.where({ event_id: eventId, email: email.toLowerCase() })
|
||||
.whereNull('consumed_at')
|
||||
.update({ consumed_at: db.fn.now() });
|
||||
|
||||
await db('guest_verification_codes').insert({
|
||||
event_id: eventId,
|
||||
email: email.toLowerCase(),
|
||||
code_hash: codeHash,
|
||||
expires_at: expiresAt,
|
||||
});
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
async function sendRecoveryEmail(toEmail, code, eventName = 'your gallery') {
|
||||
const transporter = await initializeTransporter();
|
||||
if (!transporter) {
|
||||
throw new Error('Email service not configured');
|
||||
}
|
||||
|
||||
const config = await db('email_configs').first();
|
||||
if (!config) {
|
||||
throw new Error('Email configuration not found');
|
||||
}
|
||||
|
||||
const subject = `Your verification code: ${code}`;
|
||||
const htmlBody = `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 600px;">
|
||||
<h2>Welcome back to ${eventName}</h2>
|
||||
<p>Enter this code to recover your picks in the gallery:</p>
|
||||
<div style="font-size: 32px; font-weight: bold; letter-spacing: 8px; background: #f5f5f5; padding: 20px; text-align: center; border-radius: 8px; margin: 20px 0;">
|
||||
${code}
|
||||
</div>
|
||||
<p style="color: #666; font-size: 14px;">
|
||||
This code expires in 15 minutes. If you did not request it, you can safely ignore this email.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
const styledHtml = await wrapEmailHtml(htmlBody, subject, 'en');
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `${config.from_name} <${config.from_email}>`,
|
||||
to: toEmail,
|
||||
subject,
|
||||
html: styledHtml,
|
||||
text: `Your verification code is ${code}. It expires in 15 minutes.`,
|
||||
});
|
||||
|
||||
logger.info('Guest recovery code sent', { email: toEmail });
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a code. Returns true if valid + marks it consumed.
|
||||
* Increments attempts on failure. Rejects after MAX_ATTEMPTS.
|
||||
*/
|
||||
async function verifyCode(eventId, email, submittedCode) {
|
||||
const normalized = String(submittedCode || '').trim();
|
||||
if (!/^\d{6}$/.test(normalized)) {
|
||||
return { ok: false, reason: 'invalid_format' };
|
||||
}
|
||||
|
||||
const row = await db('guest_verification_codes')
|
||||
.where({ event_id: eventId, email: email.toLowerCase() })
|
||||
.whereNull('consumed_at')
|
||||
.andWhere('expires_at', '>', new Date())
|
||||
.orderBy('created_at', 'desc')
|
||||
.first();
|
||||
|
||||
if (!row) {
|
||||
return { ok: false, reason: 'expired_or_missing' };
|
||||
}
|
||||
|
||||
if (row.attempts >= MAX_ATTEMPTS) {
|
||||
await db('guest_verification_codes').where('id', row.id).update({ consumed_at: db.fn.now() });
|
||||
return { ok: false, reason: 'too_many_attempts' };
|
||||
}
|
||||
|
||||
const matches = await bcrypt.compare(normalized, row.code_hash);
|
||||
if (!matches) {
|
||||
await db('guest_verification_codes')
|
||||
.where('id', row.id)
|
||||
.update({ attempts: row.attempts + 1 });
|
||||
return { ok: false, reason: 'wrong_code' };
|
||||
}
|
||||
|
||||
await db('guest_verification_codes')
|
||||
.where('id', row.id)
|
||||
.update({ consumed_at: db.fn.now() });
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createCode,
|
||||
sendRecoveryEmail,
|
||||
verifyCode,
|
||||
CODE_TTL_MS,
|
||||
MAX_ATTEMPTS,
|
||||
};
|
||||
@@ -184,7 +184,9 @@ const validateFeedbackSettings = [
|
||||
body('allow_favorites').optional().isBoolean(),
|
||||
body('require_name_email').optional().isBoolean(),
|
||||
body('moderate_comments').optional().isBoolean(),
|
||||
body('show_feedback_to_guests').optional().isBoolean()
|
||||
body('show_feedback_to_guests').optional().isBoolean(),
|
||||
body('identity_mode').optional().isIn(['simple', 'guest'])
|
||||
.withMessage('identity_mode must be "simple" or "guest"')
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const ADMIN_COOKIE_NAME = 'admin_token';
|
||||
const GALLERY_COOKIE_NAME = 'gallery_token';
|
||||
const GALLERY_COOKIE_PREFIX = 'gallery_token_';
|
||||
const GUEST_COOKIE_PREFIX = 'guest_token_';
|
||||
|
||||
const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
@@ -114,10 +115,37 @@ function getGalleryTokenFromRequest(req, slug) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getGuestTokenFromRequest(req, slug) {
|
||||
// Primary transport: custom header (set by frontend axios interceptor).
|
||||
const headerToken = req.headers?.['x-guest-token'];
|
||||
if (headerToken) {
|
||||
return headerToken;
|
||||
}
|
||||
|
||||
if (!req.cookies) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (slug) {
|
||||
const cookieName = `${GUEST_COOKIE_PREFIX}${sanitizeSlugForCookie(slug)}`;
|
||||
if (req.cookies[cookieName]) {
|
||||
return req.cookies[cookieName];
|
||||
}
|
||||
}
|
||||
|
||||
const prefixed = Object.keys(req.cookies).find((name) => name.startsWith(GUEST_COOKIE_PREFIX));
|
||||
if (prefixed) {
|
||||
return req.cookies[prefixed];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ADMIN_COOKIE_NAME,
|
||||
GALLERY_COOKIE_NAME,
|
||||
GALLERY_COOKIE_PREFIX,
|
||||
GUEST_COOKIE_PREFIX,
|
||||
sanitizeSlugForCookie,
|
||||
setAdminAuthCookie,
|
||||
clearAdminAuthCookie,
|
||||
@@ -125,4 +153,5 @@ module.exports = {
|
||||
clearGalleryAuthCookies,
|
||||
getAdminTokenFromRequest,
|
||||
getGalleryTokenFromRequest,
|
||||
getGuestTokenFromRequest,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Heart, Bookmark, Star, MessageCircle } from 'lucide-react';
|
||||
import { Loading } from '../common';
|
||||
import { guestsService, AdminGuest } from '../../services/guests.service';
|
||||
import { AuthenticatedImage } from '../common/AuthenticatedImage';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
|
||||
interface AdminGuestDetailProps {
|
||||
eventId: number;
|
||||
guest: AdminGuest;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Tab = 'all' | 'liked' | 'favorited' | 'rated' | 'commented';
|
||||
|
||||
export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, guest, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<Tab>('all');
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-guest-detail', eventId, guest.id],
|
||||
queryFn: () => guestsService.getGuestDetail(eventId, guest.id),
|
||||
});
|
||||
|
||||
const selections = data?.selections;
|
||||
const liked = selections?.liked || [];
|
||||
const favorited = selections?.favorited || [];
|
||||
const rated = selections?.rated || [];
|
||||
const commented = selections?.commented || [];
|
||||
|
||||
// "all" view combines the three visual selection types.
|
||||
type GridItem = { photo: { id: number; filename: string; thumbnail_url: string }; badges: string[] };
|
||||
const allItems: GridItem[] = [];
|
||||
const seen = new Map<number, GridItem>();
|
||||
const add = (photo: { id: number; filename: string; thumbnail_url: string }, badge: string) => {
|
||||
if (!seen.has(photo.id)) {
|
||||
const item: GridItem = { photo, badges: [badge] };
|
||||
seen.set(photo.id, item);
|
||||
allItems.push(item);
|
||||
} else {
|
||||
seen.get(photo.id)!.badges.push(badge);
|
||||
}
|
||||
};
|
||||
liked.forEach((p) => add(p, 'like'));
|
||||
favorited.forEach((p) => add(p, 'favorite'));
|
||||
rated.forEach((r) => add(r.photo, 'rating'));
|
||||
|
||||
const visibleItems: GridItem[] =
|
||||
tab === 'all'
|
||||
? allItems
|
||||
: tab === 'liked'
|
||||
? liked.map((p) => ({ photo: p, badges: ['like'] }))
|
||||
: tab === 'favorited'
|
||||
? favorited.map((p) => ({ photo: p, badges: ['favorite'] }))
|
||||
: tab === 'rated'
|
||||
? rated.map((r) => ({ photo: r.photo, badges: [`${r.rating}★`] }))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto p-4 pt-16">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative bg-white dark:bg-neutral-900 rounded-lg shadow-xl w-full max-w-5xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="p-4 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{guest.name}</h2>
|
||||
{guest.email && (
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">{guest.email}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="p-8">
|
||||
<Loading size="lg" text={t('admin.guests.loadingDetail', 'Loading selections...')} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-y-auto p-4">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-2 mb-4">
|
||||
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded text-center">
|
||||
<div className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{liked.length}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
|
||||
<Heart className="w-3 h-3" />
|
||||
{t('admin.guests.columns.likes', 'Likes')}
|
||||
</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">
|
||||
{favorited.length}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
|
||||
<Bookmark className="w-3 h-3" />
|
||||
{t('admin.guests.columns.favorites', 'Favorites')}
|
||||
</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">
|
||||
{rated.length}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
|
||||
<Star className="w-3 h-3" />
|
||||
{t('admin.guests.columns.ratings', 'Ratings')}
|
||||
</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">
|
||||
{commented.length}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
|
||||
<MessageCircle className="w-3 h-3" />
|
||||
{t('admin.guests.columns.comments', 'Comments')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-neutral-200 dark:border-neutral-700 mb-4">
|
||||
{(['all', 'liked', 'favorited', 'rated', 'commented'] as const).map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => setTab(k)}
|
||||
className={`px-3 py-2 text-sm font-medium border-b-2 transition ${
|
||||
tab === k
|
||||
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'border-transparent text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100'
|
||||
}`}
|
||||
>
|
||||
{t(`admin.guests.detail.${k}`, k)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{tab === 'commented' ? (
|
||||
commented.length === 0 ? (
|
||||
<div className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-8">
|
||||
{t('admin.guests.detail.noComments', 'No comments')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{commented.map((c, idx) => (
|
||||
<div key={idx} className="flex gap-3 p-3 bg-neutral-50 dark:bg-neutral-800 rounded">
|
||||
<AuthenticatedImage
|
||||
src={buildResourceUrl(c.photo.thumbnail_url)}
|
||||
alt={c.photo.filename}
|
||||
className="w-16 h-16 object-cover rounded flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{c.photo.filename} · {new Date(c.created_at).toLocaleString()}
|
||||
</div>
|
||||
<p className="text-sm text-neutral-900 dark:text-neutral-100 mt-1">{c.comment}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : visibleItems.length === 0 ? (
|
||||
<div className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-8">
|
||||
{t('admin.guests.detail.empty', 'No selections in this category')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
|
||||
{visibleItems.map((item) => (
|
||||
<div key={item.photo.id} className="relative group">
|
||||
<AuthenticatedImage
|
||||
src={buildResourceUrl(item.photo.thumbnail_url)}
|
||||
alt={item.photo.filename}
|
||||
className="w-full aspect-square object-cover rounded"
|
||||
/>
|
||||
<div className="absolute top-1 right-1 flex gap-1">
|
||||
{item.badges.map((b, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="bg-black/60 text-white text-xs px-1.5 py-0.5 rounded"
|
||||
>
|
||||
{b === 'like' ? '♥' : b === 'favorite' ? '★' : b}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent opacity-0 group-hover:opacity-100 transition-opacity text-white text-xs p-2 rounded-b">
|
||||
{item.photo.filename}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,344 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Trash2, Eye, Download, UserPlus, Grid3x3, List } from 'lucide-react';
|
||||
import { Card, Button, Loading } from '../common';
|
||||
import { guestsService, AdminGuest } from '../../services/guests.service';
|
||||
import { AdminGuestDetail } from './AdminGuestDetail';
|
||||
import { GuestSelectionsAggregate } from './GuestSelectionsAggregate';
|
||||
import { GuestInviteDialog } from './GuestInviteDialog';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface AdminGuestsListProps {
|
||||
eventId: number;
|
||||
eventName?: string;
|
||||
}
|
||||
|
||||
type View = 'list' | 'aggregate';
|
||||
|
||||
export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, eventName }) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [view, setView] = useState<View>('list');
|
||||
const [selectedGuest, setSelectedGuest] = useState<AdminGuest | null>(null);
|
||||
const [mergeMode, setMergeMode] = useState(false);
|
||||
const [mergeSelection, setMergeSelection] = useState<number[]>([]);
|
||||
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['admin-guests', eventId],
|
||||
queryFn: () => guestsService.getEventGuests(eventId),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (guestId: number) => guestsService.deleteGuest(eventId, guestId),
|
||||
onSuccess: () => {
|
||||
toast.success(t('admin.guests.deletedToast', 'Guest removed'));
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-guests', eventId] });
|
||||
},
|
||||
onError: () => toast.error(t('admin.guests.deletedError', 'Failed to remove guest')),
|
||||
});
|
||||
|
||||
const mergeMutation = useMutation({
|
||||
mutationFn: ({ keepId, mergeIds }: { keepId: number; mergeIds: number[] }) =>
|
||||
guestsService.mergeGuests(eventId, keepId, mergeIds),
|
||||
onSuccess: () => {
|
||||
toast.success(t('admin.guests.mergedToast', 'Guests merged'));
|
||||
setMergeMode(false);
|
||||
setMergeSelection([]);
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-guests', eventId] });
|
||||
},
|
||||
onError: () => toast.error(t('admin.guests.mergedError', 'Failed to merge guests')),
|
||||
});
|
||||
|
||||
const handleDelete = (guest: AdminGuest) => {
|
||||
if (window.confirm(t('admin.guests.forgetGuestConfirm', 'Remove this guest? Their picks will be anonymized but kept in aggregate totals.'))) {
|
||||
deleteMutation.mutate(guest.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async (guest: AdminGuest, format: 'txt' | 'csv' | 'json') => {
|
||||
try {
|
||||
const blob = await guestsService.exportGuest(eventId, guest.id, format);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${guest.name.replace(/[^a-zA-Z0-9_-]/g, '_')}.${format}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
toast.error(t('admin.guests.exportError', 'Export failed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportAll = async (format: 'txt' | 'csv' | 'json') => {
|
||||
try {
|
||||
const blob = await guestsService.exportAllGuests(eventId, format);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `event-${eventId}-guests.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
toast.error(t('admin.guests.exportError', 'Export failed'));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMergeSelection = (id: number) => {
|
||||
setMergeSelection((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const performMerge = () => {
|
||||
if (mergeSelection.length < 2) {
|
||||
toast.warning(t('admin.guests.mergeSelectAtLeastTwo', 'Select at least 2 guests to merge'));
|
||||
return;
|
||||
}
|
||||
const [keepId, ...mergeIds] = mergeSelection;
|
||||
const keepName = data?.guests.find((g) => g.id === keepId)?.name;
|
||||
const confirmMsg = t(
|
||||
'admin.guests.mergeConfirm',
|
||||
'Merge {{count}} guests into {{name}}? This cannot be undone.',
|
||||
{ count: mergeSelection.length, name: keepName || '#' + keepId }
|
||||
);
|
||||
if (window.confirm(confirmMsg)) {
|
||||
mergeMutation.mutate({ keepId, mergeIds });
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading size="lg" text={t('admin.guests.loading', 'Loading guests...')} />;
|
||||
}
|
||||
|
||||
const guests = data?.guests || [];
|
||||
|
||||
if (view === 'aggregate') {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setView('list')} leftIcon={<List className="w-4 h-4" />}>
|
||||
{t('admin.guests.backToList', 'Back to list')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<GuestSelectionsAggregate eventId={eventId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{t('admin.guests.title', 'Guests')} ({guests.length})
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{mergeMode ? (
|
||||
<>
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('admin.guests.mergeSelected', '{{count}} selected', { count: mergeSelection.length })}
|
||||
</span>
|
||||
<Button variant="primary" size="sm" onClick={performMerge} disabled={mergeSelection.length < 2}>
|
||||
{t('admin.guests.mergeNow', 'Merge selected')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => { setMergeMode(false); setMergeSelection([]); }}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<UserPlus className="w-4 h-4" />}
|
||||
onClick={() => setInviteDialogOpen(true)}
|
||||
>
|
||||
{t('admin.guests.createInvite', 'Create invite')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Grid3x3 className="w-4 h-4" />}
|
||||
onClick={() => setView('aggregate')}
|
||||
>
|
||||
{t('admin.guests.aggregateView', 'By popularity')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setMergeMode(true)}
|
||||
disabled={guests.length < 2}
|
||||
>
|
||||
{t('admin.guests.mergeMode', 'Merge')}
|
||||
</Button>
|
||||
<div className="relative group">
|
||||
<Button variant="outline" size="sm" leftIcon={<Download className="w-4 h-4" />}>
|
||||
{t('admin.guests.exportAll', 'Export all')}
|
||||
</Button>
|
||||
<div className="absolute right-0 top-full mt-1 hidden group-hover:block bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded shadow-lg z-10 min-w-[120px]">
|
||||
{(['csv', 'txt', 'json'] as const).map((fmt) => (
|
||||
<button
|
||||
key={fmt}
|
||||
onClick={() => handleExportAll(fmt)}
|
||||
className="block w-full text-left px-3 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700"
|
||||
>
|
||||
{fmt.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{guests.length === 0 ? (
|
||||
<Card>
|
||||
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">
|
||||
{t('admin.guests.empty', 'No guests have registered yet.')}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-neutral-50 dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<tr>
|
||||
{mergeMode && <th className="px-4 py-3 w-8" />}
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
|
||||
{t('admin.guests.columns.name', 'Name')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
|
||||
{t('admin.guests.columns.email', 'Email')}
|
||||
</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.likes', 'Likes')}
|
||||
</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.favorites', 'Favorites')}
|
||||
</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.comments', 'Comments')}
|
||||
</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.ratings', 'Ratings')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
|
||||
{t('admin.guests.columns.lastSeen', 'Last seen')}
|
||||
</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||
{guests.map((guest) => (
|
||||
<tr key={guest.id} className="hover:bg-neutral-50 dark:hover:bg-neutral-800">
|
||||
{mergeMode && (
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mergeSelection.includes(guest.id)}
|
||||
onChange={() => toggleMergeSelection(guest.id)}
|
||||
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-4 py-3 font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{guest.name}
|
||||
{guest.email_verified_at && (
|
||||
<span className="ml-2 text-xs text-green-600">✓</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{guest.email || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{guest.stats.likes}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{guest.stats.favorites}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{guest.stats.comments}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{guest.stats.ratings}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{new Date(guest.last_seen_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedGuest(guest)}
|
||||
className="p-1 text-neutral-500 hover:text-primary-600"
|
||||
title={t('admin.guests.view', 'View details')}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="relative group">
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 text-neutral-500 hover:text-primary-600"
|
||||
title={t('admin.guests.export', 'Export')}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="absolute right-0 top-full mt-1 hidden group-hover:block bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded shadow-lg z-10 min-w-[100px]">
|
||||
{(['csv', 'txt', 'json'] as const).map((fmt) => (
|
||||
<button
|
||||
key={fmt}
|
||||
onClick={() => handleExport(guest, fmt)}
|
||||
className="block w-full text-left px-3 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700"
|
||||
>
|
||||
{fmt.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(guest)}
|
||||
className="p-1 text-neutral-500 hover:text-red-600"
|
||||
title={t('admin.guests.forgetGuest', 'Remove guest')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{selectedGuest && (
|
||||
<AdminGuestDetail
|
||||
eventId={eventId}
|
||||
guest={selectedGuest}
|
||||
onClose={() => setSelectedGuest(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{inviteDialogOpen && (
|
||||
<GuestInviteDialog
|
||||
eventId={eventId}
|
||||
eventName={eventName}
|
||||
onClose={() => {
|
||||
setInviteDialogOpen(false);
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { MessageSquare, Star, Heart, Bookmark, Shield, Eye } from 'lucide-react';
|
||||
import { MessageSquare, Star, Heart, Bookmark, Shield, Eye, User, Users } from 'lucide-react';
|
||||
import { Card } from '../common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -21,6 +21,7 @@ interface FeedbackSettings {
|
||||
enable_rate_limiting: boolean;
|
||||
rate_limit_window_minutes?: number;
|
||||
rate_limit_max_requests?: number;
|
||||
identity_mode?: 'simple' | 'guest';
|
||||
}
|
||||
|
||||
export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
@@ -70,6 +71,74 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
|
||||
{settings.feedback_enabled && (
|
||||
<>
|
||||
{/* Identity Mode */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
{t('feedback.settings.identityMode', 'Identity Mode')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<label
|
||||
className={`flex items-start gap-3 p-3 rounded-lg cursor-pointer border transition ${
|
||||
(settings.identity_mode || 'simple') === 'simple'
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="identity_mode"
|
||||
value="simple"
|
||||
checked={(settings.identity_mode || 'simple') === 'simple'}
|
||||
onChange={() => onChange({ ...settings, identity_mode: 'simple' })}
|
||||
className="mt-0.5 w-4 h-4 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<User className="w-5 h-5 mt-0.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.identityModeSimple', 'Simple feedback')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t(
|
||||
'feedback.settings.identityModeSimpleDesc',
|
||||
'Anonymous, device-based. All visitors on the same device share state.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label
|
||||
className={`flex items-start gap-3 p-3 rounded-lg cursor-pointer border transition ${
|
||||
settings.identity_mode === 'guest'
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="identity_mode"
|
||||
value="guest"
|
||||
checked={settings.identity_mode === 'guest'}
|
||||
onChange={() => onChange({ ...settings, identity_mode: 'guest' })}
|
||||
className="mt-0.5 w-4 h-4 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<Users className="w-5 h-5 mt-0.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.identityModeGuest', 'Per-guest selections')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t(
|
||||
'feedback.settings.identityModeGuestDesc',
|
||||
'Each visitor enters their name. Enables per-guest tracking and admin insights.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-4" />
|
||||
|
||||
{/* Feedback Types */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Copy, Check, Trash2 } from 'lucide-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button, Input, Loading } from '../common';
|
||||
import { guestsService, GuestInvite } from '../../services/guests.service';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface GuestInviteDialogProps {
|
||||
eventId: number;
|
||||
eventName?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin dialog to create pre-minted invite tokens and list existing ones.
|
||||
* Each invite generates a unique URL that the admin can send to a specific
|
||||
* guest. Opening the URL auto-registers that guest (single use).
|
||||
*/
|
||||
export const GuestInviteDialog: React.FC<GuestInviteDialogProps> = ({ eventId, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [copiedId, setCopiedId] = useState<number | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-guest-invites', eventId],
|
||||
queryFn: () => guestsService.listInvites(eventId),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => guestsService.createInvite(eventId, { name, email: email || undefined }),
|
||||
onSuccess: () => {
|
||||
setName('');
|
||||
setEmail('');
|
||||
toast.success(t('admin.guests.inviteCreated', 'Invite created'));
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-guest-invites', eventId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-guests', eventId] });
|
||||
},
|
||||
onError: () => toast.error(t('admin.guests.inviteCreateError', 'Failed to create invite')),
|
||||
});
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: (inviteId: number) => guestsService.revokeInvite(eventId, inviteId),
|
||||
onSuccess: () => {
|
||||
toast.success(t('admin.guests.inviteRevoked', 'Invite revoked'));
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-guest-invites', eventId] });
|
||||
},
|
||||
onError: () => toast.error(t('admin.guests.inviteRevokeError', 'Failed to revoke invite')),
|
||||
});
|
||||
|
||||
const copy = (invite: GuestInvite) => {
|
||||
navigator.clipboard.writeText(invite.url).then(() => {
|
||||
setCopiedId(invite.id);
|
||||
setTimeout(() => setCopiedId(null), 1500);
|
||||
});
|
||||
};
|
||||
|
||||
const invites = data?.invites || [];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 pt-16">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative bg-white dark:bg-neutral-900 rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="p-4 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{t('admin.guests.invitesTitle', 'Guest invites')}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto p-4 space-y-4">
|
||||
{/* Create form */}
|
||||
<div className="p-4 bg-neutral-50 dark:bg-neutral-800 rounded">
|
||||
<h3 className="text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-3">
|
||||
{t('admin.guests.createInvite', 'Create invite')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3">
|
||||
<Input
|
||||
label={t('admin.guests.inviteName', 'Guest name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Alice"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="email"
|
||||
label={t('admin.guests.inviteEmail', 'Email (optional)')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="alice@example.com"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={!name.trim() || createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending
|
||||
? t('common.submitting', 'Submitting...')
|
||||
: t('admin.guests.generateInvite', 'Generate invite link')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Existing invites */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-2">
|
||||
{t('admin.guests.existingInvites', 'Existing invites')}
|
||||
</h3>
|
||||
{isLoading ? (
|
||||
<Loading size="sm" />
|
||||
) : invites.length === 0 ? (
|
||||
<div className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-4">
|
||||
{t('admin.guests.noInvites', 'No invites yet')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{invites.map((invite) => (
|
||||
<div
|
||||
key={invite.id}
|
||||
className="p-3 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{invite.guest.name}
|
||||
{invite.guest.email && (
|
||||
<span className="text-neutral-500 dark:text-neutral-400 font-normal ml-2">
|
||||
· {invite.guest.email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs mt-1">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full font-medium ${
|
||||
invite.status === 'redeemed'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
|
||||
: invite.status === 'revoked'
|
||||
? 'bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300'
|
||||
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
}`}
|
||||
>
|
||||
{t(`admin.guests.inviteStatus.${invite.status}`, invite.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 truncate mt-1 font-mono">
|
||||
{invite.url}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{invite.status === 'pending' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copy(invite)}
|
||||
className="p-1.5 text-neutral-500 hover:text-primary-600"
|
||||
title={t('admin.guests.copyLink', 'Copy link')}
|
||||
>
|
||||
{copiedId === invite.id ? (
|
||||
<Check className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => revokeMutation.mutate(invite.id)}
|
||||
className="p-1.5 text-neutral-500 hover:text-red-600"
|
||||
title={t('admin.guests.revokeInvite', 'Revoke')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Users } from 'lucide-react';
|
||||
import { Card, Loading } from '../common';
|
||||
import { guestsService } from '../../services/guests.service';
|
||||
import { AuthenticatedImage } from '../common/AuthenticatedImage';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
|
||||
interface GuestSelectionsAggregateProps {
|
||||
eventId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows photos sorted by the number of distinct guests who liked or
|
||||
* favorited them. Photos with zero picks are filtered server-side.
|
||||
*/
|
||||
export const GuestSelectionsAggregate: React.FC<GuestSelectionsAggregateProps> = ({ eventId }) => {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-guests-aggregate', eventId],
|
||||
queryFn: () => guestsService.getAggregatePicks(eventId),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading size="lg" text={t('admin.guests.loading', 'Loading...')} />;
|
||||
}
|
||||
|
||||
const photos = data?.photos || [];
|
||||
|
||||
if (photos.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">
|
||||
{t('admin.guests.aggregate.empty', 'No guest picks yet.')}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t(
|
||||
'admin.guests.aggregate.description',
|
||||
'Photos sorted by how many distinct guests liked or favorited them.'
|
||||
)}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||
{photos.map((p) => (
|
||||
<div key={p.id} className="relative group">
|
||||
<AuthenticatedImage
|
||||
src={buildResourceUrl(p.thumbnail_url)}
|
||||
alt={p.filename}
|
||||
className="w-full aspect-square object-cover rounded"
|
||||
/>
|
||||
<div className="absolute top-2 right-2 bg-primary-600 text-white text-xs font-semibold px-2 py-1 rounded-full flex items-center gap-1 shadow">
|
||||
<Users className="w-3 h-3" />
|
||||
{p.picker_count}
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent opacity-0 group-hover:opacity-100 transition-opacity text-white text-xs p-2 rounded-b">
|
||||
{p.original_filename || p.filename}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -36,3 +36,7 @@ export { EventRenameDialog } from './EventRenameDialog';
|
||||
export { PhotoFilterPanel } from './PhotoFilterPanel';
|
||||
export { PhotoExportMenu } from './PhotoExportMenu';
|
||||
export { CssTemplateEditor } from './CssTemplateEditor';
|
||||
export { AdminGuestsList } from './AdminGuestsList';
|
||||
export { AdminGuestDetail } from './AdminGuestDetail';
|
||||
export { GuestSelectionsAggregate } from './GuestSelectionsAggregate';
|
||||
export { GuestInviteDialog } from './GuestInviteDialog';
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { Button } from '../common';
|
||||
import { DynamicFavicon } from '../common/DynamicFavicon';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
import type { HeaderStyleType } from '../../types/theme.types';
|
||||
|
||||
@@ -59,6 +60,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
const { theme } = useTheme();
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
|
||||
// Determine header style - use prop first (from event data), then theme, then fall back to 'standard'
|
||||
const headerStyle: HeaderStyleType = headerStyleProp || theme.headerStyle || 'standard';
|
||||
@@ -589,20 +591,36 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
</p>
|
||||
)}
|
||||
{/* Legal Links */}
|
||||
<div className="mt-4 flex items-center justify-center gap-4">
|
||||
<Link
|
||||
to="/impressum"
|
||||
<div className="mt-4 flex items-center justify-center gap-4 flex-wrap">
|
||||
<Link
|
||||
to="/impressum"
|
||||
className="text-xs text-muted-theme hover:text-theme transition-colors"
|
||||
>
|
||||
{t('legal.impressum')}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-theme">|</span>
|
||||
<Link
|
||||
to="/datenschutz"
|
||||
<Link
|
||||
to="/datenschutz"
|
||||
className="text-xs text-muted-theme hover:text-theme transition-colors"
|
||||
>
|
||||
{t('legal.datenschutz')}
|
||||
</Link>
|
||||
{guestIdentity?.identity && (
|
||||
<>
|
||||
<span className="text-xs text-muted-theme">|</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-theme hover:text-theme transition-colors"
|
||||
onClick={async () => {
|
||||
if (window.confirm(t('gallery.footer.forgetMeConfirm', 'Your name and selections will be removed from this gallery.'))) {
|
||||
await guestIdentity.forget();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('gallery.footer.forgetMe', 'Forget me ({{name}})', { name: guestIdentity.identity.name })}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -13,6 +13,9 @@ import { GalleryLayout } from './GalleryLayout';
|
||||
import { GallerySidebar } from './GallerySidebar';
|
||||
import { PhotoFilterBar } from './PhotoFilterBar';
|
||||
import { UserPhotoUpload } from './UserPhotoUpload';
|
||||
import { GuestNamePromptModal } from './GuestNamePromptModal';
|
||||
import { GuestRecoveryModal } from './GuestRecoveryModal';
|
||||
import { GuestIdentityProvider } from '../../contexts/GuestIdentityContext';
|
||||
import type { FilterType } from './GalleryFilter';
|
||||
import { analyticsService } from '../../services/analytics.service';
|
||||
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
|
||||
@@ -701,8 +704,14 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const identityMode: 'simple' | 'guest' =
|
||||
feedbackSettings?.identity_mode === 'guest' ? 'guest' : 'simple';
|
||||
|
||||
return (
|
||||
<GuestIdentityProvider slug={slug} identityMode={identityMode}>
|
||||
<>
|
||||
<GuestNamePromptModal requireEmail={!!feedbackSettings?.require_name_email} />
|
||||
<GuestRecoveryModal />
|
||||
{/* Sidebar for non-grid layouts */}
|
||||
{showSidebar ? (
|
||||
<GallerySidebar
|
||||
@@ -915,5 +924,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
)}
|
||||
</GalleryLayout>
|
||||
</>
|
||||
</GuestIdentityProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Input } from '../common';
|
||||
import { useGuestIdentity } from '../../contexts/GuestIdentityContext';
|
||||
|
||||
interface GuestNamePromptModalProps {
|
||||
requireEmail?: boolean;
|
||||
allowCancel?: boolean;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session-wide prompt shown in guest identity mode when no identity exists
|
||||
* yet. Triggered by `ensureIdentity()` on the first interactive feedback
|
||||
* attempt, or manually via `openPrompt()`.
|
||||
*
|
||||
* Includes a link to the recovery flow for users who already registered on
|
||||
* another device.
|
||||
*/
|
||||
export const GuestNamePromptModal: React.FC<GuestNamePromptModalProps> = ({
|
||||
requireEmail = false,
|
||||
allowCancel = true,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { promptOpen, closePrompt, register, openRecovery } = useGuestIdentity();
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
if (!promptOpen) return null;
|
||||
|
||||
const handleClose = () => {
|
||||
setName('');
|
||||
setEmail('');
|
||||
setErrors({});
|
||||
setSubmitError(null);
|
||||
closePrompt();
|
||||
onCancel?.();
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!name.trim()) {
|
||||
newErrors.name = t('gallery.guestPrompt.nameRequired', 'Name is required');
|
||||
}
|
||||
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
newErrors.email = t('gallery.guestPrompt.invalidEmail', 'Invalid email address');
|
||||
}
|
||||
if (requireEmail && !email.trim()) {
|
||||
newErrors.email = t('gallery.guestPrompt.emailRequired', 'Email is required');
|
||||
}
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
await register(name.trim(), email.trim() || undefined);
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
setSubmitError(error.response?.data?.error || t('gallery.guestPrompt.error', 'Registration failed'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={allowCancel ? handleClose : undefined} />
|
||||
<div className="relative bg-surface rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
{allowCancel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="absolute top-4 right-4 p-1 hover:bg-black/10 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-muted-theme" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<h2 className="text-lg font-semibold text-theme mb-2">
|
||||
{t('gallery.guestPrompt.title', "Welcome — what's your name?")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-theme mb-4">
|
||||
{t(
|
||||
'gallery.guestPrompt.description',
|
||||
'Your picks will be saved under this name so the photographer knows which photos you love.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label={t('gallery.guestPrompt.nameLabel', 'Your name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
error={errors.name}
|
||||
placeholder={t('gallery.guestPrompt.namePlaceholder', 'Enter your name')}
|
||||
autoFocus
|
||||
required
|
||||
maxLength={100}
|
||||
/>
|
||||
<Input
|
||||
type="email"
|
||||
label={
|
||||
requireEmail
|
||||
? t('gallery.guestPrompt.emailLabelRequired', 'Email')
|
||||
: t('gallery.guestPrompt.emailLabel', 'Email (optional)')
|
||||
}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
error={errors.email}
|
||||
placeholder={t('gallery.guestPrompt.emailPlaceholder', 'you@example.com')}
|
||||
maxLength={255}
|
||||
/>
|
||||
|
||||
{submitError && (
|
||||
<div className="text-sm text-red-600 bg-red-50 dark:bg-red-900/20 rounded px-3 py-2">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button type="submit" variant="primary" className="flex-1" disabled={submitting}>
|
||||
{submitting
|
||||
? t('common.submitting', 'Submitting...')
|
||||
: t('gallery.guestPrompt.submit', 'Continue')}
|
||||
</Button>
|
||||
{allowCancel && (
|
||||
<Button type="button" variant="ghost" onClick={handleClose} disabled={submitting}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
closePrompt();
|
||||
openRecovery();
|
||||
}}
|
||||
className="text-sm text-primary-600 hover:underline w-full text-center pt-2"
|
||||
>
|
||||
{t('gallery.guestPrompt.alreadyHere', "I've been here before")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, ArrowLeft } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Input } from '../common';
|
||||
import { useGuestIdentity } from '../../contexts/GuestIdentityContext';
|
||||
|
||||
/**
|
||||
* Email-based identity recovery flow (Phase 3.2).
|
||||
*
|
||||
* Two steps:
|
||||
* 1) Enter email → server sends a 6-digit code.
|
||||
* 2) Enter code → server returns a guest token, identity restored.
|
||||
*
|
||||
* Opens when the user clicks "I've been here before" in the name prompt.
|
||||
*/
|
||||
export const GuestRecoveryModal: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { recoveryOpen, closeRecovery, recoverRequest, recoverVerify, openPrompt } =
|
||||
useGuestIdentity();
|
||||
|
||||
const [step, setStep] = useState<'email' | 'code'>('email');
|
||||
const [email, setEmail] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
|
||||
if (!recoveryOpen) return null;
|
||||
|
||||
const reset = () => {
|
||||
setStep('email');
|
||||
setEmail('');
|
||||
setCode('');
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
reset();
|
||||
closeRecovery();
|
||||
};
|
||||
|
||||
const backToPrompt = () => {
|
||||
reset();
|
||||
closeRecovery();
|
||||
openPrompt();
|
||||
};
|
||||
|
||||
const handleRequestCode = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
setError(t('gallery.guestRecovery.invalidEmail', 'Enter a valid email address'));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await recoverRequest(email.trim().toLowerCase());
|
||||
setInfo(t('gallery.guestRecovery.codeSent', 'Check your inbox for a verification code.'));
|
||||
setStep('code');
|
||||
} catch {
|
||||
setError(t('gallery.guestRecovery.requestError', 'Could not send code. Try again.'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!/^\d{6}$/.test(code.trim())) {
|
||||
setError(t('gallery.guestRecovery.invalidCode', 'Enter the 6-digit code'));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await recoverVerify(email.trim().toLowerCase(), code.trim());
|
||||
// Success: context clears recoveryOpen on success, component will
|
||||
// unmount naturally.
|
||||
} catch {
|
||||
setError(t('gallery.guestRecovery.verifyError', 'Invalid or expired code.'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={handleClose} />
|
||||
<div className="relative bg-surface rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="absolute top-4 right-4 p-1 hover:bg-black/10 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-muted-theme" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={backToPrompt}
|
||||
className="flex items-center gap-1 text-sm text-muted-theme hover:text-theme mb-3"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t('gallery.guestRecovery.back', 'Back')}
|
||||
</button>
|
||||
|
||||
<h2 className="text-lg font-semibold text-theme mb-2">
|
||||
{t('gallery.guestRecovery.title', 'Recover your picks')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-theme mb-4">
|
||||
{step === 'email'
|
||||
? t(
|
||||
'gallery.guestRecovery.emailStepDescription',
|
||||
'Enter the email you used before. We will send a 6-digit verification code.'
|
||||
)
|
||||
: t(
|
||||
'gallery.guestRecovery.codeStepDescription',
|
||||
'Enter the 6-digit code we sent to your email.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
{info && step === 'code' && (
|
||||
<div className="text-sm text-green-700 bg-green-50 dark:bg-green-900/20 rounded px-3 py-2 mb-3">
|
||||
{info}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 bg-red-50 dark:bg-red-900/20 rounded px-3 py-2 mb-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'email' ? (
|
||||
<form onSubmit={handleRequestCode} className="space-y-4">
|
||||
<Input
|
||||
type="email"
|
||||
label={t('gallery.guestRecovery.emailLabel', 'Email')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<Button type="submit" variant="primary" className="w-full" disabled={submitting}>
|
||||
{submitting
|
||||
? t('common.submitting', 'Submitting...')
|
||||
: t('gallery.guestRecovery.sendCode', 'Send code')}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleVerify} className="space-y-4">
|
||||
<Input
|
||||
label={t('gallery.guestRecovery.codeLabel', 'Verification code')}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="123456"
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<Button type="submit" variant="primary" className="w-full" disabled={submitting}>
|
||||
{submitting
|
||||
? t('common.submitting', 'Submitting...')
|
||||
: t('gallery.guestRecovery.verifyCode', 'Verify and continue')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { toast } from 'react-toastify';
|
||||
import { format } from 'date-fns';
|
||||
import { Button, Input } from '../common';
|
||||
import type { PhotoFeedback } from '../../services/feedback.service';
|
||||
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
||||
|
||||
interface PhotoCommentsProps {
|
||||
photoId: string;
|
||||
@@ -29,6 +30,8 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const isGuestMode = guestIdentity?.identityMode === 'guest';
|
||||
const [showCommentForm, setShowCommentForm] = useState(false);
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const [guestName, setGuestName] = useState('');
|
||||
@@ -78,7 +81,7 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmitComment = (e: React.FormEvent) => {
|
||||
const handleSubmitComment = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrors({});
|
||||
|
||||
@@ -87,7 +90,9 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
||||
if (!commentText.trim()) {
|
||||
newErrors.comment_text = t('feedback.commentRequired', 'Comment is required');
|
||||
}
|
||||
if (requireNameEmail) {
|
||||
// In guest identity mode, name/email come from the guest token — don't
|
||||
// ask for them here.
|
||||
if (requireNameEmail && !isGuestMode) {
|
||||
if (!guestName.trim()) {
|
||||
newErrors.guest_name = t('feedback.nameRequired', 'Name is required');
|
||||
}
|
||||
@@ -101,6 +106,16 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGuestMode && guestIdentity) {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
submitCommentMutation.mutate({ comment_text: commentText.trim() });
|
||||
return;
|
||||
}
|
||||
|
||||
submitCommentMutation.mutate({
|
||||
comment_text: commentText.trim(),
|
||||
guest_name: guestName.trim() || undefined,
|
||||
@@ -140,7 +155,7 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
||||
{/* Comment Form */}
|
||||
{showCommentForm && (
|
||||
<form onSubmit={handleSubmitComment} className="space-y-3 p-4 bg-surface rounded-lg border border-surface">
|
||||
{requireNameEmail && (
|
||||
{requireNameEmail && !isGuestMode && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Input
|
||||
placeholder={t('feedback.yourName', 'Your name')}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { toast } from 'react-toastify';
|
||||
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
|
||||
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
||||
|
||||
interface PhotoFavoritesProps {
|
||||
photoId: string;
|
||||
@@ -27,6 +28,7 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [animating, setAnimating] = useState(false);
|
||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||
@@ -68,9 +70,19 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
|
||||
}
|
||||
});
|
||||
|
||||
const handleFavoriteClick = () => {
|
||||
const handleFavoriteClick = async () => {
|
||||
if (!isEnabled || isSubmitting) return;
|
||||
|
||||
|
||||
if (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
submitFavoriteMutation.mutate({});
|
||||
return;
|
||||
}
|
||||
|
||||
if (requireNameEmail && !savedIdentity) {
|
||||
setShowIdentityModal(true);
|
||||
} else {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { toast } from 'react-toastify';
|
||||
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
|
||||
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
||||
|
||||
interface PhotoLikesProps {
|
||||
photoId: string;
|
||||
@@ -27,6 +28,7 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [animating, setAnimating] = useState(false);
|
||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||
@@ -68,9 +70,23 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
|
||||
}
|
||||
});
|
||||
|
||||
const handleLikeClick = () => {
|
||||
const handleLikeClick = async () => {
|
||||
if (!isEnabled || isSubmitting) return;
|
||||
|
||||
|
||||
// Guest identity mode: ensure we have a per-person guest token. The
|
||||
// server will read name/email from the token — body values are ignored.
|
||||
if (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
// User cancelled the prompt — silently abort.
|
||||
return;
|
||||
}
|
||||
submitLikeMutation.mutate({});
|
||||
return;
|
||||
}
|
||||
|
||||
// Simple mode (or no provider at all): legacy inline prompt flow.
|
||||
if (requireNameEmail && !savedIdentity) {
|
||||
setShowIdentityModal(true);
|
||||
} else {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { toast } from 'react-toastify';
|
||||
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
|
||||
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
||||
|
||||
interface PhotoRatingProps {
|
||||
photoId: string;
|
||||
@@ -31,6 +32,7 @@ export const PhotoRating: React.FC<PhotoRatingProps> = ({
|
||||
const safeAverageRating = typeof averageRating === 'number' && !isNaN(averageRating) ? averageRating : 0;
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const [hoveredRating, setHoveredRating] = useState(0);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||
@@ -72,18 +74,28 @@ export const PhotoRating: React.FC<PhotoRatingProps> = ({
|
||||
}
|
||||
});
|
||||
|
||||
const handleRatingClick = (rating: number) => {
|
||||
const handleRatingClick = async (rating: number) => {
|
||||
if (!isEnabled || isSubmitting) return;
|
||||
|
||||
|
||||
// If clicking the same rating, remove it
|
||||
const newRating = rating === currentRating ? 0 : rating;
|
||||
|
||||
|
||||
if (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
submitRatingMutation.mutate({ rating: newRating });
|
||||
return;
|
||||
}
|
||||
|
||||
if (requireNameEmail && !savedIdentity) {
|
||||
setPendingRating(newRating);
|
||||
setShowIdentityModal(true);
|
||||
} else {
|
||||
submitRatingMutation.mutate({
|
||||
rating: newRating,
|
||||
submitRatingMutation.mutate({
|
||||
rating: newRating,
|
||||
guest_name: savedIdentity?.name,
|
||||
guest_email: savedIdentity?.email
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AuthenticatedImage, Button } from '../../common';
|
||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||
import { feedbackService } from '../../../services/feedback.service';
|
||||
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
|
||||
|
||||
export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
photos,
|
||||
@@ -66,6 +67,7 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
|
||||
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
|
||||
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
|
||||
|
||||
@@ -150,6 +152,20 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setLikedIds(prev => new Set(prev).add(currentPhoto.id));
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug!, String(currentPhoto.id), {
|
||||
feedback_type: 'like',
|
||||
});
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
||||
setPendingAction({ type: 'like', photoId: currentPhoto.id });
|
||||
setShowIdentityModal(true);
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import type { Photo } from '../../../types';
|
||||
import { AuthenticatedImage } from '../../common';
|
||||
import { feedbackService } from '../../../services/feedback.service';
|
||||
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
|
||||
import { FeedbackIdentityModal } from '../FeedbackIdentityModal';
|
||||
import { galleryService } from '../../../services/gallery.service';
|
||||
import { analyticsService } from '../../../services/analytics.service';
|
||||
@@ -188,6 +189,7 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||
const [likedPhotoIds, setLikedPhotoIds] = useState<Set<number>>(new Set());
|
||||
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||
const [pendingLikePhotoId, setPendingLikePhotoId] = useState<number | null>(null);
|
||||
|
||||
@@ -237,6 +239,28 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
||||
const handleLike = useCallback(async (photo: Photo, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setLikedPhotoIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(photo.id);
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug, String(photo.id), {
|
||||
feedback_type: 'like',
|
||||
});
|
||||
onFeedbackChange?.();
|
||||
} catch (err) {
|
||||
console.warn('Like submit failed', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
||||
setPendingLikePhotoId(photo.id);
|
||||
setShowIdentityModal(true);
|
||||
@@ -260,7 +284,7 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
||||
} catch (err) {
|
||||
console.warn('Like submit failed', err);
|
||||
}
|
||||
}, [slug, savedIdentity, feedbackOptions, onFeedbackChange]);
|
||||
}, [slug, savedIdentity, feedbackOptions, onFeedbackChange, guestIdentity]);
|
||||
|
||||
const handleIdentitySubmit = useCallback(async (name: string, email: string) => {
|
||||
setSavedIdentity({ name, email });
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { AuthenticatedImage } from '../../common';
|
||||
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||
import { feedbackService } from '../../../services/feedback.service';
|
||||
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
|
||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import type { Photo } from '../../../types';
|
||||
|
||||
@@ -61,6 +62,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
onLikeSuccess
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const [overlayVisible, setOverlayVisible] = React.useState(false);
|
||||
const [isTouchDevice, setIsTouchDevice] = React.useState(false);
|
||||
const overlayTimeoutRef = React.useRef<number | null>(null);
|
||||
@@ -259,6 +261,25 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
className={`p-2 rounded-full transition-colors ${liked ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
hideOverlay();
|
||||
return;
|
||||
}
|
||||
if (onLikeSuccess) onLikeSuccess();
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||
feedback_type: 'like',
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Like submit failed, keeping optimistic UI', err);
|
||||
}
|
||||
if (onFeedbackChange) onFeedbackChange();
|
||||
hideOverlay();
|
||||
return;
|
||||
}
|
||||
if (feedbackOptions?.requireNameEmail && !savedIdentity && onRequireIdentity) {
|
||||
onRequireIdentity('like', photo.id);
|
||||
hideOverlay();
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { AuthenticatedImage } from '../../common';
|
||||
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||
import { feedbackService } from '../../../services/feedback.service';
|
||||
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
|
||||
import { buildResourceUrl } from '../../../utils/url';
|
||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import type { Photo } from '../../../types';
|
||||
@@ -81,6 +82,7 @@ const JustifiedPhoto: React.FC<JustifiedPhotoProps> = ({
|
||||
liked = false,
|
||||
onLikeSuccess,
|
||||
}) => {
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const [overlayVisible, setOverlayVisible] = useState(false);
|
||||
const [isTouchDevice, setIsTouchDevice] = useState(false);
|
||||
const overlayTimeoutRef = useRef<number | null>(null);
|
||||
@@ -301,6 +303,25 @@ const JustifiedPhoto: React.FC<JustifiedPhotoProps> = ({
|
||||
}`}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
hideOverlay();
|
||||
return;
|
||||
}
|
||||
if (onLikeSuccess) onLikeSuccess();
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||
feedback_type: 'like',
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Like submit failed, keeping optimistic UI', err);
|
||||
}
|
||||
if (onFeedbackChange) onFeedbackChange();
|
||||
hideOverlay();
|
||||
return;
|
||||
}
|
||||
if (feedbackOptions?.requireNameEmail && !savedIdentity && onRequireIdentity) {
|
||||
onRequireIdentity('like', photo.id);
|
||||
hideOverlay();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { AuthenticatedImage } from '../../common';
|
||||
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||
import { feedbackService } from '../../../services/feedback.service';
|
||||
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
|
||||
import {
|
||||
calculateJustifiedLayout,
|
||||
createJustifiedPhotos,
|
||||
@@ -53,6 +54,7 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
|
||||
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
|
||||
// Calculate height based on actual photo aspect ratio
|
||||
// This preserves the photo's natural proportions in the masonry layout
|
||||
@@ -151,6 +153,17 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||
feedback_type: 'like',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
||||
setPendingAction({ type: 'like', photoId: photo.id });
|
||||
setShowIdentityModal(true);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { AuthenticatedImage } from '../../common';
|
||||
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||
import { feedbackService } from '../../../services/feedback.service';
|
||||
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
|
||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import type { Photo } from '../../../types';
|
||||
|
||||
@@ -53,6 +54,7 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
||||
const [showIdentityModal, setShowIdentityModal] = React.useState(false);
|
||||
const [pendingAction, setPendingAction] = React.useState<null | { type: 'like'; photoId: number }>(null);
|
||||
const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null);
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const [likedLocal, setLikedLocal] = React.useState(false);
|
||||
const canComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onQuickComment);
|
||||
|
||||
@@ -108,6 +110,20 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
||||
className={`p-2 rounded-full transition-colors ${likedLocal ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setLikedLocal(true);
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||
feedback_type: 'like',
|
||||
});
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
||||
setPendingAction({ type: 'like', photoId: photo.id });
|
||||
setShowIdentityModal(true);
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import type { Photo } from '../../../types';
|
||||
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||
import { feedbackService } from '../../../services/feedback.service';
|
||||
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
|
||||
|
||||
export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
photos,
|
||||
@@ -26,6 +27,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
|
||||
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
const gallerySettings = theme.gallerySettings || {};
|
||||
const grouping = gallerySettings.timelineGrouping || 'day';
|
||||
const showDates = gallerySettings.timelineShowDates !== false;
|
||||
@@ -147,6 +149,20 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
className={`p-2 rounded-full transition-colors ${likedIds.has(photo.id) ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setLikedIds(prev => new Set(prev).add(photo.id));
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||
feedback_type: 'like',
|
||||
});
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
||||
setPendingAction({ type: 'like', photoId: photo.id });
|
||||
setShowIdentityModal(true);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
inferGallerySlugFromLocation,
|
||||
resolveSlugFromRequestUrl,
|
||||
} from '../utils/galleryAuthStorage';
|
||||
import { getGuestToken } from '../utils/guestIdentityStorage';
|
||||
import { getApiBaseUrl } from '../utils/url';
|
||||
|
||||
// Maintenance mode callback
|
||||
@@ -80,6 +81,25 @@ api.interceptors.request.use(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also inject guest token (x-guest-token) for per-person identity.
|
||||
// Separate header so gallery auth and guest identity are independent.
|
||||
const guestToken = getGuestToken(slug);
|
||||
if (guestToken) {
|
||||
if (!config.headers) {
|
||||
config.headers = new AxiosHeaders();
|
||||
}
|
||||
if (config.headers instanceof AxiosHeaders) {
|
||||
if (!config.headers.get('x-guest-token')) {
|
||||
config.headers.set('x-guest-token', guestToken);
|
||||
}
|
||||
} else {
|
||||
const headersRecord = config.headers as Record<string, string | undefined>;
|
||||
if (!headersRecord['x-guest-token']) {
|
||||
headersRecord['x-guest-token'] = guestToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { guestsService, GuestIdentity } from '../services/guests.service';
|
||||
import {
|
||||
clearGuestIdentity,
|
||||
getGuestIdentity,
|
||||
storeGuestIdentity,
|
||||
} from '../utils/guestIdentityStorage';
|
||||
|
||||
type IdentityMode = 'simple' | 'guest';
|
||||
|
||||
interface GuestIdentityContextValue {
|
||||
slug: string;
|
||||
identity: GuestIdentity | null;
|
||||
identityMode: IdentityMode;
|
||||
isRequired: boolean; // true when mode='guest' AND no identity yet
|
||||
promptOpen: boolean;
|
||||
recoveryOpen: boolean;
|
||||
openPrompt: () => void;
|
||||
closePrompt: () => void;
|
||||
openRecovery: () => void;
|
||||
closeRecovery: () => void;
|
||||
register: (name: string, email?: string) => Promise<GuestIdentity>;
|
||||
recoverRequest: (email: string) => Promise<void>;
|
||||
recoverVerify: (email: string, code: string) => Promise<GuestIdentity>;
|
||||
forget: () => Promise<void>;
|
||||
/**
|
||||
* Used by feedback components. Returns the current identity, or opens the
|
||||
* prompt and waits until the user registers (or cancels, in which case it
|
||||
* throws a "user_cancelled" error).
|
||||
*/
|
||||
ensureIdentity: () => Promise<GuestIdentity>;
|
||||
}
|
||||
|
||||
const GuestIdentityContext = createContext<GuestIdentityContextValue | null>(null);
|
||||
|
||||
interface GuestIdentityProviderProps {
|
||||
slug: string;
|
||||
identityMode: IdentityMode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const GuestIdentityProvider: React.FC<GuestIdentityProviderProps> = ({
|
||||
slug,
|
||||
identityMode,
|
||||
children,
|
||||
}) => {
|
||||
const [identity, setIdentity] = useState<GuestIdentity | null>(() => getGuestIdentity(slug));
|
||||
const [promptOpen, setPromptOpen] = useState(false);
|
||||
const [recoveryOpen, setRecoveryOpen] = useState(false);
|
||||
|
||||
// Pending promise resolvers for ensureIdentity() calls waiting on prompt.
|
||||
const pendingResolvers = useRef<Array<(identity: GuestIdentity) => void>>([]);
|
||||
const pendingRejecters = useRef<Array<(reason: Error) => void>>([]);
|
||||
|
||||
// Rehydrate identity when slug changes.
|
||||
useEffect(() => {
|
||||
setIdentity(getGuestIdentity(slug));
|
||||
}, [slug]);
|
||||
|
||||
// When an invite token is present on the URL (?invite=xxx), redeem it once
|
||||
// on mount. The server returns a guest token we can persist.
|
||||
useEffect(() => {
|
||||
if (identityMode !== 'guest' || identity) return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const inviteToken = params.get('invite');
|
||||
if (!inviteToken) return;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const response = await guestsService.redeemInvite(slug, inviteToken);
|
||||
storeGuestIdentity(slug, response.guest, response.token);
|
||||
setIdentity(response.guest);
|
||||
// Strip invite param from URL to prevent re-redemption on reload.
|
||||
params.delete('invite');
|
||||
const newSearch = params.toString();
|
||||
const newUrl = window.location.pathname + (newSearch ? `?${newSearch}` : '') + window.location.hash;
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
} catch (error) {
|
||||
// Silently fail invalid invites; user will fall back to normal prompt.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Failed to redeem invite token', error);
|
||||
}
|
||||
})();
|
||||
}, [slug, identityMode, identity]);
|
||||
|
||||
const openPrompt = useCallback(() => setPromptOpen(true), []);
|
||||
const closePrompt = useCallback(() => {
|
||||
setPromptOpen(false);
|
||||
// Reject any pending ensureIdentity() promises.
|
||||
pendingRejecters.current.forEach((r) => r(new Error('user_cancelled')));
|
||||
pendingResolvers.current = [];
|
||||
pendingRejecters.current = [];
|
||||
}, []);
|
||||
|
||||
const openRecovery = useCallback(() => setRecoveryOpen(true), []);
|
||||
const closeRecovery = useCallback(() => setRecoveryOpen(false), []);
|
||||
|
||||
const register = useCallback(
|
||||
async (name: string, email?: string): Promise<GuestIdentity> => {
|
||||
const response = await guestsService.registerGuest(slug, { name, email });
|
||||
storeGuestIdentity(slug, response.guest, response.token);
|
||||
setIdentity(response.guest);
|
||||
setPromptOpen(false);
|
||||
// Resolve pending ensureIdentity() promises.
|
||||
pendingResolvers.current.forEach((r) => r(response.guest));
|
||||
pendingResolvers.current = [];
|
||||
pendingRejecters.current = [];
|
||||
return response.guest;
|
||||
},
|
||||
[slug]
|
||||
);
|
||||
|
||||
const recoverRequest = useCallback(
|
||||
async (email: string): Promise<void> => {
|
||||
await guestsService.requestRecoveryCode(slug, email);
|
||||
},
|
||||
[slug]
|
||||
);
|
||||
|
||||
const recoverVerify = useCallback(
|
||||
async (email: string, code: string): Promise<GuestIdentity> => {
|
||||
const response = await guestsService.verifyRecoveryCode(slug, email, code);
|
||||
storeGuestIdentity(slug, response.guest, response.token);
|
||||
setIdentity(response.guest);
|
||||
setPromptOpen(false);
|
||||
setRecoveryOpen(false);
|
||||
pendingResolvers.current.forEach((r) => r(response.guest));
|
||||
pendingResolvers.current = [];
|
||||
pendingRejecters.current = [];
|
||||
return response.guest;
|
||||
},
|
||||
[slug]
|
||||
);
|
||||
|
||||
const forget = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
if (identity) {
|
||||
await guestsService.forgetMe(slug);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort. Clear local state regardless.
|
||||
}
|
||||
clearGuestIdentity(slug);
|
||||
setIdentity(null);
|
||||
}, [slug, identity]);
|
||||
|
||||
const ensureIdentity = useCallback((): Promise<GuestIdentity> => {
|
||||
if (identityMode !== 'guest') {
|
||||
// In simple mode, there is no per-person identity. Return a synthetic
|
||||
// "null" identity that callers will ignore.
|
||||
return Promise.resolve({
|
||||
id: 0,
|
||||
name: '',
|
||||
email: null,
|
||||
identifier: '',
|
||||
} as GuestIdentity);
|
||||
}
|
||||
if (identity) return Promise.resolve(identity);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingResolvers.current.push(resolve);
|
||||
pendingRejecters.current.push(reject);
|
||||
setPromptOpen(true);
|
||||
});
|
||||
}, [identityMode, identity]);
|
||||
|
||||
const isRequired = identityMode === 'guest' && !identity;
|
||||
|
||||
const value = useMemo<GuestIdentityContextValue>(
|
||||
() => ({
|
||||
slug,
|
||||
identity,
|
||||
identityMode,
|
||||
isRequired,
|
||||
promptOpen,
|
||||
recoveryOpen,
|
||||
openPrompt,
|
||||
closePrompt,
|
||||
openRecovery,
|
||||
closeRecovery,
|
||||
register,
|
||||
recoverRequest,
|
||||
recoverVerify,
|
||||
forget,
|
||||
ensureIdentity,
|
||||
}),
|
||||
[
|
||||
slug,
|
||||
identity,
|
||||
identityMode,
|
||||
isRequired,
|
||||
promptOpen,
|
||||
recoveryOpen,
|
||||
openPrompt,
|
||||
closePrompt,
|
||||
openRecovery,
|
||||
closeRecovery,
|
||||
register,
|
||||
recoverRequest,
|
||||
recoverVerify,
|
||||
forget,
|
||||
ensureIdentity,
|
||||
]
|
||||
);
|
||||
|
||||
return <GuestIdentityContext.Provider value={value}>{children}</GuestIdentityContext.Provider>;
|
||||
};
|
||||
|
||||
export function useGuestIdentity(): GuestIdentityContextValue {
|
||||
const ctx = useContext(GuestIdentityContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useGuestIdentity must be used within a GuestIdentityProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe hook that returns null if no provider is present. Useful when code
|
||||
* needs to optionally tie into guest identity without crashing when used
|
||||
* outside a gallery (e.g. in admin contexts).
|
||||
*/
|
||||
export function useGuestIdentityOptional(): GuestIdentityContextValue | null {
|
||||
return useContext(GuestIdentityContext);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useCallback } from 'react';
|
||||
import { feedbackService } from '../services/feedback.service';
|
||||
import { useGuestIdentityOptional } from '../contexts/GuestIdentityContext';
|
||||
|
||||
/**
|
||||
* Shared helper used by gallery layout "quick action" buttons (like, favorite,
|
||||
* rating, etc.) to submit feedback with proper identity handling:
|
||||
*
|
||||
* - In guest identity mode: ensures the visitor has a guest token (prompts
|
||||
* if needed), then submits. Server reads name/email from the token.
|
||||
* - In simple mode with require_name_email: callers still need to show
|
||||
* their own inline FeedbackIdentityModal (we return `needsSimpleIdentity`
|
||||
* to signal this).
|
||||
* - In simple mode without require_name_email: submits directly.
|
||||
*/
|
||||
export function useGalleryFeedbackAction() {
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
|
||||
/**
|
||||
* Submit a feedback action. Returns:
|
||||
* - { submitted: true } if the submission happened.
|
||||
* - { cancelled: true } if the user cancelled the guest prompt.
|
||||
* - { needsSimpleIdentity: true } if the caller must show its own legacy
|
||||
* identity modal (simple mode with require_name_email).
|
||||
*/
|
||||
const submit = useCallback(
|
||||
async (
|
||||
slug: string,
|
||||
photoId: number | string,
|
||||
action: {
|
||||
feedback_type: 'like' | 'favorite' | 'rating' | 'comment';
|
||||
rating?: number;
|
||||
comment_text?: string;
|
||||
},
|
||||
options?: {
|
||||
requireNameEmail?: boolean;
|
||||
savedIdentity?: { name: string; email: string } | null;
|
||||
}
|
||||
): Promise<{ submitted?: boolean; cancelled?: boolean; needsSimpleIdentity?: boolean }> => {
|
||||
if (guestIdentity?.identityMode === 'guest') {
|
||||
try {
|
||||
await guestIdentity.ensureIdentity();
|
||||
} catch {
|
||||
return { cancelled: true };
|
||||
}
|
||||
await feedbackService.submitFeedback(slug, String(photoId), action);
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
// Simple mode
|
||||
if (options?.requireNameEmail && !options.savedIdentity) {
|
||||
return { needsSimpleIdentity: true };
|
||||
}
|
||||
|
||||
await feedbackService.submitFeedback(slug, String(photoId), {
|
||||
...action,
|
||||
guest_name: options?.savedIdentity?.name,
|
||||
guest_email: options?.savedIdentity?.email,
|
||||
});
|
||||
return { submitted: true };
|
||||
},
|
||||
[guestIdentity]
|
||||
);
|
||||
|
||||
return {
|
||||
submit,
|
||||
isGuestMode: guestIdentity?.identityMode === 'guest',
|
||||
};
|
||||
}
|
||||
@@ -2320,7 +2320,12 @@
|
||||
"comments": "Comments",
|
||||
"commentsDesc": "Text comments on photos",
|
||||
"favorites": "Favorites",
|
||||
"favoritesDesc": "Mark photos as favorites"
|
||||
"favoritesDesc": "Mark photos as favorites",
|
||||
"identityMode": "Identity Mode",
|
||||
"identityModeSimple": "Simple feedback",
|
||||
"identityModeSimpleDesc": "Anonymous, device-based. All visitors on the same device share state.",
|
||||
"identityModeGuest": "Per-guest selections",
|
||||
"identityModeGuestDesc": "Each visitor enters their name. Enables per-guest tracking and admin insights."
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
|
||||
@@ -53,7 +53,7 @@ import { toast } from 'react-toastify';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu } from '../../components/admin';
|
||||
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu, AdminGuestsList } from '../../components/admin';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { publicSettingsService } from '../../services/publicSettings.service';
|
||||
@@ -232,7 +232,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
const [clientPin, setClientPin] = useState('');
|
||||
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
|
||||
const [showExternalImport, setShowExternalImport] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'photos' | 'categories'>('overview');
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'photos' | 'categories' | 'guests'>('overview');
|
||||
const [externalPath, setExternalPath] = useState<string>('');
|
||||
const [importing, setImporting] = useState<boolean>(false);
|
||||
const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null);
|
||||
@@ -912,6 +912,18 @@ export const EventDetailsPage: React.FC = () => {
|
||||
>
|
||||
{t('events.categories')}
|
||||
</button>
|
||||
{eventFeedbackSettings?.identity_mode === 'guest' && (
|
||||
<button
|
||||
onClick={() => setActiveTab('guests')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'guests'
|
||||
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||
}`}
|
||||
>
|
||||
{t('admin.events.tabs.guests', 'Guests')}
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -2104,6 +2116,11 @@ export const EventDetailsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Guests Tab (only visible when identity_mode === 'guest') */}
|
||||
{activeTab === 'guests' && eventFeedbackSettings?.identity_mode === 'guest' && (
|
||||
<AdminGuestsList eventId={parseInt(id!)} eventName={event.event_name} />
|
||||
)}
|
||||
|
||||
{/* Password Reset Modal */}
|
||||
{showPasswordReset && (
|
||||
<PasswordResetModal
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { api } from '../config/api';
|
||||
|
||||
export type IdentityMode = 'simple' | 'guest';
|
||||
|
||||
export interface FeedbackSettings {
|
||||
feedback_enabled: boolean;
|
||||
allow_ratings: boolean;
|
||||
@@ -12,6 +14,7 @@ export interface FeedbackSettings {
|
||||
enable_rate_limiting: boolean;
|
||||
rate_limit_window_minutes?: number;
|
||||
rate_limit_max_requests?: number;
|
||||
identity_mode?: IdentityMode;
|
||||
}
|
||||
|
||||
export interface PhotoFeedback {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { api } from '../config/api';
|
||||
|
||||
export interface GuestIdentity {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string | null;
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
export interface GuestRegisterResponse {
|
||||
guest: GuestIdentity;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface AdminGuestStats {
|
||||
likes: number;
|
||||
favorites: number;
|
||||
comments: number;
|
||||
ratings: number;
|
||||
distinct_photos: number;
|
||||
}
|
||||
|
||||
export interface AdminGuest {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string | null;
|
||||
created_at: string;
|
||||
last_seen_at: string;
|
||||
email_verified_at: string | null;
|
||||
is_deleted: boolean;
|
||||
stats: AdminGuestStats;
|
||||
}
|
||||
|
||||
export interface AdminGuestPhoto {
|
||||
id: number;
|
||||
filename: string;
|
||||
original_filename: string | null;
|
||||
type?: string;
|
||||
url: string;
|
||||
thumbnail_url: string;
|
||||
}
|
||||
|
||||
export interface AdminGuestSelections {
|
||||
liked: AdminGuestPhoto[];
|
||||
favorited: AdminGuestPhoto[];
|
||||
rated: Array<{ photo: AdminGuestPhoto; rating: number }>;
|
||||
commented: Array<{ photo: AdminGuestPhoto; comment: string; created_at: string }>;
|
||||
}
|
||||
|
||||
export interface AdminGuestDetail {
|
||||
guest: AdminGuest;
|
||||
selections: AdminGuestSelections;
|
||||
}
|
||||
|
||||
export interface AggregatePhoto extends AdminGuestPhoto {
|
||||
picker_count: number;
|
||||
}
|
||||
|
||||
export interface GuestInvite {
|
||||
id: number;
|
||||
token: string;
|
||||
url: string;
|
||||
created_at: string;
|
||||
redeemed_at: string | null;
|
||||
revoked_at: string | null;
|
||||
status: 'pending' | 'redeemed' | 'revoked';
|
||||
guest: { id: number; name: string; email: string | null };
|
||||
}
|
||||
|
||||
class GuestsService {
|
||||
// ===================================================================
|
||||
// Gallery-side (public) — guest identity
|
||||
// ===================================================================
|
||||
|
||||
async registerGuest(slug: string, data: { name: string; email?: string }): Promise<GuestRegisterResponse> {
|
||||
const response = await api.post(`/gallery/${slug}/guest`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getGuestMe(slug: string): Promise<{ guest: GuestIdentity }> {
|
||||
const response = await api.get(`/gallery/${slug}/guest/me`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async forgetMe(slug: string): Promise<{ success: boolean }> {
|
||||
const response = await api.delete(`/gallery/${slug}/guest/me`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async requestRecoveryCode(slug: string, email: string): Promise<{ success: boolean }> {
|
||||
const response = await api.post(`/gallery/${slug}/guest/recover`, { email });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async verifyRecoveryCode(slug: string, email: string, code: string): Promise<GuestRegisterResponse> {
|
||||
const response = await api.post(`/gallery/${slug}/guest/verify`, { email, code });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async redeemInvite(slug: string, inviteToken: string): Promise<GuestRegisterResponse> {
|
||||
const response = await api.post(`/gallery/${slug}/guest/redeem`, { inviteToken });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Admin-side
|
||||
// ===================================================================
|
||||
|
||||
async getEventGuests(eventId: number): Promise<{ guests: AdminGuest[] }> {
|
||||
const response = await api.get(`/admin/events/${eventId}/guests`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getGuestDetail(eventId: number, guestId: number): Promise<AdminGuestDetail> {
|
||||
const response = await api.get(`/admin/events/${eventId}/guests/${guestId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getAggregatePicks(eventId: number): Promise<{ photos: AggregatePhoto[] }> {
|
||||
const response = await api.get(`/admin/events/${eventId}/guests/aggregate`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteGuest(eventId: number, guestId: number): Promise<void> {
|
||||
await api.delete(`/admin/events/${eventId}/guests/${guestId}`);
|
||||
}
|
||||
|
||||
async mergeGuests(eventId: number, keepId: number, mergeIds: number[]): Promise<void> {
|
||||
await api.post(`/admin/events/${eventId}/guests/${keepId}/merge`, { mergeIds });
|
||||
}
|
||||
|
||||
async exportGuest(eventId: number, guestId: number, format: 'txt' | 'csv' | 'json'): Promise<Blob> {
|
||||
const response = await api.get(`/admin/events/${eventId}/guests/${guestId}/export`, {
|
||||
params: { format },
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async exportAllGuests(eventId: number, format: 'txt' | 'csv' | 'json'): Promise<Blob> {
|
||||
const response = await api.get(`/admin/events/${eventId}/guests/export-all`, {
|
||||
params: { format },
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async listInvites(eventId: number): Promise<{ invites: GuestInvite[] }> {
|
||||
const response = await api.get(`/admin/events/${eventId}/guests/invites`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async createInvite(eventId: number, data: { name: string; email?: string }): Promise<{ invite: GuestInvite }> {
|
||||
const response = await api.post(`/admin/events/${eventId}/guests/invites`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async revokeInvite(eventId: number, inviteId: number): Promise<void> {
|
||||
await api.delete(`/admin/events/${eventId}/guests/invites/${inviteId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const guestsService = new GuestsService();
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Per-gallery guest identity persistence.
|
||||
*
|
||||
* Stores the guest JWT and profile in sessionStorage, keyed by gallery slug,
|
||||
* so multiple open tabs of the same gallery share identity but different
|
||||
* browser contexts (and different galleries in the same context) stay
|
||||
* independent.
|
||||
*/
|
||||
|
||||
import type { GuestIdentity } from '../services/guests.service';
|
||||
|
||||
const TOKEN_KEY_PREFIX = 'guest_token_';
|
||||
const IDENTITY_KEY_PREFIX = 'guest_identity_';
|
||||
|
||||
const isBrowser = typeof window !== 'undefined';
|
||||
|
||||
const getStorage = (): Storage | null => {
|
||||
if (!isBrowser) return null;
|
||||
try {
|
||||
return window.sessionStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export function storeGuestIdentity(slug: string, identity: GuestIdentity, token: string): void {
|
||||
const storage = getStorage();
|
||||
if (!storage || !slug) return;
|
||||
storage.setItem(`${TOKEN_KEY_PREFIX}${slug}`, token);
|
||||
storage.setItem(`${IDENTITY_KEY_PREFIX}${slug}`, JSON.stringify(identity));
|
||||
}
|
||||
|
||||
export function getGuestToken(slug?: string | null): string | null {
|
||||
const storage = getStorage();
|
||||
if (!storage) return null;
|
||||
const resolvedSlug = slug || extractSlugFromLocation();
|
||||
if (!resolvedSlug) return null;
|
||||
return storage.getItem(`${TOKEN_KEY_PREFIX}${resolvedSlug}`);
|
||||
}
|
||||
|
||||
export function getGuestIdentity(slug?: string | null): GuestIdentity | null {
|
||||
const storage = getStorage();
|
||||
if (!storage) return null;
|
||||
const resolvedSlug = slug || extractSlugFromLocation();
|
||||
if (!resolvedSlug) return null;
|
||||
const raw = storage.getItem(`${IDENTITY_KEY_PREFIX}${resolvedSlug}`);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as GuestIdentity;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearGuestIdentity(slug: string): void {
|
||||
const storage = getStorage();
|
||||
if (!storage || !slug) return;
|
||||
storage.removeItem(`${TOKEN_KEY_PREFIX}${slug}`);
|
||||
storage.removeItem(`${IDENTITY_KEY_PREFIX}${slug}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the gallery slug from a request URL path like "/gallery/:slug/...".
|
||||
* Matches the axios interceptor logic in api.ts.
|
||||
*/
|
||||
export function extractGuestSlugFromUrl(url: string): string | null {
|
||||
if (!url) return null;
|
||||
const pathOnly = url.startsWith('http://') || url.startsWith('https://')
|
||||
? (() => {
|
||||
try {
|
||||
return new URL(url).pathname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
})()
|
||||
: url;
|
||||
const match = pathOnly.match(/\/gallery\/([^/?#]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
function extractSlugFromLocation(): string | null {
|
||||
if (!isBrowser) return null;
|
||||
const match = window.location.pathname.match(/\/gallery\/([^/?#]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
Reference in New Issue
Block a user