EventBuzz_Backend/src/utils/micrositeBuild.js

628 lines
24 KiB
JavaScript

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 `<script>
(function(){
var phraseEl=document.getElementById("cd-phrase");
if(!phraseEl)return;
var target=${startExpr};
var eventName=${nameJson};
function toGo(n,unit){return n===1?"1 "+unit+" to go":n+" "+unit+"s to go";}
function tick(){
if(target==null||!Number.isFinite(target)){phraseEl.textContent="";return;}
var t=target-Date.now();
if(t<=0){phraseEl.textContent="We're live — welcome to "+eventName+"!";return;}
var sec=Math.floor(t/1e3),days=Math.floor(sec/86400);sec%=86400;
var hours=Math.floor(sec/3600);sec%=3600;var mins=Math.floor(sec/60);
if(days>=1)phraseEl.textContent=toGo(days,"day");
else if(hours>=1)phraseEl.textContent=toGo(hours,"hour");
else if(mins>=1)phraseEl.textContent=toGo(mins,"minute");
else phraseEl.textContent="Starting soon";
}
tick();setInterval(tick,1e3);
})();
</script>`;
}
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
? `<p class="lead mb-0">${escapeHtml(desc)}</p>`
: `<p class="text-muted mb-0">Event details coming soon.</p>`,
};
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 = `<title>${pageTitle}</title>\n <meta name="description" content="${metaDesc}">`;
const apiBaseForMicrosite = process.env.MICROSITE_API_BASE_URL || '';
let html = shell
.replace('<!-- EB_INJECT_API_BASE -->', escapeHtml(apiBaseForMicrosite))
.replace('<!-- EB_INJECT_HEAD -->', headInject)
.replace('<!-- EB_INJECT_MAIN -->', sectionsHtml)
.replace('<!-- EB_INJECT_COUNTDOWN -->', buildCountdownInlineScript(event));
if (folderDir === 'conference') {
const fontLink = buildConferenceFontLink(website);
const themeTag = buildConferenceThemeStyleTag(website);
html = html
.replace('<!-- EB_INJECT_FONTS -->', fontLink)
.replace('<!-- EB_INJECT_THEME -->', 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,
};