Compare commits
20 Commits
ba1975dd6d
...
backup5
| Author | SHA1 | Date | |
|---|---|---|---|
| 15655e3987 | |||
|
|
5263fa6834 | ||
|
|
923c86a3b3 | ||
| d8b3864411 | |||
|
|
4aaffe99c7 | ||
| 194cbd6ca8 | |||
|
|
a11da5a977 | ||
| d5445c2282 | |||
|
|
6bc7e13e78 | ||
| dcfefc8371 | |||
|
|
c3897efd02 | ||
| b5f14335c4 | |||
|
|
62bf048680 | ||
| d44ac949b5 | |||
|
|
b9f46b02cc | ||
| a5e939221d | |||
|
|
18254c566e | ||
|
|
95ee5a5dec | ||
| 833e3e5a9c | |||
|
|
77e92e5d96 |
@@ -19,7 +19,7 @@ GOOGLE_CLIENT_ID=
|
|||||||
# Server Configuration
|
# Server Configuration
|
||||||
PORT=3001
|
PORT=3001
|
||||||
API_URL=http://localhost: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)
|
# Revalidation secret (shared with frontend for on-demand cache revalidation)
|
||||||
# Must match the REVALIDATE_SECRET in frontend/.env
|
# 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
|
# 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
|
||||||
|
|
||||||
|
|||||||
@@ -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!');
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -1173,6 +1174,100 @@ export const emailService = {
|
|||||||
errors,
|
errors,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
|||||||
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 { Hono } from 'hono';
|
||||||
import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js';
|
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 { requireAuth } from '../lib/auth.js';
|
||||||
import { getNow } from '../lib/utils.js';
|
import { getNow } from '../lib/utils.js';
|
||||||
|
|
||||||
@@ -222,6 +222,211 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
|
|||||||
return c.json({ tickets: enrichedTickets });
|
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)
|
// Export financial data (admin)
|
||||||
adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => {
|
adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => {
|
||||||
const startDate = c.req.query('startDate');
|
const startDate = c.req.query('startDate');
|
||||||
|
|||||||
@@ -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, '&')
|
||||||
|
.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({
|
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { db, dbGet, dbAll, events, tickets, payments, eventPaymentOverrides, ema
|
|||||||
import { eq, desc, and, gte, sql } from 'drizzle-orm';
|
import { eq, desc, and, gte, sql } from 'drizzle-orm';
|
||||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||||
import { generateId, getNow, convertBooleansForDb, toDbDate, calculateAvailableSeats } from '../lib/utils.js';
|
import { generateId, getNow, convertBooleansForDb, toDbDate, calculateAvailableSeats } from '../lib/utils.js';
|
||||||
|
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||||
|
|
||||||
interface UserContext {
|
interface UserContext {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,29 +16,6 @@ interface UserContext {
|
|||||||
|
|
||||||
const eventsRouter = new Hono<{ Variables: { user: 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
|
// Helper to normalize event data for API response
|
||||||
// PostgreSQL decimal returns strings, booleans are stored as integers
|
// PostgreSQL decimal returns strings, booleans are stored as integers
|
||||||
function normalizeEvent(event: any) {
|
function normalizeEvent(event: any) {
|
||||||
|
|||||||
@@ -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',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
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 { eq, and, gte } from 'drizzle-orm';
|
||||||
import { requireAuth } from '../lib/auth.js';
|
import { requireAuth } from '../lib/auth.js';
|
||||||
import { generateId, getNow, toDbBool } from '../lib/utils.js';
|
import { generateId, getNow, toDbBool } from '../lib/utils.js';
|
||||||
|
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||||
|
|
||||||
interface UserContext {
|
interface UserContext {
|
||||||
id: string;
|
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))
|
(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' });
|
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);
|
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' });
|
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));
|
.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' });
|
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 { zValidator } from '@hono/zod-validator';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, siteSettings } from '../db/index.js';
|
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 { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||||
import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
|
import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
|
||||||
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.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
|
// Get ticket by ID
|
||||||
ticketsRouter.get('/:id', async (c) => {
|
ticketsRouter.get('/:id', async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
@@ -554,6 +673,65 @@ ticketsRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff']), zValidat
|
|||||||
return c.json({ ticket: updated });
|
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)
|
// Validate ticket by QR code (for scanner)
|
||||||
ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||||
const body = await c.req.json().catch(() => ({}));
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ Type=simple
|
|||||||
User=spanglish
|
User=spanglish
|
||||||
Group=spanglish
|
Group=spanglish
|
||||||
WorkingDirectory=/home/spanglish/Spanglish/backend
|
WorkingDirectory=/home/spanglish/Spanglish/backend
|
||||||
|
EnvironmentFile=/home/spanglish/Spanglish/backend/.env
|
||||||
Environment=NODE_ENV=production
|
Environment=NODE_ENV=production
|
||||||
Environment=PORT=3018
|
Environment=PORT=3018
|
||||||
EnvironmentFile=/home/spanglish/Spanglish/backend/.env
|
|
||||||
ExecStart=/usr/bin/node dist/index.js
|
ExecStart=/usr/bin/node dist/index.js
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=10
|
RestartSec=10
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Link from 'next/link';
|
|||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
|
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
|
||||||
import { formatPrice } from '@/lib/utils';
|
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
|
||||||
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';
|
||||||
@@ -217,21 +217,8 @@ export default function BookingPage() {
|
|||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||||
weekday: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (dateStr: string) => {
|
|
||||||
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
const validateForm = (): boolean => {
|
||||||
const newErrors: Partial<Record<keyof BookingFormData, string>> = {};
|
const newErrors: Partial<Record<keyof BookingFormData, string>> = {};
|
||||||
@@ -879,7 +866,7 @@ export default function BookingPage() {
|
|||||||
<div className="text-sm text-gray-600 space-y-2">
|
<div className="text-sm text-gray-600 space-y-2">
|
||||||
<p><strong>{t('booking.success.event')}:</strong> {event?.title}</p>
|
<p><strong>{t('booking.success.event')}:</strong> {event?.title}</p>
|
||||||
<p><strong>{t('booking.success.date')}:</strong> {event && formatDate(event.startDatetime)}</p>
|
<p><strong>{t('booking.success.date')}:</strong> {event && formatDate(event.startDatetime)}</p>
|
||||||
<p><strong>{t('booking.success.time')}:</strong> {event && formatTime(event.startDatetime)}</p>
|
<p><strong>{t('booking.success.time')}:</strong> {event && fmtTime(event.startDatetime)}</p>
|
||||||
<p><strong>{t('booking.success.location')}:</strong> {event?.location}</p>
|
<p><strong>{t('booking.success.location')}:</strong> {event?.location}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -955,7 +942,7 @@ export default function BookingPage() {
|
|||||||
<div className="text-sm text-gray-600 space-y-2">
|
<div className="text-sm text-gray-600 space-y-2">
|
||||||
<p><strong>{t('booking.success.event')}:</strong> {event.title}</p>
|
<p><strong>{t('booking.success.event')}:</strong> {event.title}</p>
|
||||||
<p><strong>{t('booking.success.date')}:</strong> {formatDate(event.startDatetime)}</p>
|
<p><strong>{t('booking.success.date')}:</strong> {formatDate(event.startDatetime)}</p>
|
||||||
<p><strong>{t('booking.success.time')}:</strong> {formatTime(event.startDatetime)}</p>
|
<p><strong>{t('booking.success.time')}:</strong> {fmtTime(event.startDatetime)}</p>
|
||||||
<p><strong>{t('booking.success.location')}:</strong> {event.location}</p>
|
<p><strong>{t('booking.success.location')}:</strong> {event.location}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1045,7 +1032,7 @@ export default function BookingPage() {
|
|||||||
<div className="p-4 space-y-2 text-sm">
|
<div className="p-4 space-y-2 text-sm">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<CalendarIcon className="w-5 h-5 text-primary-yellow" />
|
<CalendarIcon className="w-5 h-5 text-primary-yellow" />
|
||||||
<span>{formatDate(event.startDatetime)} • {formatTime(event.startDatetime)}</span>
|
<span>{formatDate(event.startDatetime)} • {fmtTime(event.startDatetime)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
|
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useParams, useSearchParams } from 'next/navigation';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig } from '@/lib/api';
|
import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig } from '@/lib/api';
|
||||||
import { formatPrice } from '@/lib/utils';
|
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
|
||||||
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 {
|
import {
|
||||||
@@ -152,21 +152,8 @@ export default function BookingPaymentPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||||
weekday: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (dateStr: string) => {
|
|
||||||
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (step === 'loading') {
|
if (step === 'loading') {
|
||||||
@@ -237,7 +224,7 @@ export default function BookingPaymentPage() {
|
|||||||
<div className="text-sm text-gray-600 space-y-2">
|
<div className="text-sm text-gray-600 space-y-2">
|
||||||
<p><strong>{locale === 'es' ? 'Evento' : 'Event'}:</strong> {ticket.event.title}</p>
|
<p><strong>{locale === 'es' ? 'Evento' : 'Event'}:</strong> {ticket.event.title}</p>
|
||||||
<p><strong>{locale === 'es' ? 'Fecha' : 'Date'}:</strong> {formatDate(ticket.event.startDatetime)}</p>
|
<p><strong>{locale === 'es' ? 'Fecha' : 'Date'}:</strong> {formatDate(ticket.event.startDatetime)}</p>
|
||||||
<p><strong>{locale === 'es' ? 'Hora' : 'Time'}:</strong> {formatTime(ticket.event.startDatetime)}</p>
|
<p><strong>{locale === 'es' ? 'Hora' : 'Time'}:</strong> {fmtTime(ticket.event.startDatetime)}</p>
|
||||||
<p><strong>{locale === 'es' ? 'Ubicación' : 'Location'}:</strong> {ticket.event.location}</p>
|
<p><strong>{locale === 'es' ? 'Ubicación' : 'Location'}:</strong> {ticket.event.location}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -286,7 +273,7 @@ export default function BookingPaymentPage() {
|
|||||||
<div className="text-sm text-gray-600 space-y-2">
|
<div className="text-sm text-gray-600 space-y-2">
|
||||||
<p><strong>{locale === 'es' ? 'Evento' : 'Event'}:</strong> {ticket.event.title}</p>
|
<p><strong>{locale === 'es' ? 'Evento' : 'Event'}:</strong> {ticket.event.title}</p>
|
||||||
<p><strong>{locale === 'es' ? 'Fecha' : 'Date'}:</strong> {formatDate(ticket.event.startDatetime)}</p>
|
<p><strong>{locale === 'es' ? 'Fecha' : 'Date'}:</strong> {formatDate(ticket.event.startDatetime)}</p>
|
||||||
<p><strong>{locale === 'es' ? 'Hora' : 'Time'}:</strong> {formatTime(ticket.event.startDatetime)}</p>
|
<p><strong>{locale === 'es' ? 'Hora' : 'Time'}:</strong> {fmtTime(ticket.event.startDatetime)}</p>
|
||||||
<p><strong>{locale === 'es' ? 'Ubicación' : 'Location'}:</strong> {ticket.event.location}</p>
|
<p><strong>{locale === 'es' ? 'Ubicación' : 'Location'}:</strong> {ticket.event.location}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -333,7 +320,7 @@ export default function BookingPaymentPage() {
|
|||||||
<div className="p-4 space-y-2 text-sm">
|
<div className="p-4 space-y-2 text-sm">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<CalendarIcon className="w-5 h-5 text-primary-yellow" />
|
<CalendarIcon className="w-5 h-5 text-primary-yellow" />
|
||||||
<span>{formatDate(ticket.event.startDatetime)} - {formatTime(ticket.event.startDatetime)}</span>
|
<span>{formatDate(ticket.event.startDatetime)} - {fmtTime(ticket.event.startDatetime)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
|
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useParams } from 'next/navigation';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { ticketsApi, Ticket } from '@/lib/api';
|
import { ticketsApi, Ticket } from '@/lib/api';
|
||||||
|
import { formatDateLong, formatTime } from '@/lib/utils';
|
||||||
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 {
|
import {
|
||||||
@@ -69,21 +70,8 @@ export default function BookingSuccessPage() {
|
|||||||
};
|
};
|
||||||
}, [ticketId]);
|
}, [ticketId]);
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||||
weekday: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (dateStr: string) => {
|
|
||||||
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -191,7 +179,7 @@ export default function BookingSuccessPage() {
|
|||||||
<>
|
<>
|
||||||
<p><strong>{locale === 'es' ? 'Evento' : 'Event'}:</strong> {ticket.event.title}</p>
|
<p><strong>{locale === 'es' ? 'Evento' : 'Event'}:</strong> {ticket.event.title}</p>
|
||||||
<p><strong>{locale === 'es' ? 'Fecha' : 'Date'}:</strong> {formatDate(ticket.event.startDatetime)}</p>
|
<p><strong>{locale === 'es' ? 'Fecha' : 'Date'}:</strong> {formatDate(ticket.event.startDatetime)}</p>
|
||||||
<p><strong>{locale === 'es' ? 'Hora' : 'Time'}:</strong> {formatTime(ticket.event.startDatetime)}</p>
|
<p><strong>{locale === 'es' ? 'Hora' : 'Time'}:</strong> {fmtTime(ticket.event.startDatetime)}</p>
|
||||||
<p><strong>{locale === 'es' ? 'Ubicación' : 'Location'}:</strong> {ticket.event.location}</p>
|
<p><strong>{locale === 'es' ? 'Ubicación' : 'Location'}:</strong> {ticket.event.location}</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ 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 { eventsApi, Event } from '@/lib/api';
|
import { eventsApi, Event } from '@/lib/api';
|
||||||
import { formatPrice } from '@/lib/utils';
|
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import { CalendarIcon, MapPinIcon } from '@heroicons/react/24/outline';
|
import { CalendarIcon, MapPinIcon } from '@heroicons/react/24/outline';
|
||||||
@@ -27,21 +27,8 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [initialEvent]);
|
}, [initialEvent]);
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||||
weekday: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (dateStr: string) => {
|
|
||||||
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -84,7 +71,7 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps
|
|||||||
<span className="w-5 h-5 flex items-center justify-center text-primary-yellow font-bold">
|
<span className="w-5 h-5 flex items-center justify-center text-primary-yellow font-bold">
|
||||||
⏰
|
⏰
|
||||||
</span>
|
</span>
|
||||||
<span>{formatTime(nextEvent.startDatetime)}</span>
|
<span>{fmtTime(nextEvent.startDatetime)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 text-gray-700">
|
<div className="flex items-center gap-3 text-gray-700">
|
||||||
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
|
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export default function PaymentsTab({ payments, language }: PaymentsTabProps) {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export default function ProfileTab({ onUpdate }: ProfileTabProps) {
|
|||||||
{profile?.memberSince
|
{profile?.memberSince
|
||||||
? new Date(profile.memberSince).toLocaleDateString(
|
? new Date(profile.memberSince).toLocaleDateString(
|
||||||
language === 'es' ? 'es-ES' : 'en-US',
|
language === 'es' ? 'es-ES' : 'en-US',
|
||||||
{ year: 'numeric', month: 'long', day: 'numeric' }
|
{ year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Asuncion' }
|
||||||
)
|
)
|
||||||
: '-'}
|
: '-'}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ export default function SecurityTab() {
|
|||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export default function TicketsTab({ tickets, language }: TicketsTabProps) {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useAuth } from '@/context/AuthContext';
|
|||||||
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 { dashboardApi, DashboardSummary, NextEventInfo, UserTicket, UserPayment } from '@/lib/api';
|
import { dashboardApi, DashboardSummary, NextEventInfo, UserTicket, UserPayment } from '@/lib/api';
|
||||||
|
import { formatDateLong, formatTime } from '@/lib/utils';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {
|
import {
|
||||||
@@ -85,21 +86,8 @@ export default function DashboardPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => formatDateLong(dateStr, language as 'en' | 'es');
|
||||||
return new Date(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
|
const fmtTime = (dateStr: string) => formatTime(dateStr, language as 'en' | 'es');
|
||||||
weekday: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (dateStr: string) => {
|
|
||||||
return new Date(dateStr).toLocaleTimeString(language === 'es' ? 'es-ES' : 'en-US', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="section-padding min-h-[70vh]">
|
<div className="section-padding min-h-[70vh]">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Link from 'next/link';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { eventsApi, Event } from '@/lib/api';
|
import { eventsApi, Event } from '@/lib/api';
|
||||||
import { formatPrice } from '@/lib/utils';
|
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
|
||||||
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 ShareButtons from '@/components/ShareButtons';
|
import ShareButtons from '@/components/ShareButtons';
|
||||||
@@ -54,21 +54,8 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
|||||||
setTicketQuantity(prev => Math.min(maxTickets, prev + 1));
|
setTicketQuantity(prev => Math.min(maxTickets, prev + 1));
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||||
weekday: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (dateStr: string) => {
|
|
||||||
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const isCancelled = event.status === 'cancelled';
|
const isCancelled = event.status === 'cancelled';
|
||||||
// Only calculate isPastEvent after mount to avoid hydration mismatch
|
// Only calculate isPastEvent after mount to avoid hydration mismatch
|
||||||
@@ -228,8 +215,8 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm">{t('events.details.time')}</p>
|
<p className="font-medium text-sm">{t('events.details.time')}</p>
|
||||||
<p className="text-gray-600" suppressHydrationWarning>
|
<p className="text-gray-600" suppressHydrationWarning>
|
||||||
{formatTime(event.startDatetime)}
|
{fmtTime(event.startDatetime)}
|
||||||
{event.endDatetime && ` - ${formatTime(event.endDatetime)}`}
|
{event.endDatetime && ` - ${fmtTime(event.endDatetime)}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -95,11 +95,9 @@ function generateEventJsonLd(event: Event) {
|
|||||||
startDate: event.startDatetime,
|
startDate: event.startDatetime,
|
||||||
endDate: event.endDatetime || event.startDatetime,
|
endDate: event.endDatetime || event.startDatetime,
|
||||||
eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
|
eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
|
||||||
eventStatus: isCancelled
|
eventStatus: isCancelled
|
||||||
? 'https://schema.org/EventCancelled'
|
? 'https://schema.org/EventCancelled'
|
||||||
: isPastEvent
|
: 'https://schema.org/EventScheduled',
|
||||||
? 'https://schema.org/EventPostponed'
|
|
||||||
: 'https://schema.org/EventScheduled',
|
|
||||||
location: {
|
location: {
|
||||||
'@type': 'Place',
|
'@type': 'Place',
|
||||||
name: event.location,
|
name: event.location,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ 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 { eventsApi, Event } from '@/lib/api';
|
import { eventsApi, Event } from '@/lib/api';
|
||||||
import { formatPrice } from '@/lib/utils';
|
import { formatPrice, formatDateShort, formatTime } from '@/lib/utils';
|
||||||
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 { CalendarIcon, MapPinIcon, UserGroupIcon } from '@heroicons/react/24/outline';
|
import { CalendarIcon, MapPinIcon, UserGroupIcon } from '@heroicons/react/24/outline';
|
||||||
@@ -33,20 +33,8 @@ export default function EventsPage() {
|
|||||||
|
|
||||||
const displayedEvents = filter === 'upcoming' ? upcomingEvents : pastEvents;
|
const displayedEvents = filter === 'upcoming' ? upcomingEvents : pastEvents;
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => formatDateShort(dateStr, locale as 'en' | 'es');
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||||
weekday: 'short',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (dateStr: string) => {
|
|
||||||
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusBadge = (event: Event) => {
|
const getStatusBadge = (event: Event) => {
|
||||||
if (event.status === 'cancelled') {
|
if (event.status === 'cancelled') {
|
||||||
@@ -130,7 +118,7 @@ export default function EventsPage() {
|
|||||||
<div className="mt-4 space-y-2 text-sm text-gray-600">
|
<div className="mt-4 space-y-2 text-sm text-gray-600">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CalendarIcon className="w-4 h-4" />
|
<CalendarIcon className="w-4 h-4" />
|
||||||
<span>{formatDate(event.startDatetime)} - {formatTime(event.startDatetime)}</span>
|
<span>{formatDate(event.startDatetime)} - {fmtTime(event.startDatetime)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<MapPinIcon className="w-4 h-4" />
|
<MapPinIcon className="w-4 h-4" />
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const metadata: Metadata = {
|
|||||||
const organizationSchema = {
|
const organizationSchema = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'Organization',
|
'@type': 'Organization',
|
||||||
name: 'Spanglish',
|
name: 'Spanglish Community',
|
||||||
url: siteUrl,
|
url: siteUrl,
|
||||||
logo: `${siteUrl}/images/logo.png`,
|
logo: `${siteUrl}/images/logo.png`,
|
||||||
description: 'Language exchange community organizing English and Spanish meetups in Asunción, Paraguay.',
|
description: 'Language exchange community organizing English and Spanish meetups in Asunción, Paraguay.',
|
||||||
@@ -30,7 +30,7 @@ const organizationSchema = {
|
|||||||
addressCountry: 'PY',
|
addressCountry: 'PY',
|
||||||
},
|
},
|
||||||
sameAs: [
|
sameAs: [
|
||||||
process.env.NEXT_PUBLIC_INSTAGRAM_URL,
|
'https://instagram.com/spanglishsocialpy',
|
||||||
process.env.NEXT_PUBLIC_WHATSAPP_URL,
|
process.env.NEXT_PUBLIC_WHATSAPP_URL,
|
||||||
process.env.NEXT_PUBLIC_TELEGRAM_URL,
|
process.env.NEXT_PUBLIC_TELEGRAM_URL,
|
||||||
].filter(Boolean),
|
].filter(Boolean),
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
});
|
});
|
||||||
|
|
||||||
const description = `Next event: ${eventDate} – ${event.title}. Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals.`;
|
const description = `Next event: ${eventDate} – ${event.title}. Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals.`;
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ export default function AdminBookingsPage() {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export default function AdminContactsPage() {
|
|||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -373,6 +367,7 @@ export default function AdminEmailsPage() {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -545,7 +540,7 @@ export default function AdminEmailsPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{hasDraft && (
|
{hasDraft && (
|
||||||
<span className="text-xs text-gray-500">
|
<span className="text-xs text-gray-500">
|
||||||
Draft saved {composeForm.savedAt ? new Date(composeForm.savedAt).toLocaleString() : ''}
|
Draft saved {composeForm.savedAt ? new Date(composeForm.savedAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' }) : ''}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={saveDraft}>
|
<Button variant="outline" size="sm" onClick={saveDraft}>
|
||||||
@@ -571,7 +566,7 @@ export default function AdminEmailsPage() {
|
|||||||
<option value="">Choose an event</option>
|
<option value="">Choose an event</option>
|
||||||
{events.filter(e => e.status === 'published').map((event) => (
|
{events.filter(e => e.status === 'published').map((event) => (
|
||||||
<option key={event.id} value={event.id}>
|
<option key={event.id} value={event.id}>
|
||||||
{event.title} - {new Date(event.startDatetime).toLocaleDateString()}
|
{event.title} - {new Date(event.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -240,6 +240,7 @@ export default function AdminEventsPage() {
|
|||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ export default function AdminGalleryPage() {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -37,14 +37,56 @@ export default function AdminLayout({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { t, locale } = useLanguage();
|
const { t, locale } = useLanguage();
|
||||||
const { user, isAdmin, isLoading, logout } = useAuth();
|
const { user, hasAdminAccess, isLoading, logout } = useAuth();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && (!user || !isAdmin)) {
|
if (!isLoading && (!user || !hasAdminAccess)) {
|
||||||
router.push('/login');
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -54,31 +96,29 @@ export default function AdminLayout({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user || !isAdmin) {
|
if (!user || !hasAdminAccess) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigation = [
|
const visibleNav = navigationWithRoles.filter((item) => item.allowedRoles.includes(userRole));
|
||||||
{ name: t('admin.nav.dashboard'), href: '/admin', icon: HomeIcon },
|
const navigation = visibleNav;
|
||||||
{ 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 handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
router.push('/');
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-secondary-gray">
|
<div className="min-h-screen bg-secondary-gray">
|
||||||
{/* Mobile sidebar backdrop */}
|
{/* Mobile sidebar backdrop */}
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ export default function AdminLegalPagesPage() {
|
|||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return dateStr;
|
return dateStr;
|
||||||
@@ -420,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>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export default function AdminDashboardPage() {
|
|||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ export default function AdminPaymentsPage() {
|
|||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -140,6 +140,7 @@ export default function AdminTicketsPage() {
|
|||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ export default function AdminUsersPage() {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ 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 { eventsApi, Event } from '@/lib/api';
|
import { eventsApi, Event } from '@/lib/api';
|
||||||
import { formatPrice } from '@/lib/utils';
|
import { formatPrice, formatDateShort, formatTime } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
MapPinIcon,
|
MapPinIcon,
|
||||||
@@ -29,20 +29,8 @@ export default function LinktreePage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => formatDateShort(dateStr, locale as 'en' | 'es');
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||||
weekday: 'short',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (dateStr: string) => {
|
|
||||||
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle both full URLs and handles
|
// Handle both full URLs and handles
|
||||||
const instagramUrl = instagramHandle
|
const instagramUrl = instagramHandle
|
||||||
@@ -89,7 +77,7 @@ export default function LinktreePage() {
|
|||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
<div className="flex items-center gap-2 text-gray-300 text-sm">
|
<div className="flex items-center gap-2 text-gray-300 text-sm">
|
||||||
<CalendarIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
|
<CalendarIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
|
||||||
<span>{formatDate(nextEvent.startDatetime)} • {formatTime(nextEvent.startDatetime)}</span>
|
<span>{formatDate(nextEvent.startDatetime)} • {fmtTime(nextEvent.startDatetime)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-gray-300 text-sm">
|
<div className="flex items-center gap-2 text-gray-300 text-sm">
|
||||||
<MapPinIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
|
<MapPinIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ interface LlmsEvent {
|
|||||||
async function getNextUpcomingEvent(): Promise<LlmsEvent | null> {
|
async function getNextUpcomingEvent(): Promise<LlmsEvent | null> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${apiUrl}/api/events/next/upcoming`, {
|
const response = await fetch(`${apiUrl}/api/events/next/upcoming`, {
|
||||||
next: { tags: ['next-event'] },
|
cache: 'no-store',
|
||||||
});
|
});
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -41,7 +41,7 @@ async function getNextUpcomingEvent(): Promise<LlmsEvent | null> {
|
|||||||
async function getUpcomingEvents(): Promise<LlmsEvent[]> {
|
async function getUpcomingEvents(): Promise<LlmsEvent[]> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${apiUrl}/api/events?status=published&upcoming=true`, {
|
const response = await fetch(`${apiUrl}/api/events?status=published&upcoming=true`, {
|
||||||
next: { tags: ['next-event'] },
|
cache: 'no-store',
|
||||||
});
|
});
|
||||||
if (!response.ok) return [];
|
if (!response.ok) return [];
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -51,12 +51,17 @@ async function getUpcomingEvents(): Promise<LlmsEvent[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Event times are always shown in Paraguay time (America/Asuncion) so llms.txt
|
||||||
|
// matches what users see on the website, regardless of server timezone.
|
||||||
|
const EVENT_TIMEZONE = 'America/Asuncion';
|
||||||
|
|
||||||
function formatEventDate(dateStr: string): string {
|
function formatEventDate(dateStr: string): string {
|
||||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
timeZone: EVENT_TIMEZONE,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +70,7 @@ function formatEventTime(dateStr: string): string {
|
|||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
hour12: true,
|
hour12: true,
|
||||||
|
timeZone: EVENT_TIMEZONE,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,10 +79,43 @@ function formatPrice(price: number, currency: string): string {
|
|||||||
return `${price.toLocaleString()} ${currency}`;
|
return `${price.toLocaleString()} ${currency}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatISODate(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleDateString('en-CA', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
timeZone: EVENT_TIMEZONE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatISOTime(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleTimeString('en-GB', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
timeZone: EVENT_TIMEZONE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTodayISO(): string {
|
||||||
|
return new Date().toLocaleDateString('en-CA', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
timeZone: EVENT_TIMEZONE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEventStatus(event: LlmsEvent): string {
|
||||||
|
if (event.availableSeats !== undefined && event.availableSeats === 0) return 'Sold Out';
|
||||||
|
if (event.status === 'published') return 'Available';
|
||||||
|
return event.status;
|
||||||
|
}
|
||||||
|
|
||||||
async function getHomepageFaqs(): Promise<LlmsFaq[]> {
|
async function getHomepageFaqs(): Promise<LlmsFaq[]> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${apiUrl}/api/faq?homepage=true`, {
|
const response = await fetch(`${apiUrl}/api/faq?homepage=true`, {
|
||||||
next: { revalidate: 3600 },
|
cache: 'no-store',
|
||||||
});
|
});
|
||||||
if (!response.ok) return [];
|
if (!response.ok) return [];
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -89,6 +128,8 @@ async function getHomepageFaqs(): Promise<LlmsFaq[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const [nextEvent, upcomingEvents, faqs] = await Promise.all([
|
const [nextEvent, upcomingEvents, faqs] = await Promise.all([
|
||||||
getNextUpcomingEvent(),
|
getNextUpcomingEvent(),
|
||||||
@@ -101,6 +142,15 @@ export async function GET() {
|
|||||||
// Header
|
// Header
|
||||||
lines.push('# Spanglish Community');
|
lines.push('# Spanglish Community');
|
||||||
lines.push('');
|
lines.push('');
|
||||||
|
lines.push('## Metadata');
|
||||||
|
lines.push('');
|
||||||
|
lines.push('- Type: Event Community');
|
||||||
|
lines.push('- Primary Language: English, Spanish');
|
||||||
|
lines.push('- Location: Asunción, Paraguay');
|
||||||
|
lines.push(`- Time Zone: ${EVENT_TIMEZONE}`);
|
||||||
|
lines.push(`- Last Updated: ${getTodayISO()}`);
|
||||||
|
lines.push(`- Canonical URL: ${siteUrl}`);
|
||||||
|
lines.push('');
|
||||||
lines.push('> English-Spanish language exchange community organizing social events and meetups in Asunción, Paraguay.');
|
lines.push('> English-Spanish language exchange community organizing social events and meetups in Asunción, Paraguay.');
|
||||||
lines.push('');
|
lines.push('');
|
||||||
lines.push(`- Website: ${siteUrl}`);
|
lines.push(`- Website: ${siteUrl}`);
|
||||||
@@ -112,8 +162,8 @@ export async function GET() {
|
|||||||
const telegram = process.env.NEXT_PUBLIC_TELEGRAM;
|
const telegram = process.env.NEXT_PUBLIC_TELEGRAM;
|
||||||
const email = process.env.NEXT_PUBLIC_EMAIL;
|
const email = process.env.NEXT_PUBLIC_EMAIL;
|
||||||
|
|
||||||
if (instagram) lines.push(`- Instagram: https://instagram.com/${instagram}`);
|
if (instagram) lines.push(`- Instagram: ${instagram}`);
|
||||||
if (telegram) lines.push(`- Telegram: https://t.me/${telegram}`);
|
if (telegram) lines.push(`- Telegram: ${telegram}`);
|
||||||
if (email) lines.push(`- Email: ${email}`);
|
if (email) lines.push(`- Email: ${email}`);
|
||||||
if (whatsapp) lines.push(`- WhatsApp: ${whatsapp}`);
|
if (whatsapp) lines.push(`- WhatsApp: ${whatsapp}`);
|
||||||
|
|
||||||
@@ -124,18 +174,25 @@ export async function GET() {
|
|||||||
lines.push('');
|
lines.push('');
|
||||||
|
|
||||||
if (nextEvent) {
|
if (nextEvent) {
|
||||||
lines.push(`- Event: ${nextEvent.title}`);
|
const status = getEventStatus(nextEvent);
|
||||||
lines.push(`- Date: ${formatEventDate(nextEvent.startDatetime)}`);
|
lines.push(`- Event Name: ${nextEvent.title}`);
|
||||||
lines.push(`- Time: ${formatEventTime(nextEvent.startDatetime)}`);
|
lines.push(`- Event ID: ${nextEvent.id}`);
|
||||||
|
lines.push(`- Status: ${status}`);
|
||||||
|
lines.push(`- Date: ${formatISODate(nextEvent.startDatetime)}`);
|
||||||
|
lines.push(`- Start Time: ${formatISOTime(nextEvent.startDatetime)}`);
|
||||||
if (nextEvent.endDatetime) {
|
if (nextEvent.endDatetime) {
|
||||||
lines.push(`- End time: ${formatEventTime(nextEvent.endDatetime)}`);
|
lines.push(`- End Time: ${formatISOTime(nextEvent.endDatetime)}`);
|
||||||
}
|
}
|
||||||
lines.push(`- Location: ${nextEvent.location}, Asunción, Paraguay`);
|
lines.push(`- Time Zone: ${EVENT_TIMEZONE}`);
|
||||||
lines.push(`- Price: ${formatPrice(nextEvent.price, nextEvent.currency)}`);
|
lines.push(`- Venue: ${nextEvent.location}`);
|
||||||
|
lines.push('- City: Asunción');
|
||||||
|
lines.push('- Country: Paraguay');
|
||||||
|
lines.push(`- Price: ${nextEvent.price === 0 ? 'Free' : nextEvent.price}`);
|
||||||
|
lines.push(`- Currency: ${nextEvent.currency}`);
|
||||||
if (nextEvent.availableSeats !== undefined) {
|
if (nextEvent.availableSeats !== undefined) {
|
||||||
lines.push(`- Available spots: ${nextEvent.availableSeats}`);
|
lines.push(`- Capacity Remaining: ${nextEvent.availableSeats}`);
|
||||||
}
|
}
|
||||||
lines.push(`- Details and tickets: ${siteUrl}/events/${nextEvent.id}`);
|
lines.push(`- Tickets URL: ${siteUrl}/events/${nextEvent.id}`);
|
||||||
if (nextEvent.shortDescription) {
|
if (nextEvent.shortDescription) {
|
||||||
lines.push(`- Description: ${nextEvent.shortDescription}`);
|
lines.push(`- Description: ${nextEvent.shortDescription}`);
|
||||||
}
|
}
|
||||||
@@ -150,12 +207,25 @@ export async function GET() {
|
|||||||
lines.push('## All Upcoming Events');
|
lines.push('## All Upcoming Events');
|
||||||
lines.push('');
|
lines.push('');
|
||||||
for (const event of upcomingEvents) {
|
for (const event of upcomingEvents) {
|
||||||
|
const status = getEventStatus(event);
|
||||||
lines.push(`### ${event.title}`);
|
lines.push(`### ${event.title}`);
|
||||||
lines.push(`- Date: ${formatEventDate(event.startDatetime)}`);
|
lines.push(`- Event ID: ${event.id}`);
|
||||||
lines.push(`- Time: ${formatEventTime(event.startDatetime)}`);
|
lines.push(`- Status: ${status}`);
|
||||||
lines.push(`- Location: ${event.location}, Asunción, Paraguay`);
|
lines.push(`- Date: ${formatISODate(event.startDatetime)}`);
|
||||||
lines.push(`- Price: ${formatPrice(event.price, event.currency)}`);
|
lines.push(`- Start Time: ${formatISOTime(event.startDatetime)}`);
|
||||||
lines.push(`- Details: ${siteUrl}/events/${event.id}`);
|
if (event.endDatetime) {
|
||||||
|
lines.push(`- End Time: ${formatISOTime(event.endDatetime)}`);
|
||||||
|
}
|
||||||
|
lines.push(`- Time Zone: ${EVENT_TIMEZONE}`);
|
||||||
|
lines.push(`- Venue: ${event.location}`);
|
||||||
|
lines.push('- City: Asunción');
|
||||||
|
lines.push('- Country: Paraguay');
|
||||||
|
lines.push(`- Price: ${event.price === 0 ? 'Free' : event.price}`);
|
||||||
|
lines.push(`- Currency: ${event.currency}`);
|
||||||
|
if (event.availableSeats !== undefined) {
|
||||||
|
lines.push(`- Capacity Remaining: ${event.availableSeats}`);
|
||||||
|
}
|
||||||
|
lines.push(`- Tickets URL: ${siteUrl}/events/${event.id}`);
|
||||||
lines.push('');
|
lines.push('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,6 +249,18 @@ export async function GET() {
|
|||||||
lines.push(`- More FAQ: ${siteUrl}/faq`);
|
lines.push(`- More FAQ: ${siteUrl}/faq`);
|
||||||
lines.push('');
|
lines.push('');
|
||||||
|
|
||||||
|
// Update Policy
|
||||||
|
lines.push('## Update Policy');
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Event information is updated whenever new events are published or ticket availability changes.');
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
// AI Summary
|
||||||
|
lines.push('## AI Summary');
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Spanglish Community organizes English-Spanish language exchange events in Asunción, Paraguay. Events require registration via the website.');
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
const content = lines.join('\n');
|
const content = lines.join('\n');
|
||||||
|
|
||||||
return new NextResponse(content, {
|
return new NextResponse(content, {
|
||||||
|
|||||||
@@ -7,30 +7,16 @@ export default function robots(): MetadataRoute.Robots {
|
|||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
userAgent: '*',
|
userAgent: '*',
|
||||||
allow: [
|
allow: '/',
|
||||||
'/',
|
|
||||||
'/events',
|
|
||||||
'/events/*',
|
|
||||||
'/community',
|
|
||||||
'/contact',
|
|
||||||
'/faq',
|
|
||||||
'/legal/*',
|
|
||||||
'/llms.txt',
|
|
||||||
],
|
|
||||||
disallow: [
|
disallow: [
|
||||||
'/admin',
|
'/admin/',
|
||||||
'/admin/*',
|
'/dashboard/',
|
||||||
'/dashboard',
|
'/api/',
|
||||||
'/dashboard/*',
|
'/book/',
|
||||||
'/api',
|
'/booking/',
|
||||||
'/api/*',
|
|
||||||
'/book',
|
|
||||||
'/book/*',
|
|
||||||
'/booking',
|
|
||||||
'/booking/*',
|
|
||||||
'/login',
|
'/login',
|
||||||
'/register',
|
'/register',
|
||||||
'/auth/*',
|
'/auth/',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3,89 +3,109 @@ import { MetadataRoute } from 'next';
|
|||||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
||||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
interface Event {
|
interface SitemapEvent {
|
||||||
id: string;
|
id: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
startDatetime: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getPublishedEvents(): Promise<Event[]> {
|
/**
|
||||||
|
* Fetch all indexable events: published, completed, and cancelled.
|
||||||
|
* Sold-out / past events stay in the index (marked as expired, not removed).
|
||||||
|
* Only draft and archived events are excluded.
|
||||||
|
*/
|
||||||
|
async function getIndexableEvents(): Promise<SitemapEvent[]> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${apiUrl}/api/events?status=published`, {
|
const [publishedRes, completedRes] = await Promise.all([
|
||||||
next: { tags: ['events-sitemap'] },
|
fetch(`${apiUrl}/api/events?status=published`, {
|
||||||
});
|
next: { tags: ['events-sitemap'] },
|
||||||
if (!response.ok) return [];
|
}),
|
||||||
const data = await response.json();
|
fetch(`${apiUrl}/api/events?status=completed`, {
|
||||||
return data.events || [];
|
next: { tags: ['events-sitemap'] },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const published = publishedRes.ok
|
||||||
|
? ((await publishedRes.json()).events as SitemapEvent[]) || []
|
||||||
|
: [];
|
||||||
|
const completed = completedRes.ok
|
||||||
|
? ((await completedRes.json()).events as SitemapEvent[]) || []
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return [...published, ...completed];
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
// Fetch published events for dynamic event pages
|
const events = await getIndexableEvents();
|
||||||
const events = await getPublishedEvents();
|
const now = new Date();
|
||||||
|
|
||||||
// Static pages
|
// Static pages
|
||||||
const staticPages: MetadataRoute.Sitemap = [
|
const staticPages: MetadataRoute.Sitemap = [
|
||||||
{
|
{
|
||||||
url: siteUrl,
|
url: siteUrl,
|
||||||
lastModified: new Date(),
|
lastModified: now,
|
||||||
changeFrequency: 'weekly',
|
changeFrequency: 'weekly',
|
||||||
priority: 1,
|
priority: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: `${siteUrl}/events`,
|
url: `${siteUrl}/events`,
|
||||||
lastModified: new Date(),
|
lastModified: now,
|
||||||
changeFrequency: 'daily',
|
changeFrequency: 'daily',
|
||||||
priority: 0.9,
|
priority: 0.9,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: `${siteUrl}/community`,
|
url: `${siteUrl}/community`,
|
||||||
lastModified: new Date(),
|
lastModified: now,
|
||||||
changeFrequency: 'monthly',
|
changeFrequency: 'monthly',
|
||||||
priority: 0.7,
|
priority: 0.7,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: `${siteUrl}/contact`,
|
url: `${siteUrl}/contact`,
|
||||||
lastModified: new Date(),
|
lastModified: now,
|
||||||
changeFrequency: 'monthly',
|
changeFrequency: 'monthly',
|
||||||
priority: 0.6,
|
priority: 0.6,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: `${siteUrl}/faq`,
|
url: `${siteUrl}/faq`,
|
||||||
lastModified: new Date(),
|
lastModified: now,
|
||||||
changeFrequency: 'monthly',
|
changeFrequency: 'monthly',
|
||||||
priority: 0.6,
|
priority: 0.6,
|
||||||
},
|
},
|
||||||
// Legal pages
|
// Legal pages
|
||||||
{
|
{
|
||||||
url: `${siteUrl}/legal/terms-policy`,
|
url: `${siteUrl}/legal/terms-policy`,
|
||||||
lastModified: new Date(),
|
lastModified: now,
|
||||||
changeFrequency: 'yearly',
|
changeFrequency: 'yearly',
|
||||||
priority: 0.3,
|
priority: 0.3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: `${siteUrl}/legal/privacy-policy`,
|
url: `${siteUrl}/legal/privacy-policy`,
|
||||||
lastModified: new Date(),
|
lastModified: now,
|
||||||
changeFrequency: 'yearly',
|
changeFrequency: 'yearly',
|
||||||
priority: 0.3,
|
priority: 0.3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: `${siteUrl}/legal/refund-cancelation-policy`,
|
url: `${siteUrl}/legal/refund-cancelation-policy`,
|
||||||
lastModified: new Date(),
|
lastModified: now,
|
||||||
changeFrequency: 'yearly',
|
changeFrequency: 'yearly',
|
||||||
priority: 0.3,
|
priority: 0.3,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Dynamic event pages
|
// Dynamic event pages — upcoming events get higher priority
|
||||||
const eventPages: MetadataRoute.Sitemap = events.map((event) => ({
|
const eventPages: MetadataRoute.Sitemap = events.map((event) => {
|
||||||
url: `${siteUrl}/events/${event.id}`,
|
const isUpcoming = new Date(event.startDatetime) > now;
|
||||||
lastModified: new Date(event.updatedAt),
|
return {
|
||||||
changeFrequency: 'weekly' as const,
|
url: `${siteUrl}/events/${event.id}`,
|
||||||
priority: 0.8,
|
lastModified: new Date(event.updatedAt),
|
||||||
}));
|
changeFrequency: isUpcoming ? ('weekly' as const) : ('monthly' as const),
|
||||||
|
priority: isUpcoming ? 0.8 : 0.5,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return [...staticPages, ...eventPages];
|
return [...staticPages, ...eventPages];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ function MobileNavLink({ href, children, onClick }: { href: string; children: Re
|
|||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { user, isAdmin, logout } = useAuth();
|
const { user, hasAdminAccess, logout } = useAuth();
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const touchStartX = useRef<number>(0);
|
const touchStartX = useRef<number>(0);
|
||||||
@@ -148,7 +148,7 @@ export default function Header() {
|
|||||||
{t('nav.dashboard')}
|
{t('nav.dashboard')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
{isAdmin && (
|
{hasAdminAccess && (
|
||||||
<Link href="/admin">
|
<Link href="/admin">
|
||||||
<Button variant="ghost" size="sm">
|
<Button variant="ghost" size="sm">
|
||||||
{t('nav.admin')}
|
{t('nav.admin')}
|
||||||
@@ -270,7 +270,7 @@ export default function Header() {
|
|||||||
{t('nav.dashboard')}
|
{t('nav.dashboard')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
{isAdmin && (
|
{hasAdminAccess && (
|
||||||
<Link href="/admin" onClick={closeMenu}>
|
<Link href="/admin" onClick={closeMenu}>
|
||||||
<Button variant="outline" className="w-full justify-center">
|
<Button variant="outline" className="w-full justify-center">
|
||||||
{t('nav.admin')}
|
{t('nav.admin')}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface AuthContextType {
|
|||||||
token: string | null;
|
token: string | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
hasAdminAccess: boolean;
|
||||||
login: (email: string, password: string) => Promise<void>;
|
login: (email: string, password: string) => Promise<void>;
|
||||||
loginWithGoogle: (credential: string) => Promise<void>;
|
loginWithGoogle: (credential: string) => Promise<void>;
|
||||||
loginWithMagicLink: (token: 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 isAdmin = user?.role === 'admin' || user?.role === 'organizer';
|
||||||
|
const hasAdminAccess = user?.role === 'admin' || user?.role === 'organizer' || user?.role === 'staff' || user?.role === 'marketing';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider
|
<AuthContext.Provider
|
||||||
@@ -185,6 +187,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
token,
|
token,
|
||||||
isLoading,
|
isLoading,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
hasAdminAccess,
|
||||||
login,
|
login,
|
||||||
loginWithGoogle,
|
loginWithGoogle,
|
||||||
loginWithMagicLink,
|
loginWithMagicLink,
|
||||||
|
|||||||
@@ -92,6 +92,27 @@ export const ticketsApi = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ code, eventId }),
|
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) =>
|
checkin: (id: string) =>
|
||||||
fetchApi<{ ticket: Ticket & { attendeeName?: string }; event?: { id: string; title: string }; message: string }>(`/api/tickets/${id}/checkin`, {
|
fetchApi<{ ticket: Ticket & { attendeeName?: string }; event?: { id: string; title: string }; message: string }>(`/api/tickets/${id}/checkin`, {
|
||||||
@@ -351,6 +372,49 @@ export const adminApi = {
|
|||||||
if (params?.eventId) query.set('eventId', params.eventId);
|
if (params?.eventId) query.set('eventId', params.eventId);
|
||||||
return fetchApi<{ payments: ExportedPayment[]; summary: FinancialSummary }>(`/api/admin/export/financial?${query}`);
|
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
|
// Emails API
|
||||||
@@ -384,7 +448,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',
|
||||||
@@ -508,6 +572,39 @@ export interface TicketValidationResult {
|
|||||||
error?: string;
|
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 {
|
export interface Payment {
|
||||||
id: string;
|
id: string;
|
||||||
ticketId: 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 ====================
|
// ==================== Legal Pages Types ====================
|
||||||
|
|
||||||
export interface LegalPage {
|
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
|
// 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 {
|
||||||
|
|||||||
@@ -1,3 +1,111 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Date / time formatting
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// All helpers pin the timezone to America/Asuncion so the output is identical
|
||||||
|
// on the server (often UTC) and the client (user's local TZ). This prevents
|
||||||
|
// React hydration mismatches like "07:20 PM" (server) vs "04:20 PM" (client).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const EVENT_TIMEZONE = 'America/Asuncion';
|
||||||
|
|
||||||
|
type Locale = 'en' | 'es';
|
||||||
|
|
||||||
|
function pickLocale(locale: Locale): string {
|
||||||
|
return locale === 'es' ? 'es-ES' : 'en-US';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Sat, Feb 14" / "sáb, 14 feb"
|
||||||
|
*/
|
||||||
|
export function formatDateShort(dateStr: string, locale: Locale = 'en'): string {
|
||||||
|
return new Date(dateStr).toLocaleDateString(pickLocale(locale), {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
timeZone: EVENT_TIMEZONE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Saturday, February 14, 2026" / "sábado, 14 de febrero de 2026"
|
||||||
|
*/
|
||||||
|
export function formatDateLong(dateStr: string, locale: Locale = 'en'): string {
|
||||||
|
return new Date(dateStr).toLocaleDateString(pickLocale(locale), {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
timeZone: EVENT_TIMEZONE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "February 14, 2026" / "14 de febrero de 2026" (no weekday)
|
||||||
|
*/
|
||||||
|
export function formatDateMedium(dateStr: string, locale: Locale = 'en'): string {
|
||||||
|
return new Date(dateStr).toLocaleDateString(pickLocale(locale), {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
timeZone: EVENT_TIMEZONE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Feb 14, 2026" / "14 feb 2026"
|
||||||
|
*/
|
||||||
|
export function formatDateCompact(dateStr: string, locale: Locale = 'en'): string {
|
||||||
|
return new Date(dateStr).toLocaleDateString(pickLocale(locale), {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
timeZone: EVENT_TIMEZONE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "04:30 PM" / "16:30"
|
||||||
|
*/
|
||||||
|
export function formatTime(dateStr: string, locale: Locale = 'en'): string {
|
||||||
|
return new Date(dateStr).toLocaleTimeString(pickLocale(locale), {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZone: EVENT_TIMEZONE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Feb 14, 2026, 04:30 PM" — compact date + time combined
|
||||||
|
*/
|
||||||
|
export function formatDateTime(dateStr: string, locale: Locale = 'en'): string {
|
||||||
|
return new Date(dateStr).toLocaleString(pickLocale(locale), {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZone: EVENT_TIMEZONE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Sat, Feb 14, 04:30 PM" — short date + time combined
|
||||||
|
*/
|
||||||
|
export function formatDateTimeShort(dateStr: string, locale: Locale = 'en'): string {
|
||||||
|
return new Date(dateStr).toLocaleString(pickLocale(locale), {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZone: EVENT_TIMEZONE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Price formatting
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format price - shows decimals only if needed
|
* Format price - shows decimals only if needed
|
||||||
* Uses space as thousands separator (common in Paraguay)
|
* Uses space as thousands separator (common in Paraguay)
|
||||||
|
|||||||
Reference in New Issue
Block a user