/**
* Creative Waco — Add to calendar widget for Webflow CMS event pages.
*
* Usage (Event template): wrap a button + menu root in an element with
* `data-cw-add-to-calendar` and bind CMS fields to data attributes (see below).
*
* Required attributes:
* data-cw-title — Event name (Plain Text / Name)
* data-cw-start — Start DateTime (ISO 8601), e.g. from Start DateTime field
*
* Optional:
* data-cw-end — End DateTime (ISO). If omitted, defaults to 1 hour after start.
* data-cw-location — Plain Text location
* data-cw-description — Plain Text or short excerpt (avoid Rich Text in attributes)
* data-cw-url — Canonical event page URL (Link or full URL field)
* data-cw-uid-slug — Stable id segment (e.g. CMS slug) for recurring ICS UID
*
* Webflow CMS field slugs (Events collection): name, slug, start-date-time,
* end-date-time, location, short-description, primary-cta-url, etc.
*
* Load after DOM: (optional)
*
*
* Trigger (open menu): an element inside the root with class `.cw-cal__trigger`
* OR custom attribute `data-cw-cal-trigger` (use one or the other on your Webflow
* Button / Link so the control can be styled in Designer).
*/
(function () {
'use strict';
var ATTR = 'data-cw-add-to-calendar';
/** Prefer attribute hook so a native Webflow button does not need a specific class. */
var TRIGGER_SEL = '[data-cw-cal-trigger], .cw-cal__trigger';
function parseInstant(value) {
if (value == null || value === '') return null;
var s = String(value).trim();
if (/^\d+$/.test(s)) {
var ms = parseInt(s, 10);
var d = new Date(ms);
return isNaN(d.getTime()) ? null : d;
}
var parsed = new Date(s);
return isNaN(parsed.getTime()) ? null : parsed;
}
function stripHtml(html) {
if (!html) return '';
return String(html)
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function pad2(n) {
return (n < 10 ? '0' : '') + n;
}
/** Format as UTC compact: YYYYMMDDTHHmmssZ */
function formatUtcCompact(d) {
return (
d.getUTCFullYear() +
pad2(d.getUTCMonth() + 1) +
pad2(d.getUTCDate()) +
'T' +
pad2(d.getUTCHours()) +
pad2(d.getUTCMinutes()) +
pad2(d.getUTCSeconds()) +
'Z'
);
}
function escapeIcsText(str) {
return String(str)
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\n/g, '\\n')
.replace(/\r/g, '');
}
/** Fold long lines per RFC 5545 (octets ~75; ASCII safe here). */
function foldLine(line) {
var max = 75;
if (line.length <= max) return line;
var out = '';
var rest = line;
while (rest.length > max) {
out += rest.slice(0, max) + '\r\n ';
rest = rest.slice(max);
}
return out + rest;
}
function buildGoogleUrl(ev) {
var dates = formatUtcCompact(ev.start) + '/' + formatUtcCompact(ev.end);
var q = new URLSearchParams();
q.set('action', 'TEMPLATE');
q.set('text', ev.title);
q.set('dates', dates);
if (ev.details) q.set('details', ev.details);
if (ev.location) q.set('location', ev.location);
return 'https://calendar.google.com/calendar/render?' + q.toString();
}
function buildOutlookUrl(ev, origin) {
var q = new URLSearchParams();
q.set('subject', ev.title);
q.set('startdt', ev.start.toISOString());
q.set('enddt', ev.end.toISOString());
if (ev.details) q.set('body', ev.details);
if (ev.location) q.set('location', ev.location);
return origin + '/calendar/0/deeplink/compose?' + q.toString();
}
function buildYahooUrl(ev) {
var q = new URLSearchParams();
q.set('v', '60');
q.set('view', 'd');
q.set('type', '20');
q.set('title', ev.title);
q.set('st', formatUtcCompact(ev.start));
q.set('et', formatUtcCompact(ev.end));
if (ev.details) q.set('desc', ev.details);
if (ev.location) q.set('in_loc', ev.location);
return 'https://calendar.yahoo.com/?' + q.toString();
}
function sanitizeUidSegment(str) {
var s = String(str || 'event')
.replace(/[^\w.-]+/g, '-')
.replace(/^-+|-+$/g, '');
return s || 'event';
}
function buildIcs(ev) {
var uid = sanitizeUidSegment(ev.uidSlug || ev.title) + '@creativewaco.org';
var stamp = formatUtcCompact(new Date());
var lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Creative Waco//Add To Calendar//EN',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'BEGIN:VEVENT',
'UID:' + uid,
'DTSTAMP:' + stamp,
'DTSTART:' + formatUtcCompact(ev.start),
'DTEND:' + formatUtcCompact(ev.end),
'SUMMARY:' + escapeIcsText(ev.title),
];
if (ev.details) {
lines.push('DESCRIPTION:' + escapeIcsText(ev.details));
}
if (ev.location) {
lines.push('LOCATION:' + escapeIcsText(ev.location));
}
if (ev.url) {
lines.push('URL:' + escapeIcsText(ev.url));
}
lines.push('END:VEVENT', 'END:VCALENDAR');
return lines
.map(function (line) {
return foldLine(line);
})
.join('\r\n');
}
function downloadIcs(filename, body) {
var blob = new Blob([body], {
type: 'text/calendar;charset=utf-8',
});
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = filename;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function safeFilename(title) {
var base = title.replace(/[^a-zA-Z0-9-_]+/g, '-').replace(/^-|-$/g, '');
return (base || 'event') + '.ics';
}
function readEventFromEl(root) {
var title = root.getAttribute('data-cw-title');
var start = parseInstant(root.getAttribute('data-cw-start'));
if (!title || !start) return null;
var end = parseInstant(root.getAttribute('data-cw-end'));
if (!end || end <= start) {
end = new Date(start.getTime() + 60 * 60 * 1000);
}
var desc = root.getAttribute('data-cw-description');
var loc = root.getAttribute('data-cw-location');
var pageUrl = root.getAttribute('data-cw-url');
var uidSlug = root.getAttribute('data-cw-uid-slug');
var details = desc ? stripHtml(desc) : '';
var url = pageUrl ? stripHtml(pageUrl) : '';
if (url && details.indexOf(url) === -1) {
details = details ? details + '\n\n' + url : url;
} else if (url && !details) {
details = url;
}
return {
title: title,
start: start,
end: end,
details: details,
location: loc ? stripHtml(loc) : '',
url: url,
uidSlug: uidSlug || '',
};
}
function closeOthers(except) {
document.querySelectorAll('[' + ATTR + '].is-open').forEach(function (el) {
if (el !== except) {
el.classList.remove('is-open');
var b = el.querySelector(TRIGGER_SEL);
if (b) b.setAttribute('aria-expanded', 'false');
}
});
}
function wire(root) {
var ev = readEventFromEl(root);
if (!ev) {
root.style.display = 'none';
if (window.console && console.warn) {
console.warn('[cw-cal] Missing data-cw-title or data-cw-start', root);
}
return;
}
var trigger = root.querySelector(TRIGGER_SEL);
var panel = root.querySelector('.cw-cal__panel');
if (!trigger || !panel) return;
var google = panel.querySelector('[data-cw-cal-link="google"]');
var outlookCom = panel.querySelector('[data-cw-cal-link="outlook"]');
var outlookLive = panel.querySelector('[data-cw-cal-link="outlook-live"]');
var yahoo = panel.querySelector('[data-cw-cal-link="yahoo"]');
var icsBtn = panel.querySelector('[data-cw-cal-link="ics"]');
if (google) google.href = buildGoogleUrl(ev);
if (outlookCom) outlookCom.href = buildOutlookUrl(ev, 'https://outlook.office.com');
if (outlookLive) outlookLive.href = buildOutlookUrl(ev, 'https://outlook.live.com');
if (yahoo) yahoo.href = buildYahooUrl(ev);
if (icsBtn) {
icsBtn.addEventListener('click', function (e) {
e.preventDefault();
var ics = buildIcs(ev);
downloadIcs(safeFilename(ev.title), ics);
root.classList.remove('is-open');
trigger.setAttribute('aria-expanded', 'false');
});
}
trigger.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
var open = !root.classList.contains('is-open');
closeOthers(open ? root : null);
root.classList.toggle('is-open', open);
trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
});
document.addEventListener('click', function () {
root.classList.remove('is-open');
trigger.setAttribute('aria-expanded', 'false');
});
root.addEventListener('click', function (e) {
e.stopPropagation();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
root.classList.remove('is-open');
trigger.setAttribute('aria-expanded', 'false');
}
});
}
function init() {
document.querySelectorAll('[' + ATTR + ']').forEach(wire);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();