Compare commits
15 Commits
a5e939221d
...
1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 15655e3987 | |||
|
|
5263fa6834 | ||
|
|
923c86a3b3 | ||
| d8b3864411 | |||
|
|
4aaffe99c7 | ||
| 194cbd6ca8 | |||
|
|
a11da5a977 | ||
| d5445c2282 | |||
|
|
6bc7e13e78 | ||
| dcfefc8371 | |||
|
|
c3897efd02 | ||
| b5f14335c4 | |||
|
|
62bf048680 | ||
| d44ac949b5 | |||
|
|
b9f46b02cc |
@@ -19,7 +19,7 @@ GOOGLE_CLIENT_ID=
|
||||
# Server Configuration
|
||||
PORT=3001
|
||||
API_URL=http://localhost:3001
|
||||
FRONTEND_URL=http://localhost:3002
|
||||
FRONTEND_URL=http://localhost:3019
|
||||
|
||||
# Revalidation secret (shared with frontend for on-demand cache revalidation)
|
||||
# Must match the REVALIDATE_SECRET in frontend/.env
|
||||
@@ -67,3 +67,9 @@ SMTP_TLS_REJECT_UNAUTHORIZED=true
|
||||
# SendGrid: SMTP_HOST=smtp.sendgrid.net, SMTP_PORT=587, SMTP_USER=apikey, SMTP_PASS=your_api_key
|
||||
# Mailgun: SMTP_HOST=smtp.mailgun.org, SMTP_PORT=587
|
||||
# Amazon SES: SMTP_HOST=email-smtp.us-east-1.amazonaws.com, SMTP_PORT=587
|
||||
|
||||
# Email Queue Rate Limiting
|
||||
# Maximum number of emails that can be sent per hour (default: 30)
|
||||
# If the limit is reached, queued emails will pause and resume automatically
|
||||
MAX_EMAILS_PER_HOUR=30
|
||||
|
||||
|
||||
@@ -437,6 +437,25 @@ async function migrate() {
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Legal settings table for legal page placeholder values
|
||||
await (db as any).run(sql`
|
||||
CREATE TABLE IF NOT EXISTS legal_settings (
|
||||
id TEXT PRIMARY KEY,
|
||||
company_name TEXT,
|
||||
legal_entity_name TEXT,
|
||||
ruc_number TEXT,
|
||||
company_address TEXT,
|
||||
company_city TEXT,
|
||||
company_country TEXT,
|
||||
support_email TEXT,
|
||||
legal_email TEXT,
|
||||
governing_law TEXT,
|
||||
jurisdiction_city TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_by TEXT REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
} else {
|
||||
// PostgreSQL migrations
|
||||
await (db as any).execute(sql`
|
||||
@@ -822,6 +841,25 @@ async function migrate() {
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Legal settings table for legal page placeholder values
|
||||
await (db as any).execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS legal_settings (
|
||||
id UUID PRIMARY KEY,
|
||||
company_name VARCHAR(255),
|
||||
legal_entity_name VARCHAR(255),
|
||||
ruc_number VARCHAR(50),
|
||||
company_address TEXT,
|
||||
company_city VARCHAR(100),
|
||||
company_country VARCHAR(100),
|
||||
support_email VARCHAR(255),
|
||||
legal_email VARCHAR(255),
|
||||
governing_law VARCHAR(255),
|
||||
jurisdiction_city VARCHAR(100),
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
updated_by UUID REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
console.log('Migrations completed successfully!');
|
||||
|
||||
@@ -281,6 +281,23 @@ export const sqliteFaqQuestions = sqliteTable('faq_questions', {
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
|
||||
// Legal Settings table for legal page placeholder values
|
||||
export const sqliteLegalSettings = sqliteTable('legal_settings', {
|
||||
id: text('id').primaryKey(),
|
||||
companyName: text('company_name'),
|
||||
legalEntityName: text('legal_entity_name'),
|
||||
rucNumber: text('ruc_number'),
|
||||
companyAddress: text('company_address'),
|
||||
companyCity: text('company_city'),
|
||||
companyCountry: text('company_country'),
|
||||
supportEmail: text('support_email'),
|
||||
legalEmail: text('legal_email'),
|
||||
governingLaw: text('governing_law'),
|
||||
jurisdictionCity: text('jurisdiction_city'),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
updatedBy: text('updated_by').references(() => sqliteUsers.id),
|
||||
});
|
||||
|
||||
// Site Settings table for global website configuration
|
||||
export const sqliteSiteSettings = sqliteTable('site_settings', {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -578,6 +595,23 @@ export const pgFaqQuestions = pgTable('faq_questions', {
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
|
||||
// Legal Settings table for legal page placeholder values
|
||||
export const pgLegalSettings = pgTable('legal_settings', {
|
||||
id: uuid('id').primaryKey(),
|
||||
companyName: varchar('company_name', { length: 255 }),
|
||||
legalEntityName: varchar('legal_entity_name', { length: 255 }),
|
||||
rucNumber: varchar('ruc_number', { length: 50 }),
|
||||
companyAddress: pgText('company_address'),
|
||||
companyCity: varchar('company_city', { length: 100 }),
|
||||
companyCountry: varchar('company_country', { length: 100 }),
|
||||
supportEmail: varchar('support_email', { length: 255 }),
|
||||
legalEmail: varchar('legal_email', { length: 255 }),
|
||||
governingLaw: varchar('governing_law', { length: 255 }),
|
||||
jurisdictionCity: varchar('jurisdiction_city', { length: 100 }),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
updatedBy: uuid('updated_by').references(() => pgUsers.id),
|
||||
});
|
||||
|
||||
// Site Settings table for global website configuration
|
||||
export const pgSiteSettings = pgTable('site_settings', {
|
||||
id: uuid('id').primaryKey(),
|
||||
@@ -623,6 +657,7 @@ export const eventPaymentOverrides = dbType === 'postgres' ? pgEventPaymentOverr
|
||||
export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqliteMagicLinkTokens;
|
||||
export const userSessions = dbType === 'postgres' ? pgUserSessions : sqliteUserSessions;
|
||||
export const invoices = dbType === 'postgres' ? pgInvoices : sqliteInvoices;
|
||||
export const legalSettings = dbType === 'postgres' ? pgLegalSettings : sqliteLegalSettings;
|
||||
export const siteSettings = dbType === 'postgres' ? pgSiteSettings : sqliteSiteSettings;
|
||||
export const legalPages = dbType === 'postgres' ? pgLegalPages : sqliteLegalPages;
|
||||
export const faqQuestions = dbType === 'postgres' ? pgFaqQuestions : sqliteFaqQuestions;
|
||||
@@ -658,3 +693,5 @@ export type LegalPage = typeof sqliteLegalPages.$inferSelect;
|
||||
export type NewLegalPage = typeof sqliteLegalPages.$inferInsert;
|
||||
export type FaqQuestion = typeof sqliteFaqQuestions.$inferSelect;
|
||||
export type NewFaqQuestion = typeof sqliteFaqQuestions.$inferInsert;
|
||||
export type LegalSettings = typeof sqliteLegalSettings.$inferSelect;
|
||||
export type NewLegalSettings = typeof sqliteLegalSettings.$inferInsert;
|
||||
@@ -21,8 +21,10 @@ import paymentOptionsRoutes from './routes/payment-options.js';
|
||||
import dashboardRoutes from './routes/dashboard.js';
|
||||
import siteSettingsRoutes from './routes/site-settings.js';
|
||||
import legalPagesRoutes from './routes/legal-pages.js';
|
||||
import legalSettingsRoutes from './routes/legal-settings.js';
|
||||
import faqRoutes from './routes/faq.js';
|
||||
import emailService from './lib/email.js';
|
||||
import { initEmailQueue } from './lib/emailQueue.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -1856,6 +1858,7 @@ app.route('/api/payment-options', paymentOptionsRoutes);
|
||||
app.route('/api/dashboard', dashboardRoutes);
|
||||
app.route('/api/site-settings', siteSettingsRoutes);
|
||||
app.route('/api/legal-pages', legalPagesRoutes);
|
||||
app.route('/api/legal-settings', legalSettingsRoutes);
|
||||
app.route('/api/faq', faqRoutes);
|
||||
|
||||
// 404 handler
|
||||
@@ -1871,6 +1874,9 @@ app.onError((err, c) => {
|
||||
|
||||
const port = parseInt(process.env.PORT || '3001');
|
||||
|
||||
// Initialize email queue with the email service reference
|
||||
initEmailQueue(emailService);
|
||||
|
||||
// Initialize email templates on startup
|
||||
emailService.seedDefaultTemplates().catch(err => {
|
||||
console.error('[Email] Failed to seed templates:', err);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
defaultTemplates,
|
||||
type DefaultTemplate
|
||||
} from './emailTemplates.js';
|
||||
import { enqueueBulkEmails, type TemplateEmailJobParams } from './emailQueue.js';
|
||||
import nodemailer from 'nodemailer';
|
||||
import type { Transporter } from 'nodemailer';
|
||||
|
||||
@@ -1174,6 +1175,100 @@ export const emailService = {
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Queue emails for event attendees (non-blocking).
|
||||
* Adds all matching recipients to the background email queue and returns immediately.
|
||||
* Rate limiting and actual sending is handled by the email queue.
|
||||
*/
|
||||
async queueEventEmails(params: {
|
||||
eventId: string;
|
||||
templateSlug: string;
|
||||
customVariables?: Record<string, any>;
|
||||
recipientFilter?: 'all' | 'confirmed' | 'pending' | 'checked_in';
|
||||
sentBy: string;
|
||||
}): Promise<{ success: boolean; queuedCount: number; error?: string }> {
|
||||
const { eventId, templateSlug, customVariables = {}, recipientFilter = 'confirmed', sentBy } = params;
|
||||
|
||||
// Validate event exists
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, eventId))
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return { success: false, queuedCount: 0, error: 'Event not found' };
|
||||
}
|
||||
|
||||
// Validate template exists
|
||||
const template = await this.getTemplate(templateSlug);
|
||||
if (!template) {
|
||||
return { success: false, queuedCount: 0, error: `Template "${templateSlug}" not found` };
|
||||
}
|
||||
|
||||
// Get tickets based on filter
|
||||
let ticketQuery = (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).eventId, eventId));
|
||||
|
||||
if (recipientFilter !== 'all') {
|
||||
ticketQuery = ticketQuery.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, eventId),
|
||||
eq((tickets as any).status, recipientFilter)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const eventTickets = await dbAll<any>(ticketQuery);
|
||||
|
||||
if (eventTickets.length === 0) {
|
||||
return { success: true, queuedCount: 0, error: 'No recipients found' };
|
||||
}
|
||||
|
||||
// Get site timezone for proper date/time formatting
|
||||
const timezone = await this.getSiteTimezone();
|
||||
|
||||
// Build individual email jobs for the queue
|
||||
const jobs: TemplateEmailJobParams[] = eventTickets.map((ticket: any) => {
|
||||
const locale = ticket.preferredLanguage || 'en';
|
||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||
|
||||
return {
|
||||
templateSlug,
|
||||
to: ticket.attendeeEmail,
|
||||
toName: fullName,
|
||||
locale,
|
||||
eventId: event.id,
|
||||
sentBy,
|
||||
variables: {
|
||||
attendeeName: fullName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
ticketId: ticket.id,
|
||||
eventTitle,
|
||||
eventDate: this.formatDate(event.startDatetime, locale, timezone),
|
||||
eventTime: this.formatTime(event.startDatetime, locale, timezone),
|
||||
eventLocation: event.location,
|
||||
eventLocationUrl: event.locationUrl || '',
|
||||
...customVariables,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Enqueue all emails for background processing
|
||||
enqueueBulkEmails(jobs);
|
||||
|
||||
console.log(`[Email] Queued ${jobs.length} emails for event "${event.title}" (filter: ${recipientFilter})`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
queuedCount: jobs.length,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a custom email (not from template)
|
||||
*/
|
||||
@@ -1183,10 +1278,11 @@ export const emailService = {
|
||||
subject: string;
|
||||
bodyHtml: string;
|
||||
bodyText?: string;
|
||||
replyTo?: string;
|
||||
eventId?: string;
|
||||
sentBy: string;
|
||||
sentBy?: string | null;
|
||||
}): Promise<{ success: boolean; logId?: string; error?: string }> {
|
||||
const { to, toName, subject, bodyHtml, bodyText, eventId, sentBy } = params;
|
||||
const { to, toName, subject, bodyHtml, bodyText, replyTo, eventId, sentBy = null } = params;
|
||||
|
||||
const allVariables = {
|
||||
...this.getCommonVariables(),
|
||||
@@ -1208,7 +1304,7 @@ export const emailService = {
|
||||
subject,
|
||||
bodyHtml: finalBodyHtml,
|
||||
status: 'pending',
|
||||
sentBy,
|
||||
sentBy: sentBy || null,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
@@ -1218,6 +1314,7 @@ export const emailService = {
|
||||
subject,
|
||||
html: finalBodyHtml,
|
||||
text: bodyText,
|
||||
replyTo,
|
||||
});
|
||||
|
||||
// Update log
|
||||
|
||||
194
backend/src/lib/emailQueue.ts
Normal file
194
backend/src/lib/emailQueue.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
// In-memory email queue with rate limiting
|
||||
// Processes emails asynchronously in the background without blocking the request thread
|
||||
|
||||
import { generateId } from './utils.js';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
export interface EmailJob {
|
||||
id: string;
|
||||
type: 'template';
|
||||
params: TemplateEmailJobParams;
|
||||
addedAt: number;
|
||||
}
|
||||
|
||||
export interface TemplateEmailJobParams {
|
||||
templateSlug: string;
|
||||
to: string;
|
||||
toName?: string;
|
||||
variables: Record<string, any>;
|
||||
locale?: string;
|
||||
eventId?: string;
|
||||
sentBy?: string;
|
||||
}
|
||||
|
||||
export interface QueueStatus {
|
||||
queued: number;
|
||||
processing: boolean;
|
||||
sentInLastHour: number;
|
||||
maxPerHour: number;
|
||||
}
|
||||
|
||||
// ==================== Queue State ====================
|
||||
|
||||
const queue: EmailJob[] = [];
|
||||
const sentTimestamps: number[] = [];
|
||||
let processing = false;
|
||||
let processTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Lazy reference to emailService to avoid circular imports
|
||||
let _emailService: any = null;
|
||||
|
||||
function getEmailService() {
|
||||
if (!_emailService) {
|
||||
// Dynamic import to avoid circular dependency
|
||||
throw new Error('[EmailQueue] Email service not initialized. Call initEmailQueue() first.');
|
||||
}
|
||||
return _emailService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the email queue with a reference to the email service.
|
||||
* Must be called once at startup.
|
||||
*/
|
||||
export function initEmailQueue(emailService: any): void {
|
||||
_emailService = emailService;
|
||||
console.log('[EmailQueue] Initialized');
|
||||
}
|
||||
|
||||
// ==================== Rate Limiting ====================
|
||||
|
||||
function getMaxPerHour(): number {
|
||||
return parseInt(process.env.MAX_EMAILS_PER_HOUR || '30', 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up timestamps older than 1 hour
|
||||
*/
|
||||
function cleanOldTimestamps(): void {
|
||||
const oneHourAgo = Date.now() - 3_600_000;
|
||||
while (sentTimestamps.length > 0 && sentTimestamps[0] <= oneHourAgo) {
|
||||
sentTimestamps.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Queue Operations ====================
|
||||
|
||||
/**
|
||||
* Add a single email job to the queue.
|
||||
* Returns the job ID.
|
||||
*/
|
||||
export function enqueueEmail(params: TemplateEmailJobParams): string {
|
||||
const id = generateId();
|
||||
queue.push({
|
||||
id,
|
||||
type: 'template',
|
||||
params,
|
||||
addedAt: Date.now(),
|
||||
});
|
||||
scheduleProcessing();
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple email jobs to the queue at once.
|
||||
* Returns array of job IDs.
|
||||
*/
|
||||
export function enqueueBulkEmails(paramsList: TemplateEmailJobParams[]): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const params of paramsList) {
|
||||
const id = generateId();
|
||||
queue.push({
|
||||
id,
|
||||
type: 'template',
|
||||
params,
|
||||
addedAt: Date.now(),
|
||||
});
|
||||
ids.push(id);
|
||||
}
|
||||
if (ids.length > 0) {
|
||||
console.log(`[EmailQueue] Queued ${ids.length} emails for background processing`);
|
||||
scheduleProcessing();
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current queue status
|
||||
*/
|
||||
export function getQueueStatus(): QueueStatus {
|
||||
cleanOldTimestamps();
|
||||
return {
|
||||
queued: queue.length,
|
||||
processing,
|
||||
sentInLastHour: sentTimestamps.length,
|
||||
maxPerHour: getMaxPerHour(),
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Processing ====================
|
||||
|
||||
function scheduleProcessing(): void {
|
||||
if (processing) return;
|
||||
processing = true;
|
||||
// Start processing on next tick to not block the caller
|
||||
setImmediate(() => processNext());
|
||||
}
|
||||
|
||||
async function processNext(): Promise<void> {
|
||||
if (queue.length === 0) {
|
||||
processing = false;
|
||||
console.log('[EmailQueue] Queue empty. Processing stopped.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Rate limit check
|
||||
cleanOldTimestamps();
|
||||
const maxPerHour = getMaxPerHour();
|
||||
|
||||
if (sentTimestamps.length >= maxPerHour) {
|
||||
// Calculate when the oldest timestamp in the window expires
|
||||
const waitMs = sentTimestamps[0] + 3_600_000 - Date.now() + 500; // 500ms buffer
|
||||
console.log(
|
||||
`[EmailQueue] Rate limit reached (${maxPerHour}/hr). ` +
|
||||
`Pausing for ${Math.ceil(waitMs / 1000)}s. ${queue.length} email(s) remaining.`
|
||||
);
|
||||
processTimer = setTimeout(() => processNext(), waitMs);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dequeue and process
|
||||
const job = queue.shift()!;
|
||||
|
||||
try {
|
||||
const emailService = getEmailService();
|
||||
await emailService.sendTemplateEmail(job.params);
|
||||
sentTimestamps.push(Date.now());
|
||||
console.log(
|
||||
`[EmailQueue] Sent email ${job.id} to ${job.params.to}. ` +
|
||||
`Queue: ${queue.length} remaining. Sent this hour: ${sentTimestamps.length}/${maxPerHour}`
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`[EmailQueue] Failed to send email ${job.id} to ${job.params.to}:`,
|
||||
error?.message || error
|
||||
);
|
||||
// The sendTemplateEmail method already logs the failure in the email_logs table,
|
||||
// so we don't need to retry here. The error is logged and we move on.
|
||||
}
|
||||
|
||||
// Small delay between sends to be gentle on the email server
|
||||
processTimer = setTimeout(() => processNext(), 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop processing (for graceful shutdown)
|
||||
*/
|
||||
export function stopQueue(): void {
|
||||
if (processTimer) {
|
||||
clearTimeout(processTimer);
|
||||
processTimer = null;
|
||||
}
|
||||
processing = false;
|
||||
console.log(`[EmailQueue] Stopped. ${queue.length} email(s) remaining in queue.`);
|
||||
}
|
||||
80
backend/src/lib/legal-placeholders.ts
Normal file
80
backend/src/lib/legal-placeholders.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { getLegalSettingsValues } from '../routes/legal-settings.js';
|
||||
|
||||
/**
|
||||
* Strict whitelist of supported placeholders.
|
||||
* Only these placeholders will be replaced in legal page content.
|
||||
* Unknown placeholders remain unchanged.
|
||||
*/
|
||||
const SUPPORTED_PLACEHOLDERS = new Set([
|
||||
'COMPANY_NAME',
|
||||
'LEGAL_ENTITY_NAME',
|
||||
'RUC_NUMBER',
|
||||
'COMPANY_ADDRESS',
|
||||
'COMPANY_CITY',
|
||||
'COMPANY_COUNTRY',
|
||||
'SUPPORT_EMAIL',
|
||||
'LEGAL_EMAIL',
|
||||
'GOVERNING_LAW',
|
||||
'JURISDICTION_CITY',
|
||||
'CURRENT_YEAR',
|
||||
'LAST_UPDATED_DATE',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Replace legal placeholders in content using strict whitelist mapping.
|
||||
*
|
||||
* Rules:
|
||||
* - Only supported placeholders are replaced
|
||||
* - Unknown placeholders remain unchanged
|
||||
* - Missing values are replaced with empty string
|
||||
* - No code execution or dynamic evaluation
|
||||
* - Replacement is pure string substitution
|
||||
*
|
||||
* @param content - The markdown/text content containing {{PLACEHOLDER}} tokens
|
||||
* @param updatedAt - The page's updated_at timestamp (for LAST_UPDATED_DATE)
|
||||
* @returns Content with placeholders replaced
|
||||
*/
|
||||
export async function replaceLegalPlaceholders(
|
||||
content: string,
|
||||
updatedAt?: string
|
||||
): Promise<string> {
|
||||
if (!content) return content;
|
||||
|
||||
// Fetch legal settings values from DB
|
||||
const settingsValues = await getLegalSettingsValues();
|
||||
|
||||
// Build the full replacement map
|
||||
const replacements: Record<string, string> = { ...settingsValues };
|
||||
|
||||
// Dynamic values
|
||||
replacements['CURRENT_YEAR'] = new Date().getFullYear().toString();
|
||||
|
||||
if (updatedAt) {
|
||||
try {
|
||||
const date = new Date(updatedAt);
|
||||
if (!isNaN(date.getTime())) {
|
||||
replacements['LAST_UPDATED_DATE'] = date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
} else {
|
||||
replacements['LAST_UPDATED_DATE'] = updatedAt;
|
||||
}
|
||||
} catch {
|
||||
replacements['LAST_UPDATED_DATE'] = updatedAt;
|
||||
}
|
||||
}
|
||||
|
||||
// Replace only whitelisted placeholders using a single regex pass
|
||||
// Matches {{PLACEHOLDER_NAME}} where PLACEHOLDER_NAME is uppercase letters and underscores
|
||||
return content.replace(/\{\{([A-Z_]+)\}\}/g, (match, placeholderName) => {
|
||||
// Only replace if the placeholder is in the whitelist
|
||||
if (!SUPPORTED_PLACEHOLDERS.has(placeholderName)) {
|
||||
return match; // Unknown placeholder - leave unchanged
|
||||
}
|
||||
|
||||
// Return the value or empty string if missing
|
||||
return replacements[placeholderName] ?? '';
|
||||
});
|
||||
}
|
||||
22
backend/src/lib/revalidate.ts
Normal file
22
backend/src/lib/revalidate.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// Trigger frontend cache revalidation (fire-and-forget)
|
||||
// Revalidates both the sitemap and the next-event data (homepage, llms.txt)
|
||||
export function revalidateFrontendCache() {
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
|
||||
const secret = process.env.REVALIDATE_SECRET;
|
||||
if (!secret) {
|
||||
console.warn('REVALIDATE_SECRET not set, skipping frontend revalidation');
|
||||
return;
|
||||
}
|
||||
fetch(`${frontendUrl}/api/revalidate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ secret, tag: ['events-sitemap', 'next-event'] }),
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) console.error('Frontend revalidation failed:', res.status);
|
||||
else console.log('Frontend revalidation triggered (sitemap + next-event)');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Frontend revalidation error:', err.message);
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from 'hono';
|
||||
import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js';
|
||||
import { eq, and, gte, sql, desc } from 'drizzle-orm';
|
||||
import { eq, and, gte, sql, desc, inArray } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
|
||||
@@ -222,6 +222,211 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
|
||||
return c.json({ tickets: enrichedTickets });
|
||||
});
|
||||
|
||||
// Export attendees for a specific event (admin) — CSV download
|
||||
adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), async (c) => {
|
||||
const eventId = c.req.param('eventId');
|
||||
const status = c.req.query('status') || 'all'; // confirmed | checked_in | confirmed_pending | all
|
||||
const q = c.req.query('q') || '';
|
||||
|
||||
// Verify event exists
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, eventId))
|
||||
);
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
// Build query for tickets belonging to this event
|
||||
let conditions: any[] = [eq((tickets as any).eventId, eventId)];
|
||||
|
||||
if (status === 'confirmed') {
|
||||
conditions.push(eq((tickets as any).status, 'confirmed'));
|
||||
} else if (status === 'checked_in') {
|
||||
conditions.push(eq((tickets as any).status, 'checked_in'));
|
||||
} else if (status === 'confirmed_pending') {
|
||||
conditions.push(inArray((tickets as any).status, ['confirmed', 'pending']));
|
||||
} else {
|
||||
// "all" — include everything
|
||||
}
|
||||
|
||||
let ticketList = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(conditions.length === 1 ? conditions[0] : and(...conditions))
|
||||
.orderBy(desc((tickets as any).createdAt))
|
||||
);
|
||||
|
||||
// Apply text search filter in-memory
|
||||
if (q) {
|
||||
const query = q.toLowerCase();
|
||||
ticketList = ticketList.filter((t: any) => {
|
||||
const fullName = `${t.attendeeFirstName || ''} ${t.attendeeLastName || ''}`.toLowerCase();
|
||||
return (
|
||||
fullName.includes(query) ||
|
||||
(t.attendeeEmail || '').toLowerCase().includes(query) ||
|
||||
(t.attendeePhone || '').toLowerCase().includes(query) ||
|
||||
t.id.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Enrich each ticket with payment data
|
||||
const rows = await Promise.all(
|
||||
ticketList.map(async (ticket: any) => {
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticket.id))
|
||||
);
|
||||
|
||||
const fullName = [ticket.attendeeFirstName, ticket.attendeeLastName].filter(Boolean).join(' ');
|
||||
const isCheckedIn = ticket.status === 'checked_in';
|
||||
|
||||
return {
|
||||
'Ticket ID': ticket.id,
|
||||
'Full Name': fullName,
|
||||
'Email': ticket.attendeeEmail || '',
|
||||
'Phone': ticket.attendeePhone || '',
|
||||
'Status': ticket.status,
|
||||
'Checked In': isCheckedIn ? 'true' : 'false',
|
||||
'Check-in Time': ticket.checkinAt || '',
|
||||
'Payment Status': payment?.status || '',
|
||||
'Booked At': ticket.createdAt || '',
|
||||
'Notes': ticket.adminNote || '',
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Generate CSV
|
||||
const csvEscape = (value: string) => {
|
||||
if (value == null) return '';
|
||||
const str = String(value);
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
||||
return '"' + str.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
'Ticket ID', 'Full Name', 'Email', 'Phone',
|
||||
'Status', 'Checked In', 'Check-in Time', 'Payment Status',
|
||||
'Booked At', 'Notes',
|
||||
];
|
||||
|
||||
const headerLine = columns.map(csvEscape).join(',');
|
||||
const dataLines = rows.map((row: any) =>
|
||||
columns.map((col) => csvEscape(row[col])).join(',')
|
||||
);
|
||||
|
||||
const csvContent = '\uFEFF' + [headerLine, ...dataLines].join('\r\n'); // BOM for UTF-8
|
||||
|
||||
// Build filename: event-slug-attendees-YYYY-MM-DD.csv
|
||||
const slug = (event.title || 'event')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '');
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
const filename = `${slug}-attendees-${dateStr}.csv`;
|
||||
|
||||
c.header('Content-Type', 'text/csv; charset=utf-8');
|
||||
c.header('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
return c.body(csvContent);
|
||||
});
|
||||
|
||||
// Legacy alias — keep old path working
|
||||
adminRouter.get('/events/:eventId/export', requireAuth(['admin']), async (c) => {
|
||||
const newUrl = new URL(c.req.url);
|
||||
newUrl.pathname = newUrl.pathname.replace('/export', '/attendees/export');
|
||||
return c.redirect(newUrl.toString(), 301);
|
||||
});
|
||||
|
||||
// Export tickets for a specific event (admin) — CSV download (confirmed/checked_in only)
|
||||
adminRouter.get('/events/:eventId/tickets/export', requireAuth(['admin']), async (c) => {
|
||||
const eventId = c.req.param('eventId');
|
||||
const status = c.req.query('status') || 'all'; // confirmed | checked_in | all
|
||||
const q = c.req.query('q') || '';
|
||||
|
||||
// Verify event exists
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, eventId))
|
||||
);
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
// Only confirmed/checked_in for tickets export
|
||||
let conditions: any[] = [
|
||||
eq((tickets as any).eventId, eventId),
|
||||
inArray((tickets as any).status, ['confirmed', 'checked_in']),
|
||||
];
|
||||
|
||||
if (status === 'confirmed') {
|
||||
conditions = [eq((tickets as any).eventId, eventId), eq((tickets as any).status, 'confirmed')];
|
||||
} else if (status === 'checked_in') {
|
||||
conditions = [eq((tickets as any).eventId, eventId), eq((tickets as any).status, 'checked_in')];
|
||||
}
|
||||
|
||||
let ticketList = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc((tickets as any).createdAt))
|
||||
);
|
||||
|
||||
// Apply text search filter
|
||||
if (q) {
|
||||
const query = q.toLowerCase();
|
||||
ticketList = ticketList.filter((t: any) => {
|
||||
const fullName = `${t.attendeeFirstName || ''} ${t.attendeeLastName || ''}`.toLowerCase();
|
||||
return (
|
||||
fullName.includes(query) ||
|
||||
t.id.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const csvEscape = (value: string) => {
|
||||
if (value == null) return '';
|
||||
const str = String(value);
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
||||
return '"' + str.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
const columns = ['Ticket ID', 'Booking ID', 'Attendee Name', 'Status', 'Check-in Time', 'Booked At'];
|
||||
|
||||
const rows = ticketList.map((ticket: any) => ({
|
||||
'Ticket ID': ticket.id,
|
||||
'Booking ID': ticket.bookingId || '',
|
||||
'Attendee Name': [ticket.attendeeFirstName, ticket.attendeeLastName].filter(Boolean).join(' '),
|
||||
'Status': ticket.status,
|
||||
'Check-in Time': ticket.checkinAt || '',
|
||||
'Booked At': ticket.createdAt || '',
|
||||
}));
|
||||
|
||||
const headerLine = columns.map(csvEscape).join(',');
|
||||
const dataLines = rows.map((row: any) =>
|
||||
columns.map((col: string) => csvEscape(row[col])).join(',')
|
||||
);
|
||||
|
||||
const csvContent = '\uFEFF' + [headerLine, ...dataLines].join('\r\n');
|
||||
|
||||
const slug = (event.title || 'event')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '');
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
const filename = `${slug}-tickets-${dateStr}.csv`;
|
||||
|
||||
c.header('Content-Type', 'text/csv; charset=utf-8');
|
||||
c.header('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
return c.body(csvContent);
|
||||
});
|
||||
|
||||
// Export financial data (admin)
|
||||
adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => {
|
||||
const startDate = c.req.query('startDate');
|
||||
|
||||
@@ -1,13 +1,37 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, dbAll, contacts, emailSubscribers } from '../db/index.js';
|
||||
import { db, dbGet, dbAll, contacts, emailSubscribers, legalSettings } from '../db/index.js';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { generateId, getNow } from '../lib/utils.js';
|
||||
import { emailService } from '../lib/email.js';
|
||||
|
||||
const contactsRouter = new Hono();
|
||||
|
||||
// ==================== Sanitization Helpers ====================
|
||||
|
||||
/**
|
||||
* Sanitize a string to prevent HTML injection
|
||||
* Escapes HTML special characters
|
||||
*/
|
||||
function sanitizeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize email header values to prevent email header injection
|
||||
* Strips newlines and carriage returns that could be used to inject headers
|
||||
*/
|
||||
function sanitizeHeaderValue(str: string): string {
|
||||
return str.replace(/[\r\n]/g, '').trim();
|
||||
}
|
||||
|
||||
const createContactSchema = z.object({
|
||||
name: z.string().min(2),
|
||||
email: z.string().email(),
|
||||
@@ -29,17 +53,74 @@ contactsRouter.post('/', zValidator('json', createContactSchema), async (c) => {
|
||||
const now = getNow();
|
||||
const id = generateId();
|
||||
|
||||
// Sanitize header-sensitive values to prevent email header injection
|
||||
const sanitizedEmail = sanitizeHeaderValue(data.email);
|
||||
const sanitizedName = sanitizeHeaderValue(data.name);
|
||||
|
||||
const newContact = {
|
||||
id,
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
name: sanitizedName,
|
||||
email: sanitizedEmail,
|
||||
message: data.message,
|
||||
status: 'new' as const,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
// Always store the message in admin, regardless of email outcome
|
||||
await (db as any).insert(contacts).values(newContact);
|
||||
|
||||
// Send email notification to support email (non-blocking)
|
||||
try {
|
||||
// Retrieve support_email from legal_settings
|
||||
const settings = await dbGet<any>(
|
||||
(db as any).select().from(legalSettings).limit(1)
|
||||
);
|
||||
|
||||
const supportEmail = settings?.supportEmail;
|
||||
|
||||
if (supportEmail) {
|
||||
const websiteUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
|
||||
|
||||
// Sanitize all values for HTML display
|
||||
const safeName = sanitizeHtml(sanitizedName);
|
||||
const safeEmail = sanitizeHtml(sanitizedEmail);
|
||||
const safeMessage = sanitizeHtml(data.message);
|
||||
|
||||
const subject = `New Contact Form Message – ${websiteUrl}`;
|
||||
|
||||
const bodyHtml = `
|
||||
<p><strong>${safeName}</strong> (${safeEmail}) sent a message:</p>
|
||||
<div style="padding: 16px 20px; background-color: #f8fafc; border-left: 4px solid #3b82f6; margin: 16px 0; white-space: pre-wrap; font-size: 15px; line-height: 1.6;">${safeMessage}</div>
|
||||
<p style="color: #64748b; font-size: 13px;">Reply directly to this email to respond to ${safeName}.</p>
|
||||
`;
|
||||
|
||||
const bodyText = [
|
||||
`${sanitizedName} (${sanitizedEmail}) sent a message:`,
|
||||
'',
|
||||
data.message,
|
||||
'',
|
||||
`Reply directly to this email to respond to ${sanitizedName}.`,
|
||||
].join('\n');
|
||||
|
||||
const emailResult = await emailService.sendCustomEmail({
|
||||
to: supportEmail,
|
||||
subject,
|
||||
bodyHtml,
|
||||
bodyText,
|
||||
replyTo: sanitizedEmail,
|
||||
});
|
||||
|
||||
if (!emailResult.success) {
|
||||
console.error('[Contact Form] Failed to send email notification:', emailResult.error);
|
||||
}
|
||||
} else {
|
||||
console.warn('[Contact Form] No support email configured in legal settings – skipping email notification');
|
||||
}
|
||||
} catch (emailError: any) {
|
||||
// Log the error but do NOT break the contact form UX
|
||||
console.error('[Contact Form] Error sending email notification:', emailError?.message || emailError);
|
||||
}
|
||||
|
||||
return c.json({ message: 'Message sent successfully' }, 201);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { requireAuth } from '../lib/auth.js';
|
||||
import { getNow, generateId } from '../lib/utils.js';
|
||||
import emailService from '../lib/email.js';
|
||||
import { getTemplateVariables, defaultTemplates } from '../lib/emailTemplates.js';
|
||||
import { getQueueStatus } from '../lib/emailQueue.js';
|
||||
|
||||
const emailsRouter = new Hono();
|
||||
|
||||
@@ -195,7 +196,7 @@ emailsRouter.get('/templates/:slug/variables', requireAuth(['admin', 'organizer'
|
||||
|
||||
// ==================== Email Sending Routes ====================
|
||||
|
||||
// Send email using template to event attendees
|
||||
// Send email using template to event attendees (non-blocking, queued)
|
||||
emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const { eventId } = c.req.param();
|
||||
const user = (c as any).get('user');
|
||||
@@ -206,7 +207,8 @@ emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), a
|
||||
return c.json({ error: 'Template slug is required' }, 400);
|
||||
}
|
||||
|
||||
const result = await emailService.sendToEventAttendees({
|
||||
// Queue emails for background processing instead of sending synchronously
|
||||
const result = await emailService.queueEventEmails({
|
||||
eventId,
|
||||
templateSlug,
|
||||
customVariables,
|
||||
@@ -411,4 +413,10 @@ emailsRouter.post('/test', requireAuth(['admin']), async (c) => {
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// Get email queue status
|
||||
emailsRouter.get('/queue/status', requireAuth(['admin']), async (c) => {
|
||||
const status = getQueueStatus();
|
||||
return c.json({ status });
|
||||
});
|
||||
|
||||
export default emailsRouter;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { db, dbGet, dbAll, events, tickets, payments, eventPaymentOverrides, ema
|
||||
import { eq, desc, and, gte, sql } from 'drizzle-orm';
|
||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||
import { generateId, getNow, convertBooleansForDb, toDbDate, calculateAvailableSeats } from '../lib/utils.js';
|
||||
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||
|
||||
interface UserContext {
|
||||
id: string;
|
||||
@@ -15,29 +16,6 @@ interface UserContext {
|
||||
|
||||
const eventsRouter = new Hono<{ Variables: { user: UserContext } }>();
|
||||
|
||||
// Trigger frontend cache revalidation (fire-and-forget)
|
||||
// Revalidates both the sitemap and the next-event data (homepage, llms.txt)
|
||||
function revalidateFrontendCache() {
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
|
||||
const secret = process.env.REVALIDATE_SECRET;
|
||||
if (!secret) {
|
||||
console.warn('REVALIDATE_SECRET not set, skipping frontend revalidation');
|
||||
return;
|
||||
}
|
||||
fetch(`${frontendUrl}/api/revalidate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ secret, tag: ['events-sitemap', 'next-event'] }),
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) console.error('Frontend revalidation failed:', res.status);
|
||||
else console.log('Frontend revalidation triggered (sitemap + next-event)');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Frontend revalidation error:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to normalize event data for API response
|
||||
// PostgreSQL decimal returns strings, booleans are stored as integers
|
||||
function normalizeEvent(event: any) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { db, dbGet, dbAll, legalPages } from '../db/index.js';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { getNow, generateId } from '../lib/utils.js';
|
||||
import { replaceLegalPlaceholders } from '../lib/legal-placeholders.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
@@ -171,12 +172,15 @@ legalPagesRouter.get('/:slug', async (c) => {
|
||||
// Get localized content with fallback
|
||||
const { title, contentMarkdown } = getLocalizedContent(page, locale);
|
||||
|
||||
// Replace legal placeholders before returning
|
||||
const processedContent = await replaceLegalPlaceholders(contentMarkdown, page.updatedAt);
|
||||
|
||||
return c.json({
|
||||
page: {
|
||||
id: page.id,
|
||||
slug: page.slug,
|
||||
title,
|
||||
contentMarkdown,
|
||||
contentMarkdown: processedContent,
|
||||
updatedAt: page.updatedAt,
|
||||
source: 'database',
|
||||
}
|
||||
@@ -195,11 +199,14 @@ legalPagesRouter.get('/:slug', async (c) => {
|
||||
? (titles?.es || titles?.en || slug)
|
||||
: (titles?.en || titles?.es || slug);
|
||||
|
||||
// Replace legal placeholders in filesystem content too
|
||||
const processedContent = await replaceLegalPlaceholders(content);
|
||||
|
||||
return c.json({
|
||||
page: {
|
||||
slug,
|
||||
title,
|
||||
contentMarkdown: content,
|
||||
contentMarkdown: processedContent,
|
||||
source: 'filesystem',
|
||||
}
|
||||
});
|
||||
|
||||
146
backend/src/routes/legal-settings.ts
Normal file
146
backend/src/routes/legal-settings.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, legalSettings } from '../db/index.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { generateId, getNow } from '../lib/utils.js';
|
||||
|
||||
interface UserContext {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
const legalSettingsRouter = new Hono<{ Variables: { user: UserContext } }>();
|
||||
|
||||
// Validation schema for updating legal settings
|
||||
const updateLegalSettingsSchema = z.object({
|
||||
companyName: z.string().optional().nullable(),
|
||||
legalEntityName: z.string().optional().nullable(),
|
||||
rucNumber: z.string().optional().nullable(),
|
||||
companyAddress: z.string().optional().nullable(),
|
||||
companyCity: z.string().optional().nullable(),
|
||||
companyCountry: z.string().optional().nullable(),
|
||||
supportEmail: z.string().email().optional().nullable().or(z.literal('')),
|
||||
legalEmail: z.string().email().optional().nullable().or(z.literal('')),
|
||||
governingLaw: z.string().optional().nullable(),
|
||||
jurisdictionCity: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
// Get legal settings (admin only)
|
||||
legalSettingsRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
const settings = await dbGet<any>(
|
||||
(db as any).select().from(legalSettings).limit(1)
|
||||
);
|
||||
|
||||
if (!settings) {
|
||||
// Return empty defaults
|
||||
return c.json({
|
||||
settings: {
|
||||
companyName: null,
|
||||
legalEntityName: null,
|
||||
rucNumber: null,
|
||||
companyAddress: null,
|
||||
companyCity: null,
|
||||
companyCountry: null,
|
||||
supportEmail: null,
|
||||
legalEmail: null,
|
||||
governingLaw: null,
|
||||
jurisdictionCity: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ settings });
|
||||
});
|
||||
|
||||
// Internal helper: get legal settings for placeholder replacement (no auth required)
|
||||
// This is called server-side from legal-pages route, not exposed as HTTP endpoint
|
||||
export async function getLegalSettingsValues(): Promise<Record<string, string>> {
|
||||
const settings = await dbGet<any>(
|
||||
(db as any).select().from(legalSettings).limit(1)
|
||||
);
|
||||
|
||||
if (!settings) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const values: Record<string, string> = {};
|
||||
if (settings.companyName) values['COMPANY_NAME'] = settings.companyName;
|
||||
if (settings.legalEntityName) values['LEGAL_ENTITY_NAME'] = settings.legalEntityName;
|
||||
if (settings.rucNumber) values['RUC_NUMBER'] = settings.rucNumber;
|
||||
if (settings.companyAddress) values['COMPANY_ADDRESS'] = settings.companyAddress;
|
||||
if (settings.companyCity) values['COMPANY_CITY'] = settings.companyCity;
|
||||
if (settings.companyCountry) values['COMPANY_COUNTRY'] = settings.companyCountry;
|
||||
if (settings.supportEmail) values['SUPPORT_EMAIL'] = settings.supportEmail;
|
||||
if (settings.legalEmail) values['LEGAL_EMAIL'] = settings.legalEmail;
|
||||
if (settings.governingLaw) values['GOVERNING_LAW'] = settings.governingLaw;
|
||||
if (settings.jurisdictionCity) values['JURISDICTION_CITY'] = settings.jurisdictionCity;
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
// Update legal settings (admin only)
|
||||
legalSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateLegalSettingsSchema), async (c) => {
|
||||
const data = c.req.valid('json');
|
||||
const user = c.get('user');
|
||||
const now = getNow();
|
||||
|
||||
// Check if settings exist
|
||||
const existing = await dbGet<any>(
|
||||
(db as any).select().from(legalSettings).limit(1)
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
// Create new settings record
|
||||
const id = generateId();
|
||||
const newSettings = {
|
||||
id,
|
||||
companyName: data.companyName || null,
|
||||
legalEntityName: data.legalEntityName || null,
|
||||
rucNumber: data.rucNumber || null,
|
||||
companyAddress: data.companyAddress || null,
|
||||
companyCity: data.companyCity || null,
|
||||
companyCountry: data.companyCountry || null,
|
||||
supportEmail: data.supportEmail || null,
|
||||
legalEmail: data.legalEmail || null,
|
||||
governingLaw: data.governingLaw || null,
|
||||
jurisdictionCity: data.jurisdictionCity || null,
|
||||
updatedAt: now,
|
||||
updatedBy: user.id,
|
||||
};
|
||||
|
||||
await (db as any).insert(legalSettings).values(newSettings);
|
||||
|
||||
return c.json({ settings: newSettings, message: 'Legal settings created successfully' }, 201);
|
||||
}
|
||||
|
||||
// Update existing settings
|
||||
const updateData: Record<string, any> = {
|
||||
...data,
|
||||
updatedAt: now,
|
||||
updatedBy: user.id,
|
||||
};
|
||||
|
||||
// Normalize empty strings to null
|
||||
for (const key of Object.keys(updateData)) {
|
||||
if (updateData[key] === '') {
|
||||
updateData[key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
await (db as any)
|
||||
.update(legalSettings)
|
||||
.set(updateData)
|
||||
.where(eq((legalSettings as any).id, existing.id));
|
||||
|
||||
const updated = await dbGet(
|
||||
(db as any).select().from(legalSettings).where(eq((legalSettings as any).id, existing.id))
|
||||
);
|
||||
|
||||
return c.json({ settings: updated, message: 'Legal settings updated successfully' });
|
||||
});
|
||||
|
||||
export default legalSettingsRouter;
|
||||
@@ -5,6 +5,7 @@ import { db, dbGet, siteSettings, events } from '../db/index.js';
|
||||
import { eq, and, gte } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { generateId, getNow, toDbBool } from '../lib/utils.js';
|
||||
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||
|
||||
interface UserContext {
|
||||
id: string;
|
||||
@@ -172,6 +173,11 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
|
||||
(db as any).select().from(siteSettings).where(eq((siteSettings as any).id, existing.id))
|
||||
);
|
||||
|
||||
// Revalidate frontend cache if featured event changed
|
||||
if (data.featuredEventId !== undefined) {
|
||||
revalidateFrontendCache();
|
||||
}
|
||||
|
||||
return c.json({ settings: updated, message: 'Settings updated successfully' });
|
||||
});
|
||||
|
||||
@@ -216,6 +222,9 @@ siteSettingsRouter.put('/featured-event', requireAuth(['admin']), zValidator('js
|
||||
|
||||
await (db as any).insert(siteSettings).values(newSettings);
|
||||
|
||||
// Revalidate frontend cache so homepage shows the updated featured event
|
||||
revalidateFrontendCache();
|
||||
|
||||
return c.json({ featuredEventId: eventId, message: eventId ? 'Event set as featured' : 'Featured event removed' });
|
||||
}
|
||||
|
||||
@@ -229,6 +238,9 @@ siteSettingsRouter.put('/featured-event', requireAuth(['admin']), zValidator('js
|
||||
})
|
||||
.where(eq((siteSettings as any).id, existing.id));
|
||||
|
||||
// Revalidate frontend cache so homepage shows the updated featured event
|
||||
revalidateFrontendCache();
|
||||
|
||||
return c.json({ featuredEventId: eventId, message: eventId ? 'Event set as featured' : 'Featured event removed' });
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, siteSettings } from '../db/index.js';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { eq, and, or, sql } from 'drizzle-orm';
|
||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||
import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
|
||||
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
|
||||
@@ -490,6 +490,125 @@ ticketsRouter.get('/:id/pdf', async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get event check-in stats for scanner (lightweight endpoint for staff)
|
||||
ticketsRouter.get('/stats/checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||
const eventId = c.req.query('eventId');
|
||||
|
||||
if (!eventId) {
|
||||
return c.json({ error: 'eventId is required' }, 400);
|
||||
}
|
||||
|
||||
// Get event info
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, eventId))
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
// Count checked-in tickets
|
||||
const checkedInCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, eventId),
|
||||
eq((tickets as any).status, 'checked_in')
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Count confirmed + checked_in (total active)
|
||||
const totalActiveCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, eventId),
|
||||
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
return c.json({
|
||||
eventId,
|
||||
capacity: event.capacity,
|
||||
checkedIn: checkedInCount?.count || 0,
|
||||
totalActive: totalActiveCount?.count || 0,
|
||||
});
|
||||
});
|
||||
|
||||
// Live search tickets (GET - for scanner live search)
|
||||
ticketsRouter.get('/search', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||
const q = c.req.query('q')?.trim() || '';
|
||||
const eventId = c.req.query('eventId');
|
||||
|
||||
if (q.length < 2) {
|
||||
return c.json({ tickets: [] });
|
||||
}
|
||||
|
||||
const searchTerm = `%${q.toLowerCase()}%`;
|
||||
|
||||
// Search by name (ILIKE), email (ILIKE), ticket ID (exact or partial)
|
||||
const nameEmailConditions = [
|
||||
sql`LOWER(${(tickets as any).attendeeEmail}) LIKE ${searchTerm}`,
|
||||
sql`LOWER(${(tickets as any).attendeeFirstName}) LIKE ${searchTerm}`,
|
||||
sql`LOWER(${(tickets as any).attendeeLastName}) LIKE ${searchTerm}`,
|
||||
sql`LOWER(${(tickets as any).attendeeFirstName} || ' ' || COALESCE(${(tickets as any).attendeeLastName}, '')) LIKE ${searchTerm}`,
|
||||
// Ticket ID exact or partial match (cast UUID to text for LOWER)
|
||||
sql`LOWER(CAST(${(tickets as any).id} AS TEXT)) LIKE ${searchTerm}`,
|
||||
sql`LOWER(CAST(${(tickets as any).qrCode} AS TEXT)) LIKE ${searchTerm}`,
|
||||
];
|
||||
|
||||
let whereClause: any = and(
|
||||
or(...nameEmailConditions),
|
||||
// Exclude cancelled tickets by default
|
||||
sql`${(tickets as any).status} != 'cancelled'`
|
||||
);
|
||||
|
||||
if (eventId) {
|
||||
whereClause = and(whereClause, eq((tickets as any).eventId, eventId));
|
||||
}
|
||||
|
||||
const matchingTickets = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(whereClause)
|
||||
.limit(20)
|
||||
);
|
||||
|
||||
// Enrich with event details
|
||||
const results = await Promise.all(
|
||||
matchingTickets.map(async (ticket: any) => {
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
return {
|
||||
ticket_id: ticket.id,
|
||||
name: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
|
||||
email: ticket.attendeeEmail,
|
||||
status: ticket.status,
|
||||
checked_in: ticket.status === 'checked_in',
|
||||
checkinAt: ticket.checkinAt,
|
||||
event_id: ticket.eventId,
|
||||
qrCode: ticket.qrCode,
|
||||
event: event ? {
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
startDatetime: event.startDatetime,
|
||||
location: event.location,
|
||||
} : null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return c.json({ tickets: results });
|
||||
});
|
||||
|
||||
// Get ticket by ID
|
||||
ticketsRouter.get('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
@@ -554,6 +673,65 @@ ticketsRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff']), zValidat
|
||||
return c.json({ ticket: updated });
|
||||
});
|
||||
|
||||
// Search tickets by name/email (for scanner manual search)
|
||||
ticketsRouter.post('/search', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const { query, eventId } = body;
|
||||
|
||||
if (!query || typeof query !== 'string' || query.trim().length < 2) {
|
||||
return c.json({ error: 'Search query must be at least 2 characters' }, 400);
|
||||
}
|
||||
|
||||
const searchTerm = `%${query.trim().toLowerCase()}%`;
|
||||
|
||||
const conditions = [
|
||||
sql`LOWER(${(tickets as any).attendeeEmail}) LIKE ${searchTerm}`,
|
||||
sql`LOWER(${(tickets as any).attendeeFirstName}) LIKE ${searchTerm}`,
|
||||
sql`LOWER(${(tickets as any).attendeeLastName}) LIKE ${searchTerm}`,
|
||||
sql`LOWER(${(tickets as any).attendeeFirstName} || ' ' || COALESCE(${(tickets as any).attendeeLastName}, '')) LIKE ${searchTerm}`,
|
||||
];
|
||||
|
||||
let whereClause = or(...conditions);
|
||||
|
||||
if (eventId) {
|
||||
whereClause = and(whereClause, eq((tickets as any).eventId, eventId));
|
||||
}
|
||||
|
||||
const matchingTickets = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(whereClause)
|
||||
.limit(20)
|
||||
);
|
||||
|
||||
// Enrich with event details
|
||||
const results = await Promise.all(
|
||||
matchingTickets.map(async (ticket: any) => {
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
return {
|
||||
id: ticket.id,
|
||||
qrCode: ticket.qrCode,
|
||||
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
attendeePhone: ticket.attendeePhone,
|
||||
status: ticket.status,
|
||||
checkinAt: ticket.checkinAt,
|
||||
event: event ? {
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
startDatetime: event.startDatetime,
|
||||
location: event.location,
|
||||
} : null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return c.json({ tickets: results });
|
||||
});
|
||||
|
||||
// Validate ticket by QR code (for scanner)
|
||||
ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
|
||||
@@ -8,9 +8,9 @@ Type=simple
|
||||
User=spanglish
|
||||
Group=spanglish
|
||||
WorkingDirectory=/home/spanglish/Spanglish/backend
|
||||
EnvironmentFile=/home/spanglish/Spanglish/backend/.env
|
||||
Environment=NODE_ENV=production
|
||||
Environment=PORT=3018
|
||||
EnvironmentFile=/home/spanglish/Spanglish/backend/.env
|
||||
ExecStart=/usr/bin/node dist/index.js
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
@@ -189,7 +189,6 @@ export default function AdminEmailsPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
const res = await emailsApi.sendToEvent(composeForm.eventId, {
|
||||
templateSlug: composeForm.templateSlug,
|
||||
@@ -197,20 +196,15 @@ export default function AdminEmailsPage() {
|
||||
customVariables: composeForm.customBody ? { customMessage: composeForm.customBody } : undefined,
|
||||
});
|
||||
|
||||
if (res.success || res.sentCount > 0) {
|
||||
toast.success(`Sent ${res.sentCount} emails successfully`);
|
||||
if (res.failedCount > 0) {
|
||||
toast.error(`${res.failedCount} emails failed`);
|
||||
}
|
||||
if (res.success) {
|
||||
toast.success(`${res.queuedCount} email(s) are being sent in the background.`);
|
||||
clearDraft();
|
||||
setShowRecipientPreview(false);
|
||||
} else {
|
||||
toast.error('Failed to send emails');
|
||||
toast.error(res.error || 'Failed to queue emails');
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to send emails');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,14 +37,56 @@ export default function AdminLayout({
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { t, locale } = useLanguage();
|
||||
const { user, isAdmin, isLoading, logout } = useAuth();
|
||||
const { user, hasAdminAccess, isLoading, logout } = useAuth();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
type Role = 'admin' | 'organizer' | 'staff' | 'marketing';
|
||||
const userRole = (user?.role || 'user') as Role;
|
||||
|
||||
const navigationWithRoles: { name: string; href: string; icon: typeof HomeIcon; allowedRoles: Role[] }[] = [
|
||||
{ name: t('admin.nav.dashboard'), href: '/admin', icon: HomeIcon, allowedRoles: ['admin', 'organizer'] },
|
||||
{ name: t('admin.nav.events'), href: '/admin/events', icon: CalendarIcon, allowedRoles: ['admin', 'organizer', 'staff'] },
|
||||
{ name: t('admin.nav.bookings'), href: '/admin/bookings', icon: TicketIcon, allowedRoles: ['admin', 'organizer'] },
|
||||
{ name: locale === 'es' ? 'Escáner' : 'Scanner', href: '/admin/scanner', icon: QrCodeIcon, allowedRoles: ['admin', 'organizer', 'staff'] },
|
||||
{ name: t('admin.nav.users'), href: '/admin/users', icon: UsersIcon, allowedRoles: ['admin'] },
|
||||
{ name: t('admin.nav.payments'), href: '/admin/payments', icon: CreditCardIcon, allowedRoles: ['admin', 'organizer'] },
|
||||
{ name: locale === 'es' ? 'Opciones de Pago' : 'Payment Options', href: '/admin/payment-options', icon: BanknotesIcon, allowedRoles: ['admin', 'organizer'] },
|
||||
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon, allowedRoles: ['admin', 'organizer', 'marketing'] },
|
||||
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon, allowedRoles: ['admin', 'organizer'] },
|
||||
{ name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon, allowedRoles: ['admin', 'organizer'] },
|
||||
{ name: locale === 'es' ? 'Páginas Legales' : 'Legal Pages', href: '/admin/legal-pages', icon: DocumentTextIcon, allowedRoles: ['admin'] },
|
||||
{ name: 'FAQ', href: '/admin/faq', icon: QuestionMarkCircleIcon, allowedRoles: ['admin'] },
|
||||
{ name: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon, allowedRoles: ['admin'] },
|
||||
];
|
||||
|
||||
const allowedPathsForRole = new Set(
|
||||
navigationWithRoles.filter((item) => item.allowedRoles.includes(userRole)).map((item) => item.href)
|
||||
);
|
||||
const defaultAdminRoute =
|
||||
userRole === 'staff' ? '/admin/scanner' : userRole === 'marketing' ? '/admin/contacts' : '/admin';
|
||||
|
||||
// All hooks must be called unconditionally before any early returns
|
||||
useEffect(() => {
|
||||
if (!isLoading && (!user || !isAdmin)) {
|
||||
if (!isLoading && (!user || !hasAdminAccess)) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [user, isAdmin, isLoading, router]);
|
||||
}, [user, hasAdminAccess, isLoading, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !hasAdminAccess) return;
|
||||
if (!pathname.startsWith('/admin')) return;
|
||||
if (pathname === '/admin' && (userRole === 'staff' || userRole === 'marketing')) {
|
||||
router.replace(defaultAdminRoute);
|
||||
return;
|
||||
}
|
||||
const isPathAllowed = (path: string) => {
|
||||
if (allowedPathsForRole.has(path)) return true;
|
||||
return Array.from(allowedPathsForRole).some((allowed) => path.startsWith(allowed + '/'));
|
||||
};
|
||||
if (!isPathAllowed(pathname)) {
|
||||
router.replace(defaultAdminRoute);
|
||||
}
|
||||
}, [pathname, userRole, defaultAdminRoute, router, user, hasAdminAccess]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -54,31 +96,29 @@ export default function AdminLayout({
|
||||
);
|
||||
}
|
||||
|
||||
if (!user || !isAdmin) {
|
||||
if (!user || !hasAdminAccess) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
{ name: t('admin.nav.dashboard'), href: '/admin', icon: HomeIcon },
|
||||
{ name: t('admin.nav.events'), href: '/admin/events', icon: CalendarIcon },
|
||||
{ name: t('admin.nav.bookings'), href: '/admin/bookings', icon: TicketIcon },
|
||||
{ name: locale === 'es' ? 'Escáner' : 'Scanner', href: '/admin/scanner', icon: QrCodeIcon },
|
||||
{ name: t('admin.nav.users'), href: '/admin/users', icon: UsersIcon },
|
||||
{ name: t('admin.nav.payments'), href: '/admin/payments', icon: CreditCardIcon },
|
||||
{ name: locale === 'es' ? 'Opciones de Pago' : 'Payment Options', href: '/admin/payment-options', icon: BanknotesIcon },
|
||||
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon },
|
||||
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon },
|
||||
{ name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon },
|
||||
{ name: locale === 'es' ? 'Páginas Legales' : 'Legal Pages', href: '/admin/legal-pages', icon: DocumentTextIcon },
|
||||
{ name: 'FAQ', href: '/admin/faq', icon: QuestionMarkCircleIcon },
|
||||
{ name: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon },
|
||||
];
|
||||
const visibleNav = navigationWithRoles.filter((item) => item.allowedRoles.includes(userRole));
|
||||
const navigation = visibleNav;
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
// Scanner page gets fullscreen layout without sidebar
|
||||
const isScannerPage = pathname === '/admin/scanner';
|
||||
|
||||
if (isScannerPage) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-secondary-gray">
|
||||
{/* Mobile sidebar backdrop */}
|
||||
|
||||
@@ -421,6 +421,46 @@ export default function AdminLegalPagesPage() {
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Available Placeholders */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800">
|
||||
<p className="font-medium mb-2">
|
||||
{locale === 'es' ? 'Marcadores de posición disponibles:' : 'Available placeholders:'}
|
||||
</p>
|
||||
<p className="text-blue-700 mb-3">
|
||||
{locale === 'es'
|
||||
? 'Puedes usar estos marcadores en el contenido. Se reemplazarán automáticamente con los valores configurados en'
|
||||
: 'You can use these placeholders in the content. They will be automatically replaced with the values configured in'
|
||||
}
|
||||
{' '}
|
||||
<a href="/admin/settings" className="underline font-medium hover:text-blue-900">
|
||||
{locale === 'es' ? 'Configuración > Legal' : 'Settings > Legal Settings'}
|
||||
</a>.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1.5">
|
||||
{[
|
||||
{ placeholder: '{{COMPANY_NAME}}', label: locale === 'es' ? 'Nombre de la empresa' : 'Company name' },
|
||||
{ placeholder: '{{LEGAL_ENTITY_NAME}}', label: locale === 'es' ? 'Nombre de la entidad legal' : 'Legal entity name' },
|
||||
{ placeholder: '{{RUC_NUMBER}}', label: locale === 'es' ? 'Número de RUC' : 'RUC number' },
|
||||
{ placeholder: '{{COMPANY_ADDRESS}}', label: locale === 'es' ? 'Dirección de la empresa' : 'Company address' },
|
||||
{ placeholder: '{{COMPANY_CITY}}', label: locale === 'es' ? 'Ciudad' : 'City' },
|
||||
{ placeholder: '{{COMPANY_COUNTRY}}', label: locale === 'es' ? 'País' : 'Country' },
|
||||
{ placeholder: '{{SUPPORT_EMAIL}}', label: locale === 'es' ? 'Email de soporte' : 'Support email' },
|
||||
{ placeholder: '{{LEGAL_EMAIL}}', label: locale === 'es' ? 'Email legal' : 'Legal email' },
|
||||
{ placeholder: '{{GOVERNING_LAW}}', label: locale === 'es' ? 'Ley aplicable' : 'Governing law' },
|
||||
{ placeholder: '{{JURISDICTION_CITY}}', label: locale === 'es' ? 'Ciudad de jurisdicción' : 'Jurisdiction city' },
|
||||
{ placeholder: '{{CURRENT_YEAR}}', label: locale === 'es' ? 'Año actual (automático)' : 'Current year (automatic)' },
|
||||
{ placeholder: '{{LAST_UPDATED_DATE}}', label: locale === 'es' ? 'Fecha de última actualización (automático)' : 'Last updated date (automatic)' },
|
||||
].map(({ placeholder, label }) => (
|
||||
<div key={placeholder} className="flex items-center gap-2">
|
||||
<code className="bg-blue-100 text-blue-900 px-1.5 py-0.5 rounded text-xs font-mono whitespace-nowrap">
|
||||
{placeholder}
|
||||
</code>
|
||||
<span className="text-blue-700 text-xs truncate">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { siteSettingsApi, eventsApi, SiteSettings, TimezoneOption, Event } from '@/lib/api';
|
||||
import { siteSettingsApi, eventsApi, legalSettingsApi, SiteSettings, TimezoneOption, Event, LegalSettingsData } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
@@ -15,13 +15,18 @@ import {
|
||||
WrenchScrewdriverIcon,
|
||||
CheckCircleIcon,
|
||||
StarIcon,
|
||||
ScaleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
type SettingsTab = 'general' | 'legal';
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
const { t, locale } = useLanguage();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingLegal, setSavingLegal] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('general');
|
||||
const [timezones, setTimezones] = useState<TimezoneOption[]>([]);
|
||||
const [featuredEvent, setFeaturedEvent] = useState<Event | null>(null);
|
||||
const [clearingFeatured, setClearingFeatured] = useState(false);
|
||||
@@ -43,18 +48,35 @@ export default function AdminSettingsPage() {
|
||||
maintenanceMessageEs: null,
|
||||
});
|
||||
|
||||
const [legalSettings, setLegalSettings] = useState<LegalSettingsData>({
|
||||
companyName: null,
|
||||
legalEntityName: null,
|
||||
rucNumber: null,
|
||||
companyAddress: null,
|
||||
companyCity: null,
|
||||
companyCountry: null,
|
||||
supportEmail: null,
|
||||
legalEmail: null,
|
||||
governingLaw: null,
|
||||
jurisdictionCity: null,
|
||||
});
|
||||
|
||||
const [legalErrors, setLegalErrors] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [settingsRes, timezonesRes] = await Promise.all([
|
||||
const [settingsRes, timezonesRes, legalRes] = await Promise.all([
|
||||
siteSettingsApi.get(),
|
||||
siteSettingsApi.getTimezones(),
|
||||
legalSettingsApi.get().catch(() => ({ settings: {} as LegalSettingsData })),
|
||||
]);
|
||||
setSettings(settingsRes.settings);
|
||||
setTimezones(timezonesRes.timezones);
|
||||
setLegalSettings(legalRes.settings);
|
||||
|
||||
// Load featured event details if one is set
|
||||
if (settingsRes.settings.featuredEventId) {
|
||||
@@ -100,10 +122,53 @@ export default function AdminSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const validateLegalSettings = (): boolean => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
// Validate email formats if provided
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (legalSettings.supportEmail && !emailRegex.test(legalSettings.supportEmail)) {
|
||||
errors.supportEmail = locale === 'es' ? 'Email no válido' : 'Invalid email address';
|
||||
}
|
||||
if (legalSettings.legalEmail && !emailRegex.test(legalSettings.legalEmail)) {
|
||||
errors.legalEmail = locale === 'es' ? 'Email no válido' : 'Invalid email address';
|
||||
}
|
||||
|
||||
setLegalErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleSaveLegal = async () => {
|
||||
if (!validateLegalSettings()) return;
|
||||
|
||||
setSavingLegal(true);
|
||||
try {
|
||||
const response = await legalSettingsApi.update(legalSettings);
|
||||
setLegalSettings(response.settings);
|
||||
toast.success(locale === 'es' ? 'Configuración legal guardada' : 'Legal settings saved');
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to save legal settings');
|
||||
} finally {
|
||||
setSavingLegal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSetting = <K extends keyof SiteSettings>(key: K, value: SiteSettings[K]) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const updateLegalSetting = <K extends keyof LegalSettingsData>(key: K, value: LegalSettingsData[K]) => {
|
||||
setLegalSettings((prev) => ({ ...prev, [key]: value }));
|
||||
// Clear error for this field when user types
|
||||
if (legalErrors[key]) {
|
||||
setLegalErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
@@ -112,6 +177,21 @@ export default function AdminSettingsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const tabs: { id: SettingsTab; label: string; labelEs: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
id: 'general',
|
||||
label: 'General Settings',
|
||||
labelEs: 'Configuración General',
|
||||
icon: <Cog6ToothIcon className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
id: 'legal',
|
||||
label: 'Legal Settings',
|
||||
labelEs: 'Configuración Legal',
|
||||
icon: <ScaleIcon className="w-4 h-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
@@ -126,12 +206,42 @@ export default function AdminSettingsPage() {
|
||||
: 'Configure general website settings'}
|
||||
</p>
|
||||
</div>
|
||||
{activeTab === 'general' && (
|
||||
<Button onClick={handleSave} isLoading={saving}>
|
||||
<CheckCircleIcon className="w-5 h-5 mr-2" />
|
||||
{locale === 'es' ? 'Guardar Cambios' : 'Save Changes'}
|
||||
</Button>
|
||||
)}
|
||||
{activeTab === 'legal' && (
|
||||
<Button onClick={handleSaveLegal} isLoading={savingLegal}>
|
||||
<CheckCircleIcon className="w-5 h-5 mr-2" />
|
||||
{locale === 'es' ? 'Guardar Cambios' : 'Save Changes'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-secondary-light-gray mb-6">
|
||||
<nav className="flex space-x-0" aria-label="Tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-3 border-b-2 text-sm font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary-yellow text-primary-dark'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{locale === 'es' ? tab.labelEs : tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* General Settings Tab */}
|
||||
{activeTab === 'general' && (
|
||||
<div className="space-y-6">
|
||||
{/* Timezone Settings */}
|
||||
<Card>
|
||||
@@ -489,6 +599,196 @@ export default function AdminSettingsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legal Settings Tab */}
|
||||
{activeTab === 'legal' && (
|
||||
<div className="space-y-6">
|
||||
{/* Info Banner */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<p className="text-blue-800 text-sm">
|
||||
<span className="font-medium">
|
||||
{locale === 'es' ? 'Nota:' : 'Note:'}
|
||||
</span>{' '}
|
||||
{locale === 'es'
|
||||
? 'Estos valores se usan como marcadores de posición en las páginas legales. Los marcadores como {{COMPANY_NAME}} se reemplazan automáticamente con los valores configurados aquí.'
|
||||
: 'These values are used as placeholders in legal pages. Placeholders like {{COMPANY_NAME}} are automatically replaced with the values configured here.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Company Information */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 bg-indigo-100 rounded-full flex items-center justify-center">
|
||||
<ScaleIcon className="w-5 h-5 text-indigo-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">
|
||||
{locale === 'es' ? 'Información de la Empresa' : 'Company Information'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{locale === 'es'
|
||||
? 'Datos legales de la empresa que aparecerán en las páginas legales'
|
||||
: 'Legal company details that will appear in legal pages'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label={locale === 'es' ? 'Nombre de la Empresa' : 'Company Name'}
|
||||
value={legalSettings.companyName || ''}
|
||||
onChange={(e) => updateLegalSetting('companyName', e.target.value || null)}
|
||||
placeholder={locale === 'es' ? 'Ej: Spanglish S.A.' : 'e.g. Spanglish S.A.'}
|
||||
/>
|
||||
<Input
|
||||
label={locale === 'es' ? 'Nombre de la Entidad Legal' : 'Legal Entity Name'}
|
||||
value={legalSettings.legalEntityName || ''}
|
||||
onChange={(e) => updateLegalSetting('legalEntityName', e.target.value || null)}
|
||||
placeholder={locale === 'es' ? 'Ej: Spanglish S.A.' : 'e.g. Spanglish S.A.'}
|
||||
/>
|
||||
<Input
|
||||
label={locale === 'es' ? 'Número de RUC' : 'RUC Number'}
|
||||
value={legalSettings.rucNumber || ''}
|
||||
onChange={(e) => updateLegalSetting('rucNumber', e.target.value || null)}
|
||||
placeholder={locale === 'es' ? 'Ej: 80012345-6' : 'e.g. 80012345-6'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Address & Location */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 bg-teal-100 rounded-full flex items-center justify-center">
|
||||
<GlobeAltIcon className="w-5 h-5 text-teal-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">
|
||||
{locale === 'es' ? 'Dirección y Ubicación' : 'Address & Location'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{locale === 'es'
|
||||
? 'Dirección física y jurisdicción legal'
|
||||
: 'Physical address and legal jurisdiction'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="max-w-lg">
|
||||
<Input
|
||||
label={locale === 'es' ? 'Dirección de la Empresa' : 'Company Address'}
|
||||
value={legalSettings.companyAddress || ''}
|
||||
onChange={(e) => updateLegalSetting('companyAddress', e.target.value || null)}
|
||||
placeholder={locale === 'es' ? 'Ej: Av. Mariscal López 1234' : 'e.g. 1234 Main Street'}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label={locale === 'es' ? 'Ciudad' : 'City'}
|
||||
value={legalSettings.companyCity || ''}
|
||||
onChange={(e) => updateLegalSetting('companyCity', e.target.value || null)}
|
||||
placeholder={locale === 'es' ? 'Ej: Asunción' : 'e.g. Asunción'}
|
||||
/>
|
||||
<Input
|
||||
label={locale === 'es' ? 'País' : 'Country'}
|
||||
value={legalSettings.companyCountry || ''}
|
||||
onChange={(e) => updateLegalSetting('companyCountry', e.target.value || null)}
|
||||
placeholder={locale === 'es' ? 'Ej: Paraguay' : 'e.g. Paraguay'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Contact Emails */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 bg-rose-100 rounded-full flex items-center justify-center">
|
||||
<EnvelopeIcon className="w-5 h-5 text-rose-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">
|
||||
{locale === 'es' ? 'Emails Legales' : 'Legal Emails'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{locale === 'es'
|
||||
? 'Direcciones de email para asuntos legales y soporte'
|
||||
: 'Email addresses for legal matters and support'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label={locale === 'es' ? 'Email de Soporte' : 'Support Email'}
|
||||
type="email"
|
||||
value={legalSettings.supportEmail || ''}
|
||||
onChange={(e) => updateLegalSetting('supportEmail', e.target.value || null)}
|
||||
placeholder="support@example.com"
|
||||
error={legalErrors.supportEmail}
|
||||
/>
|
||||
<Input
|
||||
label={locale === 'es' ? 'Email Legal' : 'Legal Email'}
|
||||
type="email"
|
||||
value={legalSettings.legalEmail || ''}
|
||||
onChange={(e) => updateLegalSetting('legalEmail', e.target.value || null)}
|
||||
placeholder="legal@example.com"
|
||||
error={legalErrors.legalEmail}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Legal Jurisdiction */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 bg-amber-100 rounded-full flex items-center justify-center">
|
||||
<ScaleIcon className="w-5 h-5 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">
|
||||
{locale === 'es' ? 'Jurisdicción Legal' : 'Legal Jurisdiction'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{locale === 'es'
|
||||
? 'Ley aplicable y jurisdicción para las páginas legales'
|
||||
: 'Governing law and jurisdiction for legal pages'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label={locale === 'es' ? 'Ley Aplicable' : 'Governing Law'}
|
||||
value={legalSettings.governingLaw || ''}
|
||||
onChange={(e) => updateLegalSetting('governingLaw', e.target.value || null)}
|
||||
placeholder={locale === 'es' ? 'Ej: las leyes de la República del Paraguay' : 'e.g. the laws of the Republic of Paraguay'}
|
||||
/>
|
||||
<Input
|
||||
label={locale === 'es' ? 'Ciudad de Jurisdicción' : 'Jurisdiction City'}
|
||||
value={legalSettings.jurisdictionCity || ''}
|
||||
onChange={(e) => updateLegalSetting('jurisdictionCity', e.target.value || null)}
|
||||
placeholder={locale === 'es' ? 'Ej: Asunción' : 'e.g. Asunción'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Save Button at Bottom */}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSaveLegal} isLoading={savingLegal} size="lg">
|
||||
<CheckCircleIcon className="w-5 h-5 mr-2" />
|
||||
{locale === 'es' ? 'Guardar Configuración Legal' : 'Save Legal Settings'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ interface LlmsEvent {
|
||||
async function getNextUpcomingEvent(): Promise<LlmsEvent | null> {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/events/next/upcoming`, {
|
||||
next: { tags: ['next-event'] },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
@@ -41,7 +41,7 @@ async function getNextUpcomingEvent(): Promise<LlmsEvent | null> {
|
||||
async function getUpcomingEvents(): Promise<LlmsEvent[]> {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/events?status=published&upcoming=true`, {
|
||||
next: { tags: ['next-event'] },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
@@ -115,7 +115,7 @@ function getEventStatus(event: LlmsEvent): string {
|
||||
async function getHomepageFaqs(): Promise<LlmsFaq[]> {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/faq?homepage=true`, {
|
||||
next: { revalidate: 3600 },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
@@ -128,6 +128,8 @@ async function getHomepageFaqs(): Promise<LlmsFaq[]> {
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
const [nextEvent, upcomingEvents, faqs] = await Promise.all([
|
||||
getNextUpcomingEvent(),
|
||||
|
||||
@@ -43,7 +43,7 @@ function MobileNavLink({ href, children, onClick }: { href: string; children: Re
|
||||
|
||||
export default function Header() {
|
||||
const { t } = useLanguage();
|
||||
const { user, isAdmin, logout } = useAuth();
|
||||
const { user, hasAdminAccess, logout } = useAuth();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const touchStartX = useRef<number>(0);
|
||||
@@ -148,7 +148,7 @@ export default function Header() {
|
||||
{t('nav.dashboard')}
|
||||
</Button>
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
{hasAdminAccess && (
|
||||
<Link href="/admin">
|
||||
<Button variant="ghost" size="sm">
|
||||
{t('nav.admin')}
|
||||
@@ -270,7 +270,7 @@ export default function Header() {
|
||||
{t('nav.dashboard')}
|
||||
</Button>
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
{hasAdminAccess && (
|
||||
<Link href="/admin" onClick={closeMenu}>
|
||||
<Button variant="outline" className="w-full justify-center">
|
||||
{t('nav.admin')}
|
||||
|
||||
@@ -21,6 +21,7 @@ interface AuthContextType {
|
||||
token: string | null;
|
||||
isLoading: boolean;
|
||||
isAdmin: boolean;
|
||||
hasAdminAccess: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
loginWithGoogle: (credential: string) => Promise<void>;
|
||||
loginWithMagicLink: (token: string) => Promise<void>;
|
||||
@@ -177,6 +178,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const isAdmin = user?.role === 'admin' || user?.role === 'organizer';
|
||||
const hasAdminAccess = user?.role === 'admin' || user?.role === 'organizer' || user?.role === 'staff' || user?.role === 'marketing';
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
@@ -185,6 +187,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
token,
|
||||
isLoading,
|
||||
isAdmin,
|
||||
hasAdminAccess,
|
||||
login,
|
||||
loginWithGoogle,
|
||||
loginWithMagicLink,
|
||||
|
||||
@@ -93,6 +93,27 @@ export const ticketsApi = {
|
||||
body: JSON.stringify({ code, eventId }),
|
||||
}),
|
||||
|
||||
// Search tickets by name/email (for scanner manual search)
|
||||
search: (query: string, eventId?: string) =>
|
||||
fetchApi<{ tickets: TicketSearchResult[] }>('/api/tickets/search', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ query, eventId }),
|
||||
}),
|
||||
|
||||
// Get event check-in stats (for scanner header counter)
|
||||
getCheckinStats: (eventId: string) =>
|
||||
fetchApi<{ eventId: string; capacity: number; checkedIn: number; totalActive: number }>(
|
||||
`/api/tickets/stats/checkin?eventId=${eventId}`
|
||||
),
|
||||
|
||||
// Live search tickets (GET - for scanner live search with debounce)
|
||||
searchLive: (q: string, eventId?: string) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('q', q);
|
||||
if (eventId) params.set('eventId', eventId);
|
||||
return fetchApi<{ tickets: LiveSearchResult[] }>(`/api/tickets/search?${params}`);
|
||||
},
|
||||
|
||||
checkin: (id: string) =>
|
||||
fetchApi<{ ticket: Ticket & { attendeeName?: string }; event?: { id: string; title: string }; message: string }>(`/api/tickets/${id}/checkin`, {
|
||||
method: 'POST',
|
||||
@@ -351,6 +372,49 @@ export const adminApi = {
|
||||
if (params?.eventId) query.set('eventId', params.eventId);
|
||||
return fetchApi<{ payments: ExportedPayment[]; summary: FinancialSummary }>(`/api/admin/export/financial?${query}`);
|
||||
},
|
||||
/** Download attendee export as a file (CSV). Returns a Blob. */
|
||||
exportAttendees: async (eventId: string, params?: { status?: string; format?: string; q?: string }) => {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.status) query.set('status', params.status);
|
||||
if (params?.format) query.set('format', params.format);
|
||||
if (params?.q) query.set('q', params.q);
|
||||
const token = typeof window !== 'undefined'
|
||||
? localStorage.getItem('spanglish-token')
|
||||
: null;
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await fetch(`${API_BASE}/api/admin/events/${eventId}/attendees/export?${query}`, { headers });
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({ error: 'Export failed' }));
|
||||
throw new Error(errorData.error || 'Export failed');
|
||||
}
|
||||
const disposition = res.headers.get('Content-Disposition') || '';
|
||||
const filenameMatch = disposition.match(/filename="?([^"]+)"?/);
|
||||
const filename = filenameMatch ? filenameMatch[1] : `attendees-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
const blob = await res.blob();
|
||||
return { blob, filename };
|
||||
},
|
||||
/** Download tickets export as CSV. Returns a Blob. */
|
||||
exportTicketsCSV: async (eventId: string, params?: { status?: string; q?: string }) => {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.status) query.set('status', params.status);
|
||||
if (params?.q) query.set('q', params.q);
|
||||
const token = typeof window !== 'undefined'
|
||||
? localStorage.getItem('spanglish-token')
|
||||
: null;
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await fetch(`${API_BASE}/api/admin/events/${eventId}/tickets/export?${query}`, { headers });
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({ error: 'Export failed' }));
|
||||
throw new Error(errorData.error || 'Export failed');
|
||||
}
|
||||
const disposition = res.headers.get('Content-Disposition') || '';
|
||||
const filenameMatch = disposition.match(/filename="?([^"]+)"?/);
|
||||
const filename = filenameMatch ? filenameMatch[1] : `tickets-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
const blob = await res.blob();
|
||||
return { blob, filename };
|
||||
},
|
||||
};
|
||||
|
||||
// Emails API
|
||||
@@ -384,7 +448,7 @@ export const emailsApi = {
|
||||
customVariables?: Record<string, any>;
|
||||
recipientFilter?: 'all' | 'confirmed' | 'pending' | 'checked_in';
|
||||
}) =>
|
||||
fetchApi<{ success: boolean; sentCount: number; failedCount: number; errors: string[] }>(
|
||||
fetchApi<{ success: boolean; queuedCount: number; error?: string }>(
|
||||
`/api/emails/send/event/${eventId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -508,6 +572,39 @@ export interface TicketValidationResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface TicketSearchResult {
|
||||
id: string;
|
||||
qrCode: string;
|
||||
attendeeName: string;
|
||||
attendeeEmail?: string;
|
||||
attendeePhone?: string;
|
||||
status: string;
|
||||
checkinAt?: string;
|
||||
event?: {
|
||||
id: string;
|
||||
title: string;
|
||||
startDatetime: string;
|
||||
location: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface LiveSearchResult {
|
||||
ticket_id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
status: string;
|
||||
checked_in: boolean;
|
||||
checkinAt?: string;
|
||||
event_id: string;
|
||||
qrCode: string;
|
||||
event?: {
|
||||
id: string;
|
||||
title: string;
|
||||
startDatetime: string;
|
||||
location: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface Payment {
|
||||
id: string;
|
||||
ticketId: string;
|
||||
@@ -1008,6 +1105,34 @@ export const siteSettingsApi = {
|
||||
}),
|
||||
};
|
||||
|
||||
// ==================== Legal Settings API ====================
|
||||
|
||||
export interface LegalSettingsData {
|
||||
id?: string;
|
||||
companyName?: string | null;
|
||||
legalEntityName?: string | null;
|
||||
rucNumber?: string | null;
|
||||
companyAddress?: string | null;
|
||||
companyCity?: string | null;
|
||||
companyCountry?: string | null;
|
||||
supportEmail?: string | null;
|
||||
legalEmail?: string | null;
|
||||
governingLaw?: string | null;
|
||||
jurisdictionCity?: string | null;
|
||||
updatedAt?: string;
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export const legalSettingsApi = {
|
||||
get: () => fetchApi<{ settings: LegalSettingsData }>('/api/legal-settings'),
|
||||
|
||||
update: (data: Partial<LegalSettingsData>) =>
|
||||
fetchApi<{ settings: LegalSettingsData; message: string }>('/api/legal-settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
};
|
||||
|
||||
// ==================== Legal Pages Types ====================
|
||||
|
||||
export interface LegalPage {
|
||||
|
||||
@@ -102,7 +102,7 @@ export function getLegalPageFromFilesystem(slug: string, locale: string = 'en'):
|
||||
|
||||
// Get a specific legal page content - tries API first, falls back to filesystem
|
||||
export async function getLegalPageAsync(slug: string, locale: string = 'en'): Promise<LegalPage | null> {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
// Try to fetch from API with locale parameter
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user