diff --git a/backend/.env.example b/backend/.env.example index 614ac56..19dfe4e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -21,6 +21,10 @@ PORT=3001 API_URL=http://localhost:3001 FRONTEND_URL=http://localhost:3002 +# 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= STRIPE_WEBHOOK_SECRET= diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index c450579..c443b50 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -421,6 +421,22 @@ 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 + ) + `); } else { // PostgreSQL migrations await (db as any).execute(sql` @@ -790,6 +806,22 @@ 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 + ) + `); } console.log('Migrations completed successfully!'); diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 1d4e2f3..f11f645 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -267,6 +267,20 @@ 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(), +}); + // Site Settings table for global website configuration export const sqliteSiteSettings = sqliteTable('site_settings', { id: text('id').primaryKey(), @@ -550,6 +564,20 @@ 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(), +}); + // Site Settings table for global website configuration export const pgSiteSettings = pgTable('site_settings', { id: uuid('id').primaryKey(), @@ -597,6 +625,7 @@ export const userSessions = dbType === 'postgres' ? pgUserSessions : sqliteUserS export const invoices = dbType === 'postgres' ? pgInvoices : sqliteInvoices; 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 +656,5 @@ 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; diff --git a/backend/src/index.ts b/backend/src/index.ts index ac85087..6aa413b 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -21,6 +21,7 @@ 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 faqRoutes from './routes/faq.js'; import emailService from './lib/email.js'; const app = new Hono(); @@ -84,6 +85,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 +1589,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 +1856,7 @@ app.route('/api/payment-options', paymentOptionsRoutes); app.route('/api/dashboard', dashboardRoutes); app.route('/api/site-settings', siteSettingsRoutes); app.route('/api/legal-pages', legalPagesRoutes); +app.route('/api/faq', faqRoutes); // 404 handler app.notFound((c) => { diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index 3eb64fc..b2ca8c6 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -15,6 +15,29 @@ interface UserContext { const eventsRouter = new Hono<{ Variables: { user: UserContext } }>(); +// Trigger frontend cache revalidation (fire-and-forget) +// Revalidates both the sitemap and the next-event data (homepage, llms.txt) +function revalidateFrontendCache() { + const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002'; + const secret = process.env.REVALIDATE_SECRET; + if (!secret) { + console.warn('REVALIDATE_SECRET not set, skipping frontend revalidation'); + return; + } + fetch(`${frontendUrl}/api/revalidate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ secret, tag: ['events-sitemap', 'next-event'] }), + }) + .then((res) => { + if (!res.ok) console.error('Frontend revalidation failed:', res.status); + else console.log('Frontend revalidation triggered (sitemap + next-event)'); + }) + .catch((err) => { + console.error('Frontend revalidation error:', err.message); + }); +} + // Helper to normalize event data for API response // PostgreSQL decimal returns strings, booleans are stored as integers function normalizeEvent(event: any) { @@ -337,6 +360,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 +399,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 +458,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' }); }); diff --git a/backend/src/routes/faq.ts b/backend/src/routes/faq.ts new file mode 100644 index 0000000..86f5b2f --- /dev/null +++ b/backend/src/routes/faq.ts @@ -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(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( + (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( + (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( + (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( + (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( + (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 = { + 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( + (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( + (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( + (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; diff --git a/frontend/.env.example b/frontend/.env.example index 1e240b6..73dc506 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -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 diff --git a/frontend/src/app/(public)/components/HomepageFaqSection.tsx b/frontend/src/app/(public)/components/HomepageFaqSection.tsx new file mode 100644 index 0000000..bf07678 --- /dev/null +++ b/frontend/src/app/(public)/components/HomepageFaqSection.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [openIndex, setOpenIndex] = useState(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 ( +
+
+
+

+ {t('home.faq.title')} +

+
+ {faqs.map((faq, index) => ( +
+ +
+
+ {locale === 'es' && faq.answerEs ? faq.answerEs : faq.answer} +
+
+
+ ))} +
+
+ + {t('home.faq.seeFull')} + +
+
+
+
+ ); +} diff --git a/frontend/src/app/(public)/components/NextEventSection.tsx b/frontend/src/app/(public)/components/NextEventSection.tsx index d306795..2fac674 100644 --- a/frontend/src/app/(public)/components/NextEventSection.tsx +++ b/frontend/src/app/(public)/components/NextEventSection.tsx @@ -9,17 +9,23 @@ 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(null); - const [loading, setLoading] = useState(true); + const [nextEvent, setNextEvent] = useState(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', { diff --git a/frontend/src/app/(public)/components/NextEventSectionWrapper.tsx b/frontend/src/app/(public)/components/NextEventSectionWrapper.tsx index a2525ef..9bd78d7 100644 --- a/frontend/src/app/(public)/components/NextEventSectionWrapper.tsx +++ b/frontend/src/app/(public)/components/NextEventSectionWrapper.tsx @@ -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')}
- +
diff --git a/frontend/src/app/(public)/faq/layout.tsx b/frontend/src/app/(public)/faq/layout.tsx index b9fcd5d..bc95501 100644 --- a/frontend/src/app/(public)/faq/layout.tsx +++ b/frontend/src/app/(public)/faq/layout.tsx @@ -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 ( <>