Compare commits
25 Commits
3025ef3d21
...
backup5
| Author | SHA1 | Date | |
|---|---|---|---|
| 15655e3987 | |||
|
|
5263fa6834 | ||
|
|
923c86a3b3 | ||
| d8b3864411 | |||
|
|
4aaffe99c7 | ||
| 194cbd6ca8 | |||
|
|
a11da5a977 | ||
| d5445c2282 | |||
|
|
6bc7e13e78 | ||
| dcfefc8371 | |||
|
|
c3897efd02 | ||
| b5f14335c4 | |||
|
|
62bf048680 | ||
| d44ac949b5 | |||
|
|
b9f46b02cc | ||
| a5e939221d | |||
|
|
18254c566e | ||
|
|
95ee5a5dec | ||
| 833e3e5a9c | |||
|
|
77e92e5d96 | ||
| ba1975dd6d | |||
|
|
07ba357194 | ||
|
|
5885044369 | ||
|
|
af94c99fd2 | ||
|
|
74464b0a7a |
@@ -19,7 +19,11 @@ GOOGLE_CLIENT_ID=
|
||||
# Server Configuration
|
||||
PORT=3001
|
||||
API_URL=http://localhost:3001
|
||||
FRONTEND_URL=http://localhost:3002
|
||||
FRONTEND_URL=http://localhost:3019
|
||||
|
||||
# Revalidation secret (shared with frontend for on-demand cache revalidation)
|
||||
# Must match the REVALIDATE_SECRET in frontend/.env
|
||||
REVALIDATE_SECRET=change-me-to-a-random-secret
|
||||
|
||||
# Payment Providers (optional)
|
||||
STRIPE_SECRET_KEY=
|
||||
@@ -63,3 +67,9 @@ SMTP_TLS_REJECT_UNAUTHORIZED=true
|
||||
# SendGrid: SMTP_HOST=smtp.sendgrid.net, SMTP_PORT=587, SMTP_USER=apikey, SMTP_PASS=your_api_key
|
||||
# Mailgun: SMTP_HOST=smtp.mailgun.org, SMTP_PORT=587
|
||||
# Amazon SES: SMTP_HOST=email-smtp.us-east-1.amazonaws.com, SMTP_PORT=587
|
||||
|
||||
# Email Queue Rate Limiting
|
||||
# Maximum number of emails that can be sent per hour (default: 30)
|
||||
# If the limit is reached, queued emails will pause and resume automatically
|
||||
MAX_EMAILS_PER_HOUR=30
|
||||
|
||||
|
||||
@@ -421,6 +421,41 @@ async function migrate() {
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// FAQ questions table
|
||||
await (db as any).run(sql`
|
||||
CREATE TABLE IF NOT EXISTS faq_questions (
|
||||
id TEXT PRIMARY KEY,
|
||||
question TEXT NOT NULL,
|
||||
question_es TEXT,
|
||||
answer TEXT NOT NULL,
|
||||
answer_es TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
show_on_homepage INTEGER NOT NULL DEFAULT 0,
|
||||
rank INTEGER NOT NULL DEFAULT 0,
|
||||
created_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 {
|
||||
// PostgreSQL migrations
|
||||
await (db as any).execute(sql`
|
||||
@@ -790,6 +825,41 @@ async function migrate() {
|
||||
created_at TIMESTAMP NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// FAQ questions table
|
||||
await (db as any).execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS faq_questions (
|
||||
id UUID PRIMARY KEY,
|
||||
question TEXT NOT NULL,
|
||||
question_es TEXT,
|
||||
answer TEXT NOT NULL,
|
||||
answer_es TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
show_on_homepage INTEGER NOT NULL DEFAULT 0,
|
||||
rank INTEGER NOT NULL DEFAULT 0,
|
||||
created_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!');
|
||||
|
||||
@@ -267,6 +267,37 @@ export const sqliteLegalPages = sqliteTable('legal_pages', {
|
||||
createdAt: text('created_at').notNull(),
|
||||
});
|
||||
|
||||
// FAQ questions table (admin-managed, shown on /faq and optionally on homepage)
|
||||
export const sqliteFaqQuestions = sqliteTable('faq_questions', {
|
||||
id: text('id').primaryKey(),
|
||||
question: text('question').notNull(),
|
||||
questionEs: text('question_es'),
|
||||
answer: text('answer').notNull(),
|
||||
answerEs: text('answer_es'),
|
||||
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
||||
showOnHomepage: integer('show_on_homepage', { mode: 'boolean' }).notNull().default(false),
|
||||
rank: integer('rank').notNull().default(0),
|
||||
createdAt: text('created_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
|
||||
export const sqliteSiteSettings = sqliteTable('site_settings', {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -550,6 +581,37 @@ export const pgLegalPages = pgTable('legal_pages', {
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
});
|
||||
|
||||
// FAQ questions table (admin-managed)
|
||||
export const pgFaqQuestions = pgTable('faq_questions', {
|
||||
id: uuid('id').primaryKey(),
|
||||
question: pgText('question').notNull(),
|
||||
questionEs: pgText('question_es'),
|
||||
answer: pgText('answer').notNull(),
|
||||
answerEs: pgText('answer_es'),
|
||||
enabled: pgInteger('enabled').notNull().default(1),
|
||||
showOnHomepage: pgInteger('show_on_homepage').notNull().default(0),
|
||||
rank: pgInteger('rank').notNull().default(0),
|
||||
createdAt: timestamp('created_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
|
||||
export const pgSiteSettings = pgTable('site_settings', {
|
||||
id: uuid('id').primaryKey(),
|
||||
@@ -595,8 +657,10 @@ export const eventPaymentOverrides = dbType === 'postgres' ? pgEventPaymentOverr
|
||||
export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqliteMagicLinkTokens;
|
||||
export const userSessions = dbType === 'postgres' ? pgUserSessions : sqliteUserSessions;
|
||||
export const invoices = dbType === 'postgres' ? pgInvoices : sqliteInvoices;
|
||||
export const legalSettings = dbType === 'postgres' ? pgLegalSettings : sqliteLegalSettings;
|
||||
export const siteSettings = dbType === 'postgres' ? pgSiteSettings : sqliteSiteSettings;
|
||||
export const legalPages = dbType === 'postgres' ? pgLegalPages : sqliteLegalPages;
|
||||
export const faqQuestions = dbType === 'postgres' ? pgFaqQuestions : sqliteFaqQuestions;
|
||||
|
||||
// Type exports
|
||||
export type User = typeof sqliteUsers.$inferSelect;
|
||||
@@ -627,3 +691,7 @@ export type SiteSettings = typeof sqliteSiteSettings.$inferSelect;
|
||||
export type NewSiteSettings = typeof sqliteSiteSettings.$inferInsert;
|
||||
export type LegalPage = typeof sqliteLegalPages.$inferSelect;
|
||||
export type NewLegalPage = typeof sqliteLegalPages.$inferInsert;
|
||||
export type FaqQuestion = typeof sqliteFaqQuestions.$inferSelect;
|
||||
export type NewFaqQuestion = typeof sqliteFaqQuestions.$inferInsert;
|
||||
export type LegalSettings = typeof sqliteLegalSettings.$inferSelect;
|
||||
export type NewLegalSettings = typeof sqliteLegalSettings.$inferInsert;
|
||||
@@ -21,7 +21,10 @@ import paymentOptionsRoutes from './routes/payment-options.js';
|
||||
import dashboardRoutes from './routes/dashboard.js';
|
||||
import siteSettingsRoutes from './routes/site-settings.js';
|
||||
import legalPagesRoutes from './routes/legal-pages.js';
|
||||
import legalSettingsRoutes from './routes/legal-settings.js';
|
||||
import faqRoutes from './routes/faq.js';
|
||||
import emailService from './lib/email.js';
|
||||
import { initEmailQueue } from './lib/emailQueue.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -84,6 +87,7 @@ const openApiSpec = {
|
||||
{ name: 'Media', description: 'File uploads and media management' },
|
||||
{ name: 'Lightning', description: 'Lightning/Bitcoin payments via LNBits' },
|
||||
{ name: 'Admin', description: 'Admin dashboard and analytics' },
|
||||
{ name: 'FAQ', description: 'FAQ questions (public and admin)' },
|
||||
],
|
||||
paths: {
|
||||
// ==================== Auth Endpoints ====================
|
||||
@@ -1587,6 +1591,144 @@ const openApiSpec = {
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ==================== FAQ Endpoints ====================
|
||||
'/api/faq': {
|
||||
get: {
|
||||
tags: ['FAQ'],
|
||||
summary: 'Get FAQ list (public)',
|
||||
description: 'Returns enabled FAQ questions, ordered by rank. Use ?homepage=true to get only questions enabled for homepage.',
|
||||
parameters: [
|
||||
{ name: 'homepage', in: 'query', schema: { type: 'boolean' }, description: 'If true, only return questions with showOnHomepage' },
|
||||
],
|
||||
responses: {
|
||||
200: { description: 'List of FAQ items (id, question, questionEs, answer, answerEs, rank)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/faq/admin/list': {
|
||||
get: {
|
||||
tags: ['FAQ'],
|
||||
summary: 'Get all FAQ questions (admin)',
|
||||
description: 'Returns all FAQ questions for management, ordered by rank.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: {
|
||||
200: { description: 'List of all FAQ questions' },
|
||||
401: { description: 'Unauthorized' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/faq/admin/:id': {
|
||||
get: {
|
||||
tags: ['FAQ'],
|
||||
summary: 'Get FAQ by ID (admin)',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [
|
||||
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
|
||||
],
|
||||
responses: {
|
||||
200: { description: 'FAQ details' },
|
||||
404: { description: 'FAQ not found' },
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ['FAQ'],
|
||||
summary: 'Update FAQ (admin)',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [
|
||||
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
|
||||
],
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
question: { type: 'string' },
|
||||
questionEs: { type: 'string' },
|
||||
answer: { type: 'string' },
|
||||
answerEs: { type: 'string' },
|
||||
enabled: { type: 'boolean' },
|
||||
showOnHomepage: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: 'FAQ updated' },
|
||||
404: { description: 'FAQ not found' },
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ['FAQ'],
|
||||
summary: 'Delete FAQ (admin)',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [
|
||||
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
|
||||
],
|
||||
responses: {
|
||||
200: { description: 'FAQ deleted' },
|
||||
404: { description: 'FAQ not found' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/faq/admin': {
|
||||
post: {
|
||||
tags: ['FAQ'],
|
||||
summary: 'Create FAQ (admin)',
|
||||
security: [{ bearerAuth: [] }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['question', 'answer'],
|
||||
properties: {
|
||||
question: { type: 'string' },
|
||||
questionEs: { type: 'string' },
|
||||
answer: { type: 'string' },
|
||||
answerEs: { type: 'string' },
|
||||
enabled: { type: 'boolean', default: true },
|
||||
showOnHomepage: { type: 'boolean', default: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: { description: 'FAQ created' },
|
||||
400: { description: 'Validation error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/faq/admin/reorder': {
|
||||
post: {
|
||||
tags: ['FAQ'],
|
||||
summary: 'Reorder FAQ questions (admin)',
|
||||
description: 'Set order by sending an ordered array of FAQ ids.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['ids'],
|
||||
properties: {
|
||||
ids: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: 'Order updated, returns full FAQ list' },
|
||||
400: { description: 'ids array required' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
@@ -1716,6 +1858,8 @@ app.route('/api/payment-options', paymentOptionsRoutes);
|
||||
app.route('/api/dashboard', dashboardRoutes);
|
||||
app.route('/api/site-settings', siteSettingsRoutes);
|
||||
app.route('/api/legal-pages', legalPagesRoutes);
|
||||
app.route('/api/legal-settings', legalSettingsRoutes);
|
||||
app.route('/api/faq', faqRoutes);
|
||||
|
||||
// 404 handler
|
||||
app.notFound((c) => {
|
||||
@@ -1730,6 +1874,9 @@ app.onError((err, c) => {
|
||||
|
||||
const port = parseInt(process.env.PORT || '3001');
|
||||
|
||||
// Initialize email queue with the email service reference
|
||||
initEmailQueue(emailService);
|
||||
|
||||
// Initialize email templates on startup
|
||||
emailService.seedDefaultTemplates().catch(err => {
|
||||
console.error('[Email] Failed to seed templates:', err);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
defaultTemplates,
|
||||
type DefaultTemplate
|
||||
} from './emailTemplates.js';
|
||||
import { enqueueBulkEmails, type TemplateEmailJobParams } from './emailQueue.js';
|
||||
import nodemailer from 'nodemailer';
|
||||
import type { Transporter } from 'nodemailer';
|
||||
|
||||
@@ -1174,6 +1175,100 @@ export const emailService = {
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Queue emails for event attendees (non-blocking).
|
||||
* Adds all matching recipients to the background email queue and returns immediately.
|
||||
* Rate limiting and actual sending is handled by the email queue.
|
||||
*/
|
||||
async queueEventEmails(params: {
|
||||
eventId: string;
|
||||
templateSlug: string;
|
||||
customVariables?: Record<string, any>;
|
||||
recipientFilter?: 'all' | 'confirmed' | 'pending' | 'checked_in';
|
||||
sentBy: string;
|
||||
}): Promise<{ success: boolean; queuedCount: number; error?: string }> {
|
||||
const { eventId, templateSlug, customVariables = {}, recipientFilter = 'confirmed', sentBy } = params;
|
||||
|
||||
// Validate event exists
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, eventId))
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return { success: false, queuedCount: 0, error: 'Event not found' };
|
||||
}
|
||||
|
||||
// Validate template exists
|
||||
const template = await this.getTemplate(templateSlug);
|
||||
if (!template) {
|
||||
return { success: false, queuedCount: 0, error: `Template "${templateSlug}" not found` };
|
||||
}
|
||||
|
||||
// Get tickets based on filter
|
||||
let ticketQuery = (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).eventId, eventId));
|
||||
|
||||
if (recipientFilter !== 'all') {
|
||||
ticketQuery = ticketQuery.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, eventId),
|
||||
eq((tickets as any).status, recipientFilter)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const eventTickets = await dbAll<any>(ticketQuery);
|
||||
|
||||
if (eventTickets.length === 0) {
|
||||
return { success: true, queuedCount: 0, error: 'No recipients found' };
|
||||
}
|
||||
|
||||
// Get site timezone for proper date/time formatting
|
||||
const timezone = await this.getSiteTimezone();
|
||||
|
||||
// Build individual email jobs for the queue
|
||||
const jobs: TemplateEmailJobParams[] = eventTickets.map((ticket: any) => {
|
||||
const locale = ticket.preferredLanguage || 'en';
|
||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||
|
||||
return {
|
||||
templateSlug,
|
||||
to: ticket.attendeeEmail,
|
||||
toName: fullName,
|
||||
locale,
|
||||
eventId: event.id,
|
||||
sentBy,
|
||||
variables: {
|
||||
attendeeName: fullName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
ticketId: ticket.id,
|
||||
eventTitle,
|
||||
eventDate: this.formatDate(event.startDatetime, locale, timezone),
|
||||
eventTime: this.formatTime(event.startDatetime, locale, timezone),
|
||||
eventLocation: event.location,
|
||||
eventLocationUrl: event.locationUrl || '',
|
||||
...customVariables,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Enqueue all emails for background processing
|
||||
enqueueBulkEmails(jobs);
|
||||
|
||||
console.log(`[Email] Queued ${jobs.length} emails for event "${event.title}" (filter: ${recipientFilter})`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
queuedCount: jobs.length,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a custom email (not from template)
|
||||
*/
|
||||
@@ -1183,10 +1278,11 @@ export const emailService = {
|
||||
subject: string;
|
||||
bodyHtml: string;
|
||||
bodyText?: string;
|
||||
replyTo?: string;
|
||||
eventId?: string;
|
||||
sentBy: string;
|
||||
sentBy?: string | null;
|
||||
}): Promise<{ success: boolean; logId?: string; error?: string }> {
|
||||
const { to, toName, subject, bodyHtml, bodyText, eventId, sentBy } = params;
|
||||
const { to, toName, subject, bodyHtml, bodyText, replyTo, eventId, sentBy = null } = params;
|
||||
|
||||
const allVariables = {
|
||||
...this.getCommonVariables(),
|
||||
@@ -1208,7 +1304,7 @@ export const emailService = {
|
||||
subject,
|
||||
bodyHtml: finalBodyHtml,
|
||||
status: 'pending',
|
||||
sentBy,
|
||||
sentBy: sentBy || null,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
@@ -1218,6 +1314,7 @@ export const emailService = {
|
||||
subject,
|
||||
html: finalBodyHtml,
|
||||
text: bodyText,
|
||||
replyTo,
|
||||
});
|
||||
|
||||
// Update log
|
||||
|
||||
194
backend/src/lib/emailQueue.ts
Normal file
194
backend/src/lib/emailQueue.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
// In-memory email queue with rate limiting
|
||||
// Processes emails asynchronously in the background without blocking the request thread
|
||||
|
||||
import { generateId } from './utils.js';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
export interface EmailJob {
|
||||
id: string;
|
||||
type: 'template';
|
||||
params: TemplateEmailJobParams;
|
||||
addedAt: number;
|
||||
}
|
||||
|
||||
export interface TemplateEmailJobParams {
|
||||
templateSlug: string;
|
||||
to: string;
|
||||
toName?: string;
|
||||
variables: Record<string, any>;
|
||||
locale?: string;
|
||||
eventId?: string;
|
||||
sentBy?: string;
|
||||
}
|
||||
|
||||
export interface QueueStatus {
|
||||
queued: number;
|
||||
processing: boolean;
|
||||
sentInLastHour: number;
|
||||
maxPerHour: number;
|
||||
}
|
||||
|
||||
// ==================== Queue State ====================
|
||||
|
||||
const queue: EmailJob[] = [];
|
||||
const sentTimestamps: number[] = [];
|
||||
let processing = false;
|
||||
let processTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Lazy reference to emailService to avoid circular imports
|
||||
let _emailService: any = null;
|
||||
|
||||
function getEmailService() {
|
||||
if (!_emailService) {
|
||||
// Dynamic import to avoid circular dependency
|
||||
throw new Error('[EmailQueue] Email service not initialized. Call initEmailQueue() first.');
|
||||
}
|
||||
return _emailService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the email queue with a reference to the email service.
|
||||
* Must be called once at startup.
|
||||
*/
|
||||
export function initEmailQueue(emailService: any): void {
|
||||
_emailService = emailService;
|
||||
console.log('[EmailQueue] Initialized');
|
||||
}
|
||||
|
||||
// ==================== Rate Limiting ====================
|
||||
|
||||
function getMaxPerHour(): number {
|
||||
return parseInt(process.env.MAX_EMAILS_PER_HOUR || '30', 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up timestamps older than 1 hour
|
||||
*/
|
||||
function cleanOldTimestamps(): void {
|
||||
const oneHourAgo = Date.now() - 3_600_000;
|
||||
while (sentTimestamps.length > 0 && sentTimestamps[0] <= oneHourAgo) {
|
||||
sentTimestamps.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Queue Operations ====================
|
||||
|
||||
/**
|
||||
* Add a single email job to the queue.
|
||||
* Returns the job ID.
|
||||
*/
|
||||
export function enqueueEmail(params: TemplateEmailJobParams): string {
|
||||
const id = generateId();
|
||||
queue.push({
|
||||
id,
|
||||
type: 'template',
|
||||
params,
|
||||
addedAt: Date.now(),
|
||||
});
|
||||
scheduleProcessing();
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple email jobs to the queue at once.
|
||||
* Returns array of job IDs.
|
||||
*/
|
||||
export function enqueueBulkEmails(paramsList: TemplateEmailJobParams[]): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const params of paramsList) {
|
||||
const id = generateId();
|
||||
queue.push({
|
||||
id,
|
||||
type: 'template',
|
||||
params,
|
||||
addedAt: Date.now(),
|
||||
});
|
||||
ids.push(id);
|
||||
}
|
||||
if (ids.length > 0) {
|
||||
console.log(`[EmailQueue] Queued ${ids.length} emails for background processing`);
|
||||
scheduleProcessing();
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current queue status
|
||||
*/
|
||||
export function getQueueStatus(): QueueStatus {
|
||||
cleanOldTimestamps();
|
||||
return {
|
||||
queued: queue.length,
|
||||
processing,
|
||||
sentInLastHour: sentTimestamps.length,
|
||||
maxPerHour: getMaxPerHour(),
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Processing ====================
|
||||
|
||||
function scheduleProcessing(): void {
|
||||
if (processing) return;
|
||||
processing = true;
|
||||
// Start processing on next tick to not block the caller
|
||||
setImmediate(() => processNext());
|
||||
}
|
||||
|
||||
async function processNext(): Promise<void> {
|
||||
if (queue.length === 0) {
|
||||
processing = false;
|
||||
console.log('[EmailQueue] Queue empty. Processing stopped.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Rate limit check
|
||||
cleanOldTimestamps();
|
||||
const maxPerHour = getMaxPerHour();
|
||||
|
||||
if (sentTimestamps.length >= maxPerHour) {
|
||||
// Calculate when the oldest timestamp in the window expires
|
||||
const waitMs = sentTimestamps[0] + 3_600_000 - Date.now() + 500; // 500ms buffer
|
||||
console.log(
|
||||
`[EmailQueue] Rate limit reached (${maxPerHour}/hr). ` +
|
||||
`Pausing for ${Math.ceil(waitMs / 1000)}s. ${queue.length} email(s) remaining.`
|
||||
);
|
||||
processTimer = setTimeout(() => processNext(), waitMs);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dequeue and process
|
||||
const job = queue.shift()!;
|
||||
|
||||
try {
|
||||
const emailService = getEmailService();
|
||||
await emailService.sendTemplateEmail(job.params);
|
||||
sentTimestamps.push(Date.now());
|
||||
console.log(
|
||||
`[EmailQueue] Sent email ${job.id} to ${job.params.to}. ` +
|
||||
`Queue: ${queue.length} remaining. Sent this hour: ${sentTimestamps.length}/${maxPerHour}`
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`[EmailQueue] Failed to send email ${job.id} to ${job.params.to}:`,
|
||||
error?.message || error
|
||||
);
|
||||
// The sendTemplateEmail method already logs the failure in the email_logs table,
|
||||
// so we don't need to retry here. The error is logged and we move on.
|
||||
}
|
||||
|
||||
// Small delay between sends to be gentle on the email server
|
||||
processTimer = setTimeout(() => processNext(), 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop processing (for graceful shutdown)
|
||||
*/
|
||||
export function stopQueue(): void {
|
||||
if (processTimer) {
|
||||
clearTimeout(processTimer);
|
||||
processTimer = null;
|
||||
}
|
||||
processing = false;
|
||||
console.log(`[EmailQueue] Stopped. ${queue.length} email(s) remaining in queue.`);
|
||||
}
|
||||
80
backend/src/lib/legal-placeholders.ts
Normal file
80
backend/src/lib/legal-placeholders.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { getLegalSettingsValues } from '../routes/legal-settings.js';
|
||||
|
||||
/**
|
||||
* Strict whitelist of supported placeholders.
|
||||
* Only these placeholders will be replaced in legal page content.
|
||||
* Unknown placeholders remain unchanged.
|
||||
*/
|
||||
const SUPPORTED_PLACEHOLDERS = new Set([
|
||||
'COMPANY_NAME',
|
||||
'LEGAL_ENTITY_NAME',
|
||||
'RUC_NUMBER',
|
||||
'COMPANY_ADDRESS',
|
||||
'COMPANY_CITY',
|
||||
'COMPANY_COUNTRY',
|
||||
'SUPPORT_EMAIL',
|
||||
'LEGAL_EMAIL',
|
||||
'GOVERNING_LAW',
|
||||
'JURISDICTION_CITY',
|
||||
'CURRENT_YEAR',
|
||||
'LAST_UPDATED_DATE',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Replace legal placeholders in content using strict whitelist mapping.
|
||||
*
|
||||
* Rules:
|
||||
* - Only supported placeholders are replaced
|
||||
* - Unknown placeholders remain unchanged
|
||||
* - Missing values are replaced with empty string
|
||||
* - No code execution or dynamic evaluation
|
||||
* - Replacement is pure string substitution
|
||||
*
|
||||
* @param content - The markdown/text content containing {{PLACEHOLDER}} tokens
|
||||
* @param updatedAt - The page's updated_at timestamp (for LAST_UPDATED_DATE)
|
||||
* @returns Content with placeholders replaced
|
||||
*/
|
||||
export async function replaceLegalPlaceholders(
|
||||
content: string,
|
||||
updatedAt?: string
|
||||
): Promise<string> {
|
||||
if (!content) return content;
|
||||
|
||||
// Fetch legal settings values from DB
|
||||
const settingsValues = await getLegalSettingsValues();
|
||||
|
||||
// Build the full replacement map
|
||||
const replacements: Record<string, string> = { ...settingsValues };
|
||||
|
||||
// Dynamic values
|
||||
replacements['CURRENT_YEAR'] = new Date().getFullYear().toString();
|
||||
|
||||
if (updatedAt) {
|
||||
try {
|
||||
const date = new Date(updatedAt);
|
||||
if (!isNaN(date.getTime())) {
|
||||
replacements['LAST_UPDATED_DATE'] = date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
} else {
|
||||
replacements['LAST_UPDATED_DATE'] = updatedAt;
|
||||
}
|
||||
} catch {
|
||||
replacements['LAST_UPDATED_DATE'] = updatedAt;
|
||||
}
|
||||
}
|
||||
|
||||
// Replace only whitelisted placeholders using a single regex pass
|
||||
// Matches {{PLACEHOLDER_NAME}} where PLACEHOLDER_NAME is uppercase letters and underscores
|
||||
return content.replace(/\{\{([A-Z_]+)\}\}/g, (match, placeholderName) => {
|
||||
// Only replace if the placeholder is in the whitelist
|
||||
if (!SUPPORTED_PLACEHOLDERS.has(placeholderName)) {
|
||||
return match; // Unknown placeholder - leave unchanged
|
||||
}
|
||||
|
||||
// Return the value or empty string if missing
|
||||
return replacements[placeholderName] ?? '';
|
||||
});
|
||||
}
|
||||
22
backend/src/lib/revalidate.ts
Normal file
22
backend/src/lib/revalidate.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// Trigger frontend cache revalidation (fire-and-forget)
|
||||
// Revalidates both the sitemap and the next-event data (homepage, llms.txt)
|
||||
export function revalidateFrontendCache() {
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
|
||||
const secret = process.env.REVALIDATE_SECRET;
|
||||
if (!secret) {
|
||||
console.warn('REVALIDATE_SECRET not set, skipping frontend revalidation');
|
||||
return;
|
||||
}
|
||||
fetch(`${frontendUrl}/api/revalidate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ secret, tag: ['events-sitemap', 'next-event'] }),
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) console.error('Frontend revalidation failed:', res.status);
|
||||
else console.log('Frontend revalidation triggered (sitemap + next-event)');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Frontend revalidation error:', err.message);
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from 'hono';
|
||||
import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js';
|
||||
import { eq, and, gte, sql, desc } from 'drizzle-orm';
|
||||
import { eq, and, gte, sql, desc, inArray } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
|
||||
@@ -222,6 +222,211 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
|
||||
return c.json({ tickets: enrichedTickets });
|
||||
});
|
||||
|
||||
// Export attendees for a specific event (admin) — CSV download
|
||||
adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), async (c) => {
|
||||
const eventId = c.req.param('eventId');
|
||||
const status = c.req.query('status') || 'all'; // confirmed | checked_in | confirmed_pending | all
|
||||
const q = c.req.query('q') || '';
|
||||
|
||||
// Verify event exists
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, eventId))
|
||||
);
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
// Build query for tickets belonging to this event
|
||||
let conditions: any[] = [eq((tickets as any).eventId, eventId)];
|
||||
|
||||
if (status === 'confirmed') {
|
||||
conditions.push(eq((tickets as any).status, 'confirmed'));
|
||||
} else if (status === 'checked_in') {
|
||||
conditions.push(eq((tickets as any).status, 'checked_in'));
|
||||
} else if (status === 'confirmed_pending') {
|
||||
conditions.push(inArray((tickets as any).status, ['confirmed', 'pending']));
|
||||
} else {
|
||||
// "all" — include everything
|
||||
}
|
||||
|
||||
let ticketList = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(conditions.length === 1 ? conditions[0] : and(...conditions))
|
||||
.orderBy(desc((tickets as any).createdAt))
|
||||
);
|
||||
|
||||
// Apply text search filter in-memory
|
||||
if (q) {
|
||||
const query = q.toLowerCase();
|
||||
ticketList = ticketList.filter((t: any) => {
|
||||
const fullName = `${t.attendeeFirstName || ''} ${t.attendeeLastName || ''}`.toLowerCase();
|
||||
return (
|
||||
fullName.includes(query) ||
|
||||
(t.attendeeEmail || '').toLowerCase().includes(query) ||
|
||||
(t.attendeePhone || '').toLowerCase().includes(query) ||
|
||||
t.id.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Enrich each ticket with payment data
|
||||
const rows = await Promise.all(
|
||||
ticketList.map(async (ticket: any) => {
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticket.id))
|
||||
);
|
||||
|
||||
const fullName = [ticket.attendeeFirstName, ticket.attendeeLastName].filter(Boolean).join(' ');
|
||||
const isCheckedIn = ticket.status === 'checked_in';
|
||||
|
||||
return {
|
||||
'Ticket ID': ticket.id,
|
||||
'Full Name': fullName,
|
||||
'Email': ticket.attendeeEmail || '',
|
||||
'Phone': ticket.attendeePhone || '',
|
||||
'Status': ticket.status,
|
||||
'Checked In': isCheckedIn ? 'true' : 'false',
|
||||
'Check-in Time': ticket.checkinAt || '',
|
||||
'Payment Status': payment?.status || '',
|
||||
'Booked At': ticket.createdAt || '',
|
||||
'Notes': ticket.adminNote || '',
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Generate CSV
|
||||
const csvEscape = (value: string) => {
|
||||
if (value == null) return '';
|
||||
const str = String(value);
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
||||
return '"' + str.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
'Ticket ID', 'Full Name', 'Email', 'Phone',
|
||||
'Status', 'Checked In', 'Check-in Time', 'Payment Status',
|
||||
'Booked At', 'Notes',
|
||||
];
|
||||
|
||||
const headerLine = columns.map(csvEscape).join(',');
|
||||
const dataLines = rows.map((row: any) =>
|
||||
columns.map((col) => csvEscape(row[col])).join(',')
|
||||
);
|
||||
|
||||
const csvContent = '\uFEFF' + [headerLine, ...dataLines].join('\r\n'); // BOM for UTF-8
|
||||
|
||||
// Build filename: event-slug-attendees-YYYY-MM-DD.csv
|
||||
const slug = (event.title || 'event')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '');
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
const filename = `${slug}-attendees-${dateStr}.csv`;
|
||||
|
||||
c.header('Content-Type', 'text/csv; charset=utf-8');
|
||||
c.header('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
return c.body(csvContent);
|
||||
});
|
||||
|
||||
// Legacy alias — keep old path working
|
||||
adminRouter.get('/events/:eventId/export', requireAuth(['admin']), async (c) => {
|
||||
const newUrl = new URL(c.req.url);
|
||||
newUrl.pathname = newUrl.pathname.replace('/export', '/attendees/export');
|
||||
return c.redirect(newUrl.toString(), 301);
|
||||
});
|
||||
|
||||
// Export tickets for a specific event (admin) — CSV download (confirmed/checked_in only)
|
||||
adminRouter.get('/events/:eventId/tickets/export', requireAuth(['admin']), async (c) => {
|
||||
const eventId = c.req.param('eventId');
|
||||
const status = c.req.query('status') || 'all'; // confirmed | checked_in | all
|
||||
const q = c.req.query('q') || '';
|
||||
|
||||
// Verify event exists
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, eventId))
|
||||
);
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
// Only confirmed/checked_in for tickets export
|
||||
let conditions: any[] = [
|
||||
eq((tickets as any).eventId, eventId),
|
||||
inArray((tickets as any).status, ['confirmed', 'checked_in']),
|
||||
];
|
||||
|
||||
if (status === 'confirmed') {
|
||||
conditions = [eq((tickets as any).eventId, eventId), eq((tickets as any).status, 'confirmed')];
|
||||
} else if (status === 'checked_in') {
|
||||
conditions = [eq((tickets as any).eventId, eventId), eq((tickets as any).status, 'checked_in')];
|
||||
}
|
||||
|
||||
let ticketList = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc((tickets as any).createdAt))
|
||||
);
|
||||
|
||||
// Apply text search filter
|
||||
if (q) {
|
||||
const query = q.toLowerCase();
|
||||
ticketList = ticketList.filter((t: any) => {
|
||||
const fullName = `${t.attendeeFirstName || ''} ${t.attendeeLastName || ''}`.toLowerCase();
|
||||
return (
|
||||
fullName.includes(query) ||
|
||||
t.id.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const csvEscape = (value: string) => {
|
||||
if (value == null) return '';
|
||||
const str = String(value);
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
||||
return '"' + str.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
const columns = ['Ticket ID', 'Booking ID', 'Attendee Name', 'Status', 'Check-in Time', 'Booked At'];
|
||||
|
||||
const rows = ticketList.map((ticket: any) => ({
|
||||
'Ticket ID': ticket.id,
|
||||
'Booking ID': ticket.bookingId || '',
|
||||
'Attendee Name': [ticket.attendeeFirstName, ticket.attendeeLastName].filter(Boolean).join(' '),
|
||||
'Status': ticket.status,
|
||||
'Check-in Time': ticket.checkinAt || '',
|
||||
'Booked At': ticket.createdAt || '',
|
||||
}));
|
||||
|
||||
const headerLine = columns.map(csvEscape).join(',');
|
||||
const dataLines = rows.map((row: any) =>
|
||||
columns.map((col: string) => csvEscape(row[col])).join(',')
|
||||
);
|
||||
|
||||
const csvContent = '\uFEFF' + [headerLine, ...dataLines].join('\r\n');
|
||||
|
||||
const slug = (event.title || 'event')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '');
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
const filename = `${slug}-tickets-${dateStr}.csv`;
|
||||
|
||||
c.header('Content-Type', 'text/csv; charset=utf-8');
|
||||
c.header('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
return c.body(csvContent);
|
||||
});
|
||||
|
||||
// Export financial data (admin)
|
||||
adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => {
|
||||
const startDate = c.req.query('startDate');
|
||||
|
||||
@@ -1,13 +1,37 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, dbAll, contacts, emailSubscribers } from '../db/index.js';
|
||||
import { db, dbGet, dbAll, contacts, emailSubscribers, legalSettings } from '../db/index.js';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { generateId, getNow } from '../lib/utils.js';
|
||||
import { emailService } from '../lib/email.js';
|
||||
|
||||
const contactsRouter = new Hono();
|
||||
|
||||
// ==================== Sanitization Helpers ====================
|
||||
|
||||
/**
|
||||
* Sanitize a string to prevent HTML injection
|
||||
* Escapes HTML special characters
|
||||
*/
|
||||
function sanitizeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize email header values to prevent email header injection
|
||||
* Strips newlines and carriage returns that could be used to inject headers
|
||||
*/
|
||||
function sanitizeHeaderValue(str: string): string {
|
||||
return str.replace(/[\r\n]/g, '').trim();
|
||||
}
|
||||
|
||||
const createContactSchema = z.object({
|
||||
name: z.string().min(2),
|
||||
email: z.string().email(),
|
||||
@@ -29,17 +53,74 @@ contactsRouter.post('/', zValidator('json', createContactSchema), async (c) => {
|
||||
const now = getNow();
|
||||
const id = generateId();
|
||||
|
||||
// Sanitize header-sensitive values to prevent email header injection
|
||||
const sanitizedEmail = sanitizeHeaderValue(data.email);
|
||||
const sanitizedName = sanitizeHeaderValue(data.name);
|
||||
|
||||
const newContact = {
|
||||
id,
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
name: sanitizedName,
|
||||
email: sanitizedEmail,
|
||||
message: data.message,
|
||||
status: 'new' as const,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
// Always store the message in admin, regardless of email outcome
|
||||
await (db as any).insert(contacts).values(newContact);
|
||||
|
||||
// Send email notification to support email (non-blocking)
|
||||
try {
|
||||
// Retrieve support_email from legal_settings
|
||||
const settings = await dbGet<any>(
|
||||
(db as any).select().from(legalSettings).limit(1)
|
||||
);
|
||||
|
||||
const supportEmail = settings?.supportEmail;
|
||||
|
||||
if (supportEmail) {
|
||||
const websiteUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
|
||||
|
||||
// Sanitize all values for HTML display
|
||||
const safeName = sanitizeHtml(sanitizedName);
|
||||
const safeEmail = sanitizeHtml(sanitizedEmail);
|
||||
const safeMessage = sanitizeHtml(data.message);
|
||||
|
||||
const subject = `New Contact Form Message – ${websiteUrl}`;
|
||||
|
||||
const bodyHtml = `
|
||||
<p><strong>${safeName}</strong> (${safeEmail}) sent a message:</p>
|
||||
<div style="padding: 16px 20px; background-color: #f8fafc; border-left: 4px solid #3b82f6; margin: 16px 0; white-space: pre-wrap; font-size: 15px; line-height: 1.6;">${safeMessage}</div>
|
||||
<p style="color: #64748b; font-size: 13px;">Reply directly to this email to respond to ${safeName}.</p>
|
||||
`;
|
||||
|
||||
const bodyText = [
|
||||
`${sanitizedName} (${sanitizedEmail}) sent a message:`,
|
||||
'',
|
||||
data.message,
|
||||
'',
|
||||
`Reply directly to this email to respond to ${sanitizedName}.`,
|
||||
].join('\n');
|
||||
|
||||
const emailResult = await emailService.sendCustomEmail({
|
||||
to: supportEmail,
|
||||
subject,
|
||||
bodyHtml,
|
||||
bodyText,
|
||||
replyTo: sanitizedEmail,
|
||||
});
|
||||
|
||||
if (!emailResult.success) {
|
||||
console.error('[Contact Form] Failed to send email notification:', emailResult.error);
|
||||
}
|
||||
} else {
|
||||
console.warn('[Contact Form] No support email configured in legal settings – skipping email notification');
|
||||
}
|
||||
} catch (emailError: any) {
|
||||
// Log the error but do NOT break the contact form UX
|
||||
console.error('[Contact Form] Error sending email notification:', emailError?.message || emailError);
|
||||
}
|
||||
|
||||
return c.json({ message: 'Message sent successfully' }, 201);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { requireAuth } from '../lib/auth.js';
|
||||
import { getNow, generateId } from '../lib/utils.js';
|
||||
import emailService from '../lib/email.js';
|
||||
import { getTemplateVariables, defaultTemplates } from '../lib/emailTemplates.js';
|
||||
import { getQueueStatus } from '../lib/emailQueue.js';
|
||||
|
||||
const emailsRouter = new Hono();
|
||||
|
||||
@@ -195,7 +196,7 @@ emailsRouter.get('/templates/:slug/variables', requireAuth(['admin', 'organizer'
|
||||
|
||||
// ==================== Email Sending Routes ====================
|
||||
|
||||
// Send email using template to event attendees
|
||||
// Send email using template to event attendees (non-blocking, queued)
|
||||
emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const { eventId } = c.req.param();
|
||||
const user = (c as any).get('user');
|
||||
@@ -206,7 +207,8 @@ emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), a
|
||||
return c.json({ error: 'Template slug is required' }, 400);
|
||||
}
|
||||
|
||||
const result = await emailService.sendToEventAttendees({
|
||||
// Queue emails for background processing instead of sending synchronously
|
||||
const result = await emailService.queueEventEmails({
|
||||
eventId,
|
||||
templateSlug,
|
||||
customVariables,
|
||||
@@ -411,4 +413,10 @@ emailsRouter.post('/test', requireAuth(['admin']), async (c) => {
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// Get email queue status
|
||||
emailsRouter.get('/queue/status', requireAuth(['admin']), async (c) => {
|
||||
const status = getQueueStatus();
|
||||
return c.json({ status });
|
||||
});
|
||||
|
||||
export default emailsRouter;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { db, dbGet, dbAll, events, tickets, payments, eventPaymentOverrides, ema
|
||||
import { eq, desc, and, gte, sql } from 'drizzle-orm';
|
||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||
import { generateId, getNow, convertBooleansForDb, toDbDate, calculateAvailableSeats } from '../lib/utils.js';
|
||||
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||
|
||||
interface UserContext {
|
||||
id: string;
|
||||
@@ -337,6 +338,9 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
|
||||
|
||||
await (db as any).insert(events).values(newEvent);
|
||||
|
||||
// Revalidate sitemap when a new event is created
|
||||
revalidateFrontendCache();
|
||||
|
||||
// Return normalized event data
|
||||
return c.json({ event: normalizeEvent(newEvent) }, 201);
|
||||
});
|
||||
@@ -373,6 +377,9 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
|
||||
(db as any).select().from(events).where(eq((events as any).id, id))
|
||||
);
|
||||
|
||||
// Revalidate sitemap when an event is updated (status/dates may have changed)
|
||||
revalidateFrontendCache();
|
||||
|
||||
return c.json({ event: normalizeEvent(updated) });
|
||||
});
|
||||
|
||||
@@ -429,6 +436,9 @@ eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
|
||||
// Finally delete the event
|
||||
await (db as any).delete(events).where(eq((events as any).id, id));
|
||||
|
||||
// Revalidate sitemap when an event is deleted
|
||||
revalidateFrontendCache();
|
||||
|
||||
return c.json({ message: 'Event deleted successfully' });
|
||||
});
|
||||
|
||||
|
||||
242
backend/src/routes/faq.ts
Normal file
242
backend/src/routes/faq.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { Hono } from 'hono';
|
||||
import { db, dbGet, dbAll, faqQuestions } from '../db/index.js';
|
||||
import { eq, asc } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { getNow, generateId } from '../lib/utils.js';
|
||||
|
||||
const faqRouter = new Hono();
|
||||
|
||||
// ==================== Public Routes ====================
|
||||
|
||||
// Get FAQ list for public (only enabled; optional filter for homepage)
|
||||
faqRouter.get('/', async (c) => {
|
||||
const homepage = c.req.query('homepage') === 'true';
|
||||
|
||||
let query = (db as any)
|
||||
.select()
|
||||
.from(faqQuestions)
|
||||
.where(eq((faqQuestions as any).enabled, 1))
|
||||
.orderBy(asc((faqQuestions as any).rank), asc((faqQuestions as any).createdAt));
|
||||
|
||||
const rows = await dbAll<any>(query);
|
||||
|
||||
let items = rows;
|
||||
if (homepage) {
|
||||
items = rows.filter((r: any) => r.showOnHomepage === true || r.showOnHomepage === 1 || r.show_on_homepage === true || r.show_on_homepage === 1);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
faqs: items.map((r: any) => ({
|
||||
id: r.id,
|
||||
question: r.question,
|
||||
questionEs: r.questionEs ?? r.question_es ?? null,
|
||||
answer: r.answer,
|
||||
answerEs: r.answerEs ?? r.answer_es ?? null,
|
||||
rank: r.rank ?? 0,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Admin Routes ====================
|
||||
|
||||
// Get all FAQ questions for admin (all, ordered by rank)
|
||||
faqRouter.get('/admin/list', requireAuth(['admin']), async (c) => {
|
||||
const rows = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(faqQuestions)
|
||||
.orderBy(asc((faqQuestions as any).rank), asc((faqQuestions as any).createdAt))
|
||||
);
|
||||
|
||||
const list = rows.map((r: any) => ({
|
||||
id: r.id,
|
||||
question: r.question,
|
||||
questionEs: r.questionEs ?? r.question_es ?? null,
|
||||
answer: r.answer,
|
||||
answerEs: r.answerEs ?? r.answer_es ?? null,
|
||||
enabled: r.enabled === true || r.enabled === 1,
|
||||
showOnHomepage: r.showOnHomepage === true || r.showOnHomepage === 1 || r.show_on_homepage === true || r.show_on_homepage === 1,
|
||||
rank: r.rank ?? 0,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
}));
|
||||
|
||||
return c.json({ faqs: list });
|
||||
});
|
||||
|
||||
// Get one FAQ by id (admin)
|
||||
faqRouter.get('/admin/:id', requireAuth(['admin']), async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const row = await dbGet<any>(
|
||||
(db as any).select().from(faqQuestions).where(eq((faqQuestions as any).id, id))
|
||||
);
|
||||
if (!row) {
|
||||
return c.json({ error: 'FAQ not found' }, 404);
|
||||
}
|
||||
return c.json({
|
||||
faq: {
|
||||
id: row.id,
|
||||
question: row.question,
|
||||
questionEs: row.questionEs ?? row.question_es ?? null,
|
||||
answer: row.answer,
|
||||
answerEs: row.answerEs ?? row.answer_es ?? null,
|
||||
enabled: row.enabled === true || row.enabled === 1,
|
||||
showOnHomepage: row.showOnHomepage === true || row.showOnHomepage === 1 || row.show_on_homepage === true || row.show_on_homepage === 1,
|
||||
rank: row.rank ?? 0,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Create FAQ (admin)
|
||||
faqRouter.post('/admin', requireAuth(['admin']), async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { question, questionEs, answer, answerEs, enabled, showOnHomepage } = body;
|
||||
|
||||
if (!question || typeof question !== 'string' || !answer || typeof answer !== 'string') {
|
||||
return c.json({ error: 'Question and answer (EN) are required' }, 400);
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
const id = generateId();
|
||||
|
||||
const allForRank = await dbAll<any>(
|
||||
(db as any).select({ rank: (faqQuestions as any).rank }).from(faqQuestions)
|
||||
);
|
||||
const maxRank = allForRank.length
|
||||
? Math.max(...allForRank.map((r: any) => Number(r.rank ?? 0)))
|
||||
: 0;
|
||||
const nextRank = maxRank + 1;
|
||||
|
||||
await (db as any).insert(faqQuestions).values({
|
||||
id,
|
||||
question: String(question).trim(),
|
||||
questionEs: questionEs != null ? String(questionEs).trim() : null,
|
||||
answer: String(answer).trim(),
|
||||
answerEs: answerEs != null ? String(answerEs).trim() : null,
|
||||
enabled: enabled !== false ? 1 : 0,
|
||||
showOnHomepage: showOnHomepage === true ? 1 : 0,
|
||||
rank: nextRank,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const created = await dbGet<any>(
|
||||
(db as any).select().from(faqQuestions).where(eq((faqQuestions as any).id, id))
|
||||
);
|
||||
return c.json(
|
||||
{
|
||||
faq: {
|
||||
id: created.id,
|
||||
question: created.question,
|
||||
questionEs: created.questionEs ?? created.question_es ?? null,
|
||||
answer: created.answer,
|
||||
answerEs: created.answerEs ?? created.answer_es ?? null,
|
||||
enabled: created.enabled === true || created.enabled === 1,
|
||||
showOnHomepage: created.showOnHomepage === true || created.showOnHomepage === 1 || created.show_on_homepage === true || created.show_on_homepage === 1,
|
||||
rank: created.rank ?? 0,
|
||||
createdAt: created.createdAt,
|
||||
updatedAt: created.updatedAt,
|
||||
},
|
||||
},
|
||||
201
|
||||
);
|
||||
});
|
||||
|
||||
// Update FAQ (admin)
|
||||
faqRouter.put('/admin/:id', requireAuth(['admin']), async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const body = await c.req.json();
|
||||
const { question, questionEs, answer, answerEs, enabled, showOnHomepage } = body;
|
||||
|
||||
const existing = await dbGet<any>(
|
||||
(db as any).select().from(faqQuestions).where(eq((faqQuestions as any).id, id))
|
||||
);
|
||||
if (!existing) {
|
||||
return c.json({ error: 'FAQ not found' }, 404);
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
updatedAt: getNow(),
|
||||
};
|
||||
if (question !== undefined) updateData.question = String(question).trim();
|
||||
if (questionEs !== undefined) updateData.questionEs = questionEs == null ? null : String(questionEs).trim();
|
||||
if (answer !== undefined) updateData.answer = String(answer).trim();
|
||||
if (answerEs !== undefined) updateData.answerEs = answerEs == null ? null : String(answerEs).trim();
|
||||
if (typeof enabled === 'boolean') updateData.enabled = enabled ? 1 : 0;
|
||||
if (typeof showOnHomepage === 'boolean') updateData.showOnHomepage = showOnHomepage ? 1 : 0;
|
||||
|
||||
await (db as any).update(faqQuestions).set(updateData).where(eq((faqQuestions as any).id, id));
|
||||
|
||||
const updated = await dbGet<any>(
|
||||
(db as any).select().from(faqQuestions).where(eq((faqQuestions as any).id, id))
|
||||
);
|
||||
return c.json({
|
||||
faq: {
|
||||
id: updated.id,
|
||||
question: updated.question,
|
||||
questionEs: updated.questionEs ?? updated.question_es ?? null,
|
||||
answer: updated.answer,
|
||||
answerEs: updated.answerEs ?? updated.answer_es ?? null,
|
||||
enabled: updated.enabled === true || updated.enabled === 1,
|
||||
showOnHomepage: updated.showOnHomepage === true || updated.showOnHomepage === 1 || updated.show_on_homepage === true || updated.show_on_homepage === 1,
|
||||
rank: updated.rank ?? 0,
|
||||
createdAt: updated.createdAt,
|
||||
updatedAt: updated.updatedAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Delete FAQ (admin)
|
||||
faqRouter.delete('/admin/:id', requireAuth(['admin']), async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const existing = await dbGet<any>(
|
||||
(db as any).select().from(faqQuestions).where(eq((faqQuestions as any).id, id))
|
||||
);
|
||||
if (!existing) {
|
||||
return c.json({ error: 'FAQ not found' }, 404);
|
||||
}
|
||||
await (db as any).delete(faqQuestions).where(eq((faqQuestions as any).id, id));
|
||||
return c.json({ message: 'FAQ deleted' });
|
||||
});
|
||||
|
||||
// Reorder FAQs (admin) – body: { ids: string[] } (ordered list of ids)
|
||||
faqRouter.post('/admin/reorder', requireAuth(['admin']), async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { ids } = body;
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return c.json({ error: 'ids array is required' }, 400);
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
await (db as any)
|
||||
.update(faqQuestions)
|
||||
.set({ rank: i, updatedAt: now })
|
||||
.where(eq((faqQuestions as any).id, ids[i]));
|
||||
}
|
||||
|
||||
const rows = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(faqQuestions)
|
||||
.orderBy(asc((faqQuestions as any).rank), asc((faqQuestions as any).createdAt))
|
||||
);
|
||||
const list = rows.map((r: any) => ({
|
||||
id: r.id,
|
||||
question: r.question,
|
||||
questionEs: r.questionEs ?? r.question_es ?? null,
|
||||
answer: r.answer,
|
||||
answerEs: r.answerEs ?? r.answer_es ?? null,
|
||||
enabled: r.enabled === true || r.enabled === 1,
|
||||
showOnHomepage: r.showOnHomepage === true || r.showOnHomepage === 1 || r.show_on_homepage === true || r.show_on_homepage === 1,
|
||||
rank: r.rank ?? 0,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
}));
|
||||
|
||||
return c.json({ faqs: list });
|
||||
});
|
||||
|
||||
export default faqRouter;
|
||||
@@ -3,6 +3,7 @@ import { db, dbGet, dbAll, legalPages } from '../db/index.js';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { getNow, generateId } from '../lib/utils.js';
|
||||
import { replaceLegalPlaceholders } from '../lib/legal-placeholders.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
@@ -171,12 +172,15 @@ legalPagesRouter.get('/:slug', async (c) => {
|
||||
// Get localized content with fallback
|
||||
const { title, contentMarkdown } = getLocalizedContent(page, locale);
|
||||
|
||||
// Replace legal placeholders before returning
|
||||
const processedContent = await replaceLegalPlaceholders(contentMarkdown, page.updatedAt);
|
||||
|
||||
return c.json({
|
||||
page: {
|
||||
id: page.id,
|
||||
slug: page.slug,
|
||||
title,
|
||||
contentMarkdown,
|
||||
contentMarkdown: processedContent,
|
||||
updatedAt: page.updatedAt,
|
||||
source: 'database',
|
||||
}
|
||||
@@ -195,11 +199,14 @@ legalPagesRouter.get('/:slug', async (c) => {
|
||||
? (titles?.es || titles?.en || slug)
|
||||
: (titles?.en || titles?.es || slug);
|
||||
|
||||
// Replace legal placeholders in filesystem content too
|
||||
const processedContent = await replaceLegalPlaceholders(content);
|
||||
|
||||
return c.json({
|
||||
page: {
|
||||
slug,
|
||||
title,
|
||||
contentMarkdown: content,
|
||||
contentMarkdown: processedContent,
|
||||
source: 'filesystem',
|
||||
}
|
||||
});
|
||||
|
||||
146
backend/src/routes/legal-settings.ts
Normal file
146
backend/src/routes/legal-settings.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, legalSettings } from '../db/index.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { generateId, getNow } from '../lib/utils.js';
|
||||
|
||||
interface UserContext {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
const legalSettingsRouter = new Hono<{ Variables: { user: UserContext } }>();
|
||||
|
||||
// Validation schema for updating legal settings
|
||||
const updateLegalSettingsSchema = z.object({
|
||||
companyName: z.string().optional().nullable(),
|
||||
legalEntityName: z.string().optional().nullable(),
|
||||
rucNumber: z.string().optional().nullable(),
|
||||
companyAddress: z.string().optional().nullable(),
|
||||
companyCity: z.string().optional().nullable(),
|
||||
companyCountry: z.string().optional().nullable(),
|
||||
supportEmail: z.string().email().optional().nullable().or(z.literal('')),
|
||||
legalEmail: z.string().email().optional().nullable().or(z.literal('')),
|
||||
governingLaw: z.string().optional().nullable(),
|
||||
jurisdictionCity: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
// Get legal settings (admin only)
|
||||
legalSettingsRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
const settings = await dbGet<any>(
|
||||
(db as any).select().from(legalSettings).limit(1)
|
||||
);
|
||||
|
||||
if (!settings) {
|
||||
// Return empty defaults
|
||||
return c.json({
|
||||
settings: {
|
||||
companyName: null,
|
||||
legalEntityName: null,
|
||||
rucNumber: null,
|
||||
companyAddress: null,
|
||||
companyCity: null,
|
||||
companyCountry: null,
|
||||
supportEmail: null,
|
||||
legalEmail: null,
|
||||
governingLaw: null,
|
||||
jurisdictionCity: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ settings });
|
||||
});
|
||||
|
||||
// Internal helper: get legal settings for placeholder replacement (no auth required)
|
||||
// This is called server-side from legal-pages route, not exposed as HTTP endpoint
|
||||
export async function getLegalSettingsValues(): Promise<Record<string, string>> {
|
||||
const settings = await dbGet<any>(
|
||||
(db as any).select().from(legalSettings).limit(1)
|
||||
);
|
||||
|
||||
if (!settings) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const values: Record<string, string> = {};
|
||||
if (settings.companyName) values['COMPANY_NAME'] = settings.companyName;
|
||||
if (settings.legalEntityName) values['LEGAL_ENTITY_NAME'] = settings.legalEntityName;
|
||||
if (settings.rucNumber) values['RUC_NUMBER'] = settings.rucNumber;
|
||||
if (settings.companyAddress) values['COMPANY_ADDRESS'] = settings.companyAddress;
|
||||
if (settings.companyCity) values['COMPANY_CITY'] = settings.companyCity;
|
||||
if (settings.companyCountry) values['COMPANY_COUNTRY'] = settings.companyCountry;
|
||||
if (settings.supportEmail) values['SUPPORT_EMAIL'] = settings.supportEmail;
|
||||
if (settings.legalEmail) values['LEGAL_EMAIL'] = settings.legalEmail;
|
||||
if (settings.governingLaw) values['GOVERNING_LAW'] = settings.governingLaw;
|
||||
if (settings.jurisdictionCity) values['JURISDICTION_CITY'] = settings.jurisdictionCity;
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
// Update legal settings (admin only)
|
||||
legalSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateLegalSettingsSchema), async (c) => {
|
||||
const data = c.req.valid('json');
|
||||
const user = c.get('user');
|
||||
const now = getNow();
|
||||
|
||||
// Check if settings exist
|
||||
const existing = await dbGet<any>(
|
||||
(db as any).select().from(legalSettings).limit(1)
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
// Create new settings record
|
||||
const id = generateId();
|
||||
const newSettings = {
|
||||
id,
|
||||
companyName: data.companyName || null,
|
||||
legalEntityName: data.legalEntityName || null,
|
||||
rucNumber: data.rucNumber || null,
|
||||
companyAddress: data.companyAddress || null,
|
||||
companyCity: data.companyCity || null,
|
||||
companyCountry: data.companyCountry || null,
|
||||
supportEmail: data.supportEmail || null,
|
||||
legalEmail: data.legalEmail || null,
|
||||
governingLaw: data.governingLaw || null,
|
||||
jurisdictionCity: data.jurisdictionCity || null,
|
||||
updatedAt: now,
|
||||
updatedBy: user.id,
|
||||
};
|
||||
|
||||
await (db as any).insert(legalSettings).values(newSettings);
|
||||
|
||||
return c.json({ settings: newSettings, message: 'Legal settings created successfully' }, 201);
|
||||
}
|
||||
|
||||
// Update existing settings
|
||||
const updateData: Record<string, any> = {
|
||||
...data,
|
||||
updatedAt: now,
|
||||
updatedBy: user.id,
|
||||
};
|
||||
|
||||
// Normalize empty strings to null
|
||||
for (const key of Object.keys(updateData)) {
|
||||
if (updateData[key] === '') {
|
||||
updateData[key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
await (db as any)
|
||||
.update(legalSettings)
|
||||
.set(updateData)
|
||||
.where(eq((legalSettings as any).id, existing.id));
|
||||
|
||||
const updated = await dbGet(
|
||||
(db as any).select().from(legalSettings).where(eq((legalSettings as any).id, existing.id))
|
||||
);
|
||||
|
||||
return c.json({ settings: updated, message: 'Legal settings updated successfully' });
|
||||
});
|
||||
|
||||
export default legalSettingsRouter;
|
||||
@@ -5,6 +5,7 @@ import { db, dbGet, siteSettings, events } from '../db/index.js';
|
||||
import { eq, and, gte } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { generateId, getNow, toDbBool } from '../lib/utils.js';
|
||||
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||
|
||||
interface UserContext {
|
||||
id: string;
|
||||
@@ -172,6 +173,11 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
|
||||
(db as any).select().from(siteSettings).where(eq((siteSettings as any).id, existing.id))
|
||||
);
|
||||
|
||||
// Revalidate frontend cache if featured event changed
|
||||
if (data.featuredEventId !== undefined) {
|
||||
revalidateFrontendCache();
|
||||
}
|
||||
|
||||
return c.json({ settings: updated, message: 'Settings updated successfully' });
|
||||
});
|
||||
|
||||
@@ -216,6 +222,9 @@ siteSettingsRouter.put('/featured-event', requireAuth(['admin']), zValidator('js
|
||||
|
||||
await (db as any).insert(siteSettings).values(newSettings);
|
||||
|
||||
// Revalidate frontend cache so homepage shows the updated featured event
|
||||
revalidateFrontendCache();
|
||||
|
||||
return c.json({ featuredEventId: eventId, message: eventId ? 'Event set as featured' : 'Featured event removed' });
|
||||
}
|
||||
|
||||
@@ -229,6 +238,9 @@ siteSettingsRouter.put('/featured-event', requireAuth(['admin']), zValidator('js
|
||||
})
|
||||
.where(eq((siteSettings as any).id, existing.id));
|
||||
|
||||
// Revalidate frontend cache so homepage shows the updated featured event
|
||||
revalidateFrontendCache();
|
||||
|
||||
return c.json({ featuredEventId: eventId, message: eventId ? 'Event set as featured' : 'Featured event removed' });
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, siteSettings } from '../db/index.js';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { eq, and, or, sql } from 'drizzle-orm';
|
||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||
import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
|
||||
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
|
||||
@@ -490,6 +490,125 @@ ticketsRouter.get('/:id/pdf', async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get event check-in stats for scanner (lightweight endpoint for staff)
|
||||
ticketsRouter.get('/stats/checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||
const eventId = c.req.query('eventId');
|
||||
|
||||
if (!eventId) {
|
||||
return c.json({ error: 'eventId is required' }, 400);
|
||||
}
|
||||
|
||||
// Get event info
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, eventId))
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
// Count checked-in tickets
|
||||
const checkedInCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, eventId),
|
||||
eq((tickets as any).status, 'checked_in')
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Count confirmed + checked_in (total active)
|
||||
const totalActiveCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, eventId),
|
||||
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
return c.json({
|
||||
eventId,
|
||||
capacity: event.capacity,
|
||||
checkedIn: checkedInCount?.count || 0,
|
||||
totalActive: totalActiveCount?.count || 0,
|
||||
});
|
||||
});
|
||||
|
||||
// Live search tickets (GET - for scanner live search)
|
||||
ticketsRouter.get('/search', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||
const q = c.req.query('q')?.trim() || '';
|
||||
const eventId = c.req.query('eventId');
|
||||
|
||||
if (q.length < 2) {
|
||||
return c.json({ tickets: [] });
|
||||
}
|
||||
|
||||
const searchTerm = `%${q.toLowerCase()}%`;
|
||||
|
||||
// Search by name (ILIKE), email (ILIKE), ticket ID (exact or partial)
|
||||
const nameEmailConditions = [
|
||||
sql`LOWER(${(tickets as any).attendeeEmail}) LIKE ${searchTerm}`,
|
||||
sql`LOWER(${(tickets as any).attendeeFirstName}) LIKE ${searchTerm}`,
|
||||
sql`LOWER(${(tickets as any).attendeeLastName}) LIKE ${searchTerm}`,
|
||||
sql`LOWER(${(tickets as any).attendeeFirstName} || ' ' || COALESCE(${(tickets as any).attendeeLastName}, '')) LIKE ${searchTerm}`,
|
||||
// Ticket ID exact or partial match (cast UUID to text for LOWER)
|
||||
sql`LOWER(CAST(${(tickets as any).id} AS TEXT)) LIKE ${searchTerm}`,
|
||||
sql`LOWER(CAST(${(tickets as any).qrCode} AS TEXT)) LIKE ${searchTerm}`,
|
||||
];
|
||||
|
||||
let whereClause: any = and(
|
||||
or(...nameEmailConditions),
|
||||
// Exclude cancelled tickets by default
|
||||
sql`${(tickets as any).status} != 'cancelled'`
|
||||
);
|
||||
|
||||
if (eventId) {
|
||||
whereClause = and(whereClause, eq((tickets as any).eventId, eventId));
|
||||
}
|
||||
|
||||
const matchingTickets = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(whereClause)
|
||||
.limit(20)
|
||||
);
|
||||
|
||||
// Enrich with event details
|
||||
const results = await Promise.all(
|
||||
matchingTickets.map(async (ticket: any) => {
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
return {
|
||||
ticket_id: ticket.id,
|
||||
name: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
|
||||
email: ticket.attendeeEmail,
|
||||
status: ticket.status,
|
||||
checked_in: ticket.status === 'checked_in',
|
||||
checkinAt: ticket.checkinAt,
|
||||
event_id: ticket.eventId,
|
||||
qrCode: ticket.qrCode,
|
||||
event: event ? {
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
startDatetime: event.startDatetime,
|
||||
location: event.location,
|
||||
} : null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return c.json({ tickets: results });
|
||||
});
|
||||
|
||||
// Get ticket by ID
|
||||
ticketsRouter.get('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
@@ -554,6 +673,65 @@ ticketsRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff']), zValidat
|
||||
return c.json({ ticket: updated });
|
||||
});
|
||||
|
||||
// Search tickets by name/email (for scanner manual search)
|
||||
ticketsRouter.post('/search', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const { query, eventId } = body;
|
||||
|
||||
if (!query || typeof query !== 'string' || query.trim().length < 2) {
|
||||
return c.json({ error: 'Search query must be at least 2 characters' }, 400);
|
||||
}
|
||||
|
||||
const searchTerm = `%${query.trim().toLowerCase()}%`;
|
||||
|
||||
const conditions = [
|
||||
sql`LOWER(${(tickets as any).attendeeEmail}) LIKE ${searchTerm}`,
|
||||
sql`LOWER(${(tickets as any).attendeeFirstName}) LIKE ${searchTerm}`,
|
||||
sql`LOWER(${(tickets as any).attendeeLastName}) LIKE ${searchTerm}`,
|
||||
sql`LOWER(${(tickets as any).attendeeFirstName} || ' ' || COALESCE(${(tickets as any).attendeeLastName}, '')) LIKE ${searchTerm}`,
|
||||
];
|
||||
|
||||
let whereClause = or(...conditions);
|
||||
|
||||
if (eventId) {
|
||||
whereClause = and(whereClause, eq((tickets as any).eventId, eventId));
|
||||
}
|
||||
|
||||
const matchingTickets = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(whereClause)
|
||||
.limit(20)
|
||||
);
|
||||
|
||||
// Enrich with event details
|
||||
const results = await Promise.all(
|
||||
matchingTickets.map(async (ticket: any) => {
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
return {
|
||||
id: ticket.id,
|
||||
qrCode: ticket.qrCode,
|
||||
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
attendeePhone: ticket.attendeePhone,
|
||||
status: ticket.status,
|
||||
checkinAt: ticket.checkinAt,
|
||||
event: event ? {
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
startDatetime: event.startDatetime,
|
||||
location: event.location,
|
||||
} : null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return c.json({ tickets: results });
|
||||
});
|
||||
|
||||
// Validate ticket by QR code (for scanner)
|
||||
ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
|
||||
@@ -8,9 +8,9 @@ Type=simple
|
||||
User=spanglish
|
||||
Group=spanglish
|
||||
WorkingDirectory=/home/spanglish/Spanglish/backend
|
||||
EnvironmentFile=/home/spanglish/Spanglish/backend/.env
|
||||
Environment=NODE_ENV=production
|
||||
Environment=PORT=3018
|
||||
EnvironmentFile=/home/spanglish/Spanglish/backend/.env
|
||||
ExecStart=/usr/bin/node dist/index.js
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
@@ -21,6 +21,10 @@ NEXT_PUBLIC_EMAIL=hola@spanglish.com.py
|
||||
NEXT_PUBLIC_TELEGRAM=spanglish_py
|
||||
NEXT_PUBLIC_TIKTOK=spanglishsocialpy
|
||||
|
||||
# Revalidation secret (shared between frontend and backend for on-demand cache revalidation)
|
||||
# Must match the REVALIDATE_SECRET in backend/.env
|
||||
REVALIDATE_SECRET=change-me-to-a-random-secret
|
||||
|
||||
# Plausible Analytics (optional - leave empty to disable tracking)
|
||||
NEXT_PUBLIC_PLAUSIBLE_URL=https://analytics.azzamo.net
|
||||
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=spanglishcommunity.com
|
||||
|
||||
@@ -6,7 +6,7 @@ import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
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 Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
@@ -217,21 +217,8 @@ export default function BookingPage() {
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
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 formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
||||
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
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">
|
||||
<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.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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -955,7 +942,7 @@ export default function BookingPage() {
|
||||
<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.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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1045,7 +1032,7 @@ export default function BookingPage() {
|
||||
<div className="p-4 space-y-2 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<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 className="flex items-center gap-3">
|
||||
<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 { useLanguage } from '@/context/LanguageContext';
|
||||
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 Button from '@/components/ui/Button';
|
||||
import {
|
||||
@@ -152,21 +152,8 @@ export default function BookingPaymentPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
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 formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
||||
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||
|
||||
// Loading state
|
||||
if (step === 'loading') {
|
||||
@@ -237,7 +224,7 @@ export default function BookingPaymentPage() {
|
||||
<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' ? '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>
|
||||
</div>
|
||||
)}
|
||||
@@ -286,7 +273,7 @@ export default function BookingPaymentPage() {
|
||||
<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' ? '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>
|
||||
</div>
|
||||
)}
|
||||
@@ -333,7 +320,7 @@ export default function BookingPaymentPage() {
|
||||
<div className="p-4 space-y-2 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<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 className="flex items-center gap-3">
|
||||
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { ticketsApi, Ticket } from '@/lib/api';
|
||||
import { formatDateLong, formatTime } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import {
|
||||
@@ -69,21 +70,8 @@ export default function BookingSuccessPage() {
|
||||
};
|
||||
}, [ticketId]);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
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 formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
||||
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -191,7 +179,7 @@ export default function BookingSuccessPage() {
|
||||
<>
|
||||
<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' ? '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>
|
||||
</>
|
||||
)}
|
||||
|
||||
82
frontend/src/app/(public)/components/HomepageFaqSection.tsx
Normal file
82
frontend/src/app/(public)/components/HomepageFaqSection.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { faqApi, FaqItem } from '@/lib/api';
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||
import Link from 'next/link';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function HomepageFaqSection() {
|
||||
const { t, locale } = useLanguage();
|
||||
const [faqs, setFaqs] = useState<FaqItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
faqApi.getList(true).then((res) => {
|
||||
if (!cancelled) setFaqs(res.faqs);
|
||||
}).finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
if (loading || faqs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="section-padding bg-secondary-gray" aria-labelledby="homepage-faq-title">
|
||||
<div className="container-page">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h2 id="homepage-faq-title" className="text-2xl md:text-3xl font-bold text-primary-dark text-center mb-8">
|
||||
{t('home.faq.title')}
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{faqs.map((faq, index) => (
|
||||
<div
|
||||
key={faq.id}
|
||||
className="bg-white rounded-btn border border-gray-200 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => setOpenIndex(openIndex === index ? null : index)}
|
||||
className="w-full px-5 py-4 flex items-center justify-between text-left hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<span className="font-semibold text-primary-dark pr-4 text-sm md:text-base">
|
||||
{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={clsx(
|
||||
'w-5 h-5 text-gray-500 flex-shrink-0 transition-transform duration-200',
|
||||
openIndex === index && 'transform rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={clsx(
|
||||
'overflow-hidden transition-all duration-200',
|
||||
openIndex === index ? 'max-h-80' : 'max-h-0'
|
||||
)}
|
||||
>
|
||||
<div className="px-5 pb-4 text-gray-600 text-sm md:text-base">
|
||||
{locale === 'es' && faq.answerEs ? faq.answerEs : faq.answer}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-8 text-center">
|
||||
<Link
|
||||
href="/faq"
|
||||
className="inline-flex items-center justify-center px-6 py-3 bg-primary-yellow text-primary-dark font-semibold rounded-btn hover:bg-primary-yellow/90 transition-colors"
|
||||
>
|
||||
{t('home.faq.seeFull')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -4,38 +4,31 @@ import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
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 Card from '@/components/ui/Card';
|
||||
import { CalendarIcon, MapPinIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
export default function NextEventSection() {
|
||||
interface NextEventSectionProps {
|
||||
initialEvent?: Event | null;
|
||||
}
|
||||
|
||||
export default function NextEventSection({ initialEvent }: NextEventSectionProps) {
|
||||
const { t, locale } = useLanguage();
|
||||
const [nextEvent, setNextEvent] = useState<Event | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [nextEvent, setNextEvent] = useState<Event | null>(initialEvent ?? null);
|
||||
const [loading, setLoading] = useState(!initialEvent);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip fetch if we already have server-provided data
|
||||
if (initialEvent !== undefined) return;
|
||||
eventsApi.getNextUpcoming()
|
||||
.then(({ event }) => setNextEvent(event))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
}, [initialEvent]);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
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 formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
||||
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -78,7 +71,7 @@ export default function NextEventSection() {
|
||||
<span className="w-5 h-5 flex items-center justify-center text-primary-yellow font-bold">
|
||||
⏰
|
||||
</span>
|
||||
<span>{formatTime(nextEvent.startDatetime)}</span>
|
||||
<span>{fmtTime(nextEvent.startDatetime)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-gray-700">
|
||||
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import NextEventSection from './NextEventSection';
|
||||
import { Event } from '@/lib/api';
|
||||
|
||||
export default function NextEventSectionWrapper() {
|
||||
interface NextEventSectionWrapperProps {
|
||||
initialEvent?: Event | null;
|
||||
}
|
||||
|
||||
export default function NextEventSectionWrapper({ initialEvent }: NextEventSectionWrapperProps) {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
@@ -13,7 +18,7 @@ export default function NextEventSectionWrapper() {
|
||||
{t('home.nextEvent.title')}
|
||||
</h2>
|
||||
<div className="mt-12 max-w-3xl mx-auto">
|
||||
<NextEventSection />
|
||||
<NextEventSection initialEvent={initialEvent} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -25,6 +25,7 @@ export default function PaymentsTab({ payments, language }: PaymentsTabProps) {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ export default function ProfileTab({ onUpdate }: ProfileTabProps) {
|
||||
{profile?.memberSince
|
||||
? new Date(profile.memberSince).toLocaleDateString(
|
||||
language === 'es' ? 'es-ES' : 'en-US',
|
||||
{ year: 'numeric', month: 'long', day: 'numeric' }
|
||||
{ year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Asuncion' }
|
||||
)
|
||||
: '-'}
|
||||
</span>
|
||||
|
||||
@@ -153,6 +153,7 @@ export default function SecurityTab() {
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ export default function TicketsTab({ tickets, language }: TicketsTabProps) {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAuth } from '@/context/AuthContext';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { dashboardApi, DashboardSummary, NextEventInfo, UserTicket, UserPayment } from '@/lib/api';
|
||||
import { formatDateLong, formatTime } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
@@ -85,21 +86,8 @@ export default function DashboardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
|
||||
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',
|
||||
});
|
||||
};
|
||||
const formatDate = (dateStr: string) => formatDateLong(dateStr, language as 'en' | 'es');
|
||||
const fmtTime = (dateStr: string) => formatTime(dateStr, language as 'en' | 'es');
|
||||
|
||||
return (
|
||||
<div className="section-padding min-h-[70vh]">
|
||||
|
||||
@@ -5,7 +5,7 @@ import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
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 Button from '@/components/ui/Button';
|
||||
import ShareButtons from '@/components/ShareButtons';
|
||||
@@ -54,21 +54,8 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
||||
setTicketQuantity(prev => Math.min(maxTickets, prev + 1));
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
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 formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
||||
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||
|
||||
const isCancelled = event.status === 'cancelled';
|
||||
// Only calculate isPastEvent after mount to avoid hydration mismatch
|
||||
@@ -228,8 +215,8 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
||||
<div>
|
||||
<p className="font-medium text-sm">{t('events.details.time')}</p>
|
||||
<p className="text-gray-600" suppressHydrationWarning>
|
||||
{formatTime(event.startDatetime)}
|
||||
{event.endDatetime && ` - ${formatTime(event.endDatetime)}`}
|
||||
{fmtTime(event.startDatetime)}
|
||||
{event.endDatetime && ` - ${fmtTime(event.endDatetime)}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -97,9 +97,7 @@ function generateEventJsonLd(event: Event) {
|
||||
eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
|
||||
eventStatus: isCancelled
|
||||
? 'https://schema.org/EventCancelled'
|
||||
: isPastEvent
|
||||
? 'https://schema.org/EventPostponed'
|
||||
: 'https://schema.org/EventScheduled',
|
||||
: 'https://schema.org/EventScheduled',
|
||||
location: {
|
||||
'@type': 'Place',
|
||||
name: event.location,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
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 Button from '@/components/ui/Button';
|
||||
import { CalendarIcon, MapPinIcon, UserGroupIcon } from '@heroicons/react/24/outline';
|
||||
@@ -33,20 +33,8 @@ export default function EventsPage() {
|
||||
|
||||
const displayedEvents = filter === 'upcoming' ? upcomingEvents : pastEvents;
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
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 formatDate = (dateStr: string) => formatDateShort(dateStr, locale as 'en' | 'es');
|
||||
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||
|
||||
const getStatusBadge = (event: Event) => {
|
||||
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="flex items-center gap-2">
|
||||
<CalendarIcon className="w-4 h-4" />
|
||||
<span>{formatDate(event.startDatetime)} - {formatTime(event.startDatetime)}</span>
|
||||
<span>{formatDate(event.startDatetime)} - {fmtTime(event.startDatetime)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPinIcon className="w-4 h-4" />
|
||||
|
||||
@@ -1,44 +1,21 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
// FAQ Page structured data
|
||||
const faqSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: [
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'What is Spanglish?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Spanglish is a language exchange community in Asunción, Paraguay. We organize monthly events where Spanish and English speakers come together to practice languages, meet new people, and have fun in a relaxed social environment.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'Who can attend Spanglish events?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Anyone interested in practicing English or Spanish is welcome! We accept all levels - from complete beginners to native speakers. Our events are designed to be inclusive and welcoming to everyone.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'How do language exchange events work?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Our events typically last 2-3 hours. You will be paired with people who speak the language you want to practice. We rotate partners throughout the evening so you can meet multiple people. There are also group activities and free conversation time.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'Do I need to speak the language already?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Not at all! We welcome complete beginners. Our events are structured to support all levels. Native speakers are patient and happy to help beginners practice.',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
async function getFaqForSchema(): Promise<{ question: string; answer: string }[]> {
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}/api/faq`, { next: { revalidate: 60 } });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
const faqs = data.faqs || [];
|
||||
return faqs.map((f: { question: string; questionEs?: string | null; answer: string; answerEs?: string | null }) => ({
|
||||
question: f.question,
|
||||
answer: f.answer || '',
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Frequently Asked Questions',
|
||||
@@ -49,11 +26,25 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function FAQLayout({
|
||||
export default async function FAQLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const faqList = await getFaqForSchema();
|
||||
const faqSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqList.map(({ question, answer }) => ({
|
||||
'@type': 'Question',
|
||||
name: question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: answer,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
|
||||
@@ -1,89 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { faqApi, FaqItem } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface FAQItem {
|
||||
question: string;
|
||||
questionEs: string;
|
||||
answer: string;
|
||||
answerEs: string;
|
||||
}
|
||||
|
||||
const faqs: FAQItem[] = [
|
||||
{
|
||||
question: "What is Spanglish?",
|
||||
questionEs: "¿Qué es Spanglish?",
|
||||
answer: "Spanglish is a language exchange community in Asunción, Paraguay. We organize monthly events where Spanish and English speakers come together to practice languages, meet new people, and have fun in a relaxed social environment.",
|
||||
answerEs: "Spanglish es una comunidad de intercambio de idiomas en Asunción, Paraguay. Organizamos eventos mensuales donde hablantes de español e inglés se reúnen para practicar idiomas, conocer gente nueva y divertirse en un ambiente social relajado."
|
||||
},
|
||||
{
|
||||
question: "Who can attend Spanglish events?",
|
||||
questionEs: "¿Quién puede asistir a los eventos de Spanglish?",
|
||||
answer: "Anyone interested in practicing English or Spanish is welcome! We accept all levels - from complete beginners to native speakers. Our events are designed to be inclusive and welcoming to everyone.",
|
||||
answerEs: "¡Cualquier persona interesada en practicar inglés o español es bienvenida! Aceptamos todos los niveles - desde principiantes hasta hablantes nativos. Nuestros eventos están diseñados para ser inclusivos y acogedores para todos."
|
||||
},
|
||||
{
|
||||
question: "How do events work?",
|
||||
questionEs: "¿Cómo funcionan los eventos?",
|
||||
answer: "Our events typically last 2-3 hours. You'll be paired with people who speak the language you want to practice. We rotate partners throughout the evening so you can meet multiple people. There are also group activities and free conversation time.",
|
||||
answerEs: "Nuestros eventos suelen durar 2-3 horas. Serás emparejado con personas que hablan el idioma que quieres practicar. Rotamos parejas durante la noche para que puedas conocer a varias personas. También hay actividades grupales y tiempo de conversación libre."
|
||||
},
|
||||
{
|
||||
question: "How much does it cost to attend?",
|
||||
questionEs: "¿Cuánto cuesta asistir?",
|
||||
answer: "Event prices vary but are always kept affordable. The price covers venue costs and event organization. Check each event page for specific pricing. Some special events may be free!",
|
||||
answerEs: "Los precios de los eventos varían pero siempre se mantienen accesibles. El precio cubre los costos del local y la organización del evento. Consulta la página de cada evento para precios específicos. ¡Algunos eventos especiales pueden ser gratis!"
|
||||
},
|
||||
{
|
||||
question: "What payment methods do you accept?",
|
||||
questionEs: "¿Qué métodos de pago aceptan?",
|
||||
answer: "We accept multiple payment methods: credit/debit cards through Bancard, Bitcoin Lightning for crypto enthusiasts, and cash payment at the event. You can choose your preferred method when booking.",
|
||||
answerEs: "Aceptamos múltiples métodos de pago: tarjetas de crédito/débito a través de Bancard, Bitcoin Lightning para entusiastas de cripto, y pago en efectivo en el evento. Puedes elegir tu método preferido al reservar."
|
||||
},
|
||||
{
|
||||
question: "Do I need to speak the language already?",
|
||||
questionEs: "¿Necesito ya hablar el idioma?",
|
||||
answer: "Not at all! We welcome complete beginners. Our events are structured to support all levels. Native speakers are patient and happy to help beginners practice. It's a judgment-free zone for learning.",
|
||||
answerEs: "¡Para nada! Damos la bienvenida a principiantes absolutos. Nuestros eventos están estructurados para apoyar todos los niveles. Los hablantes nativos son pacientes y felices de ayudar a los principiantes a practicar. Es una zona libre de juicios para aprender."
|
||||
},
|
||||
{
|
||||
question: "Can I come alone?",
|
||||
questionEs: "¿Puedo ir solo/a?",
|
||||
answer: "Absolutely! Most people come alone and that's totally fine. In fact, it's a great way to meet new people. Our events are designed to be social, so you'll quickly find conversation partners.",
|
||||
answerEs: "¡Absolutamente! La mayoría de las personas vienen solas y eso está totalmente bien. De hecho, es una excelente manera de conocer gente nueva. Nuestros eventos están diseñados para ser sociales, así que encontrarás compañeros de conversación rápidamente."
|
||||
},
|
||||
{
|
||||
question: "What if I can't make it after booking?",
|
||||
questionEs: "¿Qué pasa si no puedo asistir después de reservar?",
|
||||
answer: "If you can't attend, please let us know as soon as possible so we can offer your spot to someone on the waitlist. Contact us through the website or WhatsApp group to cancel your booking.",
|
||||
answerEs: "Si no puedes asistir, por favor avísanos lo antes posible para poder ofrecer tu lugar a alguien en la lista de espera. Contáctanos a través del sitio web o el grupo de WhatsApp para cancelar tu reserva."
|
||||
},
|
||||
{
|
||||
question: "How can I stay updated about events?",
|
||||
questionEs: "¿Cómo puedo mantenerme actualizado sobre los eventos?",
|
||||
answer: "Join our WhatsApp group for instant updates, follow us on Instagram for announcements and photos, or subscribe to our newsletter on the website. We typically announce events 2-3 weeks in advance.",
|
||||
answerEs: "Únete a nuestro grupo de WhatsApp para actualizaciones instantáneas, síguenos en Instagram para anuncios y fotos, o suscríbete a nuestro boletín en el sitio web. Normalmente anunciamos eventos con 2-3 semanas de anticipación."
|
||||
},
|
||||
{
|
||||
question: "Can I volunteer or help organize events?",
|
||||
questionEs: "¿Puedo ser voluntario o ayudar a organizar eventos?",
|
||||
answer: "Yes! We're always looking for enthusiastic volunteers. Volunteers help with setup, greeting newcomers, facilitating activities, and more. Contact us through the website if you're interested in getting involved.",
|
||||
answerEs: "¡Sí! Siempre estamos buscando voluntarios entusiastas. Los voluntarios ayudan con la preparación, saludar a los recién llegados, facilitar actividades y más. Contáctanos a través del sitio web si estás interesado en participar."
|
||||
}
|
||||
];
|
||||
|
||||
export default function FAQPage() {
|
||||
const { t, locale } = useLanguage();
|
||||
const { locale } = useLanguage();
|
||||
const [faqs, setFaqs] = useState<FaqItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
faqApi.getList().then((res) => {
|
||||
if (!cancelled) {
|
||||
setFaqs(res.faqs);
|
||||
}
|
||||
}).finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const toggleFAQ = (index: number) => {
|
||||
setOpenIndex(openIndex === index ? null : index);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-3xl flex justify-center py-20">
|
||||
<div className="animate-spin w-10 h-10 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-3xl">
|
||||
@@ -98,36 +53,46 @@ export default function FAQPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{faqs.map((faq, index) => (
|
||||
<Card key={index} className="overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleFAQ(index)}
|
||||
className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<span className="font-semibold text-primary-dark pr-4">
|
||||
{locale === 'es' ? faq.questionEs : faq.question}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
{faqs.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<p className="text-gray-600">
|
||||
{locale === 'es'
|
||||
? 'No hay preguntas frecuentes publicadas en este momento.'
|
||||
: 'No FAQ questions are published at the moment.'}
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{faqs.map((faq, index) => (
|
||||
<Card key={faq.id} className="overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleFAQ(index)}
|
||||
className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<span className="font-semibold text-primary-dark pr-4">
|
||||
{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={clsx(
|
||||
'w-5 h-5 text-gray-500 flex-shrink-0 transition-transform duration-200',
|
||||
openIndex === index && 'transform rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={clsx(
|
||||
'w-5 h-5 text-gray-500 flex-shrink-0 transition-transform duration-200',
|
||||
openIndex === index && 'transform rotate-180'
|
||||
'overflow-hidden transition-all duration-200',
|
||||
openIndex === index ? 'max-h-96' : 'max-h-0'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={clsx(
|
||||
'overflow-hidden transition-all duration-200',
|
||||
openIndex === index ? 'max-h-96' : 'max-h-0'
|
||||
)}
|
||||
>
|
||||
<div className="px-6 pb-4 text-gray-600">
|
||||
{locale === 'es' ? faq.answerEs : faq.answer}
|
||||
>
|
||||
<div className="px-6 pb-4 text-gray-600">
|
||||
{locale === 'es' && faq.answerEs ? faq.answerEs : faq.answer}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="mt-12 p-8 text-center bg-primary-yellow/10">
|
||||
<h2 className="text-xl font-semibold text-primary-dark mb-2">
|
||||
|
||||
@@ -20,7 +20,7 @@ export const metadata: Metadata = {
|
||||
const organizationSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'Spanglish',
|
||||
name: 'Spanglish Community',
|
||||
url: siteUrl,
|
||||
logo: `${siteUrl}/images/logo.png`,
|
||||
description: 'Language exchange community organizing English and Spanish meetups in Asunción, Paraguay.',
|
||||
@@ -30,7 +30,7 @@ const organizationSchema = {
|
||||
addressCountry: 'PY',
|
||||
},
|
||||
sameAs: [
|
||||
process.env.NEXT_PUBLIC_INSTAGRAM_URL,
|
||||
'https://instagram.com/spanglishsocialpy',
|
||||
process.env.NEXT_PUBLIC_WHATSAPP_URL,
|
||||
process.env.NEXT_PUBLIC_TELEGRAM_URL,
|
||||
].filter(Boolean),
|
||||
|
||||
@@ -1,20 +1,168 @@
|
||||
import type { Metadata } from 'next';
|
||||
import HeroSection from './components/HeroSection';
|
||||
import NextEventSectionWrapper from './components/NextEventSectionWrapper';
|
||||
import AboutSection from './components/AboutSection';
|
||||
import MediaCarouselSection from './components/MediaCarouselSection';
|
||||
import NewsletterSection from './components/NewsletterSection';
|
||||
import HomepageFaqSection from './components/HomepageFaqSection';
|
||||
import { getCarouselImages } from '@/lib/carouselImages';
|
||||
|
||||
export default function HomePage() {
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
interface NextEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
titleEs?: string;
|
||||
description: string;
|
||||
descriptionEs?: string;
|
||||
shortDescription?: string;
|
||||
shortDescriptionEs?: string;
|
||||
startDatetime: string;
|
||||
endDatetime?: string;
|
||||
location: string;
|
||||
locationUrl?: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
capacity: number;
|
||||
status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived';
|
||||
bannerUrl?: string;
|
||||
externalBookingEnabled?: boolean;
|
||||
externalBookingUrl?: string;
|
||||
availableSeats?: number;
|
||||
bookedCount?: number;
|
||||
isFeatured?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
async function getNextUpcomingEvent(): Promise<NextEvent | null> {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/events/next/upcoming`, {
|
||||
next: { tags: ['next-event'] },
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data.event || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic metadata with next event date for AI crawlers and SEO
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const event = await getNextUpcomingEvent();
|
||||
|
||||
if (!event) {
|
||||
return {
|
||||
title: 'Spanglish – Language Exchange Events in Asunción',
|
||||
description:
|
||||
'Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals. Join the next Spanglish meetup.',
|
||||
};
|
||||
}
|
||||
|
||||
const eventDate = new Date(event.startDatetime).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
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.`;
|
||||
|
||||
return {
|
||||
title: 'Spanglish – Language Exchange Events in Asunción',
|
||||
description,
|
||||
openGraph: {
|
||||
title: 'Spanglish – Language Exchange Events in Asunción',
|
||||
description,
|
||||
url: siteUrl,
|
||||
siteName: 'Spanglish',
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: event.bannerUrl
|
||||
? event.bannerUrl.startsWith('http')
|
||||
? event.bannerUrl
|
||||
: `${siteUrl}${event.bannerUrl}`
|
||||
: `${siteUrl}/images/og-image.jpg`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: `Spanglish – ${event.title}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'Spanglish – Language Exchange Events in Asunción',
|
||||
description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function generateNextEventJsonLd(event: NextEvent) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Event',
|
||||
name: event.title,
|
||||
description: event.shortDescription || event.description,
|
||||
startDate: event.startDatetime,
|
||||
endDate: event.endDatetime || event.startDatetime,
|
||||
eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
|
||||
eventStatus:
|
||||
event.status === 'cancelled'
|
||||
? 'https://schema.org/EventCancelled'
|
||||
: 'https://schema.org/EventScheduled',
|
||||
location: {
|
||||
'@type': 'Place',
|
||||
name: event.location,
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: 'Asunción',
|
||||
addressCountry: 'PY',
|
||||
},
|
||||
},
|
||||
organizer: {
|
||||
'@type': 'Organization',
|
||||
name: 'Spanglish',
|
||||
url: siteUrl,
|
||||
},
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: event.price,
|
||||
priceCurrency: event.currency,
|
||||
availability:
|
||||
(event.availableSeats ?? 0) > 0
|
||||
? 'https://schema.org/InStock'
|
||||
: 'https://schema.org/SoldOut',
|
||||
url: `${siteUrl}/events/${event.id}`,
|
||||
},
|
||||
image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`,
|
||||
url: `${siteUrl}/events/${event.id}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function HomePage() {
|
||||
const carouselImages = getCarouselImages();
|
||||
const nextEvent = await getNextUpcomingEvent();
|
||||
|
||||
return (
|
||||
<>
|
||||
{nextEvent && (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(generateNextEventJsonLd(nextEvent)),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<HeroSection />
|
||||
<NextEventSectionWrapper />
|
||||
<NextEventSectionWrapper initialEvent={nextEvent} />
|
||||
<AboutSection />
|
||||
<MediaCarouselSection images={carouselImages} />
|
||||
<NewsletterSection />
|
||||
<HomepageFaqSection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,6 +125,7 @@ export default function AdminBookingsPage() {
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ export default function AdminContactsPage() {
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -189,7 +189,6 @@ export default function AdminEmailsPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
const res = await emailsApi.sendToEvent(composeForm.eventId, {
|
||||
templateSlug: composeForm.templateSlug,
|
||||
@@ -197,20 +196,15 @@ export default function AdminEmailsPage() {
|
||||
customVariables: composeForm.customBody ? { customMessage: composeForm.customBody } : undefined,
|
||||
});
|
||||
|
||||
if (res.success || res.sentCount > 0) {
|
||||
toast.success(`Sent ${res.sentCount} emails successfully`);
|
||||
if (res.failedCount > 0) {
|
||||
toast.error(`${res.failedCount} emails failed`);
|
||||
}
|
||||
if (res.success) {
|
||||
toast.success(`${res.queuedCount} email(s) are being sent in the background.`);
|
||||
clearDraft();
|
||||
setShowRecipientPreview(false);
|
||||
} else {
|
||||
toast.error('Failed to send emails');
|
||||
toast.error(res.error || 'Failed to queue emails');
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to send emails');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -373,6 +367,7 @@ export default function AdminEmailsPage() {
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -545,7 +540,7 @@ export default function AdminEmailsPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
{hasDraft && (
|
||||
<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>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={saveDraft}>
|
||||
@@ -571,7 +566,7 @@ export default function AdminEmailsPage() {
|
||||
<option value="">Choose an event</option>
|
||||
{events.filter(e => e.status === 'published').map((event) => (
|
||||
<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>
|
||||
))}
|
||||
</select>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -240,6 +240,7 @@ export default function AdminEventsPage() {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
395
frontend/src/app/admin/faq/page.tsx
Normal file
395
frontend/src/app/admin/faq/page.tsx
Normal file
@@ -0,0 +1,395 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { faqApi, FaqItemAdmin } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
PlusIcon,
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
Bars3Icon,
|
||||
XMarkIcon,
|
||||
CheckIcon,
|
||||
ArrowLeftIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
type FormState = { id: string | null; question: string; questionEs: string; answer: string; answerEs: string; enabled: boolean; showOnHomepage: boolean };
|
||||
|
||||
const emptyForm: FormState = {
|
||||
id: null,
|
||||
question: '',
|
||||
questionEs: '',
|
||||
answer: '',
|
||||
answerEs: '',
|
||||
enabled: true,
|
||||
showOnHomepage: false,
|
||||
};
|
||||
|
||||
export default function AdminFaqPage() {
|
||||
const { locale } = useLanguage();
|
||||
const [faqs, setFaqs] = useState<FaqItemAdmin[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [draggedId, setDraggedId] = useState<string | null>(null);
|
||||
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadFaqs();
|
||||
}, []);
|
||||
|
||||
const loadFaqs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await faqApi.getAdminList();
|
||||
setFaqs(res.faqs);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast.error(locale === 'es' ? 'Error al cargar FAQs' : 'Failed to load FAQs');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setForm(emptyForm);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleEdit = (faq: FaqItemAdmin) => {
|
||||
setForm({
|
||||
id: faq.id,
|
||||
question: faq.question,
|
||||
questionEs: faq.questionEs ?? '',
|
||||
answer: faq.answer,
|
||||
answerEs: faq.answerEs ?? '',
|
||||
enabled: faq.enabled,
|
||||
showOnHomepage: faq.showOnHomepage,
|
||||
});
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.question.trim() || !form.answer.trim()) {
|
||||
toast.error(locale === 'es' ? 'Pregunta y respuesta (EN) son obligatorios' : 'Question and answer (EN) are required');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSaving(true);
|
||||
if (form.id) {
|
||||
await faqApi.update(form.id, {
|
||||
question: form.question.trim(),
|
||||
questionEs: form.questionEs.trim() || null,
|
||||
answer: form.answer.trim(),
|
||||
answerEs: form.answerEs.trim() || null,
|
||||
enabled: form.enabled,
|
||||
showOnHomepage: form.showOnHomepage,
|
||||
});
|
||||
toast.success(locale === 'es' ? 'FAQ actualizado' : 'FAQ updated');
|
||||
} else {
|
||||
await faqApi.create({
|
||||
question: form.question.trim(),
|
||||
questionEs: form.questionEs.trim() || undefined,
|
||||
answer: form.answer.trim(),
|
||||
answerEs: form.answerEs.trim() || undefined,
|
||||
enabled: form.enabled,
|
||||
showOnHomepage: form.showOnHomepage,
|
||||
});
|
||||
toast.success(locale === 'es' ? 'FAQ creado' : 'FAQ created');
|
||||
}
|
||||
setForm(emptyForm);
|
||||
setShowForm(false);
|
||||
await loadFaqs();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || (locale === 'es' ? 'Error al guardar' : 'Failed to save'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm(locale === 'es' ? '¿Eliminar esta pregunta?' : 'Delete this question?')) return;
|
||||
try {
|
||||
await faqApi.delete(id);
|
||||
toast.success(locale === 'es' ? 'FAQ eliminado' : 'FAQ deleted');
|
||||
if (form.id === id) { setForm(emptyForm); setShowForm(false); }
|
||||
await loadFaqs();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || (locale === 'es' ? 'Error al eliminar' : 'Failed to delete'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (faq: FaqItemAdmin) => {
|
||||
try {
|
||||
await faqApi.update(faq.id, { enabled: !faq.enabled });
|
||||
setFaqs(prev => prev.map(f => f.id === faq.id ? { ...f, enabled: !f.enabled } : f));
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || (locale === 'es' ? 'Error al actualizar' : 'Failed to update'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleShowOnHomepage = async (faq: FaqItemAdmin) => {
|
||||
try {
|
||||
await faqApi.update(faq.id, { showOnHomepage: !faq.showOnHomepage });
|
||||
setFaqs(prev => prev.map(f => f.id === faq.id ? { ...f, showOnHomepage: !f.showOnHomepage } : f));
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || (locale === 'es' ? 'Error al actualizar' : 'Failed to update'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, id: string) => {
|
||||
setDraggedId(id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', id);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, id: string) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setDragOverId(id);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDragOverId(null);
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent, targetId: string) => {
|
||||
e.preventDefault();
|
||||
setDragOverId(null);
|
||||
setDraggedId(null);
|
||||
const sourceId = e.dataTransfer.getData('text/plain');
|
||||
if (!sourceId || sourceId === targetId) return;
|
||||
const idx = faqs.findIndex(f => f.id === sourceId);
|
||||
const targetIdx = faqs.findIndex(f => f.id === targetId);
|
||||
if (idx === -1 || targetIdx === -1) return;
|
||||
const newOrder = [...faqs];
|
||||
const [removed] = newOrder.splice(idx, 1);
|
||||
newOrder.splice(targetIdx, 0, removed);
|
||||
const ids = newOrder.map(f => f.id);
|
||||
try {
|
||||
const res = await faqApi.reorder(ids);
|
||||
setFaqs(res.faqs);
|
||||
toast.success(locale === 'es' ? 'Orden actualizado' : 'Order updated');
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || (locale === 'es' ? 'Error al reordenar' : 'Failed to reorder'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedId(null);
|
||||
setDragOverId(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold font-heading">
|
||||
{locale === 'es' ? 'FAQ' : 'FAQ'}
|
||||
</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">
|
||||
{locale === 'es'
|
||||
? 'Crear y editar preguntas frecuentes. Arrastra para cambiar el orden.'
|
||||
: 'Create and edit FAQ questions. Drag to change order.'}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleCreate}>
|
||||
<PlusIcon className="w-4 h-4 mr-2" />
|
||||
{locale === 'es' ? 'Nueva pregunta' : 'Add question'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<Card>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{form.id ? (locale === 'es' ? 'Editar pregunta' : 'Edit question') : (locale === 'es' ? 'Nueva pregunta' : 'New question')}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => { setForm(emptyForm); setShowForm(false); }}
|
||||
className="p-2 hover:bg-gray-100 rounded-full"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Question (EN) *</label>
|
||||
<Input
|
||||
value={form.question}
|
||||
onChange={e => setForm(f => ({ ...f, question: e.target.value }))}
|
||||
placeholder="Question in English"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Pregunta (ES)</label>
|
||||
<Input
|
||||
value={form.questionEs}
|
||||
onChange={e => setForm(f => ({ ...f, questionEs: e.target.value }))}
|
||||
placeholder="Pregunta en español"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Answer (EN) *</label>
|
||||
<textarea
|
||||
className="w-full border border-gray-300 rounded-btn px-3 py-2 min-h-[100px]"
|
||||
value={form.answer}
|
||||
onChange={e => setForm(f => ({ ...f, answer: e.target.value }))}
|
||||
placeholder="Answer in English"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Respuesta (ES)</label>
|
||||
<textarea
|
||||
className="w-full border border-gray-300 rounded-btn px-3 py-2 min-h-[100px]"
|
||||
value={form.answerEs}
|
||||
onChange={e => setForm(f => ({ ...f, answerEs: e.target.value }))}
|
||||
placeholder="Respuesta en español"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.enabled}
|
||||
onChange={e => setForm(f => ({ ...f, enabled: e.target.checked }))}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm">{locale === 'es' ? 'Mostrar en el sitio' : 'Show on site'}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.showOnHomepage}
|
||||
onChange={e => setForm(f => ({ ...f, showOnHomepage: e.target.checked }))}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm">{locale === 'es' ? 'Mostrar en inicio' : 'Show on homepage'}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} isLoading={saving}>
|
||||
<CheckIcon className="w-4 h-4 mr-1" />
|
||||
{locale === 'es' ? 'Guardar' : 'Save'}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => { setForm(emptyForm); setShowForm(false); }} disabled={saving}>
|
||||
{locale === 'es' ? 'Cancelar' : 'Cancel'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="w-10 px-4 py-3" />
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">
|
||||
{locale === 'es' ? 'Pregunta' : 'Question'}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase w-24">
|
||||
{locale === 'es' ? 'En sitio' : 'On site'}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase w-28">
|
||||
{locale === 'es' ? 'En inicio' : 'Homepage'}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600 uppercase w-32">
|
||||
{locale === 'es' ? 'Acciones' : 'Actions'}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{faqs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
|
||||
{locale === 'es' ? 'No hay preguntas. Añade la primera.' : 'No questions yet. Add the first one.'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
faqs.map((faq) => (
|
||||
<tr
|
||||
key={faq.id}
|
||||
draggable
|
||||
onDragStart={e => handleDragStart(e, faq.id)}
|
||||
onDragOver={e => handleDragOver(e, faq.id)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={e => handleDrop(e, faq.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={clsx(
|
||||
'hover:bg-gray-50',
|
||||
draggedId === faq.id && 'opacity-50',
|
||||
dragOverId === faq.id && 'bg-primary-yellow/10'
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<span className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600" title={locale === 'es' ? 'Arrastrar para reordenar' : 'Drag to reorder'}>
|
||||
<Bars3Icon className="w-5 h-5" />
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-primary-dark line-clamp-1">
|
||||
{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => handleToggleEnabled(faq)}
|
||||
className={clsx(
|
||||
'inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium',
|
||||
faq.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'
|
||||
)}
|
||||
>
|
||||
{faq.enabled ? (locale === 'es' ? 'Sí' : 'Yes') : (locale === 'es' ? 'No' : 'No')}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => handleToggleShowOnHomepage(faq)}
|
||||
className={clsx(
|
||||
'inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium',
|
||||
faq.showOnHomepage ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'
|
||||
)}
|
||||
>
|
||||
{faq.showOnHomepage ? (locale === 'es' ? 'Sí' : 'Yes') : (locale === 'es' ? 'No' : 'No')}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button size="sm" variant="outline" onClick={() => handleEdit(faq)}>
|
||||
<PencilSquareIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => handleDelete(faq.id)} className="text-red-600 hover:bg-red-50">
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -112,6 +112,7 @@ export default function AdminGalleryPage() {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
BanknotesIcon,
|
||||
QrCodeIcon,
|
||||
DocumentTextIcon,
|
||||
QuestionMarkCircleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
import { useState } from 'react';
|
||||
@@ -36,14 +37,56 @@ export default function AdminLayout({
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { t, locale } = useLanguage();
|
||||
const { user, isAdmin, isLoading, logout } = useAuth();
|
||||
const { user, hasAdminAccess, isLoading, logout } = useAuth();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
type Role = 'admin' | 'organizer' | 'staff' | 'marketing';
|
||||
const userRole = (user?.role || 'user') as Role;
|
||||
|
||||
const navigationWithRoles: { name: string; href: string; icon: typeof HomeIcon; allowedRoles: Role[] }[] = [
|
||||
{ name: t('admin.nav.dashboard'), href: '/admin', icon: HomeIcon, allowedRoles: ['admin', 'organizer'] },
|
||||
{ name: t('admin.nav.events'), href: '/admin/events', icon: CalendarIcon, allowedRoles: ['admin', 'organizer', 'staff'] },
|
||||
{ name: t('admin.nav.bookings'), href: '/admin/bookings', icon: TicketIcon, allowedRoles: ['admin', 'organizer'] },
|
||||
{ name: locale === 'es' ? 'Escáner' : 'Scanner', href: '/admin/scanner', icon: QrCodeIcon, allowedRoles: ['admin', 'organizer', 'staff'] },
|
||||
{ name: t('admin.nav.users'), href: '/admin/users', icon: UsersIcon, allowedRoles: ['admin'] },
|
||||
{ name: t('admin.nav.payments'), href: '/admin/payments', icon: CreditCardIcon, allowedRoles: ['admin', 'organizer'] },
|
||||
{ name: locale === 'es' ? 'Opciones de Pago' : 'Payment Options', href: '/admin/payment-options', icon: BanknotesIcon, allowedRoles: ['admin', 'organizer'] },
|
||||
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon, allowedRoles: ['admin', 'organizer', 'marketing'] },
|
||||
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon, allowedRoles: ['admin', 'organizer'] },
|
||||
{ name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon, allowedRoles: ['admin', 'organizer'] },
|
||||
{ name: locale === 'es' ? 'Páginas Legales' : 'Legal Pages', href: '/admin/legal-pages', icon: DocumentTextIcon, allowedRoles: ['admin'] },
|
||||
{ name: 'FAQ', href: '/admin/faq', icon: QuestionMarkCircleIcon, allowedRoles: ['admin'] },
|
||||
{ name: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon, allowedRoles: ['admin'] },
|
||||
];
|
||||
|
||||
const allowedPathsForRole = new Set(
|
||||
navigationWithRoles.filter((item) => item.allowedRoles.includes(userRole)).map((item) => item.href)
|
||||
);
|
||||
const defaultAdminRoute =
|
||||
userRole === 'staff' ? '/admin/scanner' : userRole === 'marketing' ? '/admin/contacts' : '/admin';
|
||||
|
||||
// All hooks must be called unconditionally before any early returns
|
||||
useEffect(() => {
|
||||
if (!isLoading && (!user || !isAdmin)) {
|
||||
if (!isLoading && (!user || !hasAdminAccess)) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [user, isAdmin, isLoading, router]);
|
||||
}, [user, hasAdminAccess, isLoading, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !hasAdminAccess) return;
|
||||
if (!pathname.startsWith('/admin')) return;
|
||||
if (pathname === '/admin' && (userRole === 'staff' || userRole === 'marketing')) {
|
||||
router.replace(defaultAdminRoute);
|
||||
return;
|
||||
}
|
||||
const isPathAllowed = (path: string) => {
|
||||
if (allowedPathsForRole.has(path)) return true;
|
||||
return Array.from(allowedPathsForRole).some((allowed) => path.startsWith(allowed + '/'));
|
||||
};
|
||||
if (!isPathAllowed(pathname)) {
|
||||
router.replace(defaultAdminRoute);
|
||||
}
|
||||
}, [pathname, userRole, defaultAdminRoute, router, user, hasAdminAccess]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -53,30 +96,29 @@ export default function AdminLayout({
|
||||
);
|
||||
}
|
||||
|
||||
if (!user || !isAdmin) {
|
||||
if (!user || !hasAdminAccess) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
{ name: t('admin.nav.dashboard'), href: '/admin', icon: HomeIcon },
|
||||
{ name: t('admin.nav.events'), href: '/admin/events', icon: CalendarIcon },
|
||||
{ name: t('admin.nav.bookings'), href: '/admin/bookings', icon: TicketIcon },
|
||||
{ name: locale === 'es' ? 'Escáner' : 'Scanner', href: '/admin/scanner', icon: QrCodeIcon },
|
||||
{ name: t('admin.nav.users'), href: '/admin/users', icon: UsersIcon },
|
||||
{ name: t('admin.nav.payments'), href: '/admin/payments', icon: CreditCardIcon },
|
||||
{ name: locale === 'es' ? 'Opciones de Pago' : 'Payment Options', href: '/admin/payment-options', icon: BanknotesIcon },
|
||||
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon },
|
||||
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon },
|
||||
{ name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon },
|
||||
{ name: locale === 'es' ? 'Páginas Legales' : 'Legal Pages', href: '/admin/legal-pages', icon: DocumentTextIcon },
|
||||
{ name: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon },
|
||||
];
|
||||
const visibleNav = navigationWithRoles.filter((item) => item.allowedRoles.includes(userRole));
|
||||
const navigation = visibleNav;
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
// Scanner page gets fullscreen layout without sidebar
|
||||
const isScannerPage = pathname === '/admin/scanner';
|
||||
|
||||
if (isScannerPage) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-secondary-gray">
|
||||
{/* Mobile sidebar backdrop */}
|
||||
|
||||
@@ -164,6 +164,7 @@ export default function AdminLegalPagesPage() {
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
@@ -420,6 +421,46 @@ export default function AdminLegalPagesPage() {
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Available Placeholders */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800">
|
||||
<p className="font-medium mb-2">
|
||||
{locale === 'es' ? 'Marcadores de posición disponibles:' : 'Available placeholders:'}
|
||||
</p>
|
||||
<p className="text-blue-700 mb-3">
|
||||
{locale === 'es'
|
||||
? 'Puedes usar estos marcadores en el contenido. Se reemplazarán automáticamente con los valores configurados en'
|
||||
: 'You can use these placeholders in the content. They will be automatically replaced with the values configured in'
|
||||
}
|
||||
{' '}
|
||||
<a href="/admin/settings" className="underline font-medium hover:text-blue-900">
|
||||
{locale === 'es' ? 'Configuración > Legal' : 'Settings > Legal Settings'}
|
||||
</a>.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1.5">
|
||||
{[
|
||||
{ placeholder: '{{COMPANY_NAME}}', label: locale === 'es' ? 'Nombre de la empresa' : 'Company name' },
|
||||
{ placeholder: '{{LEGAL_ENTITY_NAME}}', label: locale === 'es' ? 'Nombre de la entidad legal' : 'Legal entity name' },
|
||||
{ placeholder: '{{RUC_NUMBER}}', label: locale === 'es' ? 'Número de RUC' : 'RUC number' },
|
||||
{ placeholder: '{{COMPANY_ADDRESS}}', label: locale === 'es' ? 'Dirección de la empresa' : 'Company address' },
|
||||
{ placeholder: '{{COMPANY_CITY}}', label: locale === 'es' ? 'Ciudad' : 'City' },
|
||||
{ placeholder: '{{COMPANY_COUNTRY}}', label: locale === 'es' ? 'País' : 'Country' },
|
||||
{ placeholder: '{{SUPPORT_EMAIL}}', label: locale === 'es' ? 'Email de soporte' : 'Support email' },
|
||||
{ placeholder: '{{LEGAL_EMAIL}}', label: locale === 'es' ? 'Email legal' : 'Legal email' },
|
||||
{ placeholder: '{{GOVERNING_LAW}}', label: locale === 'es' ? 'Ley aplicable' : 'Governing law' },
|
||||
{ placeholder: '{{JURISDICTION_CITY}}', label: locale === 'es' ? 'Ciudad de jurisdicción' : 'Jurisdiction city' },
|
||||
{ placeholder: '{{CURRENT_YEAR}}', label: locale === 'es' ? 'Año actual (automático)' : 'Current year (automatic)' },
|
||||
{ placeholder: '{{LAST_UPDATED_DATE}}', label: locale === 'es' ? 'Fecha de última actualización (automático)' : 'Last updated date (automatic)' },
|
||||
].map(({ placeholder, label }) => (
|
||||
<div key={placeholder} className="flex items-center gap-2">
|
||||
<code className="bg-blue-100 text-blue-900 px-1.5 py-0.5 rounded text-xs font-mono whitespace-nowrap">
|
||||
{placeholder}
|
||||
</code>
|
||||
<span className="text-blue-700 text-xs truncate">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,7 @@ export default function AdminDashboardPage() {
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -199,6 +199,7 @@ export default function AdminPaymentsPage() {
|
||||
day: 'numeric',
|
||||
hour: '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',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ export default function AdminUsersPage() {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
31
frontend/src/app/api/revalidate/route.ts
Normal file
31
frontend/src/app/api/revalidate/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { revalidateTag } from 'next/cache';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { secret, tag } = body;
|
||||
|
||||
// Validate the revalidation secret
|
||||
const revalidateSecret = process.env.REVALIDATE_SECRET;
|
||||
if (!revalidateSecret || secret !== revalidateSecret) {
|
||||
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Validate tag(s) - supports single tag or array of tags
|
||||
const allowedTags = ['events-sitemap', 'next-event'];
|
||||
const tags: string[] = Array.isArray(tag) ? tag : [tag];
|
||||
const invalidTags = tags.filter((t: string) => !allowedTags.includes(t));
|
||||
if (tags.length === 0 || invalidTags.length > 0) {
|
||||
return NextResponse.json({ error: 'Invalid tag' }, { status: 400 });
|
||||
}
|
||||
|
||||
for (const t of tags) {
|
||||
revalidateTag(t);
|
||||
}
|
||||
|
||||
return NextResponse.json({ revalidated: true, tags, now: Date.now() });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Failed to revalidate' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { eventsApi, Event } from '@/lib/api';
|
||||
import { formatPrice } from '@/lib/utils';
|
||||
import { formatPrice, formatDateShort, formatTime } from '@/lib/utils';
|
||||
import {
|
||||
CalendarIcon,
|
||||
MapPinIcon,
|
||||
@@ -29,20 +29,8 @@ export default function LinktreePage() {
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
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 formatDate = (dateStr: string) => formatDateShort(dateStr, locale as 'en' | 'es');
|
||||
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||
|
||||
// Handle both full URLs and handles
|
||||
const instagramUrl = instagramHandle
|
||||
@@ -80,7 +68,7 @@ export default function LinktreePage() {
|
||||
<div className="animate-spin w-6 h-6 border-2 border-primary-yellow border-t-transparent rounded-full mx-auto" />
|
||||
</div>
|
||||
) : nextEvent ? (
|
||||
<Link href={`/book/${nextEvent.id}`} className="block group">
|
||||
<Link href={`/events/${nextEvent.id}`} className="block group">
|
||||
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-5 border border-white/10 transition-all duration-300 hover:bg-white/15 hover:scale-[1.02] hover:shadow-xl">
|
||||
<h3 className="font-bold text-lg text-white group-hover:text-primary-yellow transition-colors">
|
||||
{locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title}
|
||||
@@ -89,7 +77,7 @@ export default function LinktreePage() {
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-gray-300 text-sm">
|
||||
<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 className="flex items-center gap-2 text-gray-300 text-sm">
|
||||
<MapPinIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
|
||||
@@ -111,7 +99,7 @@ export default function LinktreePage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-4 bg-primary-yellow text-primary-dark font-semibold py-3 px-4 rounded-xl text-center transition-all duration-200 group-hover:bg-yellow-400">
|
||||
{t('linktree.bookNow')}
|
||||
{t('linktree.moreInfo')}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
272
frontend/src/app/llms.txt/route.ts
Normal file
272
frontend/src/app/llms.txt/route.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
interface LlmsFaq {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
interface LlmsEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
titleEs?: string;
|
||||
shortDescription?: string;
|
||||
shortDescriptionEs?: string;
|
||||
description: string;
|
||||
descriptionEs?: string;
|
||||
startDatetime: string;
|
||||
endDatetime?: string;
|
||||
location: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
availableSeats?: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
async function getNextUpcomingEvent(): Promise<LlmsEvent | null> {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/events/next/upcoming`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data.event || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getUpcomingEvents(): Promise<LlmsEvent[]> {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/events?status=published&upcoming=true`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data.events || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: EVENT_TIMEZONE,
|
||||
});
|
||||
}
|
||||
|
||||
function formatEventTime(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
timeZone: EVENT_TIMEZONE,
|
||||
});
|
||||
}
|
||||
|
||||
function formatPrice(price: number, currency: string): string {
|
||||
if (price === 0) return 'Free';
|
||||
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[]> {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/faq?homepage=true`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return (data.faqs || []).map((f: any) => ({
|
||||
question: f.question,
|
||||
answer: f.answer,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
const [nextEvent, upcomingEvents, faqs] = await Promise.all([
|
||||
getNextUpcomingEvent(),
|
||||
getUpcomingEvents(),
|
||||
getHomepageFaqs(),
|
||||
]);
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
// Header
|
||||
lines.push('# Spanglish Community');
|
||||
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('');
|
||||
lines.push(`- Website: ${siteUrl}`);
|
||||
lines.push(`- Events page: ${siteUrl}/events`);
|
||||
|
||||
// Social links
|
||||
const instagram = process.env.NEXT_PUBLIC_INSTAGRAM;
|
||||
const whatsapp = process.env.NEXT_PUBLIC_WHATSAPP;
|
||||
const telegram = process.env.NEXT_PUBLIC_TELEGRAM;
|
||||
const email = process.env.NEXT_PUBLIC_EMAIL;
|
||||
|
||||
if (instagram) lines.push(`- Instagram: ${instagram}`);
|
||||
if (telegram) lines.push(`- Telegram: ${telegram}`);
|
||||
if (email) lines.push(`- Email: ${email}`);
|
||||
if (whatsapp) lines.push(`- WhatsApp: ${whatsapp}`);
|
||||
|
||||
lines.push('');
|
||||
|
||||
// Next Event (most important section for AI)
|
||||
lines.push('## Next Event');
|
||||
lines.push('');
|
||||
|
||||
if (nextEvent) {
|
||||
const status = getEventStatus(nextEvent);
|
||||
lines.push(`- Event Name: ${nextEvent.title}`);
|
||||
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) {
|
||||
lines.push(`- End Time: ${formatISOTime(nextEvent.endDatetime)}`);
|
||||
}
|
||||
lines.push(`- Time Zone: ${EVENT_TIMEZONE}`);
|
||||
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) {
|
||||
lines.push(`- Capacity Remaining: ${nextEvent.availableSeats}`);
|
||||
}
|
||||
lines.push(`- Tickets URL: ${siteUrl}/events/${nextEvent.id}`);
|
||||
if (nextEvent.shortDescription) {
|
||||
lines.push(`- Description: ${nextEvent.shortDescription}`);
|
||||
}
|
||||
} else {
|
||||
lines.push('No upcoming events currently scheduled. Check back soon or follow us on social media for announcements.');
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
|
||||
// All upcoming events
|
||||
if (upcomingEvents.length > 1) {
|
||||
lines.push('## All Upcoming Events');
|
||||
lines.push('');
|
||||
for (const event of upcomingEvents) {
|
||||
const status = getEventStatus(event);
|
||||
lines.push(`### ${event.title}`);
|
||||
lines.push(`- Event ID: ${event.id}`);
|
||||
lines.push(`- Status: ${status}`);
|
||||
lines.push(`- Date: ${formatISODate(event.startDatetime)}`);
|
||||
lines.push(`- Start Time: ${formatISOTime(event.startDatetime)}`);
|
||||
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('');
|
||||
}
|
||||
}
|
||||
|
||||
// About section
|
||||
lines.push('## About Spanglish');
|
||||
lines.push('');
|
||||
lines.push('Spanglish is a language exchange community based in Asunción, Paraguay. We organize regular social events where people can practice English and Spanish in a relaxed, friendly environment. Our events bring together locals and internationals for conversation, cultural exchange, and fun.');
|
||||
lines.push('');
|
||||
lines.push('## Frequently Asked Questions');
|
||||
lines.push('');
|
||||
if (faqs.length > 0) {
|
||||
for (const faq of faqs) {
|
||||
lines.push(`- **${faq.question}** ${faq.answer}`);
|
||||
}
|
||||
} else {
|
||||
lines.push('- **What is Spanglish?** A language exchange community that hosts social events to practice English and Spanish.');
|
||||
lines.push('- **Where are events held?** In Asunción, Paraguay. Specific venues are listed on each event page.');
|
||||
lines.push('- **How do I attend an event?** Visit the events page to see upcoming events and book tickets.');
|
||||
}
|
||||
lines.push(`- More FAQ: ${siteUrl}/faq`);
|
||||
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');
|
||||
|
||||
return new NextResponse(content, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -7,29 +7,16 @@ export default function robots(): MetadataRoute.Robots {
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: [
|
||||
'/',
|
||||
'/events',
|
||||
'/events/*',
|
||||
'/community',
|
||||
'/contact',
|
||||
'/faq',
|
||||
'/legal/*',
|
||||
],
|
||||
allow: '/',
|
||||
disallow: [
|
||||
'/admin',
|
||||
'/admin/*',
|
||||
'/dashboard',
|
||||
'/dashboard/*',
|
||||
'/api',
|
||||
'/api/*',
|
||||
'/book',
|
||||
'/book/*',
|
||||
'/booking',
|
||||
'/booking/*',
|
||||
'/admin/',
|
||||
'/dashboard/',
|
||||
'/api/',
|
||||
'/book/',
|
||||
'/booking/',
|
||||
'/login',
|
||||
'/register',
|
||||
'/auth/*',
|
||||
'/auth/',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -3,89 +3,109 @@ import { MetadataRoute } from 'next';
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
interface Event {
|
||||
interface SitemapEvent {
|
||||
id: string;
|
||||
status: string;
|
||||
startDatetime: 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 {
|
||||
const response = await fetch(`${apiUrl}/api/events?status=published`, {
|
||||
next: { revalidate: 3600 }, // Cache for 1 hour
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data.events || [];
|
||||
const [publishedRes, completedRes] = await Promise.all([
|
||||
fetch(`${apiUrl}/api/events?status=published`, {
|
||||
next: { tags: ['events-sitemap'] },
|
||||
}),
|
||||
fetch(`${apiUrl}/api/events?status=completed`, {
|
||||
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 {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
// Fetch published events for dynamic event pages
|
||||
const events = await getPublishedEvents();
|
||||
const events = await getIndexableEvents();
|
||||
const now = new Date();
|
||||
|
||||
// Static pages
|
||||
const staticPages: MetadataRoute.Sitemap = [
|
||||
{
|
||||
url: siteUrl,
|
||||
lastModified: new Date(),
|
||||
lastModified: now,
|
||||
changeFrequency: 'weekly',
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/events`,
|
||||
lastModified: new Date(),
|
||||
lastModified: now,
|
||||
changeFrequency: 'daily',
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/community`,
|
||||
lastModified: new Date(),
|
||||
lastModified: now,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.7,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/contact`,
|
||||
lastModified: new Date(),
|
||||
lastModified: now,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.6,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/faq`,
|
||||
lastModified: new Date(),
|
||||
lastModified: now,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.6,
|
||||
},
|
||||
// Legal pages
|
||||
{
|
||||
url: `${siteUrl}/legal/terms-policy`,
|
||||
lastModified: new Date(),
|
||||
lastModified: now,
|
||||
changeFrequency: 'yearly',
|
||||
priority: 0.3,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/legal/privacy-policy`,
|
||||
lastModified: new Date(),
|
||||
lastModified: now,
|
||||
changeFrequency: 'yearly',
|
||||
priority: 0.3,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/legal/refund-cancelation-policy`,
|
||||
lastModified: new Date(),
|
||||
lastModified: now,
|
||||
changeFrequency: 'yearly',
|
||||
priority: 0.3,
|
||||
},
|
||||
];
|
||||
|
||||
// Dynamic event pages
|
||||
const eventPages: MetadataRoute.Sitemap = events.map((event) => ({
|
||||
url: `${siteUrl}/events/${event.id}`,
|
||||
lastModified: new Date(event.updatedAt),
|
||||
changeFrequency: 'weekly' as const,
|
||||
priority: 0.8,
|
||||
}));
|
||||
// Dynamic event pages — upcoming events get higher priority
|
||||
const eventPages: MetadataRoute.Sitemap = events.map((event) => {
|
||||
const isUpcoming = new Date(event.startDatetime) > now;
|
||||
return {
|
||||
url: `${siteUrl}/events/${event.id}`,
|
||||
lastModified: new Date(event.updatedAt),
|
||||
changeFrequency: isUpcoming ? ('weekly' as const) : ('monthly' as const),
|
||||
priority: isUpcoming ? 0.8 : 0.5,
|
||||
};
|
||||
});
|
||||
|
||||
return [...staticPages, ...eventPages];
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ function MobileNavLink({ href, children, onClick }: { href: string; children: Re
|
||||
|
||||
export default function Header() {
|
||||
const { t } = useLanguage();
|
||||
const { user, isAdmin, logout } = useAuth();
|
||||
const { user, hasAdminAccess, logout } = useAuth();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const touchStartX = useRef<number>(0);
|
||||
@@ -148,7 +148,7 @@ export default function Header() {
|
||||
{t('nav.dashboard')}
|
||||
</Button>
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
{hasAdminAccess && (
|
||||
<Link href="/admin">
|
||||
<Button variant="ghost" size="sm">
|
||||
{t('nav.admin')}
|
||||
@@ -270,7 +270,7 @@ export default function Header() {
|
||||
{t('nav.dashboard')}
|
||||
</Button>
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
{hasAdminAccess && (
|
||||
<Link href="/admin" onClick={closeMenu}>
|
||||
<Button variant="outline" className="w-full justify-center">
|
||||
{t('nav.admin')}
|
||||
|
||||
@@ -342,6 +342,7 @@ export default function RichTextEditor({
|
||||
const lastContentRef = useRef(content);
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: {
|
||||
@@ -393,6 +394,7 @@ export function RichTextPreview({
|
||||
className?: string;
|
||||
}) {
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
extensions: [StarterKit],
|
||||
content: markdownToHtml(content),
|
||||
editable: false,
|
||||
|
||||
@@ -21,6 +21,7 @@ interface AuthContextType {
|
||||
token: string | null;
|
||||
isLoading: boolean;
|
||||
isAdmin: boolean;
|
||||
hasAdminAccess: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
loginWithGoogle: (credential: string) => Promise<void>;
|
||||
loginWithMagicLink: (token: string) => Promise<void>;
|
||||
@@ -177,6 +178,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const isAdmin = user?.role === 'admin' || user?.role === 'organizer';
|
||||
const hasAdminAccess = user?.role === 'admin' || user?.role === 'organizer' || user?.role === 'staff' || user?.role === 'marketing';
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
@@ -185,6 +187,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
token,
|
||||
isLoading,
|
||||
isAdmin,
|
||||
hasAdminAccess,
|
||||
login,
|
||||
loginWithGoogle,
|
||||
loginWithMagicLink,
|
||||
|
||||
@@ -67,6 +67,10 @@
|
||||
"button": "Subscribe",
|
||||
"success": "Thanks for subscribing!",
|
||||
"error": "Subscription failed. Please try again."
|
||||
},
|
||||
"faq": {
|
||||
"title": "Frequently Asked Questions",
|
||||
"seeFull": "See full FAQ"
|
||||
}
|
||||
},
|
||||
"events": {
|
||||
@@ -317,7 +321,7 @@
|
||||
"tagline": "Language Exchange Community",
|
||||
"nextEvent": "Next Event",
|
||||
"noEvents": "No upcoming events",
|
||||
"bookNow": "Book Now",
|
||||
"moreInfo": "More info",
|
||||
"joinCommunity": "Join Our Community",
|
||||
"visitWebsite": "Visit Our Website",
|
||||
"whatsapp": {
|
||||
|
||||
@@ -67,6 +67,10 @@
|
||||
"button": "Suscribirse",
|
||||
"success": "¡Gracias por suscribirte!",
|
||||
"error": "Error al suscribirse. Por favor intenta de nuevo."
|
||||
},
|
||||
"faq": {
|
||||
"title": "Preguntas Frecuentes",
|
||||
"seeFull": "Ver FAQ completo"
|
||||
}
|
||||
},
|
||||
"events": {
|
||||
@@ -317,7 +321,7 @@
|
||||
"tagline": "Comunidad de Intercambio de Idiomas",
|
||||
"nextEvent": "Próximo Evento",
|
||||
"noEvents": "No hay eventos próximos",
|
||||
"bookNow": "Reservar Ahora",
|
||||
"moreInfo": "Más información",
|
||||
"joinCommunity": "Únete a Nuestra Comunidad",
|
||||
"visitWebsite": "Visitar Nuestro Sitio",
|
||||
"whatsapp": {
|
||||
|
||||
@@ -93,6 +93,27 @@ export const ticketsApi = {
|
||||
body: JSON.stringify({ code, eventId }),
|
||||
}),
|
||||
|
||||
// Search tickets by name/email (for scanner manual search)
|
||||
search: (query: string, eventId?: string) =>
|
||||
fetchApi<{ tickets: TicketSearchResult[] }>('/api/tickets/search', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ query, eventId }),
|
||||
}),
|
||||
|
||||
// Get event check-in stats (for scanner header counter)
|
||||
getCheckinStats: (eventId: string) =>
|
||||
fetchApi<{ eventId: string; capacity: number; checkedIn: number; totalActive: number }>(
|
||||
`/api/tickets/stats/checkin?eventId=${eventId}`
|
||||
),
|
||||
|
||||
// Live search tickets (GET - for scanner live search with debounce)
|
||||
searchLive: (q: string, eventId?: string) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('q', q);
|
||||
if (eventId) params.set('eventId', eventId);
|
||||
return fetchApi<{ tickets: LiveSearchResult[] }>(`/api/tickets/search?${params}`);
|
||||
},
|
||||
|
||||
checkin: (id: string) =>
|
||||
fetchApi<{ ticket: Ticket & { attendeeName?: string }; event?: { id: string; title: string }; message: string }>(`/api/tickets/${id}/checkin`, {
|
||||
method: 'POST',
|
||||
@@ -351,6 +372,49 @@ export const adminApi = {
|
||||
if (params?.eventId) query.set('eventId', params.eventId);
|
||||
return fetchApi<{ payments: ExportedPayment[]; summary: FinancialSummary }>(`/api/admin/export/financial?${query}`);
|
||||
},
|
||||
/** Download attendee export as a file (CSV). Returns a Blob. */
|
||||
exportAttendees: async (eventId: string, params?: { status?: string; format?: string; q?: string }) => {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.status) query.set('status', params.status);
|
||||
if (params?.format) query.set('format', params.format);
|
||||
if (params?.q) query.set('q', params.q);
|
||||
const token = typeof window !== 'undefined'
|
||||
? localStorage.getItem('spanglish-token')
|
||||
: null;
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await fetch(`${API_BASE}/api/admin/events/${eventId}/attendees/export?${query}`, { headers });
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({ error: 'Export failed' }));
|
||||
throw new Error(errorData.error || 'Export failed');
|
||||
}
|
||||
const disposition = res.headers.get('Content-Disposition') || '';
|
||||
const filenameMatch = disposition.match(/filename="?([^"]+)"?/);
|
||||
const filename = filenameMatch ? filenameMatch[1] : `attendees-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
const blob = await res.blob();
|
||||
return { blob, filename };
|
||||
},
|
||||
/** Download tickets export as CSV. Returns a Blob. */
|
||||
exportTicketsCSV: async (eventId: string, params?: { status?: string; q?: string }) => {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.status) query.set('status', params.status);
|
||||
if (params?.q) query.set('q', params.q);
|
||||
const token = typeof window !== 'undefined'
|
||||
? localStorage.getItem('spanglish-token')
|
||||
: null;
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await fetch(`${API_BASE}/api/admin/events/${eventId}/tickets/export?${query}`, { headers });
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({ error: 'Export failed' }));
|
||||
throw new Error(errorData.error || 'Export failed');
|
||||
}
|
||||
const disposition = res.headers.get('Content-Disposition') || '';
|
||||
const filenameMatch = disposition.match(/filename="?([^"]+)"?/);
|
||||
const filename = filenameMatch ? filenameMatch[1] : `tickets-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
const blob = await res.blob();
|
||||
return { blob, filename };
|
||||
},
|
||||
};
|
||||
|
||||
// Emails API
|
||||
@@ -384,7 +448,7 @@ export const emailsApi = {
|
||||
customVariables?: Record<string, any>;
|
||||
recipientFilter?: 'all' | 'confirmed' | 'pending' | 'checked_in';
|
||||
}) =>
|
||||
fetchApi<{ success: boolean; sentCount: number; failedCount: number; errors: string[] }>(
|
||||
fetchApi<{ success: boolean; queuedCount: number; error?: string }>(
|
||||
`/api/emails/send/event/${eventId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -508,6 +572,39 @@ export interface TicketValidationResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface TicketSearchResult {
|
||||
id: string;
|
||||
qrCode: string;
|
||||
attendeeName: string;
|
||||
attendeeEmail?: string;
|
||||
attendeePhone?: string;
|
||||
status: string;
|
||||
checkinAt?: string;
|
||||
event?: {
|
||||
id: string;
|
||||
title: string;
|
||||
startDatetime: string;
|
||||
location: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface LiveSearchResult {
|
||||
ticket_id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
status: string;
|
||||
checked_in: boolean;
|
||||
checkinAt?: string;
|
||||
event_id: string;
|
||||
qrCode: string;
|
||||
event?: {
|
||||
id: string;
|
||||
title: string;
|
||||
startDatetime: string;
|
||||
location: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface Payment {
|
||||
id: string;
|
||||
ticketId: string;
|
||||
@@ -1008,6 +1105,34 @@ export const siteSettingsApi = {
|
||||
}),
|
||||
};
|
||||
|
||||
// ==================== Legal Settings API ====================
|
||||
|
||||
export interface LegalSettingsData {
|
||||
id?: string;
|
||||
companyName?: string | null;
|
||||
legalEntityName?: string | null;
|
||||
rucNumber?: string | null;
|
||||
companyAddress?: string | null;
|
||||
companyCity?: string | null;
|
||||
companyCountry?: string | null;
|
||||
supportEmail?: string | null;
|
||||
legalEmail?: string | null;
|
||||
governingLaw?: string | null;
|
||||
jurisdictionCity?: string | null;
|
||||
updatedAt?: string;
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export const legalSettingsApi = {
|
||||
get: () => fetchApi<{ settings: LegalSettingsData }>('/api/legal-settings'),
|
||||
|
||||
update: (data: Partial<LegalSettingsData>) =>
|
||||
fetchApi<{ settings: LegalSettingsData; message: string }>('/api/legal-settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
};
|
||||
|
||||
// ==================== Legal Pages Types ====================
|
||||
|
||||
export interface LegalPage {
|
||||
@@ -1078,3 +1203,71 @@ export const legalPagesApi = {
|
||||
method: 'POST',
|
||||
}),
|
||||
};
|
||||
|
||||
// ==================== FAQ Types ====================
|
||||
|
||||
export interface FaqItem {
|
||||
id: string;
|
||||
question: string;
|
||||
questionEs?: string | null;
|
||||
answer: string;
|
||||
answerEs?: string | null;
|
||||
rank?: number;
|
||||
}
|
||||
|
||||
export interface FaqItemAdmin extends FaqItem {
|
||||
enabled: boolean;
|
||||
showOnHomepage: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ==================== FAQ API ====================
|
||||
|
||||
export const faqApi = {
|
||||
// Public
|
||||
getList: (homepageOnly?: boolean) =>
|
||||
fetchApi<{ faqs: FaqItem[] }>(`/api/faq${homepageOnly ? '?homepage=true' : ''}`),
|
||||
|
||||
// Admin
|
||||
getAdminList: () =>
|
||||
fetchApi<{ faqs: FaqItemAdmin[] }>('/api/faq/admin/list'),
|
||||
|
||||
getById: (id: string) =>
|
||||
fetchApi<{ faq: FaqItemAdmin }>(`/api/faq/admin/${id}`),
|
||||
|
||||
create: (data: {
|
||||
question: string;
|
||||
questionEs?: string;
|
||||
answer: string;
|
||||
answerEs?: string;
|
||||
enabled?: boolean;
|
||||
showOnHomepage?: boolean;
|
||||
}) =>
|
||||
fetchApi<{ faq: FaqItemAdmin }>('/api/faq/admin', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
update: (id: string, data: {
|
||||
question?: string;
|
||||
questionEs?: string | null;
|
||||
answer?: string;
|
||||
answerEs?: string | null;
|
||||
enabled?: boolean;
|
||||
showOnHomepage?: boolean;
|
||||
}) =>
|
||||
fetchApi<{ faq: FaqItemAdmin }>(`/api/faq/admin/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
delete: (id: string) =>
|
||||
fetchApi<{ message: string }>(`/api/faq/admin/${id}`, { method: 'DELETE' }),
|
||||
|
||||
reorder: (ids: string[]) =>
|
||||
fetchApi<{ faqs: FaqItemAdmin[] }>('/api/faq/admin/reorder', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -102,7 +102,7 @@ export function getLegalPageFromFilesystem(slug: string, locale: string = 'en'):
|
||||
|
||||
// Get a specific legal page content - tries API first, falls back to filesystem
|
||||
export async function getLegalPageAsync(slug: string, locale: string = 'en'): Promise<LegalPage | null> {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
// Try to fetch from API with locale parameter
|
||||
try {
|
||||
|
||||
@@ -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
|
||||
* Uses space as thousands separator (common in Paraguay)
|
||||
|
||||
Reference in New Issue
Block a user