import { gancioClient } from './gancio.client'; import { siteSettingsService } from '../modules/settings/settings.service'; import { env } from '../config/env'; import { logger } from '../utils/logger'; // CML settings fields that trigger a Gancio sync when changed const GANCIO_RELEVANT_FIELDS = [ 'organizationName', 'organizationShortName', 'publicColorPrimary', 'publicColorBgBase', 'publicColorBgContainer', 'publicHeaderGradient', 'footerText', 'enableEvents', // Feature flags that affect the injected nav bar 'enableInfluence', 'enableMap', 'enableMediaFeatures', 'enablePayments', 'navConfig', ] as const; interface GancioColorPalette { primary?: string; error?: string; info?: string; success?: string; warning?: string; } /** * Generates custom CSS for Gancio to match CML's dark theme. * Targets Vuetify component classes used in Gancio's UI. */ function buildCustomCss(settings: { publicColorBgBase?: string | null; publicColorBgContainer?: string | null; publicColorPrimary?: string | null; publicHeaderGradient?: string | null; }): string { const lines: string[] = ['/* Auto-synced from Changemaker Lite */']; if (settings.publicColorBgBase) { lines.push(`.v-application { background-color: ${settings.publicColorBgBase} !important; }`); lines.push(`.v-main { background-color: ${settings.publicColorBgBase} !important; }`); } if (settings.publicColorBgContainer) { lines.push(`.v-card { background-color: ${settings.publicColorBgContainer} !important; }`); lines.push(`.v-dialog .v-card { background-color: ${settings.publicColorBgContainer} !important; }`); } if (settings.publicHeaderGradient) { lines.push(`.v-app-bar { background: ${settings.publicHeaderGradient} !important; }`); } else if (settings.publicColorPrimary) { lines.push(`.v-app-bar { background-color: ${settings.publicColorPrimary} !important; }`); } // Push Gancio content down to make room for the injected CML nav bar (CSS fallback) lines.push(`.v-application--wrap, .v-application .v-main { padding-top: 56px !important; }`); return lines.join('\n'); } /** * Compact inline SVG icons matching Ant Design's outlined style. * Stroke-based, 24x24 viewBox, rendered at 1em (14px in the nav context). * Using fill="none" stroke="currentColor" so they inherit link color + hover transitions. */ const NAV_ICONS: Record = { Home: '', Campaigns: '', Map: '', Shifts: '', Events: '', Gallery: '', Donate: '', Admin: '', Website: '', Docs: '', }; /** * Generates a self-contained JS snippet that injects a CML navigation bar * into Gancio's page. Uses Gancio's `custom_js` setting. * * The bar mirrors PublicLayout's header: same gradient, colors, nav links, * inline SVG icons, and feature-flag-aware conditional rendering. */ interface NavConfigItem { id: string; label: string; path: string; icon: string; enabled: boolean; order: number; type: 'builtin' | 'custom' | 'group'; featureFlag?: string; external?: boolean; children?: NavConfigItem[]; } /** Map navConfig icon IDs to the SVG NAV_ICONS keys */ const ICON_ID_TO_KEY: Record = { HomeOutlined: 'Home', SendOutlined: 'Campaigns', EnvironmentOutlined: 'Map', CalendarOutlined: 'Shifts', ScheduleOutlined: 'Shifts', BarChartOutlined: 'Events', PlayCircleOutlined: 'Gallery', HeartOutlined: 'Donate', DollarOutlined: 'Donate', ShoppingOutlined: 'Donate', GlobalOutlined: 'Website', BookOutlined: 'Docs', TagOutlined: 'Events', VideoCameraOutlined: 'Events', FileTextOutlined: 'Docs', TrophyOutlined: 'Home', }; function buildCustomJs(settings: { organizationName?: string | null; publicHeaderGradient?: string | null; publicColorPrimary?: string | null; enableInfluence?: boolean | null; enableMap?: boolean | null; enableMediaFeatures?: boolean | null; enablePayments?: boolean | null; enableEvents?: boolean | null; enableMeetingPlanner?: boolean | null; enableTicketedEvents?: boolean | null; enableSocial?: boolean | null; enableMeet?: boolean | null; enableLandingPages?: boolean | null; navConfig?: { items: NavConfigItem[] } | null; }, appUrl: string, homeUrl: string): string { const orgName = settings.organizationName || 'Changemaker Lite'; const gradient = settings.publicHeaderGradient || 'linear-gradient(135deg, #005a9c 0%, #007acc 100%)'; // Strip trailing slash from URLs for consistent link construction const baseUrl = appUrl.replace(/\/+$/, ''); const home = homeUrl.replace(/\/+$/, ''); // Build nav links array from navConfig if available, else fall back to feature flags const links: Array<{ href: string; label: string; icon: string; active?: boolean }> = []; if (settings.navConfig?.items) { const featureFlagMap: Record = { enableInfluence: settings.enableInfluence, enableMap: settings.enableMap, enableMediaFeatures: settings.enableMediaFeatures, enablePayments: settings.enablePayments, enableEvents: settings.enableEvents, enableMeetingPlanner: settings.enableMeetingPlanner, enableTicketedEvents: settings.enableTicketedEvents, enableSocial: settings.enableSocial, enableMeet: settings.enableMeet, enableLandingPages: settings.enableLandingPages, }; // Flatten groups: Gancio uses a flat HTML nav bar, no dropdown support const flatItems: NavConfigItem[] = []; for (const item of settings.navConfig.items) { if (item.type === 'group' && item.children) { for (const child of item.children) flatItems.push(child); } else { flatItems.push(item); } } const sorted = flatItems .filter(item => item.enabled) .filter(item => !item.featureFlag || featureFlagMap[item.featureFlag] !== false) .sort((a, b) => a.order - b.order); for (const item of sorted) { // Resolve $token paths first if (item.path === '$landing') { links.push({ href: home, label: item.label, icon: ICON_ID_TO_KEY[item.icon] || 'Website' }); } else if (item.path === '$docs') { links.push({ href: `${home}/docs/`, label: item.label, icon: ICON_ID_TO_KEY[item.icon] || 'Docs' }); } else if (item.id === 'events') { // Events is the current page on Gancio, mark active links.push({ href: '#', label: item.label, icon: ICON_ID_TO_KEY[item.icon] || 'Events', active: true }); } else if (item.external && item.id === 'home') { links.push({ href: home, label: item.label, icon: ICON_ID_TO_KEY[item.icon] || 'Home' }); } else if (item.external) { links.push({ href: item.path, label: item.label, icon: ICON_ID_TO_KEY[item.icon] || item.label }); } else { links.push({ href: `${baseUrl}${item.path}`, label: item.label, icon: ICON_ID_TO_KEY[item.icon] || item.label }); } } } else { // Legacy fallback: build from feature flags links.push({ href: home, label: 'Home', icon: 'Home' }); if (settings.enableInfluence !== false) { links.push({ href: `${baseUrl}/campaigns`, label: 'Campaigns', icon: 'Campaigns' }); } if (settings.enableMap !== false) { links.push({ href: `${baseUrl}/map`, label: 'Map', icon: 'Map' }); links.push({ href: `${baseUrl}/shifts`, label: 'Shifts', icon: 'Shifts' }); } links.push({ href: '#', label: 'Events', icon: 'Events', active: true }); if (settings.enableMediaFeatures !== false) { links.push({ href: `${baseUrl}/gallery`, label: 'Gallery', icon: 'Gallery' }); } if (settings.enablePayments === true) { links.push({ href: `${baseUrl}/donate`, label: 'Donate', icon: 'Donate' }); } } // Always add Admin link — the admin page handles its own auth guard links.push({ href: `${baseUrl}/app`, label: 'Admin', icon: 'Admin' }); // Serialize links for embedding in JS (icon key is a string, resolved at runtime) const linksJson = JSON.stringify(links); // Build the icons object for the generated JS (keyed by label) const iconsUsed = [...new Set(links.map((l) => l.icon))]; const iconsObj = iconsUsed .map((key) => `${key}:'${NAV_ICONS[key]?.replace(/'/g, "\\'") ?? ''}'`) .join(','); // Safe-escaped org name for embedding in JS string literals const orgNameEscaped = orgName.replace(/'/g, "\\'").replace(//g, '>'); return `(function(){ if(document.getElementById('cml-nav-bar'))return; var icons={${iconsObj}}; var bar=document.createElement('div'); bar.id='cml-nav-bar'; bar.style.cssText='position:fixed;top:0;left:0;right:0;z-index:9999;height:56px;display:flex;align-items:center;justify-content:space-between;padding:0 24px;background:${gradient};font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;box-sizing:border-box;'; var brand=document.createElement('a'); brand.href='${baseUrl}/campaigns'; brand.style.cssText='color:#fff;text-decoration:none;font-size:18px;font-weight:600;display:flex;align-items:center;gap:8px;white-space:nowrap;'; brand.textContent='${orgNameEscaped}'; bar.appendChild(brand); var nav=document.createElement('div'); nav.className='cml-nav-links'; nav.style.cssText='display:flex;align-items:center;gap:16px;'; var links=${linksJson}; links.forEach(function(l){ var a=document.createElement('a'); a.href=l.href; a.style.cssText='color:'+(l.active?'#fff':'rgba(255,255,255,0.85)')+';text-decoration:none;font-size:14px;font-weight:'+(l.active?'600':'400')+';border-bottom:'+(l.active?'2px solid #fff':'2px solid transparent')+';padding-bottom:2px;transition:color 0.2s;white-space:nowrap;display:inline-flex;align-items:center;gap:6px;'; a.innerHTML=(icons[l.icon]||'')+''+l.label+''; if(!l.active){ a.addEventListener('mouseenter',function(){this.style.color='#fff';}); a.addEventListener('mouseleave',function(){this.style.color='rgba(255,255,255,0.85)';}); } nav.appendChild(a); }); bar.appendChild(nav); var mobileBack=document.createElement('a'); mobileBack.href='${baseUrl}/campaigns'; mobileBack.className='cml-nav-mobile-back'; mobileBack.style.cssText='display:none;color:#fff;text-decoration:none;font-size:14px;white-space:nowrap;'; mobileBack.textContent='\\u2190 Back to ${orgNameEscaped}'; bar.appendChild(mobileBack); var style=document.createElement('style'); style.textContent='.v-application--wrap,.v-application .v-main{padding-top:56px!important;}@media(max-width:768px){.cml-nav-links{display:none!important;}.cml-nav-mobile-back{display:block!important;}}'; document.head.appendChild(style); document.body.insertBefore(bar,document.body.firstChild); })();`; } class GancioSettingsSyncService { private syncInProgress = false; /** * Quick check: does the update payload contain any Gancio-relevant fields? */ hasGancioChanges(payload: Record): boolean { return GANCIO_RELEVANT_FIELDS.some((f) => f in payload); } /** * Full sync: reads CML settings, reads existing Gancio settings (to preserve * non-primary colors), then pushes all mapped settings to Gancio. * Used at API startup. */ async syncAll(): Promise { if (!gancioClient.enabled) return; if (this.syncInProgress) { logger.debug('Gancio settings sync already in progress, skipping'); return; } this.syncInProgress = true; try { const settings = await siteSettingsService.get(); // Guard: check DB-level feature flag if (!settings.enableEvents) { logger.debug('Gancio sync skipped: enableEvents is false'); return; } // Read existing Gancio settings to preserve non-primary color values const gancioSettings = await gancioClient.getSettings(); const promises: Promise[] = []; // Title & description if (settings.organizationName) { promises.push(gancioClient.setSetting('title', settings.organizationName)); promises.push(gancioClient.setSetting('description', `${settings.organizationName} Events`)); } // Dark theme promises.push(gancioClient.setSetting('theme.is_dark', true)); // Color palettes — read-then-merge to preserve error/info/success/warning if (settings.publicColorPrimary) { const existingDark = (gancioSettings?.dark_colors as GancioColorPalette) || {}; const existingLight = (gancioSettings?.light_colors as GancioColorPalette) || {}; promises.push(gancioClient.setSetting('dark_colors', { ...existingDark, primary: settings.publicColorPrimary, })); promises.push(gancioClient.setSetting('light_colors', { ...existingLight, primary: settings.publicColorPrimary, })); } // Footer links if (settings.footerText) { const adminUrl = env.ADMIN_URL; promises.push(gancioClient.setSetting('footerLinks', [ { label: settings.footerText, href: adminUrl }, ])); } // Custom CSS for deeper theming const css = buildCustomCss(settings); promises.push(gancioClient.setSetting('custom_css', css)); // Custom JS — inject CML navigation bar into Gancio const appUrl = env.ADMIN_URL; const homeUrl = `https://${env.DOMAIN}`; const js = buildCustomJs(settings as any, appUrl, homeUrl); promises.push(gancioClient.setSetting('custom_js', js)); const results = await Promise.allSettled(promises); const succeeded = results.filter((r) => r.status === 'fulfilled' && r.value).length; const failed = results.length - succeeded; if (failed > 0) { logger.warn(`Gancio settings sync: ${succeeded} succeeded, ${failed} failed`); } else { logger.info(`Gancio settings sync: synced ${succeeded} settings`); } } catch (err) { logger.warn('Gancio settings sync failed:', err instanceof Error ? err.message : err); } finally { this.syncInProgress = false; } } /** * Partial sync: only pushes Gancio settings for fields that actually changed. * Used from the PUT /api/settings handler. */ async syncChanged(changedFields: Record): Promise { if (!gancioClient.enabled) return; try { // If enableEvents was just turned off, skip sync if (changedFields.enableEvents === false) { logger.debug('Gancio sync skipped: enableEvents set to false'); return; } // If enableEvents was just turned on, do a full sync if (changedFields.enableEvents === true) { await this.syncAll(); return; } // Check DB-level feature flag (may not be in changedFields) const settings = await siteSettingsService.get(); if (!settings.enableEvents) { logger.debug('Gancio sync skipped: enableEvents is false'); return; } const promises: Promise[] = []; // Organization name → title + description if ('organizationName' in changedFields && changedFields.organizationName) { const name = changedFields.organizationName as string; promises.push(gancioClient.setSetting('title', name)); promises.push(gancioClient.setSetting('description', `${name} Events`)); } // Primary color → dark_colors + light_colors (read-then-merge) if ('publicColorPrimary' in changedFields && changedFields.publicColorPrimary) { const gancioSettings = await gancioClient.getSettings(); const existingDark = (gancioSettings?.dark_colors as GancioColorPalette) || {}; const existingLight = (gancioSettings?.light_colors as GancioColorPalette) || {}; promises.push(gancioClient.setSetting('dark_colors', { ...existingDark, primary: changedFields.publicColorPrimary, })); promises.push(gancioClient.setSetting('light_colors', { ...existingLight, primary: changedFields.publicColorPrimary, })); } // Footer text → footerLinks if ('footerText' in changedFields) { const adminUrl = env.ADMIN_URL; if (changedFields.footerText) { promises.push(gancioClient.setSetting('footerLinks', [ { label: changedFields.footerText as string, href: adminUrl }, ])); } else { promises.push(gancioClient.setSetting('footerLinks', [])); } } // Theme-related CSS fields — rebuild CSS from full settings (not just changed fields) const cssFields = ['publicColorBgBase', 'publicColorBgContainer', 'publicHeaderGradient', 'publicColorPrimary']; if (cssFields.some((f) => f in changedFields)) { const css = buildCustomCss(settings); promises.push(gancioClient.setSetting('custom_css', css)); } // Nav bar JS — rebuild when org name, feature flags, header gradient, or navConfig change const jsFields = [ 'organizationName', 'publicHeaderGradient', 'publicColorPrimary', 'enableInfluence', 'enableMap', 'enableMediaFeatures', 'enablePayments', 'navConfig', ]; if (jsFields.some((f) => f in changedFields)) { const appUrl = env.ADMIN_URL; const homeUrl = `https://${env.DOMAIN}`; const js = buildCustomJs(settings as any, appUrl, homeUrl); promises.push(gancioClient.setSetting('custom_js', js)); } if (promises.length === 0) return; const results = await Promise.allSettled(promises); const succeeded = results.filter((r) => r.status === 'fulfilled' && r.value).length; const failed = results.length - succeeded; if (failed > 0) { logger.warn(`Gancio partial sync: ${succeeded}/${results.length} succeeded`); } else { logger.debug(`Gancio partial sync: ${succeeded} settings updated`); } } catch (err) { logger.warn('Gancio partial sync failed:', err instanceof Error ? err.message : err); } } } export const gancioSettingsSyncService = new GancioSettingsSyncService();