Demonstrate Calendar Page (using Fullcalendar).
View this pattern on the live demo site
Event calendar page wrapping the FullCalendar section.
{% comment %}
Display a calendar page using FullCalendar.
{% endcomment -%}
{%- include "/_header.liquid" -%}
{%- include "/sections/_breadcrumbs.liquid" -%}
{%- include "/sections/_fullcalendar.liquid" -%}
{%- include "/sections/_simple_cta.liquid" -%}
{%- include "/_footer.liquid" -%}Interactive calendar; loads event JSON from a feed partial.
{% comment %}
Display a FullCalendar calendar events view
Inputs:
section_class
section_id
Custom Fields:
events_feed_url - article with a URL that uses /feeds/events.json.liquid
calendar_intro_html
calendar_legend_heading
Outputs:
section_rendered - true when this section outputs markup, false otherwise
{% endcomment -%}
{%- if entity.events_feed_url.is_valid and entity.events_feed_url.has_url -%}
{%- var section_id = section_id -%}
{%- unless section_id -%}
{%- id set section_id = prefix:'fc_section_' -%}
{%- endunless -%}
{%- id set calendar_legend_id = prefix:'fc_legend_' -%}
{%- var events_feed = entity.events_feed_url -%}
{%- var feed_calendars = events_feed.calendars -%}
{%- var allow_additional_calendars = events_feed.allow_additional_calendars.checked -%}
{%- add_stylesheet inline -%}
#{{ section_id }} .fc-event-cat-primary { background-color: var(--bs-primary); }
#{{ section_id }} .fc-event-cat-secondary { background-color: var(--bs-secondary); }
#{{ section_id }} .fc-event-cat-dark { background-color: var(--bs-dark); }
#{{ section_id }} .fc-event-cat-default { background-color: var(--bs-info); }
#{{ section_id }} .fc-event-cat-info { background-color: var(--bs-info); }
#{{ section_id }} .fc-event-cat-success { background-color: var(--bs-success); }
#{{ section_id }} .fc-event-cat-warning { background-color: var(--bs-warning); color: #212529; }
{%- end_stylesheet -%}
<section id="{{ section_id }}"{% if section_class %} class="{{ section_class }}"{% endif %}>
<div class="container py-4 py-lg-5">
{%- if entity.calendar_intro_html.is_valid -%}
<div class="mb-4 text-center mx-auto" style="max-width: 40rem;">
{{ entity.calendar_intro_html }}
</div>
{%- endif -%}
{%- if feed_calendars.is_valid or allow_additional_calendars -%}
<div id="{{ calendar_legend_id }}" class="d-flex flex-wrap align-items-center justify-content-center gap-3 mb-4" data-allow-additional="{% if allow_additional_calendars %}true{% else %}false{% endif %}">
{%- var legend_heading = entity.calendar_legend_heading | default: 'Event Types' -%}
<span class="text-uppercase fw-semibold small text-muted" style="letter-spacing: 0.06em;">{{ legend_heading }}</span>
{%- if feed_calendars.is_valid -%}
{%- for entry in feed_calendars -%}
{%- if entry.calendar.is_valid -%}
{%- var scheme = entry.color_scheme.value | default: entry.color_scheme | default: 'primary' -%}
<span class="d-inline-flex align-items-center gap-2 small" data-calendar-label="{{ entry.calendar.title | escape }}">
<span class="rounded-1 fc-event-cat-{{ scheme }}" style="width: 0.9rem; height: 0.9rem; display: inline-block;" aria-hidden="true"></span>
<span>{{ entry.calendar.title }}</span>
</span>
{%- endif -%}
{%- endfor -%}
{%- endif -%}
</div>
{%- endif -%}
<div class="card border-0 shadow-sm rounded-4 overflow-hidden">
<div class="card-body p-3 p-lg-4">
{%- include "/partials/_fullcalendar.liquid" events_feed_url:events_feed.full_url legend_id:calendar_legend_id allow_additional_calendars:allow_additional_calendars -%}
</div>
</div>
</div>
</section>
{%- set section_rendered = true -%}
{%- else -%}
{%- set section_rendered = false -%}
{%- endif -%}
Custom fields owned by the calendar section.
[
{
"Type": "group",
"Fields": [
{
"Type": "article",
"FieldID": "events_feed_url",
"Label": "Events Feed",
"Subtext": "Select the events feed that powers this calendar. Calendar colors and the legend come from that feed.",
"IsRequired": true,
"HasUrl": true,
"TemplateGUID": "/feeds/events.json.liquid"
},
{
"Type": "html",
"FieldID": "calendar_intro_html",
"Label": "Intro Content",
"Subtext": "Optional HTML shown above the calendar"
},
{
"Type": "text",
"FieldID": "calendar_legend_heading",
"Label": "Legend Heading",
"DefaultValue": "Event Types"
}
],
"Label": "Fullcalendar"
}
]
JSON shape for one event in the calendar feed.
{% comment %}
Outputs a single event as a JSON object, compatible with FullCalendar.
Note: The "AllDay" properties has been added as an alias because it is what FullCalendar expects
Inputs:
event - the calendar entry to output
calendar_color_map - optional dictionary mapping calendar titles to CSS class strings.
When valid, looks up event.calendar.guid. Falls back to calendar_color_map.default.
Omits classNames when the map is missing, or when both the calendar key and default are missing.
{% endcomment -%}
{%- var event_class = '' -%}
{%- if calendar_color_map is_valid -%}
{%- capture var cal_key %}{{event.calendar.guid}}{% endcapture -%}
{%- set event_class = calendar_color_map[cal_key] -%}
{%- unless event_class is_valid -%}
{%- set event_class = calendar_color_map.default -%}
{%- if event_class is_list %}{% cycle set event_class 'feed_calendar_event_class':event_class %}{% endif -%}
{%- set_dictionary calendar_color_map = &cal_key:event_class -%}
{%- endunless -%}
{%- endif -%}
{
"title": {{ event.title.value | json_encode }},
"start": {{ event.start_date | date: 'yyyy-MM-ddTHH:mm:ss' | json_encode }},
"end": {{ event.end_date | date: 'yyyy-MM-ddTHH:mm:ss' | json_encode }},
"timezone": {{ event.timezone.value | json_encode }}{% if event.all_day %},
"all_day": true,
"allDay": true{% endif %}{% if event.link.is_valid %},
"link": {{ event.link.value | json_encode }}{% endif %}{% if event.has_url %},
"url": {{ event.full_url | json_encode }}{% endif %}{% if event.image.is_valid %},
"image": {{ event.image.url | json_encode }}{% endif %}{% if event.description.is_valid %},
"description": {{ event.description | json_encode }}{% endif %}{% if event.location.is_valid %},
"location": {{ event.location.value | json_encode }}{% endif %}{% if event.calendar.is_valid %},
"calendar": {{ event.calendar.title.value | json_encode }}{% endif %}{% if event_class is_valid %},
"classNames": [{{ event_class | json_encode }}]{% endif %}
}FullCalendar widget: fetches the JSON feed and opens event details.
{% comment %}
Renders the FullCalendar container and script. Uses events_feed_url for data.
Inputs:
events_feed_url - URL of the JSON events feed (produced by /feeds/events.json.liquid)
legend_id - optional DOM id of the legend container (for dynamic additional-calendar entries)
allow_additional_calendars - when true, append legend items for calendars discovered in the feed that are not already listed
event_modal_id - optional modal id for event details. Defaults to mpEventDetailModal
{% endcomment -%}
{%- add_stylesheet "https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.css" -%}
{%- add_javascript "https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js" defer:true position:'body' -%}
{%- var site_timezone = site.timezone | default: site.default_timezone -%}
{%- var event_modal_id = event_modal_id | default: 'mpEventDetailModal' -%}
{%- include "/partials/_event_detail_modal.liquid" modal_id:event_modal_id -%}
<div id="{% id output_to_template fullcalendar_id = prefix:'fc_' %}" class="mp-fullcalendar" data-events-url="{{ events_feed_url }}" data-modal-id="{{ event_modal_id }}"{% if legend_id is_valid %} data-legend-id="{{ legend_id }}"{% endif %}{% if allow_additional_calendars %} data-allow-additional="true"{% endif %}></div>
{%- add_stylesheet inline -%}
#{{ fullcalendar_id }}.mp-fullcalendar {
--fc-border-color: rgba(33, 37, 41, 0.12);
--fc-page-bg-color: transparent;
--fc-neutral-bg-color: rgba(var(--bs-primary-rgb, 13, 110, 253), 0.06);
--fc-today-bg-color: rgba(var(--bs-warning-rgb, 255, 193, 7), 0.18);
--fc-button-bg-color: var(--bs-primary);
--fc-button-border-color: var(--bs-primary);
--fc-button-text-color: #fff;
--fc-button-hover-bg-color: var(--bs-primary);
--fc-button-hover-border-color: var(--bs-primary);
--fc-button-active-bg-color: var(--bs-primary);
--fc-button-active-border-color: var(--bs-primary);
--fc-list-event-hover-bg-color: transparent;
}
#{{ fullcalendar_id }} .fc-toolbar {
gap: 0.75rem;
flex-wrap: wrap;
}
#{{ fullcalendar_id }} .fc-toolbar-chunk {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
#{{ fullcalendar_id }} .fc-button-group {
display: inline-flex;
gap: 0.5rem;
}
#{{ fullcalendar_id }} .fc-button-group > .fc-button {
margin-left: 0 !important;
}
#{{ fullcalendar_id }} .fc-toolbar-title {
font-size: 1.5rem;
font-weight: 600;
color: var(--bs-primary);
}
#{{ fullcalendar_id }} .fc-col-header-cell-cushion,
#{{ fullcalendar_id }} .fc-daygrid-day-number {
color: var(--bs-primary);
font-weight: 600;
text-decoration: none;
}
#{{ fullcalendar_id }} .fc-day-other .fc-daygrid-day-number {
opacity: 0.45;
}
#{{ fullcalendar_id }} .fc-button {
border-radius: 0.375rem;
text-transform: uppercase;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
padding: 0.45rem 0.85rem;
margin: 0 0.15rem;
}
#{{ fullcalendar_id }} .fc-button-primary:not(:disabled).fc-button-active,
#{{ fullcalendar_id }} .fc-button-primary:not(:disabled):active {
box-shadow: none;
}
#{{ fullcalendar_id }} .fc-daygrid-day-frame {
min-height: 6.5rem;
padding: 0.15rem;
}
#{{ fullcalendar_id }} .fc-event {
border: 0;
border-radius: 0.35rem;
padding: 0.15rem 0.4rem;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
}
#{{ fullcalendar_id }} .fc-daygrid-event {
white-space: normal;
line-height: 1.25;
}
#{{ fullcalendar_id }} .fc-event-cat-primary,
#{{ fullcalendar_id }} .fc-list-event.fc-event-cat-primary td,
#{{ fullcalendar_id }} .fc-list-event.fc-event-cat-primary a {
background-color: var(--bs-primary) !important;
color: #fff !important;
}
#{{ fullcalendar_id }} .fc-event-cat-secondary,
#{{ fullcalendar_id }} .fc-list-event.fc-event-cat-secondary td,
#{{ fullcalendar_id }} .fc-list-event.fc-event-cat-secondary a {
background-color: var(--bs-secondary) !important;
color: #fff !important;
}
#{{ fullcalendar_id }} .fc-event-cat-dark,
#{{ fullcalendar_id }} .fc-list-event.fc-event-cat-dark td,
#{{ fullcalendar_id }} .fc-list-event.fc-event-cat-dark a {
background-color: var(--bs-dark) !important;
color: #fff !important;
}
#{{ fullcalendar_id }} .fc-event-cat-default,
#{{ fullcalendar_id }} .fc-event-cat-info,
#{{ fullcalendar_id }} .fc-list-event.fc-event-cat-default td,
#{{ fullcalendar_id }} .fc-list-event.fc-event-cat-default a,
#{{ fullcalendar_id }} .fc-list-event.fc-event-cat-info td,
#{{ fullcalendar_id }} .fc-list-event.fc-event-cat-info a {
background-color: var(--bs-info) !important;
color: #fff !important;
}
#{{ fullcalendar_id }} .fc-event-cat-success,
#{{ fullcalendar_id }} .fc-list-event.fc-event-cat-success td,
#{{ fullcalendar_id }} .fc-list-event.fc-event-cat-success a {
background-color: var(--bs-success) !important;
color: #fff !important;
}
#{{ fullcalendar_id }} .fc-event-cat-warning,
#{{ fullcalendar_id }} .fc-list-event.fc-event-cat-warning td,
#{{ fullcalendar_id }} .fc-list-event.fc-event-cat-warning a {
background-color: var(--bs-warning) !important;
color: #212529 !important;
}
#{{ fullcalendar_id }} .fc-list-event.fc-event-cat-primary:hover td,
#{{ fullcalendar_id }} .fc-list-event.fc-event-cat-primary:hover a {
background-color: var(--bs-primary) !important;
color: #fff !important;
filter: brightness(0.92);
}
#{{ fullcalendar_id }} .fc-list-event.fc-event-cat-secondary:hover td,
#{{ fullcalendar_id }} .fc-list-event.fc-event-cat-secondary:hover a {
background-color: var(--bs-secondary) !important;
color: #fff !important;
filter: brightness(0.92);
}
#{{ fullcalendar_id }} .fc-list-event.fc-event-cat-dark:hover td,
#{{ fullcalendar_id }} .fc-list-event.fc-event-cat-dark:hover a {
background-color: var(--bs-dark) !important;
color: #fff !important;
filter: brightness(0.92);
}
#{{ fullcalendar_id }} .fc-list-event.fc-event-cat-default:hover td,
#{{ fullcalendar_id }} .fc-list-event.fc-event-cat-default:hover a,
#{{ fullcalendar_id }} .fc-list-event.fc-event-cat-info:hover td,
#{{ fullcalendar_id }} .fc-list-event.fc-event-cat-info:hover a {
background-color: var(--bs-info) !important;
color: #fff !important;
filter: brightness(0.92);
}
#{{ fullcalendar_id }} .fc-list-event.fc-event-cat-success:hover td,
#{{ fullcalendar_id }} .fc-list-event.fc-event-cat-success:hover a {
background-color: var(--bs-success) !important;
color: #fff !important;
filter: brightness(0.92);
}
#{{ fullcalendar_id }} .fc-list-event.fc-event-cat-warning:hover td,
#{{ fullcalendar_id }} .fc-list-event.fc-event-cat-warning:hover a {
background-color: var(--bs-warning) !important;
color: #212529 !important;
filter: brightness(0.95);
}
#{{ fullcalendar_id }} .fc-list-event-dot {
border-color: currentColor;
}
{%- end_stylesheet -%}
{%- add_javascript inline fullCalendarInit = position:'body' -%}
document.addEventListener('DOMContentLoaded', function() {
var calendarEl = document.getElementById('{{ fullcalendar_id }}');
if (!calendarEl) {
return;
}
var eventsUrl = calendarEl.getAttribute('data-events-url');
if (!eventsUrl || typeof FullCalendar === 'undefined') {
return;
}
var legendId = calendarEl.getAttribute('data-legend-id');
var allowAdditional = calendarEl.getAttribute('data-allow-additional') === 'true';
var modalId = calendarEl.getAttribute('data-modal-id') || 'mpEventDetailModal';
function isGenericEventsPath(url) {
if (!url) {
return true;
}
try {
var parsed = new URL(url, window.location.origin);
return parsed.pathname.replace(/\/$/, '') === '/events';
} catch (err) {
return String(url).replace(/\/$/, '') === '/events';
}
}
function decodeEntities(value) {
if (!value) {
return '';
}
var textarea = document.createElement('textarea');
textarea.innerHTML = value;
return textarea.value;
}
function formatWhen(event) {
if (!event.start) {
return '';
}
if (event.allDay) {
return event.start.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
}
var startText = event.start.toLocaleString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
if (!event.end) {
return startText;
}
return startText + ' – ' + event.end.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
}
function usefulEventUrl(event) {
var candidates = [event.url, event.extendedProps && event.extendedProps.link];
for (var i = 0; i < candidates.length; i++) {
if (candidates[i] && !isGenericEventsPath(candidates[i])) {
return candidates[i];
}
}
return '';
}
function syncLegend(events) {
if (!allowAdditional || !legendId) {
return;
}
var legendEl = document.getElementById(legendId);
if (!legendEl) {
return;
}
var known = {};
legendEl.querySelectorAll('[data-calendar-label]').forEach(function(node) {
known[node.getAttribute('data-calendar-label')] = true;
});
(events || []).forEach(function(event) {
var label = event.extendedProps && event.extendedProps.calendar;
if (!label || known[label]) {
return;
}
known[label] = true;
var item = document.createElement('span');
item.className = 'd-inline-flex align-items-center gap-2 small';
item.setAttribute('data-calendar-label', label);
item.innerHTML = '<span class="rounded-1 fc-event-cat-default" style="width: 0.9rem; height: 0.9rem; display: inline-block;" aria-hidden="true"></span><span></span>';
item.lastChild.textContent = label;
legendEl.appendChild(item);
});
}
function openEvent(event) {
if (typeof window.mpOpenEventDetail !== 'function') {
return;
}
var descriptionHtml = event.extendedProps && event.extendedProps.description ? event.extendedProps.description : '';
window.mpOpenEventDetail({
modalId: modalId,
title: decodeEntities(event.title || ''),
when: formatWhen(event),
location: event.extendedProps && event.extendedProps.location ? decodeEntities(event.extendedProps.location) : '',
descriptionHtml: descriptionHtml,
url: usefulEventUrl(event)
});
}
var calendarOptions = {
initialView: 'dayGridMonth',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,listMonth'
},
buttonText: {
today: 'Today',
month: 'Month',
list: 'List'
},
events: eventsUrl,
startParam: 'start_date',
endParam: 'end_date',
height: 'auto',
navLinks: true,
dayMaxEvents: true,
eventDisplay: 'block',
eventDataTransform: function(event) {
if (event.title) {
event.title = decodeEntities(event.title);
}
if (event.link && !event.url && !isGenericEventsPath(event.link)) {
event.url = event.link;
}
if (event.url && isGenericEventsPath(event.url)) {
delete event.url;
}
return event;
},
eventClick: function(info) {
info.jsEvent.preventDefault();
openEvent(info.event);
},
eventsSet: function(events) {
syncLegend(events);
}
};
{%- if site_timezone is_valid -%}
calendarOptions.timeZone = {{ site_timezone | json_encode }};
{%- endif -%}
var calendar = new FullCalendar.Calendar(calendarEl, calendarOptions);
calendar.render();
});
{%- end_javascript -%}
Modal used when a calendar event is clicked.
{% comment %}
Shared Bootstrap modal for calendar event details.
Inputs:
modal_id - optional DOM id. Defaults to mpEventDetailModal
{% endcomment -%}
{%- var modal_id = modal_id | default: 'mpEventDetailModal' -%}
<div class="modal fade" id="{{ modal_id }}" tabindex="-1" aria-labelledby="{{ modal_id }}_title" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content border-0 shadow">
<div class="modal-header border-0 pb-0">
<div>
<p class="small text-uppercase text-muted mb-1" id="{{ modal_id }}_when"></p>
<h2 class="modal-title h4 text-primary mb-0" id="{{ modal_id }}_title"></h2>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body pt-3">
<p class="small text-muted mb-3 d-none" id="{{ modal_id }}_location"></p>
<div id="{{ modal_id }}_description" class="mb-0"></div>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
<a href="#" class="btn btn-primary d-none" id="{{ modal_id }}_link" target="_self">Read More</a>
</div>
</div>
</div>
</div>
{%- add_javascript inline eventDetailModalHelper = position:'body' -%}
window.mpOpenEventDetail = window.mpOpenEventDetail || function(options) {
var modalId = (options && options.modalId) || '{{ modal_id }}';
var modalEl = document.getElementById(modalId);
if (!modalEl || typeof bootstrap === 'undefined') {
return;
}
var titleEl = document.getElementById(modalId + '_title');
var whenEl = document.getElementById(modalId + '_when');
var locationEl = document.getElementById(modalId + '_location');
var descriptionEl = document.getElementById(modalId + '_description');
var linkEl = document.getElementById(modalId + '_link');
if (titleEl) {
titleEl.textContent = options.title || '';
}
if (whenEl) {
whenEl.textContent = options.when || '';
whenEl.classList.toggle('d-none', !options.when);
}
if (locationEl) {
locationEl.textContent = options.location || '';
locationEl.classList.toggle('d-none', !options.location);
}
if (descriptionEl) {
descriptionEl.innerHTML = options.descriptionHtml || '';
if (!options.descriptionHtml && options.description) {
descriptionEl.textContent = options.description;
}
}
if (linkEl) {
if (options.url) {
linkEl.href = options.url;
linkEl.classList.remove('d-none');
} else {
linkEl.classList.add('d-none');
linkEl.removeAttribute('href');
}
}
bootstrap.Modal.getOrCreateInstance(modalEl).show();
};
{%- end_javascript -%}