Merge pull request #141 from the-luap/feat/new-features
feat: new features and bug fixes for beta release
This commit is contained in:
@@ -94,3 +94,11 @@ backup/
|
|||||||
|
|
||||||
# Local SQLite files in backend
|
# Local SQLite files in backend
|
||||||
backend/*.sqlite*
|
backend/*.sqlite*
|
||||||
|
|
||||||
|
# Test files and artifacts
|
||||||
|
test-logo*.jpg
|
||||||
|
test-logo*.png
|
||||||
|
test-results/
|
||||||
|
|
||||||
|
# Development docker compose
|
||||||
|
docker-compose.dev.yml
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Migration: Add event_types table
|
||||||
|
*
|
||||||
|
* Creates a customizable event types system to replace hardcoded event types.
|
||||||
|
* This allows users to:
|
||||||
|
* - Rename existing event types (wedding, birthday, corporate, other)
|
||||||
|
* - Create custom event types with custom slug prefixes
|
||||||
|
* - Associate default theme presets with event types
|
||||||
|
*
|
||||||
|
* Backward compatible: Existing events keep their event_type strings.
|
||||||
|
* New events can use either legacy strings or custom event type slug_prefix.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { createTableIfNotExists, insertIfNotExists } = require('../helpers');
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
console.log('Creating event_types table...');
|
||||||
|
|
||||||
|
// Create event_types table
|
||||||
|
const hasEventTypesTable = await knex.schema.hasTable('event_types');
|
||||||
|
if (!hasEventTypesTable) {
|
||||||
|
await knex.schema.createTable('event_types', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.string('name', 100).notNullable(); // Display name: "Family Shoot"
|
||||||
|
table.string('slug_prefix', 50).unique().notNullable(); // URL prefix: "family"
|
||||||
|
table.string('emoji', 10); // Icon emoji: "👨👩👧"
|
||||||
|
table.string('theme_preset', 50); // Default theme: "elegantWedding"
|
||||||
|
table.text('theme_config'); // Custom theme JSON overrides (optional)
|
||||||
|
table.integer('display_order').defaultTo(0); // Sorting in dropdowns
|
||||||
|
table.boolean('is_system').defaultTo(false); // Protect default types
|
||||||
|
table.boolean('is_active').defaultTo(true); // Allow hiding types
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
// Indexes for performance
|
||||||
|
table.index('slug_prefix');
|
||||||
|
table.index('display_order');
|
||||||
|
table.index('is_active');
|
||||||
|
});
|
||||||
|
console.log('event_types table created');
|
||||||
|
} else {
|
||||||
|
console.log('event_types table already exists, skipping creation');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed default event types (matching current hardcoded values)
|
||||||
|
const existingTypes = await knex('event_types').select('slug_prefix');
|
||||||
|
const existingSlugs = existingTypes.map(t => t.slug_prefix);
|
||||||
|
|
||||||
|
const defaultTypes = [
|
||||||
|
{
|
||||||
|
name: 'Wedding',
|
||||||
|
slug_prefix: 'wedding',
|
||||||
|
emoji: '💒',
|
||||||
|
theme_preset: 'elegantWedding',
|
||||||
|
display_order: 1,
|
||||||
|
is_system: true,
|
||||||
|
is_active: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Birthday',
|
||||||
|
slug_prefix: 'birthday',
|
||||||
|
emoji: '🎂',
|
||||||
|
theme_preset: 'birthdayFun',
|
||||||
|
display_order: 2,
|
||||||
|
is_system: true,
|
||||||
|
is_active: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Corporate',
|
||||||
|
slug_prefix: 'corporate',
|
||||||
|
emoji: '🏢',
|
||||||
|
theme_preset: 'corporateTimeline',
|
||||||
|
display_order: 3,
|
||||||
|
is_system: true,
|
||||||
|
is_active: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Other',
|
||||||
|
slug_prefix: 'other',
|
||||||
|
emoji: '📸',
|
||||||
|
theme_preset: 'default',
|
||||||
|
display_order: 4,
|
||||||
|
is_system: true,
|
||||||
|
is_active: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const typesToInsert = defaultTypes.filter(type => !existingSlugs.includes(type.slug_prefix));
|
||||||
|
|
||||||
|
if (typesToInsert.length > 0) {
|
||||||
|
await knex('event_types').insert(typesToInsert);
|
||||||
|
console.log(`Inserted ${typesToInsert.length} default event types`);
|
||||||
|
} else {
|
||||||
|
console.log('Default event types already exist, skipping seed');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Migration 061_add_event_types_table completed successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
console.log('Rolling back event_types table...');
|
||||||
|
|
||||||
|
// Drop the table (data will be lost)
|
||||||
|
await knex.schema.dropTableIfExists('event_types');
|
||||||
|
|
||||||
|
console.log('event_types table dropped');
|
||||||
|
};
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Migration: Add hero logo customization settings to events table
|
||||||
|
*
|
||||||
|
* Allows per-event customization of the hero gallery logo:
|
||||||
|
* - hero_logo_visible: Show/hide the logo overlay
|
||||||
|
* - hero_logo_size: Logo size (small, medium, large, xlarge)
|
||||||
|
* - hero_logo_position: Logo position (top, center, bottom)
|
||||||
|
*
|
||||||
|
* Addresses GitHub Issue #138: Add Option to customize the Hero gallery layout
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function (knex) {
|
||||||
|
console.log('Adding hero logo settings to events table...');
|
||||||
|
|
||||||
|
// Add hero_logo_visible column
|
||||||
|
const hasVisibleColumn = await knex.schema.hasColumn('events', 'hero_logo_visible');
|
||||||
|
if (!hasVisibleColumn) {
|
||||||
|
await knex.schema.table('events', (table) => {
|
||||||
|
table.boolean('hero_logo_visible').notNullable().defaultTo(true);
|
||||||
|
});
|
||||||
|
console.log('Added hero_logo_visible column');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add hero_logo_size column
|
||||||
|
const hasSizeColumn = await knex.schema.hasColumn('events', 'hero_logo_size');
|
||||||
|
if (!hasSizeColumn) {
|
||||||
|
await knex.schema.table('events', (table) => {
|
||||||
|
table.string('hero_logo_size', 20).notNullable().defaultTo('medium');
|
||||||
|
});
|
||||||
|
console.log('Added hero_logo_size column');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add hero_logo_position column
|
||||||
|
const hasPositionColumn = await knex.schema.hasColumn('events', 'hero_logo_position');
|
||||||
|
if (!hasPositionColumn) {
|
||||||
|
await knex.schema.table('events', (table) => {
|
||||||
|
table.string('hero_logo_position', 20).notNullable().defaultTo('top');
|
||||||
|
});
|
||||||
|
console.log('Added hero_logo_position column');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Migration 062_add_hero_logo_settings completed successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function (knex) {
|
||||||
|
console.log('Rolling back hero logo settings...');
|
||||||
|
|
||||||
|
const hasVisibleColumn = await knex.schema.hasColumn('events', 'hero_logo_visible');
|
||||||
|
if (hasVisibleColumn) {
|
||||||
|
await knex.schema.table('events', (table) => {
|
||||||
|
table.dropColumn('hero_logo_visible');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSizeColumn = await knex.schema.hasColumn('events', 'hero_logo_size');
|
||||||
|
if (hasSizeColumn) {
|
||||||
|
await knex.schema.table('events', (table) => {
|
||||||
|
table.dropColumn('hero_logo_size');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPositionColumn = await knex.schema.hasColumn('events', 'hero_logo_position');
|
||||||
|
if (hasPositionColumn) {
|
||||||
|
await knex.schema.table('events', (table) => {
|
||||||
|
table.dropColumn('hero_logo_position');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Hero logo settings columns dropped');
|
||||||
|
};
|
||||||
@@ -437,6 +437,7 @@ app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport'));
|
|||||||
app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
|
app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
|
||||||
app.use('/api/admin/events', require('./src/routes/adminEventRename'));
|
app.use('/api/admin/events', require('./src/routes/adminEventRename'));
|
||||||
app.use('/api/admin/users', require('./src/routes/adminUsers'));
|
app.use('/api/admin/users', require('./src/routes/adminUsers'));
|
||||||
|
app.use('/api/admin/event-types', require('./src/routes/adminEventTypes'));
|
||||||
app.use('/api/invite', require('./src/routes/acceptInvite'));
|
app.use('/api/invite', require('./src/routes/acceptInvite'));
|
||||||
app.use('/api/public/settings', require('./src/routes/publicSettings'));
|
app.use('/api/public/settings', require('./src/routes/publicSettings'));
|
||||||
app.use('/api/public', require('./src/routes/publicCMS'));
|
app.use('/api/public', require('./src/routes/publicCMS'));
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
/**
|
||||||
|
* Admin Event Types Routes
|
||||||
|
* CRUD operations for managing customizable event types
|
||||||
|
*
|
||||||
|
* @module routes/adminEventTypes
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { body, param, validationResult } = require('express-validator');
|
||||||
|
const { logActivity } = require('../database/db');
|
||||||
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
|
const eventTypeService = require('../services/eventTypeService');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/event-types
|
||||||
|
* Get all event types (for admin management)
|
||||||
|
*/
|
||||||
|
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const includeInactive = req.query.includeInactive === 'true';
|
||||||
|
const eventTypes = await eventTypeService.getAllEventTypes({
|
||||||
|
activeOnly: !includeInactive
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ eventTypes });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching event types:', { error: error.message });
|
||||||
|
res.status(500).json({ error: 'Failed to fetch event types' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/event-types/active
|
||||||
|
* Get only active event types (for dropdowns/selection)
|
||||||
|
*/
|
||||||
|
router.get('/active', adminAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const eventTypes = await eventTypeService.getActiveEventTypes();
|
||||||
|
res.json({ eventTypes });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching active event types:', { error: error.message });
|
||||||
|
res.status(500).json({ error: 'Failed to fetch event types' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/event-types/:id
|
||||||
|
* Get a single event type by ID
|
||||||
|
*/
|
||||||
|
router.get('/:id', adminAuth, requirePermission('settings.view'), [
|
||||||
|
param('id').isInt().withMessage('Invalid event type ID')
|
||||||
|
], async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const eventType = await eventTypeService.getEventTypeById(parseInt(id));
|
||||||
|
|
||||||
|
if (!eventType) {
|
||||||
|
return res.status(404).json({ error: 'Event type not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(eventType);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching event type:', { error: error.message });
|
||||||
|
res.status(500).json({ error: 'Failed to fetch event type' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/event-types
|
||||||
|
* Create a new event type
|
||||||
|
*/
|
||||||
|
router.post('/', adminAuth, requirePermission('settings.edit'), [
|
||||||
|
body('name').notEmpty().trim().withMessage('Name is required'),
|
||||||
|
body('slug_prefix')
|
||||||
|
.notEmpty()
|
||||||
|
.trim()
|
||||||
|
.isLength({ min: 2, max: 50 })
|
||||||
|
.matches(/^[a-z0-9-]+$/i)
|
||||||
|
.withMessage('Slug prefix must be 2-50 characters and contain only letters, numbers, and hyphens'),
|
||||||
|
body('emoji').optional().trim(),
|
||||||
|
body('theme_preset').optional().trim(),
|
||||||
|
body('theme_config').optional(),
|
||||||
|
body('display_order').optional().isInt({ min: 0 })
|
||||||
|
], async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
slug_prefix,
|
||||||
|
emoji,
|
||||||
|
theme_preset,
|
||||||
|
theme_config,
|
||||||
|
display_order
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
const eventType = await eventTypeService.createEventType({
|
||||||
|
name,
|
||||||
|
slug_prefix,
|
||||||
|
emoji,
|
||||||
|
theme_preset,
|
||||||
|
theme_config,
|
||||||
|
display_order
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
await logActivity('event_type_created',
|
||||||
|
{ name, slug_prefix },
|
||||||
|
null,
|
||||||
|
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json(eventType);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error creating event type:', { error: error.message });
|
||||||
|
|
||||||
|
if (error.code === 'DUPLICATE_SLUG_PREFIX') {
|
||||||
|
return res.status(400).json({ error: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({ error: 'Failed to create event type' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /admin/event-types/:id
|
||||||
|
* Update an event type
|
||||||
|
*/
|
||||||
|
router.put('/:id', adminAuth, requirePermission('settings.edit'), [
|
||||||
|
param('id').isInt().withMessage('Invalid event type ID'),
|
||||||
|
body('name').optional().notEmpty().trim().withMessage('Name cannot be empty'),
|
||||||
|
body('slug_prefix')
|
||||||
|
.optional()
|
||||||
|
.trim()
|
||||||
|
.isLength({ min: 2, max: 50 })
|
||||||
|
.matches(/^[a-z0-9-]+$/i)
|
||||||
|
.withMessage('Slug prefix must be 2-50 characters and contain only letters, numbers, and hyphens'),
|
||||||
|
body('emoji').optional().trim(),
|
||||||
|
body('theme_preset').optional().trim(),
|
||||||
|
body('theme_config').optional(),
|
||||||
|
body('display_order').optional().isInt({ min: 0 }),
|
||||||
|
body('is_active').optional().isBoolean()
|
||||||
|
], async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const updates = req.body;
|
||||||
|
|
||||||
|
const eventType = await eventTypeService.updateEventType(parseInt(id), updates);
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
await logActivity('event_type_updated',
|
||||||
|
{ id, changes: Object.keys(updates) },
|
||||||
|
null,
|
||||||
|
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(eventType);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating event type:', { error: error.message });
|
||||||
|
|
||||||
|
if (error.code === 'NOT_FOUND') {
|
||||||
|
return res.status(404).json({ error: error.message });
|
||||||
|
}
|
||||||
|
if (error.code === 'DUPLICATE_SLUG_PREFIX') {
|
||||||
|
return res.status(400).json({ error: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({ error: 'Failed to update event type' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /admin/event-types/:id
|
||||||
|
* Delete an event type (only non-system types with no events)
|
||||||
|
*/
|
||||||
|
router.delete('/:id', adminAuth, requirePermission('settings.edit'), [
|
||||||
|
param('id').isInt().withMessage('Invalid event type ID')
|
||||||
|
], async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const result = await eventTypeService.deleteEventType(parseInt(id));
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
await logActivity('event_type_deleted',
|
||||||
|
{ id, name: result.deleted.name, slug_prefix: result.deleted.slug_prefix },
|
||||||
|
null,
|
||||||
|
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ message: 'Event type deleted successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting event type:', { error: error.message });
|
||||||
|
|
||||||
|
if (error.code === 'NOT_FOUND') {
|
||||||
|
return res.status(404).json({ error: error.message });
|
||||||
|
}
|
||||||
|
if (error.code === 'SYSTEM_TYPE' || error.code === 'IN_USE') {
|
||||||
|
return res.status(400).json({ error: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({ error: 'Failed to delete event type' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/event-types/reorder
|
||||||
|
* Reorder event types by providing an array of IDs in the desired order
|
||||||
|
*/
|
||||||
|
router.post('/reorder', adminAuth, requirePermission('settings.edit'), [
|
||||||
|
body('orderedIds').isArray().withMessage('orderedIds must be an array'),
|
||||||
|
body('orderedIds.*').isInt().withMessage('Each ID must be an integer')
|
||||||
|
], async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orderedIds } = req.body;
|
||||||
|
const eventTypes = await eventTypeService.reorderEventTypes(orderedIds);
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
await logActivity('event_types_reordered',
|
||||||
|
{ newOrder: orderedIds },
|
||||||
|
null,
|
||||||
|
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ eventTypes, message: 'Event types reordered successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error reordering event types:', { error: error.message });
|
||||||
|
res.status(500).json({ error: 'Failed to reorder event types' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -6,8 +6,10 @@ const { buildShareLinkVariants } = require('../services/shareLinkService');
|
|||||||
const { requirePermission } = require('../middleware/permissions');
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
|
|
||||||
// Enhanced event creation with password validation
|
// Enhanced event creation with password validation
|
||||||
|
// Note: This is a partial/reference file - dynamic event type validation should be implemented
|
||||||
|
// similar to adminEvents.js using eventTypeService.isValidEventType()
|
||||||
router.post('/', adminAuth, requirePermission('events.create'), [
|
router.post('/', adminAuth, requirePermission('events.create'), [
|
||||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
body('event_type').notEmpty().trim(), // Dynamic validation via eventTypeService
|
||||||
body('event_name').notEmpty().trim(),
|
body('event_name').notEmpty().trim(),
|
||||||
body('event_date').isDate(),
|
body('event_date').isDate(),
|
||||||
body('customer_email').isEmail().normalizeEmail(),
|
body('customer_email').isEmail().normalizeEmail(),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwor
|
|||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||||
const { parseBooleanInput, parseStringInput, parseJsonInput } = require('../utils/parsers');
|
const { parseBooleanInput, parseStringInput, parseJsonInput } = require('../utils/parsers');
|
||||||
|
const eventTypeService = require('../services/eventTypeService');
|
||||||
|
|
||||||
// Helper to get event field requirements from settings
|
// Helper to get event field requirements from settings
|
||||||
const getEventFieldRequirements = async () => {
|
const getEventFieldRequirements = async () => {
|
||||||
@@ -112,7 +113,13 @@ const hasCustomerContactColumns = async () => {
|
|||||||
|
|
||||||
// Create new event
|
// Create new event
|
||||||
router.post('/', adminAuth, requirePermission('events.create'), [
|
router.post('/', adminAuth, requirePermission('events.create'), [
|
||||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
body('event_type').notEmpty().trim().custom(async (value) => {
|
||||||
|
const isValid = await eventTypeService.isValidEventType(value);
|
||||||
|
if (!isValid) {
|
||||||
|
throw new Error('Invalid event type');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
body('event_name').notEmpty().trim(),
|
body('event_name').notEmpty().trim(),
|
||||||
body('event_date').optional().isDate(),
|
body('event_date').optional().isDate(),
|
||||||
body('customer_name').optional().trim(),
|
body('customer_name').optional().trim(),
|
||||||
@@ -151,7 +158,11 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
|||||||
body('disable_right_click').optional().isBoolean(),
|
body('disable_right_click').optional().isBoolean(),
|
||||||
body('watermark_downloads').optional().isBoolean(),
|
body('watermark_downloads').optional().isBoolean(),
|
||||||
body('watermark_text').optional().trim(),
|
body('watermark_text').optional().trim(),
|
||||||
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt()
|
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
||||||
|
// Hero logo settings
|
||||||
|
body('hero_logo_visible').optional().isBoolean(),
|
||||||
|
body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']),
|
||||||
|
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom'])
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
logger.debug('Create event request body', { body: req.body });
|
logger.debug('Create event request body', { body: req.body });
|
||||||
@@ -190,7 +201,11 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
|||||||
moderate_comments = true,
|
moderate_comments = true,
|
||||||
show_feedback_to_guests = true,
|
show_feedback_to_guests = true,
|
||||||
// CSS Template
|
// CSS Template
|
||||||
css_template_id = null
|
css_template_id = null,
|
||||||
|
// Hero logo settings
|
||||||
|
hero_logo_visible = true,
|
||||||
|
hero_logo_size = 'medium',
|
||||||
|
hero_logo_position = 'top'
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
const customerName = getCustomerNameFromPayload(req.body);
|
const customerName = getCustomerNameFromPayload(req.body);
|
||||||
@@ -250,6 +265,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get event type info for slug generation
|
||||||
|
const eventTypeInfo = await eventTypeService.getEventTypeForSlug(event_type);
|
||||||
|
const slugPrefix = eventTypeInfo.slug_prefix || event_type;
|
||||||
|
|
||||||
// Generate unique slug
|
// Generate unique slug
|
||||||
const processedEventName = event_name
|
const processedEventName = event_name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -324,7 +343,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
|||||||
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
|
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
|
||||||
watermark_text,
|
watermark_text,
|
||||||
require_password: formatBoolean(requirePassword),
|
require_password: formatBoolean(requirePassword),
|
||||||
css_template_id: css_template_id || null
|
css_template_id: css_template_id || null,
|
||||||
|
hero_logo_visible: formatBoolean(hero_logo_visible !== undefined ? hero_logo_visible : true),
|
||||||
|
hero_logo_size: hero_logo_size || 'medium',
|
||||||
|
hero_logo_position: hero_logo_position || 'top'
|
||||||
}).returning('id');
|
}).returning('id');
|
||||||
|
|
||||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||||
@@ -601,7 +623,11 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt()
|
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
||||||
|
// Hero logo settings
|
||||||
|
body('hero_logo_visible').optional().isBoolean(),
|
||||||
|
body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']),
|
||||||
|
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom'])
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
@@ -713,6 +739,11 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
|
|||||||
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Format hero logo settings if provided
|
||||||
|
if (Object.prototype.hasOwnProperty.call(updates, 'hero_logo_visible')) {
|
||||||
|
updates.hero_logo_visible = formatBoolean(updates.hero_logo_visible);
|
||||||
|
}
|
||||||
|
|
||||||
// Update event
|
// Update event
|
||||||
await db('events')
|
await db('events')
|
||||||
.where('id', id)
|
.where('id', id)
|
||||||
@@ -946,7 +977,8 @@ router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), as
|
|||||||
|
|
||||||
// For resending creation email, we need the actual password
|
// For resending creation email, we need the actual password
|
||||||
// First, try to get it from the request body if provided
|
// First, try to get it from the request body if provided
|
||||||
let galleryPassword = req.body.password;
|
// Use optional chaining to handle cases where req.body might be undefined
|
||||||
|
let galleryPassword = req.body?.password;
|
||||||
|
|
||||||
// If no password provided, we can't decrypt the existing one
|
// If no password provided, we can't decrypt the existing one
|
||||||
// So we'll show a security message
|
// So we'll show a security message
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const path = require('path');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||||
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
|
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
|
||||||
|
const eventTypeService = require('../services/eventTypeService');
|
||||||
|
|
||||||
// Use parseStringInput from shared parsers for customer data extraction
|
// Use parseStringInput from shared parsers for customer data extraction
|
||||||
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
|
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
|
||||||
@@ -55,7 +56,13 @@ const hasCustomerContactColumns = async () => {
|
|||||||
|
|
||||||
// Create new event
|
// Create new event
|
||||||
router.post('/', adminAuth, [
|
router.post('/', adminAuth, [
|
||||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
body('event_type').notEmpty().trim().custom(async (value) => {
|
||||||
|
const isValid = await eventTypeService.isValidEventType(value);
|
||||||
|
if (!isValid) {
|
||||||
|
throw new Error('Invalid event type');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
body('event_name').notEmpty(),
|
body('event_name').notEmpty(),
|
||||||
body('event_date').isDate(),
|
body('event_date').isDate(),
|
||||||
body('customer_name').notEmpty().trim(),
|
body('customer_name').notEmpty().trim(),
|
||||||
|
|||||||
@@ -115,7 +115,10 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
'require_password',
|
'require_password',
|
||||||
'color_theme',
|
'color_theme',
|
||||||
'enable_devtools_protection',
|
'enable_devtools_protection',
|
||||||
'use_canvas_rendering'
|
'use_canvas_rendering',
|
||||||
|
'hero_logo_visible',
|
||||||
|
'hero_logo_size',
|
||||||
|
'hero_logo_position'
|
||||||
)
|
)
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
@@ -162,7 +165,10 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
watermark_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1',
|
watermark_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1',
|
||||||
watermark_text: event.watermark_text,
|
watermark_text: event.watermark_text,
|
||||||
enable_devtools_protection: event.enable_devtools_protection === true || event.enable_devtools_protection === 1 || event.enable_devtools_protection === '1',
|
enable_devtools_protection: event.enable_devtools_protection === true || event.enable_devtools_protection === 1 || event.enable_devtools_protection === '1',
|
||||||
use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1'
|
use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1',
|
||||||
|
hero_logo_visible: event.hero_logo_visible !== false && event.hero_logo_visible !== 0 && event.hero_logo_visible !== '0',
|
||||||
|
hero_logo_size: event.hero_logo_size || 'medium',
|
||||||
|
hero_logo_position: event.hero_logo_position || 'top'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching gallery info:', error);
|
console.error('Error fetching gallery info:', error);
|
||||||
@@ -332,6 +338,9 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
|||||||
watermark_text: req.event.watermark_text,
|
watermark_text: req.event.watermark_text,
|
||||||
enable_devtools_protection: req.event.enable_devtools_protection === true,
|
enable_devtools_protection: req.event.enable_devtools_protection === true,
|
||||||
use_canvas_rendering: req.event.use_canvas_rendering === true,
|
use_canvas_rendering: req.event.use_canvas_rendering === true,
|
||||||
|
hero_logo_visible: req.event.hero_logo_visible !== false && req.event.hero_logo_visible !== 0 && req.event.hero_logo_visible !== '0',
|
||||||
|
hero_logo_size: req.event.hero_logo_size || 'medium',
|
||||||
|
hero_logo_position: req.event.hero_logo_position || 'top',
|
||||||
...protectionSettings
|
...protectionSettings
|
||||||
},
|
},
|
||||||
categories: categories,
|
categories: categories,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const { formatBoolean } = require('../utils/dbCompat');
|
|||||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||||
const { buildShareLinkVariants } = require('./shareLinkService');
|
const { buildShareLinkVariants } = require('./shareLinkService');
|
||||||
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
|
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
|
||||||
|
const eventTypeService = require('./eventTypeService');
|
||||||
|
|
||||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
|
|
||||||
@@ -67,13 +68,23 @@ const mapEventForApi = (event) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a unique slug for an event
|
* Generate a unique slug for an event
|
||||||
* @param {string} eventType
|
* @param {string} eventType - Event type identifier (slug_prefix or legacy type)
|
||||||
* @param {string} eventName
|
* @param {string} eventName
|
||||||
* @param {string} eventDate
|
* @param {string} eventDate
|
||||||
* @returns {Promise<string>}
|
* @returns {Promise<string>}
|
||||||
*/
|
*/
|
||||||
const generateUniqueSlug = async (eventType, eventName, eventDate) => {
|
const generateUniqueSlug = async (eventType, eventName, eventDate) => {
|
||||||
const baseSlug = `${eventType}-${eventName.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${eventDate}`;
|
// Get the slug_prefix from event type (supports both new dynamic types and legacy)
|
||||||
|
const eventTypeInfo = await eventTypeService.getEventTypeForSlug(eventType);
|
||||||
|
const slugPrefix = eventTypeInfo.slug_prefix || eventType;
|
||||||
|
|
||||||
|
const processedName = eventName
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
|
||||||
|
const baseSlug = `${slugPrefix}-${processedName}-${eventDate}`;
|
||||||
let slug = baseSlug;
|
let slug = baseSlug;
|
||||||
let counter = 1;
|
let counter = 1;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
/**
|
||||||
|
* Event Type Service Layer
|
||||||
|
* Handles all event type-related business logic
|
||||||
|
*
|
||||||
|
* @module services/eventTypeService
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all event types
|
||||||
|
* @param {Object} options - Filter options
|
||||||
|
* @param {boolean} options.activeOnly - Only return active types
|
||||||
|
* @returns {Promise<Array>} - Array of event types
|
||||||
|
*/
|
||||||
|
const getAllEventTypes = async (options = {}) => {
|
||||||
|
const { activeOnly = false } = options;
|
||||||
|
|
||||||
|
let query = db('event_types').select('*');
|
||||||
|
|
||||||
|
if (activeOnly) {
|
||||||
|
query = query.where('is_active', formatBoolean(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
const types = await query.orderBy('display_order', 'asc');
|
||||||
|
return types;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active event types for dropdown/selection
|
||||||
|
* @returns {Promise<Array>} - Array of active event types
|
||||||
|
*/
|
||||||
|
const getActiveEventTypes = async () => {
|
||||||
|
return getAllEventTypes({ activeOnly: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an event type by ID
|
||||||
|
* @param {number} id - Event type ID
|
||||||
|
* @returns {Promise<Object|null>}
|
||||||
|
*/
|
||||||
|
const getEventTypeById = async (id) => {
|
||||||
|
const eventType = await db('event_types').where('id', id).first();
|
||||||
|
return eventType || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an event type by slug prefix
|
||||||
|
* @param {string} slugPrefix - The slug prefix
|
||||||
|
* @returns {Promise<Object|null>}
|
||||||
|
*/
|
||||||
|
const getEventTypeBySlugPrefix = async (slugPrefix) => {
|
||||||
|
const eventType = await db('event_types')
|
||||||
|
.where('slug_prefix', slugPrefix.toLowerCase())
|
||||||
|
.first();
|
||||||
|
return eventType || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a slug prefix is valid (exists in event_types or is a legacy type)
|
||||||
|
* @param {string} slugPrefix - The slug prefix to validate
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
const isValidEventType = async (slugPrefix) => {
|
||||||
|
const normalized = slugPrefix.toLowerCase();
|
||||||
|
|
||||||
|
// Check in database
|
||||||
|
const eventType = await getEventTypeBySlugPrefix(normalized);
|
||||||
|
if (eventType && eventType.is_active) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy fallback: Accept old hardcoded values for backward compatibility
|
||||||
|
const legacyTypes = ['wedding', 'birthday', 'corporate', 'other'];
|
||||||
|
return legacyTypes.includes(normalized);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all valid slug prefixes (for validation)
|
||||||
|
* @returns {Promise<string[]>}
|
||||||
|
*/
|
||||||
|
const getValidSlugPrefixes = async () => {
|
||||||
|
const types = await db('event_types')
|
||||||
|
.where('is_active', formatBoolean(true))
|
||||||
|
.select('slug_prefix');
|
||||||
|
|
||||||
|
return types.map(t => t.slug_prefix);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new event type
|
||||||
|
* @param {Object} eventTypeData - Event type data
|
||||||
|
* @returns {Promise<Object>} - Created event type
|
||||||
|
*/
|
||||||
|
const createEventType = async (eventTypeData) => {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
slug_prefix,
|
||||||
|
emoji,
|
||||||
|
theme_preset,
|
||||||
|
theme_config,
|
||||||
|
display_order
|
||||||
|
} = eventTypeData;
|
||||||
|
|
||||||
|
// Normalize slug_prefix
|
||||||
|
const normalizedSlugPrefix = slug_prefix.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||||
|
|
||||||
|
// Check for duplicate slug_prefix
|
||||||
|
const existing = await getEventTypeBySlugPrefix(normalizedSlugPrefix);
|
||||||
|
if (existing) {
|
||||||
|
const error = new Error('An event type with this slug prefix already exists');
|
||||||
|
error.code = 'DUPLICATE_SLUG_PREFIX';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get max display order if not provided
|
||||||
|
let finalDisplayOrder = display_order;
|
||||||
|
if (finalDisplayOrder === undefined || finalDisplayOrder === null) {
|
||||||
|
const maxOrder = await db('event_types').max('display_order as max').first();
|
||||||
|
finalDisplayOrder = (maxOrder?.max || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertData = {
|
||||||
|
name,
|
||||||
|
slug_prefix: normalizedSlugPrefix,
|
||||||
|
emoji: emoji || '📷',
|
||||||
|
theme_preset: theme_preset || 'default',
|
||||||
|
theme_config: theme_config ? JSON.stringify(theme_config) : null,
|
||||||
|
display_order: finalDisplayOrder,
|
||||||
|
is_system: false,
|
||||||
|
is_active: true,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertResult = await db('event_types').insert(insertData).returning('id');
|
||||||
|
const eventTypeId = insertResult[0]?.id || insertResult[0];
|
||||||
|
|
||||||
|
return getEventTypeById(eventTypeId);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an event type
|
||||||
|
* @param {number} id - Event type ID
|
||||||
|
* @param {Object} updates - Fields to update
|
||||||
|
* @returns {Promise<Object>} - Updated event type
|
||||||
|
*/
|
||||||
|
const updateEventType = async (id, updates) => {
|
||||||
|
const eventType = await getEventTypeById(id);
|
||||||
|
if (!eventType) {
|
||||||
|
const error = new Error('Event type not found');
|
||||||
|
error.code = 'NOT_FOUND';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update object
|
||||||
|
const updateData = {};
|
||||||
|
|
||||||
|
if (updates.name !== undefined) {
|
||||||
|
updateData.name = updates.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.slug_prefix !== undefined) {
|
||||||
|
const normalizedSlugPrefix = updates.slug_prefix.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||||
|
|
||||||
|
// Check for duplicate (excluding current)
|
||||||
|
const existing = await db('event_types')
|
||||||
|
.where('slug_prefix', normalizedSlugPrefix)
|
||||||
|
.whereNot('id', id)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const error = new Error('An event type with this slug prefix already exists');
|
||||||
|
error.code = 'DUPLICATE_SLUG_PREFIX';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData.slug_prefix = normalizedSlugPrefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.emoji !== undefined) {
|
||||||
|
updateData.emoji = updates.emoji;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.theme_preset !== undefined) {
|
||||||
|
updateData.theme_preset = updates.theme_preset;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.theme_config !== undefined) {
|
||||||
|
updateData.theme_config = updates.theme_config ? JSON.stringify(updates.theme_config) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.display_order !== undefined) {
|
||||||
|
updateData.display_order = updates.display_order;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.is_active !== undefined) {
|
||||||
|
updateData.is_active = formatBoolean(updates.is_active);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData.updated_at = new Date();
|
||||||
|
|
||||||
|
await db('event_types').where('id', id).update(updateData);
|
||||||
|
|
||||||
|
return getEventTypeById(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an event type
|
||||||
|
* @param {number} id - Event type ID
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
const deleteEventType = async (id) => {
|
||||||
|
const eventType = await getEventTypeById(id);
|
||||||
|
if (!eventType) {
|
||||||
|
const error = new Error('Event type not found');
|
||||||
|
error.code = 'NOT_FOUND';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent deletion of system types
|
||||||
|
if (eventType.is_system) {
|
||||||
|
const error = new Error('Cannot delete system event types. You can deactivate them instead.');
|
||||||
|
error.code = 'SYSTEM_TYPE';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any events use this type
|
||||||
|
const eventsUsingType = await db('events')
|
||||||
|
.where('event_type', eventType.slug_prefix)
|
||||||
|
.count('id as count')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (eventsUsingType && parseInt(eventsUsingType.count) > 0) {
|
||||||
|
const error = new Error(`Cannot delete: ${eventsUsingType.count} events are using this type. Deactivate it instead or reassign those events.`);
|
||||||
|
error.code = 'IN_USE';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db('event_types').where('id', id).del();
|
||||||
|
|
||||||
|
return { success: true, deleted: eventType };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorder event types
|
||||||
|
* @param {Array} orderedIds - Array of IDs in new order
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
|
const reorderEventTypes = async (orderedIds) => {
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
for (let i = 0; i < orderedIds.length; i++) {
|
||||||
|
await trx('event_types')
|
||||||
|
.where('id', orderedIds[i])
|
||||||
|
.update({ display_order: i + 1, updated_at: new Date() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return getAllEventTypes();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get event type info for slug generation
|
||||||
|
* Returns the slug_prefix to use for a given event type identifier
|
||||||
|
* @param {string} eventTypeIdentifier - Either an ID or slug_prefix
|
||||||
|
* @returns {Promise<Object>} - Event type with slug_prefix and theme_preset
|
||||||
|
*/
|
||||||
|
const getEventTypeForSlug = async (eventTypeIdentifier) => {
|
||||||
|
// Try to find by slug_prefix first
|
||||||
|
let eventType = await getEventTypeBySlugPrefix(eventTypeIdentifier);
|
||||||
|
|
||||||
|
if (eventType) {
|
||||||
|
return eventType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try by ID if numeric
|
||||||
|
if (!isNaN(eventTypeIdentifier)) {
|
||||||
|
eventType = await getEventTypeById(parseInt(eventTypeIdentifier));
|
||||||
|
if (eventType) {
|
||||||
|
return eventType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for legacy types - return a compatible object
|
||||||
|
const legacyDefaults = {
|
||||||
|
wedding: { slug_prefix: 'wedding', theme_preset: 'elegantWedding', emoji: '💒' },
|
||||||
|
birthday: { slug_prefix: 'birthday', theme_preset: 'birthdayFun', emoji: '🎂' },
|
||||||
|
corporate: { slug_prefix: 'corporate', theme_preset: 'corporateTimeline', emoji: '🏢' },
|
||||||
|
other: { slug_prefix: 'other', theme_preset: 'default', emoji: '📸' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalized = eventTypeIdentifier.toLowerCase();
|
||||||
|
if (legacyDefaults[normalized]) {
|
||||||
|
return legacyDefaults[normalized];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default fallback
|
||||||
|
return { slug_prefix: 'event', theme_preset: 'default', emoji: '📷' };
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getAllEventTypes,
|
||||||
|
getActiveEventTypes,
|
||||||
|
getEventTypeById,
|
||||||
|
getEventTypeBySlugPrefix,
|
||||||
|
isValidEventType,
|
||||||
|
getValidSlugPrefixes,
|
||||||
|
createEventType,
|
||||||
|
updateEventType,
|
||||||
|
deleteEventType,
|
||||||
|
reorderEventTypes,
|
||||||
|
getEventTypeForSlug
|
||||||
|
};
|
||||||
@@ -24,7 +24,8 @@ import {
|
|||||||
SettingsPage,
|
SettingsPage,
|
||||||
BackupManagement,
|
BackupManagement,
|
||||||
CMSPage,
|
CMSPage,
|
||||||
UserManagementPage
|
UserManagementPage,
|
||||||
|
EventTypesPage
|
||||||
} from './pages/admin';
|
} from './pages/admin';
|
||||||
import { AcceptInvitePage } from './pages/public/AcceptInvitePage';
|
import { AcceptInvitePage } from './pages/public/AcceptInvitePage';
|
||||||
import { AdminLayout, AdminAuthWrapper } from './components/admin';
|
import { AdminLayout, AdminAuthWrapper } from './components/admin';
|
||||||
@@ -128,6 +129,7 @@ function App() {
|
|||||||
<Route path="analytics" element={<AnalyticsPage />} />
|
<Route path="analytics" element={<AnalyticsPage />} />
|
||||||
<Route path="branding" element={<BrandingPage />} />
|
<Route path="branding" element={<BrandingPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
|
<Route path="event-types" element={<EventTypesPage />} />
|
||||||
<Route path="backup" element={<BackupManagement />} />
|
<Route path="backup" element={<BackupManagement />} />
|
||||||
<Route path="cms" element={<CMSPage />} />
|
<Route path="cms" element={<CMSPage />} />
|
||||||
<Route path="users" element={<UserManagementPage />} />
|
<Route path="users" element={<UserManagementPage />} />
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
Palette,
|
Palette,
|
||||||
FileText,
|
FileText,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Users
|
Users,
|
||||||
|
Tags
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -39,6 +40,7 @@ const navigation: NavItem[] = [
|
|||||||
{ nameKey: 'navigation.emailSettings', href: '/admin/email', icon: Mail, permission: 'email.view' },
|
{ nameKey: 'navigation.emailSettings', href: '/admin/email', icon: Mail, permission: 'email.view' },
|
||||||
{ nameKey: 'navigation.branding', href: '/admin/branding', icon: Palette, permission: 'branding.view' },
|
{ nameKey: 'navigation.branding', href: '/admin/branding', icon: Palette, permission: 'branding.view' },
|
||||||
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' },
|
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' },
|
||||||
|
{ nameKey: 'navigation.eventTypes', href: '/admin/event-types', icon: Tags, permission: 'settings.view' },
|
||||||
{ nameKey: 'navigation.backup', href: '/admin/backup', icon: HardDrive, permission: 'backup.view' },
|
{ nameKey: 'navigation.backup', href: '/admin/backup', icon: HardDrive, permission: 'backup.view' },
|
||||||
{ nameKey: 'navigation.cmsPages', href: '/admin/cms', icon: FileText, permission: 'cms.view' },
|
{ nameKey: 'navigation.cmsPages', href: '/admin/cms', icon: FileText, permission: 'cms.view' },
|
||||||
{ nameKey: 'navigation.users', href: '/admin/users', icon: Users, permission: 'users.view' },
|
{ nameKey: 'navigation.users', href: '/admin/users', icon: Users, permission: 'users.view' },
|
||||||
|
|||||||
@@ -75,7 +75,8 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isPreviewMode) {
|
if (isPreviewMode) {
|
||||||
onChange(updated);
|
// Include customCss in the propagated theme
|
||||||
|
onChange({ ...updated, customCss });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,6 +85,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
if (preset) {
|
if (preset) {
|
||||||
setSelectedPreset(presetKey);
|
setSelectedPreset(presetKey);
|
||||||
setLocalTheme(preset.config);
|
setLocalTheme(preset.config);
|
||||||
|
setCustomCss(''); // Clear custom CSS when selecting a preset
|
||||||
if (onPresetChange) {
|
if (onPresetChange) {
|
||||||
onPresetChange(presetKey);
|
onPresetChange(presetKey);
|
||||||
}
|
}
|
||||||
@@ -722,12 +724,17 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
<textarea
|
<textarea
|
||||||
value={customCss}
|
value={customCss}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setCustomCss(e.target.value);
|
const newCss = e.target.value;
|
||||||
|
setCustomCss(newCss);
|
||||||
// Mark as custom when CSS is added
|
// Mark as custom when CSS is added
|
||||||
if (e.target.value && selectedPreset !== 'custom' && onPresetChange) {
|
if (newCss && selectedPreset !== 'custom' && onPresetChange) {
|
||||||
setSelectedPreset('custom');
|
setSelectedPreset('custom');
|
||||||
onPresetChange('custom');
|
onPresetChange('custom');
|
||||||
}
|
}
|
||||||
|
// Propagate customCss changes to parent in preview mode
|
||||||
|
if (isPreviewMode) {
|
||||||
|
onChange({ ...localTheme, customCss: newCss });
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="/* Add custom CSS here */"
|
placeholder="/* Add custom CSS here */"
|
||||||
className="w-full h-40 px-3 py-2 font-mono text-sm border border-neutral-300 rounded-lg bg-neutral-50"
|
className="w-full h-40 px-3 py-2 font-mono text-sm border border-neutral-300 rounded-lg bg-neutral-50"
|
||||||
|
|||||||
@@ -310,10 +310,12 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
}
|
}
|
||||||
}, [settingsData, data, setTheme]); // Use data instead of event prop
|
}, [settingsData, data, setTheme]); // Use data instead of event prop
|
||||||
|
|
||||||
// Calculate days until expiration
|
// Calculate days until expiration (null means never expires)
|
||||||
const daysUntilExpiration = differenceInDays(parseISO(event.expires_at), new Date());
|
const daysUntilExpiration = event.expires_at
|
||||||
const showUrgentWarning = daysUntilExpiration <= 7;
|
? differenceInDays(parseISO(event.expires_at), new Date())
|
||||||
const isExpired = daysUntilExpiration < 0;
|
: null;
|
||||||
|
const showUrgentWarning = daysUntilExpiration !== null && daysUntilExpiration <= 7;
|
||||||
|
const isExpired = daysUntilExpiration !== null && daysUntilExpiration < 0;
|
||||||
|
|
||||||
// Filter and sort photos
|
// Filter and sort photos
|
||||||
const filteredPhotos = useMemo(() => {
|
const filteredPhotos = useMemo(() => {
|
||||||
@@ -602,7 +604,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
headerExtra={(() => {
|
headerExtra={(() => {
|
||||||
const items = [];
|
const items = [];
|
||||||
|
|
||||||
if (daysUntilExpiration <= 1 && daysUntilExpiration > 0) {
|
if (daysUntilExpiration !== null && daysUntilExpiration <= 1 && daysUntilExpiration > 0) {
|
||||||
items.push(
|
items.push(
|
||||||
<CountdownTimer key="countdown" expiresAt={event.expires_at} className="mr-2" />
|
<CountdownTimer key="countdown" expiresAt={event.expires_at} className="mr-2" />
|
||||||
);
|
);
|
||||||
@@ -689,6 +691,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
disableRightClick={disableRightClick}
|
disableRightClick={disableRightClick}
|
||||||
enableDevtoolsProtection={enableDevtoolsProtection}
|
enableDevtoolsProtection={enableDevtoolsProtection}
|
||||||
useCanvasRendering={useCanvasRendering}
|
useCanvasRendering={useCanvasRendering}
|
||||||
|
heroLogoVisible={data?.event?.hero_logo_visible !== false}
|
||||||
|
heroLogoSize={data?.event?.hero_logo_size || 'medium'}
|
||||||
|
heroLogoPosition={data?.event?.hero_logo_position || 'top'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ interface PhotoGridWithLayoutsProps {
|
|||||||
requireNameEmail?: boolean;
|
requireNameEmail?: boolean;
|
||||||
};
|
};
|
||||||
onFeedbackChange?: () => void;
|
onFeedbackChange?: () => void;
|
||||||
|
// Hero logo customization options
|
||||||
|
heroLogoVisible?: boolean;
|
||||||
|
heroLogoSize?: 'small' | 'medium' | 'large' | 'xlarge';
|
||||||
|
heroLogoPosition?: 'top' | 'center' | 'bottom';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||||
@@ -76,7 +80,10 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
eventName,
|
eventName,
|
||||||
eventLogo,
|
eventLogo,
|
||||||
eventDate,
|
eventDate,
|
||||||
expiresAt
|
expiresAt,
|
||||||
|
heroLogoVisible = true,
|
||||||
|
heroLogoSize = 'medium',
|
||||||
|
heroLogoPosition = 'top'
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
@@ -200,6 +207,9 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
expiresAt,
|
expiresAt,
|
||||||
feedbackEnabled,
|
feedbackEnabled,
|
||||||
feedbackOptions,
|
feedbackOptions,
|
||||||
|
heroLogoVisible,
|
||||||
|
heroLogoSize,
|
||||||
|
heroLogoPosition,
|
||||||
};
|
};
|
||||||
|
|
||||||
let LayoutComponent;
|
let LayoutComponent;
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ interface HeroGalleryLayoutProps extends BaseGalleryLayoutProps {
|
|||||||
expiresAt?: string;
|
expiresAt?: string;
|
||||||
// Use a static hero photo independent of current filter
|
// Use a static hero photo independent of current filter
|
||||||
heroPhotoOverride?: Photo | null;
|
heroPhotoOverride?: Photo | null;
|
||||||
|
// Hero logo customization options
|
||||||
|
heroLogoVisible?: boolean;
|
||||||
|
heroLogoSize?: 'small' | 'medium' | 'large' | 'xlarge';
|
||||||
|
heroLogoPosition?: 'top' | 'center' | 'bottom';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
||||||
@@ -34,6 +38,9 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
|||||||
eventDate,
|
eventDate,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
heroPhotoOverride,
|
heroPhotoOverride,
|
||||||
|
heroLogoVisible = true,
|
||||||
|
heroLogoSize = 'medium',
|
||||||
|
heroLogoPosition = 'top',
|
||||||
allowDownloads = true,
|
allowDownloads = true,
|
||||||
protectionLevel = 'standard',
|
protectionLevel = 'standard',
|
||||||
useEnhancedProtection = false,
|
useEnhancedProtection = false,
|
||||||
@@ -51,6 +58,22 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
|||||||
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||||
const gallerySettings = theme.gallerySettings || {};
|
const gallerySettings = theme.gallerySettings || {};
|
||||||
const overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3;
|
const overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3;
|
||||||
|
|
||||||
|
// Helper function to get logo size classes
|
||||||
|
const getLogoSizeClasses = (size: string): string => {
|
||||||
|
switch (size) {
|
||||||
|
case 'small':
|
||||||
|
return 'h-12 sm:h-14 lg:h-16';
|
||||||
|
case 'medium':
|
||||||
|
return 'h-20 sm:h-24 lg:h-32';
|
||||||
|
case 'large':
|
||||||
|
return 'h-28 sm:h-32 lg:h-40';
|
||||||
|
case 'xlarge':
|
||||||
|
return 'h-36 sm:h-40 lg:h-48';
|
||||||
|
default:
|
||||||
|
return 'h-20 sm:h-24 lg:h-32';
|
||||||
|
}
|
||||||
|
};
|
||||||
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
|
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
|
||||||
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
|
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
|
||||||
const gridRef = useRef<HTMLDivElement | null>(null);
|
const gridRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -133,7 +156,8 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
|||||||
{/* Hero Content */}
|
{/* Hero Content */}
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<div className="text-center px-4">
|
<div className="text-center px-4">
|
||||||
{/* Logo - Show custom logo or fallback to PicPeak logo */}
|
{/* Logo at top position */}
|
||||||
|
{heroLogoVisible && heroLogoPosition === 'top' && (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<img
|
<img
|
||||||
src={eventLogo ?
|
src={eventLogo ?
|
||||||
@@ -141,15 +165,15 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
|||||||
'/picpeak-logo-transparent.png'
|
'/picpeak-logo-transparent.png'
|
||||||
}
|
}
|
||||||
alt="Event logo"
|
alt="Event logo"
|
||||||
className="h-20 sm:h-24 lg:h-32 mx-auto"
|
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
|
||||||
style={{
|
style={{
|
||||||
// Only apply brightness/invert filter to default logo; custom logos display as-is
|
|
||||||
filter: eventLogo
|
filter: eventLogo
|
||||||
? 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
? 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
||||||
: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Event Title */}
|
{/* Event Title */}
|
||||||
{eventName && (
|
{eventName && (
|
||||||
@@ -158,6 +182,25 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
|||||||
</h1>
|
</h1>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Logo at center position (between title and dates) */}
|
||||||
|
{heroLogoVisible && heroLogoPosition === 'center' && (
|
||||||
|
<div className="my-6">
|
||||||
|
<img
|
||||||
|
src={eventLogo ?
|
||||||
|
buildResourceUrl(eventLogo) :
|
||||||
|
'/picpeak-logo-transparent.png'
|
||||||
|
}
|
||||||
|
alt="Event logo"
|
||||||
|
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
|
||||||
|
style={{
|
||||||
|
filter: eventLogo
|
||||||
|
? 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
||||||
|
: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Event Dates */}
|
{/* Event Dates */}
|
||||||
{(eventDate || expiresAt) && (
|
{(eventDate || expiresAt) && (
|
||||||
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 text-white/90">
|
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 text-white/90">
|
||||||
@@ -175,6 +218,25 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Logo at bottom position */}
|
||||||
|
{heroLogoVisible && heroLogoPosition === 'bottom' && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<img
|
||||||
|
src={eventLogo ?
|
||||||
|
buildResourceUrl(eventLogo) :
|
||||||
|
'/picpeak-logo-transparent.png'
|
||||||
|
}
|
||||||
|
alt="Event logo"
|
||||||
|
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
|
||||||
|
style={{
|
||||||
|
filter: eventLogo
|
||||||
|
? 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
||||||
|
: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export interface GeneralSettings {
|
|||||||
max_file_size_mb: number;
|
max_file_size_mb: number;
|
||||||
max_files_per_upload: number;
|
max_files_per_upload: number;
|
||||||
allowed_file_types: string;
|
allowed_file_types: string;
|
||||||
enable_watermark: boolean;
|
|
||||||
enable_analytics: boolean;
|
enable_analytics: boolean;
|
||||||
enable_registration: boolean;
|
enable_registration: boolean;
|
||||||
maintenance_mode: boolean;
|
maintenance_mode: boolean;
|
||||||
@@ -76,7 +75,6 @@ export function useSettingsState() {
|
|||||||
max_file_size_mb: 50,
|
max_file_size_mb: 50,
|
||||||
max_files_per_upload: 500,
|
max_files_per_upload: 500,
|
||||||
allowed_file_types: 'jpg,jpeg,png,gif,webp',
|
allowed_file_types: 'jpg,jpeg,png,gif,webp',
|
||||||
enable_watermark: false,
|
|
||||||
enable_analytics: true,
|
enable_analytics: true,
|
||||||
enable_registration: false,
|
enable_registration: false,
|
||||||
maintenance_mode: false,
|
maintenance_mode: false,
|
||||||
@@ -146,7 +144,6 @@ export function useSettingsState() {
|
|||||||
Math.max(1, toNumber(settings.general_max_files_per_upload, 500))
|
Math.max(1, toNumber(settings.general_max_files_per_upload, 500))
|
||||||
),
|
),
|
||||||
allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp',
|
allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp',
|
||||||
enable_watermark: toBoolean(settings.general_enable_watermark, false),
|
|
||||||
enable_analytics: toBoolean(settings.general_enable_analytics, true),
|
enable_analytics: toBoolean(settings.general_enable_analytics, true),
|
||||||
enable_registration: toBoolean(settings.general_enable_registration, false),
|
enable_registration: toBoolean(settings.general_enable_registration, false),
|
||||||
maintenance_mode: toBoolean(settings.general_maintenance_mode, false),
|
maintenance_mode: toBoolean(settings.general_maintenance_mode, false),
|
||||||
|
|||||||
@@ -184,16 +184,6 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
|
|||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.featureToggles')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.featureToggles')}</h2>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={generalSettings.enable_watermark}
|
|
||||||
onChange={(e) => setGeneralSettings(prev => ({ ...prev, enable_watermark: e.target.checked }))}
|
|
||||||
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
|
||||||
/>
|
|
||||||
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.enableWatermark')}</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -154,6 +154,7 @@
|
|||||||
"events": "Veranstaltungen",
|
"events": "Veranstaltungen",
|
||||||
"archives": "Archive",
|
"archives": "Archive",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
|
"eventTypes": "Veranstaltungsarten",
|
||||||
"branding": "Branding",
|
"branding": "Branding",
|
||||||
"analytics": "Analytik",
|
"analytics": "Analytik",
|
||||||
"emailSettings": "E-Mail-Einstellungen",
|
"emailSettings": "E-Mail-Einstellungen",
|
||||||
@@ -161,6 +162,54 @@
|
|||||||
"cmsPages": "CMS-Seiten",
|
"cmsPages": "CMS-Seiten",
|
||||||
"users": "Benutzer"
|
"users": "Benutzer"
|
||||||
},
|
},
|
||||||
|
"eventTypes": {
|
||||||
|
"title": "Veranstaltungsarten",
|
||||||
|
"subtitle": "Veranstaltungsarten und deren Standard-Themes anpassen",
|
||||||
|
"loading": "Lade Veranstaltungsarten...",
|
||||||
|
"loadError": "Fehler beim Laden der Veranstaltungsarten",
|
||||||
|
"createNew": "Neue Veranstaltungsart",
|
||||||
|
"edit": "Veranstaltungsart bearbeiten",
|
||||||
|
"created": "Veranstaltungsart erfolgreich erstellt",
|
||||||
|
"updated": "Veranstaltungsart erfolgreich aktualisiert",
|
||||||
|
"deleted": "Veranstaltungsart erfolgreich gelöscht",
|
||||||
|
"createError": "Fehler beim Erstellen der Veranstaltungsart",
|
||||||
|
"updateError": "Fehler beim Aktualisieren der Veranstaltungsart",
|
||||||
|
"deleteError": "Fehler beim Löschen der Veranstaltungsart",
|
||||||
|
"searchPlaceholder": "Veranstaltungsarten suchen...",
|
||||||
|
"showInactive": "Inaktive anzeigen",
|
||||||
|
"noResults": "Keine Veranstaltungsarten gefunden",
|
||||||
|
"empty": "Noch keine Veranstaltungsarten",
|
||||||
|
"system": "System",
|
||||||
|
"table": {
|
||||||
|
"type": "Typ",
|
||||||
|
"slugPrefix": "URL-Präfix",
|
||||||
|
"theme": "Standard-Theme",
|
||||||
|
"status": "Status",
|
||||||
|
"actions": "Aktionen"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"name": "Anzeigename",
|
||||||
|
"namePlaceholder": "z.B. Familienshooting",
|
||||||
|
"slugPrefix": "URL-Präfix",
|
||||||
|
"slugPrefixPlaceholder": "z.B. familie",
|
||||||
|
"slugPreview": "Beispiel-URL:",
|
||||||
|
"emoji": "Symbol",
|
||||||
|
"themePreset": "Standard-Theme",
|
||||||
|
"isActive": "Aktiv (sichtbar bei Veranstaltungserstellung)"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"slugFormat": "Nur Buchstaben, Zahlen und Bindestriche erlaubt"
|
||||||
|
},
|
||||||
|
"slugInfo": {
|
||||||
|
"title": "URL-Präfix Info:",
|
||||||
|
"description": "Das URL-Präfix wird zur Generierung von Galerie-URLs verwendet. Zum Beispiel erstellt eine Veranstaltungsart mit Präfix \"familie\" URLs wie: familie-mueller-familie-2025-01-22"
|
||||||
|
},
|
||||||
|
"deleteConfirm": {
|
||||||
|
"title": "Veranstaltungsart löschen",
|
||||||
|
"message": "Sind Sie sicher, dass Sie löschen möchten",
|
||||||
|
"warning": "Diese Aktion kann nicht rückgängig gemacht werden. Stellen Sie sicher, dass keine Veranstaltungen diesen Typ verwenden."
|
||||||
|
}
|
||||||
|
},
|
||||||
"backup": {
|
"backup": {
|
||||||
"external": {
|
"external": {
|
||||||
"warning": {
|
"warning": {
|
||||||
@@ -784,6 +833,22 @@
|
|||||||
"protectionLevelStandard": "Standard - Tastenkombinationen blockiert",
|
"protectionLevelStandard": "Standard - Tastenkombinationen blockiert",
|
||||||
"protectionLevelEnhanced": "Erweitert - Bildschirmfoto-Erkennung",
|
"protectionLevelEnhanced": "Erweitert - Bildschirmfoto-Erkennung",
|
||||||
"protectionLevelMaximum": "Maximum - DevTools-Erkennung & Canvas-Rendering",
|
"protectionLevelMaximum": "Maximum - DevTools-Erkennung & Canvas-Rendering",
|
||||||
|
"heroLogoSettings": "Hero-Logo-Einstellungen",
|
||||||
|
"heroLogoVisible": "Logo im Hero-Bereich anzeigen",
|
||||||
|
"heroLogoSize": "Logo-Größe",
|
||||||
|
"heroLogoSizeSmall": "Klein",
|
||||||
|
"heroLogoSizeMedium": "Mittel",
|
||||||
|
"heroLogoSizeLarge": "Groß",
|
||||||
|
"heroLogoSizeXLarge": "Extra Groß",
|
||||||
|
"heroLogoPosition": "Logo-Position",
|
||||||
|
"heroLogoPositionTop": "Oben (über dem Titel)",
|
||||||
|
"heroLogoPositionCenter": "Mitte (zwischen Titel und Datum)",
|
||||||
|
"heroLogoPositionBottom": "Unten (unter dem Datum)",
|
||||||
|
"heroLogoInfo": "Diese Einstellungen gelten für das Hero-Layout der Galerie. Sie können das Logo ausblenden oder Größe und Position anpassen.",
|
||||||
|
"heroLogoVisibleLabel": "Logo sichtbar",
|
||||||
|
"heroLogoSizeLabel": "Größe",
|
||||||
|
"heroLogoPositionLabel": "Position",
|
||||||
|
"heroLogoHidden": "Logo ausgeblendet",
|
||||||
"heroPhoto": "Hero-Foto",
|
"heroPhoto": "Hero-Foto",
|
||||||
"heroPhotoHelp": "Wählen Sie ein hervorgehobenes Foto für das Hero-Galerie-Layout",
|
"heroPhotoHelp": "Wählen Sie ein hervorgehobenes Foto für das Hero-Galerie-Layout",
|
||||||
"selectHeroPhoto": "Hero-Foto auswählen",
|
"selectHeroPhoto": "Hero-Foto auswählen",
|
||||||
@@ -910,7 +975,6 @@
|
|||||||
"allowedFileTypes": "Erlaubte Dateitypen",
|
"allowedFileTypes": "Erlaubte Dateitypen",
|
||||||
"allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen",
|
"allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen",
|
||||||
"featureToggles": "Funktionsschalter",
|
"featureToggles": "Funktionsschalter",
|
||||||
"enableWatermark": "Wasserzeichen auf Fotos aktivieren",
|
|
||||||
"enableAnalytics": "Analytics-Tracking aktivieren",
|
"enableAnalytics": "Analytics-Tracking aktivieren",
|
||||||
"enableRegistration": "Selbstregistrierung für Admins erlauben",
|
"enableRegistration": "Selbstregistrierung für Admins erlauben",
|
||||||
"enableShortGalleryUrls": "Kurze Galerie-Links verwenden",
|
"enableShortGalleryUrls": "Kurze Galerie-Links verwenden",
|
||||||
@@ -1146,7 +1210,6 @@
|
|||||||
"faviconHelp": "PNG- oder ICO-Format, empfohlene Größe: 32x32px",
|
"faviconHelp": "PNG- oder ICO-Format, empfohlene Größe: 32x32px",
|
||||||
"watermark": "Wasserzeichen",
|
"watermark": "Wasserzeichen",
|
||||||
"watermarkSettings": "Wasserzeichen-Einstellungen",
|
"watermarkSettings": "Wasserzeichen-Einstellungen",
|
||||||
"enableWatermark": "Wasserzeichen auf Fotos aktivieren",
|
|
||||||
"enableWatermarks": "Wasserzeichen aktivieren",
|
"enableWatermarks": "Wasserzeichen aktivieren",
|
||||||
"watermarkHelp": "Fügen Sie Ihren Firmennamen als Wasserzeichen auf heruntergeladenen Fotos hinzu",
|
"watermarkHelp": "Fügen Sie Ihren Firmennamen als Wasserzeichen auf heruntergeladenen Fotos hinzu",
|
||||||
"watermarkLogo": "Wasserzeichen-Logo",
|
"watermarkLogo": "Wasserzeichen-Logo",
|
||||||
|
|||||||
@@ -154,6 +154,7 @@
|
|||||||
"events": "Events",
|
"events": "Events",
|
||||||
"archives": "Archives",
|
"archives": "Archives",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
|
"eventTypes": "Event Types",
|
||||||
"branding": "Branding",
|
"branding": "Branding",
|
||||||
"analytics": "Analytics",
|
"analytics": "Analytics",
|
||||||
"emailSettings": "Email Settings",
|
"emailSettings": "Email Settings",
|
||||||
@@ -458,6 +459,22 @@
|
|||||||
"protectionLevelStandard": "Standard - Keyboard shortcuts blocked",
|
"protectionLevelStandard": "Standard - Keyboard shortcuts blocked",
|
||||||
"protectionLevelEnhanced": "Enhanced - Print screen detection",
|
"protectionLevelEnhanced": "Enhanced - Print screen detection",
|
||||||
"protectionLevelMaximum": "Maximum - DevTools detection & canvas rendering",
|
"protectionLevelMaximum": "Maximum - DevTools detection & canvas rendering",
|
||||||
|
"heroLogoSettings": "Hero Logo Settings",
|
||||||
|
"heroLogoVisible": "Display logo in hero section",
|
||||||
|
"heroLogoSize": "Logo Size",
|
||||||
|
"heroLogoSizeSmall": "Small",
|
||||||
|
"heroLogoSizeMedium": "Medium",
|
||||||
|
"heroLogoSizeLarge": "Large",
|
||||||
|
"heroLogoSizeXLarge": "Extra Large",
|
||||||
|
"heroLogoPosition": "Logo Position",
|
||||||
|
"heroLogoPositionTop": "Top (above title)",
|
||||||
|
"heroLogoPositionCenter": "Center (between title and dates)",
|
||||||
|
"heroLogoPositionBottom": "Bottom (below dates)",
|
||||||
|
"heroLogoInfo": "These settings apply when the gallery uses the Hero layout. You can hide the logo or customize its size and position.",
|
||||||
|
"heroLogoVisibleLabel": "Logo visible",
|
||||||
|
"heroLogoSizeLabel": "Size",
|
||||||
|
"heroLogoPositionLabel": "Position",
|
||||||
|
"heroLogoHidden": "Logo hidden",
|
||||||
"heroPhoto": "Hero Photo",
|
"heroPhoto": "Hero Photo",
|
||||||
"heroPhotoHelp": "Select a featured photo for the hero gallery layout",
|
"heroPhotoHelp": "Select a featured photo for the hero gallery layout",
|
||||||
"selectHeroPhoto": "Select Hero Photo",
|
"selectHeroPhoto": "Select Hero Photo",
|
||||||
@@ -583,7 +600,6 @@
|
|||||||
"allowedFileTypes": "Allowed File Types",
|
"allowedFileTypes": "Allowed File Types",
|
||||||
"allowedFileTypesHelp": "Comma-separated list of file extensions",
|
"allowedFileTypesHelp": "Comma-separated list of file extensions",
|
||||||
"featureToggles": "Feature Toggles",
|
"featureToggles": "Feature Toggles",
|
||||||
"enableWatermark": "Enable watermark on photos",
|
|
||||||
"enableAnalytics": "Enable analytics tracking",
|
"enableAnalytics": "Enable analytics tracking",
|
||||||
"enableRegistration": "Allow self-registration for admins",
|
"enableRegistration": "Allow self-registration for admins",
|
||||||
"enableShortGalleryUrls": "Use short gallery URLs",
|
"enableShortGalleryUrls": "Use short gallery URLs",
|
||||||
@@ -909,7 +925,6 @@
|
|||||||
"faviconHelp": "PNG or ICO format, recommended size: 32x32px",
|
"faviconHelp": "PNG or ICO format, recommended size: 32x32px",
|
||||||
"watermark": "Watermark",
|
"watermark": "Watermark",
|
||||||
"watermarkSettings": "Watermark Settings",
|
"watermarkSettings": "Watermark Settings",
|
||||||
"enableWatermark": "Enable watermark on photos",
|
|
||||||
"enableWatermarks": "Enable Watermarks",
|
"enableWatermarks": "Enable Watermarks",
|
||||||
"watermarkHelp": "Add your company name as a watermark on downloaded photos",
|
"watermarkHelp": "Add your company name as a watermark on downloaded photos",
|
||||||
"watermarkLogo": "Watermark Logo",
|
"watermarkLogo": "Watermark Logo",
|
||||||
@@ -1445,6 +1460,54 @@
|
|||||||
"datenschutz": "Privacy Policy",
|
"datenschutz": "Privacy Policy",
|
||||||
"pageUpdated": "Page updated successfully"
|
"pageUpdated": "Page updated successfully"
|
||||||
},
|
},
|
||||||
|
"eventTypes": {
|
||||||
|
"title": "Event Types",
|
||||||
|
"subtitle": "Customize event types and their default themes",
|
||||||
|
"loading": "Loading event types...",
|
||||||
|
"loadError": "Failed to load event types",
|
||||||
|
"createNew": "New Event Type",
|
||||||
|
"edit": "Edit Event Type",
|
||||||
|
"created": "Event type created successfully",
|
||||||
|
"updated": "Event type updated successfully",
|
||||||
|
"deleted": "Event type deleted successfully",
|
||||||
|
"createError": "Failed to create event type",
|
||||||
|
"updateError": "Failed to update event type",
|
||||||
|
"deleteError": "Failed to delete event type",
|
||||||
|
"searchPlaceholder": "Search event types...",
|
||||||
|
"showInactive": "Show inactive",
|
||||||
|
"noResults": "No event types found",
|
||||||
|
"empty": "No event types yet",
|
||||||
|
"system": "System",
|
||||||
|
"table": {
|
||||||
|
"type": "Type",
|
||||||
|
"slugPrefix": "URL Prefix",
|
||||||
|
"theme": "Default Theme",
|
||||||
|
"status": "Status",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"name": "Display Name",
|
||||||
|
"namePlaceholder": "e.g., Family Shoot",
|
||||||
|
"slugPrefix": "URL Prefix",
|
||||||
|
"slugPrefixPlaceholder": "e.g., family",
|
||||||
|
"slugPreview": "Example URL:",
|
||||||
|
"emoji": "Icon",
|
||||||
|
"themePreset": "Default Theme",
|
||||||
|
"isActive": "Active (visible in event creation)"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"slugFormat": "Only letters, numbers, and hyphens allowed"
|
||||||
|
},
|
||||||
|
"slugInfo": {
|
||||||
|
"title": "URL Prefix Info:",
|
||||||
|
"description": "The URL prefix is used to generate gallery URLs. For example, an event type with prefix \"family\" will create URLs like: family-smith-family-2025-01-22"
|
||||||
|
},
|
||||||
|
"deleteConfirm": {
|
||||||
|
"title": "Delete Event Type",
|
||||||
|
"message": "Are you sure you want to delete",
|
||||||
|
"warning": "This action cannot be undone. Make sure no events are using this type."
|
||||||
|
}
|
||||||
|
},
|
||||||
"backup": {
|
"backup": {
|
||||||
"external": {
|
"external": {
|
||||||
"warning": {
|
"warning": {
|
||||||
|
|||||||
@@ -187,8 +187,8 @@ export const GalleryPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [galleryInfo, isAuthenticated, autoLoginAttempted, login, resolvedSlug, isResolvingIdentifier]);
|
}, [galleryInfo, isAuthenticated, autoLoginAttempted, login, resolvedSlug, isResolvingIdentifier]);
|
||||||
|
|
||||||
// Calculate days until expiration
|
// Calculate days until expiration (null if no expiration set)
|
||||||
const daysUntilExpiration = galleryInfo
|
const daysUntilExpiration = galleryInfo?.expires_at
|
||||||
? differenceInDays(parseISO(galleryInfo.expires_at), new Date())
|
? differenceInDays(parseISO(galleryInfo.expires_at), new Date())
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -394,9 +394,11 @@ export const GalleryPage: React.FC = () => {
|
|||||||
<CardContent className="text-center py-12">
|
<CardContent className="text-center py-12">
|
||||||
<Clock className="w-16 h-16 text-amber-500 mx-auto mb-4" />
|
<Clock className="w-16 h-16 text-amber-500 mx-auto mb-4" />
|
||||||
<h2 className="text-xl font-semibold mb-2">{t('gallery.expired')}</h2>
|
<h2 className="text-xl font-semibold mb-2">{t('gallery.expired')}</h2>
|
||||||
|
{galleryInfo.expires_at && (
|
||||||
<p className="text-neutral-600 mb-4">
|
<p className="text-neutral-600 mb-4">
|
||||||
{t('gallery.expiredOn', { date: format(parseISO(galleryInfo.expires_at), 'PP') })}
|
{t('gallery.expiredOn', { date: format(parseISO(galleryInfo.expires_at), 'PP') })}
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
<p className="text-sm text-neutral-500">
|
<p className="text-sm text-neutral-500">
|
||||||
{t('gallery.contactOrganizer')}
|
{t('gallery.contactOrganizer')}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
// Calculate expiring events
|
// Calculate expiring events
|
||||||
const activeEvents = eventsData?.events.filter(e => e.is_active && !e.is_archived) || [];
|
const activeEvents = eventsData?.events.filter(e => e.is_active && !e.is_archived) || [];
|
||||||
const expiringEvents = activeEvents.filter(e => {
|
const expiringEvents = activeEvents.filter(e => {
|
||||||
|
if (!e.expires_at) return false;
|
||||||
const days = differenceInDays(parseISO(e.expires_at), new Date());
|
const days = differenceInDays(parseISO(e.expires_at), new Date());
|
||||||
return days <= 7 && days > 0;
|
return days <= 7 && days > 0;
|
||||||
});
|
});
|
||||||
@@ -204,9 +205,11 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium text-neutral-900">{event.event_name}</h3>
|
<h3 className="font-medium text-neutral-900">{event.event_name}</h3>
|
||||||
|
{event.event_date && (
|
||||||
<p className="text-sm text-neutral-600">
|
<p className="text-sm text-neutral-600">
|
||||||
{format(parseISO(event.event_date), 'PP')}
|
{format(parseISO(event.event_date), 'PP')}
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-sm font-medium text-orange-600">
|
<p className="text-sm font-medium text-orange-600">
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
|||||||
import { categoriesService } from '../../services/categories.service';
|
import { categoriesService } from '../../services/categories.service';
|
||||||
import { settingsService } from '../../services/settings.service';
|
import { settingsService } from '../../services/settings.service';
|
||||||
import { cssTemplatesService } from '../../services/cssTemplates.service';
|
import { cssTemplatesService } from '../../services/cssTemplates.service';
|
||||||
|
import { eventTypesService } from '../../services/eventTypes.service';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||||
import { Code } from 'lucide-react';
|
import { Code } from 'lucide-react';
|
||||||
@@ -57,18 +58,12 @@ interface FormData {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const EVENT_TYPE_PRESETS: Record<string, string> = {
|
// Fallback event types (used when API is unavailable)
|
||||||
wedding: 'elegantWedding',
|
const FALLBACK_EVENT_TYPES = [
|
||||||
birthday: 'birthdayFun',
|
{ value: 'wedding', name: 'Wedding', emoji: '💒', theme_preset: 'elegantWedding' },
|
||||||
corporate: 'corporateTimeline',
|
{ value: 'birthday', name: 'Birthday', emoji: '🎂', theme_preset: 'birthdayFun' },
|
||||||
other: 'default'
|
{ value: 'corporate', name: 'Corporate', emoji: '🏢', theme_preset: 'corporateTimeline' },
|
||||||
};
|
{ value: 'other', name: 'Other', emoji: '📸', theme_preset: 'default' },
|
||||||
|
|
||||||
const EVENT_TYPES = [
|
|
||||||
{ value: 'wedding', labelKey: 'events.types.wedding', emoji: '💒' },
|
|
||||||
{ value: 'birthday', labelKey: 'events.types.birthday', emoji: '🎂' },
|
|
||||||
{ value: 'corporate', labelKey: 'events.types.corporate', emoji: '🏢' },
|
|
||||||
{ value: 'other', labelKey: 'events.types.other', emoji: '📸' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const CreateEventPage: React.FC = () => {
|
export const CreateEventPage: React.FC = () => {
|
||||||
@@ -132,6 +127,22 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
queryFn: () => cssTemplatesService.getEnabledTemplates()
|
queryFn: () => cssTemplatesService.getEnabledTemplates()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch event types
|
||||||
|
const { data: eventTypes } = useQuery({
|
||||||
|
queryKey: ['event-types', 'active'],
|
||||||
|
queryFn: () => eventTypesService.getActiveEventTypes()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compute event types to use (API data or fallback)
|
||||||
|
const availableEventTypes = eventTypes?.length
|
||||||
|
? eventTypes.map(et => ({
|
||||||
|
value: et.slug_prefix,
|
||||||
|
name: et.name,
|
||||||
|
emoji: et.emoji,
|
||||||
|
theme_preset: et.theme_preset
|
||||||
|
}))
|
||||||
|
: FALLBACK_EVENT_TYPES;
|
||||||
|
|
||||||
// Fetch default settings
|
// Fetch default settings
|
||||||
const { data: settings } = useQuery({
|
const { data: settings } = useQuery({
|
||||||
queryKey: ['admin-settings'],
|
queryKey: ['admin-settings'],
|
||||||
@@ -163,7 +174,10 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
|
|
||||||
// Update theme when event type changes
|
// Update theme when event type changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const recommendedPreset = EVENT_TYPE_PRESETS[formData.event_type];
|
// Find the selected event type's theme preset
|
||||||
|
const selectedType = availableEventTypes.find(t => t.value === formData.event_type);
|
||||||
|
const recommendedPreset = selectedType?.theme_preset || 'default';
|
||||||
|
|
||||||
if (recommendedPreset && GALLERY_THEME_PRESETS[recommendedPreset]) {
|
if (recommendedPreset && GALLERY_THEME_PRESETS[recommendedPreset]) {
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -171,7 +185,7 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
theme_config: GALLERY_THEME_PRESETS[recommendedPreset].config
|
theme_config: GALLERY_THEME_PRESETS[recommendedPreset].config
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}, [formData.event_type]);
|
}, [formData.event_type, availableEventTypes]);
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: eventsService.createEvent,
|
mutationFn: eventsService.createEvent,
|
||||||
@@ -366,7 +380,7 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
{t('events.eventType')}
|
{t('events.eventType')}
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
{EVENT_TYPES.map((type) => (
|
{availableEventTypes.map((type) => (
|
||||||
<button
|
<button
|
||||||
key={type.value}
|
key={type.value}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -378,7 +392,7 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="text-2xl mb-1">{type.emoji}</div>
|
<div className="text-2xl mb-1">{type.emoji}</div>
|
||||||
<div className="text-sm font-medium">{t(type.labelKey)}</div>
|
<div className="text-sm font-medium">{type.name}</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,14 +25,15 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
Monitor,
|
Monitor,
|
||||||
Droplets,
|
Droplets,
|
||||||
MousePointer
|
MousePointer,
|
||||||
|
Layout
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { parseISO, differenceInDays, isValid } from 'date-fns';
|
import { parseISO, differenceInDays, isValid } from 'date-fns';
|
||||||
|
|
||||||
// Helper to safely parse dates that might be strings, Date objects, or timestamps
|
// Helper to safely parse dates that might be strings, Date objects, or timestamps
|
||||||
const safeParseDate = (dateValue: unknown): Date => {
|
const safeParseDate = (dateValue: unknown): Date | null => {
|
||||||
if (!dateValue) {
|
if (!dateValue) {
|
||||||
return new Date();
|
return null;
|
||||||
}
|
}
|
||||||
if (dateValue instanceof Date) {
|
if (dateValue instanceof Date) {
|
||||||
return dateValue;
|
return dateValue;
|
||||||
@@ -44,7 +45,7 @@ const safeParseDate = (dateValue: unknown): Date => {
|
|||||||
const parsed = parseISO(dateValue);
|
const parsed = parseISO(dateValue);
|
||||||
return isValid(parsed) ? parsed : new Date(dateValue);
|
return isValid(parsed) ? parsed : new Date(dateValue);
|
||||||
}
|
}
|
||||||
return new Date();
|
return null;
|
||||||
};
|
};
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||||
@@ -160,6 +161,10 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
watermark_downloads: boolean;
|
watermark_downloads: boolean;
|
||||||
enable_devtools_protection: boolean;
|
enable_devtools_protection: boolean;
|
||||||
use_canvas_rendering: boolean;
|
use_canvas_rendering: boolean;
|
||||||
|
// Hero logo settings
|
||||||
|
hero_logo_visible: boolean;
|
||||||
|
hero_logo_size: 'small' | 'medium' | 'large' | 'xlarge';
|
||||||
|
hero_logo_position: 'top' | 'center' | 'bottom';
|
||||||
};
|
};
|
||||||
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
@@ -184,6 +189,10 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
watermark_downloads: false,
|
watermark_downloads: false,
|
||||||
enable_devtools_protection: true,
|
enable_devtools_protection: true,
|
||||||
use_canvas_rendering: false,
|
use_canvas_rendering: false,
|
||||||
|
// Hero logo settings
|
||||||
|
hero_logo_visible: true,
|
||||||
|
hero_logo_size: 'medium',
|
||||||
|
hero_logo_position: 'top',
|
||||||
});
|
});
|
||||||
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
|
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
|
||||||
feedback_enabled: false,
|
feedback_enabled: false,
|
||||||
@@ -380,16 +389,17 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const daysUntilExpiration = differenceInDays(safeParseDate(event.expires_at), new Date());
|
const expiresAtDate = safeParseDate(event.expires_at);
|
||||||
const isExpired = daysUntilExpiration <= 0;
|
const daysUntilExpiration = expiresAtDate ? differenceInDays(expiresAtDate, new Date()) : null;
|
||||||
const isExpiring = daysUntilExpiration > 0 && daysUntilExpiration <= 7;
|
const isExpired = daysUntilExpiration !== null && daysUntilExpiration <= 0;
|
||||||
|
const isExpiring = daysUntilExpiration !== null && daysUntilExpiration > 0 && daysUntilExpiration <= 7;
|
||||||
|
|
||||||
const handleStartEdit = () => {
|
const handleStartEdit = () => {
|
||||||
setEditForm({
|
setEditForm({
|
||||||
welcome_message: event.welcome_message || '',
|
welcome_message: event.welcome_message || '',
|
||||||
color_theme: event.color_theme || '',
|
color_theme: event.color_theme || '',
|
||||||
css_template_id: event.css_template_id || null,
|
css_template_id: event.css_template_id || null,
|
||||||
expires_at: format(safeParseDate(event.expires_at), 'yyyy-MM-dd'),
|
expires_at: expiresAtDate ? format(expiresAtDate, 'yyyy-MM-dd') : '',
|
||||||
allow_user_uploads: event.allow_user_uploads || false,
|
allow_user_uploads: event.allow_user_uploads || false,
|
||||||
upload_category_id: event.upload_category_id || null,
|
upload_category_id: event.upload_category_id || null,
|
||||||
hero_photo_id: event.hero_photo_id || null,
|
hero_photo_id: event.hero_photo_id || null,
|
||||||
@@ -406,6 +416,10 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
watermark_downloads: event.watermark_downloads ?? false,
|
watermark_downloads: event.watermark_downloads ?? false,
|
||||||
enable_devtools_protection: event.enable_devtools_protection ?? true,
|
enable_devtools_protection: event.enable_devtools_protection ?? true,
|
||||||
use_canvas_rendering: event.use_canvas_rendering ?? false,
|
use_canvas_rendering: event.use_canvas_rendering ?? false,
|
||||||
|
// Load hero logo settings from event
|
||||||
|
hero_logo_visible: event.hero_logo_visible ?? true,
|
||||||
|
hero_logo_size: event.hero_logo_size || 'medium',
|
||||||
|
hero_logo_position: event.hero_logo_position || 'top',
|
||||||
});
|
});
|
||||||
|
|
||||||
setShowNewPassword(false);
|
setShowNewPassword(false);
|
||||||
@@ -496,6 +510,10 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
watermark_downloads: editForm.watermark_downloads,
|
watermark_downloads: editForm.watermark_downloads,
|
||||||
enable_devtools_protection: editForm.enable_devtools_protection,
|
enable_devtools_protection: editForm.enable_devtools_protection,
|
||||||
use_canvas_rendering: editForm.use_canvas_rendering,
|
use_canvas_rendering: editForm.use_canvas_rendering,
|
||||||
|
// Hero logo settings
|
||||||
|
hero_logo_visible: editForm.hero_logo_visible,
|
||||||
|
hero_logo_size: editForm.hero_logo_size,
|
||||||
|
hero_logo_position: editForm.hero_logo_position,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only include fields that have defined values
|
// Only include fields that have defined values
|
||||||
@@ -599,10 +617,12 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-neutral-900">{event.event_name}</h1>
|
<h1 className="text-2xl font-bold text-neutral-900">{event.event_name}</h1>
|
||||||
<div className="flex items-center gap-4 mt-2 text-sm text-neutral-600">
|
<div className="flex items-center gap-4 mt-2 text-sm text-neutral-600">
|
||||||
|
{event.event_date && (
|
||||||
<span className="flex items-center">
|
<span className="flex items-center">
|
||||||
<Calendar className="w-4 h-4 mr-1" />
|
<Calendar className="w-4 h-4 mr-1" />
|
||||||
{format(safeParseDate(event.event_date), 'PPP')}
|
{format(safeParseDate(event.event_date)!, 'PPP')}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
<span className="capitalize">{event.event_type}</span>
|
<span className="capitalize">{event.event_type}</span>
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
@@ -1062,6 +1082,66 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Hero Logo Settings */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-neutral-200">
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-900 mb-3 flex items-center gap-2">
|
||||||
|
<Layout className="w-4 h-4 text-primary-600" />
|
||||||
|
{t('events.heroLogoSettings', 'Hero Logo Settings')}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editForm.hero_logo_visible}
|
||||||
|
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_visible: e.target.checked }))}
|
||||||
|
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<Image className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
|
||||||
|
<span className="text-sm text-neutral-700">{t('events.heroLogoVisible', 'Display logo in hero section')}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{editForm.hero_logo_visible && (
|
||||||
|
<>
|
||||||
|
<div className="ml-6">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('events.heroLogoSize', 'Logo Size')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={editForm.hero_logo_size}
|
||||||
|
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_size: e.target.value as 'small' | 'medium' | 'large' | 'xlarge' }))}
|
||||||
|
className="w-full sm:w-48 px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 text-sm"
|
||||||
|
>
|
||||||
|
<option value="small">{t('events.heroLogoSizeSmall', 'Small')}</option>
|
||||||
|
<option value="medium">{t('events.heroLogoSizeMedium', 'Medium')}</option>
|
||||||
|
<option value="large">{t('events.heroLogoSizeLarge', 'Large')}</option>
|
||||||
|
<option value="xlarge">{t('events.heroLogoSizeXLarge', 'Extra Large')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-6">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('events.heroLogoPosition', 'Logo Position')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={editForm.hero_logo_position}
|
||||||
|
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_position: e.target.value as 'top' | 'center' | 'bottom' }))}
|
||||||
|
className="w-full sm:w-48 px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 text-sm"
|
||||||
|
>
|
||||||
|
<option value="top">{t('events.heroLogoPositionTop', 'Top (above title)')}</option>
|
||||||
|
<option value="center">{t('events.heroLogoPositionCenter', 'Center (between title and dates)')}</option>
|
||||||
|
<option value="bottom">{t('events.heroLogoPositionBottom', 'Bottom (below dates)')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-neutral-500 mt-2">
|
||||||
|
{t('events.heroLogoInfo', 'These settings apply when the gallery uses the Hero layout. You can hide the logo or customize its size and position.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<dl className="space-y-4">
|
<dl className="space-y-4">
|
||||||
@@ -1104,19 +1184,25 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-neutral-500">{t('events.created')}</dt>
|
<dt className="text-sm font-medium text-neutral-500">{t('events.created')}</dt>
|
||||||
<dd className="mt-1 text-sm text-neutral-900">
|
<dd className="mt-1 text-sm text-neutral-900">
|
||||||
{format(safeParseDate(event.created_at), 'PP')}
|
{event.created_at && format(safeParseDate(event.created_at)!, 'PP')}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-neutral-500">{t('events.expires')}</dt>
|
<dt className="text-sm font-medium text-neutral-500">{t('events.expires')}</dt>
|
||||||
<dd className="mt-1 text-sm text-neutral-900">
|
<dd className="mt-1 text-sm text-neutral-900">
|
||||||
{format(safeParseDate(event.expires_at), 'PP')}
|
{event.expires_at ? (
|
||||||
{!event.is_archived && daysUntilExpiration > 0 && (
|
<>
|
||||||
|
{format(safeParseDate(event.expires_at)!, 'PP')}
|
||||||
|
{!event.is_archived && daysUntilExpiration !== null && daysUntilExpiration > 0 && (
|
||||||
<span className="text-neutral-500 ml-1">
|
<span className="text-neutral-500 ml-1">
|
||||||
{t('events.daysLeft', { count: daysUntilExpiration })}
|
{t('events.daysLeft', { count: daysUntilExpiration })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-neutral-500">{t('events.neverExpires', 'Never')}</span>
|
||||||
|
)}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1197,6 +1283,37 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Hero Logo Settings Display */}
|
||||||
|
<div className="pt-3 mt-3 border-t border-neutral-200">
|
||||||
|
<dt className="text-sm font-medium text-neutral-500 flex items-center gap-2">
|
||||||
|
<Layout className="w-4 h-4" />
|
||||||
|
{t('events.heroLogoSettings', 'Hero Logo Settings')}
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-2 text-sm text-neutral-900">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{event.hero_logo_visible !== false ? (
|
||||||
|
<>
|
||||||
|
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded">
|
||||||
|
<Image className="w-3 h-3 mr-1" />
|
||||||
|
{t('events.heroLogoVisibleLabel', 'Logo visible')}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 text-neutral-700 rounded">
|
||||||
|
{t('events.heroLogoSizeLabel', 'Size')}: {event.hero_logo_size || 'medium'}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 text-neutral-700 rounded">
|
||||||
|
{t('events.heroLogoPositionLabel', 'Position')}: {event.hero_logo_position || 'top'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 text-neutral-700 rounded">
|
||||||
|
<Image className="w-3 h-3 mr-1" />
|
||||||
|
{t('events.heroLogoHidden', 'Logo hidden')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1409,7 +1526,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-neutral-500">{t('events.archivedOn')}</p>
|
<p className="text-sm font-medium text-neutral-500">{t('events.archivedOn')}</p>
|
||||||
<p className="text-sm text-neutral-900">
|
<p className="text-sm text-neutral-900">
|
||||||
{event.archived_at && format(safeParseDate(event.archived_at), 'PPp')}
|
{event.archived_at && format(safeParseDate(event.archived_at)!, 'PPp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,550 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
Search,
|
||||||
|
GripVertical,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
X,
|
||||||
|
AlertTriangle,
|
||||||
|
Tags
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button, Input, Card, Loading } from '../../components/common';
|
||||||
|
import { eventTypesService, EventType, CreateEventTypeData, UpdateEventTypeData } from '../../services/eventTypes.service';
|
||||||
|
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||||
|
|
||||||
|
// Common emoji options for event types
|
||||||
|
const EMOJI_OPTIONS = [
|
||||||
|
'📷', '💒', '🎂', '🏢', '🎉', '🎊', '🎈', '👨👩👧', '💍', '🌸',
|
||||||
|
'🎄', '🎃', '🐣', '🎓', '🏆', '🎸', '🎭', '🍽️', '🏖️', '✈️'
|
||||||
|
];
|
||||||
|
|
||||||
|
export const EventTypesPage: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [showInactive, setShowInactive] = useState(false);
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [editingType, setEditingType] = useState<EventType | null>(null);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<EventType | null>(null);
|
||||||
|
|
||||||
|
// Query
|
||||||
|
const { data: eventTypes, isLoading, error, refetch } = useQuery({
|
||||||
|
queryKey: ['admin-event-types', showInactive],
|
||||||
|
queryFn: () => eventTypesService.getEventTypes(showInactive)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: eventTypesService.createEventType,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-event-types'] });
|
||||||
|
setShowCreateModal(false);
|
||||||
|
toast.success(t('eventTypes.created', 'Event type created successfully'));
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.error || t('eventTypes.createError', 'Failed to create event type'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: UpdateEventTypeData }) =>
|
||||||
|
eventTypesService.updateEventType(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-event-types'] });
|
||||||
|
setEditingType(null);
|
||||||
|
toast.success(t('eventTypes.updated', 'Event type updated successfully'));
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.error || t('eventTypes.updateError', 'Failed to update event type'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: eventTypesService.deleteEventType,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-event-types'] });
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
toast.success(t('eventTypes.deleted', 'Event type deleted successfully'));
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.error || t('eventTypes.deleteError', 'Failed to delete event type'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter event types
|
||||||
|
const filteredTypes = useMemo(() => {
|
||||||
|
if (!eventTypes) return [];
|
||||||
|
if (!searchTerm) return eventTypes;
|
||||||
|
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
return eventTypes.filter(type =>
|
||||||
|
type.name.toLowerCase().includes(term) ||
|
||||||
|
type.slug_prefix.toLowerCase().includes(term)
|
||||||
|
);
|
||||||
|
}, [eventTypes, searchTerm]);
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<Loading size="lg" text={t('eventTypes.loading', 'Loading event types...')} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-red-600">{t('eventTypes.loadError', 'Failed to load event types')}</p>
|
||||||
|
<Button onClick={() => refetch()} className="mt-4">
|
||||||
|
{t('common.tryAgain', 'Try Again')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-900 flex items-center gap-2">
|
||||||
|
<Tags className="w-6 h-6" />
|
||||||
|
{t('eventTypes.title', 'Event Types')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-neutral-600 mt-1">
|
||||||
|
{t('eventTypes.subtitle', 'Customize event types and their default themes')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
leftIcon={<Plus className="w-4 h-4" />}
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
>
|
||||||
|
{t('eventTypes.createNew', 'New Event Type')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<div className="p-4 flex flex-col sm:flex-row gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
placeholder={t('eventTypes.searchPlaceholder', 'Search event types...')}
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
leftIcon={<Search className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showInactive}
|
||||||
|
onChange={(e) => setShowInactive(e.target.checked)}
|
||||||
|
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-neutral-700">
|
||||||
|
{t('eventTypes.showInactive', 'Show inactive')}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Event Types List */}
|
||||||
|
<Card>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase w-10">
|
||||||
|
{/* Drag handle column */}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase">
|
||||||
|
{t('eventTypes.table.type', 'Type')}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase">
|
||||||
|
{t('eventTypes.table.slugPrefix', 'URL Prefix')}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase">
|
||||||
|
{t('eventTypes.table.theme', 'Default Theme')}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase">
|
||||||
|
{t('eventTypes.table.status', 'Status')}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-500 uppercase">
|
||||||
|
{t('eventTypes.table.actions', 'Actions')}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-neutral-200">
|
||||||
|
{filteredTypes.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-4 py-8 text-center text-neutral-500">
|
||||||
|
{searchTerm
|
||||||
|
? t('eventTypes.noResults', 'No event types found')
|
||||||
|
: t('eventTypes.empty', 'No event types yet')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredTypes.map((type) => (
|
||||||
|
<tr key={type.id} className={`hover:bg-neutral-50 ${!type.is_active ? 'opacity-60' : ''}`}>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<GripVertical className="w-4 h-4 text-neutral-400 cursor-grab" />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-2xl">{type.emoji}</span>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-neutral-900">{type.name}</div>
|
||||||
|
{type.is_system && (
|
||||||
|
<span className="text-xs text-neutral-500">
|
||||||
|
{t('eventTypes.system', 'System')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<code className="px-2 py-1 bg-neutral-100 rounded text-sm">
|
||||||
|
{type.slug_prefix}
|
||||||
|
</code>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 text-sm text-neutral-600">
|
||||||
|
{GALLERY_THEME_PRESETS[type.theme_preset]?.name || type.theme_preset || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
{type.is_active ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-700 rounded-full text-xs">
|
||||||
|
<Eye className="w-3 h-3" />
|
||||||
|
{t('common.active', 'Active')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-neutral-100 text-neutral-600 rounded-full text-xs">
|
||||||
|
<EyeOff className="w-3 h-3" />
|
||||||
|
{t('common.inactive', 'Inactive')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingType(type)}
|
||||||
|
className="p-2 hover:bg-neutral-100 rounded-lg text-neutral-600 hover:text-primary-600"
|
||||||
|
title={t('common.edit', 'Edit')}
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
{!type.is_system && (
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteConfirm(type)}
|
||||||
|
className="p-2 hover:bg-red-50 rounded-lg text-neutral-600 hover:text-red-600"
|
||||||
|
title={t('common.delete', 'Delete')}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Slug Preview Info */}
|
||||||
|
<div className="mt-4 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||||
|
<p className="text-sm text-blue-800">
|
||||||
|
<strong>{t('eventTypes.slugInfo.title', 'URL Prefix Info:')}</strong>{' '}
|
||||||
|
{t('eventTypes.slugInfo.description', 'The URL prefix is used to generate gallery URLs. For example, an event type with prefix "family" will create URLs like: family-smith-family-2025-01-22')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Modal */}
|
||||||
|
{showCreateModal && (
|
||||||
|
<EventTypeModal
|
||||||
|
onClose={() => setShowCreateModal(false)}
|
||||||
|
onSubmit={(data) => createMutation.mutate(data)}
|
||||||
|
isLoading={createMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
{editingType && (
|
||||||
|
<EventTypeModal
|
||||||
|
eventType={editingType}
|
||||||
|
onClose={() => setEditingType(null)}
|
||||||
|
onSubmit={(data) => updateMutation.mutate({ id: editingType.id, data })}
|
||||||
|
isLoading={updateMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
{deleteConfirm && (
|
||||||
|
<DeleteConfirmModal
|
||||||
|
eventType={deleteConfirm}
|
||||||
|
onClose={() => setDeleteConfirm(null)}
|
||||||
|
onConfirm={() => deleteMutation.mutate(deleteConfirm.id)}
|
||||||
|
isLoading={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Event Type Modal Component
|
||||||
|
interface EventTypeModalProps {
|
||||||
|
eventType?: EventType;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (data: CreateEventTypeData | UpdateEventTypeData) => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EventTypeModal: React.FC<EventTypeModalProps> = ({
|
||||||
|
eventType,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
isLoading
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const isEditing = !!eventType;
|
||||||
|
|
||||||
|
const [form, setForm] = useState<CreateEventTypeData>({
|
||||||
|
name: eventType?.name || '',
|
||||||
|
slug_prefix: eventType?.slug_prefix || '',
|
||||||
|
emoji: eventType?.emoji || '📷',
|
||||||
|
theme_preset: eventType?.theme_preset || 'default'
|
||||||
|
});
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState<Partial<Record<keyof CreateEventTypeData, string>>>({});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const newErrors: typeof errors = {};
|
||||||
|
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
newErrors.name = t('validation.required', 'This field is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.slug_prefix.trim()) {
|
||||||
|
newErrors.slug_prefix = t('validation.required', 'This field is required');
|
||||||
|
} else if (!/^[a-z0-9-]+$/i.test(form.slug_prefix)) {
|
||||||
|
newErrors.slug_prefix = t('eventTypes.validation.slugFormat', 'Only letters, numbers, and hyphens allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(newErrors).length > 0) {
|
||||||
|
setErrors(newErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For editing, only send changed fields (plus is_active if toggling)
|
||||||
|
if (isEditing) {
|
||||||
|
const updates: UpdateEventTypeData = {};
|
||||||
|
if (form.name !== eventType?.name) updates.name = form.name;
|
||||||
|
if (form.slug_prefix !== eventType?.slug_prefix) updates.slug_prefix = form.slug_prefix;
|
||||||
|
if (form.emoji !== eventType?.emoji) updates.emoji = form.emoji;
|
||||||
|
if (form.theme_preset !== eventType?.theme_preset) updates.theme_preset = form.theme_preset;
|
||||||
|
onSubmit(updates);
|
||||||
|
} else {
|
||||||
|
onSubmit(form);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||||
|
<Card className="w-full max-w-lg">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-xl font-semibold text-neutral-900">
|
||||||
|
{isEditing
|
||||||
|
? t('eventTypes.edit', 'Edit Event Type')
|
||||||
|
: t('eventTypes.createNew', 'New Event Type')}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 hover:bg-neutral-100 rounded-lg"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-neutral-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Name */}
|
||||||
|
<Input
|
||||||
|
label={t('eventTypes.form.name', 'Display Name')}
|
||||||
|
placeholder={t('eventTypes.form.namePlaceholder', 'e.g., Family Shoot')}
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setForm({ ...form, name: e.target.value });
|
||||||
|
setErrors({ ...errors, name: undefined });
|
||||||
|
}}
|
||||||
|
error={errors.name}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Slug Prefix */}
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
label={t('eventTypes.form.slugPrefix', 'URL Prefix')}
|
||||||
|
placeholder={t('eventTypes.form.slugPrefixPlaceholder', 'e.g., family')}
|
||||||
|
value={form.slug_prefix}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||||
|
setForm({ ...form, slug_prefix: value });
|
||||||
|
setErrors({ ...errors, slug_prefix: undefined });
|
||||||
|
}}
|
||||||
|
error={errors.slug_prefix}
|
||||||
|
/>
|
||||||
|
{form.slug_prefix && (
|
||||||
|
<p className="mt-1 text-xs text-neutral-500">
|
||||||
|
{t('eventTypes.form.slugPreview', 'Example URL:')}{' '}
|
||||||
|
<code className="bg-neutral-100 px-1 rounded">
|
||||||
|
{form.slug_prefix}-event-name-2025-01-22
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Emoji */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||||
|
{t('eventTypes.form.emoji', 'Icon')}
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{EMOJI_OPTIONS.map((emoji) => (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm({ ...form, emoji })}
|
||||||
|
className={`p-2 text-xl rounded-lg border-2 transition-all ${
|
||||||
|
form.emoji === emoji
|
||||||
|
? 'border-primary-600 bg-primary-50'
|
||||||
|
: 'border-neutral-200 hover:border-neutral-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Theme Preset */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||||
|
{t('eventTypes.form.themePreset', 'Default Theme')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={form.theme_preset}
|
||||||
|
onChange={(e) => setForm({ ...form, theme_preset: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
>
|
||||||
|
{Object.entries(GALLERY_THEME_PRESETS).map(([key, preset]) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{preset.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active toggle for editing */}
|
||||||
|
{isEditing && (
|
||||||
|
<label className="flex items-center gap-3 pt-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={eventType?.is_active}
|
||||||
|
onChange={(e) => onSubmit({ is_active: e.target.checked })}
|
||||||
|
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-neutral-700">
|
||||||
|
{t('eventTypes.form.isActive', 'Active (visible in event creation)')}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-neutral-200">
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
||||||
|
{t('common.cancel', 'Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" type="submit" isLoading={isLoading}>
|
||||||
|
{isEditing ? t('common.save', 'Save') : t('common.create', 'Create')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete Confirmation Modal
|
||||||
|
interface DeleteConfirmModalProps {
|
||||||
|
eventType: EventType;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeleteConfirmModal: React.FC<DeleteConfirmModalProps> = ({
|
||||||
|
eventType,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
isLoading
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="p-2 bg-red-100 rounded-full">
|
||||||
|
<AlertTriangle className="w-6 h-6 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold text-neutral-900">
|
||||||
|
{t('eventTypes.deleteConfirm.title', 'Delete Event Type')}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-neutral-600 mb-4">
|
||||||
|
{t('eventTypes.deleteConfirm.message', 'Are you sure you want to delete')} "{eventType.name}"?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-sm text-neutral-500 bg-neutral-50 p-3 rounded-lg mb-6">
|
||||||
|
{t('eventTypes.deleteConfirm.warning', 'This action cannot be undone. Make sure no events are using this type.')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
||||||
|
{t('common.cancel', 'Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={onConfirm}
|
||||||
|
isLoading={isLoading}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
{t('common.delete', 'Delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EventTypesPage;
|
||||||
@@ -12,3 +12,4 @@ export { CMSPage } from './CMSPage';
|
|||||||
export { BackupManagement } from './BackupManagement';
|
export { BackupManagement } from './BackupManagement';
|
||||||
export { EventFeedbackPage } from './EventFeedbackPage';
|
export { EventFeedbackPage } from './EventFeedbackPage';
|
||||||
export { UserManagementPage } from './UserManagementPage';
|
export { UserManagementPage } from './UserManagementPage';
|
||||||
|
export { EventTypesPage } from './EventTypesPage';
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Event Types Service
|
||||||
|
* API client for event type management
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { api } from '../config/api';
|
||||||
|
|
||||||
|
export interface EventType {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug_prefix: string;
|
||||||
|
emoji: string;
|
||||||
|
theme_preset: string;
|
||||||
|
theme_config: string | null;
|
||||||
|
display_order: number;
|
||||||
|
is_system: boolean;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateEventTypeData {
|
||||||
|
name: string;
|
||||||
|
slug_prefix: string;
|
||||||
|
emoji?: string;
|
||||||
|
theme_preset?: string;
|
||||||
|
theme_config?: Record<string, unknown>;
|
||||||
|
display_order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateEventTypeData {
|
||||||
|
name?: string;
|
||||||
|
slug_prefix?: string;
|
||||||
|
emoji?: string;
|
||||||
|
theme_preset?: string;
|
||||||
|
theme_config?: Record<string, unknown>;
|
||||||
|
display_order?: number;
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const eventTypesService = {
|
||||||
|
/**
|
||||||
|
* Get all event types (for admin management)
|
||||||
|
*/
|
||||||
|
async getEventTypes(includeInactive = false): Promise<EventType[]> {
|
||||||
|
const response = await api.get<{ eventTypes: EventType[] }>('/admin/event-types', {
|
||||||
|
params: { includeInactive: includeInactive.toString() }
|
||||||
|
});
|
||||||
|
return response.data.eventTypes;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get only active event types (for dropdowns/selection)
|
||||||
|
*/
|
||||||
|
async getActiveEventTypes(): Promise<EventType[]> {
|
||||||
|
const response = await api.get<{ eventTypes: EventType[] }>('/admin/event-types/active');
|
||||||
|
return response.data.eventTypes;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single event type by ID
|
||||||
|
*/
|
||||||
|
async getEventType(id: number): Promise<EventType> {
|
||||||
|
const response = await api.get<EventType>(`/admin/event-types/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new event type
|
||||||
|
*/
|
||||||
|
async createEventType(data: CreateEventTypeData): Promise<EventType> {
|
||||||
|
const response = await api.post<EventType>('/admin/event-types', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an event type
|
||||||
|
*/
|
||||||
|
async updateEventType(id: number, data: UpdateEventTypeData): Promise<EventType> {
|
||||||
|
const response = await api.put<EventType>(`/admin/event-types/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an event type
|
||||||
|
*/
|
||||||
|
async deleteEventType(id: number): Promise<void> {
|
||||||
|
await api.delete(`/admin/event-types/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorder event types
|
||||||
|
*/
|
||||||
|
async reorderEventTypes(orderedIds: number[]): Promise<EventType[]> {
|
||||||
|
const response = await api.post<{ eventTypes: EventType[] }>('/admin/event-types/reorder', {
|
||||||
|
orderedIds
|
||||||
|
});
|
||||||
|
return response.data.eventTypes;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -41,6 +41,10 @@ export interface Event {
|
|||||||
watermark_downloads?: boolean;
|
watermark_downloads?: boolean;
|
||||||
enable_devtools_protection?: boolean;
|
enable_devtools_protection?: boolean;
|
||||||
use_canvas_rendering?: boolean;
|
use_canvas_rendering?: boolean;
|
||||||
|
// Hero logo customization fields
|
||||||
|
hero_logo_visible?: boolean;
|
||||||
|
hero_logo_size?: 'small' | 'medium' | 'large' | 'xlarge';
|
||||||
|
hero_logo_position?: 'top' | 'center' | 'bottom';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GalleryInfo {
|
export interface GalleryInfo {
|
||||||
|
|||||||
Reference in New Issue
Block a user