Email queue + async sending; legal settings and placeholders #6

Merged
Michilis merged 1 commits from dev into main 2026-02-12 21:04:59 +00:00
17 changed files with 1410 additions and 352 deletions

View File

@@ -67,3 +67,8 @@ SMTP_TLS_REJECT_UNAUTHORIZED=true
# SendGrid: SMTP_HOST=smtp.sendgrid.net, SMTP_PORT=587, SMTP_USER=apikey, SMTP_PASS=your_api_key # 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 # Mailgun: SMTP_HOST=smtp.mailgun.org, SMTP_PORT=587
# Amazon SES: SMTP_HOST=email-smtp.us-east-1.amazonaws.com, 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

View File

@@ -437,6 +437,25 @@ async function migrate() {
updated_at TEXT NOT NULL 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 { } else {
// PostgreSQL migrations // PostgreSQL migrations
await (db as any).execute(sql` await (db as any).execute(sql`
@@ -822,6 +841,25 @@ async function migrate() {
updated_at TIMESTAMP NOT NULL 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!'); console.log('Migrations completed successfully!');

View File

@@ -281,6 +281,23 @@ export const sqliteFaqQuestions = sqliteTable('faq_questions', {
updatedAt: text('updated_at').notNull(), 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 // Site Settings table for global website configuration
export const sqliteSiteSettings = sqliteTable('site_settings', { export const sqliteSiteSettings = sqliteTable('site_settings', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
@@ -578,6 +595,23 @@ export const pgFaqQuestions = pgTable('faq_questions', {
updatedAt: timestamp('updated_at').notNull(), 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 // Site Settings table for global website configuration
export const pgSiteSettings = pgTable('site_settings', { export const pgSiteSettings = pgTable('site_settings', {
id: uuid('id').primaryKey(), id: uuid('id').primaryKey(),
@@ -623,6 +657,7 @@ export const eventPaymentOverrides = dbType === 'postgres' ? pgEventPaymentOverr
export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqliteMagicLinkTokens; export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqliteMagicLinkTokens;
export const userSessions = dbType === 'postgres' ? pgUserSessions : sqliteUserSessions; export const userSessions = dbType === 'postgres' ? pgUserSessions : sqliteUserSessions;
export const invoices = dbType === 'postgres' ? pgInvoices : sqliteInvoices; export const invoices = dbType === 'postgres' ? pgInvoices : sqliteInvoices;
export const legalSettings = dbType === 'postgres' ? pgLegalSettings : sqliteLegalSettings;
export const siteSettings = dbType === 'postgres' ? pgSiteSettings : sqliteSiteSettings; export const siteSettings = dbType === 'postgres' ? pgSiteSettings : sqliteSiteSettings;
export const legalPages = dbType === 'postgres' ? pgLegalPages : sqliteLegalPages; export const legalPages = dbType === 'postgres' ? pgLegalPages : sqliteLegalPages;
export const faqQuestions = dbType === 'postgres' ? pgFaqQuestions : sqliteFaqQuestions; 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 NewLegalPage = typeof sqliteLegalPages.$inferInsert;
export type FaqQuestion = typeof sqliteFaqQuestions.$inferSelect; export type FaqQuestion = typeof sqliteFaqQuestions.$inferSelect;
export type NewFaqQuestion = typeof sqliteFaqQuestions.$inferInsert; export type NewFaqQuestion = typeof sqliteFaqQuestions.$inferInsert;
export type LegalSettings = typeof sqliteLegalSettings.$inferSelect;
export type NewLegalSettings = typeof sqliteLegalSettings.$inferInsert;

View File

@@ -21,8 +21,10 @@ import paymentOptionsRoutes from './routes/payment-options.js';
import dashboardRoutes from './routes/dashboard.js'; import dashboardRoutes from './routes/dashboard.js';
import siteSettingsRoutes from './routes/site-settings.js'; import siteSettingsRoutes from './routes/site-settings.js';
import legalPagesRoutes from './routes/legal-pages.js'; import legalPagesRoutes from './routes/legal-pages.js';
import legalSettingsRoutes from './routes/legal-settings.js';
import faqRoutes from './routes/faq.js'; import faqRoutes from './routes/faq.js';
import emailService from './lib/email.js'; import emailService from './lib/email.js';
import { initEmailQueue } from './lib/emailQueue.js';
const app = new Hono(); const app = new Hono();
@@ -1856,6 +1858,7 @@ app.route('/api/payment-options', paymentOptionsRoutes);
app.route('/api/dashboard', dashboardRoutes); app.route('/api/dashboard', dashboardRoutes);
app.route('/api/site-settings', siteSettingsRoutes); app.route('/api/site-settings', siteSettingsRoutes);
app.route('/api/legal-pages', legalPagesRoutes); app.route('/api/legal-pages', legalPagesRoutes);
app.route('/api/legal-settings', legalSettingsRoutes);
app.route('/api/faq', faqRoutes); app.route('/api/faq', faqRoutes);
// 404 handler // 404 handler
@@ -1871,6 +1874,9 @@ app.onError((err, c) => {
const port = parseInt(process.env.PORT || '3001'); const port = parseInt(process.env.PORT || '3001');
// Initialize email queue with the email service reference
initEmailQueue(emailService);
// Initialize email templates on startup // Initialize email templates on startup
emailService.seedDefaultTemplates().catch(err => { emailService.seedDefaultTemplates().catch(err => {
console.error('[Email] Failed to seed templates:', err); console.error('[Email] Failed to seed templates:', err);

View File

@@ -10,6 +10,7 @@ import {
defaultTemplates, defaultTemplates,
type DefaultTemplate type DefaultTemplate
} from './emailTemplates.js'; } from './emailTemplates.js';
import { enqueueBulkEmails, type TemplateEmailJobParams } from './emailQueue.js';
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
import type { Transporter } 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) * Send a custom email (not from template)
*/ */
@@ -1183,10 +1278,11 @@ export const emailService = {
subject: string; subject: string;
bodyHtml: string; bodyHtml: string;
bodyText?: string; bodyText?: string;
replyTo?: string;
eventId?: string; eventId?: string;
sentBy: string; sentBy?: string | null;
}): Promise<{ success: boolean; logId?: string; error?: string }> { }): 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 = { const allVariables = {
...this.getCommonVariables(), ...this.getCommonVariables(),
@@ -1208,7 +1304,7 @@ export const emailService = {
subject, subject,
bodyHtml: finalBodyHtml, bodyHtml: finalBodyHtml,
status: 'pending', status: 'pending',
sentBy, sentBy: sentBy || null,
createdAt: now, createdAt: now,
}); });
@@ -1218,6 +1314,7 @@ export const emailService = {
subject, subject,
html: finalBodyHtml, html: finalBodyHtml,
text: bodyText, text: bodyText,
replyTo,
}); });
// Update log // Update log

View 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.`);
}

View 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] ?? '';
});
}

View File

@@ -1,13 +1,37 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { z } from 'zod'; 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 { eq, desc } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js'; import { generateId, getNow } from '../lib/utils.js';
import { emailService } from '../lib/email.js';
const contactsRouter = new Hono(); 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
/**
* 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({ const createContactSchema = z.object({
name: z.string().min(2), name: z.string().min(2),
email: z.string().email(), email: z.string().email(),
@@ -29,17 +53,74 @@ contactsRouter.post('/', zValidator('json', createContactSchema), async (c) => {
const now = getNow(); const now = getNow();
const id = generateId(); const id = generateId();
// Sanitize header-sensitive values to prevent email header injection
const sanitizedEmail = sanitizeHeaderValue(data.email);
const sanitizedName = sanitizeHeaderValue(data.name);
const newContact = { const newContact = {
id, id,
name: data.name, name: sanitizedName,
email: data.email, email: sanitizedEmail,
message: data.message, message: data.message,
status: 'new' as const, status: 'new' as const,
createdAt: now, createdAt: now,
}; };
// Always store the message in admin, regardless of email outcome
await (db as any).insert(contacts).values(newContact); 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); return c.json({ message: 'Message sent successfully' }, 201);
}); });

View File

@@ -5,6 +5,7 @@ import { requireAuth } from '../lib/auth.js';
import { getNow, generateId } from '../lib/utils.js'; import { getNow, generateId } from '../lib/utils.js';
import emailService from '../lib/email.js'; import emailService from '../lib/email.js';
import { getTemplateVariables, defaultTemplates } from '../lib/emailTemplates.js'; import { getTemplateVariables, defaultTemplates } from '../lib/emailTemplates.js';
import { getQueueStatus } from '../lib/emailQueue.js';
const emailsRouter = new Hono(); const emailsRouter = new Hono();
@@ -195,7 +196,7 @@ emailsRouter.get('/templates/:slug/variables', requireAuth(['admin', 'organizer'
// ==================== Email Sending Routes ==================== // ==================== 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) => { emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), async (c) => {
const { eventId } = c.req.param(); const { eventId } = c.req.param();
const user = (c as any).get('user'); 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); 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, eventId,
templateSlug, templateSlug,
customVariables, customVariables,
@@ -411,4 +413,10 @@ emailsRouter.post('/test', requireAuth(['admin']), async (c) => {
return c.json(result); 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; export default emailsRouter;

View File

@@ -3,6 +3,7 @@ import { db, dbGet, dbAll, legalPages } from '../db/index.js';
import { eq, desc } from 'drizzle-orm'; import { eq, desc } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { getNow, generateId } from '../lib/utils.js'; import { getNow, generateId } from '../lib/utils.js';
import { replaceLegalPlaceholders } from '../lib/legal-placeholders.js';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
@@ -171,12 +172,15 @@ legalPagesRouter.get('/:slug', async (c) => {
// Get localized content with fallback // Get localized content with fallback
const { title, contentMarkdown } = getLocalizedContent(page, locale); const { title, contentMarkdown } = getLocalizedContent(page, locale);
// Replace legal placeholders before returning
const processedContent = await replaceLegalPlaceholders(contentMarkdown, page.updatedAt);
return c.json({ return c.json({
page: { page: {
id: page.id, id: page.id,
slug: page.slug, slug: page.slug,
title, title,
contentMarkdown, contentMarkdown: processedContent,
updatedAt: page.updatedAt, updatedAt: page.updatedAt,
source: 'database', source: 'database',
} }
@@ -195,11 +199,14 @@ legalPagesRouter.get('/:slug', async (c) => {
? (titles?.es || titles?.en || slug) ? (titles?.es || titles?.en || slug)
: (titles?.en || titles?.es || slug); : (titles?.en || titles?.es || slug);
// Replace legal placeholders in filesystem content too
const processedContent = await replaceLegalPlaceholders(content);
return c.json({ return c.json({
page: { page: {
slug, slug,
title, title,
contentMarkdown: content, contentMarkdown: processedContent,
source: 'filesystem', source: 'filesystem',
} }
}); });

View 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;

View File

@@ -189,7 +189,6 @@ export default function AdminEmailsPage() {
return; return;
} }
setSending(true);
try { try {
const res = await emailsApi.sendToEvent(composeForm.eventId, { const res = await emailsApi.sendToEvent(composeForm.eventId, {
templateSlug: composeForm.templateSlug, templateSlug: composeForm.templateSlug,
@@ -197,20 +196,15 @@ export default function AdminEmailsPage() {
customVariables: composeForm.customBody ? { customMessage: composeForm.customBody } : undefined, customVariables: composeForm.customBody ? { customMessage: composeForm.customBody } : undefined,
}); });
if (res.success || res.sentCount > 0) { if (res.success) {
toast.success(`Sent ${res.sentCount} emails successfully`); toast.success(`${res.queuedCount} email(s) are being sent in the background.`);
if (res.failedCount > 0) {
toast.error(`${res.failedCount} emails failed`);
}
clearDraft(); clearDraft();
setShowRecipientPreview(false); setShowRecipientPreview(false);
} else { } else {
toast.error('Failed to send emails'); toast.error(res.error || 'Failed to queue emails');
} }
} catch (error: any) { } catch (error: any) {
toast.error(error.message || 'Failed to send emails'); toast.error(error.message || 'Failed to send emails');
} finally {
setSending(false);
} }
}; };

View File

@@ -423,7 +423,6 @@ export default function AdminEventDetailPage() {
return; return;
} }
setSending(true);
try { try {
const res = await emailsApi.sendToEvent(eventId, { const res = await emailsApi.sendToEvent(eventId, {
templateSlug: selectedTemplate, templateSlug: selectedTemplate,
@@ -432,14 +431,12 @@ export default function AdminEventDetailPage() {
}); });
if (res.success) { if (res.success) {
toast.success(`Email sent to ${res.sentCount} recipients`); toast.success(`${res.queuedCount} email(s) are being sent in the background.`);
} else { } else {
toast.error(`Sent: ${res.sentCount}, Failed: ${res.failedCount}`); toast.error(res.error || 'Failed to queue emails');
} }
} catch (error: any) { } catch (error: any) {
toast.error(error.message || 'Failed to send emails'); toast.error(error.message || 'Failed to send emails');
} finally {
setSending(false);
} }
}; };

View File

@@ -421,6 +421,46 @@ export default function AdminLegalPagesPage() {
</li> </li>
</ul> </ul>
</div> </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> </div>
</Card> </Card>
</div> </div>

View File

@@ -3,7 +3,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext'; 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 Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
@@ -15,13 +15,18 @@ import {
WrenchScrewdriverIcon, WrenchScrewdriverIcon,
CheckCircleIcon, CheckCircleIcon,
StarIcon, StarIcon,
ScaleIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
type SettingsTab = 'general' | 'legal';
export default function AdminSettingsPage() { export default function AdminSettingsPage() {
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [savingLegal, setSavingLegal] = useState(false);
const [activeTab, setActiveTab] = useState<SettingsTab>('general');
const [timezones, setTimezones] = useState<TimezoneOption[]>([]); const [timezones, setTimezones] = useState<TimezoneOption[]>([]);
const [featuredEvent, setFeaturedEvent] = useState<Event | null>(null); const [featuredEvent, setFeaturedEvent] = useState<Event | null>(null);
const [clearingFeatured, setClearingFeatured] = useState(false); const [clearingFeatured, setClearingFeatured] = useState(false);
@@ -43,18 +48,35 @@ export default function AdminSettingsPage() {
maintenanceMessageEs: null, 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(() => { useEffect(() => {
loadData(); loadData();
}, []); }, []);
const loadData = async () => { const loadData = async () => {
try { try {
const [settingsRes, timezonesRes] = await Promise.all([ const [settingsRes, timezonesRes, legalRes] = await Promise.all([
siteSettingsApi.get(), siteSettingsApi.get(),
siteSettingsApi.getTimezones(), siteSettingsApi.getTimezones(),
legalSettingsApi.get().catch(() => ({ settings: {} as LegalSettingsData })),
]); ]);
setSettings(settingsRes.settings); setSettings(settingsRes.settings);
setTimezones(timezonesRes.timezones); setTimezones(timezonesRes.timezones);
setLegalSettings(legalRes.settings);
// Load featured event details if one is set // Load featured event details if one is set
if (settingsRes.settings.featuredEventId) { 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]) => { const updateSetting = <K extends keyof SiteSettings>(key: K, value: SiteSettings[K]) => {
setSettings((prev) => ({ ...prev, [key]: value })); 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) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12"> <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 ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
@@ -126,12 +206,42 @@ export default function AdminSettingsPage() {
: 'Configure general website settings'} : 'Configure general website settings'}
</p> </p>
</div> </div>
{activeTab === 'general' && (
<Button onClick={handleSave} isLoading={saving}> <Button onClick={handleSave} isLoading={saving}>
<CheckCircleIcon className="w-5 h-5 mr-2" /> <CheckCircleIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Guardar Cambios' : 'Save Changes'} {locale === 'es' ? 'Guardar Cambios' : 'Save Changes'}
</Button> </Button>
)}
{activeTab === 'legal' && (
<Button onClick={handleSaveLegal} isLoading={savingLegal}>
<CheckCircleIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Guardar Cambios' : 'Save Changes'}
</Button>
)}
</div> </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"> <div className="space-y-6">
{/* Timezone Settings */} {/* Timezone Settings */}
<Card> <Card>
@@ -489,6 +599,196 @@ export default function AdminSettingsPage() {
</Button> </Button>
</div> </div>
</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> </div>
); );
} }

View File

@@ -384,7 +384,7 @@ export const emailsApi = {
customVariables?: Record<string, any>; customVariables?: Record<string, any>;
recipientFilter?: 'all' | 'confirmed' | 'pending' | 'checked_in'; 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}`, `/api/emails/send/event/${eventId}`,
{ {
method: 'POST', method: 'POST',
@@ -1008,6 +1008,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 ==================== // ==================== Legal Pages Types ====================
export interface LegalPage { export interface LegalPage {

View File

@@ -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 // 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> { 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 to fetch from API with locale parameter
try { try {