Add engagement APIs, attendance WhatsApp notify, and Dehradun agenda import.

Keep microsite v2 templates in sync and replace printed-programme import so event schedule and dashboard agenda stay aligned.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ravi Gulhane 2026-09-30 09:39:46 +05:30
parent b9d0c32a6b
commit f8155e1127
63 changed files with 6550 additions and 2673 deletions

View File

@ -2196,7 +2196,7 @@ h1 {
background: #f3f4f3;
}
#contact-map {
#contact {
background: #f3f4f3;
}
@ -2360,6 +2360,38 @@ h1 {
.contact {
background: #111111;
}
.contact-info {
padding-top: 10px;
}
.contact-info ul {
margin: 0;
padding: 0;
}
.contact-info ul li {
font-size: 16px;
line-height: 1.6;
}
.contact-info ul li strong {
margin-right: 5px;
}
.contact-address {
font-size: 16px;
line-height: 1.7;
}
.contact-address p {
margin-bottom: 0;
}
@media (max-width: 767px) {
.contact-info {
margin-bottom: 35px;
}
}
#conatiner-map {
width: 100%;
@ -3541,7 +3573,23 @@ body.eventup-marketing-popup-open {
border-radius: 12px;
background: #000;
}
@media (min-width: 992px) {
#navbarCollapse {
position: relative;
}
.navbar-center-links {
position: absolute;
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
}
#navbarCollapse > .ml-auto {
margin-left: auto !important;
}
}
@media (max-width: 767px) {
.eventup-marketing-popup__close {
top: 8px;

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 560 KiB

View File

@ -1,10 +1,10 @@
@font-face {
font-family: 'LineIcons';
src: url('LineIcons.eot');
src: url('LineIcons.eot?#iefix') format('embedded-opentype'),
url('LineIcons.woff') format('woff'),
url('LineIcons.ttf') format('truetype'),
url('LineIcons.svg#LineIcons') format('svg');
src: url('LineIcons.eot%3Fy2l643');
src: url('LineIcons.eot%3Fy2l643') format('embedded-opentype'),
url('LineIcons.ttf%3Fy2l643') format('truetype'),
url('LineIcons.woff%3Fy2l643') format('woff'),
url('LineIcons.svg%3Fy2l643') format('svg');
font-weight: normal;
font-style: normal;
}

View File

@ -11,60 +11,97 @@
"accentHoverEnd": "#4DB676",
"headingColor": "#015AAA"
},
"sections": {
"marketing_popup": {
"show": true,
"heading": "Welcome to EventUp 2026",
"logo": "img/logo.png",
"media": {
"type": "image",
"image": "img/event-banner.png"
},
"autoCloseDuration": "5000"
"sections": {
"marketing_popup": {
"show": true,
"heading": "Welcome to EventUp 2026",
"logo": "img/logo.png",
"media": {
"type": "image",
"image": "img/event-banner.png"
},
"logo": {
"show": true,
"header": {
"url": "img/logo.png",
"alt": "EventUp",
"maxHeight": "56"
"autoCloseDuration": "5000"
},
"navbar": {
"show": true,
"links": [
{
"text": "Home",
"url": "#header-wrap"
},
"hero": {
"url": "img/novel-logo.png",
"alt": "EventUp"
{
"text": "About",
"url": "#about"
},
"footer": {
"url": "img/logo.png",
"alt": "EventUp"
{
"text": "Speakers",
"url": "#team"
},
{
"text": "Schedule",
"url": "#schedules"
},
{
"text": "Sponsors",
"url": "#sponsors"
},
{
"text": "Contact",
"url": "#contact"
}
},
]
},
"logo": {
"show": true,
"header": {
"url": "img/logo.png",
"alt": "EventUp",
"maxHeight": "56"
},
"hero": {
"url": "img/novel-logo.png",
"alt": "EventUp"
},
"footer": {
"url": "img/logo.png",
"alt": "EventUp"
},
"registerButton": {
"show": true,
"text": "Register",
"url": "#"
}
},
"hero": {
"show": true,
"event_name": "EventUp Conference 2026",
"date_venue": "August 15–17, 2026 · Mumbai, India",
"image": {
"url": "img/hero-area.jpg"
},
"cta": {
"text": "Register Now",
"url": "#contact",
"target": "_self"
},
"countdown": {
"show": true,
"date": "2026-08-15",
"time": "09:00"
}
},
"show": true,
"event_name": "EventUp Conference 2026",
"date_venue": "August 15–17, 2026 · Mumbai, India",
"image": {
"url": "img/hero-area.jpg"
},
"cta": {
"show": true,
"text": "Register Now",
"url": "#contact",
"target": "_self"
},
"countdown": {
"show": true,
"date": "2026-08-15",
"time": "09:00"
}
},
"about": {
"show": true,
"heading": "About the Event",
"content": "<p>Join industry leaders, innovators, and practitioners for three days of keynotes, workshops, and networking. EventUp brings together the brightest minds to share insights on technology, healthcare, and business transformation.</p><p>Whether you're a first-time attendee or a returning delegate, you'll leave with actionable strategies and lasting connections.</p>",
"media": {
"type": "image",
"image": "img/about/img1.png"
}
},
"show": true,
"heading": "About the Event",
"content": "<p>Join industry leaders, innovators, and practitioners for three days of keynotes, workshops, and networking. EventUp brings together the brightest minds to share insights on technology, healthcare, and business transformation.</p><p>Whether you're a first-time attendee or a returning delegate, you'll leave with actionable strategies and lasting connections.</p>",
"media": {
"type": "image",
"image": "img/about/img1.png"
}
},
"video": {
"show": false,
"heading": "Hear it from experts",
@ -110,14 +147,30 @@
]
},
"event_stats": {
"show": true,
"items": [
{ "icon": "lni-mic", "value": "80", "label": "Sessions" },
{ "icon": "lni-users", "value": "120", "label": "Speakers" },
{ "icon": "lni-heart", "value": "2500", "label": "Attendees" },
{ "icon": "lni-handshake", "value": "45", "label": "Sponsors" }
]
},
"show": true,
"items": [
{
"icon": "lni-mic",
"value": "80",
"label": "Sessions"
},
{
"icon": "lni-users",
"value": "120",
"label": "Speakers"
},
{
"icon": "lni-heart",
"value": "2500",
"label": "Attendees"
},
{
"icon": "lni-handshake",
"value": "45",
"label": "Sponsors"
}
]
},
"speakers": {
"show": true,
"heading": "Our Speakers",
@ -144,132 +197,167 @@
]
},
"event_schedule": {
"show": true,
"heading": "Event Schedule",
"subtitle": "Plan your conference experience across three exciting days.",
"days": [
{
"label": "Day 01",
"date": "Friday, Aug 15, 2026",
"sessions": [
{ "time": "9:00 AM – 10:00 AM", "type": "Keynote", "title": "Opening Keynote: The Future of Innovation" },
{ "time": "10:30 AM – 12:00 PM", "type": "Workshop", "title": "Building Scalable Cloud Architectures" },
{ "time": "1:00 PM – 2:30 PM", "type": "Panel", "title": "Healthcare Transformation in the Digital Age" },
{ "time": "3:00 PM – 4:00 PM", "type": "Breakout", "title": "AI in Clinical Decision Support" }
]
},
{
"label": "Day 02",
"date": "Saturday, Aug 16, 2026",
"sessions": [
{ "time": "9:00 AM – 10:30 AM", "type": "Keynote", "title": "Sustainability and Business Growth" },
{ "time": "11:00 AM – 12:30 PM", "type": "Workshop", "title": "Data Privacy and Compliance" },
{ "time": "2:00 PM – 3:30 PM", "type": "Networking", "title": "Sponsor Meet & Greet" }
]
}
]
},
"show": true,
"heading": "Event Schedule",
"subtitle": "Plan your conference experience across three exciting days.",
"days": [
{
"label": "Day 01",
"date": "Friday, Aug 15, 2026",
"sessions": [
{
"time": "9:00 AM – 10:00 AM",
"type": "Keynote",
"title": "Opening Keynote: The Future of Innovation"
},
{
"time": "10:30 AM – 12:00 PM",
"type": "Workshop",
"title": "Building Scalable Cloud Architectures"
},
{
"time": "1:00 PM – 2:30 PM",
"type": "Panel",
"title": "Healthcare Transformation in the Digital Age"
},
{
"time": "3:00 PM – 4:00 PM",
"type": "Breakout",
"title": "AI in Clinical Decision Support"
}
]
},
{
"label": "Day 02",
"date": "Saturday, Aug 16, 2026",
"sessions": [
{
"time": "9:00 AM – 10:30 AM",
"type": "Keynote",
"title": "Sustainability and Business Growth"
},
{
"time": "11:00 AM – 12:30 PM",
"type": "Workshop",
"title": "Data Privacy and Compliance"
},
{
"time": "2:00 PM – 3:30 PM",
"type": "Networking",
"title": "Sponsor Meet & Greet"
}
]
}
]
},
"pricing": {
"show": true,
"heading": "Ticket Pricing",
"subtitle": "Choose the ticket that best suits your needs and join us for an unforgettable event.",
"plans": [
{
"title": "Basic Pass",
"price": "$29",
"duration": "Per Day",
"featured": false,
"buttonText": "Buy Ticket",
"buttonLink": "#",
"features": [
{
"text": "Entrance",
"included": true
},
{
"text": "Coffee Break",
"included": true
},
{
"text": "Lunch on all days",
"included": true
},
{
"text": "Access to all areas",
"included": false
},
{
"text": "Certificate",
"included": false
}
]
},
{
"title": "Standard Pass",
"price": "$40",
"duration": "Per Day",
"featured": true,
"buttonText": "Buy Ticket",
"buttonLink": "#",
"features": [
{
"text": "Entrance",
"included": true
},
{
"text": "Coffee Break",
"included": true
},
{
"text": "Lunch on all days",
"included": true
},
{
"text": "Access to All Sessions",
"included": true
},
{
"text": "Certificate",
"included": true
}
]
},
{
"title": "Premium Pass",
"price": "$68",
"duration": "Per Day",
"featured": false,
"buttonText": "Buy Ticket",
"buttonLink": "#",
"features": [
{
"text": "Entrance",
"included": true
},
{
"text": "Coffee Break",
"included": true
},
{
"text": "Lunch on all days",
"included": true
},
{
"text": "Access to VIP Lounge",
"included": true
},
{
"text": "Certificate",
"included": true
}
]
}
]
},
"show": true,
"heading": "Ticket Pricing",
"subtitle": "Choose the ticket that best suits your needs and join us for an unforgettable event.",
"plans": [
{
"title": "Basic Pass",
"price": "29",
"currency": "$",
"duration": "Per Day",
"featured": false,
"showButton": true,
"buttonText": "Buy Ticket",
"buttonLink": "#",
"features": [
{
"text": "Entrance",
"included": true
},
{
"text": "Coffee Break",
"included": true
},
{
"text": "Lunch on all days",
"included": true
},
{
"text": "Access to all areas",
"included": false
},
{
"text": "Certificate",
"included": false
}
]
},
{
"title": "Standard Pass",
"price": "40",
"currency": "$",
"duration": "Per Day",
"featured": true,
"showButton": true,
"buttonText": "Buy Ticket",
"buttonLink": "#",
"features": [
{
"text": "Entrance",
"included": true
},
{
"text": "Coffee Break",
"included": true
},
{
"text": "Lunch on all days",
"included": true
},
{
"text": "Access to All Sessions",
"included": true
},
{
"text": "Certificate",
"included": true
}
]
},
{
"title": "Premium Pass",
"price": "68",
"currency": "$",
"duration": "Per Day",
"featured": false,
"showButton": true,
"buttonText": "Buy Ticket",
"buttonLink": "#",
"features": [
{
"text": "Entrance",
"included": true
},
{
"text": "Coffee Break",
"included": true
},
{
"text": "Lunch on all days",
"included": true
},
{
"text": "Access to VIP Lounge",
"included": true
},
{
"text": "Certificate",
"included": true
}
]
}
]
},
"latest_news": {
"show": true,
"heading": "Latest News",
"subtitle": "Stay updated with the latest announcements, event news, and important updates.",
"showButton": true,
"buttonText": "View All Blog",
"buttonLink": "#",
"news": [
@ -303,50 +391,72 @@
]
},
"sponsors": {
"show": true,
"heading": "Our Sponsors",
"subtitle": "Thank you to our partners who make EventUp possible.",
"hideHeading": false,
"hideGroupTitles": false,
"groups": [
{
"title": "Platinum Sponsors",
"tier": "platinum",
"items": [
{ "image": "img/sponsors/pfizer.png", "alt": "Pfizer" },
{ "image": "img/sponsors/cipla.png", "alt": "Cipla" }
]
},
{
"title": "Gold Sponsors",
"tier": "gold",
"items": [
{ "image": "img/sponsors/abott.png", "alt": "Abbott" },
{ "image": "img/sponsors/zuventus.png", "alt": "Zuventus" },
{ "image": "img/sponsors/ganshorn.png", "alt": "Ganshorn" }
]
}
],
"buttonText": "Partner With Us",
"buttonUrl": "#"
},
"show": true,
"heading": "Our Sponsors",
"subtitle": "Thank you to our partners who make EventUp possible.",
"hideHeading": false,
"hideGroupTitles": false,
"groups": [
{
"title": "Platinum Sponsors",
"tier": "platinum",
"items": [
{
"image": "img/sponsors/pfizer.png",
"alt": "Pfizer"
},
{
"image": "img/sponsors/cipla.png",
"alt": "Cipla"
}
]
},
{
"title": "Gold Sponsors",
"tier": "gold",
"items": [
{
"image": "img/sponsors/abott.png",
"alt": "Abbott"
},
{
"image": "img/sponsors/zuventus.png",
"alt": "Zuventus"
},
{
"image": "img/sponsors/ganshorn.png",
"alt": "Ganshorn"
}
]
}
],
"showButton": true,
"buttonText": "Partner With Us",
"buttonUrl": "#"
},
"map": {
"show": false,
"embedUrl": "https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3771.0!2d72.8!3d19.1!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x0%3A0x0!2zMTnCsDA2JzAwLjAiTiA3MsKwNDgnMDAuMCJF!5e0!3m2!1sen!2sin!4v1",
"height": "450"
},
"show": false,
"embedUrl": "https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3771.0!2d72.8!3d19.1!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x0%3A0x0!2zMTnCsDA2JzAwLjAiTiA3MsKwNDgnMDAuMCJF!5e0!3m2!1sen!2sin!4v1",
"height": "450"
},
"contact": {
"show": true,
"heading": "Contact Us",
"subtitle": "Have questions? Reach out to the EventUp team.",
"formId": "",
"apiBaseUrl": "https://api.eventbuzz.live/api",
"address": "Jio World Convention Centre\nBandra Kurla Complex\nMumbai, Maharashtra 400051",
"phones": [
{ "label": "Registration", "number": "+91 22 1234 5678", "tel": "+912212345678" },
{ "label": "Sponsorship", "number": "+91 22 8765 4321", "tel": "+912287654321" }
]
},
"show": true,
"heading": "Contact Us",
"subtitle": "Have questions? Reach out to the EventUp team.",
"formId": "",
"apiBaseUrl": "https://api.eventbuzz.live/api",
"address": "Jio World Convention Centre\nBandra Kurla Complex\nMumbai, Maharashtra 400051",
"phones": [
{
"label": "Registration",
"number": "+91 22 1234 5678"
},
{
"label": "Sponsorship",
"number": "+91 22 8765 4321"
}
]
},
"feedback": {
"show": false,
"label": "Feedback",
@ -354,23 +464,52 @@
"target": "_blank"
},
"footer": {
"show": true,
"tagline": "EventUp Conference — Where innovation meets inspiration.",
"linksHeading": "Quick Links",
"social": {
"heading": "Follow us",
"links": [
{ "url": "https://facebook.com", "icon": "lni-facebook-filled", "label": "Facebook" },
{ "url": "https://twitter.com", "icon": "lni-twitter-filled", "label": "Twitter" },
{ "url": "https://linkedin.com", "icon": "lni-linkedin-original", "label": "LinkedIn" }
]
"show": true,
"tagline": "EventUp Conference — Where innovation meets inspiration.",
"linksHeading": "Quick Links",
"social": {
"heading": "Follow us",
"links": [
{
"url": "https://facebook.com",
"icon": "lni-facebook-filled",
"label": "Facebook"
},
"links": [
{ "text": "About", "url": "#about", "target": "_self" },
{ "text": "Speakers", "url": "#speakers", "target": "_self" },
{ "text": "Schedule", "url": "#schedules", "target": "_self" },
{ "text": "Contact", "url": "#contact", "target": "_self" }
]
{
"url": "https://twitter.com",
"icon": "lni-twitter-filled",
"label": "Twitter"
},
{
"url": "https://linkedin.com",
"icon": "lni-linkedin-original",
"label": "LinkedIn"
}
]
},
"links": [
{
"text": "About",
"url": "#about",
"target": "_self"
},
{
"text": "Speakers",
"url": "#speakers",
"target": "_self"
},
{
"text": "Schedule",
"url": "#schedules",
"target": "_self"
},
{
"text": "Contact",
"url": "#contact",
"target": "_self"
}
}
]
}
}
}

View File

@ -36,37 +36,115 @@
</head>
<body>
{{#if_show sections.logo}}
<nav class="navbar navbar-expand-lg bg-inverse fixed-top scrolling-navbar">
<div class="container">
<a href="#header-wrap" class="navbar-brand">
{{#if sections.logo.header.url}}
<img src="{{resolveAsset assetsBaseUrl sections.logo.header.url}}" alt="{{sections.logo.header.alt}}" style="max-height:{{sections.logo.header.maxHeight}}px">
{{#if_show sections.logo}}
<nav class="navbar navbar-expand-lg bg-inverse fixed-top scrolling-navbar">
<div class="container">
<a href="#header-wrap" class="navbar-brand">
{{#if sections.logo.header.url}}
<img src="{{resolveAsset assetsBaseUrl sections.logo.header.url}}" alt="{{sections.logo.header.alt}}" style="max-height:{{sections.logo.header.maxHeight}}px">
{{/if}}
</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarCollapse" aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation">
<i class="lni-menu"></i>
</button>
<div class="collapse navbar-collapse position-relative" id="navbarCollapse">
<!-- CENTER: Dynamic Navbar Links -->
<ul class="navbar-nav navbar-center-links">
{{#if_show sections.navbar}}
{{#each sections.navbar.links}}
{{!-- Home --}}
{{#if (eq this.url "#")}}
<li class="nav-item">
<a class="nav-link" href="{{this.url}}">
{{this.text}}
</a>
</li>
{{!-- About --}}
{{else if (and (eq this.url "#about") @root.sections.about.show)}}
<li class="nav-item">
<a class="nav-link" href="{{this.url}}">
{{this.text}}
</a>
</li>
{{!-- Speakers --}}
{{else if (and (eq this.url "#team") @root.sections.speakers.show)}}
<li class="nav-item">
<a class="nav-link" href="{{this.url}}">
{{this.text}}
</a>
</li>
{{!-- Schedule --}}
{{else if (and (eq this.url "#schedule") @root.sections.schedule.show)}}
<li class="nav-item">
<a class="nav-link" href="{{this.url}}">
{{this.text}}
</a>
</li>
{{!-- News --}}
{{else if (and (eq this.url "#news") @root.sections.news.show)}}
<li class="nav-item">
<a class="nav-link" href="{{this.url}}">
{{this.text}}
</a>
</li>
{{!-- Pricing --}}
{{else if (and (eq this.url "#pricing") @root.sections.pricing.show)}}
<li class="nav-item">
<a class="nav-link" href="{{this.url}}">
{{this.text}}
</a>
</li>
{{!-- Sponsors --}}
{{else if (and (eq this.url "#sponsors") @root.sections.sponsors.show)}}
<li class="nav-item">
<a class="nav-link" href="{{this.url}}">
{{this.text}}
</a>
</li>
{{!-- Contact --}}
{{else if (and (eq this.url "#contact") @root.sections.contact.show)}}
<li class="nav-item">
<a class="nav-link" href="{{this.url}}">
{{this.text}}
</a>
</li>
{{/if}}
</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarCollapse" aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation">
<i class="lni-menu"></i>
</button>
<div class="collapse navbar-collapse" id="navbarCollapse">
<ul class="navbar-nav mr-auto w-100 justify-content-end">
<li class="nav-item active"><a class="nav-link" href="#header-wrap">Home</a></li>
{{#if_show sections.about}}<li class="nav-item"><a class="nav-link" href="#about">About</a></li>{{/if_show}}
{{#if_show sections.speakers}}<li class="nav-item"><a class="nav-link" href="#team">Speakers</a></li>{{/if_show}}
{{#if_show sections.event_schedule}}<li class="nav-item"><a class="nav-link" href="#schedules">Schedule</a></li>{{/if_show}}
{{#if_show sections.sponsors}}<li class="nav-item"><a class="nav-link" href="#sponsors">Sponsors</a></li>{{/if_show}}
{{#if_show sections.contact}}<li class="nav-item"><a class="nav-link" href="#contact-map">Contact</a></li>{{/if_show}}
{{#if sections.logo.registerButton.text}}
{{#if sections.logo.registerButton.text}}
<li class="nav-item nav-item--register">
<a class="btn btn-common"
href="{{sections.logo.registerButton.url}}"
target="_blank"
rel="noopener noreferrer">
{{sections.logo.registerButton.text}}
</a>
</li>
{{/if}}
{{/if}}
{{/each}}
{{/if_show}}
</ul>
<!--Right Reg Button-->
{{#if sections.logo.registerButton.show}}
<ul class="navbar-nav ml-auto">
<li class="nav-item nav-item--register">
<a
class="btn btn-common"
href="{{sections.logo.registerButton.url}}"
target="_blank"
rel="noopener noreferrer">
{{sections.logo.registerButton.text}}
</a>
</li>
</ul>
{{/if}}
</div>
</ul>
</div>
</div>
@ -74,6 +152,7 @@
<div class="navbar-spacer" aria-hidden="true"></div>
{{/if_show}}
<header id="header-wrap">
{{#if_show sections.hero}}
<div id="hero-area" class="hero-area-bg" data-site-component="hero">
@ -91,11 +170,21 @@
<div class="hero-split__text">
<h2 class="head-title hero-brand-title">{{sections.hero.event_name}}</h2>
<p class="banner-info">{{sections.hero.date_venue}}</p>
{{#if sections.hero.cta.text}}
<div class="banner-btn">
<a href="{{sections.hero.cta.url}}" class="btn btn-common" data-hero-cta-link{{#if (eq sections.hero.cta.target "_blank")}} target="_blank" rel="noopener noreferrer"{{/if}}>{{sections.hero.cta.text}}</a>
</div>
{{/if}}
{{#if sections.hero.cta.show}}
<div class="banner-btn">
<a
href="{{sections.hero.cta.url}}"
class="btn btn-common"
data-hero-cta-link
{{#if (eq sections.hero.cta.target "_blank")}}
target="_blank"
rel="noopener noreferrer"
{{/if}}
>
{{sections.hero.cta.text}}
</a>
</div>
{{/if}}
</div>
</div>
</div>
@ -104,6 +193,7 @@
{{/if_show}}
</header>
{{#if sections.hero.countdown.show}}
<section id="count" data-site-component="countdown">
<div class="container">
@ -120,6 +210,7 @@
</section>
{{/if}}
{{#if_show sections.about}}
<section id="about" class="section-padding" data-site-component="about">
<div class="container">
@ -292,6 +383,9 @@
</section>
{{/if_show}}
{{#if_show sections.event_schedule}}
<section id="schedules" class="schedule section-padding" data-site-component="event_schedule">
<div class="container">
@ -324,6 +418,9 @@
</div>
</section>
{{/if_show}}
{{#if_show sections.pricing.show}}
<section id="pricing" class="section-padding">
<div class="container">
@ -354,7 +451,9 @@
</div>
<div class="price">
<h2>{{price}}</h2>
<h2>
{{this.currency}}{{this.price}}
</h2>
<span>{{duration}}</span>
</div>
@ -376,9 +475,11 @@
</ul>
</div>
{{#if showButton}}
<a href="{{buttonLink}}" class="btn btn-common">
{{buttonText}}
</a>
{{/if}}
</div>
@ -390,7 +491,9 @@
</div>
</section>
{{/if_show}}
{{#if_show sections.about_crest}}
<!-- {{#if_show sections.about_crest}}
<section id="about-crest" class="section-padding" data-site-component="about_crest">
<div class="container">
<div class="row justify-content-center">
@ -405,6 +508,21 @@
</div>
</section>
{{/if_show}}
{
"id": "about_crest",
"label": "About CREST / secondary about",
"show": false,
"fields": [
{ "key": "heading", "type": "text", "label": "Heading", "default": "About" },
{ "key": "content", "type": "richtext", "label": "Content", "default": "" },
{ "key": "cta.text", "type": "text", "label": "CTA text", "default": "" },
{ "key": "cta.url", "type": "url", "label": "CTA URL", "default": "#" }
]
},
-->
{{#if_show sections.latest_news.show}}
@ -471,13 +589,16 @@
</div>
{{/each}}
{{#if sections.latest_news.buttonText}}
<div class="col-12 text-center">
<a href="{{sections.latest_news.buttonLink}}" class="btn btn-common">
{{sections.latest_news.buttonText}}
</a>
</div>
{{/if}}
{{#if sections.latest_news.showButton}}
<div class="col-12 text-center">
<a
href="{{sections.latest_news.buttonLink}}"
class="btn btn-common"
>
{{sections.latest_news.buttonText}}
</a>
</div>
{{/if}}
</div>
@ -507,190 +628,251 @@
</div>
</div>
{{/each}}
{{#if sections.sponsors.buttonText}}
<div class="text-center mt-5">
<a
href="{{sections.sponsors.buttonUrl}}"
class="btn btn-common"
>
{{sections.sponsors.buttonText}}
</a>
</div>
{{/if}}
{{#if sections.sponsors.showButton}}
<div class="text-center mt-5">
<a
href="{{sections.sponsors.buttonUrl}}"
class="btn btn-common"
>
{{sections.sponsors.buttonText}}
</a>
</div>
{{/if}}
</div>
</div>
</section>
{{/if_show}}
{{#if_show sections.contact.show}}
<section id="contact-map" class="section-padding" data-site-component="contact">
{{#if_show sections.contact.show}}
<section id="contact" class="section-padding" data-site-component="contact">
<div class="container">
<!-- Section Heading -->
<div class="section-title-header text-center">
<h2 class="section-title wow fadeInUp" data-wow-delay="0.2s">
<h2
class="section-title wow fadeInUp"
data-wow-delay="0.2s"
>
{{sections.contact.heading}}
</h2>
{{#if sections.contact.subtitle}}
<p class="wow fadeInDown" data-wow-delay="0.2s">
<p
class="wow fadeInDown"
data-wow-delay="0.2s"
>
{{sections.contact.subtitle}}
</p>
{{/if}}
</div>
<div class="row justify-content-center">
<div class="col-lg-8 col-xl-7">
<!-- Contact Content -->
<div class="row">
<form role="form" method="post" id="contactForm" name="contact-form" novalidate>
<!-- =========================
LEFT SIDE: CONTACT INFO
========================== -->
<div class="col-lg-4 col-md-5">
<div class="row">
<div class="contact-info">
<div class="col-md-6">
<div class="form-group">
<input
type="text"
class="form-control"
name="name"
placeholder="Name"
required>
</div>
</div>
<!-- Phone Numbers -->
{{#if sections.contact.phones}}
<div class="col-md-6">
<div class="form-group">
<input
type="email"
class="form-control"
name="email"
placeholder="Email"
required>
</div>
</div>
<ul class="list-unstyled">
<div class="col-md-12">
<div class="form-group">
<input
type="text"
class="form-control"
name="subject"
placeholder="Subject">
</div>
</div>
{{#each sections.contact.phones}}
<div class="col-md-12">
<div class="form-group">
<textarea
class="form-control"
rows="6"
name="message"
placeholder="Message"></textarea>
</div>
</div>
<li class="mb-3">
<div class="col-md-12">
<strong>
{{label}}:
</strong>
<div class="form-group contact-captcha-group">
<a href="tel:{{number}}">
{{number}}
</a>
<label
class="contact-captcha-label"
for="contactCaptcha">
Security check
</label>
</li>
<div class="contact-captcha-row">
{{/each}}
<span
class="contact-captcha-question"
data-contact-captcha-question
aria-live="polite">
Loading...
</span>
</ul>
{{/if}}
<!-- Address -->
{{#if sections.contact.address}}
<div class="contact-address mt-4">
<p>
{{sections.contact.address}}
</p>
</div>
{{/if}}
</div>
</div>
<!-- =========================
RIGHT SIDE: CONTACT FORM
========================== -->
<div class="col-lg-8 col-md-7">
<div class="contact-form">
<form
role="form"
method="post"
id="contactForm"
name="contact-form"
novalidate
>
<div class="row">
<!-- Name -->
<div class="col-md-6">
<div class="form-group">
<input
type="text"
class="form-control contact-captcha-input"
id="contactCaptcha"
data-contact-captcha-answer
inputmode="numeric"
autocomplete="off"
placeholder="Answer"
required>
<button
type="button"
class="btn btn-common contact-captcha-reload"
data-contact-captcha-reload
aria-label="Reload captcha">
↻
</button>
class="form-control"
name="name"
placeholder="Name"
required
>
</div>
</div>
</div>
<div class="eb-contact-honeypot" aria-hidden="true">
<input
type="text"
name="_gotcha"
tabindex="-1"
autocomplete="off">
</div>
<!-- Email -->
<div class="col-md-6">
<div class="form-group">
<div class="col-md-12">
<div class="form-submit">
<button
type="submit"
class="btn btn-common"
id="form-submit">
Send Message
</button>
<input
type="email"
class="form-control"
name="email"
placeholder="Email"
required
>
<div id="msgSubmit" class="h3 text-center hidden"></div>
</div>
</div>
<!-- Subject -->
<div class="col-md-12">
<div class="form-group">
<input
type="text"
class="form-control"
name="subject"
placeholder="Subject"
>
</div>
</div>
<!-- Message -->
<div class="col-md-12">
<div class="form-group">
<textarea
class="form-control"
rows="6"
name="message"
placeholder="Message"
></textarea>
</div>
</div>
<!-- Security Check -->
<div class="col-md-12">
<div class="form-group contact-captcha-group">
<label
class="contact-captcha-label"
for="contactCaptcha"
>
Security check
</label>
<div class="contact-captcha-row">
<span
class="contact-captcha-question"
data-contact-captcha-question
aria-live="polite"
>
Loading...
</span>
<input
type="text"
class="form-control contact-captcha-input"
id="contactCaptcha"
data-contact-captcha-answer
inputmode="numeric"
autocomplete="off"
placeholder="Answer"
required
>
<button
type="button"
class="btn btn-common contact-captcha-reload"
data-contact-captcha-reload
aria-label="Reload captcha"
>
↻
</button>
</div>
</div>
</div>
<!-- Honeypot -->
<div
class="eb-contact-honeypot"
aria-hidden="true"
>
<input
type="text"
name="_gotcha"
tabindex="-1"
autocomplete="off"
>
</div>
<!-- Submit Button -->
<div class="col-md-12">
<div class="form-submit">
<button
type="submit"
class="btn btn-common"
id="form-submit"
>
Send Message
</button>
<div
id="msgSubmit"
class="h3 text-center hidden"
></div>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
{{#if sections.contact.phones}}
<div class="row justify-content-center mt-5">
<div class="col-lg-8 text-center">
<ul class="list-unstyled">
{{#each sections.contact.phones}}
<li class="mb-2">
<strong>{{label}}:</strong>
<a href="tel:{{tel}}">{{number}}</a>
</li>
{{/each}}
</ul>
</form>
</div>
</div>
</div>
{{/if}}
{{#if sections.contact.address}}
<div class="row justify-content-center">
<div class="col-lg-8 text-center">
<p>{{sections.contact.address}}</p>
</div>
</div>
{{/if}}
</div>
</section>
{{/if_show}}
{{/if_show}}
{{#if_show sections.map}}
<section id="google-map-area" data-site-component="map">
<div class="container-fluid">
@ -713,6 +895,7 @@
{{/if}}
{{#if sections.footer.tagline}}<p class="footer-tagline">{{sections.footer.tagline}}</p>{{/if}}
</div>
<div class="col-lg-4 footer-col-links">
<h6 class="footer-section-heading mb-4">{{sections.footer.linksHeading}}</h6>
<ul class="footer-link-list list-unstyled">
@ -739,8 +922,9 @@
</footer>
{{/if_show}}
<a href="#" class="back-to-top"><i class="lni-chevron-up"></i></a>
<a href="#" class="back-to-top"><i class="lni-chevron-up"></i></a>
<div id="preloader">
<div class="sk-circle">
<div class="sk-circle1 sk-child"></div><div class="sk-circle2 sk-child"></div><div class="sk-circle3 sk-child"></div>
@ -750,6 +934,8 @@
</div>
</div>
<script src="{{assetUrl assetsBaseUrl 'js/jquery-min.js'}}"></script>
<script src="{{assetUrl assetsBaseUrl 'js/popper.min.js'}}"></script>
<script src="{{assetUrl assetsBaseUrl 'js/bootstrap.min.js'}}"></script>
@ -774,7 +960,7 @@ const playBtn = document.getElementById("playPauseBtn");
const muteBtn = document.getElementById("muteBtn");
playBtn.onclick = function(){
if(video.paused){
video.play();
playBtn.innerHTML='<i class="lni-pause"></i>';
@ -782,9 +968,9 @@ playBtn.onclick = function(){
video.pause();
playBtn.innerHTML='<i class="lni-play"></i>';
}
}
muteBtn.onclick = function(){
video.muted=!video.muted;
@ -794,7 +980,8 @@ muteBtn.onclick = function(){
}else{
muteBtn.innerHTML='<i class="lni-volume"></i>';
}
}
}</script>
</script>
</body>
</html>

View File

@ -31,37 +31,7 @@
"fields": [
{ "key": "heading", "type": "text", "label": "Heading", "default": "Introducing" },
{ "key": "logo", "type": "image", "label": "Popup logo" },
{
"key": "media.type",
"type": "select",
"label": "Media",
"default": "none",
"options": [
{ "value": "none", "label": "None" },
{ "value": "image", "label": "Image" },
{ "value": "video", "label": "Video (upload)" },
{ "value": "youtube", "label": "YouTube video" }
]
},
{
"key": "media.image",
"type": "image",
"label": "Popup image",
"showIf": { "field": "media.type", "equals": "image" }
},
{
"key": "media.video",
"type": "video",
"label": "Popup video",
"showIf": { "field": "media.type", "equals": "video" }
},
{
"key": "media.youtube",
"type": "url",
"label": "YouTube URL",
"placeholder": "https://www.youtube.com/watch?v=...",
"showIf": { "field": "media.type", "equals": "youtube" }
},
{
"key": "autoCloseDuration",
"type": "text",
@ -70,7 +40,35 @@
}
]
},
{
"id": "navbar",
"label": "Navbar",
"show": true,
"fields": [
{
"key": "links",
"type": "array",
"label": "Navbar Links",
"minItems": 1,
"maxItems": 10,
"itemFields": [
{
"key": "text",
"type": "text",
"label": "Link Text",
"default": "Home"
},
{
"key": "url",
"type": "text",
"label": "Link URL",
"default": "#"
}
]
}
]
},
{
"id": "logo",
"label": "Logos",
"show": true,
@ -114,19 +112,30 @@
"label": "Footer logo alt",
"default": ""
},
{
"key": "registerButton.text",
"type": "text",
"label": "Register Button Text",
"default": "Register"
},
{
"key": "registerButton.url",
"type": "text",
"label": "Register Button URL",
"default": "#"
}
"key": "registerButton.show",
"type": "boolean",
"label": "Show Register Button in Navbar",
"default": true
},
{
"key": "registerButton.text",
"type": "text",
"label": "Register Button Text",
"default": "Register",
"show_if": {
"registerButton.show": true
}
},
{
"key": "registerButton.url",
"type": "text",
"label": "Register Button URL",
"default": "#",
"show_if": {
"registerButton.show": true
}
}
]
},
{
@ -137,19 +146,49 @@
{ "key": "event_name", "type": "text", "label": "Event name", "default": "Your Event Name" },
{ "key": "date_venue", "type": "text", "label": "Date & venue", "default": "Date and location" },
{ "key": "image.url", "type": "image", "label": "Hero background image" },
{ "key": "cta.text", "type": "text", "label": "CTA label", "default": "Register Now" },
{ "key": "cta.url", "type": "url", "label": "CTA URL", "default": "#" },
{
"key": "cta.target",
"type": "select",
"label": "CTA target",
"default": "_blank",
"options": [
{ "value": "_blank", "label": "New tab" },
{ "value": "_self", "label": "Same tab" }
]
},
"key": "cta.show",
"type": "boolean",
"label": "Show CTA Button",
"default": true
},
{
"key": "cta.text",
"type": "text",
"label": "CTA Button Text",
"default": "Register Now",
"show_if": {
"cta.show": true
}
},
{
"key": "cta.url",
"type": "url",
"label": "CTA Button URL",
"default": "#",
"show_if": {
"cta.show": true
}
},
{
"key": "cta.target",
"type": "select",
"label": "CTA Target",
"default": "_blank",
"options": [
{
"value": "_blank",
"label": "New tab"
},
{
"value": "_self",
"label": "Same tab"
}
],
"show_if": {
"cta.show": true
}
},
{ "key": "countdown.show", "type": "boolean", "label": "Show countdown", "default": true },
{ "key": "countdown.date", "type": "date", "label": "Countdown date", "default": "" },
{ "key": "countdown.time", "type": "time", "label": "Countdown time", "default": "12:00" }
@ -314,14 +353,14 @@
"label": "Days",
"itemFields": [
{ "key": "label", "type": "text", "label": "Day label", "default": "Day 01" },
{ "key": "date", "type": "text", "label": "Date line", "default": "" },
{ "key": "date", "type": "date", "label": "Date line", "default": "" },
{
"key": "sessions",
"type": "array",
"label": "Sessions",
"itemFields": [
{ "key": "time", "type": "text", "label": "Time" },
{ "key": "type", "type": "text", "label": "Type", "default": "Main programme" },
{ "key": "time", "type": "time", "label": "Time" },
{ "key": "type", "type": "text", "label": "Type (Ex: Main programme, Workshop)", "default": "Main programme" },
{ "key": "title", "type": "text", "label": "Title" }
]
}
@ -363,10 +402,26 @@
"label": "Price",
"default": "$29"
},
{
"key": "currency",
"type": "select",
"label": "Currency",
"default": "$",
"options": [
{
"label": "$",
"value": "$"
},
{
"label": "₹",
"value": "₹"
}
]
},
{
"key": "duration",
"type": "text",
"label": "Duration",
"label": "Duration (Ex: Per Day, Per Event, etc.)",
"default": "Per Day"
},
{
@ -375,17 +430,29 @@
"label": "Highlight Plan",
"default": false
},
{
"key": "showButton",
"type": "boolean",
"label": "Show Button",
"default": true
},
{
"key": "buttonText",
"type": "text",
"label": "Button Text",
"default": "Buy Ticket"
"default": "Buy Ticket",
"show_if": {
"showButton": true
}
},
{
"key": "buttonLink",
"type": "url",
"label": "Button Link",
"default": "#"
"default": "#",
"show_if": {
"showButton": true
}
},
{
"key": "features",
@ -501,17 +568,7 @@
}
]
},
{
"id": "about_crest",
"label": "About CREST / secondary about",
"show": false,
"fields": [
{ "key": "heading", "type": "text", "label": "Heading", "default": "About" },
{ "key": "content", "type": "richtext", "label": "Content", "default": "" },
{ "key": "cta.text", "type": "text", "label": "CTA text", "default": "" },
{ "key": "cta.url", "type": "url", "label": "CTA URL", "default": "#" }
]
},
{
"id": "latest_news",
"label": "Latest News",
@ -529,17 +586,29 @@
"label": "Subtitle",
"default": "Lorem ipsum dolor sit amet, in quodsi vulputate pro. Ius illum vocent mediocritatem an cule dicta iriure at phaedrum."
},
{
"key": "showButton",
"type": "boolean",
"label": "Show Button",
"default": true
},
{
"key": "buttonText",
"type": "text",
"label": "Button Text",
"default": "View All Blog"
"default": "View All Blog",
"show_if": {
"showButton": true
}
},
{
"key": "buttonLink",
"type": "text",
"label": "Button Link",
"default": "#"
"default": "#",
"show_if": {
"showButton": true
}
},
{
"key": "news",
@ -619,41 +688,102 @@
]
},
{
"id": "sponsors",
"label": "Sponsors",
"show": true,
"fields": [
{ "key": "heading", "type": "text", "label": "Heading (optional)", "default": "Sponsors" },
{ "key": "subtitle", "type": "textarea", "label": "Subtitle", "default": "" },
{ "key": "hideHeading", "type": "boolean", "label": "Hide section heading", "default": false },
{ "key": "hideGroupTitles", "type": "boolean", "label": "Hide sponsor tier titles", "default": false },
{
"key": "groups",
"type": "array",
"label": "Sponsor groups",
"itemFields": [
{ "key": "title", "type": "text", "label": "Tier title" },
{ "key": "tier", "type": "text", "label": "Tier id", "default": "other" },
{
"key": "items",
"type": "array",
"label": "Logos",
"itemFields": [
{ "key": "image", "type": "image", "label": "Logo image" },
{ "key": "alt", "type": "text", "label": "Alt text" }
]
"id": "sponsors",
"label": "Sponsors",
"show": true,
"fields": [
{
"key": "heading",
"type": "text",
"label": "Heading (optional)",
"default": "Sponsors"
},
{
"key": "subtitle",
"type": "textarea",
"label": "Subtitle",
"default": ""
},
{
"key": "hideHeading",
"type": "boolean",
"label": "Hide section heading",
"default": false
},
{
"key": "hideGroupTitles",
"type": "boolean",
"label": "Hide sponsor tier titles",
"default": false
},
{
"key": "groups",
"type": "array",
"label": "Sponsor groups",
"itemFields": [
{
"key": "title",
"type": "text",
"label": "Tier title"
},
{
"key": "tier",
"type": "text",
"label": "Tier id",
"default": "other"
},
{
"key": "items",
"type": "array",
"label": "Logos",
"itemFields": [
{
"key": "image",
"type": "image",
"label": "Logo image"
},
{
"key": "alt",
"type": "text",
"label": "Alt text"
}
]
}
]
},
{
"key": "showButton",
"type": "boolean",
"label": "Show Button",
"default": true
},
{
"key": "buttonText",
"type": "text",
"label": "Button Text",
"default": "Become a Sponsor",
"show_if": {
"showButton": true
}
]
}
]
},
},
{
"key": "buttonUrl",
"type": "url",
"label": "Button URL",
"default": "#",
"show_if": {
"showButton": true
}
}
]
},
{
"id": "map",
"label": "Map",
"show": false,
"fields": [
{ "key": "embedUrl", "type": "url", "label": "Google Maps embed URL" },
{ "key": "height", "type": "text", "label": "Iframe height (px)", "default": "450" }
{ "key": "embedUrl", "type": "url", "label": "Google Maps embed URL" }
]
},
{
@ -663,7 +793,6 @@
"fields": [
{ "key": "heading", "type": "text", "label": "Heading", "default": "Contact Us" },
{ "key": "subtitle", "type": "textarea", "label": "Subtitle", "default": "" },
{ "key": "formId", "type": "text", "label": "EventBuzz form ID" },
{ "key": "apiBaseUrl", "type": "url", "label": "API base URL", "default": "https://api.eventbuzz.live/api" },
{ "key": "address", "type": "textarea", "label": "Address", "default": "" },
{
@ -672,8 +801,7 @@
"label": "Phone numbers",
"itemFields": [
{ "key": "label", "type": "text", "label": "Label" },
{ "key": "number", "type": "text", "label": "Display number" },
{ "key": "tel", "type": "text", "label": "tel: href value" }
{ "key": "number", "type": "text", "label": "Display number" }
]
}
]
@ -703,26 +831,16 @@
"show": true,
"fields": [
{ "key": "tagline", "type": "text", "label": "Tagline", "default": "" },
{ "key": "linksHeading", "type": "text", "label": "Links heading", "default": "Quick Links" },
{ "key": "social.heading", "type": "text", "label": "Social heading", "default": "Follow us" },
{ "key": "linksHeading", "type": "text", "label": "Links heading (Ex: Quick Links)", "default": "Quick Links" },
{ "key": "social.heading", "type": "text", "label": "Social heading (Ex: Follow us)", "default": "Follow us" },
{
"key": "links",
"type": "array",
"label": "Footer links",
"itemFields": [
{ "key": "text", "type": "text", "label": "Text" },
{ "key": "url", "type": "url", "label": "URL" },
{
"key": "target",
"type": "select",
"label": "Target",
"default": "_self",
"options": [
{ "value": "modal", "label": "Modal" },
{ "value": "_blank", "label": "New tab" },
{ "value": "_self", "label": "Same tab" }
]
}
{ "key": "url", "type": "url", "label": "URL" }
]
},
{
@ -731,8 +849,9 @@
"label": "Social links",
"itemFields": [
{ "key": "url", "type": "url", "label": "URL" },
{ "key": "icon", "type": "text", "label": "Icon class", "default": "lni-facebook-filled" },
{ "key": "label", "type": "text", "label": "Aria label" }
{ "key": "label", "type": "text", "label": "Label" },
{ "key": "icon", "type": "text", "label": "Icon class", "default": "lni-facebook-filled" }
]
}
]

View File

@ -515,6 +515,28 @@ p {
-o-transform: rotate(135deg);
transform: rotate(135deg);
top: -7px; }
.navbar-brand {
display: flex;
align-items: center;
padding: 0;
}
.navbar-brand img {
height: 45px; /* apne hisab se 50-70px We can adjust Logo Height*/
width: auto;
object-fit: contain;
display: block;
}
.navbar-area .navbar {
min-height: 80px;
align-items: center;
}
.logo-img{
height: 60px;
width: auto;
}
@media only screen and (min-width: 768px) and (max-width: 991px) {
.navbar-area .navbar .navbar-collapse {
position: absolute;
@ -950,6 +972,33 @@ background: linear-gradient(
color: #fff; }
.event-tab .tab-content .event-content .single-event:hover .event-content .event-meta li {
color: #fff; }
.schedule-nav{
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
gap: 20px;
}
.schedule-nav .nav-item{
flex: 0 0 auto;
}
@media (max-width: 767px){
.schedule-nav{
flex-direction: row !important;
gap: 12px;
}
.schedule-nav .nav-item{
width: calc(50% - 6px);
}
.event-tab .nav .nav-item a{
width: 100%;
}
}
/*===========================
6.FEATURES css
@ -1283,12 +1332,30 @@ opacity: 0.85;
color: #fff; }
.single-pricing .pricing-price .text {
color: #fff; }
.single-pricing .pricing-list {
padding-top: 15px; }
.single-pricing .pricing-list ul li {
font-size: 16px;
color: #777777;
margin-top: 15px; }
.single-pricing .pricing-list{
padding-top: 20px;
}
.single-pricing .pricing-list ul{
padding: 0;
margin: 0;
list-style: none;
}
.single-pricing .pricing-list ul li{
display: flex;
align-items: center;
gap: 12px;
text-align: left;
font-size: 16px;
color: #555;
margin-top: 16px;
line-height: 1.5;
}
.single-pricing .pricing-list ul li i{
color: #6C2BFF;
font-size: 18px;
flex-shrink: 0;
}
.single-pricing .pricing-btn {
padding-top: 30px; }
.single-pricing .pricing-btn .main-btn {
@ -1304,7 +1371,116 @@ opacity: 0.85;
-moz-box-shadow: 0px 8px 16px 0px rgba(72, 127, 255, 0.1);
box-shadow: 0px 8px 16px 0px rgba(72, 127, 255, 0.1); }
/*===========================
/*===========================
8.CTA css */
/*===========================
CTA TICKET
===========================*/
.cta-ticket{
padding: 0 0 115px;
}
.cta-ticket-wrapper{
background: linear-gradient(135deg,#2200a8 0%,#9b2cff 100%);
border-radius: 24px;
overflow: hidden;
padding: 60px;
position: relative;
}
.cta-ticket-wrapper::before{
content:'';
position:absolute;
width:280px;
height:280px;
background:rgba(255,255,255,.08);
border-radius:50%;
top:-120px;
right:-100px;
}
.cta-content{
position:relative;
z-index:2;
}
.cta-badge{
display:inline-block;
background:rgba(255,255,255,.15);
color:#fff;
padding:8px 16px;
border-radius:30px;
font-size:14px;
margin-bottom:20px;
}
.cta-content h2{
color:#fff;
font-size:42px;
font-weight:700;
line-height:1.2;
margin-bottom:20px;
}
.cta-content h2 span{
color:#FFD86B;
}
.cta-content p{
color:rgba(255,255,255,.88);
font-size:16px;
margin-bottom:35px;
max-width:520px;
}
.cta-image img{
max-width:100%;
max-height:330px;
}
@media(max-width:991px){
.cta-ticket-wrapper{
padding:40px 30px;
text-align:center;
}
.cta-content p{
margin:0 auto 30px;
}
.cta-content h2{
font-size:32px;
}
.cta-image{
margin-top:35px;
}
}
@media(max-width:767px){
.cta-ticket{
padding-bottom:80px;
}
.cta-ticket-wrapper{
padding:30px 22px;
}
.cta-content h2{
font-size:28px;
}
.cta-image img{
max-height:240px;
}
}
/*
9.CLICNT css
===========================*/
/* .single-client img {
@ -1328,6 +1504,7 @@ opacity: 0.85;
object-fit:contain;
display:block;
}
/*===========================
10.CONTACT css
===========================*/
@ -1416,6 +1593,57 @@ opacity: 0.85;
.contact-map .gmap_canvas iframe {
width: 100%;
height: 480px; }
/* Button row center */
.contact-form .single-form:has(.seperate-contact-btn){
display: flex;
justify-content: center;
margin-top: 10px;
}
/* New button class */
.seperate-contact-btn{
min-width: 220px;
text-align: center;
}
.contact-btn-wrap{
display: flex;
justify-content: center;
margin-top: 10px;
}
.seperate-contact-btn{
min-width: 220px;
}
.contact-form-desc{
text-align: center;
}
.contact-form-desc .text{
margin: 0 auto;
}
@media (max-width: 767px){
.contact-form-desc{
text-align: center; /* mobile pe readable rahega */
}
}
.map-area{
padding: 0 !important;
margin: 0;
}
.map-area iframe{
display: block;
width: 100%;
height: 420px; /* desktop */
border: 0;
}
@media (max-width: 767px){
.map-area iframe{
height: 280px;
}
}
/*===========================
11.FOOTER css

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@
:root{
--primarycolor: {{sections.theme.primarycolor}};
--secondarycolor: {{sections.theme.secondarycolor}};
--secondarygradcolor : {{sections.theme.secondarygradcolor}}
--secondarygradcolor : {{sections.theme.secondarygradcolor}};
}
</style>
<!--====== Required meta tags ======-->
@ -95,9 +95,9 @@
href="{{sections.header.logo.url}}"
>
<img
src="{{resolveAsset @root.assetsBaseUrl sections.header.logo.image}}"
alt="{{sections.header.logo.alt}}" style="max-height:{{sections.logo.header.maxHeight}}px"
>
src="{{resolveAsset @root.assetsBaseUrl sections.header.logo.image}}"
alt="{{sections.header.logo.alt}}"
class="logo-img">
</a>
<!-- {{/if}} -->
<button
@ -220,26 +220,29 @@
{{sections.hero.event_date}} • {{sections.hero.venue}}
</h3>
<ul class="header-btn">
{{#if sections.hero.register_text}}
{{#if sections.hero.showPrimaryButton}}
<li>
<a
class="main-btn main-btn-2"
href="{{sections.hero.register_url}}"
href="{{sections.hero.primaryButtonUrl}}"
>
{{sections.hero.register_text}}
{{sections.hero.primaryButtonText}}
</a>
</li>
{{/if}}
{{#if sections.hero.learn_more_text}}
{{#if sections.hero.showSecondaryButton}}
<li>
<a
class="main-btn"
href="{{sections.hero.learn_more_url}}"
href="{{sections.hero.secondaryButtonUrl}}"
>
{{sections.hero.learn_more_text}}
{{sections.hero.secondaryButtonText}}
</a>
</li>
{{/if}}
</ul>
</div>
</div>
@ -349,11 +352,7 @@
<div class="row">
<div class="col-lg-12">
<div class="event-tab mt-60">
<ul
class="nav justify-content-between align-items-center"
id="myTab"
role="tablist"
>
<ul class="nav justify-content-center align-items-center schedule-nav" id="myTab" role="tablist">
{{#each sections.event_schedule.days}}
<li class="nav-item">
<a
@ -666,7 +665,7 @@
</div>
<div class="pricing-price">
<span>
{{this.price}}
{{this.currency}}{{this.price}}
</span>
<p class="text">
{{this.duration}}
@ -676,19 +675,22 @@
<ul>
{{#each this.features}}
<li>
{{this.text}}
<span>✓ {{this.text}}</span>
</li>
{{/each}}
</ul>
</div>
<div class="pricing-btn">
<a
class="main-btn {{#if this.active}}main-btn-2{{/if}}"
href="{{this.buttonUrl}}"
>
{{this.buttonText}}
</a>
</div>
{{#if this.showButton}}
<div class="pricing-btn">
<a
class="main-btn {{#if this.active}}main-btn-2{{/if}}"
href="{{this.buttonUrl}}"
>
{{this.buttonText}}
</a>
</div>
{{/if}}
</div>
</div>
{{/each}}
@ -697,12 +699,52 @@
</section>
{{/if_show}}
<!--====== PRICING PART ENDS ======-->
<!--====== CTA TICKET PART START ======-->
{{#if_show sections.ctaTicket}}
<section class="cta-ticket">
<div class="container">
<div class="cta-ticket-wrapper">
<div class="row align-items-center">
<div class="col-lg-7">
<div class="cta-content">
<span class="cta-badge">{{sections.ctaTicket.badge}}</span>
<h2>
{{sections.ctaTicket.title}}
<span>{{sections.ctaTicket.highlightTitle}}</span>
</h2>
<p>{{sections.ctaTicket.description}}</p>
{{#if sections.ctaTicket.showButton}}
<a href="{{sections.ctaTicket.buttonUrl}}" class="main-btn main-btn-2">
{{sections.ctaTicket.buttonText}}
</a>
{{/if}}
</div>
</div>
<div class="col-lg-5 text-center">
<div class="cta-image">
<img src="{{resolveAsset @root.assetsBaseUrl sections.ctaTicket.image}}" alt="{{sections.ctaTicket.imageAlt}}">
</div>
</div>
</div>
</div>
</div>
</section>
{{/if_show}}
<!--====== CTA TICKET PART END ======-->
<!--====== CLIENT PART START ======-->
{{#if_show sections.sponsors}}
<div id="client" class="client-area pt-115 pb-130 gray-bg">
<div id="sponsors" class="client-area pt-115 pb-130 gray-bg">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-8">
@ -729,18 +771,18 @@
{{/each}}
</div>
{{#if sections.sponsors.buttonText}}
<div class="row mt-5">
<div class="col-12 text-center">
<a
href="{{sections.sponsors.buttonUrl}}"
class="main-btn main-btn-2"
>
{{sections.sponsors.buttonText}}
</a>
</div>
</div>
{{/if}}
{{#if sections.sponsors.showButton}}
<div class="row mt-5">
<div class="col-12 text-center">
<a
href="{{sections.sponsors.buttonUrl}}"
class="main-btn main-btn-2"
>
{{sections.sponsors.buttonText}}
</a>
</div>
</div>
{{/if}}
</div>
</div>
{{/if_show}}
@ -750,141 +792,134 @@
<!--====== CONTACT PART START ======-->
{{#if_show sections.contact}}
<section id="contact" class="contact-area pt-80 pb-130">
<section id="contact" class="contact-area pt-80 pb-80">
<div class="container">
<div class="row">
<div class="col-lg-4">
<div class="contact-info pt-40">
<div class="contact-info pt-30">
<div class="section-title pb-10">
<h2 class="title">
{{sections.contact.heading}}
</h2>
<h2 class="title">{{sections.contact.heading}}</h2>
{{#if sections.contact.description}}
<p class="text mt-15">
{{sections.contact.description}}
</p>
{{/if}}
</div>
<ul>
{{#if sections.contact.email}}
<li>
<div class="single-info d-flex mt-25">
<div class="info-icon">
<i class="lni-envelope"></i>
</div>
<div class="info-content media-body">
<h6 class="info-title">
Email Address
</h6>
<p class="text">
<a href="mailto:{{sections.contact.email}}">
{{sections.contact.email}}
</a>
</p>
</div>
</div>
</li>
{{/if}}
<li>
<div class="single-info d-flex mt-25">
<div class="info-icon">
<i class="lni lni-envelope"></i>
</div>
<div class="info-content media-body">
<h6 class="info-title">Email Address</h6>
<p class="text">
<a href="mailto:{{sections.contact.email}}">
{{sections.contact.email}}
</a>
</p>
</div>
</div>
</li>
{{#if sections.contact.phone}}
<li>
<div class="single-info d-flex mt-25">
<div class="info-icon">
<i class="lni-phone-handset"></i>
</div>
<div class="info-content media-body">
<h6 class="info-title">
Phone Number
</h6>
<p class="text">
{{sections.contact.phone}}
</p>
</div>
</div>
</li>
{{/if}}
<li>
<div class="single-info d-flex mt-25">
<div class="info-icon">
<i class="lni lni-phone"></i>
</div>
<div class="info-content media-body">
<h6 class="info-title">Phone Number</h6>
<p class="text">{{sections.contact.phone}}</p>
</div>
</div>
</li>
{{#if sections.contact.location}}
<li>
<div class="single-info d-flex mt-25">
<div class="info-icon">
<i class="lni-money-location"></i>
</div>
<div class="info-content media-body">
<h6 class="info-title">
Location
</h6>
<p class="text">
{{sections.contact.location}}
</p>
</div>
</div>
</li>
{{/if}}
</ul>
<li>
<div class="single-info d-flex mt-25">
<div class="info-icon">
<i class="lni lni-map-marker"></i>
</div>
<div class="info-content media-body">
<h6 class="info-title">Location</h6>
<p class="text">{{sections.contact.location}}</p>
</div>
</div>
</li>
</ul>
</div>
</div>
<div class="col-lg-8">
<div class="contact-form pt-20">
<div class="contact-form pt-30">
{{#if sections.contact.form.description}}
<div class="section-title pb-25 text-center contact-form-desc">
<p class="text">
{{sections.contact.form.description}}
</p>
</div>
{{/if}}
<form id="contact-form">
<div class="row">
{{#if sections.contact.form.showName}}
<div class="col-md-6">
<div class="single-form">
<input
type="text"
name="name"
placeholder="Your Name"
>
<input type="text" name="name" placeholder="Your Name">
<i class="lni-user"></i>
</div>
</div>
{{/if}}
{{#if sections.contact.form.showEmail}}
<div class="col-md-6">
<div class="single-form">
<input
type="email"
name="email"
placeholder="Your Email"
>
<input type="email" name="email" placeholder="Your Email">
<i class="lni-envelope"></i>
</div>
</div>
{{/if}}
{{#if sections.contact.form.showSubject}}
<div class="col-md-6">
<div class="single-form">
<input
type="text"
name="subject"
placeholder="Your Subject"
>
<input type="text" name="subject" placeholder="Your Subject">
<i class="lni-pencil-alt"></i>
</div>
</div>
{{/if}}
{{#if sections.contact.form.showPhone}}
<div class="col-md-6">
<div class="single-form">
<input
type="text"
name="number"
placeholder="Phone Number"
>
<input type="text" name="number" placeholder="Phone Number">
<i class="lni-phone-handset"></i>
</div>
</div>
{{/if}}
{{#if sections.contact.form.showMessage}}
<div class="col-md-12">
<div class="single-form">
<textarea
name="message"
placeholder="Your Message"
></textarea>
<textarea name="message" placeholder="Your Message"></textarea>
<i class="lni-comment-alt"></i>
</div>
</div>
{{/if}}
<p class="form-message"></p>
{{#if sections.contact.form.showButton}}
<div class="col-md-12">
<div class="single-form">
<button
type="submit"
class="main-btn main-btn-2"
>
{{sections.contact.form.buttonText}}
</button>
</div>
<div class="single-form contact-btn-wrap">
<a href="{{sections.contact.form.actionUrl}}" class="main-btn main-btn-2 seperate-contact-btn">
{{sections.contact.form.buttonText}}
</a>
</div>
</div>
{{/if}}
</div>
</form>
</div>
@ -899,7 +934,7 @@
<!--====== Map PART START ======-->
{{#if_show sections.map}}
<section id="google-map-area" class="pt-80 pb-80" data-site-component="map">
<section id="google-map-area" class="map-area" data-site-component="map">
<div class="container-fluid p-0">
<iframe
title="Event Location Map"

View File

@ -47,7 +47,7 @@
},
{
"id": "header",
"label": "Header & Navigation",
"label": "Navbar",
"show": true,
"fields": [
{
@ -73,12 +73,7 @@
"label": "Logo URL",
"default": "#home"
},
{
"key": "logo.maxHeight",
"type": "text",
"label": "Logo Max Height (px)",
"default": "56"
},
{
"key": "navigation",
@ -95,47 +90,28 @@
"type": "text",
"label": "Link URL",
"default": "#"
},
{
"key": "active",
"type": "boolean",
"label": "Active Link",
"default": false
}
]
},
{
"key": "button",
"type": "group",
"label": "Ticket Button",
"fields": [
{
"key": "show",
"type": "boolean",
"label": "Show Ticket Button",
"default": true
},
{
"key": "text",
"type": "text",
"label": "Button Text",
"default": "Get a Ticket"
},
{
"key": "url",
"type": "text",
"label": "Button URL",
"default": "#pricing"
},
{
"key": "target",
"type": "text",
"label": "Button Target",
"default": "_self"
}
]
}
"key": "button.show",
"type": "boolean",
"label": "Show Ticket Button",
"default": true
},
{
"key": "button.text",
"type": "text",
"label": "Button Text",
"default": "Get a Ticket"
},
{
"key": "button.url",
"type": "text",
"label": "Button URL",
"default": "#pricing"
}
]
},
{
@ -166,29 +142,53 @@
"label": "Hero Background Image"
},
{
"key": "register_text",
"type": "text",
"label": "Register Button Text",
"default": "Register Now"
},
{
"key": "register_url",
"type": "url",
"label": "Register Button URL",
"default": "#"
},
{
"key": "learn_more_text",
"type": "text",
"label": "Learn More Button Text",
"default": "Learn More"
},
{
"key": "learn_more_url",
"type": "url",
"label": "Learn More URL",
"default": "#about"
}
"key": "showPrimaryButton",
"type": "boolean",
"label": "Show Primary Button",
"default": true
},
{
"key": "primaryButtonText",
"type": "text",
"label": "Primary Button Text",
"default": "Register Now",
"show_if": {
"showPrimaryButton": true
}
},
{
"key": "primaryButtonUrl",
"type": "url",
"label": "Primary Button URL",
"default": "#",
"show_if": {
"showPrimaryButton": true
}
},
{
"key": "showSecondaryButton",
"type": "boolean",
"label": "Show Secondary Button",
"default": true
},
{
"key": "secondaryButtonText",
"type": "text",
"label": "Secondary Button Text",
"default": "Learn More",
"show_if": {
"showSecondaryButton": true
}
},
{
"key": "secondaryButtonUrl",
"type": "url",
"label": "Secondary Button URL",
"default": "#about",
"show_if": {
"showSecondaryButton": true
}
}
]
},
{
@ -245,12 +245,6 @@
"key": "label",
"type": "text",
"label": "Counter Label"
},
{
"key": "delay",
"type": "text",
"label": "Animation Delay",
"default": "0s"
}
]
}
@ -377,12 +371,6 @@
"type": "url",
"label": "Feature URL",
"default": "#"
},
{
"key": "delay",
"type": "text",
"label": "Animation Delay",
"default": "0s"
}
]
}
@ -531,10 +519,26 @@
"type": "text",
"label": "Price"
},
{
"key": "currency",
"type": "select",
"label": "Currency",
"default": "$",
"options": [
{
"label": "$",
"value": "$"
},
{
"label": "₹",
"value": "₹"
}
]
},
{
"key": "duration",
"type": "text",
"label": "Price Duration",
"label": "Price Duration (Ex: Per Day)",
"default": "Per Day"
},
{
@ -543,18 +547,7 @@
"label": "Featured Plan",
"default": false
},
{
"key": "animation",
"type": "text",
"label": "Animation Class",
"default": "fadeInUp"
},
{
"key": "delay",
"type": "text",
"label": "Animation Delay",
"default": "0s"
},
{
"key": "features",
"type": "array",
@ -567,23 +560,101 @@
}
]
},
{
"key": "showButton",
"type": "boolean",
"label": "Show Button",
"default": true
},
{
"key": "buttonText",
"type": "text",
"label": "Button Text",
"default": "Buy Ticket"
"default": "Buy Ticket",
"show_if": {
"showButton": true
}
},
{
"key": "buttonUrl",
"type": "url",
"label": "Button URL",
"default": "#"
"default": "#",
"show_if": {
"showButton": true
}
}
]
}
]
},
{
"id": "ctaTicket",
"label": "CTA Ticket",
"show": true,
"fields": [
{
"key": "badge",
"type": "text",
"label": "Badge",
"default": "Limited Time"
},
{
"key": "title",
"type": "text",
"label": "Title",
"default": "Ready to Join the"
},
{
"key": "highlightTitle",
"type": "text",
"label": "Highlight Title",
"default": "Balkan Ecommerce Summit?"
},
{
"key": "description",
"type": "textarea",
"label": "Description",
"default": "Secure your seat today and connect with global ecommerce leaders, innovators, investors and entrepreneurs across Europe."
},
{
"key": "image",
"type": "image",
"label": "Right Image"
},
{
"key": "imageAlt",
"type": "text",
"label": "Image Alt",
"default": "CTA Image"
},
{
"key": "showButton",
"type": "boolean",
"label": "Show Button",
"default": true
},
{
"key": "buttonText",
"type": "text",
"label": "Button Text",
"default": "Buy Your Ticket",
"show_if": {
"showButton": true
}
},
{
"key": "buttonUrl",
"type": "url",
"label": "Button URL",
"default": "#pricing",
"show_if": {
"showButton": true
}
}
]
},
{
"id": "sponsors",
"label": "Event Sponsors",
"show": true,
@ -617,6 +688,12 @@
}
]
},
{
"key": "showButton",
"type": "boolean",
"label":"Show Partner Button",
"default":true
},
{
"key": "buttonText",
"type": "text",
@ -642,6 +719,11 @@
"label": "Section Heading",
"default": "Get In Touch"
},
{
"key": "description",
"type": "textarea",
"label": "Left Side Description"
},
{
"key": "email",
"type": "text",
@ -657,17 +739,64 @@
"type": "textarea",
"label": "Location"
},
{
"key": "form.description",
"type": "textarea",
"label": "Right side Form Description"
},
{
"key": "form.showName",
"type": "boolean",
"label": "Show Name Field",
"default": true
},
{
"key": "form.showEmail",
"type": "boolean",
"label": "Show Email Field",
"default": true
},
{
"key": "form.showSubject",
"type": "boolean",
"label": "Show Subject Field",
"default": true
},
{
"key": "form.showPhone",
"type": "boolean",
"label": "Show Phone Field",
"default": true
},
{
"key": "form.showMessage",
"type": "boolean",
"label": "Show Message Field",
"default": true
},
{
"key": "form.showButton",
"type": "boolean",
"label": "Show Submit Button",
"default": true
},
{
"key": "form.buttonText",
"type": "text",
"label": "Submit Button Text",
"default": "Send Message"
"default": "Send Message",
"show_if": {
"form.showButton": true
}
},
{
"key": "form.actionUrl",
"type": "url",
"label": "Form Action URL",
"default":"#"
"label": "Button URL",
"default": "#",
"show_if": {
"form.showButton": true
}
}
]
},
@ -680,12 +809,6 @@
"key": "embedUrl",
"type": "url",
"label": "Google Maps Embed URL"
},
{
"key": "height",
"type": "text",
"label": "Iframe Height (px)",
"default": "450"
}
]
},
@ -776,11 +899,6 @@
"type": "array",
"label": "Social Links",
"itemFields": [
{
"key": "label",
"type": "text",
"label": "Platform"
},
{
"key": "icon",
"type": "text",

View File

@ -9,11 +9,14 @@ body {
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, h4, h5, h6 {
h2, h3, h4, h5, h6 {
font-family: "Montserrat", sans-serif;
color: #222222;
font-weight: 600;
}
h1{
color: var(--primary);
}
h1 {
font-size: 2.8rem;
@ -549,7 +552,8 @@ select.form-control:not([size]):not([multiple]) {
}
.main-nav .navbar-brand {
padding: 35px 40px 35px 50px;
/* padding: 35px 40px 35px 50px; */
padding: 18px 30px;
border-right: 1px solid #e5e5e5;
}
.main-nav .navbar-brand img{
@ -560,7 +564,11 @@ select.form-control:not([size]):not([multiple]) {
@media (max-width: 1024px) {
.main-nav .navbar-brand {
padding: 20px 30px;
padding: 14px 22px;
}
.main-nav .ticket {
padding: 16px 18px;
}
}
@ -690,7 +698,8 @@ select.form-control:not([size]):not([multiple]) {
.main-nav .ticket {
background: var(--primary);
display: block;
padding: 34px 32px;
/* padding: 34px 32px; */
padding: 22px 26px;
}
@media (max-width: 1024px) {
@ -714,8 +723,8 @@ select.form-control:not([size]):not([multiple]) {
font-size: 1.125rem;
text-transform: uppercase;
font-family: "Montserrat", sans-serif;
border-left: 1px solid #ffa366;
padding-left: 24px;
/* border-left: 1px solid #ffa366; */
padding-left: 12px;
}
@media (max-width: 1024px) {
@ -946,7 +955,7 @@ select.form-control:not([size]):not([multiple]) {
z-index: 10;
}
.banner .block .timer .syotimer-cell {
/* .banner .block .timer .syotimer-cell {
margin-right: 25px;
margin-bottom: 33px;
display: inline-block;
@ -996,8 +1005,93 @@ select.form-control:not([size]):not([multiple]) {
font-weight: 500;
margin-bottom: 5px;
text-shadow: 3.5px 6.062px 0px rgba(0, 0, 0, 0.1);
} */
/* ==============================
Responsive Syotimer
============================== */
.banner .block .timer {
display: flex;
justify-content: center;
margin-top: 5px;
}
.banner .block .timer .syotimer__body {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 25px;
}
.banner .block .timer .syotimer-cell {
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
}
.banner .block .timer .syotimer-cell .syotimer-cell__value {
width: 80px;
height: 80px;
min-width: 80px;
font-family: "Montserrat", sans-serif;
font-size: 35px;
font-weight: bold;
color: #fff;
border: 2px solid #7c7491;
border-radius: 50%;
box-shadow: 3.5px 6px 0 var(--primary);
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
margin-bottom: 8px;
}
.banner .block .timer .syotimer-cell .syotimer-cell__unit {
text-align: center;
font-size: 16px;
text-transform: lowercase;
color: #fff;
font-family: "Montserrat", sans-serif;
}
/* Tablet */
@media (max-width: 768px) {
.banner .block .timer .syotimer__body {
gap: 16px;
}
.banner .block .timer .syotimer-cell .syotimer-cell__value {
width: 68px;
height: 68px;
min-width: 68px;
font-size: 28px;
}
.banner .block .timer .syotimer-cell .syotimer-cell__unit {
font-size: 14px;
}
}
/* Mobile */
@media (max-width: 480px) {
.banner .block .timer .syotimer__body {
gap: 10px;
}
.banner .block .timer .syotimer-cell .syotimer-cell__value {
width: 56px;
height: 56px;
min-width: 56px;
font-size: 22px;
}
.banner .block .timer .syotimer-cell .syotimer-cell__unit {
font-size: 12px;
}
}
@media (max-width: 767px) {
.banner .block h1 {
font-size: 4rem;
@ -1213,10 +1307,38 @@ select.form-control:not([size]):not([multiple]) {
}
}
@media (max-width: 480px) {
/* @media (max-width: 480px) {
.speakers .speaker-item .image .socials ul {
width: 31%;
}
} */
@media (max-width:480px){
.speakers .speaker-item .image .socials{
display:flex;
justify-content:center;
align-items:center;
}
.speakers .speaker-item .image .socials ul{
width:auto;
display:flex;
gap:10px;
padding:0;
margin:0;
}
.speakers .speaker-item .image .socials ul li{
margin:0;
}
.speakers .speaker-item .image .socials ul li a{
width:42px;
height:42px;
display:flex;
align-items:center;
justify-content:center;
}
}
.speakers .speaker-item .image .socials ul li.list-inline-item:not(:last-child) {
@ -3510,4 +3632,70 @@ ul.pagination .active a {
margin-bottom: 30px;
}
/* ==============================
Schedule Mobile Fix
============================== */
@media (max-width: 767px){
/* horizontal day tabs */
.schedule .schedule-tab .nav{
flex-wrap: nowrap;
overflow-x: auto;
white-space: nowrap;
}
.schedule .schedule-tab .nav-item{
width: auto !important;
flex: 0 0 auto;
margin-right: 10px;
}
/* hide orange heading row */
.schedule .headings{
display: none;
}
/* make each session a card */
.schedule .schedule-details .block{
display: flex !important;
flex-direction: column;
align-items: flex-start;
gap: 10px;
padding: 16px 18px !important;
}
.schedule .schedule-details .time,
.schedule .schedule-details .speaker,
.schedule .schedule-details .subject,
.schedule .schedule-details .venue{
width: 100% !important;
display: flex !important;
align-items: center;
}
/* show speaker image again */
.schedule .schedule-details .speaker img{
display: block !important;
width: 52px;
height: 52px;
margin-right: 12px;
border-radius: 50%;
object-fit: cover;
}
.schedule .schedule-details .speaker .name{
margin-left: 0 !important;
font-size: 16px;
font-weight: 600;
}
/* show subject & venue on mobile */
.schedule .schedule-details .subject,
.schedule .schedule-details .venue{
display: block !important;
color: #666;
font-size: 14px;
padding-left: 64px;
}
}
/*# sourceMappingURL=maps/style.css.map */

View File

@ -33,15 +33,21 @@
]
});
// -----------------------------
// Count Down JS
// Count Down JS (Dynamic)
// -----------------------------
$('.timer').syotimer({
year: 2017,
month: 12,
day: 9,
hour: 20,
minute: 30
});
const eventDate = $('.timer').data('date'); // e.g. 2026-12-11
if (eventDate) {
const d = new Date(eventDate);
$('.timer').syotimer({
year: d.getFullYear(),
month: d.getMonth() + 1,
day: d.getDate(),
hour: 9,
minute: 0
});
}
// -----------------------------
// To Top Init
// -----------------------------

View File

@ -24,31 +24,56 @@
"sections": {
"navbar": {
"show": true,
"showLogo": true,
"logo": "images/logo.png",
"logoAlt": "logo",
"logoUrl": "#home",
"homeLabel": "Home",
"homeUrl": "#home",
"navigation": [
{
"text": "Home",
"url": "#home"
},
{
"text": "About Us",
"url": "#about"
},
{
"text": "Speakers",
"url": "#speakers"
},
{
"text": "Schedule",
"url": "#schedule"
},
{
"text": "Registration",
"url": "#registration"
},
{
"text": "Pricing",
"url": "#pricing"
},
{
"text": "Sponsors",
"url": "#sponsors"
},
{
"text": "News",
"url": "#news"
},
{
"text": "Subscribe",
"url": "#subscribe"
},
{
"text": "Map",
"url": "#map"
}
],
"aboutLabel": "About Us",
"aboutUrl": "#about",
"speakersLabel": "Speakers",
"speakersUrl": "#speakers",
"scheduleLabel": "Schedule",
"scheduleUrl": "#schedule",
"contactLabel": "Contact",
"contactUrl": "#registration",
"sponsorsLabel": "Sponsors",
"sponsorsUrl": "#sponsors",
"newsLabel": "News",
"newsUrl": "#news",
"ticketIcon": "images/icon/ticket.png",
"showTicketButton": true,
"ticketText": "Buy Ticket",
"ticketUrl": "#"
},
@ -59,110 +84,133 @@
"subtitle": "Conference 2026",
"eventDate": "17-22 July 2026",
"eventLocation": "California",
"showButton": true,
"buttonText": "Book Ticket Now",
"buttonUrl": "#"
},
"about": {
"show": true,
"image": "images/speakers/featured-speaker.jpg",
"imageAlt": "Eventre",
"title": "About The",
"highlightTitle": "Eventre",
"descriptionOne": "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusm tempor incididunt ut labore dolore magna aliqua enim ad minim veniam quis nostrud exercitation ullamco.",
"descriptionTwo": "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmtempor incididunt ut labore et dolore magna aliq enim ad minim veniam quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea.",
"primaryButtonText": "Buy ticket",
"primaryButtonUrl": "#",
"secondaryButtonText": "Read more",
"secondaryButtonUrl": "#"
},
"show": true,
"image": "images/speakers/featured-speaker.jpg",
"imageAlt": "Eventre",
"title": "About The",
"highlightTitle": "Eventre",
"descriptionOne": "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusm tempor incididunt ut labore dolore magna aliqua enim ad minim veniam quis nostrud exercitation ullamco.",
"descriptionTwo": "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusm tempor incididunt ut labore et dolore magna aliq enim ad minim veniam quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea.",
"showPrimaryButton": true,
"primaryButtonText": "Buy Ticket",
"primaryButtonUrl": "#",
"showSecondaryButton": true,
"secondaryButtonText": "Read More",
"secondaryButtonUrl": "#"
},
"speakers": {
"show": true,
"title": "Who",
"highlightTitle": "Speaking?",
"description": "Lorem ipsum dolor sit amet, consectetur adipisicing elit sed do eiusm tempor incididunt ut labore",
"speakers": [
{
"name": "Johnathan Franco",
"designation": "Project Manager",
"image": "images/speakers/speaker-one.jpg",
"profileUrl": "#",
"facebookUrl": "#",
"twitterUrl": "#",
"linkedinUrl": "#",
"pinterestUrl": "#"
},
{
"name": "Johnathan Franco",
"designation": "Project Manager",
"image": "images/speakers/speaker-two.jpg",
"profileUrl": "#",
"facebookUrl": "#",
"twitterUrl": "#",
"linkedinUrl": "#",
"pinterestUrl": "#"
},
{
"name": "Johnathan Franco",
"designation": "Project Manager",
"image": "images/speakers/speaker-three.jpg",
"profileUrl": "#",
"facebookUrl": "#",
"twitterUrl": "#",
"linkedinUrl": "#",
"pinterestUrl": "#"
},
{
"name": "Johnathan Franco",
"designation": "Project Manager",
"image": "images/speakers/speaker-four.jpg",
"profileUrl": "#",
"facebookUrl": "#",
"twitterUrl": "#",
"linkedinUrl": "#",
"pinterestUrl": "#"
},
{
"name": "Johnathan Franco",
"designation": "Project Manager",
"image": "images/speakers/speaker-five.jpg",
"profileUrl": "#",
"facebookUrl": "#",
"twitterUrl": "#",
"linkedinUrl": "#",
"pinterestUrl": "#"
},
{
"name": "Johnathan Franco",
"designation": "Project Manager",
"image": "images/speakers/speaker-six.jpg",
"profileUrl": "#",
"facebookUrl": "#",
"twitterUrl": "#",
"linkedinUrl": "#",
"pinterestUrl": "#"
},
{
"name": "Johnathan Franco",
"designation": "Project Manager",
"image": "images/speakers/speaker-five.jpg",
"profileUrl": "#",
"facebookUrl": "#",
"twitterUrl": "#",
"linkedinUrl": "#",
"pinterestUrl": "#"
},
{
"name": "Johnathan Franco",
"designation": "Project Manager",
"image": "images/speakers/speaker-six.jpg",
"profileUrl": "#",
"facebookUrl": "#",
"twitterUrl": "#",
"linkedinUrl": "#",
"pinterestUrl": "#"
}
]
"show": true,
"title": "Who",
"highlightTitle": "Speaking?",
"description": "Lorem ipsum dolor sit amet, consectetur adipisicing elit sed do eiusm tempor incididunt ut labore",
"speakers": [
{
"name": "Johnathan Franco",
"designation": "Project Manager",
"image": "images/speakers/speaker-one.jpg",
"profileUrl": "#",
"social": [
{ "icon": "fa fa-facebook", "url": "#" },
{ "icon": "fa fa-twitter", "url": "#" },
{ "icon": "fa fa-linkedin", "url": "#" },
{ "icon": "fa fa-pinterest", "url": "#" }
]
},
{
"name": "Johnathan Franco",
"designation": "Project Manager",
"image": "images/speakers/speaker-two.jpg",
"profileUrl": "#",
"social": [
{ "icon": "fa fa-facebook", "url": "#" },
{ "icon": "fa fa-twitter", "url": "#" },
{ "icon": "fa fa-linkedin", "url": "#" },
{ "icon": "fa fa-pinterest", "url": "#" }
]
},
{
"name": "Johnathan Franco",
"designation": "Project Manager",
"image": "images/speakers/speaker-three.jpg",
"profileUrl": "#",
"social": [
{ "icon": "fa fa-facebook", "url": "#" },
{ "icon": "fa fa-twitter", "url": "#" },
{ "icon": "fa fa-linkedin", "url": "#" },
{ "icon": "fa fa-pinterest", "url": "#" }
]
},
{
"name": "Johnathan Franco",
"designation": "Project Manager",
"image": "images/speakers/speaker-four.jpg",
"profileUrl": "#",
"social": [
{ "icon": "fa fa-facebook", "url": "#" },
{ "icon": "fa fa-twitter", "url": "#" },
{ "icon": "fa fa-linkedin", "url": "#" },
{ "icon": "fa fa-pinterest", "url": "#" }
]
},
{
"name": "Johnathan Franco",
"designation": "Project Manager",
"image": "images/speakers/speaker-five.jpg",
"profileUrl": "#",
"social": [
{ "icon": "fa fa-facebook", "url": "#" },
{ "icon": "fa fa-twitter", "url": "#" },
{ "icon": "fa fa-linkedin", "url": "#" },
{ "icon": "fa fa-pinterest", "url": "#" }
]
},
{
"name": "Johnathan Franco",
"designation": "Project Manager",
"image": "images/speakers/speaker-six.jpg",
"profileUrl": "#",
"social": [
{ "icon": "fa fa-facebook", "url": "#" },
{ "icon": "fa fa-twitter", "url": "#" },
{ "icon": "fa fa-linkedin", "url": "#" },
{ "icon": "fa fa-pinterest", "url": "#" }
]
},
{
"name": "Johnathan Franco",
"designation": "Project Manager",
"image": "images/speakers/speaker-five.jpg",
"profileUrl": "#",
"social": [
{ "icon": "fa fa-facebook", "url": "#" },
{ "icon": "fa fa-twitter", "url": "#" },
{ "icon": "fa fa-linkedin", "url": "#" },
{ "icon": "fa fa-pinterest", "url": "#" }
]
},
{
"name": "Johnathan Franco",
"designation": "Project Manager",
"image": "images/speakers/speaker-six.jpg",
"profileUrl": "#",
"social": [
{ "icon": "fa fa-facebook", "url": "#" },
{ "icon": "fa fa-twitter", "url": "#" },
{ "icon": "fa fa-linkedin", "url": "#" },
{ "icon": "fa fa-pinterest", "url": "#" }
]
}
]
},
"schedule": {
"show": true,
"title": "Event",
@ -291,310 +339,296 @@
]
},
"registration": {
"show": true,
"services": [
{
"icon": "fa fa-microphone",
"text": "8 Speakers"
},
{
"icon": "fa fa-flag",
"text": "500 + Seats"
},
{
"icon": "fa fa-ticket",
"text": "300 tickets"
},
{
"icon": "fa fa-calendar",
"text": "3 days event"
}
],
"title": "Get Register to",
"highlightTitle": "Eventre",
"description": "Demo description ",
"namePlaceholder": "Your Name",
"emailPlaceholder": "Email",
"phonePlaceholder": "Phone",
"ticketPlaceholder": "Ticket Type",
"ticketOptions": [
{
"value": "1",
"label": "1"
},
{
"value": "2",
"label": "2"
},
{
"value": "3",
"label": "3"
},
{
"value": "4",
"label": "4"
},
{
"value": "5",
"label": "5"
}
],
"buttonText": "Register Here"
"show": true,
"services": [
{
"icon": "fa fa-microphone",
"text": "8 Speakers"
},
{
"icon": "fa fa-flag",
"text": "500 + Seats"
},
{
"icon": "fa fa-ticket",
"text": "300 Tickets"
},
{
"icon": "fa fa-calendar",
"text": "3 Days Event"
}
],
"title": "Register to",
"highlightTitle": "Eventre",
"description": "Lorem ipsum dolor sit amet, consectetur adipisicing elit",
"showName": true,
"namePlaceholder": "Your Name",
"showEmail": true,
"emailPlaceholder": "Email",
"showPhone": true,
"phonePlaceholder": "Phone",
"showTicket": true,
"ticketPlaceholder": "Ticket Type",
"ticketOptions": [
{
"value": "1",
"label": "1"
},
{
"value": "2",
"label": "2"
},
{
"value": "3",
"label": "3"
},
{
"value": "4",
"label": "4"
},
{
"value": "5",
"label": "5"
}
],
"showButton": true,
"buttonText": "Register Now",
"buttonUrl": "#"
},
"pricing": {
"show": true,
"title": "Get",
"highlightTitle": "ticket",
"description": "Lorem ipsum dolor sit amet consectetur adipisicing elit sed do eiusm tempor incididunt ut labore",
"plans": [
{
"title": "Starter",
"price": "39.00",
"currency": "$",
"priceLabel": "/Person",
"featured": false,
"center": false,
"buttonText": "Buy a ticket",
"buttonUrl": "#",
"features": [
{
"text": "1 Comfortable Seats",
"available": true
},
{
"text": "Free Lunch and Coffee",
"available": true
},
{
"text": "Certificate",
"available": false
},
{
"text": "Easy Access",
"available": false
}
]
},
{
"title": "Standard",
"price": "49.00",
"currency": "$",
"priceLabel": "/Person",
"featured": true,
"center": false,
"buttonText": "Buy a ticket",
"buttonUrl": "#",
"features": [
{
"text": "1 Comfortable Seats",
"available": true
},
{
"text": "Free Lunch and Coffee",
"available": true
},
{
"text": "Certificate",
"available": true
},
{
"text": "Easy Access",
"available": false
}
]
},
{
"title": "Platinum",
"price": "99.00",
"currency": "$",
"priceLabel": "/Person",
"featured": false,
"center": true,
"buttonText": "Buy a ticket",
"buttonUrl": "#",
"features": [
{
"text": "1 Comfortable Seats",
"available": true
},
{
"text": "Free Lunch and Coffee",
"available": true
},
{
"text": "Certificate",
"available": true
},
{
"text": "Easy Access",
"available": true
}
]
}
]
"show": true,
"title": "Get",
"highlightTitle": "ticket",
"description": "Lorem ipsum dolor sit amet consectetur adipisicing elit sed do eiusm tempor incididunt ut labore",
"plans": [
{
"title": "Starter",
"price": "39.00",
"currency": "$",
"showButton": true,
"buttonText": "Buy a Ticket",
"buttonUrl": "#",
"featured": false,
"features": [
{ "text": "1 Comfortable Seat" },
{ "text": "Free Lunch and Coffee" },
{ "text": "Certificate" },
{ "text": "Easy Access" }
]
},
{
"title": "Standard",
"price": "49.00",
"currency": "$",
"showButton": true,
"buttonText": "Buy a Ticket",
"buttonUrl": "#",
"featured": true,
"features": [
{ "text": "1 Comfortable Seat" },
{ "text": "Free Lunch and Coffee" },
{ "text": "Certificate" },
{ "text": "Easy Access" }
]
},
{
"title": "Platinum",
"price": "99.00",
"currency": "$",
"showButton": true,
"buttonText": "Buy a Ticket",
"buttonUrl": "#",
"featured": false,
"features": [
{ "text": "1 Comfortable Seat" },
{ "text": "Free Lunch and Coffee" },
{ "text": "Certificate" },
{ "text": "Easy Access" }
]
}
]
},
"ctaTicket": {
"show": true,
"title": "Get Ticket",
"highlightTitle": "Now!",
"description": "Lorem ipsum dolor sit amet consectetur adipisicing elit",
"buttonText": "Buy ticket",
"buttonUrl": "#",
"image": "images/speakers/speaker-ticket.png",
"imageAlt": "Ticket Speaker"
},
"sponsors": {
"show": true,
"title": "Our",
"highlightTitle": "Sponsors",
"description": "Lorem ipsum dolor sit amet consectetur adipisicing elit sed do eiusm tempor incididunt ut labore dolore",
"groups": [
{
"title": "Platinum Sponsors",
"sponsors": [
{
"image": "images/sponsors/pt-spon-two.png",
"alt": "sponsors-logo",
"url": "#"
},
{
"image": "images/sponsors/pt-spon-two.png",
"alt": "sponsors-logo",
"url": "#"
},
{
"image": "images/sponsors/pt-spon-three.png",
"alt": "sponsors-logo",
"url": "#"
},
{
"image": "images/sponsors/pt-spon-four.png",
"alt": "sponsors-logo",
"url": "#"
}
]
},
{
"title": "Gold Sponsors",
"sponsors": [
{
"image": "images/sponsors/gl-spon-one.png",
"alt": "sponsors-logo",
"url": "#"
},
{
"image": "images/sponsors/gl-spon-two.png",
"alt": "sponsors-logo",
"url": "#"
},
{
"image": "images/sponsors/gl-spon-three.png",
"alt": "sponsors-logo",
"url": "#"
}
]
}
],
"buttonText": "Become a sponsor",
"buttonUrl": "#"
"show": true,
"title": "Get Ticket",
"highlightTitle": "Now!",
"description": "Lorem ipsum dolor sit amet consectetur adipisicing elit sed do eiusmtempor incidi",
"showButton": true,
"buttonText": "Buy Ticket",
"buttonUrl": "#",
"image": "images/speakers/speaker-ticket.png",
"imageAlt": "Ticket Speaker"
},
"ponsors": {
"show": true,
"title": "Our",
"highlightTitle": "Sponsors",
"description": "Lorem ipsum dolor sit amet consectetur adipisicing elit sed do eiusm tempor incididunt ut labore dolore",
"groups": [
{
"title": "Platinum Sponsors",
"sponsors": [
{
"image": "images/sponsors/pt-spon-one.png",
"alt": "Sponsor Logo",
"url": "#"
},
{
"image": "images/sponsors/pt-spon-two.png",
"alt": "Sponsor Logo",
"url": "#"
},
{
"image": "images/sponsors/pt-spon-three.png",
"alt": "Sponsor Logo",
"url": "#"
},
{
"image": "images/sponsors/pt-spon-four.png",
"alt": "Sponsor Logo",
"url": "#"
}
]
},
{
"title": "Gold Sponsors",
"sponsors": [
{
"image": "images/sponsors/gl-spon-one.png",
"alt": "Sponsor Logo",
"url": "#"
},
{
"image": "images/sponsors/gl-spon-two.png",
"alt": "Sponsor Logo",
"url": "#"
},
{
"image": "images/sponsors/gl-spon-three.png",
"alt": "Sponsor Logo",
"url": "#"
}
]
}
],
"showButton": true,
"buttonText": "Become a Sponsor",
"buttonUrl": "#"
},
"news": {
"show": true,
"title": "Eventre",
"highlightTitle": "News",
"description": "Lorem ipsum dolor sit amet consectetur adipisicing elit sed do eiusm tempor incididunt ut labore dolore",
"posts": [
{
"image": "images/news/post-thumb-one.jpg",
"imageAlt": "post-image",
"day": "20",
"month": "May",
"title": "Elementum purus id ultrices.",
"postUrl": "#",
"author": "Admin",
"authorUrl": "#",
"likes": "350",
"likesUrl": "#",
"comments": "30",
"commentsUrl": "#",
"columnClass": "col-lg-4 col-md-6 col-sm-8 col-10 m-auto"
},
{
"image": "images/news/post-thumb-two.jpg",
"imageAlt": "post-image",
"day": "20",
"month": "May",
"title": "Elementum purus id ultrices.",
"postUrl": "#",
"author": "Admin",
"authorUrl": "#",
"likes": "350",
"likesUrl": "#",
"comments": "30",
"commentsUrl": "#",
"columnClass": "col-lg-4 col-md-6 col-sm-8 col-10 m-auto"
},
{
"image": "images/news/post-thumb-three.jpg",
"imageAlt": "post-image",
"day": "20",
"month": "May",
"title": "Elementum purus id ultrices.",
"postUrl": "#",
"author": "Admin",
"authorUrl": "#",
"likes": "350",
"likesUrl": "#",
"comments": "30",
"commentsUrl": "#",
"columnClass": "col-lg-4 col-md-6 m-md-auto col-sm-8 col-10 m-auto"
}
]
"show": true,
"title": "Eventre",
"highlightTitle": "News",
"description": "Lorem ipsum dolor sit amet consectetur adipisicing elit sed do eiusm tempor incididunt ut labore dolore",
"posts": [
{
"image": "images/news/post-thumb-one.jpg",
"imageAlt": "Post Image",
"day": "20",
"month": "May",
"title": "Elementum purus id ultrices.",
"postUrl": "#",
"author": "Admin",
"authorUrl": "#",
"likes": "350",
"likesUrl": "#",
"comments": "30",
"commentsUrl": "#"
},
{
"image": "images/news/post-thumb-two.jpg",
"imageAlt": "Post Image",
"day": "20",
"month": "May",
"title": "Elementum purus id ultrices.",
"postUrl": "#",
"author": "Admin",
"authorUrl": "#",
"likes": "350",
"likesUrl": "#",
"comments": "30",
"commentsUrl": "#"
},
{
"image": "images/news/post-thumb-three.jpg",
"imageAlt": "Post Image",
"day": "20",
"month": "May",
"title": "Elementum purus id ultrices.",
"postUrl": "#",
"author": "Admin",
"authorUrl": "#",
"likes": "350",
"likesUrl": "#",
"comments": "30",
"commentsUrl": "#"
}
]
},
"subscribe": {
"show": true,
"title": "Subscribe to Our",
"highlightTitle": "Newsletter",
"description": "Lorem ipsum dolor sit amet consectetur adipisicing elit sed do eiusm tempor",
"emailPlaceholder": "Email",
"buttonText": "Subscribe"
},
"show": true,
"title": "Subscribe to Our",
"highlightTitle": "Newsletter",
"description": "Lorem ipsum dolor sit amet consectetur adipisicing elit sed do eiusm tempor",
"emailPlaceholder": "Email",
"showButton": true,
"buttonText": "Subscribe"
},
"map": {
"show": true,
"embedUrl": "https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d199267.57537573267!2d73.7098078914174!3d18.503508757047037!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x3bc2bf2e67461101%3A0x828d43bf9d9ee343!2sPune%2C%20Maharashtra!5e1!3m2!1sen!2sin!4v1784028866733!5m2!1sen!2sin",
"venueName": "Docklands Convention",
"address": "1201 Park Street, Fifth Avenue, Dhanmondy, Dhaka.",
"phone": "[88] 657 524 332",
"buttonText": "Get Direction",
"buttonUrl": "#"
},
"show": true,
"embedUrl": "https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d199267.57537573267!2d73.7098078914174!3d18.503508757047037!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x3bc2bf2e67461101%3A0x828d43bf9d9ee343!2sPune%2C%20Maharashtra!5e1!3m2!1sen!2sin!4v1784028866733!5m2!1sen!2sin",
"venueName": "Docklands Convention",
"address": "1201 Park Street, Fifth Avenue, Dhanmondy, Dhaka.",
"phone": "[88] 657 524 332",
"showButton": true,
"buttonText": "Get Direction",
"buttonUrl": "#"
},
"footer": {
"show": true,
"logo": "images/footer-logo.png",
"logoAlt": "logo",
"socialLinks": [
{
"icon": "fa fa-facebook",
"url": "#"
},
{
"icon": "fa fa-twitter",
"url": "#"
},
{
"icon": "fa fa-instagram",
"url": "#"
},
{
"icon": "fa fa-rss",
"url": "#"
},
{
"icon": "fa fa-vimeo",
"url": "#"
}
]
"show": true,
"logo": "images/footer-logo.png",
"logoAlt": "logo",
"socialLinks": [
{
"icon": "fa fa-facebook",
"url": "#"
},
{
"icon": "fa fa-twitter",
"url": "#"
},
{
"icon": "fa fa-instagram",
"url": "#"
},
{
"icon": "fa fa-rss",
"url": "#"
},
{
"icon": "fa fa-vimeo",
"url": "#"
}
]
},
"subfooter": {
"show": true,
"brandName": "Eventre -",

View File

@ -7,8 +7,8 @@
--secondary: {{sections.theme.secondaryColor}};
--heading: {{sections.theme.textColor}};
--body: {{sections.theme.bodyTextColor}};
--sectionBackground: {{sections.theme.sectionBackground}}
--backgroundColor : {{sections.theme.backgroundColor}}
--sectionBackground: {{sections.theme.sectionBackground}};
--backgroundColor : {{sections.theme.backgroundColor}};
--btn-bg: {{sections.theme.buttonBackground}};
--btn-text: {{sections.theme.buttonText}};
@ -65,12 +65,13 @@
<div class="container-fluid p-0">
<!-- Logo -->
<a class="navbar-brand" href="{{sections.navbar.homeUrl}}">
{{#if sections.navbar.showLogo}}
<a class="navbar-brand" href="{{sections.navbar.logoUrl}}">
<img
src="{{resolveAsset assetsBaseUrl sections.navbar.logo}}"
alt="{{sections.navbar.logoAlt}}"
>
alt="{{sections.navbar.logoAlt}}">
</a>
{{/if}}
<!-- Mobile Toggle -->
<button
@ -80,8 +81,7 @@
data-target="#navbarNav"
aria-controls="navbarNav"
aria-expanded="false"
aria-label="Toggle navigation"
>
aria-label="Toggle navigation">
<span class="fa fa-bars"></span>
</button>
@ -89,85 +89,96 @@
<ul class="navbar-nav mx-auto">
<!-- Home -->
<li class="nav-item active">
<a class="nav-link" href="{{sections.navbar.homeUrl}}">
{{sections.navbar.homeLabel}}
</a>
</li>
{{#each sections.navbar.navigation}}
<!-- About Us -->
{{#if_show sections.about}}
<li class="nav-item">
<a class="nav-link" href="{{sections.navbar.aboutUrl}}">
{{sections.navbar.aboutLabel}}
</a>
</li>
{{/if_show}}
{{#if (eq this.url "#")}}
<li class="nav-item">
<a class="nav-link" href="{{this.url}}">
{{this.text}}
</a>
</li>
<!-- Speakers -->
{{#if_show sections.speakers}}
<li class="nav-item">
<a class="nav-link" href="{{sections.navbar.speakersUrl}}">
{{sections.navbar.speakersLabel}}
</a>
</li>
{{/if_show}}
{{else if (and (eq this.url "#about") @root.sections.about.show)}}
<li class="nav-item">
<a class="nav-link" href="{{this.url}}">
{{this.text}}
</a>
</li>
<!-- Schedule -->
{{#if_show sections.schedule}}
<li class="nav-item">
<a class="nav-link" href="{{sections.navbar.scheduleUrl}}">
{{sections.navbar.scheduleLabel}}
</a>
</li>
{{/if_show}}
{{else if (and (eq this.url "#speakers") @root.sections.speakers.show)}}
<li class="nav-item">
<a class="nav-link" href="{{this.url}}">
{{this.text}}
</a>
</li>
<!-- Contact -->
{{#if_show sections.registration}}
<li class="nav-item">
<a class="nav-link" href="{{sections.navbar.contactUrl}}">
{{sections.navbar.contactLabel}}
</a>
</li>
{{/if_show}}
{{else if (and (eq this.url "#schedule") @root.sections.schedule.show)}}
<li class="nav-item">
<a class="nav-link" href="{{this.url}}">
{{this.text}}
</a>
</li>
<!-- Sponsors -->
{{#if_show sections.sponsors}}
<li class="nav-item">
<a class="nav-link" href="{{sections.navbar.sponsorsUrl}}">
{{sections.navbar.sponsorsLabel}}
</a>
</li>
{{/if_show}}
{{else if (and (eq this.url "#registration") @root.sections.registration.show)}}
<li class="nav-item">
<a class="nav-link" href="{{this.url}}">
{{this.text}}
</a>
</li>
<!-- News -->
{{#if_show sections.news}}
<li class="nav-item">
<a class="nav-link" href="{{sections.navbar.newsUrl}}">
{{sections.navbar.newsLabel}}
</a>
</li>
{{/if_show}}
{{else if (and (eq this.url "#pricing") @root.sections.pricing.show)}}
<li class="nav-item">
<a class="nav-link" href="{{this.url}}">
{{this.text}}
</a>
</li>
</ul>
{{else if (and (eq this.url "#sponsors") @root.sections.sponsors.show)}}
<li class="nav-item">
<a class="nav-link" href="{{this.url}}">
{{this.text}}
</a>
</li>
<!-- Buy Ticket -->
{{else if (and (eq this.url "#news") @root.sections.news.show)}}
<li class="nav-item">
<a class="nav-link" href="{{this.url}}">
{{this.text}}
</a>
</li>
{{else if (and (eq this.url "#subscribe") @root.sections.subscribe.show)}}
<li class="nav-item">
<a class="nav-link" href="{{this.url}}">
{{this.text}}
</a>
</li>
{{else if (and (eq this.url "#map") @root.sections.map.show)}}
<li class="nav-item">
<a class="nav-link" href="{{this.url}}">
{{this.text}}
</a>
</li>
{{/if}}
{{/each}}
</ul>
{{#if sections.navbar.showTicketButton}}
<a href="{{sections.navbar.ticketUrl}}" class="ticket">
<img
src="{{resolveAsset assetsBaseUrl sections.navbar.ticketIcon}}"
alt="ticket"
>
<span>{{sections.navbar.ticketText}}</span>
</a>
{{/if}}
</div>
</div>
</nav>
@ -193,7 +204,7 @@
<div class="col-lg-12">
<div class="block">
<div class="timer"></div>
<div class="timer" data-date="{{sections.banner.eventDate}}"></div>
<h1>{{sections.banner.title}}</h1>
@ -202,16 +213,18 @@
<h6>
{{sections.banner.eventDate}}
{{#if sections.banner.eventLocation}}
&nbsp;|&nbsp;{{sections.banner.eventLocation}}
&nbsp;&nbsp;{{sections.banner.eventLocation}}
{{/if}}
</h6>
<a
href="{{sections.banner.buttonUrl}}"
class="btn btn-white-md"
>
{{sections.banner.buttonText}}
</a>
{{#if sections.banner.showButton}}
<a
href="{{sections.banner.buttonUrl}}"
class="btn btn-white-md"
>
{{sections.banner.buttonText}}
</a>
{{/if}}
</div>
</div>
@ -265,24 +278,30 @@
</div>
<ul class="list-inline">
<li class="list-inline-item">
<a
href="{{sections.about.primaryButtonUrl}}"
class="btn btn-main-md"
>
{{sections.about.primaryButtonText}}
</a>
</li>
<li class="list-inline-item">
<a
href="{{sections.about.secondaryButtonUrl}}"
class="btn btn-transparent-md"
>
{{sections.about.secondaryButtonText}}
</a>
</li>
</ul>
{{#if sections.about.showPrimaryButton}}
<li class="list-inline-item">
<a
href="{{sections.about.primaryButtonUrl}}"
class="btn btn-main-md"
>
{{sections.about.primaryButtonText}}
</a>
</li>
{{/if}}
{{#if sections.about.showSecondaryButton}}
<li class="list-inline-item">
<a
href="{{sections.about.secondaryButtonUrl}}"
class="btn btn-transparent-md"
>
{{sections.about.secondaryButtonText}}
</a>
</li>
{{/if}}
</ul>
</div>
</div>
@ -503,25 +522,25 @@
<div class="container-fuild p-0">
<div class="row">
<!-- Left Services -->
<div class="col-lg-6 col-md-12 p-0">
<div class="service-block bg-service overlay-primary text-center">
<div class="row no-gutters">
{{#each sections.registration.services}}
<div class="col-6">
<div class="service-item">
<i class="{{icon}}"></i>
<h5>{{text}}</h5>
</div>
</div>
{{/each}}
</div>
</div>
</div>
<!-- Right Registration Form -->
<div class="col-lg-6 col-md-12 p-0">
<div class="registration-block bg-registration overlay-dark">
<div class="block">
@ -539,6 +558,8 @@
<form action="#" class="row">
<!-- Name -->
{{#if sections.registration.showName}}
<div class="col-md-6">
<input
type="text"
@ -546,7 +567,10 @@
placeholder="{{sections.registration.namePlaceholder}}"
>
</div>
{{/if}}
<!-- Email -->
{{#if sections.registration.showEmail}}
<div class="col-md-6">
<input
type="email"
@ -554,7 +578,10 @@
placeholder="{{sections.registration.emailPlaceholder}}"
>
</div>
{{/if}}
<!-- Phone -->
{{#if sections.registration.showPhone}}
<div class="col-md-6">
<input
type="text"
@ -562,39 +589,39 @@
placeholder="{{sections.registration.phonePlaceholder}}"
>
</div>
{{/if}}
<!-- Ticket Type -->
{{#if sections.registration.showTicket}}
<div class="col-md-6">
<div class="form-group">
<select
class="form-control main"
id="select-ticket"
>
<select class="form-control main" id="select-ticket">
<option value="">
{{sections.registration.ticketPlaceholder}}
</option>
{{#each sections.registration.ticketOptions}}
<option value="{{value}}">
{{label}}
</option>
{{/each}}
</select>
</div>
</div>
{{/if}}
<!-- Button -->
{{#if sections.registration.showButton}}
<div class="col-12">
<button
type="submit"
<a
href="{{sections.registration.buttonUrl}}"
class="btn btn-white-md"
>
{{sections.registration.buttonText}}
</button>
</a>
</div>
{{/if}}
</form>
@ -616,7 +643,7 @@
{{#if_show sections.pricing}}
<section class="section pricing">
<section class="section pricing" id="pricing">
<div class="container">
<div class="row">
@ -651,9 +678,9 @@
</div>
<div class="price">
<h2>
{{price}}<span>{{currency}}</span>
</h2>
<h2>
{{currency}}{{price}}
</h2>
<p>{{priceLabel}}</p>
</div>
@ -668,17 +695,7 @@
<li>
<p>
{{#if available}}
<span class="fa fa-check-circle available"></span>
{{else}}
<span class="fa fa-times-circle unavailable"></span>
{{/if}}
{{text}}
</p>
@ -692,12 +709,14 @@
<div class="pricing-footer text-center">
{{#if showButton}}
<a
href="{{buttonUrl}}"
class="btn {{#if featured}}btn-main-md{{else}}btn-transparent-md{{/if}}"
>
{{buttonText}}
</a>
{{/if}}
</div>
@ -738,12 +757,14 @@
<p>{{sections.ctaTicket.description}}</p>
<a
href="{{sections.ctaTicket.buttonUrl}}"
class="btn btn-main-md"
>
{{sections.ctaTicket.buttonText}}
</a>
{{#if sections.ctaTicket.showButton}}
<a
href="{{sections.ctaTicket.buttonUrl}}"
class="btn btn-main-md"
>
{{sections.ctaTicket.buttonText}}
</a>
{{/if}}
</div>
</div>
@ -826,14 +847,16 @@
{{/each}}
<div class="sponsor-btn text-center">
<a
href="{{sections.sponsors.buttonUrl}}"
class="btn btn-main-md"
>
{{sections.sponsors.buttonText}}
</a>
</div>
{{#if sections.sponsors.showButton}}
<div class="sponsor-btn text-center">
<a
href="{{sections.sponsors.buttonUrl}}"
class="btn btn-main-md"
>
{{sections.sponsors.buttonText}}
</a>
</div>
{{/if}}
</div>
</div>
@ -983,16 +1006,18 @@
</div>
<div class="col-lg-4 col-md-12">
<div class="subscribe-button">
{{#if sections.subscribe.showButton}}
<div class="subscribe-button">
<button
type="submit"
class="btn btn-main-md"
>
{{sections.subscribe.buttonText}}
</button>
<button
type="submit"
class="btn btn-main-md"
>
{{sections.subscribe.buttonText}}
</button>
</div>
</div>
{{/if}}
</div>
</form>
@ -1042,12 +1067,14 @@
</ul>
<a
href="{{sections.map.buttonUrl}}"
class="btn btn-white-md"
>
{{sections.map.buttonText}}
</a>
{{#if sections.map.showButton}}
<a
href="{{sections.map.buttonUrl}}"
class="btn btn-white-md"
>
{{sections.map.buttonText}}
</a>
{{/if}}
</div>

View File

@ -67,124 +67,128 @@
}
]
},
{
{
"id": "navbar",
"label": "Navbar",
"show": true,
"fields": [
{
"key": "showLogo",
"label": "Show Logo",
"type": "boolean",
"default": true
},
{
"key": "logo",
"label": "Logo",
"type": "image",
"default": "images/logo.png"
"default": "images/logo.png",
"show_if": {
"showLogo": true
}
},
{
"key": "logoAlt",
"label": "Logo Alt Text",
"type": "text",
"default": "logo"
"default": "logo",
"show_if": {
"showLogo": true
}
},
{
"key": "homeLabel",
"label": "Home Label",
"key": "logoUrl",
"label": "Logo URL",
"type": "text",
"default": "Home"
"default": "#home",
"show_if": {
"showLogo": true
}
},
{
"key": "homeUrl",
"label": "Home URL",
"key": "navigation",
"type": "array",
"label": "Navigation Links",
"itemFields": [
{
"key": "text",
"type": "text",
"default": "#home"
"label": "Link Text"
},
{
"key": "aboutLabel",
"label": "About Label",
"key": "url",
"type": "text",
"default": "About Us"
"label": "Link URL"
}
],
"default": [
{
"text": "Home",
"url": "#"
},
{
"key": "aboutUrl",
"label": "About URL",
"type": "text",
"default": "#about"
"text": "About",
"url": "#about"
},
{
"key": "speakersLabel",
"label": "Speakers Label",
"type": "text",
"default": "Speakers"
"text": "Speakers",
"url": "#speakers"
},
{
"key": "speakersUrl",
"label": "Speakers URL",
"type": "text",
"default": "#speakers"
"text": "Schedule",
"url": "#schedule"
},
{
"key": "scheduleLabel",
"label": "Schedule Label",
"type": "text",
"default": "Schedule"
"text": "Registration",
"url": "#registration"
},
{
"key": "scheduleUrl",
"label": "Schedule URL",
"type": "text",
"default": "#schedule"
"text": "Pricing",
"url": "#pricing"
},
{
"key": "contactLabel",
"label": "Contact Label",
"type": "text",
"default": "Contact"
"text": "Sponsors",
"url": "#sponsors"
},
{
"key": "contactUrl",
"label": "Contact URL",
"type": "text",
"default": "#registration"
"text": "News",
"url": "#news"
},
{
"key": "sponsorsLabel",
"label": "Sponsors Label",
"type": "text",
"default": "Sponsors"
"text": "Subscribe",
"url": "#subscribe"
},
{
"key": "sponsorsUrl",
"label": "Sponsors URL",
"type": "text",
"default": "#sponsors"
},
"text": "Map",
"url": "#map"
}
]
},
{
"key": "newsLabel",
"label": "News Label",
"type": "text",
"default": "News"
},
{
"key": "newsUrl",
"label": "News URL",
"type": "text",
"default": "#news"
},
{
"key": "ticketIcon",
"label": "Ticket Icon",
"type": "image",
"default": "images/icon/ticket.png"
"key": "showTicketButton",
"label": "Show Ticket Button",
"type": "boolean",
"default": true
},
{
"key": "ticketText",
"label": "Ticket Text",
"type": "text",
"default": "Buy Ticket"
"default": "Buy Ticket",
"show_if": {
"showTicketButton": true
}
},
{
"key": "ticketUrl",
"label": "Ticket URL",
"type": "text",
"default": "#"
"default": "#",
"show_if": {
"showTicketButton": true
}
}
]
},
@ -223,17 +227,29 @@
"default": "California"
},
{
"key": "buttonText",
"label": "Button Text",
"type": "text",
"default": "get ticket now"
},
{
"key": "buttonUrl",
"label": "Button URL",
"type": "text",
"default": "#"
}
"key": "showButton",
"label": "Show Button",
"type": "boolean",
"default": true
},
{
"key": "buttonText",
"label": "Button Text",
"type": "text",
"default": "Get Ticket Now",
"show_if": {
"showButton": true
}
},
{
"key": "buttonUrl",
"label": "Button URL",
"type": "text",
"default": "#",
"show_if": {
"showButton": true
}
}
]
},
{
@ -278,29 +294,53 @@
"default": "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmtempor incididunt ut labore et dolore magna aliq enim ad minim veniam quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea."
},
{
"key": "primaryButtonText",
"label": "Primary Button Text",
"type": "text",
"default": "Buy ticket"
},
{
"key": "primaryButtonUrl",
"label": "Primary Button URL",
"type": "text",
"default": "#"
},
{
"key": "secondaryButtonText",
"label": "Secondary Button Text",
"type": "text",
"default": "Read more"
},
{
"key": "secondaryButtonUrl",
"label": "Secondary Button URL",
"type": "text",
"default": "#"
}
"key": "showPrimaryButton",
"label": "Show Primary Button",
"type": "boolean",
"default": true
},
{
"key": "primaryButtonText",
"label": "Primary Button Text",
"type": "text",
"default": "Buy Ticket",
"show_if": {
"showPrimaryButton": true
}
},
{
"key": "primaryButtonUrl",
"label": "Primary Button URL",
"type": "text",
"default": "#",
"show_if": {
"showPrimaryButton": true
}
},
{
"key": "showSecondaryButton",
"label": "Show Secondary Button",
"type": "boolean",
"default": true
},
{
"key": "secondaryButtonText",
"label": "Secondary Button Text",
"type": "text",
"default": "Read More",
"show_if": {
"showSecondaryButton": true
}
},
{
"key": "secondaryButtonUrl",
"label": "Secondary Button URL",
"type": "text",
"default": "#",
"show_if": {
"showSecondaryButton": true
}
}
]
},
{
@ -460,7 +500,7 @@
{
"key": "id",
"type": "text",
"label": "Day ID",
"label": "Day ID (ex: Event date is 20th November then ID: nov20)",
"default": "day1"
},
{
@ -616,74 +656,54 @@
"default": "Lorem ipsum dolor sit amet, consectetur adipisicing elit"
},
{
"key": "namePlaceholder",
"label": "Name Placeholder",
"type": "text",
"default": "Your Name"
},
"key": "showName",
"label": "Show Name Field",
"type": "boolean",
"default": true
},
{
"key": "showEmail",
"label": "Show Email Field",
"type": "boolean",
"default": true
},
{
"key": "showPhone",
"label": "Show Phone Field",
"type": "boolean",
"default": true
},
{
"key": "showTicket",
"label": "Show Ticket Field",
"type": "boolean",
"default": true
},
{
"key": "showButton",
"label": "Show Register Button",
"type": "boolean",
"default": true
},
{
"key": "emailPlaceholder",
"label": "Email Placeholder",
"type": "text",
"default": "Email"
},
{
"key": "phonePlaceholder",
"label": "Phone Placeholder",
"type": "text",
"default": "Phone"
},
{
"key": "ticketPlaceholder",
"label": "Ticket Placeholder",
"type": "text",
"default": "Ticket Type"
},
{
"key": "ticketOptions",
"label": "Ticket Options",
"type": "array",
"itemFields": [
{
"key": "value",
"type": "text",
"label": "Value"
},
{
"key": "label",
"type": "text",
"label": "Label"
}
],
"default": [
{
"value": "1",
"label": "1"
},
{
"value": "2",
"label": "2"
},
{
"value": "3",
"label": "3"
},
{
"value": "4",
"label": "4"
},
{
"value": "5",
"label": "5"
}
]
},
{
"key": "buttonText",
"label": "Button Text",
"type": "text",
"default": "Register Now"
}
"key": "buttonText",
"label": "Button Text",
"type": "text",
"default": "Register Now",
"show_if": {
"showButton": true
}
},
{
"key": "buttonUrl",
"label": "Button URL",
"type": "text",
"default": "#",
"show_if": {
"showButton": true
}
}
]
},
{
@ -724,6 +744,28 @@
"type": "text",
"label": "Price"
},
{
"key": "currency",
"type": "select",
"label": "Currency",
"default": "$",
"options": [
{
"label": "$ (USD)",
"value": "$"
},
{
"label": "₹ (INR)",
"value": "₹"
}
]
},
{
"key": "showButton",
"type": "boolean",
"label": "Show Button",
"default": true
},
{
"key": "buttonText",
"type": "text",
@ -737,10 +779,11 @@
"default": "#"
},
{
"key": "featured",
"type": "checkbox",
"label": "Featured Plan (true or false)"
},
"key": "featured",
"type": "boolean",
"label": "Featured Plan",
"default": false
},
{
"key": "features",
"label": "Features",
@ -822,17 +865,29 @@
"default": "Lorem ipsum dolor sit amet consectetur adipisicing elit sed do eiusmtempor incidi"
},
{
"key": "buttonText",
"label": "Button Text",
"type": "text",
"default": "Buy ticket"
},
{
"key": "buttonUrl",
"label": "Button URL",
"type": "text",
"default": "#"
},
"key": "showButton",
"label": "Show Button",
"type": "boolean",
"default": true
},
{
"key": "buttonText",
"label": "Button Text",
"type": "text",
"default": "Buy Ticket",
"show_if": {
"showButton": true
}
},
{
"key": "buttonUrl",
"label": "Button URL",
"type": "text",
"default": "#",
"show_if": {
"showButton": true
}
},
{
"key": "image",
"label": "Speaker Image (Transparent)",
@ -953,18 +1008,30 @@
}
]
},
{
"key": "buttonText",
"label": "Button Text",
"type": "text",
"default": "Become a sponsor"
},
{
"key": "buttonUrl",
"label": "Button URL",
"type": "text",
"default": "#"
}
{
"key": "showButton",
"label": "Show Button",
"type": "boolean",
"default": true
},
{
"key": "buttonText",
"label": "Button Text",
"type": "text",
"default": "Become a Sponsor",
"show_if": {
"showButton": true
}
},
{
"key": "buttonUrl",
"label": "Button URL",
"type": "text",
"default": "#",
"show_if": {
"showButton": true
}
}
]
},
{
@ -1187,10 +1254,19 @@
"default": "Email"
},
{
"key": "buttonText",
"label": "Button Text",
"type": "text",
"default": "Subscribe"
"key": "showButton",
"label": "Show Button",
"type": "boolean",
"default": true
},
{
"key": "buttonText",
"label": "Button Text",
"type": "text",
"default": "Subscribe",
"show_if": {
"showButton": true
}
}
]
},
@ -1224,17 +1300,29 @@
"default": "[88] 657 524 332"
},
{
"key": "buttonText",
"label": "Button Text",
"type": "text",
"default": "Get Direction"
},
{
"key": "buttonUrl",
"label": "Button URL",
"type": "text",
"default": "#"
}
"key": "showButton",
"label": "Show Button",
"type": "boolean",
"default": true
},
{
"key": "buttonText",
"label": "Button Text",
"type": "text",
"default": "Get Direction",
"show_if": {
"showButton": true
}
},
{
"key": "buttonUrl",
"label": "Button URL",
"type": "text",
"default": "#",
"show_if": {
"showButton": true
}
}
]
},
{

View File

@ -0,0 +1,132 @@
/**
* Apply Designing detailed Dehradun Agenda.xlsx to the Dehradun microsite
* and dashboard agenda, keeping 19–20 September dates.
*
* Usage:
* node scripts/apply-dehradun-excel-agenda.js [--publish] [--xlsx=path]
*/
const path = require('path');
const fs = require('fs');
const { spawnSync } = require('child_process');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const mongoose = require('mongoose');
const { connectDB } = require('../src/config/database');
const Event = require('../src/models/Event');
const EventStaticMicrosite = require('../src/models/EventStaticMicrosite');
const User = require('../src/models/User');
const { publishStaticMicrosite } = require('../src/utils/publishStaticMicrosite');
const { importNovelScheduleToAgenda } = require('../src/utils/importNovelScheduleAgenda');
const TARGET_EVENT_ID = '6a50d3de64775fc391bd92bb';
const TARGET_SUBDOMAIN = 'novel2026-dehradun-19-20-sep';
const SUBTITLE = 'Scientific programme at Hyatt Regency, Dehradun.';
const DATE_KEYS = ['2026-09-19', '2026-09-20'];
const DEFAULT_XLSX = '/Volumes/Storage/Downloads/Designing detailed Dehradun Agenda.xlsx';
const PARSER = path.join(__dirname, 'parse-dehradun-excel-agenda.py');
function argValue(name, fallback) {
const hit = process.argv.find((a) => a.startsWith(`--${name}=`));
return hit ? hit.split('=').slice(1).join('=').trim() : fallback;
}
function deepClone(obj) {
return JSON.parse(JSON.stringify(obj));
}
async function findPublishUser() {
const platform = await User.findOne({ platform_admin: 'yes', isActive: { $ne: false } }).sort({ createdAt: 1 });
if (platform) return platform;
const superAdmin = await User.findOne({ role: 'super_admin' }).sort({ createdAt: 1 });
if (superAdmin) return superAdmin;
throw new Error('No user found for publish');
}
function loadDaysFromExcel(xlsxPath) {
if (!fs.existsSync(xlsxPath)) {
throw new Error(`Excel not found: ${xlsxPath}`);
}
const env = { ...process.env, DEHRADUN_AGENDA_XLSX: xlsxPath };
const result = spawnSync('python3', [PARSER], { encoding: 'utf8', env });
if (result.status !== 0) {
throw new Error(result.stderr || result.stdout || 'Failed to parse Dehradun Excel');
}
const parsed = JSON.parse(result.stdout);
if (!parsed?.days?.length) throw new Error('Excel parser returned no days');
return parsed.days;
}
function applyScheduleToConfig(config, days) {
const next = deepClone(config);
next.sections = next.sections || {};
next.sections.event_schedule = {
...(next.sections.event_schedule || {}),
show: true,
heading: next.sections.event_schedule?.heading || 'Event Schedule',
subtitle: SUBTITLE,
days,
};
return next;
}
function summarize(days) {
return (days || []).map((day) => ({
label: day.label,
date: day.date,
sessions: (day.sessions || []).length,
topics: (day.sessions || []).reduce((n, s) => n + ((s.topics || []).length), 0),
first: day.sessions?.[0]?.name,
}));
}
async function main() {
const shouldPublish = process.argv.includes('--publish');
const xlsxPath = argValue('xlsx', DEFAULT_XLSX);
const days = loadDaysFromExcel(xlsxPath);
await connectDB();
const targetEvent = await Event.findById(TARGET_EVENT_ID).lean();
if (!targetEvent) throw new Error(`Target event not found: ${TARGET_EVENT_ID}`);
const agenda = await importNovelScheduleToAgenda(TARGET_EVENT_ID, days, { dateKeys: DATE_KEYS });
console.log(`Agenda: deleted ${agenda.deleted}, created ${agenda.created} session(s)`);
const targetSite = await EventStaticMicrosite.findOne({
$or: [{ event: TARGET_EVENT_ID }, { subdomain: TARGET_SUBDOMAIN }],
});
if (!targetSite) throw new Error(`Dehradun microsite not found: ${TARGET_SUBDOMAIN}`);
targetSite.config = applyScheduleToConfig(targetSite.config, days);
targetSite.markModified('config');
const user = shouldPublish ? await findPublishUser() : null;
if (user) targetSite.updatedBy = user._id;
await targetSite.save();
console.log(`Event: ${targetEvent.name}`);
console.log(`Subdomain: ${targetSite.subdomain}`);
console.log(`Excel: ${xlsxPath}`);
console.log('Schedule:', JSON.stringify(summarize(days), null, 2));
if (shouldPublish) {
const populated = await EventStaticMicrosite.findById(targetSite._id).populate('template');
const result = await publishStaticMicrosite({
staticMicrosite: populated,
template: populated.template,
userId: user._id,
});
Object.assign(populated, result);
await populated.save();
console.log(`Published: ${result.publicUrl}`);
} else {
console.log('Saved as draft. Re-run with --publish to publish.');
}
await mongoose.disconnect();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@ -0,0 +1,116 @@
/**
* Apply the printed Dehradun scientific programme to the event agenda
* and microsite, then optionally publish.
*
* Usage:
* node scripts/apply-dehradun-printed-agenda.js [--publish]
*/
const path = require('path');
const fs = require('fs');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const mongoose = require('mongoose');
const { connectDB } = require('../src/config/database');
const Event = require('../src/models/Event');
const EventStaticMicrosite = require('../src/models/EventStaticMicrosite');
const User = require('../src/models/User');
const { publishStaticMicrosite } = require('../src/utils/publishStaticMicrosite');
const { importNovelScheduleToAgenda } = require('../src/utils/importNovelScheduleAgenda');
const TARGET_EVENT_ID = '6a50d3de64775fc391bd92bb';
const TARGET_SUBDOMAIN = 'novel2026-dehradun-19-20-sep';
const SUBTITLE = 'Scientific programme at Hyatt Regency, Dehradun.';
const DATE_KEYS = ['2026-09-19', '2026-09-20'];
const AGENDA_JSON = path.join(__dirname, 'data', 'dehradun-printed-agenda.json');
function deepClone(obj) {
return JSON.parse(JSON.stringify(obj));
}
async function findPublishUser() {
const platform = await User.findOne({ platform_admin: 'yes', isActive: { $ne: false } }).sort({ createdAt: 1 });
if (platform) return platform;
const superAdmin = await User.findOne({ role: 'super_admin' }).sort({ createdAt: 1 });
if (superAdmin) return superAdmin;
throw new Error('No user found for publish');
}
function loadDays() {
const parsed = JSON.parse(fs.readFileSync(AGENDA_JSON, 'utf8'));
if (!parsed?.days?.length) throw new Error('Printed agenda JSON has no days');
return parsed.days;
}
function applyScheduleToConfig(config, days) {
const next = deepClone(config);
next.sections = next.sections || {};
next.sections.event_schedule = {
...(next.sections.event_schedule || {}),
show: true,
heading: next.sections.event_schedule?.heading || 'Event Schedule',
subtitle: SUBTITLE,
days,
};
return next;
}
function summarize(days) {
return (days || []).map((day) => ({
label: day.label,
date: day.date,
sessions: (day.sessions || []).length,
topics: (day.sessions || []).reduce((n, s) => n + ((s.topics || []).length), 0),
first: day.sessions?.[0]?.name,
last: day.sessions?.[day.sessions.length - 1]?.name,
}));
}
async function main() {
const shouldPublish = process.argv.includes('--publish');
const days = loadDays();
await connectDB();
const targetEvent = await Event.findById(TARGET_EVENT_ID).lean();
if (!targetEvent) throw new Error(`Target event not found: ${TARGET_EVENT_ID}`);
const agenda = await importNovelScheduleToAgenda(TARGET_EVENT_ID, days, { dateKeys: DATE_KEYS });
console.log(`Agenda: deleted ${agenda.deleted}, created ${agenda.created} session(s)`);
const targetSite = await EventStaticMicrosite.findOne({
$or: [{ event: TARGET_EVENT_ID }, { subdomain: TARGET_SUBDOMAIN }],
});
if (!targetSite) throw new Error(`Dehradun microsite not found: ${TARGET_SUBDOMAIN}`);
targetSite.config = applyScheduleToConfig(targetSite.config, days);
targetSite.markModified('config');
const user = shouldPublish ? await findPublishUser() : null;
if (user) targetSite.updatedBy = user._id;
await targetSite.save();
console.log(`Event: ${targetEvent.name}`);
console.log(`Subdomain: ${targetSite.subdomain}`);
console.log('Schedule:', JSON.stringify(summarize(days), null, 2));
if (shouldPublish) {
const populated = await EventStaticMicrosite.findById(targetSite._id).populate('template');
const result = await publishStaticMicrosite({
staticMicrosite: populated,
template: populated.template,
userId: user._id,
});
Object.assign(populated, result);
await populated.save();
console.log(`Published: ${result.publicUrl}`);
} else {
console.log('Saved as draft. Re-run with --publish to publish.');
}
await mongoose.disconnect();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@ -0,0 +1,135 @@
/**
* Copy Kochi NOVEL microsite scientific programme onto Dehradun, retitle days, and publish.
*
* Usage:
* node scripts/copy-kochi-schedule-to-dehradun.js [--publish]
*/
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const mongoose = require('mongoose');
const { connectDB } = require('../src/config/database');
const Event = require('../src/models/Event');
const EventStaticMicrosite = require('../src/models/EventStaticMicrosite');
const User = require('../src/models/User');
const { publishStaticMicrosite } = require('../src/utils/publishStaticMicrosite');
const { importNovelScheduleToAgenda } = require('../src/utils/importNovelScheduleAgenda');
const SOURCE_SUBDOMAIN = 'novel2026-kochi-22-23-aug';
const TARGET_SUBDOMAIN = 'novel2026-dehradun-19-20-sep';
const TARGET_EVENT_ID = '6a50d3de64775fc391bd92bb';
const TARGET_SCHEDULE = {
subtitle: 'Scientific programme at Hyatt Regency, Dehradun.',
day1: 'September 19, 2026',
day2: 'September 20, 2026',
dateKeys: ['2026-09-19', '2026-09-20'],
};
function deepClone(obj) {
return JSON.parse(JSON.stringify(obj));
}
async function findPublishUser() {
const platform = await User.findOne({ platform_admin: 'yes', isActive: { $ne: false } }).sort({ createdAt: 1 });
if (platform) return platform;
const superAdmin = await User.findOne({ role: 'super_admin' }).sort({ createdAt: 1 });
if (superAdmin) return superAdmin;
throw new Error('No user found for publish');
}
function scheduleFromConfig(config) {
return config?.sections?.event_schedule || config?.components?.event_schedule || null;
}
function applyKochiSchedule(targetConfig, sourceSchedule) {
const next = deepClone(targetConfig);
next.sections = next.sections || {};
const days = deepClone(sourceSchedule.days || []);
if (days[0]) {
days[0].label = days[0].label || 'Day 01';
days[0].date = TARGET_SCHEDULE.day1;
}
if (days[1]) {
days[1].label = days[1].label || 'Day 02';
days[1].date = TARGET_SCHEDULE.day2;
}
next.sections.event_schedule = {
...(next.sections.event_schedule || {}),
show: true,
heading: sourceSchedule.heading || next.sections.event_schedule?.heading || 'Event Schedule',
subtitle: TARGET_SCHEDULE.subtitle,
days,
};
return next;
}
function summarizeDays(days) {
return (days || []).map((day) => ({
label: day.label,
date: day.date,
sessions: (day.sessions || []).length,
topics: (day.sessions || []).reduce((n, s) => n + ((s.topics || []).length), 0),
}));
}
async function main() {
const shouldPublish = process.argv.includes('--publish');
await connectDB();
const targetEvent = await Event.findById(TARGET_EVENT_ID).lean();
if (!targetEvent) throw new Error(`Target event not found: ${TARGET_EVENT_ID}`);
const sourceSite = await EventStaticMicrosite.findOne({ subdomain: SOURCE_SUBDOMAIN }).lean();
if (!sourceSite) throw new Error(`Source microsite not found: ${SOURCE_SUBDOMAIN}`);
const sourceSchedule = scheduleFromConfig(sourceSite.config);
if (!sourceSchedule?.days?.length) {
throw new Error('Kochi event_schedule is missing days');
}
const targetSite = await EventStaticMicrosite.findOne({
$or: [{ event: TARGET_EVENT_ID }, { subdomain: TARGET_SUBDOMAIN }],
});
if (!targetSite) throw new Error(`Dehradun microsite not found: ${TARGET_SUBDOMAIN}`);
const days = applyKochiSchedule({ sections: {} }, sourceSchedule).sections.event_schedule.days;
const agendaResult = await importNovelScheduleToAgenda(TARGET_EVENT_ID, days, {
dateKeys: TARGET_SCHEDULE.dateKeys,
});
console.log(
`Agenda: deleted ${agendaResult.deleted}, created ${agendaResult.created} session(s) on track ${agendaResult.trackId}`,
);
targetSite.config = applyKochiSchedule(targetSite.config, sourceSchedule);
targetSite.markModified('config');
const user = shouldPublish ? await findPublishUser() : null;
if (user) targetSite.updatedBy = user._id;
await targetSite.save();
console.log(`Event: ${targetEvent.name}`);
console.log(`Subdomain: ${targetSite.subdomain}`);
console.log('Schedule:', JSON.stringify(summarizeDays(days), null, 2));
if (shouldPublish) {
const populated = await EventStaticMicrosite.findById(targetSite._id).populate('template');
const result = await publishStaticMicrosite({
staticMicrosite: populated,
template: populated.template,
userId: user._id,
});
Object.assign(populated, result);
await populated.save();
console.log(`Published: ${result.publicUrl}`);
} else {
console.log('Saved as draft. Re-run with --publish to publish.');
}
await mongoose.disconnect();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@ -0,0 +1,310 @@
{
"days": [
{
"label": "Day 01",
"date": "September 19, 2026",
"sessions": [
{
"time": "1:45 PM – 2:00 PM",
"type": "Main programme",
"title": "",
"name": "INTRODUCTION OF THE NOVEL LUNG HEALTH SUMMIT",
"topics": [
{
"type": "WELCOME & CONTEXT SETTING",
"duration": "15",
"title": "Introduction of the Novel Lung Health Summit",
"role": "Dr. Maj. Monica Barne (Retd.)"
}
]
},
{
"time": "2:00 PM – 2:35 PM",
"type": "Main programme",
"title": "",
"name": "SESSION I: PRECISION MEDICINE — Moderator: Dr. Sonia Dalal",
"topics": [
{
"type": "LECTURE",
"duration": "15",
"title": "Translating Precision SITT into Everyday Practice",
"role": "Dr. Karan Madan"
},
{
"type": "PANEL DISCUSSION",
"duration": "20",
"title": "SITT: Right Patient. Right Inhaler. Right Outcome",
"role": "Panelists: Dr. Abhinav Bhanot, Dr. Karan Madan, Dr. Sudhanshu Kalra"
}
]
},
{
"time": "2:35 PM – 3:15 PM",
"type": "Main programme",
"title": "",
"name": "SESSION II: AEROSOL THERAPY — Moderator: Dr. Anshum Aneja",
"topics": [
{
"type": "LECTURE",
"duration": "20",
"title": "Aerosol Therapies: Customizing Innovations for India",
"role": "Dr. Anshum Aneja"
},
{
"type": "PANEL DISCUSSION",
"duration": "20",
"title": "Optimizing Care for the Elusive Indian Patients",
"role": "Panelists: Dr. Pallav Bhattacharyya, Dr. Rakhee Khanduri, Dr. Sanjay Sarin"
}
]
},
{
"time": "3:15 PM – 3:55 PM",
"type": "Main programme",
"title": "",
"name": "SESSION III: GREY ZONES OF RESPIRATORY MEDICINE — Moderator: Dr. Harjit Dumra",
"topics": [
{
"type": "PANEL DISCUSSION",
"duration": "40",
"title": "Untangling Complex Scenarios of combined ILD with OAD: The Overlaps, Mimicry or Coexistence?",
"role": "Panelists: Dr. Abhinav Bhanot, Dr. Karan Madan, Dr. Sonia Dalal"
}
]
},
{
"time": "3:55 PM – 4:10 PM",
"type": "Main programme",
"title": "",
"name": "SESSION IV: JUST ASK ME ANYTHING: JAMA — Moderator: Dr. Sonia Dalal",
"topics": [
{
"type": "JAMA",
"duration": "15",
"title": "\"Is it Really the End of Blue Inhalers?\"",
"role": "Panelists: Dr. Bharat Toshniwal, Dr. Rakhee Khanduri, Dr. Samadarshi Datta"
}
]
},
{
"time": "4:10 PM – 4:25 PM",
"type": "Main programme",
"title": "",
"name": "TEA BREAK",
"topics": []
},
{
"time": "4:25 PM – 5:10 PM",
"type": "Main programme",
"title": "",
"name": "SESSION V: TUBERCULOSIS-ASSOCIATED LUNG DISEASE (TBaLD) — Moderator: Dr. Debraj Jash",
"topics": [
{
"type": "LECTURE",
"duration": "15",
"title": "TBaLD: The Hidden Burden We Can No Longer Ignore",
"role": "Dr. Sundeep Salvi"
},
{
"type": "PANEL DISCUSSION",
"duration": "30",
"title": "Recognize, Restore & Rehabilitate",
"role": "Panelists: Dr. Parthasarathi Bhattacharyya, Dr. Parvaiz Koul, Dr. Samadarshi Datta"
}
]
},
{
"time": "5:10 PM – 6:00 PM",
"type": "Main programme",
"title": "",
"name": "SESSION VI: SCREENING TOOLS — Subject Expert/Moderator: Dr. Maj. Monica Barne (Retd)",
"topics": [
{
"type": "INTRODUCTION",
"duration": "4",
"title": "Novel Screening & Assessment Tools: Closing India's Care Gap",
"role": "Dr. Maj. Monica Barne (Retd)"
},
{
"type": "LECTURE",
"duration": "7",
"title": "Neb Score",
"role": "Dr. Madhuragauri Shevade"
},
{
"type": "LECTURE",
"duration": "7",
"title": "ILD Screening Tool",
"role": "Dr. Gaurav Singhal"
},
{
"type": "LECTURE",
"duration": "7",
"title": "C.A.R.E. Questionnaire",
"role": "Ms. Sapna Madas"
},
{
"type": "LECTURE",
"duration": "5",
"title": "EXPLOR Study",
"role": "Ms. Ruby Swami"
},
{
"type": "PANEL DISCUSSION",
"duration": "20",
"title": "Panel Discussion",
"role": "Panelists: Dr. Gaurav Singhal, Dr. Madhuragauri Shevade, Ms. Sapna Madas"
}
]
},
{
"time": "6:00 PM – 6:30 PM",
"type": "Main programme",
"title": "",
"name": "SESSION VII: PULMONARY REHABILITATION — Moderator: Dr. Maj. Monica Barne (Retd)",
"topics": [
{
"type": "INTRODUCTION",
"duration": "5",
"title": "Introduction",
"role": "Dr. Maj. Monica Barne (Retd)"
},
{
"type": "LECTURE + Q&A",
"duration": "25",
"title": "Pulmonary Rehabilitation: Restoring Lives Beyond Medicines",
"role": "Dr. Parthasarathi Bhattacharyya"
}
]
}
]
},
{
"label": "Day 02",
"date": "September 20, 2026",
"sessions": [
{
"time": "9:00 AM – 9:20 AM",
"type": "Main programme",
"title": "",
"name": "SESSION VIII: ALLERGIC BRONCHOPULMONARY ASPERGILLOSIS (ABPA) — Clinical Expert: Dr. Karan Madan",
"topics": [
{
"type": "LECTURE + Q&A",
"duration": "20",
"title": "ABPA: The Hidden Nemesis of the Indian Pulmonologist",
"role": "Dr. Debraj Jash"
}
]
},
{
"time": "9:20 AM – 10:00 AM",
"type": "Main programme",
"title": "",
"name": "SESSION IX: BIOLOGICS — Moderator: Dr. Sourabh Pahuja",
"topics": [
{
"type": "LECTURE",
"duration": "20",
"title": "Transforming Respiratory Medicine: The Era of Biologics",
"role": "Dr. Deepak Talwar"
},
{
"type": "PANEL DISCUSSION",
"duration": "20",
"title": "Matching the Right Patient to the Right Biologic",
"role": "Panelists: Dr. Anshum Aneja, Dr. Deepak Talwar, Dr. Sanjay Sarin"
}
]
},
{
"time": "10:00 AM – 10:40 AM",
"type": "Main programme",
"title": "",
"name": "SESSION X: PULMONARY DIAGNOSTICS — Moderator: Dr. Anshum Aneja",
"topics": [
{
"type": "LECTURE",
"duration": "25",
"title": "Practice-Changing Advances in Pulmonary Diagnostics",
"role": "Dr. Deepak Talwar"
},
{
"type": "PANEL DISCUSSION",
"duration": "15",
"title": "Clinical Applications",
"role": "Panelists: Dr. Deepak Talwar, Dr. Gaurav Singhal, Dr. Jyoti Bajpai"
}
]
},
{
"time": "10:40 AM – 11:20 AM",
"type": "Main programme",
"title": "",
"name": "SESSION XI: THE DISCHARGE BUNDLE — Moderator: Dr. Brijesh Prajapat",
"topics": [
{
"type": "INTRODUCTION",
"duration": "5",
"title": "Prevention of Future Exacerbations",
"role": "Dr. Brijesh Prajapat"
},
{
"type": "PANEL DISCUSSION",
"duration": "35",
"title": "From Hospital to Home: Fostering Better Outcomes",
"role": "Panelists: Dr. Dhruva Chaudhry, Dr. Gaurav Singhal, Dr. Sourabh Pahuja"
}
]
},
{
"time": "11:20 AM – 11:35 AM",
"type": "Main programme",
"title": "",
"name": "TEA BREAK",
"topics": []
},
{
"time": "11:35 AM – 12:15 PM",
"type": "Main programme",
"title": "",
"name": "SESSION XII: CLINICAL GRAND ROUNDS — Expert Panel: Dr. Dhruva Chaudhry, Dr. Sanjay Sarin",
"topics": [
{
"type": "CASE PRESENTATION+DISCUSSION",
"duration": "13",
"title": "Case 1",
"role": "Dr. Brijesh Prajapat"
},
{
"type": "CASE PRESENTATION+DISCUSSION",
"duration": "13",
"title": "Case 2",
"role": "Dr. Dhruv Talwar"
},
{
"type": "CASE PRESENTATION+DISCUSSION",
"duration": "14",
"title": "Case 3",
"role": "Dr. Jyoti Bajpai"
}
]
},
{
"time": "12:15 PM – 12:35 PM",
"type": "Main programme",
"title": "",
"name": "SESSION XIII: FUTURE OF RESPIRATORY CARE — Moderator: Dr. Deepak Talwar",
"topics": [
{
"type": "PANEL DISCUSSION",
"duration": "20",
"title": "What Will Define Respiratory Care of Tomorrow?",
"role": "Panelists: Dr. Bharat Toshniwal, Dr. Sourabh Pahuja, Dr. Sudhanshu Kalra"
}
]
}
]
}
]
}

View File

@ -0,0 +1,253 @@
#!/usr/bin/env python3
"""Parse Designing detailed Dehradun Agenda.xlsx into nested session/topic JSON."""
import json
import re
import zipfile
import xml.etree.ElementTree as ET
from pathlib import Path
XLSX = Path(
__import__('os').environ.get(
'DEHRADUN_AGENDA_XLSX',
'/Volumes/Storage/Downloads/Designing detailed Dehradun Agenda.xlsx',
)
)
NS = {'m': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
TOPIC_TYPES = {
'LECTURE',
'PANEL DISCUSSION',
'JAMA',
'INTRODUCTION',
'TALK',
'TALK + Q&A',
'CASE PRESENTATION+DISCUSSION',
'CASE PRESENTATION + DISCUSSION',
}
DAY_DATES = ['September 19, 2026', 'September 20, 2026']
def load_rows(path):
with zipfile.ZipFile(path) as z:
ss_root = ET.fromstring(z.read('xl/sharedStrings.xml'))
strings = []
for si in ss_root.findall('m:si', NS):
strings.append(''.join(t.text or '' for t in si.findall('.//m:t', NS)))
sheet = ET.fromstring(z.read('xl/worksheets/sheet1.xml'))
rows = []
for row in sheet.findall('m:sheetData/m:row', NS):
cells = {}
for c in row.findall('m:c', NS):
ref = c.get('r')
col = re.match(r'[A-Z]+', ref).group(0)
t = c.get('t')
v = c.find('m:v', NS)
is_el = c.find('m:is', NS)
val = ''
if t == 's' and v is not None:
val = strings[int(v.text)]
elif t == 'inlineStr' and is_el is not None:
val = ''.join(t2.text or '' for t2 in is_el.findall('.//m:t', NS))
elif v is not None:
val = v.text or ''
cells[col] = val
rows.append((int(row.get('r')), cells))
return rows
def clean(val):
return re.sub(r'[ \t]+', ' ', str(val or '')).replace('\xa0', ' ').strip()
def normalize_clock(part, day_index):
text = clean(part).replace(' :', ':')
m = re.search(r'(\d{1,2}):\s*(\d{2})\s*(AM|PM)?', text, re.I)
if not m:
return text
hour = int(m.group(1))
minute = int(m.group(2))
suffix = (m.group(3) or '').upper()
if day_index == 0 and 1 <= hour <= 6:
suffix = 'PM'
elif day_index >= 1:
if 9 <= hour <= 11:
suffix = 'AM'
elif hour == 12:
suffix = 'PM'
elif not suffix:
suffix = 'PM' if 1 <= hour <= 6 else 'AM'
hour12 = hour if hour <= 12 else hour - 12
return f'{hour12}:{minute:02d} {suffix}'
def normalize_range(raw, day_index):
text = clean(raw).replace(' :', ':')
if not text:
return ''
parts = re.split(r'\s*[–—-]\s*', text)
if len(parts) < 2:
return normalize_clock(text, day_index)
return f'{normalize_clock(parts[0], day_index)} – {normalize_clock(parts[1], day_index)}'
def minutes_between(range_str):
parts = re.split(r'\s*[–—]\s*', range_str)
if len(parts) < 2:
return ''
clocks = []
for part in parts[:2]:
m = re.search(r'(\d{1,2}):(\d{2})\s*(AM|PM)', part, re.I)
if not m:
return ''
hour = int(m.group(1)) % 12
if m.group(3).upper() == 'PM':
hour += 12
clocks.append(hour * 60 + int(m.group(2)))
delta = clocks[1] - clocks[0]
return str(delta) if delta > 0 else ''
def is_topic_type(theme):
return clean(theme).upper() in TOPIC_TYPES or clean(theme).upper().startswith('TALK')
def is_session_header(time, theme, title, faculty):
blob = '\n'.join([time, theme])
if re.search(r'^SESSION\s+', blob, re.I | re.M):
return True
if re.search(r'INTRODUCTION TO THE NOVEL LUNG HEALTH SUMMIT', blob, re.I):
return True
return False
def session_name_from(text):
lines = [clean(line) for line in str(text or '').splitlines() if clean(line)]
if not lines:
return ''
name = lines[0]
if len(lines) > 1:
extra = ' '.join(lines[1:])
name = f'{name} — {extra}'
return name
def faculty_role(topic_type, faculty):
role = clean(faculty).strip(' ,')
if not role:
return ''
kind = topic_type.upper()
if kind in {'PANEL DISCUSSION', 'JAMA'} and not re.match(r'panelists?:', role, re.I):
return f'Panelists: {role}'
return role
def parse_agenda(path):
rows = load_rows(path)
days = []
current_day = None
current_session = None
day_index = -1
def start_day(label, date):
nonlocal current_day, current_session, day_index
day_index = len(days)
current_day = {'label': label, 'date': date, 'sessions': []}
days.append(current_day)
current_session = None
def start_session(name):
nonlocal current_session
if not current_day:
start_day('Day 01', DAY_DATES[0])
current_session = {
'time': '',
'type': 'Main programme',
'title': '',
'name': name,
'topics': [],
'_times': [],
}
current_day['sessions'].append(current_session)
return current_session
def add_topic(topic_type, time_range, title, faculty):
if current_session is None:
return
if time_range:
current_session['_times'].append(time_range)
current_session['topics'].append({
'type': topic_type,
'duration': minutes_between(time_range) if time_range else '',
'title': title,
'role': faculty_role(topic_type, faculty),
})
for _, cells in rows:
time = clean(cells.get('B', ''))
theme = clean(cells.get('C', ''))
title = clean(cells.get('D', ''))
faculty = clean(cells.get('E', ''))
if not any([time, theme, title, faculty]):
continue
if time in {'Time'} or theme in {'Theme'}:
continue
day_match = re.match(r'DAY\s+(\d+)', time, re.I)
if day_match and not theme:
idx = int(day_match.group(1)) - 1
start_day(f'Day {idx + 1:02d}', DAY_DATES[idx] if idx < len(DAY_DATES) else '')
continue
if re.search(r'^TEA BREAK$', theme, re.I) or re.search(r'^TEA BREAK$', time, re.I):
sess = start_session('TEA BREAK')
sess['time'] = normalize_range(time, max(day_index, 0))
sess['topics'] = []
sess['_times'] = [sess['time']] if sess['time'] else []
continue
header_text = time if re.search(r'^SESSION\s+', time, re.I) else (
theme if re.search(r'INTRODUCTION TO THE NOVEL|SESSION\s+', theme, re.I) else ''
)
if header_text and not title and not faculty and not is_topic_type(theme):
start_session(session_name_from(header_text or theme or time))
continue
if is_session_header(time, theme, title, faculty) and not is_topic_type(theme):
start_session(session_name_from(theme or time))
if title or faculty:
add_topic('', normalize_range(time, max(day_index, 0)), title, faculty)
continue
if is_topic_type(theme) or title or faculty:
if current_session is None:
start_session(theme or title)
add_topic(
theme if is_topic_type(theme) else '',
normalize_range(time, max(day_index, 0)),
title,
faculty,
)
for day in days:
for sess in day['sessions']:
times = [t for t in sess.pop('_times', []) if t]
if times:
first = times[0].split(' – ')[0]
last = times[-1].split(' – ')[-1]
sess['time'] = f'{first} – {last}' if first != last or '–' in times[0] else times[0]
if sess['name'] == 'TEA BREAK':
sess['topics'] = []
return {'days': days}
def main():
data = parse_agenda(XLSX)
print(json.dumps(data, indent=2, ensure_ascii=False))
print('\nSUMMARY', file=__import__('sys').stderr)
for day in data['days']:
print(f"{day['label']} {day['date']} sessions={len(day['sessions'])}", file=__import__('sys').stderr)
for s in day['sessions']:
print(f" {s['time']} | {s['name'][:80]} | topics={len(s['topics'])}", file=__import__('sys').stderr)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,173 @@
/**
* Restore Dehradun's original programme and dates, using Kochi's nested
* session + topics structure on the microsite (without copying Kochi content).
*
* Usage:
* node scripts/restore-dehradun-schedule-structure.js [--publish]
*/
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const mongoose = require('mongoose');
const { connectDB } = require('../src/config/database');
const Event = require('../src/models/Event');
const Session = require('../src/models/Session');
const EventStaticMicrosite = require('../src/models/EventStaticMicrosite');
const User = require('../src/models/User');
const { publishStaticMicrosite } = require('../src/utils/publishStaticMicrosite');
const { eventBuzzCodeFor } = require('../src/utils/eventBuzzCode');
const { resolveDefaultTrackId } = require('../src/utils/importNovelScheduleAgenda');
const SOURCE_EVENT_ID = '6a509c4aaea9db627064ecf1'; // original 14-session programme
const TARGET_EVENT_ID = '6a50d3de64775fc391bd92bb';
const TARGET_SUBDOMAIN = 'novel2026-dehradun-19-20-sep';
const SOURCE_DAY1 = Date.parse('2026-08-07T00:00:00.000Z');
const TARGET_DAY1 = Date.parse('2026-09-19T00:00:00.000Z');
const DAY_DATES = ['September 19, 2026', 'September 20, 2026'];
const SUBTITLE = 'Scientific programme at Hyatt Regency, Dehradun.';
function deepClone(obj) {
return JSON.parse(JSON.stringify(obj));
}
function shiftTime(date) {
if (!date) return date;
return new Date(new Date(date).getTime() + (TARGET_DAY1 - SOURCE_DAY1));
}
async function findPublishUser() {
const platform = await User.findOne({ platform_admin: 'yes', isActive: { $ne: false } }).sort({ createdAt: 1 });
if (platform) return platform;
const superAdmin = await User.findOne({ role: 'super_admin' }).sort({ createdAt: 1 });
if (superAdmin) return superAdmin;
throw new Error('No user found for publish');
}
function toKochiSessionStructure(session) {
return {
time: session.time || '',
type: session.type || 'Main programme',
title: '',
name: session.name || session.title || '',
topics: Array.isArray(session.topics) ? session.topics : [],
};
}
function applyStructuredSchedule(config, sourceDays) {
const next = deepClone(config);
next.sections = next.sections || {};
next.sections.event_schedule = {
...(next.sections.event_schedule || {}),
show: true,
heading: next.sections.event_schedule?.heading || 'Event Schedule',
subtitle: SUBTITLE,
days: (sourceDays || []).map((day, index) => ({
label: day.label || `Day 0${index + 1}`,
date: DAY_DATES[index] || day.date,
sessions: (day.sessions || []).map(toKochiSessionStructure),
})),
};
return next;
}
async function restoreAgenda(sourceSessions) {
const trackId = await resolveDefaultTrackId(TARGET_EVENT_ID);
const deleted = await Session.deleteMany({ event: TARGET_EVENT_ID });
const payloads = sourceSessions.map((session) => {
const payload = {
event: TARGET_EVENT_ID,
track: trackId,
title: session.title,
description: session.description || '',
type: session.type || 'panel',
startTime: shiftTime(session.startTime),
endTime: shiftTime(session.endTime),
location: session.location || '',
speakers: session.speakers || [],
keyPersons: session.keyPersons || [],
moderator: session.moderator || undefined,
status: session.status || 'scheduled',
topics: session.topics || [],
audienceFeedbackEnabled: session.audienceFeedbackEnabled !== false,
audienceRatingsEnabled: session.audienceRatingsEnabled !== false,
audienceKeyPersonsEnabled: session.audienceKeyPersonsEnabled !== false,
audienceMySessionsEnabled: session.audienceMySessionsEnabled !== false,
audienceAgendaEnabled: session.audienceAgendaEnabled !== false,
enableQnA: session.enableQnA !== false,
enableQnP: session.enableQnP !== false,
};
return payload;
});
const created = payloads.length ? await Session.insertMany(payloads) : [];
await Promise.all(
created.map(async (doc) => {
const joinCode = eventBuzzCodeFor(String(doc._id), 'session');
await Session.updateOne({ _id: doc._id }, { $set: { joinCode } });
}),
);
return { deleted: deleted.deletedCount || 0, created: created.length, trackId: String(trackId) };
}
async function main() {
const shouldPublish = process.argv.includes('--publish');
await connectDB();
const targetEvent = await Event.findById(TARGET_EVENT_ID).lean();
if (!targetEvent) throw new Error(`Target event not found: ${TARGET_EVENT_ID}`);
const sourceSite = await EventStaticMicrosite.findOne({ event: SOURCE_EVENT_ID }).lean();
const sourceDays = sourceSite?.config?.sections?.event_schedule?.days;
if (!sourceDays?.length) throw new Error('Original programme days not found');
const sourceSessions = await Session.find({ event: SOURCE_EVENT_ID }).sort({ startTime: 1 }).lean();
if (sourceSessions.length !== 14) {
throw new Error(`Expected 14 original sessions, found ${sourceSessions.length}`);
}
const agenda = await restoreAgenda(sourceSessions);
console.log(`Agenda restored: deleted ${agenda.deleted}, created ${agenda.created}`);
const targetSite = await EventStaticMicrosite.findOne({
$or: [{ event: TARGET_EVENT_ID }, { subdomain: TARGET_SUBDOMAIN }],
});
if (!targetSite) throw new Error(`Dehradun microsite not found: ${TARGET_SUBDOMAIN}`);
targetSite.config = applyStructuredSchedule(targetSite.config, sourceDays);
targetSite.markModified('config');
const user = shouldPublish ? await findPublishUser() : null;
if (user) targetSite.updatedBy = user._id;
await targetSite.save();
const days = targetSite.config.sections.event_schedule.days;
console.log(`Event: ${targetEvent.name}`);
console.log(`Subdomain: ${targetSite.subdomain}`);
console.log(
'Schedule:',
days.map((d) => `${d.label} ${d.date} sessions=${d.sessions.length} first=${d.sessions[0]?.name}`).join(' | '),
);
if (shouldPublish) {
const populated = await EventStaticMicrosite.findById(targetSite._id).populate('template');
const result = await publishStaticMicrosite({
staticMicrosite: populated,
template: populated.template,
userId: user._id,
});
Object.assign(populated, result);
await populated.save();
console.log(`Published: ${result.publicUrl}`);
} else {
console.log('Saved as draft. Re-run with --publish to publish.');
}
await mongoose.disconnect();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@ -55,6 +55,7 @@ const publicEventPhotoSearchRoutes = require('./routes/publicEventPhotoSearch.ro
const publicPhotoIngestRoutes = require('./routes/publicPhotoIngest.routes');
const publicSocialShareRoutes = require('./routes/publicSocialShare.routes');
const publicAiAvatarRoutes = require('./routes/publicAiAvatar.routes');
const publicEngagementRoutes = require('./routes/publicEngagement.routes');
const publicVisitorThemeRoutes = require('./routes/publicVisitorTheme.routes');
const staticMicrositeRoutes = require('./routes/staticMicrosite.routes');
const eventPhotoRoutes = require('./routes/eventPhoto.routes');
@ -62,6 +63,7 @@ const photoWallRoutes = require('./routes/photoWall.routes');
const eventPhotoSearchRoutes = require('./routes/eventPhotoSearch.routes');
const socialShareRoutes = require('./routes/socialShare.routes');
const aiAvatarRoutes = require('./routes/aiAvatar.routes');
const engagementRoutes = require('./routes/engagement.routes');
const createServer = async () => {
/** Production: set CORS_ORIGIN (or FRONTEND_URL) to every origin that loads the SPA, e.g. https://console.example.com (no trailing slash). */
@ -301,6 +303,7 @@ const createServer = async () => {
server.route(publicPhotoIngestRoutes);
server.route(publicSocialShareRoutes);
server.route(publicAiAvatarRoutes);
server.route(publicEngagementRoutes);
server.route(publicVisitorThemeRoutes);
server.route(authRoutes);
@ -345,6 +348,7 @@ const createServer = async () => {
server.route(eventPhotoSearchRoutes);
server.route(socialShareRoutes);
server.route(aiAvatarRoutes);
server.route(engagementRoutes);
server.route(conciergeBotRoutes);
server.route(attendanceRoutes);
server.route(checkinsRoutes);

View File

@ -101,6 +101,13 @@ const ACL_MODULE_DEFS = {
rowLevel: false,
planFeatures: [],
},
engagement: {
key: 'engagement',
label: 'Engagement',
category: 'event',
rowLevel: false,
planFeatures: [],
},
};
for (const key of COMBINED_PLAN_KEYS) {
@ -130,6 +137,7 @@ const ORDERED_ACL_MODULE_KEYS = [
'photo_wall',
'social_share',
'ai_avatar',
'engagement',
'email_outreach_campaigns',
'custom_badge_and_print_file_creation',
'custom_template_for_wa_messaging',
@ -146,6 +154,7 @@ const ORDERED_ACL_MODULE_KEYS = [
'multi_track_agenda', 'speaker_management', 'exhibitors', 'content_management', 'photo_wall',
'social_share',
'ai_avatar',
'engagement',
'email_outreach_campaigns', 'custom_badge_and_print_file_creation',
'custom_template_for_wa_messaging', 'live_polls', 'multiple_quiz_modes',
'qna_with_moderation', 'ticketing_free_and_paid', 'payment_gateway_integration',

View File

@ -44,7 +44,7 @@ const KEY_PERSON_ROLES = [
'Virtual/Hybrid Host',
];
const FORM_TYPES = ['registration', 'feedback', 'survey', 'custom', 'other', 'web-check-in'];
const FIELD_TYPES = ['text', 'textarea', 'email', 'phone', 'select', 'stars', 'radio', 'checkbox', 'checkboxes', 'file', 'date', 'time', 'section', 'country_state'];
const FIELD_TYPES = ['text', 'textarea', 'email', 'phone', 'select', 'stars', 'radio', 'checkbox', 'checkboxes', 'file', 'date', 'time', 'section', 'country_state', 'rank'];
/** Registration field → Participant property mapping (or `custom` + customMapsKey). */
const FORM_FIELD_MAPS_TO = [
'firstName',

View File

@ -28,6 +28,7 @@ const CORE_EXPLICIT = [
'photo_wall',
'social_share',
'ai_avatar',
'engagement',
'user_management',
'settings',
'billing',
@ -46,6 +47,7 @@ const SYSTEM_ROLE_PERMISSIONS = {
perm('photo_wall', ['read', 'write', 'delete'], 'all'),
perm('social_share', ['read', 'write', 'delete'], 'all'),
perm('ai_avatar', ['read', 'write', 'delete'], 'all'),
perm('engagement', ['read', 'write', 'delete'], 'all'),
perm('user_management', ['read', 'write', 'delete'], 'all'),
perm('settings', ['read', 'write', 'delete'], 'all'),
perm('billing', ['read', 'write'], 'all'),
@ -65,6 +67,7 @@ const SYSTEM_ROLE_PERMISSIONS = {
perm('photo_wall', ['read', 'write', 'delete'], 'all'),
perm('social_share', ['read', 'write', 'delete'], 'all'),
perm('ai_avatar', ['read', 'write', 'delete'], 'all'),
perm('engagement', ['read', 'write', 'delete'], 'all'),
perm('user_management', ['read', 'write'], 'all'),
perm('settings', ['read', 'write'], 'all'),
perm('billing', ['read', 'write'], 'all'),
@ -84,6 +87,7 @@ const SYSTEM_ROLE_PERMISSIONS = {
perm('photo_wall', ['read', 'write'], 'assigned'),
perm('social_share', ['read', 'write'], 'assigned'),
perm('ai_avatar', ['read', 'write'], 'assigned'),
perm('engagement', ['read', 'write'], 'assigned'),
...allPlanModules(['read', 'write'], 'all').filter(
(p) => !CORE_EXPLICIT.includes(p.module),
),
@ -100,6 +104,7 @@ const SYSTEM_ROLE_PERMISSIONS = {
perm('photo_wall', ['read'], 'assigned'),
perm('social_share', ['read'], 'assigned'),
perm('ai_avatar', ['read'], 'assigned'),
perm('engagement', ['read'], 'assigned'),
...allPlanModules(['read'], 'all').filter(
(p) => !CORE_EXPLICIT.includes(p.module),
),

View File

@ -20,6 +20,34 @@ const {
participantAllowedByAudience,
} = require('../utils/attendanceAudience');
function sanitizeWhatsappNotifyForSave(settings) {
const raw = settings?.whatsappNotify;
if (!raw || typeof raw !== 'object') {
return {
enabled: false,
whatsappTemplateId: null,
waHeaderImageUrl: '',
waOverrides: null,
};
}
const enabled = Boolean(raw.enabled);
let whatsappTemplateId = null;
if (raw.whatsappTemplateId && mongoose.Types.ObjectId.isValid(String(raw.whatsappTemplateId))) {
whatsappTemplateId = raw.whatsappTemplateId;
}
const waHeaderImageUrl = String(raw.waHeaderImageUrl || '').trim().slice(0, 2000);
let waOverrides = null;
if (raw.waOverrides && typeof raw.waOverrides === 'object') {
waOverrides = raw.waOverrides;
}
return {
enabled,
whatsappTemplateId: enabled ? whatsappTemplateId : null,
waHeaderImageUrl: enabled ? waHeaderImageUrl : '',
waOverrides: enabled ? waOverrides : null,
};
}
async function assertEventAccess(eventId, user) {
const event = await Event.findById(eventId);
if (!event) return { error: (h) => h.response(error('NOT_FOUND', 'Event not found')).code(404) };
@ -75,6 +103,7 @@ const create = async (request, h) => {
allowAssignBlank: Boolean(settings?.allowAssignBlank),
publicLinkEnabled: settings?.publicLinkEnabled !== false,
audience: sanitizeAudienceForSave(settings),
whatsappNotify: sanitizeWhatsappNotifyForSave(settings),
},
publicToken,
status: 'active',
@ -122,6 +151,9 @@ const update = async (request, h) => {
if (settings.audience !== undefined) {
item.settings.audience = sanitizeAudienceForSave(settings);
}
if (settings.whatsappNotify !== undefined) {
item.settings.whatsappNotify = sanitizeWhatsappNotifyForSave(settings);
}
}
await item.save();
const stats = await getAttendanceStats(item._id);

View File

@ -4,12 +4,15 @@ const KnowledgeSource = require('../models/KnowledgeSource');
const ContextChunk = require('../models/ContextChunk');
const ChatSubscriber = require('../models/ChatSubscriber');
const Event = require('../models/Event');
const WhatsAppDripJob = require('../models/WhatsAppDripJob');
const WhatsAppChatSession = require('../models/WhatsAppChatSession');
const { success, error } = require('../utils/response');
const { getOrgWhatsAppCredentials, assertPhoneNumberOnWaba } = require('../utils/metaWhatsApp');
const {
getOrgWhatsAppDisplayDigits,
buildWaMeUrl,
generateReceptionQrPng,
generateReceptionQrSvg,
receptionPrefill,
} = require('../utils/receptionWhatsApp');
const { resolveScopedOrganizationId } = require('../utils/tenantListScope');
@ -64,6 +67,7 @@ function normalizeReceptionPayload(p) {
const audience = p.dripAudience === 'all_inbound' ? 'all_inbound' : 'matched';
return {
enabled: Boolean(p.enabled),
allowNonRegistered: Boolean(p.allowNonRegistered),
prefillText: String(p.prefillText != null ? p.prefillText : 'Hi').trim() || 'Hi',
welcomeMessage: String(p.welcomeMessage != null ? p.welcomeMessage : ''),
unmatchedBadgePrompt: String(p.unmatchedBadgePrompt != null ? p.unmatchedBadgePrompt : ''),
@ -248,6 +252,8 @@ const create = async (request, h) => {
knowledge,
faqs,
enabled,
voiceEnabled,
videoAvatarEnabled,
} = request.payload;
if (!mongoose.Types.ObjectId.isValid(String(eventId))) {
@ -306,6 +312,8 @@ const create = async (request, h) => {
knowledge,
faqs: ensureVenueFaqs(seededFaqs),
enabled: Boolean(enabled),
voiceEnabled: Boolean(voiceEnabled),
videoAvatarEnabled: videoAvatarEnabled !== false,
status: enabled ? 'ACTIVE' : 'DRAFT',
});
} catch (e) {
@ -427,6 +435,12 @@ const update = async (request, h) => {
if (bot.enabled) bot.status = 'ACTIVE';
if (!bot.enabled && bot.status === 'ACTIVE') bot.status = 'DRAFT';
}
if (p.voiceEnabled != null) {
bot.voiceEnabled = Boolean(p.voiceEnabled);
}
if (p.videoAvatarEnabled != null) {
bot.videoAvatarEnabled = Boolean(p.videoAvatarEnabled);
}
if (p.reception != null) {
let next;
try {
@ -1071,10 +1085,13 @@ const getReception = async (request, h) => {
const resolved = await getOrgWhatsAppDisplayDigits(String(bot.organization));
const prefill = receptionPrefill(bot);
const waMeUrl = resolved.digits ? buildWaMeUrl(resolved.digits, prefill) : '';
const withLogo =
String(request.query?.logo || '').toLowerCase() === '1' ||
String(request.query?.logo || '').toLowerCase() === 'true';
let qrPngBase64 = '';
if (resolved.digits) {
try {
const buf = await generateReceptionQrPng(resolved.digits, prefill);
const buf = await generateReceptionQrPng(resolved.digits, prefill, { withLogo });
qrPngBase64 = buf.toString('base64');
} catch (e) {
console.warn('[reception-qr]', e.message);
@ -1086,14 +1103,41 @@ const getReception = async (request, h) => {
displayDigits: resolved.digits || '',
waMeUrl,
qrPngBase64,
qrWithLogo: withLogo,
canSendMessages: Boolean(creds && creds.accessToken && String(creds.phoneNumberId || '').trim()),
graphAccess: Boolean(creds && creds.wabaId && creds.accessToken),
resolveError: resolved.error || null,
prefillText: prefill,
sessionCount: await WhatsAppChatSession.countDocuments({ botId: bot._id }),
}),
);
};
const clearReceptionSessions = async (request, h) => {
const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
const [sessions, jobs] = await Promise.all([
WhatsAppChatSession.deleteMany({ botId: bot._id }),
WhatsAppDripJob.deleteMany({ botId: bot._id }),
]);
return h.response(
success(
{
sessionsCleared: sessions.deletedCount || 0,
dripJobsCleared: jobs.deletedCount || 0,
},
'Reception WhatsApp list cleared',
),
);
};
function receptionQrWantsLogo(request) {
const raw = String(request.query?.logo || '').toLowerCase();
return raw === '1' || raw === 'true' || raw === 'yes';
}
const getReceptionQrPng = async (request, h) => {
const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user);
if (err) return h.response(error(err.code, err.message)).code(err.status).type('text/plain');
@ -1101,19 +1145,81 @@ const getReceptionQrPng = async (request, h) => {
if (!resolved.digits) {
return h.response(resolved.error || 'WhatsApp number unavailable').code(400).type('text/plain');
}
const withLogo = receptionQrWantsLogo(request);
try {
const buf = await generateReceptionQrPng(resolved.digits, receptionPrefill(bot));
const buf = await generateReceptionQrPng(resolved.digits, receptionPrefill(bot), { withLogo });
const filename = withLogo ? 'reception-whatsapp-qr-logo.png' : 'reception-whatsapp-qr.png';
return h
.response(buf)
.type('image/png')
.header('Cache-Control', 'private, max-age=0')
.header('Content-Disposition', 'attachment; filename="reception-whatsapp-qr.png"');
.header('Content-Disposition', `attachment; filename="${filename}"`);
} catch (e) {
console.error('[reception-qr]', e.message);
return h.response('Could not generate QR').code(500).type('text/plain');
}
};
const getReceptionQrSvg = async (request, h) => {
const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user);
if (err) return h.response(error(err.code, err.message)).code(err.status).type('text/plain');
const resolved = await getOrgWhatsAppDisplayDigits(String(bot.organization));
if (!resolved.digits) {
return h.response(resolved.error || 'WhatsApp number unavailable').code(400).type('text/plain');
}
const withLogo = receptionQrWantsLogo(request);
try {
const svg = await generateReceptionQrSvg(resolved.digits, receptionPrefill(bot), { withLogo });
const filename = withLogo ? 'reception-whatsapp-qr-logo.svg' : 'reception-whatsapp-qr.svg';
return h
.response(svg)
.type('image/svg+xml')
.header('Cache-Control', 'private, max-age=0')
.header('Content-Disposition', `attachment; filename="${filename}"`);
} catch (e) {
console.error('[reception-qr-svg]', e.message);
return h.response('Could not generate QR').code(500).type('text/plain');
}
};
const getDripStats = async (request, h) => {
const user = request.auth.credentials.user;
const { bot, err } = await getBotForUser(request.params.botId, user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
const dripId = String(request.params.dripId || '').trim();
if (!dripId) {
return h.response(error('VALIDATION_ERROR', 'dripId is required')).code(400);
}
const drips = Array.isArray(bot.reception?.drips) ? bot.reception.drips : [];
const dripOnBot = drips.some((d) => d && String(d._id) === dripId);
if (!dripOnBot) {
return h.response(error('NOT_FOUND', 'Drip message not found on this chatbot')).code(404);
}
const rows = await WhatsAppDripJob.aggregate([
{ $match: { botId: bot._id, dripId } },
{ $group: { _id: '$status', count: { $sum: 1 } } },
]);
const counts = { pending: 0, sent: 0, skipped: 0, failed: 0 };
for (const row of rows) {
const key = String(row._id || '');
if (Object.prototype.hasOwnProperty.call(counts, key)) {
counts[key] = row.count;
}
}
const total = counts.pending + counts.sent + counts.skipped + counts.failed;
return h.response(
success({
dripId,
stats: { ...counts, total },
}),
);
};
module.exports = {
list,
create,
@ -1122,6 +1228,9 @@ module.exports = {
remove,
getReception,
getReceptionQrPng,
getReceptionQrSvg,
getDripStats,
clearReceptionSessions,
getByEvent,
listSources,
createSource,

View File

@ -0,0 +1,84 @@
const mongoose = require('mongoose');
const Event = require('../models/Event');
const { success, error } = require('../utils/response');
const { normalizeEventHeaderDisplay } = require('../utils/formPublicHelpers');
const {
getOrCreateEngagement,
serializeEngagement,
normalizeFeatures,
normalizeProfileFields,
} = require('../utils/engagementConfig');
async function assertEventOrgAccess(eventId, user) {
if (user.role === 'super_admin') return true;
const oid = user.organization?._id || user.organization;
const event = await Event.findById(eventId).select('organization').lean();
if (!event) return false;
return event.organization?.toString() === oid?.toString();
}
const getEngagement = async (request, h) => {
const user = request.auth.credentials?.user;
const { eventId } = request.params;
if (!user) return h.response(error('UNAUTHORIZED', 'Not authenticated')).code(401);
if (!mongoose.Types.ObjectId.isValid(eventId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid event id')).code(400);
}
if (!(await assertEventOrgAccess(eventId, user))) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const event = await Event.findById(eventId).select('organization').lean();
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const config = await getOrCreateEngagement(eventId, event.organization);
return h.response(success(serializeEngagement(config))).code(200);
};
const updateEngagement = async (request, h) => {
const user = request.auth.credentials?.user;
const { eventId } = request.params;
if (!user) return h.response(error('UNAUTHORIZED', 'Not authenticated')).code(401);
if (!mongoose.Types.ObjectId.isValid(eventId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid event id')).code(400);
}
if (!(await assertEventOrgAccess(eventId, user))) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const event = await Event.findById(eventId).select('organization').lean();
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const config = await getOrCreateEngagement(eventId, event.organization);
const p = request.payload || {};
if (typeof p.enabled === 'boolean') config.enabled = p.enabled;
if (p.eventHeader != null && typeof p.eventHeader === 'object') {
config.eventHeader = normalizeEventHeaderDisplay({
...(config.eventHeader?.toObject?.() || config.eventHeader || {}),
...p.eventHeader,
});
}
if (p.features != null && typeof p.features === 'object') {
config.features = normalizeFeatures({
...(config.features?.toObject?.() || config.features || {}),
...p.features,
});
}
if (p.profileFields != null && typeof p.profileFields === 'object') {
const current = config.profileFields?.toObject?.() || config.profileFields || {};
config.profileFields = normalizeProfileFields({
...current,
...p.profileFields,
custom: Array.isArray(p.profileFields.custom) ? p.profileFields.custom : current.custom,
});
}
await config.save();
return h.response(success(serializeEngagement(config), 'Engagement updated')).code(200);
};
module.exports = {
getEngagement,
updateEngagement,
};

View File

@ -219,17 +219,95 @@ function csvColumnsFromFormFields(fields) {
for (const f of fields || []) {
if (f.type === 'section' || !(f.label || f.name)) continue;
const base = fieldDataKey(f);
const fieldLabel = f.label || f.name;
if (f.type === 'country_state') {
const label = f.label || f.name;
cols.push({ header: `${label} - Country`, key: `${base} - Country` });
cols.push({ header: `${label} - State`, key: `${base} - State` });
} else {
cols.push({ header: f.label || f.name, key: base });
cols.push({ header: `${fieldLabel} - Country`, key: `${base} - Country`, type: 'country_state' });
cols.push({ header: `${fieldLabel} - State`, key: `${base} - State`, type: 'country_state' });
continue;
}
if (f.type === 'rank') {
const options = Array.isArray(f.options) ? f.options.map((o) => String(o)).filter(Boolean) : [];
for (const opt of options) {
cols.push({
header: `${fieldLabel} - ${opt}`,
key: base,
type: 'rank_option',
optionLabel: opt,
});
}
if (f.allowOther) {
const otherLabel = String(f.otherLabel || 'Other').trim() || 'Other';
cols.push({
header: `${fieldLabel} - ${otherLabel} (text)`,
key: base,
type: 'rank_other_text',
});
cols.push({
header: `${fieldLabel} - ${otherLabel} (rank)`,
key: base,
type: 'rank_other_rank',
});
}
continue;
}
cols.push({ header: fieldLabel, key: base, type: String(f.type || '') });
}
return cols;
}
/**
* Parse stored rank answer into { optionRanks: Map<label, rank>, otherText, otherRank }.
* Stored form is usually an ordered string[] (rank 1 first).
*/
function parseRankAnswerForExport(val, fixedOptions) {
const optionRanks = new Map();
let otherText = '';
let otherRank = '';
const fixedSet = new Set((fixedOptions || []).map(String));
const assignOrdered = (labels) => {
labels.forEach((label, idx) => {
const text = String(label ?? '').trim();
if (!text) return;
const rank = idx + 1;
if (fixedSet.has(text)) optionRanks.set(text, rank);
else {
// First non-fixed label wins as Other (custom text).
if (!otherText) {
otherText = text;
otherRank = rank;
}
}
});
};
if (Array.isArray(val)) {
assignOrdered(val);
} else if (val && typeof val === 'object') {
if (val.ranks && typeof val.ranks === 'object' && !Array.isArray(val.ranks)) {
for (const [label, rank] of Object.entries(val.ranks)) {
const n = Number(rank);
if (!Number.isFinite(n) || n <= 0) continue;
if (label === '__other__') {
otherText = String(val.otherText || '').trim();
otherRank = n;
} else if (fixedSet.has(String(label))) {
optionRanks.set(String(label), n);
}
}
} else {
const keys = Object.keys(val);
if (keys.length > 0 && keys.every((k) => /^\d+$/.test(k))) {
assignOrdered(
keys.sort((a, b) => Number(a) - Number(b)).map((k) => val[k]),
);
}
}
}
return { optionRanks, otherText, otherRank };
}
async function upsertParticipantFromRegistrationForm(form, tenantOrganization, dataObj) {
const fields = form.fields || [];
const orgOid =
@ -907,11 +985,63 @@ function getSubmissionFieldValue(dataObj, key) {
return dataObj[key] ?? dataObj[sanitized] ?? '';
}
/**
* Turn stored submission values into CSV-friendly text.
* Rank/checkboxes are arrays; Mongo Map quirks can also yield numeric-key objects.
*/
function formatSubmissionCsvValue(val, fieldType) {
if (val == null || val === '') return '';
const sep = fieldType === 'rank' ? ' → ' : ', ';
if (Array.isArray(val)) {
return val.map((x) => String(x ?? '').trim()).filter(Boolean).join(sep);
}
if (typeof val === 'object') {
// Accidental Rank UI state { ranks, otherText } — reconstruct ordered labels if possible.
if (val.ranks && typeof val.ranks === 'object' && !Array.isArray(val.ranks)) {
const items = [];
for (const [label, rank] of Object.entries(val.ranks)) {
if (label === '__other__') {
const text = String(val.otherText || '').trim();
const n = Number(rank);
if (text && Number.isFinite(n) && n > 0) items.push({ label: text, rank: n });
continue;
}
const n = Number(rank);
if (Number.isFinite(n) && n > 0) items.push({ label: String(label), rank: n });
}
items.sort((a, b) => a.rank - b.rank);
if (items.length) return items.map((i) => i.label).join(sep);
}
const keys = Object.keys(val);
if (keys.length > 0 && keys.every((k) => /^\d+$/.test(k))) {
return keys
.sort((a, b) => Number(a) - Number(b))
.map((k) => String(val[k] ?? '').trim())
.filter(Boolean)
.join(sep);
}
}
return String(val);
}
function submissionSearchBlob(sub) {
const parts = [];
const o = dataMapToObject(sub.data);
Object.values(o).forEach((v) => {
if (v != null && v !== '') parts.push(String(v));
if (v == null || v === '') return;
if (Array.isArray(v)) {
parts.push(v.map((x) => String(x ?? '')).join(' '));
return;
}
if (typeof v === 'object') {
parts.push(formatSubmissionCsvValue(v, 'rank'));
return;
}
parts.push(String(v));
});
const p = sub.participant;
if (p && typeof p === 'object') {
@ -971,13 +1101,36 @@ async function resolveFormEventName(form) {
}
function submissionsToCsv(form, rows) {
const cols = csvColumnsFromFormFields(form.fields);
const fields = form.fields || [];
const fieldByKey = new Map();
for (const f of fields) {
const k = fieldDataKey(f);
if (k) fieldByKey.set(k, f);
}
const cols = csvColumnsFromFormFields(fields);
const header = ['Submitted At', 'Participant Email', ...cols.map((c) => c.header)];
const lines = [header.map(escapeCsvCell).join(',')];
for (const s of rows) {
const o = dataMapToObject(s.data);
const email = s.participant && typeof s.participant === 'object' ? s.participant.email || '' : '';
const vals = cols.map((c) => getSubmissionFieldValue(o, c.key));
const vals = cols.map((c) => {
const raw = getSubmissionFieldValue(o, c.key);
if (c.type === 'rank_option' || c.type === 'rank_other_text' || c.type === 'rank_other_rank') {
const field = fieldByKey.get(c.key);
const options = Array.isArray(field?.options) ? field.options.map(String) : [];
const parsed = parseRankAnswerForExport(raw, options);
if (c.type === 'rank_option') {
const rank = parsed.optionRanks.get(String(c.optionLabel));
return rank != null ? String(rank) : '';
}
if (c.type === 'rank_other_text') return parsed.otherText || '';
if (c.type === 'rank_other_rank') {
return parsed.otherRank !== '' && parsed.otherRank != null ? String(parsed.otherRank) : '';
}
}
return formatSubmissionCsvValue(raw, c.type);
});
const row = [new Date(s.submittedAt).toISOString(), email, ...vals];
lines.push(row.map(escapeCsvCell).join(','));
}

View File

@ -72,6 +72,10 @@ function serializeWall(wall, counts = {}) {
createParticipant: Boolean(wall.createParticipant),
moderationMode: wall.moderationMode || 'auto',
photosPerRow: wall.photosPerRow ?? 4,
totalRows: wall.totalRows ?? 3,
marqueeDirection: ['left', 'right', 'alternate'].includes(wall.marqueeDirection)
? wall.marqueeDirection
: 'alternate',
maxPhotoWidth: wall.maxPhotoWidth ?? 320,
maxPhotoHeight: wall.maxPhotoHeight ?? 320,
gapPx: wall.gapPx ?? 8,
@ -215,6 +219,13 @@ const updatePhotoWall = async (request, h) => {
const n = Number(p.photosPerRow);
if (Number.isFinite(n)) wall.photosPerRow = Math.min(12, Math.max(1, Math.round(n)));
}
if (p.totalRows != null) {
const n = Number(p.totalRows);
if (Number.isFinite(n)) wall.totalRows = Math.min(8, Math.max(1, Math.round(n)));
}
if (p.marqueeDirection === 'left' || p.marqueeDirection === 'right' || p.marqueeDirection === 'alternate') {
wall.marqueeDirection = p.marqueeDirection;
}
if (p.maxPhotoWidth != null) {
const n = Number(p.maxPhotoWidth);
if (Number.isFinite(n)) wall.maxPhotoWidth = Math.min(1200, Math.max(80, Math.round(n)));

View File

@ -0,0 +1,669 @@
const mongoose = require('mongoose');
const path = require('path');
const crypto = require('crypto');
const Engagement = require('../models/Engagement');
const Event = require('../models/Event');
const EventMicrosite = require('../models/EventMicrosite');
const Participant = require('../models/Participant');
const AiAvatar = require('../models/AiAvatar');
const SocialShare = require('../models/SocialShare');
const PhotoWall = require('../models/PhotoWall');
const PhotoWallPhoto = require('../models/PhotoWallPhoto');
const EventPhotoSearch = require('../models/EventPhotoSearch');
const { success, error } = require('../utils/response');
const { getMembership } = require('../utils/participantMembership');
const {
EVENT_PUBLIC_SELECT,
buildPublicEventPayload,
normalizeEventHeaderDisplay,
} = require('../utils/formPublicHelpers');
const { serializeEngagement, normalizeFeatures, normalizeProfileFields } = require('../utils/engagementConfig');
const {
uploadEventPhotoToS3,
uploadPhotoWallPhotoToS3,
getEventPhotoBufferFromS3,
PHOTO_ACTIVITIES,
PHOTO_KINDS,
resolvePhotoPublicUrl,
toPicturesKey,
} = require('../utils/s3EventPhotos');
const { renderAndUploadFramedPhoto } = require('../utils/photoWallFrameRender');
const ALLOWED_MIME = new Set([
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
'image/bmp',
]);
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const MEDIA_KINDS = new Set(['dp', 'aiAvatar', 'socialShare', 'photoWall']);
async function streamToBuffer(stream) {
const chunks = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
async function extractSingleFile(payload) {
const raw = payload?.file || payload?.files || payload?.photo;
if (!raw) return null;
const file = Array.isArray(raw) ? raw[0] : raw;
if (!file) return null;
let buffer;
if (Buffer.isBuffer(file)) {
buffer = file;
} else if (typeof file.pipe === 'function' || typeof file[Symbol.asyncIterator] === 'function') {
buffer = await streamToBuffer(file);
} else if (file._data && Buffer.isBuffer(file._data)) {
buffer = file._data;
} else {
return null;
}
const originalName = file.hapi?.filename || file.filename || 'profile.jpg';
const mimeType =
file.hapi?.headers?.['content-type'] ||
file.headers?.['content-type'] ||
payload?.mimeType ||
'image/jpeg';
return { buffer, originalName, mimeType };
}
function customFieldsToObject(customFields) {
if (!customFields) return {};
if (customFields instanceof Map) return Object.fromEntries(customFields.entries());
if (typeof customFields === 'object' && !Array.isArray(customFields)) return { ...customFields };
return {};
}
function pickPhotoUrl(membership, participant) {
const fromMembership = String(membership?.engagement?.photoUrl || '').trim();
if (fromMembership) return fromMembership;
const cf = customFieldsToObject(participant.customFields);
const candidates = [cf.photoUrl, cf.photo, cf.profileImage, cf.imageUrl, cf.avatar];
for (const value of candidates) {
const text = String(value || '').trim();
if (text) return text;
}
return null;
}
function displayNameOf(participant) {
const display = String(participant.displayName || '').trim();
if (display) return display;
return [participant.firstName, participant.lastName].map((s) => String(s || '').trim()).filter(Boolean).join(' ').trim();
}
function ensureMembershipEngagement(membership) {
if (!membership.engagement || typeof membership.engagement !== 'object') {
membership.engagement = {};
}
if (!Array.isArray(membership.engagement.media)) {
membership.engagement.media = [];
}
return membership.engagement;
}
function publicMediaUrl(url) {
const raw = String(url || '').trim();
if (!raw) return '';
return resolvePhotoPublicUrl(raw) || raw;
}
function photoIdentityKey(url) {
const key = toPicturesKey(url);
return String(key || url || '')
.replace(/^\/+/, '')
.toLowerCase();
}
function appendEngagementMedia(eng, kind, url, sourceUrl) {
const resolved = publicMediaUrl(url);
if (!resolved || !MEDIA_KINDS.has(kind)) return false;
if (!Array.isArray(eng.media)) eng.media = [];
const source = publicMediaUrl(sourceUrl);
if (kind === 'photoWall' && source && wallSourceAlreadyPosted(eng, [], source)) return false;
const exists = eng.media.some((item) => publicMediaUrl(item?.url) === resolved);
if (exists) return false;
const row = { kind, url: resolved, createdAt: new Date() };
if (source) row.sourceUrl = source;
eng.media.push(row);
return true;
}
function wallSourceAlreadyPosted(eng, extras, sourceUrl) {
const sourceKey = photoIdentityKey(sourceUrl);
if (!sourceKey) return false;
const matches = (item) => {
if (!item || item.kind !== 'photoWall') return false;
return photoIdentityKey(item.url) === sourceKey || photoIdentityKey(item.sourceUrl) === sourceKey;
};
if (Array.isArray(eng?.media) && eng.media.some(matches)) return true;
if (Array.isArray(extras) && extras.some(matches)) return true;
return false;
}
async function loadWallMedia(eventId, participantId) {
const rows = await PhotoWallPhoto.find({
event: eventId,
status: { $ne: 'removed' },
$or: [{ participant: participantId }, { uploadedByParticipant: participantId }],
})
.sort({ createdAt: -1 })
.select('framedUrl url sourceUrl createdAt')
.lean();
return rows
.map((row) => ({
kind: 'photoWall',
url: publicMediaUrl(row.framedUrl || row.url),
sourceUrl: publicMediaUrl(row.sourceUrl) || '',
createdAt: row.createdAt || null,
}))
.filter((item) => item.url);
}
function serializeMedia(eng, extras = []) {
const items = [];
const seen = new Set();
const push = (kind, url, createdAt, sourceUrl) => {
const resolved = publicMediaUrl(url);
if (!resolved || seen.has(resolved)) return;
seen.add(resolved);
const item = { kind, url: resolved, createdAt: createdAt || null };
const source = publicMediaUrl(sourceUrl);
if (source) item.sourceUrl = source;
items.push(item);
};
push('dp', eng?.photoUrl, eng?.setupCompletedAt);
for (const item of Array.isArray(eng?.media) ? eng.media : []) {
push(item.kind, item.url, item.createdAt, item.sourceUrl);
}
for (const item of extras) {
push(item.kind, item.url, item.createdAt, item.sourceUrl);
}
return items;
}
async function loadEnabledConfig(eventId) {
const config = await Engagement.findOne({ event: eventId });
if (!config || !config.enabled) return null;
return config;
}
async function loadPublicEvent(eventId) {
const ev = await Event.findById(eventId).select(EVENT_PUBLIC_SELECT).lean();
if (!ev) return null;
const microsite = await EventMicrosite.findOne({ event: eventId }).select('logoUrl').lean();
return buildPublicEventPayload(ev, microsite?.logoUrl);
}
async function loadActivityTokens(eventId, features) {
const [ai, social, wall, search] = await Promise.all([
AiAvatar.findOne({ event: eventId, enabled: true }).select('uploadToken enabled').lean(),
SocialShare.findOne({ event: eventId, enabled: true }).select('uploadToken enabled').lean(),
PhotoWall.findOne({ event: eventId, enabled: true }).select('uploadToken enabled').lean(),
EventPhotoSearch.findOne({ event: eventId, enabled: true }).select('searchToken enabled').lean(),
]);
const placement = (key) => String(features?.[key]?.placement || 'off');
const tokens = {
photoSearchToken: '',
aiAvatarToken: '',
socialShareToken: '',
photoWallToken: '',
};
const resolvedFeatures = {
aiAvatar: { placement: 'off', available: false },
socialShare: { placement: 'off', available: false },
photoWall: { placement: 'off', available: false },
photoSearch: { placement: 'off', available: false },
};
const apply = (key, moduleEnabled, token, tokenField) => {
const place = placement(key);
const available = Boolean(moduleEnabled && token && place !== 'off');
resolvedFeatures[key] = {
placement: available ? place : 'off',
available,
moduleEnabled: Boolean(moduleEnabled),
};
if (available) tokens[tokenField] = token;
};
apply('aiAvatar', ai?.enabled, ai?.uploadToken, 'aiAvatarToken');
apply('socialShare', social?.enabled, social?.uploadToken, 'socialShareToken');
apply('photoWall', wall?.enabled, wall?.uploadToken, 'photoWallToken');
apply('photoSearch', search?.enabled, search?.searchToken, 'photoSearchToken');
return { tokens, features: resolvedFeatures };
}
function serializeProgress(membership) {
const eng = membership?.engagement || {};
return {
setupCompleted: Boolean(eng.setupCompletedAt || String(eng.photoUrl || '').trim()),
aiAvatarCompleted: Boolean(eng.aiAvatarCompletedAt),
socialShareCompleted: Boolean(eng.socialShareCompletedAt),
};
}
function serializeParticipant(participant, membership, photoUrl) {
const cf = customFieldsToObject(participant.customFields);
return {
_id: String(participant._id),
firstName: participant.firstName || '',
lastName: participant.lastName || '',
displayName: displayNameOf(participant),
email: participant.email || '',
title: participant.designation || '',
company: participant.organization || '',
photoUrl,
customFields: cf,
};
}
async function resolveParticipant(eventId, participantId) {
if (!mongoose.Types.ObjectId.isValid(eventId) || !mongoose.Types.ObjectId.isValid(participantId)) {
return { status: 400, err: error('VALIDATION_ERROR', 'Invalid event or participant id') };
}
const participant = await Participant.findOne({ _id: participantId, deletedAt: null });
if (!participant) return { status: 404, err: error('NOT_FOUND', 'Participant not found') };
const membership = getMembership(participant, eventId);
if (!membership) return { status: 404, err: error('NOT_FOUND', 'Participant is not registered for this event') };
return { participant, membership };
}
async function buildBootstrap(eventId, participant, membership, config) {
const serialized = serializeEngagement(config);
const featuresNorm = normalizeFeatures(config.features);
const { tokens, features } = await loadActivityTokens(eventId, featuresNorm);
const event = await loadPublicEvent(eventId);
const photoUrl = pickPhotoUrl(membership, participant);
const wallMedia = await loadWallMedia(eventId, participant._id);
return {
eventId: String(eventId),
enabled: true,
event,
eventHeader: normalizeEventHeaderDisplay(config.eventHeader),
features,
profileFields: normalizeProfileFields(config.profileFields),
tokens,
progress: serializeProgress(membership),
media: serializeMedia(membership?.engagement, wallMedia),
participant: serializeParticipant(participant, membership, photoUrl),
configId: serialized._id,
};
}
const getEngagementProfile = async (request, h) => {
const { eventId, participantId } = request.params;
const config = await loadEnabledConfig(eventId);
if (!config) return h.response(error('NOT_FOUND', 'Engagement is not enabled for this event')).code(404);
const resolved = await resolveParticipant(eventId, participantId);
if (resolved.err) return h.response(resolved.err).code(resolved.status);
const data = await buildBootstrap(eventId, resolved.participant, resolved.membership, config);
return h.response(success(data)).code(200);
};
const uploadEngagementPhoto = async (request, h) => {
const { eventId, participantId } = request.params;
const config = await loadEnabledConfig(eventId);
if (!config) return h.response(error('NOT_FOUND', 'Engagement is not enabled for this event')).code(404);
const resolved = await resolveParticipant(eventId, participantId);
if (resolved.err) return h.response(resolved.err).code(resolved.status);
const file = await extractSingleFile(request.payload);
if (!file?.buffer?.length) {
return h.response(error('VALIDATION_ERROR', 'Please upload a photo')).code(400);
}
const mime = String(file.mimeType || '').toLowerCase();
if (!ALLOWED_MIME.has(mime)) {
return h.response(error('VALIDATION_ERROR', 'Unsupported image type')).code(400);
}
const ext = path.extname(file.originalName || '').toLowerCase() || (mime.includes('png') ? '.png' : '.jpg');
const filename = `dp-${participantId}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}${ext}`;
const uploaded = await uploadEventPhotoToS3({
buffer: file.buffer,
contentType: mime,
eventId,
filename,
activity: PHOTO_ACTIVITIES.engagement,
kind: PHOTO_KINDS.uploads,
});
const { participant, membership } = resolved;
const eng = ensureMembershipEngagement(membership);
eng.photoUrl = uploaded.url;
if (!eng.setupCompletedAt) eng.setupCompletedAt = new Date();
appendEngagementMedia(eng, 'dp', uploaded.url);
participant.markModified('eventMemberships');
if (!participant.customFields || !(participant.customFields instanceof Map)) {
participant.customFields = new Map(Object.entries(customFieldsToObject(participant.customFields)));
}
participant.customFields.set('photoUrl', uploaded.url);
participant.markModified('customFields');
await participant.save();
const data = await buildBootstrap(eventId, participant, membership, config);
return h.response(success({ ...data, photoUrl: uploaded.url }, 'Photo saved')).code(200);
};
function fieldError(field, message) {
return { field, message };
}
const patchEngagementProfile = async (request, h) => {
const { eventId, participantId } = request.params;
const config = await loadEnabledConfig(eventId);
if (!config) return h.response(error('NOT_FOUND', 'Engagement is not enabled for this event')).code(404);
const resolved = await resolveParticipant(eventId, participantId);
if (resolved.err) return h.response(resolved.err).code(resolved.status);
const { participant } = resolved;
const fields = normalizeProfileFields(config.profileFields);
const p = request.payload || {};
const details = [];
if (fields.name.show) {
let firstName = p.firstName !== undefined ? String(p.firstName || '').trim() : String(participant.firstName || '').trim();
let lastName = p.lastName !== undefined ? String(p.lastName || '').trim() : String(participant.lastName || '').trim();
let displayName = p.displayName !== undefined ? String(p.displayName || '').trim() : String(participant.displayName || '').trim();
if (p.name !== undefined) {
displayName = String(p.name || '').trim();
const parts = displayName.split(/\s+/).filter(Boolean);
firstName = parts[0] || '';
lastName = parts.slice(1).join(' ');
}
if (fields.name.required && !firstName && !displayName) {
details.push(fieldError('name', 'Please enter your name'));
} else {
if (p.firstName !== undefined || p.name !== undefined) participant.firstName = firstName || undefined;
if (p.lastName !== undefined || p.name !== undefined) participant.lastName = lastName || undefined;
if (p.displayName !== undefined || p.name !== undefined) {
participant.displayName = displayName || [firstName, lastName].filter(Boolean).join(' ') || undefined;
}
}
}
if (fields.email.show) {
const email = p.email !== undefined ? String(p.email || '').trim().toLowerCase() : String(participant.email || '').trim();
if (fields.email.required && !email) {
details.push(fieldError('email', 'Please enter your email'));
} else if (email && !EMAIL_RE.test(email)) {
details.push(fieldError('email', 'Please enter a valid email'));
} else if (p.email !== undefined) {
participant.email = email || undefined;
}
}
const incomingCustom = p.customFields && typeof p.customFields === 'object' && !Array.isArray(p.customFields) ? p.customFields : {};
if (!participant.customFields || !(participant.customFields instanceof Map)) {
participant.customFields = new Map(Object.entries(customFieldsToObject(participant.customFields)));
}
for (const def of fields.custom) {
const raw = incomingCustom[def.key];
const value = raw === undefined || raw === null ? '' : Array.isArray(raw) ? raw.map(String).join(', ') : String(raw).trim();
if (def.required && !value) {
details.push(fieldError(def.key, `Please enter ${def.label}`));
continue;
}
if (def.type === 'email' && value && !EMAIL_RE.test(value)) {
details.push(fieldError(def.key, `Please enter a valid email for ${def.label}`));
continue;
}
if (def.type === 'select' && value && def.options.length && !def.options.includes(value)) {
details.push(fieldError(def.key, `Please choose a valid option for ${def.label}`));
continue;
}
if (Object.prototype.hasOwnProperty.call(incomingCustom, def.key)) {
if (!value) participant.customFields.delete(def.key);
else participant.customFields.set(def.key, value);
}
}
if (details.length) {
return h.response(error('VALIDATION_ERROR', details[0].message, details)).code(400);
}
participant.markModified('customFields');
await participant.save();
const refreshed = await Participant.findById(participant._id);
const membership = getMembership(refreshed, eventId);
const data = await buildBootstrap(eventId, refreshed, membership, config);
return h.response(success(data, 'Profile saved')).code(200);
};
const markEngagementProgress = async (request, h) => {
const { eventId, participantId } = request.params;
const config = await loadEnabledConfig(eventId);
if (!config) return h.response(error('NOT_FOUND', 'Engagement is not enabled for this event')).code(404);
const resolved = await resolveParticipant(eventId, participantId);
if (resolved.err) return h.response(resolved.err).code(resolved.status);
const step = String(request.payload?.step || '').trim();
const allowed = { aiAvatar: 'aiAvatarCompletedAt', socialShare: 'socialShareCompletedAt', setup: 'setupCompletedAt' };
const field = allowed[step];
if (!field) {
return h.response(error('VALIDATION_ERROR', 'Unknown progress step')).code(400);
}
const { participant, membership } = resolved;
const eng = ensureMembershipEngagement(membership);
if (!eng[field]) eng[field] = new Date();
const mediaKind = step === 'aiAvatar' || step === 'socialShare' ? step : 'dp';
appendEngagementMedia(eng, mediaKind, request.payload?.url);
participant.markModified('eventMemberships');
await participant.save();
const data = await buildBootstrap(eventId, participant, membership, config);
return h.response(success(data, 'Progress saved')).code(200);
};
const saveEngagementMedia = async (request, h) => {
const { eventId, participantId } = request.params;
const config = await loadEnabledConfig(eventId);
if (!config) return h.response(error('NOT_FOUND', 'Engagement is not enabled for this event')).code(404);
const resolved = await resolveParticipant(eventId, participantId);
if (resolved.err) return h.response(resolved.err).code(resolved.status);
const kind = String(request.payload?.kind || '').trim();
const url = String(request.payload?.url || '').trim();
if (!MEDIA_KINDS.has(kind) || !url) {
return h.response(error('VALIDATION_ERROR', 'Please provide a media kind and url')).code(400);
}
const { participant, membership } = resolved;
const eng = ensureMembershipEngagement(membership);
appendEngagementMedia(eng, kind, url, request.payload?.sourceUrl);
participant.markModified('eventMemberships');
await participant.save();
const data = await buildBootstrap(eventId, participant, membership, config);
return h.response(success(data, 'Media saved')).code(200);
};
const postGalleryToWall = async (request, h) => {
const { eventId, participantId } = request.params;
const config = await loadEnabledConfig(eventId);
if (!config) return h.response(error('NOT_FOUND', 'Engagement is not enabled for this event')).code(404);
const resolved = await resolveParticipant(eventId, participantId);
if (resolved.err) return h.response(resolved.err).code(resolved.status);
const featuresNorm = normalizeFeatures(config.features);
if (String(featuresNorm.photoWall?.placement || 'off') === 'off') {
return h.response(error('NOT_FOUND', 'Photo Wall is not available on this profile')).code(404);
}
const wall = await PhotoWall.findOne({ event: eventId, enabled: true });
if (!wall) return h.response(error('NOT_FOUND', 'Photo Wall is not enabled for this event')).code(404);
const sourceUrl = publicMediaUrl(request.payload?.url);
if (!sourceUrl) return h.response(error('VALIDATION_ERROR', 'Missing image url')).code(400);
const { participant, membership } = resolved;
const eng = ensureMembershipEngagement(membership);
const wallMedia = await loadWallMedia(eventId, participant._id);
const dataAlready = async () => buildBootstrap(eventId, participant, membership, config);
if (wallSourceAlreadyPosted(eng, wallMedia, sourceUrl)) {
return h.response(success({ ...(await dataAlready()), alreadyPosted: true }, 'Already on the mosaic wall')).code(200);
}
const s3Key = toPicturesKey(sourceUrl);
const eventPrefix = `${String(eventId).trim()}/`;
if (!s3Key || !s3Key.startsWith(eventPrefix)) {
return h.response(error('VALIDATION_ERROR', 'This photo cannot be posted to the wall')).code(400);
}
let buffer;
try {
buffer = await getEventPhotoBufferFromS3(s3Key);
} catch (err) {
console.error('[engagement] wall source load failed', err?.message || err);
return h.response(error('INTERNAL', 'Could not load this photo')).code(500);
}
if (!buffer?.length) return h.response(error('VALIDATION_ERROR', 'Could not load this photo')).code(400);
const filename = `gallery-wall-${participantId}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}.jpg`;
let uploaded;
try {
uploaded = await uploadPhotoWallPhotoToS3({
buffer,
contentType: 'image/jpeg',
eventId: String(wall.event),
filename,
framed: false,
});
} catch (err) {
console.error('[engagement] wall S3 upload failed', err?.message || err);
return h.response(error('UPLOAD_FAILED', 'Failed to post to the wall')).code(500);
}
const status = wall.moderationMode === 'approval' ? 'pending' : 'approved';
const photo = await PhotoWallPhoto.create({
photoWall: wall._id,
event: wall.event,
organization: wall.organization,
url: uploaded.url,
s3Key: uploaded.s3Key,
mimeType: 'image/jpeg',
size: buffer.length,
originalFileName: path.basename(s3Key) || 'event-photo.jpg',
displayName: displayNameOf(participant) || undefined,
status,
participant: participant._id,
uploadedByParticipant: participant._id,
sourceUrl,
});
if (wall.frameEnabled && wall.frameImageUrl) {
try {
const framed = await renderAndUploadFramedPhoto({ wall, photo });
photo.framedUrl = framed.framedUrl;
photo.framedS3Key = framed.framedS3Key;
await photo.save();
} catch (err) {
console.error('[engagement] wall frame failed', err?.message || err);
}
}
const wallUrl = publicMediaUrl(photo.framedUrl || photo.url);
appendEngagementMedia(eng, 'photoWall', wallUrl, sourceUrl);
participant.markModified('eventMemberships');
await participant.save();
const data = await buildBootstrap(eventId, participant, membership, config);
return h
.response(
success(
{ ...data, alreadyPosted: false, awaitingApproval: status === 'pending' },
status === 'pending' ? 'Posted — pending approval' : 'Posted to the mosaic wall',
),
)
.code(200);
};
function safeDownloadFilename(name, fallback = 'event-photo.jpg') {
const raw = String(name || fallback).trim() || fallback;
const cleaned = raw.replace(/[^\w.\-()+ ]+/g, '_').slice(0, 120) || fallback;
return /\.\w+$/.test(cleaned) ? cleaned : `${cleaned}.jpg`;
}
function mimeFromFilename(name) {
const lower = String(name || '').toLowerCase();
if (lower.endsWith('.png')) return 'image/png';
if (lower.endsWith('.webp')) return 'image/webp';
if (lower.endsWith('.gif')) return 'image/gif';
return 'image/jpeg';
}
/**
* Stream gallery bytes through the API so the visitor app can download
* without fetching CloudFront directly (pictures CDN has no CORS).
*/
const downloadEngagementFile = async (request, h) => {
const { eventId, participantId } = request.params;
const config = await loadEnabledConfig(eventId);
if (!config) return h.response(error('NOT_FOUND', 'Engagement is not enabled for this event')).code(404);
const resolved = await resolveParticipant(eventId, participantId);
if (resolved.err) return h.response(resolved.err).code(resolved.status);
const rawUrl = String(request.query?.url || '').trim();
if (!rawUrl) return h.response(error('VALIDATION_ERROR', 'Missing image url')).code(400);
const s3Key = toPicturesKey(rawUrl);
if (!s3Key) return h.response(error('VALIDATION_ERROR', 'Invalid image url')).code(400);
const eventPrefix = `${String(eventId).trim()}/`;
if (!s3Key.startsWith(eventPrefix)) {
return h.response(error('FORBIDDEN', 'Photo is not part of this event')).code(403);
}
const filename = safeDownloadFilename(request.query?.filename, path.basename(s3Key) || 'event-photo.jpg');
const asAttachment =
String(request.query?.download || '').toLowerCase() === '1' ||
String(request.query?.download || '').toLowerCase() === 'true';
try {
const buf = await getEventPhotoBufferFromS3(s3Key);
return h
.response(buf)
.type(mimeFromFilename(filename))
.header('Cache-Control', 'private, max-age=300')
.header(
'Content-Disposition',
`${asAttachment ? 'attachment' : 'inline'}; filename="${filename}"`,
);
} catch (err) {
console.error('[engagement] file proxy failed', err?.message || err);
return h.response(error('INTERNAL', 'Could not load photo')).code(500);
}
};
module.exports = {
getEngagementProfile,
uploadEngagementPhoto,
patchEngagementProfile,
markEngagementProgress,
saveEngagementMedia,
postGalleryToWall,
downloadEngagementFile,
};

View File

@ -537,6 +537,10 @@ const getPresentWall = async (request, h) => {
event: eventPublic,
eventHeader: normalizePresenterEventHeaderDisplay(wall.presenterEventHeader),
photosPerRow: wall.photosPerRow ?? 4,
totalRows: wall.totalRows ?? 3,
marqueeDirection: ['left', 'right', 'alternate'].includes(wall.marqueeDirection)
? wall.marqueeDirection
: 'alternate',
maxPhotoWidth,
maxPhotoHeight,
gapPx: wall.gapPx ?? 8,

View File

@ -1119,12 +1119,15 @@ async function visitorEventRewardHistory(request, h) {
function visitorFeedbackFormPayload(form) {
const fields = (form.fields || []).map((f) => {
const remarks = f.remarks != null ? String(f.remarks).trim() : '';
const otherLabel = f.otherLabel != null ? String(f.otherLabel).trim() : '';
return {
label: f.label,
type: f.type,
required: !!f.required,
options: Array.isArray(f.options) ? f.options.map((o) => String(o)) : [],
placeholder: f.placeholder ? String(f.placeholder) : '',
allowOther: !!f.allowOther,
otherLabel: otherLabel || 'Other',
...(remarks ? { remarks } : {}),
};
});

View File

@ -42,6 +42,17 @@ const attendanceSchema = new mongoose.Schema(
default: [],
},
},
/** Send WhatsApp template once per participant on first successful check-in. */
whatsappNotify: {
enabled: { type: Boolean, default: false },
whatsappTemplateId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'WhatsAppTemplate',
default: null,
},
waHeaderImageUrl: { type: String, default: '', trim: true },
waOverrides: { type: mongoose.Schema.Types.Mixed, default: null },
},
},
publicToken: { type: String, required: true, unique: true, index: true },
status: {

View File

@ -45,6 +45,8 @@ const attendanceParticipantSchema = new mongoose.Schema(
phone: { type: String, trim: true },
checkIns: { type: [checkInEntrySchema], default: [] },
undos: { type: [undoEntrySchema], default: [] },
/** Set when WhatsApp check-in notify is claimed/sent (once per attendance+participant). */
whatsappNotifySentAt: { type: Date, default: null },
},
{ timestamps: true }
);

View File

@ -17,6 +17,10 @@ const conciergeBotSchema = new mongoose.Schema(
/** Website widget accent (hex or rgb). Default matches embed teal. */
accentColor: { type: String, trim: true, default: 'rgb(13, 148, 136)' },
enabled: { type: Boolean, default: false },
/** Website embed: show mic and start a LiveKit voice session. */
voiceEnabled: { type: Boolean, default: false },
/** Avatar page: SIMLI talking-head. If false, show a voice waveform instead. */
videoAvatarEnabled: { type: Boolean, default: true },
status: {
type: String,
enum: ['DRAFT', 'ACTIVE', 'ARCHIVED'],
@ -57,6 +61,7 @@ const conciergeBotSchema = new mongoose.Schema(
'venue_parking',
'venue_parking_transport',
'venue_food',
'accommodation',
'venue_accessibility',
'wifi',
'app_account',
@ -98,6 +103,8 @@ const conciergeBotSchema = new mongoose.Schema(
/** Reception-desk WhatsApp check-in (credentials come from Organisation WhatsApp settings). */
reception: {
enabled: { type: Boolean, default: false },
/** If unmatched, create a participant from WhatsApp phone + profile name. */
allowNonRegistered: { type: Boolean, default: false },
prefillText: { type: String, trim: true, default: 'Hi' },
welcomeMessage: {
type: String,

85
src/models/Engagement.js Normal file
View File

@ -0,0 +1,85 @@
const mongoose = require('mongoose');
const FLOW_PLACEMENTS = ['off', 'flow', 'profile'];
const PROFILE_ONLY_PLACEMENTS = ['off', 'profile'];
const CUSTOM_FIELD_TYPES = ['text', 'textarea', 'email', 'phone', 'select'];
const featurePlacementSchema = new mongoose.Schema(
{
placement: {
type: String,
enum: FLOW_PLACEMENTS,
default: 'off',
},
},
{ _id: false },
);
const profileCoreFieldSchema = new mongoose.Schema(
{
show: { type: Boolean, default: true },
required: { type: Boolean, default: false },
},
{ _id: false },
);
const profileCustomFieldSchema = new mongoose.Schema(
{
key: { type: String, trim: true, required: true },
label: { type: String, trim: true, required: true },
type: {
type: String,
enum: CUSTOM_FIELD_TYPES,
default: 'text',
},
required: { type: Boolean, default: false },
showOnProfile: { type: Boolean, default: false },
options: { type: [String], default: [] },
},
{ _id: false },
);
const engagementSchema = new mongoose.Schema(
{
event: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Event',
required: true,
},
organization: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
},
enabled: { type: Boolean, default: false },
eventHeader: {
showLogo: { type: Boolean, default: true },
showBanner: { type: Boolean, default: false },
showTitle: { type: Boolean, default: true },
showDateRange: { type: Boolean, default: true },
showEventType: { type: Boolean, default: true },
showLocationType: { type: Boolean, default: true },
showVenue: { type: Boolean, default: true },
},
features: {
aiAvatar: { type: featurePlacementSchema, default: () => ({ placement: 'off' }) },
socialShare: { type: featurePlacementSchema, default: () => ({ placement: 'off' }) },
photoWall: { type: featurePlacementSchema, default: () => ({ placement: 'off' }) },
photoSearch: { type: featurePlacementSchema, default: () => ({ placement: 'off' }) },
},
profileFields: {
name: { type: profileCoreFieldSchema, default: () => ({ show: true, required: true }) },
email: { type: profileCoreFieldSchema, default: () => ({ show: true, required: false }) },
custom: { type: [profileCustomFieldSchema], default: [] },
},
},
{ timestamps: true },
);
engagementSchema.index({ event: 1 }, { unique: true });
engagementSchema.statics.FLOW_PLACEMENTS = FLOW_PLACEMENTS;
engagementSchema.statics.PROFILE_ONLY_PLACEMENTS = PROFILE_ONLY_PLACEMENTS;
engagementSchema.statics.CUSTOM_FIELD_TYPES = CUSTOM_FIELD_TYPES;
module.exports = mongoose.model('Engagement', engagementSchema);

View File

@ -6,7 +6,7 @@ const formFieldSchema = new mongoose.Schema({
name: String,
type: {
type: String,
enum: ['text', 'textarea', 'email', 'phone', 'select', 'stars', 'radio', 'checkbox', 'checkboxes', 'file', 'date', 'time', 'section', 'country_state'],
enum: ['text', 'textarea', 'email', 'phone', 'select', 'stars', 'radio', 'checkbox', 'checkboxes', 'file', 'date', 'time', 'section', 'country_state', 'rank'],
},
required: Boolean,
options: [String],

View File

@ -24,6 +24,26 @@ const eventMembershipSchema = new mongoose.Schema(
},
/** Event-scoped labels (registration / event UI). Shown on global person + this event only. */
tags: [String],
/** Engagement hub (WhatsApp unique link): DP + first-visit flow progress. */
engagement: {
photoUrl: { type: String, trim: true, default: '' },
setupCompletedAt: Date,
aiAvatarCompletedAt: Date,
socialShareCompletedAt: Date,
media: [
{
kind: {
type: String,
enum: ['dp', 'aiAvatar', 'socialShare', 'photoWall'],
required: true,
},
url: { type: String, trim: true, required: true },
/** Gallery image this wall post was created from (dedupe). */
sourceUrl: { type: String, trim: true, default: '' },
createdAt: { type: Date, default: Date.now },
},
],
},
},
{ _id: false }
);

View File

@ -31,6 +31,13 @@ const photoWallSchema = new mongoose.Schema(
default: 'auto',
},
photosPerRow: { type: Number, default: 4, min: 1, max: 12 },
/** Horizontal marquee lanes on the presenter wall. */
totalRows: { type: Number, default: 3, min: 1, max: 8 },
marqueeDirection: {
type: String,
enum: ['left', 'right', 'alternate'],
default: 'alternate',
},
maxPhotoWidth: { type: Number, default: 320, min: 80, max: 1200 },
maxPhotoHeight: { type: Number, default: 320, min: 80, max: 1200 },
gapPx: { type: Number, default: 8, min: 0, max: 64 },

View File

@ -37,6 +37,8 @@ const photoWallPhotoSchema = new mongoose.Schema(
formSubmission: { type: mongoose.Schema.Types.ObjectId, ref: 'FormSubmission' },
participant: { type: mongoose.Schema.Types.ObjectId, ref: 'Participant' },
uploadedByParticipant: { type: mongoose.Schema.Types.ObjectId, ref: 'Participant' },
/** Original gallery/CDN URL when posted from Engagement (dedupe). */
sourceUrl: { type: String, trim: true, default: null },
},
{ timestamps: true },
);

View File

@ -0,0 +1,30 @@
const mongoose = require('mongoose');
/**
* Per-recipient reception drip send jobs (written by concierge worker).
* Same collection as concierge `WhatsAppDripJob`.
*/
const whatsAppDripJobSchema = new mongoose.Schema(
{
botId: { type: mongoose.Schema.Types.ObjectId, ref: 'ConciergeBot', required: true, index: true },
session: { type: mongoose.Schema.Types.ObjectId, ref: 'WhatsAppChatSession' },
phoneNumber: { type: String, trim: true },
organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' },
sendAt: { type: Date, index: true },
status: {
type: String,
enum: ['pending', 'sent', 'skipped', 'failed'],
default: 'pending',
index: true,
},
dripId: { type: String, default: '', trim: true, index: true },
lastError: { type: String, default: '' },
sentAt: Date,
},
{ timestamps: true, collection: 'whatsappdripjobs' },
);
whatsAppDripJobSchema.index({ botId: 1, dripId: 1, status: 1 });
module.exports =
mongoose.models.WhatsAppDripJob || mongoose.model('WhatsAppDripJob', whatsAppDripJobSchema);

View File

@ -37,7 +37,6 @@ const whatsAppTemplateSchema = new mongoose.Schema(
createdBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
},
},
{ timestamps: true }

View File

@ -16,6 +16,28 @@ const settingsSchema = Joi.object({
allowAssignBlank: Joi.boolean(),
publicLinkEnabled: Joi.boolean(),
audience: audienceSchema.optional(),
whatsappNotify: Joi.object({
enabled: Joi.boolean(),
whatsappTemplateId: Joi.alternatives().try(Joi.string().hex().length(24), Joi.valid(null, '')),
waHeaderImageUrl: Joi.string().allow('').max(2000),
waOverrides: Joi.object({
header: Joi.object().pattern(Joi.string(), Joi.object({
kind: Joi.string().valid('merge', 'fixed'),
key: Joi.string().allow(''),
value: Joi.string().allow(''),
}).unknown(true)).optional(),
body: Joi.object().pattern(Joi.string(), Joi.object({
kind: Joi.string().valid('merge', 'fixed'),
key: Joi.string().allow(''),
value: Joi.string().allow(''),
}).unknown(true)).optional(),
url: Joi.object().pattern(Joi.string(), Joi.object({
kind: Joi.string().valid('merge', 'fixed'),
key: Joi.string().allow(''),
value: Joi.string().allow(''),
}).unknown(true)).optional(),
}).unknown(true).allow(null).optional(),
}).optional(),
});
const createPayload = Joi.object({

View File

@ -28,6 +28,7 @@ const faqItemSchema = Joi.object({
'venue_parking',
'venue_parking_transport',
'venue_food',
'accommodation',
'venue_accessibility',
'wifi',
'app_account',
@ -77,6 +78,8 @@ const createPayload = Joi.object({
knowledge: knowledgeSchema,
faqs: Joi.array().items(faqItemSchema),
enabled: Joi.boolean(),
voiceEnabled: Joi.boolean(),
videoAvatarEnabled: Joi.boolean(),
});
const ctaItemSchema = Joi.object({
@ -100,6 +103,7 @@ const receptionDripSchema = Joi.object({
const receptionSchema = Joi.object({
enabled: Joi.boolean(),
allowNonRegistered: Joi.boolean(),
prefillText: Joi.string().allow(''),
welcomeMessage: Joi.string().allow(''),
unmatchedBadgePrompt: Joi.string().allow(''),
@ -118,6 +122,8 @@ const updatePayload = Joi.object({
knowledge: knowledgeSchema,
faqs: Joi.array().items(faqItemSchema),
enabled: Joi.boolean(),
voiceEnabled: Joi.boolean(),
videoAvatarEnabled: Joi.boolean(),
reception: receptionSchema,
}).min(1);
@ -142,12 +148,58 @@ module.exports = [
{
method: 'GET',
path: '/api/concierge-bots/{botId}/reception',
options: { handler: controller.getReception },
options: {
validate: {
query: Joi.object({
logo: Joi.alternatives().try(Joi.boolean(), Joi.string().valid('0', '1', 'true', 'false', 'yes', 'no')),
}),
},
handler: controller.getReception,
},
},
{
method: 'GET',
path: '/api/concierge-bots/{botId}/reception-qr.png',
options: { handler: controller.getReceptionQrPng },
options: {
validate: {
query: Joi.object({
logo: Joi.alternatives().try(Joi.boolean(), Joi.string().valid('0', '1', 'true', 'false', 'yes', 'no')),
}),
},
handler: controller.getReceptionQrPng,
},
},
{
method: 'GET',
path: '/api/concierge-bots/{botId}/reception-qr.svg',
options: {
validate: {
query: Joi.object({
logo: Joi.alternatives().try(Joi.boolean(), Joi.string().valid('0', '1', 'true', 'false', 'yes', 'no')),
}),
},
handler: controller.getReceptionQrSvg,
},
},
{
method: 'GET',
path: '/api/concierge-bots/{botId}/drips/{dripId}/stats',
options: {
validate: {
params: Joi.object({
botId: Joi.string().hex().length(24).required(),
dripId: Joi.string().trim().min(1).max(128).required(),
}),
},
handler: controller.getDripStats,
},
},
{
method: 'POST',
path: '/api/concierge-bots/{botId}/reception/sessions/clear',
options: {
handler: controller.clearReceptionSessions,
},
},
{ method: 'PUT', path: '/api/concierge-bots/{botId}', options: { validate: { payload: updatePayload }, handler: controller.update } },
{ method: 'DELETE', path: '/api/concierge-bots/{botId}', options: { handler: controller.remove } },

View File

@ -0,0 +1,70 @@
const Joi = require('joi');
const controller = require('../controllers/engagement.controller');
const eventHeaderSchema = Joi.object({
showLogo: Joi.boolean(),
showBanner: Joi.boolean(),
showTitle: Joi.boolean(),
showDateRange: Joi.boolean(),
showEventType: Joi.boolean(),
showLocationType: Joi.boolean(),
showVenue: Joi.boolean(),
});
const flowPlacement = Joi.object({
placement: Joi.string().valid('off', 'flow', 'profile'),
});
const profileOnlyPlacement = Joi.object({
placement: Joi.string().valid('off', 'profile'),
});
const coreFieldSchema = Joi.object({
show: Joi.boolean(),
required: Joi.boolean(),
});
const customFieldSchema = Joi.object({
key: Joi.string().trim().max(40).allow(''),
label: Joi.string().trim().max(80).required(),
type: Joi.string().valid('text', 'textarea', 'email', 'phone', 'select'),
required: Joi.boolean(),
showOnProfile: Joi.boolean(),
options: Joi.array().items(Joi.string().trim().allow('').max(80)).max(40),
});
const updatePayload = Joi.object({
enabled: Joi.boolean(),
eventHeader: eventHeaderSchema,
features: Joi.object({
aiAvatar: flowPlacement,
socialShare: flowPlacement,
photoWall: profileOnlyPlacement,
photoSearch: profileOnlyPlacement,
}),
profileFields: Joi.object({
name: coreFieldSchema,
email: coreFieldSchema,
custom: Joi.array().items(customFieldSchema).max(20),
}),
}).min(1);
module.exports = [
{
method: 'GET',
path: '/api/events/{eventId}/engagement',
options: {
auth: 'jwt',
handler: controller.getEngagement,
},
},
{
method: 'PATCH',
path: '/api/events/{eventId}/engagement',
options: {
auth: 'jwt',
validate: { payload: updatePayload },
handler: controller.updateEngagement,
},
},
];

View File

@ -53,6 +53,8 @@ const updatePayload = Joi.object({
createParticipant: Joi.boolean(),
moderationMode: Joi.string().valid('auto', 'approval'),
photosPerRow: Joi.number().integer().min(1).max(12),
totalRows: Joi.number().integer().min(1).max(8),
marqueeDirection: Joi.string().valid('left', 'right', 'alternate'),
maxPhotoWidth: Joi.number().integer().min(80).max(1200),
maxPhotoHeight: Joi.number().integer().min(80).max(1200),
gapPx: Joi.number().integer().min(0).max(64),

View File

@ -0,0 +1,97 @@
const Joi = require('joi');
const controller = require('../controllers/publicEngagement.controller');
const profilePayload = Joi.object({
name: Joi.string().allow('').max(120),
firstName: Joi.string().allow('').max(80),
lastName: Joi.string().allow('').max(80),
displayName: Joi.string().allow('').max(120),
email: Joi.string().allow('').max(160),
customFields: Joi.object().unknown(true),
}).min(1);
const progressPayload = Joi.object({
step: Joi.string().valid('setup', 'aiAvatar', 'socialShare').required(),
url: Joi.string().allow('').max(2000),
});
const mediaPayload = Joi.object({
kind: Joi.string().valid('dp', 'aiAvatar', 'socialShare', 'photoWall').required(),
url: Joi.string().required().max(2000),
sourceUrl: Joi.string().allow('').max(2000),
});
const wallPayload = Joi.object({
url: Joi.string().required().max(2000),
});
module.exports = [
{
method: 'GET',
path: '/api/public/engagement/{eventId}/participants/{participantId}',
options: {
auth: false,
handler: controller.getEngagementProfile,
},
},
{
method: 'POST',
path: '/api/public/engagement/{eventId}/participants/{participantId}/photo',
options: {
auth: false,
payload: {
maxBytes: 20 * 1024 * 1024,
output: 'stream',
parse: true,
multipart: true,
allow: 'multipart/form-data',
timeout: false,
},
handler: controller.uploadEngagementPhoto,
},
},
{
method: 'PATCH',
path: '/api/public/engagement/{eventId}/participants/{participantId}/profile',
options: {
auth: false,
validate: { payload: profilePayload },
handler: controller.patchEngagementProfile,
},
},
{
method: 'POST',
path: '/api/public/engagement/{eventId}/participants/{participantId}/progress',
options: {
auth: false,
validate: { payload: progressPayload },
handler: controller.markEngagementProgress,
},
},
{
method: 'POST',
path: '/api/public/engagement/{eventId}/participants/{participantId}/media',
options: {
auth: false,
validate: { payload: mediaPayload },
handler: controller.saveEngagementMedia,
},
},
{
method: 'POST',
path: '/api/public/engagement/{eventId}/participants/{participantId}/wall',
options: {
auth: false,
validate: { payload: wallPayload },
handler: controller.postGalleryToWall,
},
},
{
method: 'GET',
path: '/api/public/engagement/{eventId}/participants/{participantId}/file',
options: {
auth: false,
handler: controller.downloadEngagementFile,
},
},
];

View File

@ -8,6 +8,7 @@ const {
buildAudienceMatchStages,
participantAllowedByAudience,
} = require('../utils/attendanceAudience');
const { scheduleAttendanceWhatsAppNotify } = require('./attendanceWhatsAppNotify.service');
function participantDisplayName(p) {
const parts = [p.firstName, p.lastName].filter(Boolean);
@ -404,6 +405,12 @@ async function performBulkCheckInSync({
await syncEventMembershipCheckIn(participant, eventIdStr, ts.checkedInAt);
scheduleAttendanceWhatsAppNotify({
attendance,
attendanceParticipant: ap,
participant,
});
const checkInCount = ap.checkIns.length;
const lastCheckedInAt = ap.checkIns[checkInCount - 1]?.checkedInAt || null;
opToAp.set(clientOpId, {
@ -682,6 +689,12 @@ async function performAssignBlank({
await syncEventMembershipCheckIn(participant, eventIdStr, entry.checkedInAt);
scheduleAttendanceWhatsAppNotify({
attendance,
attendanceParticipant: ap,
participant,
});
return {
result: formatCheckInResult(attendance, ap, participant),
};
@ -766,6 +779,12 @@ async function performCheckIn({ attendanceId, publicToken, participantId, source
await syncEventMembershipCheckIn(participant, eventIdStr);
scheduleAttendanceWhatsAppNotify({
attendance,
attendanceParticipant: ap,
participant,
});
const flat = flattenParticipantForEvent(participant.toObject(), eventIdStr);
return {
result: formatCheckInResult(attendance, ap, participant),

View File

@ -0,0 +1,137 @@
const mongoose = require('mongoose');
const AttendanceParticipant = require('../models/AttendanceParticipant');
const WhatsAppTemplate = require('../models/WhatsAppTemplate');
const Event = require('../models/Event');
const Participant = require('../models/Participant');
const { getOrgWhatsAppCredentials, sendTemplateMessage } = require('../utils/metaWhatsApp');
const { buildWhatsAppTemplatePayload } = require('../utils/waTemplateSendParams');
const { flattenParticipantForEvent } = require('../utils/participantMembership');
const { normalizeWhatsAppTo } = require('../utils/phone');
async function clearNotifyClaim(apId) {
try {
await AttendanceParticipant.updateOne(
{ _id: apId },
{ $set: { whatsappNotifySentAt: null } },
);
} catch (e) {
console.error('[attendanceWhatsAppNotify] clear claim failed', e.message);
}
}
/**
* Send WhatsApp template once per attendance participant after successful check-in.
* Fire-and-forget safe: never throws to caller.
*
* @param {object} opts
* @param {object} opts.attendance - lean or doc with settings + organization + event
* @param {object} opts.attendanceParticipant - AP doc after check-in saved
* @param {object} [opts.participant] - Participant doc/lean (optional; loaded if missing)
*/
async function maybeSendAttendanceWhatsAppNotify({ attendance, attendanceParticipant, participant }) {
try {
const notify = attendance?.settings?.whatsappNotify;
if (!notify?.enabled) return;
const tplId = notify.whatsappTemplateId;
if (!tplId || !mongoose.Types.ObjectId.isValid(String(tplId))) return;
const apId = attendanceParticipant?._id;
if (!apId) return;
const claimed = await AttendanceParticipant.findOneAndUpdate(
{
_id: apId,
$or: [{ whatsappNotifySentAt: null }, { whatsappNotifySentAt: { $exists: false } }],
},
{ $set: { whatsappNotifySentAt: new Date() } },
{ new: true },
);
if (!claimed) return;
const orgId = String(attendance.organization || '');
const eventId = String(attendance.event || '');
const participantId = String(
attendanceParticipant.participant?._id || attendanceParticipant.participant || '',
);
const creds = orgId ? await getOrgWhatsAppCredentials(orgId) : null;
if (!creds?.accessToken || !creds.phoneNumberId) {
console.warn('[attendanceWhatsAppNotify] WhatsApp not configured', { orgId, apId: String(apId) });
await clearNotifyClaim(apId);
return;
}
const tpl = await WhatsAppTemplate.findById(tplId).lean();
if (!tpl || String(tpl.status || '').toUpperCase() !== 'APPROVED') {
console.warn('[attendanceWhatsAppNotify] template missing or not APPROVED', {
tplId: String(tplId),
});
await clearNotifyClaim(apId);
return;
}
const eventDoc = await Event.findById(eventId).lean();
if (!eventDoc) {
await clearNotifyClaim(apId);
return;
}
let pDoc = participant;
if (!pDoc || !pDoc.toObject) {
pDoc = await Participant.findById(participantId);
}
if (!pDoc) {
await clearNotifyClaim(apId);
return;
}
const flat = flattenParticipantForEvent(
typeof pDoc.toObject === 'function' ? pDoc.toObject() : pDoc,
eventId,
);
if (!flat) {
await clearNotifyClaim(apId);
return;
}
const to = normalizeWhatsAppTo(flat.phone || attendanceParticipant.phone || '');
if (!to) {
console.warn('[attendanceWhatsAppNotify] no phone', { participantId, apId: String(apId) });
await clearNotifyClaim(apId);
return;
}
const content = {
whatsappTemplateId: String(tplId),
waHeaderImageUrl: String(notify.waHeaderImageUrl || '').trim(),
waOverrides: notify.waOverrides || undefined,
};
let built;
try {
built = buildWhatsAppTemplatePayload(tpl, content, flat, eventDoc);
} catch (e) {
console.error('[attendanceWhatsAppNotify] build failed', e.message);
await clearNotifyClaim(apId);
return;
}
await sendTemplateMessage(creds.phoneNumberId, creds.accessToken, to, built.template);
} catch (e) {
console.error('[attendanceWhatsAppNotify]', e.message || e);
if (attendanceParticipant?._id) {
await clearNotifyClaim(attendanceParticipant._id);
}
}
}
function scheduleAttendanceWhatsAppNotify(opts) {
setImmediate(() => {
void maybeSendAttendanceWhatsAppNotify(opts);
});
}
module.exports = {
maybeSendAttendanceWhatsAppNotify,
scheduleAttendanceWhatsAppNotify,
};

View File

@ -23,6 +23,26 @@ function nearText(location) {
return location ? ` near ${location}` : ' near the venue';
}
function accommodationFaqText(bot) {
const rows = Array.isArray(bot?.faqs) ? bot.faqs : [];
const row = rows.find((f) => {
const key = String(f?.key || '').trim();
const cat = String(f?.category || '').trim();
return key === 'accommodation' || cat === 'accommodation';
});
if (!row) return '';
const a = String(row.answer || '').trim();
const d = String(row.answerDetail || '').trim();
return [a, d].filter(Boolean).join('\n');
}
function faqSaysOrganizerProvides(text) {
const t = String(text || '').toLowerCase();
if (!t) return false;
if (/\bno, book your own\b|\bbook your own stay\b|\bpartner hotel\b/.test(t)) return false;
return /\byes, arranged\b|\barranged by the organizer\b|\bselected attendees\b/.test(t);
}
/** Normalize a stored hotel record into a public accommodation card payload. */
function publicHotelFromRecord(hotel) {
const id = String(hotel?.place_id || hotel?.placeId || hotel?.id || '').trim();
@ -100,7 +120,23 @@ const FALLBACKS = {
module.exports = function createLogisticsHandler({ field, topic }) {
return async function logisticsHandler({ plan, bot, event, session, turnLog }) {
const logistics = await loadLogistics(String(bot.event));
const hostManaged = Boolean(logistics[field]);
const faqText = topic === 'accommodation' ? accommodationFaqText(bot) : '';
const hostManaged = faqText
? faqSaysOrganizerProvides(faqText)
: Boolean(logistics[field]);
if (faqText && topic === 'accommodation') {
if (hostManaged) {
return { reply: faqText, usedCall2: false };
}
const cards = await buildAccommodationCards(String(bot.event), logistics.location);
return {
reply: faqText,
cards: cards.map((data) => ({ type: 'accommodation', data })),
places: cards,
usedCall2: false,
};
}
if (hostManaged) {
const call2 = await generateConciergeResponse({ bot, event, session, plan, turnLog });

View File

@ -13,20 +13,53 @@ function rejectionFromMeta(m) {
return String(m.rejected_reason || m.reason || '').trim();
}
function normalizeCategory(raw) {
const c = String(raw || '')
.trim()
.toUpperCase();
if (c === 'MARKETING' || c === 'UTILITY' || c === 'AUTHENTICATION') return c;
return 'UTILITY';
}
async function resolveCreatedBy(organizationId, locals) {
for (const loc of locals || []) {
if (loc.createdBy) return loc.createdBy;
}
try {
const User = require('../models/User');
const u = await User.findOne({ organization: organizationId }).select('_id').lean();
if (u?._id) return u._id;
} catch {
/* ignore */
}
return undefined;
}
/**
* Pull template rows from Meta and update local docs (by metaTemplateId or name+language).
* Pull template rows from Meta and upsert local docs (by metaTemplateId or name+language).
* Creates local rows for Meta templates that are not yet in the dashboard.
* @param {import('mongoose').Types.ObjectId|string} organizationId
* @param {{ onlyNames?: string[] }} [opts] — when set, only upsert these Meta template names
*/
async function syncOrganizationTemplates(organizationId) {
async function syncOrganizationTemplates(organizationId, opts = {}) {
const creds = await getOrgWhatsAppCredentials(organizationId);
if (!creds) {
return { ok: false, error: 'WhatsApp Cloud is not configured for this organisation (WABA + access token).' };
}
const data = await listMessageTemplates(creds.wabaId, creds.accessToken);
const list = Array.isArray(data.data) ? data.data : [];
let list = Array.isArray(data.data) ? data.data : [];
const onlyNames = Array.isArray(opts.onlyNames)
? opts.onlyNames.map((n) => String(n || '').trim().toLowerCase()).filter(Boolean)
: null;
if (onlyNames && onlyNames.length) {
list = list.filter((m) => onlyNames.includes(String(m.name || '').trim().toLowerCase()));
}
const locals = await WhatsAppTemplate.find({ organization: organizationId }).lean();
const now = new Date();
let updated = 0;
let created = 0;
for (const loc of locals) {
let m = null;
@ -38,22 +71,73 @@ async function syncOrganizationTemplates(organizationId) {
}
if (m) {
const st = m.status != null ? String(m.status).toUpperCase() : loc.status;
await WhatsAppTemplate.updateOne(
{ _id: loc._id },
{
$set: {
metaTemplateId: String(m.id),
status: st,
rejectionReason: rejectionFromMeta(m),
lastSyncedAt: now,
lastError: '',
},
}
);
const $set = {
metaTemplateId: String(m.id),
status: st,
rejectionReason: rejectionFromMeta(m),
lastSyncedAt: now,
lastError: '',
};
if (m.category) $set.category = normalizeCategory(m.category);
if (Array.isArray(m.components) && m.components.length) $set.components = m.components;
await WhatsAppTemplate.updateOne({ _id: loc._id }, { $set });
updated += 1;
}
}
return { ok: true, metaCount: list.length };
const createdBy = await resolveCreatedBy(organizationId, locals);
for (const m of list) {
const name = String(m.name || '').trim();
const language = String(m.language || '').trim().replace(/-/g, '_');
if (!name || !language) continue;
const exists = locals.some(
(loc) =>
(loc.metaTemplateId && String(loc.metaTemplateId) === String(m.id)) ||
(loc.name === name && normLang(loc.language) === normLang(language)),
);
if (exists) continue;
try {
await WhatsAppTemplate.create({
organization: organizationId,
name,
language,
category: normalizeCategory(m.category),
components: Array.isArray(m.components) ? m.components : [],
metaTemplateId: m.id != null ? String(m.id) : '',
status: m.status != null ? String(m.status).toUpperCase() : 'PENDING',
rejectionReason: rejectionFromMeta(m),
lastSyncedAt: now,
lastError: '',
variableBindings: { header: [], body: [], url: [] },
...(createdBy ? { createdBy } : {}),
});
created += 1;
} catch (e) {
if (e && e.code === 11000) {
await WhatsAppTemplate.updateOne(
{ organization: organizationId, name, language },
{
$set: {
metaTemplateId: m.id != null ? String(m.id) : '',
status: m.status != null ? String(m.status).toUpperCase() : 'PENDING',
rejectionReason: rejectionFromMeta(m),
lastSyncedAt: now,
lastError: '',
...(m.category ? { category: normalizeCategory(m.category) } : {}),
...(Array.isArray(m.components) && m.components.length ? { components: m.components } : {}),
},
},
);
updated += 1;
continue;
}
throw e;
}
}
return { ok: true, metaCount: list.length, updated, created };
}
module.exports = { syncOrganizationTemplates };

View File

@ -7,6 +7,7 @@ const VENUE_FAQ_CATEGORIES = [
/** @deprecated kept for legacy rows during ensure migration */
'venue_parking_transport',
'venue_food',
'accommodation',
'venue_accessibility',
'checkin_venue',
'emergency_contact',
@ -105,6 +106,18 @@ const ALL_FAQ_TEMPLATES = [
answerFormat: 'mcq',
options: ['All meals included', 'Lunch only', 'Tea & Coffee only', 'Nearby restaurants'],
},
{
key: 'accommodation',
category: 'accommodation',
question: 'Is accommodation provided for attendees?',
answerFormat: 'mcq_short_text',
options: [
'Yes, arranged by the organizer',
'Selected attendees only',
'Partner hotel rates',
'No, book your own stay',
],
},
{
key: 'venue_amenity',
category: 'venue_amenity',

View File

@ -0,0 +1,132 @@
const Engagement = require('../models/Engagement');
const { normalizeEventHeaderDisplay } = require('./formPublicHelpers');
const CUSTOM_FIELD_TYPES = Engagement.CUSTOM_FIELD_TYPES || ['text', 'textarea', 'email', 'phone', 'select'];
const FLOW_KEYS = ['aiAvatar', 'socialShare'];
const PROFILE_ONLY_KEYS = ['photoWall', 'photoSearch'];
function slugifyKey(label, fallback = 'field') {
const base = String(label || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 40);
return base || fallback;
}
function uniqueCustomKey(rawKey, label, used) {
let key = slugifyKey(rawKey || label);
if (key === 'name' || key === 'email' || key === 'photo' || key === 'photoUrl') {
key = `${key}_field`;
}
let candidate = key;
let n = 1;
while (used.has(candidate)) {
n += 1;
candidate = `${key}_${n}`;
}
used.add(candidate);
return candidate;
}
function normalizeCoreField(raw, defaults) {
const src = raw && typeof raw === 'object' ? raw : {};
const show = typeof src.show === 'boolean' ? src.show : defaults.show;
const required = typeof src.required === 'boolean' ? src.required : defaults.required;
return { show, required: show ? required : false };
}
function normalizeCustomField(raw, used) {
if (!raw || typeof raw !== 'object') return null;
const label = String(raw.label || '').trim();
if (!label) return null;
const type = CUSTOM_FIELD_TYPES.includes(raw.type) ? raw.type : 'text';
const options =
type === 'select'
? (Array.isArray(raw.options) ? raw.options : [])
.map((o) => String(o || '').trim())
.filter(Boolean)
.slice(0, 40)
: [];
return {
key: uniqueCustomKey(raw.key, label, used),
label,
type,
required: Boolean(raw.required),
showOnProfile: Boolean(raw.showOnProfile),
options,
};
}
function normalizeProfileFields(raw) {
const src = raw && typeof raw === 'object' ? raw : {};
const used = new Set();
const custom = Array.isArray(src.custom)
? src.custom.map((f) => normalizeCustomField(f, used)).filter(Boolean).slice(0, 20)
: [];
return {
name: normalizeCoreField(src.name, { show: true, required: true }),
email: normalizeCoreField(src.email, { show: true, required: false }),
custom,
};
}
function normalizePlacement(raw, profileOnly) {
const allowed = profileOnly ? ['off', 'profile'] : ['off', 'flow', 'profile'];
const value = String(raw?.placement || raw || 'off').trim();
return { placement: allowed.includes(value) ? value : 'off' };
}
function normalizeFeatures(raw) {
const src = raw && typeof raw === 'object' ? raw : {};
return {
aiAvatar: normalizePlacement(src.aiAvatar, false),
socialShare: normalizePlacement(src.socialShare, false),
photoWall: normalizePlacement(src.photoWall, true),
photoSearch: normalizePlacement(src.photoSearch, true),
};
}
function defaultEngagementDoc(eventId, organizationId) {
return {
event: eventId,
organization: organizationId,
enabled: false,
eventHeader: normalizeEventHeaderDisplay(null),
features: normalizeFeatures(null),
profileFields: normalizeProfileFields(null),
};
}
function serializeEngagement(config) {
return {
_id: config._id,
event: config.event,
organization: config.organization,
enabled: Boolean(config.enabled),
eventHeader: normalizeEventHeaderDisplay(config.eventHeader),
features: normalizeFeatures(config.features),
profileFields: normalizeProfileFields(config.profileFields),
createdAt: config.createdAt,
updatedAt: config.updatedAt,
};
}
async function getOrCreateEngagement(eventId, organizationId) {
let config = await Engagement.findOne({ event: eventId });
if (!config) {
config = await Engagement.create(defaultEngagementDoc(eventId, organizationId));
}
return config;
}
module.exports = {
FLOW_KEYS,
PROFILE_ONLY_KEYS,
normalizeFeatures,
normalizeProfileFields,
serializeEngagement,
getOrCreateEngagement,
defaultEngagementDoc,
};

View File

@ -10,6 +10,19 @@ const {
const DAY_DATE_KEYS = ['2026-08-07', '2026-08-08'];
const DEFAULT_TRACK_NAME = 'Main programme';
function dateKeyFromLabel(dateStr) {
const raw = String(dateStr || '').trim();
if (!raw) return null;
const iso = raw.match(/^(\d{4}-\d{2}-\d{2})/);
if (iso) return iso[1];
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime())) return null;
const y = parsed.getFullYear();
const m = String(parsed.getMonth() + 1).padStart(2, '0');
const d = String(parsed.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
/**
* Resolve the default agenda track for imported sessions.
* Prefers an existing "Main programme" track; otherwise first track; creates one if none exist.
@ -57,15 +70,18 @@ async function assignJoinCodes(sessionDocs) {
*
* @param {string} eventId
* @param {Array<{ label: string, date: string, sessions: object[] }>} days
* @param {{ dateKeys?: string[] }} [options]
* @returns {Promise<{ deleted: number, created: number, trackId: string }>}
*/
async function importNovelScheduleToAgenda(eventId, days) {
async function importNovelScheduleToAgenda(eventId, days, options = {}) {
const trackId = await resolveDefaultTrackId(eventId);
const deletedResult = await Session.deleteMany({ event: eventId });
const fallbackKeys = Array.isArray(options.dateKeys) && options.dateKeys.length ? options.dateKeys : DAY_DATE_KEYS;
const payloads = [];
(days || []).forEach((day, dayIndex) => {
const dateKey = DAY_DATE_KEYS[dayIndex] || DAY_DATE_KEYS[0];
const dateKey =
dateKeyFromLabel(day?.date) || fallbackKeys[dayIndex] || fallbackKeys[0];
(day.sessions || []).forEach((session) => {
const range = parseSessionTimeRange(session.time, dateKey, dayIndex);
if (!range) return;

View File

@ -496,7 +496,7 @@ async function assertPhoneNumberOnWaba(wabaId, phoneNumberId, accessToken) {
async function listMessageTemplates(wabaId, accessToken, query = {}) {
const qs = new URLSearchParams({
limit: '500',
fields: 'name,status,language,id,category,reason,rejected_reason',
fields: 'name,status,language,id,category,reason,rejected_reason,components',
...query,
});
const url = `${graphBaseUrl()}/${encodeURIComponent(wabaId)}/message_templates?${qs.toString()}`;

View File

@ -93,7 +93,7 @@ function formatScheduleTimeDisplay(timeStr, dayIndex = 0) {
if (/\b(am|pm|a\.m|p\.m)\b/i.test(raw)) return raw;
const separator = /\s+to\s+/i.test(raw) ? ' to ' : ' - ';
const parts = raw.split(/\s+to\s+|\s*-\s*/i).map(trim).filter(Boolean);
const parts = raw.split(/\s+to\s+|\s*[–—-]\s*/i).map(trim).filter(Boolean);
if (parts.length < 2) return raw;
return formatClockPart(parts[0], dayIndex) + separator + formatClockPart(parts[1], dayIndex);
@ -225,7 +225,7 @@ function parseSessionTimeRange(timeStr, dateKey, dayIndex = 0) {
const raw = trim(timeStr);
if (!raw) return null;
const parts = raw.split(/\s+to\s+|\s*-\s*/i).map(trim).filter(Boolean);
const parts = raw.split(/\s+to\s+|\s*[–—-]\s*/i).map(trim).filter(Boolean);
if (parts.length < 2) return null;
const parseClock = (part) => {

View File

@ -1,8 +1,14 @@
const QRCode = require('qrcode');
const sharp = require('sharp');
const { getOrgWhatsAppCredentials, listWabaPhoneNumbers } = require('./metaWhatsApp');
const { normalizeWhatsAppTo } = require('./phone');
const DEFAULT_PREFILL = 'Hi';
const QR_SIZE = 440;
/** Official-style WhatsApp glyph (viewBox 0 0 24 24). */
const WHATSAPP_ICON_PATH =
'M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.435 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z';
function receptionPrefill(bot) {
const t = String(bot?.reception?.prefillText || '').trim();
@ -44,16 +50,80 @@ function buildWaMeUrl(digits, prefillText) {
return `https://wa.me/${to}?text=${text}`;
}
async function generateReceptionQrPng(digits, prefillText) {
function qrOptions(withLogo) {
return {
margin: 2,
width: QR_SIZE,
errorCorrectionLevel: withLogo ? 'H' : 'M',
color: { dark: '#0f172a', light: '#ffffff' },
};
}
function whatsappLogoOverlaySvg(sizePx) {
const size = Math.max(24, Math.round(sizePx));
const inset = Math.round(size * 0.12);
const icon = size - inset * 2;
return Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<circle cx="${size / 2}" cy="${size / 2}" r="${size / 2}" fill="#ffffff"/>
<svg x="${inset}" y="${inset}" width="${icon}" height="${icon}" viewBox="0 0 24 24">
<path fill="#25D366" d="${WHATSAPP_ICON_PATH}"/>
</svg>
</svg>`,
);
}
function injectWhatsAppLogoIntoQrSvg(svg) {
const match = String(svg).match(/viewBox=["']0\s+0\s+([\d.]+)\s+([\d.]+)["']/i);
const size = match ? Number(match[1]) : QR_SIZE;
const logoBox = size * 0.22;
const pad = logoBox * 0.12;
const outer = logoBox + pad * 2;
const cx = size / 2;
const cy = size / 2;
const x = cx - logoBox / 2;
const y = cy - logoBox / 2;
const overlay = `
<g id="whatsapp-logo">
<circle cx="${cx}" cy="${cy}" r="${outer / 2}" fill="#ffffff"/>
<svg x="${x}" y="${y}" width="${logoBox}" height="${logoBox}" viewBox="0 0 24 24">
<path fill="#25D366" d="${WHATSAPP_ICON_PATH}"/>
</svg>
</g>
`;
if (/<\/svg>\s*$/i.test(svg)) {
return String(svg).replace(/<\/svg>\s*$/i, `${overlay}</svg>`);
}
return `${svg}${overlay}`;
}
async function generateReceptionQrSvg(digits, prefillText, opts = {}) {
const withLogo = Boolean(opts.withLogo);
const url = buildWaMeUrl(digits, prefillText);
if (!url) throw new Error('WhatsApp number is not available');
return QRCode.toBuffer(url, {
type: 'png',
width: 440,
margin: 2,
errorCorrectionLevel: 'M',
color: { dark: '#0f172a', light: '#ffffff' },
const svg = await QRCode.toString(url, {
type: 'svg',
...qrOptions(withLogo),
});
return withLogo ? injectWhatsAppLogoIntoQrSvg(svg) : svg;
}
async function generateReceptionQrPng(digits, prefillText, opts = {}) {
const withLogo = Boolean(opts.withLogo);
const url = buildWaMeUrl(digits, prefillText);
if (!url) throw new Error('WhatsApp number is not available');
const buf = await QRCode.toBuffer(url, {
type: 'png',
...qrOptions(withLogo),
});
if (!withLogo) return buf;
const logoSize = Math.round(QR_SIZE * 0.26);
const overlay = await sharp(whatsappLogoOverlaySvg(logoSize)).png().toBuffer();
return sharp(buf)
.composite([{ input: overlay, gravity: 'centre' }])
.png()
.toBuffer();
}
module.exports = {
@ -62,4 +132,5 @@ module.exports = {
getOrgWhatsAppDisplayDigits,
buildWaMeUrl,
generateReceptionQrPng,
generateReceptionQrSvg,
};

View File

@ -17,6 +17,7 @@ const PHOTO_ACTIVITIES = {
photoSearch: 'photo-search',
aiAvatar: 'ai-avatar',
socialShare: 'social-share',
engagement: 'engagement',
};
/** Kind folder names under {eventId}/{activity}/… */

View File

@ -6,6 +6,14 @@
* @param {{ strict?: boolean }} [opts] — when strict, throws on mixed types / multi-URL
* @returns {{ type: 'reply'|'url', label: string, value: string }[]}
*/
function isPlaceholderCtaUrl(value) {
return /\{engagementUrl\}|\{eventId\}|\{participantId\}/i.test(String(value || ''));
}
function isHttpCtaUrl(value) {
return /^https?:\/\//i.test(String(value || '').trim());
}
function normalizeWhatsAppCtas(raw, opts = {}) {
const strict = Boolean(opts.strict);
if (!Array.isArray(raw) || raw.length === 0) return [];
@ -23,8 +31,10 @@ function normalizeWhatsAppCtas(raw, opts = {}) {
if (!value && type === 'reply') value = label;
if (!value) continue;
if (type === 'url') {
if (!/^https?:\/\//i.test(value)) {
if (strict) throw new Error('URL buttons must start with http:// or https://');
if (!isHttpCtaUrl(value) && !isPlaceholderCtaUrl(value)) {
if (strict) {
throw new Error('URL buttons must start with http:// or https://, or use {engagementUrl}');
}
continue;
}
value = value.slice(0, 2000);
@ -50,4 +60,4 @@ function normalizeWhatsAppCtas(raw, opts = {}) {
return rows;
}
module.exports = { normalizeWhatsAppCtas };
module.exports = { normalizeWhatsAppCtas, isHttpCtaUrl, isPlaceholderCtaUrl };