const fs = require('fs/promises'); const path = require('path'); const EventPage = require('../models/EventPage'); const { loadAgendaAndSpeakers } = require('./eventAgendaData'); const { buildSectionsHtml, escapeHtml } = require('./websiteHtml'); const { buildConferenceThemeStyleTag, buildConferenceFontLink } = require('./conferenceTheme'); const { WEBSITE_SECTION_TYPES, DEFAULT_MICROSITE_SECTIONS } = require('../config/constants'); const LAYOUT_TO_FOLDER = { conference: 'conference' }; function eventBuzzPublicBase() { return (process.env.EVENTBUZZ_PUBLIC_URL || process.env.FRONTEND_URL || 'http://localhost:5173').replace( /\/$/, '', ); } /** * Public dashboard form page (fallback when MICROSITE_REGISTRATION_URL_TEMPLATE is unset). * @param {string} formId * @returns {string} */ function publicFormUrl(formId, eventId) { const fid = formId != null ? String(formId).trim() : ''; if (!fid) return ''; const eid = eventId != null ? String(eventId).trim() : ''; if (eid) return `${eventBuzzPublicBase()}/event/${eid}/form/${fid}`; return `${eventBuzzPublicBase()}/form/${fid}`; } /** * @param {object} event * @returns {string} */ function registrationHref(event, website) { const w = website || event.website || {}; const template = process.env.MICROSITE_REGISTRATION_URL_TEMPLATE || ''; const formId = w.registrationFormId != null ? String(w.registrationFormId).trim() : ''; const eid = event._id != null ? String(event._id).trim() : ''; if (template && formId && eid) { return template.replace(/\{eventId\}/g, eid).replace(/\{formId\}/g, formId); } const legacy = w.registrationUrl != null ? String(w.registrationUrl).trim() : ''; if (legacy) return legacy; if (eid) return `${eventBuzzPublicBase()}/event/${eid}/register`; return ''; } /** * Registration URL for an explicit form id (visual builder CTA). Uses same template vars as {@link registrationHref}. * @param {object} event * @param {object} [website] * @param {string} [explicitFormId] Mongo id of Form * @returns {string} */ function registrationUrlForForm(event, website, explicitFormId) { const w = website || event.website || {}; const template = process.env.MICROSITE_REGISTRATION_URL_TEMPLATE || ''; const fid = explicitFormId != null && String(explicitFormId).trim() ? String(explicitFormId).trim() : w.registrationFormId != null ? String(w.registrationFormId).trim() : ''; const eid = event._id != null ? String(event._id) : ''; if (template && fid && eid) { return template.replace(/\{eventId\}/g, eid).replace(/\{formId\}/g, fid); } const legacy = w.registrationUrl != null ? String(w.registrationUrl).trim() : ''; if (legacy) return legacy; if (fid && eid) return `${eventBuzzPublicBase()}/event/${eid}/register`; if (fid) return publicFormUrl(fid, eid); return ''; } /** * @param {object} [website] website bag (event.website shape) * @returns {string} */ function normalizeLayoutKey(website) { const w = website || {}; const t = w.layoutTemplate || 'conference'; if (t === 'conference') return 'conference'; return 'conference'; } /** * @param {object} event * @returns {string} */ function buildCountdownInlineScript(event) { const start = event.startDate ? new Date(event.startDate).getTime() : NaN; const nameJson = JSON.stringify(event.name || 'Event'); const valid = Number.isFinite(start); const startExpr = valid ? String(start) : 'null'; return ``; } const REPO_ROOT = path.join(__dirname, '..', '..', '..'); /** * @param {object} event * @returns {string} */ function formatHeroMeta(event) { const loc = event.location || ''; const start = event.startDate ? new Date(event.startDate) : null; const end = event.endDate ? new Date(event.endDate) : null; let datePart = ''; if (start && !Number.isNaN(start.getTime())) { const dopts = { month: 'short', day: 'numeric', year: 'numeric' }; if (end && !Number.isNaN(end.getTime()) && start.toDateString() !== end.toDateString()) { datePart = `${start.toLocaleDateString('en-US', dopts)} \u2013 ${end.toLocaleDateString('en-US', dopts)}`; } else { datePart = start.toLocaleDateString('en-US', dopts); } } return [datePart, loc].filter(Boolean).join(' \u00b7 '); } /** * @param {object} event * @returns {string} */ function heroBackgroundImage(event, website) { if (event.bannerImage) return String(event.bannerImage); const w = website || event.website || {}; if (Array.isArray(w.banners) && w.banners.length && w.banners[0]) return String(w.banners[0]); return ''; } /** * @param {string} layoutKey * @returns {string} */ /** * @param {string} type * @param {object} event mongoose doc or plain * @param {object} [website] website bag * @returns {object} */ function defaultConfigFor(type, event, website) { const w = website || event.website || {}; const name = event.name || 'Event'; const sub = event.subtitle || ''; const desc = event.description || ''; const reg = registrationHref(event, w) || ''; const regAnchor = reg || '#register'; switch (type) { case 'header': return { logoText: name, logoUrl: w.logo ? String(w.logo) : '', menuItems: [ { label: 'About', url: '#about' }, { label: 'Speakers', url: '#speakers' }, { label: 'Schedule', url: '#schedule' }, { label: 'Register', url: regAnchor }, ], }; case 'banner': return { headline: name, subtext: sub || desc.slice(0, 280), ctaText: 'Register', ctaUrl: regAnchor, secondaryCtaText: 'View schedule', secondaryCtaUrl: '#schedule', backgroundClass: 'bg-primary', backgroundImage: heroBackgroundImage(event, w), heroMetaLine: formatHeroMeta(event), }; case 'contact_form': return { title: 'Contact us' }; case 'agenda': return { title: 'Schedule' }; case 'register_links': return { title: 'Register', primaryLabel: 'Register now', primaryUrl: regAnchor, }; case 'speakers': return { title: 'Speakers', limit: 12 }; case 'footer': return { copyrightText: `© ${new Date().getFullYear()} ${name}. All rights reserved.` }; case 'sponsors': return { title: 'Sponsors & partners', sponsors: [], partnerCtaLabel: 'Become a partner', partnerCtaUrl: '#', }; case 'cta': return { headline: 'Join us', subtext: sub, buttonLabel: 'Register', buttonUrl: regAnchor }; case 'custom_html': return { html: desc ? `

${escapeHtml(desc)}

` : `

Event details coming soon.

`, }; case 'countdown': { const start = event.startDate ? new Date(event.startDate) : null; const line = start && !Number.isNaN(start.getTime()) ? `Event starts · ${start.toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })}` : `Event starts · ${name}`; return { headingLine: line }; } case 'gallery': return { title: 'Gallery', images: [ { url: 'https://images.unsplash.com/photo-1475721027785-f74eccf877e2?auto=format&fit=crop&w=800&q=80', alt: 'Conference audience (placeholder)' }, { url: 'https://images.unsplash.com/photo-1505373877841-8d25f7d46678?auto=format&fit=crop&w=800&q=80', alt: 'Stage (placeholder)' }, ], }; case 'about': return { title: 'About the event', lead: desc ? desc.slice(0, 400) : `Learn more about ${name}.`, bulletsTitle: 'Why attend', bullets: [ 'Hands-on sessions and practical takeaways', 'Networking with peers and sponsors', 'Product tips from the EventBuzz team', ], ctaLabel: 'Reserve your seat', ctaUrl: regAnchor, }; case 'venue': return { title: 'Venue', venueName: event.location || 'Venue TBA', intro: event.location ? `Join us at ${event.location}.` : 'Venue details will be announced soon.', mapEmbedUrl: 'https://www.google.com/maps?q=0,0&z=15&hl=en&output=embed', transportCards: [ { icon: 'airplane', title: 'By plane', text: 'Check airport transfers and local transit.' }, { icon: 'train-front', title: 'By train', text: 'Nearest station within walking distance.' }, { icon: 'bus-front', title: 'By bus', text: 'Regional lines may stop nearby.' }, { icon: 'car-front', title: 'Parking', text: 'Parking information will be shared before the event.' }, ], }; case 'faq': return { title: 'Frequently Asked Questions', items: [ { question: `When and where is ${name}?`, answer: formatHeroMeta(event) || 'Details coming soon.' }, { question: 'What is included with my ticket?', answer: 'See the registration page for tier details (demo answer).' }, { question: 'Will sessions be recorded?', answer: 'Recording policy will be communicated by the organizers.' }, ], contactEmail: 'events@example.com', }; case 'tickets': return { title: 'Tickets', intro: 'Choose the tier that fits you (demo pricing).', tiers: [ { name: 'Community', price: '199', blurb: 'Livestream + digital access (demo).', featured: false, buttonLabel: 'Choose', buttonUrl: regAnchor }, { name: 'Professional', price: '449', blurb: 'Full access, workshops (demo).', featured: true, buttonLabel: 'Buy now', buttonUrl: regAnchor }, { name: 'VIP', price: '799', blurb: 'Reserved seating + extras (demo).', featured: false, buttonLabel: 'Choose', buttonUrl: regAnchor }, ], includedTitle: "What's included (demo)", includedBullets: ['Session recordings where available', 'Templates and resources', 'Community badge on the hub'], }; default: return {}; } } /** * Overlay Website Builder section config on publish defaults so live agenda/speakers * and event-backed fields still fill gaps. * @param {string} type * @param {object|undefined|null} saved * @param {object} event * @returns {object} */ function mergeSectionConfig(type, saved, event, website) { const base = defaultConfigFor(type, event, website); if (!saved || typeof saved !== 'object' || Array.isArray(saved)) return base; const out = { ...base, ...saved }; // Website Builder seeds empty strings; treat as “use event default” for key display fields. if (type === 'header' && !String(out.logoText || '').trim()) { out.logoText = base.logoText; } if (type === 'banner') { if (!String(out.headline || '').trim()) out.headline = base.headline; if (!String(out.subtext || '').trim()) out.subtext = base.subtext; if (!String(out.secondaryCtaText || '').trim()) out.secondaryCtaText = base.secondaryCtaText; } return out; } /** * Publish section list: * - When Configure Microsite has a non-empty `micrositeSections` checklist, that order wins * (so the panel is not ignored by an older Website Builder Home page). * Config for each type is taken from the first matching Home section when present; otherwise defaults. * - When the checklist is empty, use Website Builder Home sections if any; else synthetic from panel array. * @param {object} event * @param {object} website event.website-shaped bag * @param {import('mongoose').Types.ObjectId|string|null|undefined} sourcePageId optional EventPage for sections * @returns {Promise<{ title: string, sections: object[] }>} */ async function resolvePublishPage(event, website, sourcePageId) { const allowed = new Set(WEBSITE_SECTION_TYPES); let homePage = null; if (sourcePageId) { const p = await EventPage.findById(sourcePageId).lean(); const evMatch = p && p.event && event._id && String(p.event) === String(event._id); if (evMatch && Array.isArray(p.sections) && p.sections.length > 0) { homePage = p; } } if (!homePage) { const pages = await EventPage.find({ event: event._id }) .sort({ 'settings.menuOrder': 1, createdAt: 1 }) .lean(); homePage = pages.find((p) => p.type === 'home' && Array.isArray(p.sections) && p.sections.length > 0) || pages.find((p) => Array.isArray(p.sections) && p.sections.length > 0); } const panelTypes = (website.micrositeSections || []).filter((t) => allowed.has(t)); const resolveTitle = () => (website && website.title) || (homePage && homePage.seo && homePage.seo.title) || (homePage && homePage.title) || event.name || 'Event'; if (panelTypes.length > 0) { const homeSections = homePage && Array.isArray(homePage.sections) ? homePage.sections : []; const usedHomeIdx = new Set(); const sections = panelTypes.map((type, i) => { let saved = null; for (let j = 0; j < homeSections.length; j++) { const s = homeSections[j]; if (!s || typeof s.type !== 'string' || !allowed.has(s.type)) continue; if (usedHomeIdx.has(j) || s.type !== type) continue; usedHomeIdx.add(j); saved = s.config; break; } return { id: `${type}-${i}`, type, order: i, config: mergeSectionConfig(type, saved, event, website), }; }); return { title: resolveTitle(), sections }; } if (homePage && Array.isArray(homePage.sections) && homePage.sections.length > 0) { const sections = homePage.sections .filter((s) => s && typeof s.type === 'string' && allowed.has(s.type)) .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) .map((s, i) => ({ id: s.id || `${s.type}-${i}`, type: s.type, order: s.order ?? i, config: mergeSectionConfig(s.type, s.config, event, website), })); if (sections.length > 0) { return { title: resolveTitle(), sections }; } } return buildSyntheticPage(website.micrositeSections || [], event, website); } /** * @param {string[]} micrositeSections * @param {object} event * @returns {{ title: string, sections: object[] }} */ function buildSyntheticPage(micrositeSections, event, website) { const w = website || event.website || {}; const allowed = new Set(WEBSITE_SECTION_TYPES); const configured = (micrositeSections || []).filter((t) => allowed.has(t)); const types = configured.length > 0 ? configured : DEFAULT_MICROSITE_SECTIONS; const title = (w.title && String(w.title).trim()) || event.name || 'Event'; return { title, sections: types.map((type, i) => ({ id: `${type}-${i}`, type, order: i, config: defaultConfigFor(type, event, w), })), }; } /** * Compose the same HTML document the static publish step writes (conference shell + theme + main inject), * without touching the filesystem. * * @param {object} event Event mongoose doc or lean * @param {object} [opts] * @param {string} [opts.registrationUrl] * @param {boolean} [opts.previewOnly=false] When true, slug/folder is not required (preview in editor). * @param {boolean} [opts.applyVisualMainOverride=true] When true, saved visual HTML replaces generated main markup (publish path). * @param {string} [opts.publishFolderOverride] When set, must be the slug folder or `{slug}_draft` (validated). * @returns {Promise<{ html: string, publishFolder: string|null, folderDir: string }>} */ async function buildMicrositeDocumentHtml(event, opts = {}) { const previewOnly = !!opts.previewOnly; const applyVisualMainOverride = opts.applyVisualMainOverride !== false; const website = opts.website != null ? opts.website : event.website || {}; const sourcePageId = opts.sourcePageId; const layoutKey = normalizeLayoutKey(website); const folderDir = LAYOUT_TO_FOLDER[layoutKey]; if (!folderDir) { throw new Error('Invalid layoutTemplate'); } const shellPath = path.join(REPO_ROOT, 'microsites', folderDir, 'shell.html'); let shell; try { shell = await fs.readFile(shellPath, 'utf8'); } catch (e) { throw new Error(`Microsite shell not found: ${shellPath}`); } const page = await resolvePublishPage(event, website, sourcePageId); if (!page.sections || page.sections.length === 0) { throw new Error( 'No sections to publish. Add sections on the Website Builder Home page, or select sections under Microsite settings.', ); } const { agendaItems, speakers } = await loadAgendaAndSpeakers(event._id, { speakerVisibility: 'public', }); let registrationUrl = typeof opts.registrationUrl === 'string' && opts.registrationUrl.trim() ? opts.registrationUrl.trim() : registrationHref(event, website); if (!registrationUrl) registrationUrl = ''; let sectionsHtml = buildSectionsHtml(page, event, { agendaItems, speakers, registrationUrl, layoutTemplate: layoutKey, }); if (applyVisualMainOverride && event._id) { let raw = website.micrositeHtml != null ? String(website.micrositeHtml).trim() : ''; if (!raw && sourcePageId) { const srcVisual = await EventPage.findById(sourcePageId).select('micrositeHtml').lean(); raw = srcVisual?.micrositeHtml != null ? String(srcVisual.micrositeHtml).trim() : ''; } if (!raw) { const homeVisual = await EventPage.findOne({ event: event._id, type: 'home' }).select('micrositeHtml').lean(); raw = homeVisual?.micrositeHtml != null ? String(homeVisual.micrositeHtml).trim() : ''; } if (raw) sectionsHtml = raw; } const htmlTitle = (website.title && String(website.title).trim()) || page.title || event.name || 'Event'; const pageTitle = escapeHtml(htmlTitle); const metaRaw = (website.seoDescription && String(website.seoDescription).trim()) || (website.description && String(website.description).trim()) || (event.description && String(event.description).trim()) || ''; const metaDesc = escapeHtml(metaRaw.slice(0, 320)); const headInject = `${pageTitle}\n `; const apiBaseForMicrosite = process.env.MICROSITE_API_BASE_URL || ''; let html = shell .replace('', escapeHtml(apiBaseForMicrosite)) .replace('', headInject) .replace('', sectionsHtml) .replace('', buildCountdownInlineScript(event)); if (folderDir === 'conference') { const fontLink = buildConferenceFontLink(website); const themeTag = buildConferenceThemeStyleTag(website); html = html .replace('', fontLink) .replace('', themeTag); } const folder = (website.folder || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, ''); if (!previewOnly && !folder) { throw new Error('Website folder (slug) is required'); } const draftName = `${folder}_draft`; const allowedFolders = new Set([folder, draftName]); let publishFolder; if (previewOnly) { publishFolder = null; } else if (opts.publishFolderOverride != null && String(opts.publishFolderOverride).trim()) { const ov = String(opts.publishFolderOverride).trim().toLowerCase().replace(/[^a-z0-9_-]/g, ''); if (!allowedFolders.has(ov)) { throw new Error('Invalid publish folder target'); } publishFolder = ov; } else { publishFolder = website.enabled ? folder : draftName; } return { html, publishFolder, folderDir }; } /** * @param {object} event Event mongoose doc (lean or hydrated) * @param {object} opts * @param {string} opts.staticRoot * @param {string} [opts.registrationUrl] * @param {string} [opts.publishFolderOverride] `folder` or `{folder}_draft` * @returns {Promise<{ diskPath: string, html: string, publishFolder: string }>} */ async function buildMicrositeHtml(event, opts) { const website = opts.website != null ? opts.website : event.website || {}; const folder = (website.folder || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, ''); if (!folder) { throw new Error('Website folder (slug) is required'); } const { html, publishFolder, folderDir } = await buildMicrositeDocumentHtml(event, { registrationUrl: opts.registrationUrl, previewOnly: false, applyVisualMainOverride: true, publishFolderOverride: opts.publishFolderOverride, website, sourcePageId: opts.sourcePageId, }); const outDir = path.join(opts.staticRoot, publishFolder); await fs.mkdir(outDir, { recursive: true }); if (folderDir === 'conference') { const layoutSrc = path.join(REPO_ROOT, 'microsites', 'conference', 'css', 'conference-layout.css'); await fs.copyFile(layoutSrc, path.join(outDir, 'conference-layout.css')); const staticCssSrc = path.join(REPO_ROOT, 'microsites', 'conference', 'static', 'conference-static.css'); await fs.copyFile(staticCssSrc, path.join(outDir, 'conference-static.css')); const contactJsSrc = path.join(REPO_ROOT, 'microsites', 'conference', 'static', 'eb-microsite-contact.js'); await fs.copyFile(contactJsSrc, path.join(outDir, 'eb-microsite-contact.js')); } const diskPath = path.join(outDir, 'index.html'); await fs.writeFile(diskPath, html, 'utf8'); return { diskPath, html, publishFolder }; } /** * CSS payload for the GrapesJS canvas so layouts match published conference microsites (theme vars + layout + static). * @param {object} website event.website * @returns {Promise<{ fontLinkTag: string, themeStyleTag: string, layoutCss: string, staticCss: string }>} */ /** * Map EventMicrosite document (lean) to the same shape as event.website for layout/publish. * @param {object} template * @returns {object} */ function templateToWebsiteBag(template) { if (!template || typeof template !== 'object') return {}; const t = template; return { enabled: t.enabled, folder: t.publishFolder != null ? String(t.publishFolder).trim().toLowerCase().replace(/[^a-z0-9-]/g, '') : '', logo: t.logo, banners: Array.isArray(t.banners) ? t.banners : [], favicon: t.favicon, title: t.title, description: t.description, keywords: Array.isArray(t.keywords) ? t.keywords : [], templateId: t.templateId, layoutTemplate: t.layoutTemplate || 'conference', micrositeSections: Array.isArray(t.micrositeSections) ? t.micrositeSections : [], primaryColor: t.primaryColor, accentColor: t.accentColor, fontPair: t.fontPair, registrationUrl: t.registrationUrl, registrationFormId: t.registrationFormId, seoDescription: t.seoDescription, micrositeHtml: t.micrositeHtml, }; } async function getConferenceCanvasStylePayload(website) { const fontLinkTag = buildConferenceFontLink(website || {}); const themeStyleTag = buildConferenceThemeStyleTag(website || {}); const layoutPath = path.join(REPO_ROOT, 'microsites', 'conference', 'css', 'conference-layout.css'); const staticPath = path.join(REPO_ROOT, 'microsites', 'conference', 'static', 'conference-static.css'); const [layoutCss, staticCss] = await Promise.all([ fs.readFile(layoutPath, 'utf8'), fs.readFile(staticPath, 'utf8'), ]); return { fontLinkTag, themeStyleTag, layoutCss, staticCss }; } module.exports = { buildMicrositeHtml, buildMicrositeDocumentHtml, getConferenceCanvasStylePayload, templateToWebsiteBag, buildSyntheticPage, defaultConfigFor, resolvePublishPage, LAYOUT_TO_FOLDER, registrationHref, registrationUrlForForm, publicFormUrl, eventBuzzPublicBase, };