diff --git a/Gemfile.lock b/Gemfile.lock index c921f99dd..b41f8d12e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -629,6 +629,7 @@ GEM PLATFORMS arm64-darwin-23 + arm64-darwin-24 x86_64-darwin-24 x86_64-linux diff --git a/app/assets/images/bx_export.svg b/app/assets/images/bx_export.svg new file mode 100644 index 000000000..9d61220cf --- /dev/null +++ b/app/assets/images/bx_export.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/assets/images/calendar-icon.svg b/app/assets/images/calendar-icon.svg new file mode 100644 index 000000000..6cb7d67f9 --- /dev/null +++ b/app/assets/images/calendar-icon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/assets/images/causes_categories/arts_culture.svg b/app/assets/images/causes_categories/arts_culture.svg index f2afbab62..8de2308c4 100644 --- a/app/assets/images/causes_categories/arts_culture.svg +++ b/app/assets/images/causes_categories/arts_culture.svg @@ -1,3 +1,3 @@ - - + + diff --git a/app/assets/images/causes_categories/disaster_relief_preparedness.svg b/app/assets/images/causes_categories/disaster_relief_preparedness.svg index d2d0da388..21d46c1ba 100644 --- a/app/assets/images/causes_categories/disaster_relief_preparedness.svg +++ b/app/assets/images/causes_categories/disaster_relief_preparedness.svg @@ -1,11 +1,4 @@ - - - - - - - - - - + + + diff --git a/app/assets/images/causes_categories/drug_alcohol_treatment.svg b/app/assets/images/causes_categories/drug_alcohol_treatment.svg index ed063d83e..05bf681bb 100644 --- a/app/assets/images/causes_categories/drug_alcohol_treatment.svg +++ b/app/assets/images/causes_categories/drug_alcohol_treatment.svg @@ -1,5 +1,5 @@ - - - - + + + + diff --git a/app/assets/images/causes_categories/education.svg b/app/assets/images/causes_categories/education.svg index b41438aba..9e7123fe2 100644 --- a/app/assets/images/causes_categories/education.svg +++ b/app/assets/images/causes_categories/education.svg @@ -1,3 +1,3 @@ - - + + diff --git a/app/assets/images/causes_categories/emergency_safety.svg b/app/assets/images/causes_categories/emergency_safety.svg index 55fde341e..761a355c3 100644 --- a/app/assets/images/causes_categories/emergency_safety.svg +++ b/app/assets/images/causes_categories/emergency_safety.svg @@ -1,32 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + diff --git a/app/assets/images/causes_categories/employment.svg b/app/assets/images/causes_categories/employment.svg index d92fa932f..995ad86e5 100644 --- a/app/assets/images/causes_categories/employment.svg +++ b/app/assets/images/causes_categories/employment.svg @@ -1,8 +1,3 @@ - - - - - diff --git a/app/assets/images/causes_categories/environment.svg b/app/assets/images/causes_categories/environment.svg index 74e740c4d..a30cabcc9 100644 --- a/app/assets/images/causes_categories/environment.svg +++ b/app/assets/images/causes_categories/environment.svg @@ -1,3 +1,3 @@ - - + + diff --git a/app/assets/images/causes_categories/health.svg b/app/assets/images/causes_categories/health.svg index 61bbdaf02..80513cbb1 100644 --- a/app/assets/images/causes_categories/health.svg +++ b/app/assets/images/causes_categories/health.svg @@ -1,11 +1,4 @@ - - - - - - - diff --git a/app/assets/images/causes_categories/transportation.svg b/app/assets/images/causes_categories/transportation.svg index e40d9f017..d5ad7a469 100644 --- a/app/assets/images/causes_categories/transportation.svg +++ b/app/assets/images/causes_categories/transportation.svg @@ -1,8 +1,3 @@ - - - - - - - + + diff --git a/app/assets/images/down-arrow.svg b/app/assets/images/down-arrow.svg new file mode 100644 index 000000000..6e5474422 --- /dev/null +++ b/app/assets/images/down-arrow.svg @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/assets/images/ellipses.svg b/app/assets/images/ellipses.svg new file mode 100644 index 000000000..cc0bf2aca --- /dev/null +++ b/app/assets/images/ellipses.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/assets/images/ellipsis-vertical-icon.svg b/app/assets/images/ellipsis-vertical-icon.svg new file mode 100644 index 000000000..a1d6bfc63 --- /dev/null +++ b/app/assets/images/ellipsis-vertical-icon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/assets/images/gc-heart.png b/app/assets/images/gc-heart.png new file mode 100644 index 000000000..291683b73 Binary files /dev/null and b/app/assets/images/gc-heart.png differ diff --git a/app/assets/images/gc-star.png b/app/assets/images/gc-star.png new file mode 100644 index 000000000..6aacb6a03 Binary files /dev/null and b/app/assets/images/gc-star.png differ diff --git a/app/assets/images/hamburger_menu_icon.svg b/app/assets/images/hamburger_menu_icon.svg new file mode 100644 index 000000000..2921e6f42 --- /dev/null +++ b/app/assets/images/hamburger_menu_icon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/assets/images/map-pin-icon.svg b/app/assets/images/map-pin-icon.svg new file mode 100644 index 000000000..0dcba1131 --- /dev/null +++ b/app/assets/images/map-pin-icon.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/assets/images/mingcute_notification-line.svg b/app/assets/images/mingcute_notification-line.svg new file mode 100644 index 000000000..9315a0558 --- /dev/null +++ b/app/assets/images/mingcute_notification-line.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/assets/stylesheets/design_system/component/_button.scss b/app/assets/stylesheets/design_system/component/_button.scss index 1cc22fdfe..c5a0713c5 100644 --- a/app/assets/stylesheets/design_system/component/_button.scss +++ b/app/assets/stylesheets/design_system/component/_button.scss @@ -34,3 +34,21 @@ } } } + +.b-button { + display: flex; + margin: 0 auto; + align-items: center; + justify-content: center; + padding: 15px 40px; + border-radius: 6px; + border: 0; + cursor: pointer; + font-size: 12px; + font-weight: bold; + text-transform: uppercase; + color: $primary-dark-blue; + background-color: $secondary-seafoam; + line-height: 10px; + letter-spacing: 0.8px; +} \ No newline at end of file diff --git a/app/assets/stylesheets/design_system/component/_input.scss b/app/assets/stylesheets/design_system/component/_input.scss index 3b310a07f..0fa9e5eaf 100644 --- a/app/assets/stylesheets/design_system/component/_input.scss +++ b/app/assets/stylesheets/design_system/component/_input.scss @@ -8,7 +8,7 @@ padding: 12px 16px; border-radius: 6px !important; border: 1px solid $var-gray-5 !important; - box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.01); + box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.01); color: $copy-gray-7; &:focus { @@ -16,9 +16,15 @@ border: 1px solid $primary-medium-blue !important; } - &::placeholder { - color: $var-gray-6; - font-size: 16px; - } + &::placeholder { + color: $var-gray-6; + font-size: 16px; + } } +// Override for textareas to allow them to be larger +.c-input[rows] { + height: auto; + min-height: 46px; + resize: vertical; +} \ No newline at end of file diff --git a/app/components/calendar/component.html.slim b/app/components/calendar/component.html.slim new file mode 100644 index 000000000..0ea080aae --- /dev/null +++ b/app/components/calendar/component.html.slim @@ -0,0 +1,493 @@ +/ FullCalendar JS +script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.17/index.global.min.js" + +div data-controller="calendar-component" data-calendar-component-target="calendar" class="calendar-container" + div class="flex align-center justify-center" + div id="calendar" class="w-full h-full mt-2" + +div class="flex stretch justify-center items-center gap-24 mt-4 bg-gray-100 p-4 rounded-lg" + div class="flex items-center gap-2" + div class="w-4 h-4 rounded-full bg-[blue]" id="legend_outreach" + span Outreach + div class="flex items-center gap-2" + div class="w-4 h-4 rounded-full bg-[blue]" id="legend_volunteer" + span Volunteer + div class="flex items-center gap-2" + div class="w-4 h-4 rounded-full bg-[blue]" id="legend_fundraiser" + span Fundraiser + div class="flex items-center gap-2" + div class="w-4 h-4 rounded-full bg-[blue]" id="legend_other" + span Other + - if @admin + div class="flex items-center gap-2" + div class="w-4 h-4 rounded-full bg-[red]" + span Draft + +style + | /* Override FullCalendar styles */ + | .fc-header-toolbar { + | margin-bottom: 10px !important; + | } + | .fc-day-today { + | background-color: #f8f9fa !important; + | } + | .fc .fc-event-title { + | white-space: normal !important; + | font: 'Inter'; + | font-size: 11px !important; + | line-height: 1.2 !important; + | overflow: hidden !important; + | text-overflow: ellipsis !important; + | display: -webkit-box !important; + | -webkit-line-clamp: 2 !important; + | -webkit-box-orient: vertical !important; + | color: #000000 !important; + | font-weight: 500 !important; + | order: 2 !important; /* Position title after time */ + | } + | .fc-event-time { + | color: #000000 !important; + | font-size: 10px !important; /* Slightly larger font size for readability */ + | font-weight: 400 !important; + | order: 1 !important; /* Position time before title */ + | white-space: nowrap !important; /* Prevent time from wrapping */ + | overflow: visible !important; /* Ensure time is not cut off */ + | min-width: fit-content !important; /* Ensure time has enough space */ + | margin-bottom: 1px !important; /* Add space between time and title */ + | } + | .fc-event-main { + | color: #000000 !important; + | display: flex !important; /* Enable flexbox */ + | flex-direction: column !important; /* Stack items vertically */ + | } + | .fc .fc-event { + | padding: 3px 6px !important; /* Increased padding for better spacing */ + | min-height: 24px !important; /* Increased height for time display */ + | margin-bottom: 2px !important; /* Increased margin for better separation */ + | overflow: visible !important; /* Ensure content is not cut off */ + | } + | .fc-daygrid-event { + | max-width: 100% !important; + | word-wrap: break-word !important; + | } + | .fc .fc-daygrid-day-events .fc-event { + | background-color: rgba(0, 0, 0, 0.02) !important; + | border-radius: 3px !important; + | border-left: 3px solid !important; + | border-top: none !important; + | border-right: none !important; + | border-bottom: none !important; + | border-left-color: var(--fc-event-border-color) !important; + | } + | .fc-daygrid-day-frame { + | min-height: 160px !important; /* Increased cell height for time display */ + | } + | .fc-daygrid-day-events { + | margin: 1px !important; + | } + | .fc-daygrid-day { + | min-height: 180px !important; /* Increased cell height for time display */ + | } + | .fc-daygrid-day-number { + | color: #1F2D3D !important; + | } + | .fc-daygrid-day-top { + | flex-direction: row !important; + | } + | .fc .fc-col-header-cell { + | background-color: #E2ECF7 !important; + | padding-top: .75em !important; + | padding-bottom: .75em !important; + | text-align: center !important;; + | } + | + | .fc .fc-col-header-cell:first-child { + | border-radius: 5px 0 0 0 !important; + | } + | + | .fc .fc-col-header-cell:last-child { + | border-radius: 0 5px 0 0 !important; + | } + | + | .fc-theme-standard .fc-scrollgrid { + | border-radius: 5px 5px 0 0 !important; + | } + | + | .fc .fc-col-header-cell-cushion { + | color: #707070 !important; + | font-size: 16px; + | font-weight: 500; + | line-height: 150%; /* 24px */ + | letter-spacing: -0.176px; + | text-transform: uppercase; + | } + | .fc .fc-button-primary { + | background: none !important; + | border: none !important; + | color: #1F2D3D !important; + | } + | + | /* Custom tooltip styling */ + | .event-tooltip { + | position: fixed; + | z-index: 9999; + | display: none; + | pointer-events: none; + | border: none !important; + | outline: none !important; + | } + | + | .tooltip-content { + | background: white; + | border-radius: 5px; + | box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + | padding: 16px; + | width: 256px; + | max-width: 300px; + | border: none !important; + | outline: none !important; + | box-sizing: border-box; + | } + | + | .tooltip-date { + | color: #113c7b; + | font-size: 12px; + | font-weight: 600; + | margin-bottom: 12px; + | font-family: 'Inter', sans-serif; + | } + | + | .tooltip-title { + | color: black; + | font-size: 16px; + | font-weight: bold; + | margin-bottom: 4px; + | font-family: 'Inter', sans-serif; + | } + | + | .tooltip-org { + | color: black; + | font-size: 14px; + | font-weight: 500; + | margin-bottom: 4px; + | font-family: 'Inter', sans-serif; + | } + | + | .tooltip-location { + | color: #6B7280; + | font-size: 12px; + | margin-bottom: 12px; + | font-family: 'Inter', sans-serif; + | } + | + | .tooltip-description { + | color: black; + | font-size: 12px; + | margin-bottom: 12px; + | font-family: 'Inter', sans-serif; + | line-height: 1.4; + | } + | + | .tooltip-badges { + | display: flex; + | gap: 8px; + | flex-wrap: wrap; + | } + | + | .tooltip-badge { + | font-size: 12px; + | font-weight: 500; + | padding: 4px 12px; + | border-radius: 16px; + | font-family: 'Inter', sans-serif; + | border: none; + | } + + +javascript: + document.addEventListener('DOMContentLoaded', setupCalendar); + + async function setupCalendar() { + // Background Colors (Brand Colors) - use categorization function + const badge_bg_colors = { + "Fundraiser": "#9ae2e0", // Seafoam green + "Volunteer": "#fc8383", // Salmon + "Outreach": "#113c7b", // Dark Blue + "Other": "#0782d0" // Medium Blue + }; + + // Text Colors (White text on brand colors) + const badge_text_colors = { + "Fundraiser": "#ffffff", + "Volunteer": "#ffffff", + "Outreach": "#ffffff", + "Other": "#ffffff" + }; + + // Function to categorize event types into four main buckets + function categorizeEventType(eventType) { + if (!eventType || eventType.length === 0) return "Other"; + + const type = eventType[0].toLowerCase(); + + // Fundraiser events + if (type.includes('fundraiser') || type.includes('fundraising')) { + return "Fundraiser"; + } + + // Volunteer events + if (type.includes('volunteer') || type.includes('volunteering')) { + return "Volunteer"; + } + + // Outreach events (community, education, awareness, etc.) + if (type.includes('community') || type.includes('outreach') || + type.includes('awareness') || type.includes('education') || + type.includes('workshop') || type.includes('training') || + type.includes('class') || type.includes('program') || + type.includes('event') || type.includes('meeting') || + type.includes('cultural') || type.includes('art') || + type.includes('music') || type.includes('sports') || + type.includes('recreation') || type.includes('job') || + type.includes('technology')) { + return "Outreach"; + } + + // Everything else + return "Other"; + } + + const cal_colors = { + "Fundraiser": "#9ae2e0", // Seafoam green + "Volunteer": "#fc8383", // Salmon + "Outreach": "#113c7b", // Dark Blue + "Other": "#0782d0" // Medium Blue + }; + + // Update legend colors + document.getElementById('legend_other').style.backgroundColor = cal_colors["Other"]; + document.getElementById('legend_volunteer').style.backgroundColor = cal_colors["Volunteer"]; + document.getElementById('legend_fundraiser').style.backgroundColor = cal_colors["Fundraiser"]; + document.getElementById('legend_outreach').style.backgroundColor = cal_colors["Outreach"]; + + var calendarEl = document.getElementById('calendar'); + const isAdminCalendar = "#{@admin}" == "true"; + var events = []; + + if (isAdminCalendar) { + let orgIds = document.getElementById('org_ids').value.split(' '); + for (let orgId of orgIds) { + const response = await fetch("/events?org_id=" + orgId, { + method: "GET", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').getAttribute('content') + } + }); + if (!response.ok) { + console.error('Error fetching events:', response.statusText); + return; + } + const data = await response.json(); + data.events.forEach(event => { + events.push({ + id: event.id, + title: event.title.length > 25 ? event.title.substring(0, 25) + '...' : event.title, + start: event.start_time, + end: event.end_time, + description: event.description, + location: event.location, + url: `/events/explore/${event.id}`, + eventType: event.type_of_event, + orgName: data.organization.name, + color: event.published ? cal_colors[categorizeEventType(event.type_of_event)] || 'grey' : 'red', + editable: false, + extendedProps: { + title: event.title // Store full title for tooltip + } + }); + }); + } + } else { + // Parse url parameters for filters if they exist + const urlParams = new URLSearchParams(window.location.search); + const response = await fetch(urlParams.size > 0 ? `/events?${urlParams.toString()}` : "/events", { + method: "GET", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').getAttribute('content') + } + }); + if (!response.ok) { + console.error('Error fetching events:', response.statusText); + return; + } + const data = await response.json(); + data.events.forEach(event => { + events.push({ + id: event.id, + title: event.title.length > 25 ? event.title.substring(0, 25) + '...' : event.title, + start: event.start_time, + end: event.end_time, + description: event.description, + location: event.location, + url: `/events/explore/${event.id}`, + eventType: event.type_of_event, + orgName: data.organizations.find(org => org.id == event.organization_id).name, + color: cal_colors[categorizeEventType(event.type_of_event)] || 'grey', + editable: false, + extendedProps: { + title: event.title // Store full title for tooltip + } + }); + }); + } + + // Global function to hide all tooltips + window.hideAllTooltips = function() { + const tooltips = document.querySelectorAll('.event-tooltip'); + tooltips.forEach(tooltip => { + tooltip.style.display = 'none'; + }); + }; + + // Global function to remove all tooltips completely + window.removeAllTooltips = function() { + const tooltips = document.querySelectorAll('.event-tooltip'); + tooltips.forEach(tooltip => { + tooltip.remove(); + }); + }; + + // Clean up any existing tooltips before creating new ones + window.removeAllTooltips(); + + // Add global event listeners to hide tooltips when page loses focus or user clicks elsewhere + document.addEventListener('click', window.hideAllTooltips); + document.addEventListener('blur', window.hideAllTooltips); + window.addEventListener('beforeunload', window.hideAllTooltips); + + // Turbo-specific cleanup for navigation + document.addEventListener('turbo:before-visit', window.hideAllTooltips); + document.addEventListener('turbo:before-cache', window.hideAllTooltips); + document.addEventListener('turbo:before-render', window.hideAllTooltips); + + window.calendar = new FullCalendar.Calendar(calendarEl, { + initialView: 'dayGridMonth', + headerToolbar: { + left: 'prev,next today', + center: 'title', + right: 'dayGridMonth,timeGridWeek,timeGridDay' + }, + events, + height: 'auto', + dayMaxEvents: 4, + eventDisplay: 'block', + eventTimeFormat: { + hour: 'numeric', + minute: '2-digit', + meridiem: 'short' + }, + eventDidMount: function(info) { + // Set the left border color to match the event color + const eventColor = info.event.backgroundColor || info.event.color; + info.el.style.borderLeftColor = eventColor; + info.el.style.setProperty('--fc-event-border-color', eventColor); + + // Set href of the event element to the event URL + info.el.setAttribute('href', info.event.url); + + // Add tooltip functionality with CSS hover + const eventStart = info.event.start; + const eventEnd = info.event.end || eventStart; + const eventTitle = info.event.extendedProps.title || info.event.title; + const location = info.event.extendedProps.location || 'Remote'; + const description = info.event.extendedProps.description || ''; + const orgName = info.event.extendedProps.orgName || '?'; + const eventTypes = info.event.extendedProps.eventType || []; + + const singleDay = eventStart.getDate() === eventEnd.getDate(); + const truncateLength = 250; + + // Create tooltip element + const tooltip = document.createElement('div'); + tooltip.className = 'event-tooltip'; + tooltip.innerHTML = ` +
+
+ ${ + singleDay + ? + eventStart.toLocaleDateString() + ' ' + eventStart.toLocaleTimeString([], { timeStyle: 'short' }) + : + eventStart.toLocaleDateString() + ' ' + eventStart.toLocaleTimeString([], { timeStyle: 'short' }) + ' - ' + + eventEnd.toLocaleDateString() + ' ' + eventEnd.toLocaleTimeString([], { timeStyle: 'short' }) + } +
+
+
${eventTitle}
+
${orgName}
+
${location}
+
+
+ ${description.length > truncateLength ? description.substring(0, truncateLength) + '...' : description} +
+
+ ${eventTypes.map(type => ` + ${type} + `).join('')} +
+
+ `; + + // Add tooltip to body and position it + document.body.appendChild(tooltip); + + // Show/hide tooltip on hover + const showTooltip = (e) => { + e.stopPropagation(); + // Hide all other tooltips first + window.hideAllTooltips(); + const rect = info.el.getBoundingClientRect(); + tooltip.style.left = rect.right + 10 + 'px'; + tooltip.style.top = rect.top + 'px'; + tooltip.style.display = 'block'; + }; + + const hideTooltip = (e) => { + e.stopPropagation(); + tooltip.style.display = 'none'; + }; + + info.el.addEventListener('mouseenter', showTooltip); + info.el.addEventListener('mouseleave', hideTooltip); + + // Also hide tooltip when clicking on the event + info.el.addEventListener('click', hideTooltip); + + // Store tooltip reference for cleanup + info.el.tooltip = tooltip; + }, + eventWillUnmount: function(info) { + // Clean up tooltip when event is unmounted + if (info.el.tooltip) { + info.el.tooltip.remove(); + info.el.tooltip = null; + } + } + }); + + // Always render the calendar initially + window.calendar.render(); + window.calendar.updateSize(); + + // Clean up tooltips when calendar is destroyed + const originalDestroy = window.calendar.destroy; + window.calendar.destroy = function() { + window.hideAllTooltips(); + window.removeAllTooltips(); + if (originalDestroy) { + originalDestroy.call(this); + } + }; + } diff --git a/app/components/calendar/component.rb b/app/components/calendar/component.rb new file mode 100644 index 000000000..bc37cad6f --- /dev/null +++ b/app/components/calendar/component.rb @@ -0,0 +1,5 @@ +class Calendar::Component < ApplicationViewComponent + def initialize(admin:) + @admin = admin + end +end diff --git a/app/components/event_list_card/component.html.erb b/app/components/event_list_card/component.html.erb new file mode 100644 index 000000000..fa0445e0b --- /dev/null +++ b/app/components/event_list_card/component.html.erb @@ -0,0 +1,208 @@ +
+
+
+ <% adjusted_start = event.start_time.in_time_zone(helpers.get_timezone()) %> + <% month = adjusted_start.strftime("%b").upcase %> + <% day = adjusted_start.strftime("%-d") %> + +
+
<%= month %>
+
<%= day %>
+
+
+ +
+ <% if !@small %> +
<%= helpers.format_event_time(event.start_time) %>
+ <% end %> + +
+

<%= event.title %>

+ + <% if event.organization && !@small %> +
<%= event.organization.name %>
+ <% end %> + + <% if event.location.present? %> +
+ +

<%= event.location %>

+ +
+ <% else %> +
+ +

Virtual Event

+
+ <% end %> + + <% if @small %> +
<%= helpers.format_event_time(event.start_time) %>
+ <% end %> +
+ +
+ <%= truncate(event.description, length: 150) %> +
+ + <% if event.type_of_event.present? %> + <% + # Helper method to categorize event types + def categorize_event_type(event_type) + return "Other" if event_type.blank? + + type = event_type.first.to_s.downcase + + # Fundraiser events + if type.include?('fundraiser') || type.include?('fundraising') + return "Fundraiser" + end + + # Volunteer events + if type.include?('volunteer') || type.include?('volunteering') + return "Volunteer" + end + + # Outreach events (community, education, awareness, etc.) + if type.include?('community') || type.include?('outreach') || + type.include?('awareness') || type.include?('education') || + type.include?('workshop') || type.include?('training') || + type.include?('class') || type.include?('program') || + type.include?('event') || type.include?('meeting') || + type.include?('cultural') || type.include?('art') || + type.include?('music') || type.include?('sports') || + type.include?('recreation') || type.include?('job') || + type.include?('technology') + return "Outreach" + end + + # Everything else + return "Other" + end + + badge_bg_colors = { + "Fundraiser" => "#9ae2e0", + "Volunteer" => "#fc8383", + "Outreach" => "#113c7b", + "Other" => "#0782d0" + } + %> + <% badge_text_colors = { "Fundraiser" => "#ffffff", "Volunteer" => "#ffffff", "Outreach" => "#ffffff", "Other" => "#ffffff" } %> +
+ <% event.type_of_event.each do |type| %> + <% category = categorize_event_type([type]) %> + <%= type %> + <% end %> +
+ <% end %> +
+ + <% if !@small %> +
+ <% if @event.image.attached? %> + <%= image_tag url_for(@event.image), alt: @event.title, class: 'shadow rounded logo-size bg-white' %> + <% end %> +
+ <% end %> + + + + <% if @admin %> +
+

+ <% if event.published %> + Published + <% else %> + Draft + <% end %> +

+
+
+
+ <%= inline_svg_tag "ellipses.svg", size:"3*3", class:"h-7 mr-2 fill-current text-gray-3" %> +
+ +
+ <% end %> +
+
+ + \ No newline at end of file diff --git a/app/components/event_list_card/component.rb b/app/components/event_list_card/component.rb new file mode 100644 index 000000000..bc0d9d840 --- /dev/null +++ b/app/components/event_list_card/component.rb @@ -0,0 +1,17 @@ +module EventListCard + class Component < ViewComponent::Base + include Rails.application.routes.url_helpers + + def initialize(admin:, small:, event:, edit_url: nil, view_url: nil) + @admin = admin + @event = event + @small = small + @edit_url = edit_url + @view_url = view_url + end + + private + + attr_reader :event + end +end diff --git a/app/components/list_calendar_switch_btn/component.html.erb b/app/components/list_calendar_switch_btn/component.html.erb new file mode 100644 index 000000000..11647dffe --- /dev/null +++ b/app/components/list_calendar_switch_btn/component.html.erb @@ -0,0 +1,10 @@ +
+ + +
\ No newline at end of file diff --git a/app/components/list_calendar_switch_btn/component.rb b/app/components/list_calendar_switch_btn/component.rb new file mode 100644 index 000000000..6aea8b97b --- /dev/null +++ b/app/components/list_calendar_switch_btn/component.rb @@ -0,0 +1,23 @@ +class ListCalendarSwitchBtn::Component < ApplicationViewComponent + include ActionButtonHelper + + def initialize(user:, location:, simplified: false) + @user = user + @location = location + @simplified = simplified + end + + def link_args + if removable_fav_location? + { + path: favorite_location_path(favorite_location), + method: :delete + } + else + { + path: favorite_locations_path(location_id: @location.id), + method: :post + } + end + end +end diff --git a/app/components/navbar/component.html.slim b/app/components/navbar/component.html.slim index c18da8001..eb01305a9 100644 --- a/app/components/navbar/component.html.slim +++ b/app/components/navbar/component.html.slim @@ -32,6 +32,8 @@ nav id="main-navbar" data-controller="navbar--component" class="#{non_sticky_pat = link_to 'About Us', about_us_path li class="navbar__option mr-1 mid:mr-5 lg:mr-8 text-base font-medium border-b-2 border-transparent #{request.env['PATH_INFO'].include?("/discover") ? "text-seafoam border-seafoam navbar__option--selected" : ""}" = link_to 'Discover', discover_path + li class="navbar__option mr-1 mid:mr-5 lg:mr-8 text-base font-medium border-b-2 border-transparent #{request.env['PATH_INFO'] == "/events/discover" ? "text-seafoam border-seafoam navbar__option--selected" : ""}" + = link_to 'Events', discover_events_path li class="navbar__option mr-1 mid:mr-5 lg:mr-8 text-base font-medium border-b-2 border-transparent #{request.env['PATH_INFO'] == "/donate" ? "text-seafoam border-seafoam navbar__option--selected" : ""}" = link_to donate_path, class:"flex" do div class='mr-2' diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d6b1c3c97..5effa7327 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -11,6 +11,7 @@ class ApplicationController < ActionController::Base after_action :verify_authorized, except: :index, unless: :skip_pundit? after_action :verify_policy_scoped, only: :index, unless: :skip_pundit? before_action :configure_permitted_parameters, if: :devise_controller? + before_action :set_user_timezone rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized private @@ -59,4 +60,8 @@ def user_not_authorized flash[:error] = "You are not allowed to perform this action" redirect_to root_path end + + def set_user_timezone + @user_timezone = request.env["user_timezone"] || cookies["user_timezone"] || "UTC" + end end diff --git a/app/controllers/event_calendar_controller.rb b/app/controllers/event_calendar_controller.rb new file mode 100644 index 000000000..dbbe4f43a --- /dev/null +++ b/app/controllers/event_calendar_controller.rb @@ -0,0 +1,15 @@ +class EventCalendarController < ApplicationController + # Ignores auth for now + skip_before_action :authenticate_user!, only: [:index] + # Ignores policy for now + skip_after_action :verify_policy_scoped, only: [:index] + + def index + # Add any logic for the Event Calendar page here + end + + def section + @section = params[:section] + render partial: "account_sections/#{@section}" + end +end diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb new file mode 100644 index 000000000..6b34859a1 --- /dev/null +++ b/app/controllers/events_controller.rb @@ -0,0 +1,448 @@ +class EventsController < ApplicationController + skip_before_action :authenticate_user!, only: [:index, :create, :update, :destroy] + skip_before_action :verify_authenticity_token, only: [:index, :create, :update, :destroy] + after_action :skip_authorization, only: [:index, :create, :update, :destroy] + skip_after_action :verify_authorized + skip_after_action :verify_policy_scoped, only: [:index] + + def index + org_id = params[:org_id] || params[:orgId] || "" + + # If no org_id is provided, return all events. Called from the discover page's calendar component + if org_id.blank? + @events = Event.where(published: true).order(:start_time) + filter_events + return render json: {events: @events, organizations: Organization.all}, status: :ok + end + + organization = Organization.find_by(id: org_id) + unless organization + return render json: {error: "Organization not found"}, status: :not_found + end + + events = organization.events.order(:start_time) + + # If user timezone is provided, convert the times for display + if @user_timezone.present? + events_with_timezone = events.map do |event| + event_hash = event.as_json + event_hash["start_time"] = event.start_time&.in_time_zone(params[:timezone]) + event_hash["end_time"] = event.end_time&.in_time_zone(params[:timezone]) + event_hash["timezone"] = @user_timezone + event_hash + end + + render json: {events: events_with_timezone, organization: organization}, status: :ok + else + # If no timezone provided, just return the events with UTC times + render json: {events: events, organization: organization, timezone: "UTC"}, status: :ok + end + end + + def new + @event = Event.new + @organization = Organization.find(params[:org_id]) if params[:org_id].present? + @event_is_remote = true + render :form + end + + def edit + @event = Event.find(params[:id]) + @organization = @event.organization + + if @event.start_time.present? + # Convert UTC times to user's timezone for display + local_start_time = @event.start_time.in_time_zone(@user_timezone) + local_end_time = @event.end_time&.in_time_zone(@user_timezone) + + @event_date = local_start_time.to_date + @event_start_time = local_start_time.strftime("%H:%M") + @event_end_time = local_end_time&.strftime("%H:%M") + end + + @event_is_remote = @event.location == "Remote" || @event.location == "Virtual Event" || @event.location.nil? || @event.location == "" + render :form + end + + def show + @event = Event.find(params[:id]) + @organization = @event.organization + + if @event.start_time.present? + # Convert UTC times to user's timezone for display + local_start_time = @event.start_time.in_time_zone(@user_timezone) + local_end_time = @event.end_time&.in_time_zone(@user_timezone) + + @event_date = local_start_time.to_date + @event_start_time = local_start_time.strftime("%H:%M") + @event_end_time = local_end_time&.strftime("%H:%M") + end + + @event_is_remote = @event.location == "Remote" || @event.location == "Virtual Event" || @event.location.nil? || @event.location == "" + @readonly = true + + respond_to do |format| + format.html { render :form } + format.json { + # For JSON response, convert times to the user's timezone + event_json = @event.as_json + event_json["start_time"] = @event.start_time&.in_time_zone(@user_timezone) + event_json["end_time"] = @event.end_time&.in_time_zone(@user_timezone) + event_json["timezone"] = @user_timezone + + render json: {event: event_json} + } + end + end + + def publish + event = Event.find(params[:id]) + if event.update(published: true) + render json: {message: "Event published successfully"}, status: :ok + else + render json: {message: "Failed to publish event", errors: event.errors.full_messages}, status: :unprocessable_entity + end + end + + # Main search point for fetching events from the events discover page + def discover + if params[:id] + @event = Event.find(params[:id]) + @organization = @event.organization + @upcoming_events = Event.where(organization_id: @organization.id, published: true) + .where("start_time > ?", Time.zone.now) + .order(:start_time) + .limit(3) + render :event + else + @events = Event.where(published: true).order(:start_time) + @events = @events.select { |event| event.start_time > Time.zone.now } + + if params[:ein].present? + @organization = Organization.find_by(ein_number: params[:ein]) + end + + filter_events + render :explore + end + end + + def create + organization = Organization.find_by(id: params[:org_id]) + user_timezone = @user_timezone + + unless organization + return render json: {error: "Organization not found"}, status: :not_found + end + + # Create a copy of the parameters we can modify + create_params = event_params.to_h + + # Process the date and time fields from the form if they exist + if params[:event][:date].present? && params[:event][:start_time].present? + date = params[:event][:date] + start_time = params[:event][:start_time] + + # Parse the local time with the user's timezone + local_start_time = Time.use_zone(user_timezone) do + Time.zone.parse("#{date} #{start_time}") + end + + # Convert to UTC for storage + create_params[:start_time] = local_start_time.utc + + # Handle end time if present + if params[:event][:end_time].present? + end_time = params[:event][:end_time] + + # Parse the local time with the user's timezone + local_end_time = Time.use_zone(user_timezone) do + Time.zone.parse("#{date} #{end_time}") + end + + # Convert to UTC for storage + create_params[:end_time] = local_end_time.utc + end + elsif params[:event][:start_time].present? # For JSON API + # Try to parse ISO8601 format with timezone info + begin + create_params[:start_time] = Time.parse(params[:event][:start_time]).utc + create_params[:end_time] = Time.parse(params[:event][:end_time]).utc if params[:event][:end_time].present? + rescue ArgumentError + # If the time string doesn't include timezone info, use the provided timezone + start_time = Time.use_zone(user_timezone) do + Time.zone.parse(params[:event][:start_time]) + end + create_params[:start_time] = start_time.utc + + if params[:event][:end_time].present? + end_time = Time.use_zone(user_timezone) do + Time.zone.parse(params[:event][:end_time]) + end + create_params[:end_time] = end_time.utc + end + end + end + + # Handle the remote/location fields + if params[:event][:remote] == "true" + create_params[:location] = "Remote" + elsif params[:event][:address].present? + create_params[:location] = params[:event][:address] + end + + # Process event types + create_params[:type_of_event] = JSON.parse(params[:event][:type_of_event]) if params[:event][:type_of_event].present? + + # Set whether it's a draft or not + create_params[:published] = params[:event][:is_draft] != "true" + + # Set recurring flag + create_params[:isRecurring] = params[:event][:recurring] == "true" + + event = organization.events.build(create_params) + + if event.save + # Convert times back to user's timezone for the response + event_json = event.as_json + event_json["start_time"] = event.start_time&.in_time_zone(user_timezone) + event_json["end_time"] = event.end_time&.in_time_zone(user_timezone) + event_json["timezone"] = user_timezone + + render json: {message: "Event created successfully", event: event_json}, status: :created + else + render json: {message: "Event creation failed", errors: event.errors.full_messages}, status: :unprocessable_entity + end + end + + def update + event = Event.find_by(id: params[:id]) + + unless event + return render json: {error: "Event not found"}, status: :not_found + end + + # Create a copy of the parameters we can modify + update_params = event_params.to_h + user_timezone = @user_timezone + + # Process the date and time fields from the form if they exist + if params[:event][:date].present? && params[:event][:start_time].present? + date = params[:event][:date] + start_time = params[:event][:start_time] + + # Parse the local time with the user's timezone + local_start_time = Time.use_zone(user_timezone) do + Time.zone.parse("#{date} #{start_time}") + end + + # Convert to UTC for storage + update_params[:start_time] = local_start_time.utc + + # Handle end time if present + if params[:event][:end_time].present? + end_time = params[:event][:end_time] + + # Parse the local time with the user's timezone + local_end_time = Time.use_zone(user_timezone) do + Time.zone.parse("#{date} #{end_time}") + end + + # Convert to UTC for storage + update_params[:end_time] = local_end_time.utc + end + elsif params[:event][:start_time].present? # For JSON API + # Try to parse ISO8601 format with timezone info + begin + update_params[:start_time] = Time.parse(params[:event][:start_time]).utc + update_params[:end_time] = Time.parse(params[:event][:end_time]).utc if params[:event][:end_time].present? + rescue ArgumentError + # If the time string doesn't include timezone info, use the provided timezone + start_time = Time.use_zone(user_timezone) do + Time.zone.parse(params[:event][:start_time]) + end + update_params[:start_time] = start_time.utc + + if params[:event][:end_time].present? + end_time = Time.use_zone(user_timezone) do + Time.zone.parse(params[:event][:end_time]) + end + update_params[:end_time] = end_time.utc + end + end + end + + # Handle the remote/location fields + if params[:event][:remote] == "true" + update_params[:location] = "Remote" + elsif params[:event][:address].present? + update_params[:location] = params[:event][:address] + end + + # Process event types + update_params[:type_of_event] = JSON.parse(params[:event][:type_of_event]) if params[:event][:type_of_event].present? + + # Set whether it's a draft or not + update_params[:published] = params[:event][:is_draft] != "true" if params[:event][:is_draft].present? + + # Set recurring flag + update_params[:isRecurring] = params[:event][:recurring] == "true" if params[:event][:recurring].present? + + if event.update(update_params) + # Convert times back to user's timezone for the response + event_json = event.as_json + event_json["start_time"] = event.start_time&.in_time_zone(user_timezone) + event_json["end_time"] = event.end_time&.in_time_zone(user_timezone) + event_json["timezone"] = user_timezone + + render json: {message: "Event updated successfully", event: event_json}, status: :ok + else + render json: {error: "Failed to update event", details: event.errors.full_messages}, status: :unprocessable_entity + end + end + + def destroy + event = Event.find_by(id: params[:id]) + + unless event + return render json: {error: "Event not found"}, status: :not_found + end + + if event.destroy + render json: {message: "Event deleted successfully"}, status: :ok + else + render json: {error: "Failed to delete event"}, status: :unprocessable_entity + end + end + + # Helper method to categorize event types + def categorize_event_type(event_type) + return "Other" if event_type.blank? + + type = event_type.first.to_s.downcase + + # Fundraiser events + if type.include?("fundraiser") || type.include?("fundraising") + return "Fundraiser" + end + + # Volunteer events + if type.include?("volunteer") || type.include?("volunteering") + return "Volunteer" + end + + # Outreach events (community, education, awareness, etc.) + if type.include?("community") || type.include?("outreach") || + type.include?("awareness") || type.include?("education") || + type.include?("workshop") || type.include?("training") || + type.include?("class") || type.include?("program") || + type.include?("event") || type.include?("meeting") || + type.include?("cultural") || type.include?("art") || + type.include?("music") || type.include?("sports") || + type.include?("recreation") || type.include?("job") || + type.include?("technology") + return "Outreach" + end + + # Everything else + "Other" + end + + helper_method :categorize_event_type + + private + + # Used for discover events to filter events based on query params + def filter_events + # If an organzation ein is provided, filter events by that organization first + if params[:ein].present? + organization = Organization.find_by(ein_number: params[:ein]) + @events = if organization + @events.select { |event| event.organization_id == organization.id } + else + [] + end + end + + # If user uses search bar, filter events based on the query second + if params[:query].present? + @events = @events.select do |event| + event.title.downcase.include?(params[:query].downcase) || + event.description&.downcase&.include?(params[:query].downcase) || + event.location&.downcase&.include?(params[:query].downcase) || + event.organization.name.downcase.include?(params[:query].downcase) + end + end + + # Then, use other filters + if params[:type_of_event].present? + types = params[:type_of_event].is_a?(Array) ? params[:type_of_event] : [params[:type_of_event]] + @events = @events.select do |event| + event.type_of_event.present? && + (types & event.type_of_event).any? + end + end + + # Filter by location type + if params[:location].present? + locations = params[:location].is_a?(Array) ? params[:location] : [params[:location]] + @events = @events.select do |event| + if locations.include?("Virtual") + event.location == "Remote" || event.location == "Virtual Event" + elsif locations.include?("In-Person") + event.location.present? && event.location != "Remote" && event.location != "Virtual Event" + else + false + end + end + end + + # Filter by date range + if params[:daterange].present? + date_range = parse_date_range(params[:daterange]) + if date_range[:start].present? && date_range[:end].present? + @events = @events.select do |event| + event.start_time.present? && + event.start_time >= date_range[:start] && + event.start_time <= date_range[:end] + end + end + end + end + + # Helper method to parse date range string + def parse_date_range(daterange_str) + parts = daterange_str.split(" - ") + result = {start: nil, end: nil} + + return result unless parts.length == 2 + + begin + # Parse dates with time information + user_timezone = @user_timezone + + result[:start] = Time.use_zone(user_timezone) do + Time.zone.parse(parts[0]) + end + + result[:end] = Time.use_zone(user_timezone) do + Time.zone.parse(parts[1]) + end + rescue ArgumentError + # Return empty result if parsing fails + end + + result + end + + def event_params + params.require(:event).permit( + :title, :description, :link, :image, :location, + :published, :isRecurring, :start_time, :end_time, + type_of_event: [], tags: [], categories: [], subcategories: [] + ).tap do |whitelisted| + # Allow end_time to be nil for create and update actions + whitelisted[:end_time] = nil if whitelisted[:end_time].blank? + end + end +end diff --git a/app/controllers/my_accounts_controller.rb b/app/controllers/my_accounts_controller.rb index d90a0d7ff..aa437f30e 100644 --- a/app/controllers/my_accounts_controller.rb +++ b/app/controllers/my_accounts_controller.rb @@ -8,5 +8,6 @@ def show @saved_pages = current_user.favorited_locations @alerts = current_user.alerts.order(:id) @my_organizations = current_user.administrated_organizations + @events = @my_organizations.flat_map(&:events).sort_by(&:start_time) end end diff --git a/app/controllers/review_and_publish_controller.rb b/app/controllers/review_and_publish_controller.rb new file mode 100644 index 000000000..e87c4d333 --- /dev/null +++ b/app/controllers/review_and_publish_controller.rb @@ -0,0 +1,14 @@ +class ReviewAndPublishController < ApplicationController + def show + # Your logic here + end + skip_before_action :authenticate_user! + + include Pundit + + skip_after_action :verify_policy_scoped + skip_after_action :verify_authorized + + def index + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 606ef0970..b1760c6c0 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -32,4 +32,24 @@ def webpack_manifest def webpack_asset_urls(asset_name, asset_type) webpack_manifest["entrypoints"][asset_name]["assets"][asset_type] end + + def get_timezone + # Get the timezone from the request environment + timezone = request.env["user_timezone"] || Time.zone.name + + # Set the user's timezone in the instance variable + @user_timezone = timezone + + # Return the timezone + timezone + end + + def format_event_time(datetime) + return "" if datetime.blank? + + datetime.in_time_zone(@user_timezone || Time.zone.name) + .strftime("%l:%M%p") + .gsub(/\s+/, "") + .strip + end end diff --git a/app/middleware/timezone_detector.rb b/app/middleware/timezone_detector.rb new file mode 100644 index 000000000..26d7d2006 --- /dev/null +++ b/app/middleware/timezone_detector.rb @@ -0,0 +1,17 @@ +class TimezoneDetector + def initialize(app) + @app = app + end + + def call(env) + request = ActionDispatch::Request.new(env) + + # Try to get timezone from the cookie if it exists + timezone = request.cookies["user_timezone"] + + # Store it in the request environment for controllers to access + env["user_timezone"] = timezone || "UTC" + + @app.call(env) + end +end diff --git a/app/models/event.rb b/app/models/event.rb new file mode 100644 index 000000000..1f0c44a5a --- /dev/null +++ b/app/models/event.rb @@ -0,0 +1,6 @@ +class Event < ApplicationRecord + belongs_to :organization + has_one_attached :image + + validates :title, :start_time, presence: true +end diff --git a/app/models/organization.rb b/app/models/organization.rb index 7812c1f8c..62fa4e1cd 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -40,6 +40,7 @@ class Organization < ApplicationRecord has_many :beneficiary_subcategories, through: :organization_beneficiaries has_many :locations, dependent: :destroy has_many :additional_locations, -> { where(main: false) }, class_name: "Location", foreign_key: :organization_id + has_many :events, dependent: :destroy has_one :main_location, -> { where(main: true) }, class_name: "Location", foreign_key: :organization_id has_one :social_media, dependent: :destroy has_one_attached :logo diff --git a/app/views/account_sections/_alerts.html.slim b/app/views/account_sections/_alerts.html.slim new file mode 100644 index 000000000..c98fb35bf --- /dev/null +++ b/app/views/account_sections/_alerts.html.slim @@ -0,0 +1,2 @@ +p.text-lg.font-bold Alerts +p You have no new alerts. diff --git a/app/views/account_sections/_my_events.html.slim b/app/views/account_sections/_my_events.html.slim new file mode 100644 index 000000000..22c89b32e --- /dev/null +++ b/app/views/account_sections/_my_events.html.slim @@ -0,0 +1,2 @@ +p.text-lg.font-bold My Events +p Your upcoming events will appear here. diff --git a/app/views/account_sections/_nonprofit_pages.html.slim b/app/views/account_sections/_nonprofit_pages.html.slim new file mode 100644 index 000000000..50e880271 --- /dev/null +++ b/app/views/account_sections/_nonprofit_pages.html.slim @@ -0,0 +1,2 @@ +p.text-lg.font-bold My Nonprofit Pages +p Here are the pages you manage. diff --git a/app/views/account_sections/_profile.html.slim b/app/views/account_sections/_profile.html.slim new file mode 100644 index 000000000..9f1963655 --- /dev/null +++ b/app/views/account_sections/_profile.html.slim @@ -0,0 +1,2 @@ +p.text-lg.font-bold My Profile +p This is the profile section. diff --git a/app/views/account_sections/_saved_pages.html.slim b/app/views/account_sections/_saved_pages.html.slim new file mode 100644 index 000000000..d925fa58e --- /dev/null +++ b/app/views/account_sections/_saved_pages.html.slim @@ -0,0 +1,2 @@ +p.text-lg.font-bold Saved Pages +p Here are your saved pages. diff --git a/app/views/events/event.html.slim b/app/views/events/event.html.slim new file mode 100644 index 000000000..3f0773d06 --- /dev/null +++ b/app/views/events/event.html.slim @@ -0,0 +1,115 @@ +- badge_bg_colors = { "Fundraiser" => "#9ae2e0", "Volunteer" => "#fc8383", "Outreach" => "#113c7b", "Other" => "#0782d0" } +- badge_text_colors = { "Fundraiser" => "#ffffff", "Volunteer" => "#ffffff", "Outreach" => "#ffffff", "Other" => "#ffffff" } + +div class="w-[75vw] mx-auto mt-6 p-3 flex justify-between gap-4 mb-24" + div class="flex flex-col gap-3 w-2/3 rounded-md bg-white" + img src="/assets/cover-default.png" alt="Cover Image" class="w-full h-24 object-cover rounded-t-md" + - if current_user.administrated_organizations.include?(@event.organization) + div class="flex justify-end me-4" + = link_to "Edit", edit_event_path(@event), class: "text-blue-500 text-sm font-semibold" + div class="flex mx-auto justify-center" + - if @event.type_of_event.present? + .flex.gap-2 + - @event.type_of_event.each do |type| + - category = categorize_event_type([type]) + span.text-xs.font-medium.leading-none[ + style="background-color: #{badge_bg_colors[category]}; color: #{badge_text_colors[category]}; border: 1px solid #{badge_text_colors[category]};" + class="mx-auto font-['Inter'] px-4 py-2 rounded-full" + ] = type + h2 class="self-stretch h-7 text-center justify-center text-slate-800 text-lg font-bold font-['Inter'] leading-7" + = @event.title + " by " + @event.organization.name + div class="flex mx-auto justify-center" + button class="h-12 px-32 py-3 bg-indigo-100 text-[#4338CA] rounded-md flex justify-center items-center gap-2 whitespace-nowrap" + svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" + path d="M11.4922 16H13.4922V7H16.4922L12.4922 2L8.49219 7H11.4922V16Z" fill="#4338CA" + path d="M5.49219 22H19.4922C20.5952 22 21.4922 21.103 21.4922 20V11C21.4922 9.897 20.5952 9 19.4922 9H15.4922V11H19.4922V20H5.49219V11H9.49219V9H5.49219C4.38919 9 3.49219 9.897 3.49219 11V20C3.49219 21.103 4.38919 22 5.49219 22Z" fill="#4338CA" + p Add to Calendar + - if @event.link.present? + div class="self-stretch h-5 text-center justify-center" + span class="text-gray-700 text-base font-bold font-['Inter'] leading-normal" + | More Information: + a class="text-[#3B4958] text-base font-normal font-['Inter'] leading-normal underline" href="//#{@event.link}" target="_blank" + = @event.link + div class="flex justify-center" + hr class="w-[95%] h-0.5 bg-gray-200 my-4" + div class="p-5" + div + h3 class="text-[#3B4958] text-base font-bold font-['Inter'] leading-7" + | Description + p class="text-gray-700 text-base font-normal font-['Inter'] leading-normal" + = @event.description + + div class="flex justify-start gap-2 items-center mt-8" + h3 class="text-[#3B4958] text-base font-bold font-['Inter'] leading-7" + | Date: + - if @event.end_time.present? && @event.start_time.to_date != @event.end_time.to_date + p class="text-gray-700 text-base font-normal font-['Inter'] leading-normal" + = @event.start_time.in_time_zone(@user_timezone).strftime("%B %d, %Y") + " - " + @event.end_time.in_time_zone(@user_timezone).strftime("%B %d, %Y") + - else + p class="text-gray-700 text-base font-normal font-['Inter'] leading-normal" + = @event.start_time.in_time_zone(@user_timezone).strftime("%B %d, %Y") + + + div class="flex justify-start gap-2 items-center" + h3 class="text-[#3B4958] text-base font-bold font-['Inter'] leading-7" + | Time: + - if @event.end_time.present? && @event.start_time.to_date == @event.end_time.to_date + p class="text-gray-700 text-base font-normal font-['Inter'] leading-normal" + = @event.start_time.in_time_zone(@user_timezone).strftime("%I:%M %p") + " - " + @event.end_time.in_time_zone(@user_timezone).strftime("%I:%M %p") + - else + p class="text-gray-700 text-base font-normal font-['Inter'] leading-normal" + = @event.start_time.in_time_zone(@user_timezone).strftime("%I:%M %p") + + div class="flex justify-start gap-2 items-center mt-6" + h3 class="text-[#3B4958] text-base font-bold font-['Inter'] leading-7" + | Location: + p class="text-gray-700 text-base font-normal font-['Inter'] leading-normal" + = @event.location + + + / TODO: Replace with event image if available + - if @event.image.present? + = image_tag url_for(@event.image), class: "w-full object-cover rounded-b-md", data: { target: "logo.output" } + + + div class="w-1/3 flex flex-col gap-4" + div class="flex flex-col gap-4 rounded-md bg-white" + div class="w-full flex flex-col gap-2 p-3 justify-center items-center" + / TODO: check org logo + = image_tag url_for(@organization.logo), class: "h-24 w-24 border border-gray-8 rounded border-2 border-gray-5 object-contain", data: { target: "logo.output" } + h2 class="w-72 h-7 text-center justify-center text-black text-lg font-bold font-['Inter'] leading-7" + = @event.organization.name + = render CausesList::Component.new( \ + causes: @event.organization.causes.uniq, \ + list_options: { class: "flex gap-3 mx-2 text-sm mt-4"}, \ + icon_wrapper_options: { class: "labelled-icon w-14 h-14 flex justify-center items-center rounded-full bg-blue-pale", aria_hidden: true}, \ + icon_svg_options: {class: "w-12 h-8", aria_hidden: true}) + + + - if @upcoming_events.present? + div class="flex flex-col gap-4 rounded-md bg-white p-4" + h3 class="self-stretch h-5 justify-center text-black text-sm font-bold font-['Inter'] uppercase leading-tight" + | UPCOMING EVENTS + + ul class="flex flex-col" + - @upcoming_events.each do |ev| + = render EventListCard::Component.new(admin: false, small: true, event: ev, edit_url: edit_event_path(ev), view_url: event_path(ev)) + + button class="flex gap-2 justify-center font-['Inter'] font-normal text-base" onclick="window.location.href='/events/explore?ein=#{@event.organization.ein_number}'" + | View All Events + + button type="button" class="flex self-stretch py-3 bg-rose-400 rounded-md justify-center items-center gap-2 text-white" + svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" + path fill-rule="evenodd" clip-rule="evenodd" d="M5.50848 9C5.50848 7.14348 6.24597 5.36301 7.55873 4.05025C8.87148 2.7375 10.652 2 12.5085 2C14.365 2 16.1455 2.7375 17.4582 4.05025C18.771 5.36301 19.5085 7.14348 19.5085 9V12.764L21.3305 16.408C21.4144 16.5757 21.454 16.7621 21.4455 16.9494C21.4371 17.1368 21.3809 17.3188 21.2823 17.4783C21.1837 17.6379 21.046 17.7695 20.8822 17.8608C20.7184 17.9521 20.534 18 20.3465 18H16.3825C16.16 18.8582 15.6589 19.6183 14.9577 20.1609C14.2566 20.7035 13.3951 20.9979 12.5085 20.9979C11.6219 20.9979 10.7604 20.7035 10.0592 20.1609C9.35805 19.6183 8.85691 18.8582 8.63448 18H4.67048C4.48295 18 4.29854 17.9521 4.13474 17.8608C3.97095 17.7695 3.83321 17.6379 3.73462 17.4783C3.63603 17.3188 3.57985 17.1368 3.57142 16.9494C3.563 16.7621 3.6026 16.5757 3.68648 16.408L5.50848 12.764V9ZM10.7765 18C10.952 18.304 11.2045 18.5565 11.5085 18.732C11.8125 18.9075 12.1574 18.9999 12.5085 18.9999C12.8595 18.9999 13.2044 18.9075 13.5084 18.732C13.8125 18.5565 14.0649 18.304 14.2405 18H10.7765ZM12.5085 4C11.1824 4 9.91062 4.52678 8.97294 5.46447C8.03526 6.40215 7.50848 7.67392 7.50848 9V12.764C7.50845 13.0743 7.43622 13.3804 7.29748 13.658L6.12748 16H18.8905L17.7205 13.658C17.5814 13.3805 17.5088 13.0744 17.5085 12.764V9C17.5085 7.67392 16.9817 6.40215 16.044 5.46447C15.1063 4.52678 13.8346 4 12.5085 4Z" fill="white" + p class="text-base text-white text-bold font-['Inter'] leading-normal text-nowrap" Subcribe to Calendar + + button type="button" class="flex py-3 bg-indigo-100 rounded-md justify-center items-center gap-2 text-[#4338CA] text-no-wrap" + svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" + path d="M11.4922 16H13.4922V7H16.4922L12.4922 2L8.49219 7H11.4922V16Z" fill="#4338CA" + path d="M5.49219 22H19.4922C20.5952 22 21.4922 21.103 21.4922 20V11C21.4922 9.897 20.5952 9 19.4922 9H15.4922V11H19.4922V20H5.49219V11H9.49219V9H5.49219C4.38919 9 3.49219 9.897 3.49219 11V20C3.49219 21.103 4.38919 22 5.49219 22Z" fill="#4338CA" + p class="text-base text-bold font-['Inter'] leading-normal text-nowrap" Export Calendar + + + + + diff --git a/app/views/events/explore.html.slim b/app/views/events/explore.html.slim new file mode 100644 index 000000000..dcfa5c972 --- /dev/null +++ b/app/views/events/explore.html.slim @@ -0,0 +1,241 @@ +script type="text/javascript" src="https://cdn.jsdelivr.net/jquery/latest/jquery.min.js" +script type="text/javascript" src="https://cdn.jsdelivr.net/momentjs/latest/moment.min.js" +script type="text/javascript" src="https://cdn.jsdelivr.net/npm/daterangepicker/daterangepicker.min.js" +link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/daterangepicker/daterangepicker.css" + +- system_params = ["controller", "action", "format"] +- num_params = request.params.count { |key, val| key != "query" && val.present? && val != "" && !system_params.include?(key) } + +div class="w-full h-64 relative bg-gradient-to-r from-teal-200 via-teal-300/75 to-teal-200 overflow-hidden" + div class="w-96 h-20 left-[505px] top-[91px] absolute text-center justify-start text-blue-900 text-3xl font-extrabold font-['Poppins'] leading-10" + - if params[:ein].present? && params[:ein] != "" + | Explore events for
#{@organization.name} + - else + | Explore nonprofit events in your community + img class="w-8 h-6 left-[1114px] top-[51px] absolute" src="/assets/gc-heart.png" width="32" height="24" + img class="w-8 h-8 left-[1244px] top-[165px] absolute" src="/assets/gc-star.png" width="32" height="32" + img class="w-8 h-8 left-[257px] top-[77px] absolute" src="/assets/gc-star.png" width="32" height="32" + +div class="y-12 mx-auto max-w-6xl md:px-28 md:py-20 mt-6" + = form_with url: "/events/explore", method: :get, local: true, class: "flex justify-between items-center items-stretch" do |f| + div.flex.justify-start.items-center.items-stretch.w-3/4 + = f.text_field :query, value: params[:query], class: "text-gray-500 text-sm font-medium font-['Inter'] leading-10 tracking-tight rounded-l-lg flex-grow", placeholder: "Try \"Mental Health Events\"" + button class="text-sky-900 bg-[#9AE2E0] text-base font-semibold font-['Inter'] leading-10 rounded-r-lg px-4 py-auto flex justify-center items-center gap-2" type="submit" + svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 17 17" fill="none" + path d="M14.875 14.875L10.625 10.625M12.0417 7.08333C12.0417 9.82175 9.82175 12.0417 7.08333 12.0417C4.34492 12.0417 2.125 9.82175 2.125 7.08333C2.125 4.34492 4.34492 2.125 7.08333 2.125C9.82175 2.125 12.0417 4.34492 12.0417 7.08333Z" stroke="#113C7B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" + p SEARCH + button class="text-center text-sky-900 text-base font-semibold font-['Inter'] leading-10 bg-slate-300 rounded-md px-4 flex justify-center items-center gap-2" onclick="showFilters(true)" type="button" + svg xmlns="http://www.w3.org/2000/svg" width="13" height="14" viewBox="0 0 13 14" fill="none" + path fill-rule="evenodd" clip-rule="evenodd" d="M4.25 0.875V4.375C4.25 4.9 3.9 5.25 3.375 5.25C2.85 5.25 2.5 4.9 2.5 4.375V0.875C2.5 0.35 2.85 0 3.375 0C3.9 0 4.25 0.35 4.25 0.875ZM2.5 12.075C1.5375 11.725 0.75 10.7625 0.75 9.625C0.75 8.1375 1.8875 7 3.375 7C4.8625 7 6 8.1375 6 9.625C6 10.7625 5.3 11.725 4.25 12.075V12.25V13.125C4.25 13.65 3.9 14 3.375 14C2.85 14 2.5 13.65 2.5 13.125V12.25V12.075ZM9.5 9.625V13.125C9.5 13.65 9.85 14 10.375 14C10.9 14 11.25 13.65 11.25 13.125V9.625C11.25 9.1 10.9 8.75 10.375 8.75C9.85 8.75 9.5 9.1 9.5 9.625ZM10.375 7C8.8875 7 7.75 5.8625 7.75 4.375C7.75 3.2375 8.5375 2.275 9.5 1.925V1.75V0.875C9.5 0.35 9.85 0 10.375 0C10.9 0 11.25 0.35 11.25 0.875V1.75V1.925C12.2125 2.275 13 3.2375 13 4.375C13 5.8625 11.8625 7 10.375 7Z" fill="#113C7B" + p FILTERS + - if num_params > 0 + p class=" text-sky-900 font-['Inter'] leadning-normal" + | (#{num_params}) + + div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-1/3 p-6 bg-white rounded-lg z-10 shadow-lg right-0 hidden" id="filters" + div class="flex justify-between items-center mb-4" + h2 class="text-xl font-semibold font-['Inter'] text-gray-900" + | Filters + button type="button" class="text-gray-500 hover:text-gray-800" onclick="showFilters(false)" + | ✕ + + div class="flex flex-col gap-6" + div + h3 class="text-gray-900 text-base font-semibold font-['Inter'] mb-2" + | Event Type + div class="flex flex-col gap-2" + div class="flex items-center gap-2" + = f.check_box :type_of_event, { multiple: true, checked: params[:type_of_event].present? && params[:type_of_event].include?("Fundraiser") }, "Fundraiser", nil + label class="text-gray-900 text-sm font-normal font-['Inter']" + | Fundraiser + div class="flex items-center gap-2" + = f.check_box :type_of_event, { multiple: true, checked: params[:type_of_event].present? && params[:type_of_event].include?("Volunteer") }, "Volunteer", nil + label class="text-gray-900 text-sm font-normal font-['Inter']" + | Volunteer + div class="flex items-center gap-2" + = f.check_box :type_of_event, { multiple: true, checked: params[:type_of_event].present? && params[:type_of_event].include?("Outreach") }, "Outreach", nil + label class="text-gray-900 text-sm font-normal font-['Inter']" + | Outreach + div class="flex items-center gap-2" + = f.check_box :type_of_event, { multiple: true, checked: params[:type_of_event].present? && params[:type_of_event].include?("Other") }, "Other", nil + label class="text-gray-900 text-sm font-normal font-['Inter']" + | Other + + div + h3 class="text-gray-900 text-base font-semibold font-['Inter'] mb-2" + | Location + div class="flex flex-col gap-2" + div class="flex items-center gap-2" + = f.check_box :location, { multiple: true, checked: params[:location].present? && params[:location] != "Remote" }, "In-Person", nil + label class="text-gray-900 text-sm font-normal font-['Inter']" + | In-Person + div class="flex items-center gap-2" + = f.check_box :location, { multiple: true, checked: params[:location].present? && params[:location] == "Remote" }, "Virtual", nil + label class="text-gray-900 text-sm font-normal font-['Inter']" + | Virtual + + div + h3 class="text-gray-900 text-base font-semibold font-['Inter'] mb-2" + | Date Range + = f.text_field :daterange, value: params[:daterange], class: "w-full rounded-md text-center", placeholder: "Select date range" + + - if num_params > 0 + div class="mt-6 flex justify-between" + button type="button" class="px-4 py-2 bg-gray-200 text-gray-900 font-semibold rounded-md" onclick="window.location.href = '/events/explore'" + | Clear Filters + button type="submit" class="px-4 py-2 bg-[#9AE2E0] text-sky-900 font-semibold rounded-md" + | Apply Filters + - else + div class="mt-6 flex justify-end" + button type="submit" class="px-4 py-2 bg-[#9AE2E0] text-sky-900 font-semibold rounded-md" + | Apply Filters + + div class="flex gap-4 mt-6 justify-between" + h2 class="text-center justify-center text-black text-2xl font-normal font-['Inter'] leading-9" + | Upcoming Events + div.inline-flex.rounded-md.shadow-xs role="group" + button.px-4.py-2.text-sm.font-medium.text-gray-700.bg-gray-200.border.border-gray-300.rounded-l-md.flex.justify-center.items-center.gap-2 onclick="toggleList(true);" id="events_list_button" + svg xmlns="http://www.w3.org/2000/svg" width="15" height="11" viewBox="0 0 15 11" fill="none" + path d="M1 0.75H13.6667M1 5.5H13.6667M1 10.25H13.6667" stroke="black" stroke-linecap="round" stroke-linejoin="round" + p List + button.px-4.py-2.text-sm.font-medium.text-gray-700.bg-gray-100.border.border-gray-300.rounded-r-md.flex.justify-center.items-center.gap-2 onclick="toggleList(false);" id="events_calendar_button" + svg xmlns="http://www.w3.org/2000/svg" width="18" height="17" viewBox="0 0 18 17" fill="none" + path d="M5.44444 4.05556V0.5M12.5556 4.05556V0.5M4.55556 7.61111H13.4444M2.77778 16.5H15.2222C16.2041 16.5 17 15.7041 17 14.7222V4.05556C17 3.07372 16.2041 2.27778 15.2222 2.27778H2.77778C1.79594 2.27778 1 3.07372 1 4.05556V14.7222C1 15.7041 1.79594 16.5 2.77778 16.5Z" stroke="black" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round" + p Calendar + + div id="events_list" class="h-full bg-white mt-3 rounded-md" + - if @events.present? + ul class="flex flex-col divide-y divide-gray-8 " + - @events.each do |event| + = render EventListCard::Component.new(admin: false, small: false, event: event, edit_url: edit_event_path(event), view_url: event_path(event)) + + - else + p class="text-center text-grey-2 pt-10 h-full" + | No events found for your search. + + div id="events_calendar" class="hidden h-0 w-0" style="min-height: 100vh;" + = render Calendar::Component.new(admin: false) + + +javascript: + function toggleList(isList) { + const listEl = document.getElementById('events_list'); + const calendarEl = document.getElementById('events_calendar'); + const listBtn = document.getElementById('events_list_button'); + const calendarBtn = document.getElementById('events_calendar_button'); + + if (isList) { + // Show list and hide calendar + listEl.classList.remove('hidden'); + calendarEl.classList.add('hidden'); + calendarEl.classList.add('h-0'); + calendarEl.classList.add('w-0'); + + // Change button styles + listBtn.classList.add('bg-gray-200'); + calendarBtn.classList.remove('bg-gray-200'); + } else { + // Hide list + listEl.classList.add('hidden'); + + // Show calendar container with proper dimensions + calendarEl.classList.remove('hidden'); + calendarEl.classList.remove('h-0'); + calendarEl.classList.remove('w-0'); + + // Change button styles + listBtn.classList.remove('bg-gray-200'); + calendarBtn.classList.add('bg-gray-200'); + + // Wait a bit for the DOM to update, then render the calendar + setTimeout(() => { + if (window.calendar && typeof window.calendar.render === 'function') { + window.calendar.render(); + window.calendar.updateSize(); + } else { + console.error('Calendar not properly initialized'); + // Try to reinitialize the calendar + if (typeof setupCalendar === 'function') { + setupCalendar(); + } + } + }, 200); + } + } + + // Add the showFilters function to toggle filter visibility + function showFilters(show) { + const filtersEl = document.getElementById('filters'); + if (show) { + filtersEl.classList.remove('hidden'); + } else { + filtersEl.classList.add('hidden'); + } + } + + function parseDateRange(dateRangeStr) { + if (!dateRangeStr) return { start: null, end: null }; + + // Decode URL-encoded characters + const decodedStr = decodeURIComponent(dateRangeStr); + + // Split by the hyphen to get start and end parts + const parts = decodedStr.split(' - '); + if (parts.length !== 2) return { start: null, end: null }; + + const startStr = parts[0]; + const endStr = parts[1]; + + // Parse dates directly with moment + const startDate = moment(startStr, 'M/D hh:mm A'); + const endDate = moment(endStr, 'M/D hh:mm A'); + + return { + start: startDate.isValid() ? startDate : null, + end: endDate.isValid() ? endDate : null + }; + } + + $(function() { + // Get the daterange parameter from URL + const urlParams = new URLSearchParams(window.location.search); + const dateRangeParam = urlParams.get('daterange'); + + // Initialize daterangepicker with appropriate options + const daterangepickerOptions = { + timePicker: true, + autoUpdateInput: false, // Don't update input until user selects a date range + locale: { + format: 'M/D h:mm A', + cancelLabel: 'Clear', + applyLabel: 'Apply' + }, + drops: 'up', + opens: 'center' + }; + + // Only set startDate and endDate if there's a valid daterange parameter + if (dateRangeParam) { + const parsedDates = parseDateRange(dateRangeParam); + if (parsedDates.start && parsedDates.end) { + daterangepickerOptions.startDate = parsedDates.start; + daterangepickerOptions.endDate = parsedDates.end; + daterangepickerOptions.autoUpdateInput = true; // Update input with the existing date range + } + } + + // Initialize daterangepicker + const dateRangePicker = $('input[name="daterange"]').daterangepicker(daterangepickerOptions); + + // Add event listeners to update the input value when user selects a date range + dateRangePicker.on('apply.daterangepicker', function(ev, picker) { + $(this).val(picker.startDate.format('M/D h:mm A') + ' - ' + picker.endDate.format('M/D h:mm A')); + }); + + // Clear the input when user cancels + dateRangePicker.on('cancel.daterangepicker', function(ev, picker) { + $(this).val(''); + }); + }); + + diff --git a/app/views/events/form.html.slim b/app/views/events/form.html.slim new file mode 100644 index 000000000..2457d5c55 --- /dev/null +++ b/app/views/events/form.html.slim @@ -0,0 +1,302 @@ +style + | .required::before { + | content: "* "; + | font-weight: bold; + | } + +div.flex.justify-center.mt-16.h-full style="background-color: #F9FAFC" + div.flex.gap-4.bg-white.w-3/4 + + = form_with model: @event, class: "w-full h-full m-12 flex flex-col items-start gap-4" do |f| + = hidden_field_tag 'org_id', params[:org_id] if params[:org_id].present? + + = hidden_field_tag 'timezone', @user_timezone + + h2.text-2xl.font-bold style="color: #3B4958;" + = @event.new_record? ? "Create Event" : @readonly ? "View Event" : "Edit Event" + + h3.text-xl + = @organization&.name || "[Organization Name]" + + hr.w-full.border.border-gray-300 + + div + h3.text-xl.mb-2 + | General Event Information + p.opacity-70.text-sm + | General information about your nonprofit event. + + div.w-full + = f.label :title, class: "required" + = f.text_field :title, class: @readonly ? "c-input bg-gray-100 cursor-not-allowed" : "c-input", readonly: @readonly + + div.w-full + = f.label :description, class: "required" + = f.text_area :description, class: @readonly ? "c-input bg-gray-100 cursor-not-allowed" : "c-input", rows: 10, readonly: @readonly + + div.w-full.flex.justify-start.gap-3 + div.w-full + = f.label :date, class: "text-base required" + = f.date_field :date, value: @event_date, class: @readonly ? "c-input bg-gray-100 cursor-not-allowed" : "c-input", disabled: @readonly + + div.w-full + = f.label :start_time, "Start Time", class: "text-base required" + = f.time_field :start_time, value: @event_start_time, class: @readonly ? "c-input bg-gray-100 cursor-not-allowed" : "c-input", disabled: @readonly + + div.w-full.relative + = f.label :end_time, "End Time", class: "text-base" + = f.time_field :end_time, value: @event_end_time, class: @readonly ? "c-input bg-gray-100 cursor-not-allowed" : "c-input", onchange: "shouldShowClearButton()", disabled: @readonly + button.hidden.absolute.right-3.bottom-3 type="button" id="clear-end-time" onclick="clearEndTime()" + | x + + i.font-light.text-sm.text-slate-500 id="timezone-info" + | (Your selected times will be based on your browser's timezone - ?). + + / Event Recurring Type + div.w-full + label.text-base.mb-2.font-medium.text-slate-700.required + | Is this a recurring event? + + div.flex.justify-start.gap-2 + = f.radio_button :isRecurring, false, class: @readonly ? "mt-1 cursor-not-allowed" : "mt-1", id: "recurring-no", disabled: @readonly + = f.label :recurring_false, "One-time", class: "text-base font-medium text-slate-700" + + div.flex.justify-start.gap-2 + = f.radio_button :isRecurring, true, class: @readonly ? "mt-1 cursor-not-allowed" : "mt-1", id: "recurring-yes", disabled: @readonly + = f.label :recurring_true, "Recurring", class: "text-base font-medium text-slate-700" + + / Location + div.w-full + label.text-base.mb-2.font-medium.text-slate-700.required + | Location + + div.flex.justify-start.gap-2 + = f.radio_button :remote, true, checked: @event_is_remote, class: @readonly ? "mt-1 cursor-not-allowed": "mt-1", id: "remote-yes", onclick: "toggleLocation(false)", disabled: @readonly + = f.label :remote_true, "Remote/Online", class: "text-base font-medium text-slate-700" + + div.flex.justify-start.gap-2 + = f.radio_button :remote, false, checked: !@event_is_remote, class: @readonly ? "mt-1 cursor-not-allowed": "mt-1", id: "remote-no", onclick: "toggleLocation(true)", disabled: @readonly + = f.label :remote_false, "In-Person", class: "text-base font-medium text-slate-700" + + / Location Container, appears when In-Person is selected + div.hidden.flex-between.w-full.gap-10 id="location-container" + div.w-1/2 + = f.label :address, class: "text-base font-medium text-slate-700 required" + = f.text_field :address, value: @event_is_remote ? '' : @event.location, class: @readonly ? "c-input bg-gray-100 cursor-not-allowed" : "c-input", readonly: @readonly, disabled: @readonly + + div.w-1/2 + label.text-base.font-medium.text-slate-700 + | Map View + img.outline.outline-1.outline-gray-700.w-full style="background: #E6E6E6; height: 200px;" id="uploaded-img" + + -# Attachment for users to insert image + div.text-base.mt-4.font-medium.text-slate-700 + | Attach Flyer/Photo + div.flex.justify-start + div.flex.flex-col.items-center + img.outline.outline-1.outline-gray-700 style="background: #E6E6E6; height: 148px; width:148px;" id="uploaded-img2" + - if @event.image.attached? + javascript: + document.addEventListener("DOMContentLoaded", function() { + document.getElementById("uploaded-img2").src = "#{url_for(@event.image)}"; + }); + div.flex.flex-col.items-start style="margin-left:27px; margin-top:50px;" + label.bg-white.hover:bg-gray-100.text-gray-700.py-2.px-4.border.border-gray-400.rounded-md.cursor-pointer for="event_image" + b Upload + = f.file_field :image, accept: "image/png, image/jpeg", class: @readonly ? "hidden cursor-not-allowed" : "hidden", id: "event_image", disabled: @readonly + p.text-s.mt-1 + | Maximum size of 5MB + + div.w-full + = f.label :link, "External Link for RSVP (if applicable)" + = f.text_field :link, class: @readonly ? "c-input bg-gray-100 cursor-not-allowed" : "c-input", readonly: @readonly, disabled: @readonly + + div.w-full + label + | Event Type + br + div.flex.flex-col.gap-2.mt-1 + - for type in ["Fundraiser", "Volunteer", "Outreach", "Other"] + div.flex.gap-2 + = f.radio_button :type_of_event, type, class: "mt-1", checked: @event.type_of_event.include?(type), disabled: @readonly + = f.label :"type_of_event_#{type.downcase}", type, class: "text-base font-medium text-slate-700" + + div.flex.justify-center.gap-4.w-full.mb-4 + - if @readonly + = f.button 'Edit', type: 'button', class: 'rounded-md text-xl py-2 px-4 bg-gray-300 hover:bg-gray-400 text-white font-semibold', onclick: "window.location.href='#{edit_event_path(@event)}'" + - if !@event.published + = f.button 'Publish', type: 'button', class: 'rounded-md text-xl py-2 px-4 bg-gray-300 hover:bg-gray-400 text-white font-semibold', onclick: "onPublishEvent('#{@event.id}', '#{@event.title}')" + - elsif !@readonly && !@event.new_record? + = f.button 'Save', type: 'button', class: 'rounded-md text-xl py-2 px-4 bg-gray-400 hover:bg-gray-500 text-white font-semibold', onclick: "return submitForm('#{!@event.published}');" + - else + = f.button 'Save as Draft', type: 'button', class: 'rounded-md text-xl py-2 px-4 bg-gray-300 hover:bg-gray-400 text-white font-semibold', onclick: "return submitForm(true);" + = f.button 'Save and Review', type: 'button', class: 'rounded-md text-xl py-2 px-4 bg-gray-400 hover:bg-gray-500 text-white font-semibold', onclick: "return submitForm(false);" + +javascript defer: + const MAX_SIZE_IN_BYTES = 5 * 1024 * 1024; // 5MB + const USER_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone; + + document.addEventListener("DOMContentLoaded", function() { + // Get today's date + const today = new Date(); + + // Format today's date as YYYY-MM-DD for the input element + const formattedDate = today.toISOString().split('T')[0]; + + // Set the min attribute to today's date + document.getElementById('event_date').setAttribute('min', formattedDate); + + // Set the timezone info text helper + document.getElementById("timezone-info").textContent = document.getElementById("timezone-info").textContent.replaceAll("?", USER_TZ); + + // Initialize location toggle based on current value + toggleLocation(document.querySelector('input[name="event[remote]"]:checked').value === "false"); + }); + + // Upload function for uploading photo + document.getElementById("event_image").addEventListener("change", function() { + const img = document.getElementById("uploaded-img2"); + const file = this.files[0]; + + if (file) { + if(file.size > MAX_SIZE_IN_BYTES){ + alert("The file size exceeds the 5MB limit. Please upload a smaller image."); + this.value = ""; // Clear the file input + return; + } + const reader = new FileReader(); + reader.onload = (e) => img.src = e.target.result; + reader.readAsDataURL(file); + } + }); + + function toggleLocation(isInPerson) { + const locationContainer = document.getElementById("location-container"); + locationContainer.style.display = isInPerson ? "flex" : "none"; + } + + function clearEndTime() { + const endTimeInput = document.getElementById("event_end_time"); + const clearButton = document.getElementById("clear-end-time"); + clearButton.style.display = "none"; + endTimeInput.value = ""; + } + + function shouldShowClearButton() { + const endTimeInput = document.getElementById("event_end_time"); + const clearButton = document.getElementById("clear-end-time"); + clearButton.style.display = endTimeInput.value ? "block" : "none"; + } + + function validateForm() { + const title = document.getElementById("event_title").value; + const description = document.getElementById("event_description").value; + const date = document.getElementById("event_date").value; + const startTime = document.getElementById("event_start_time").value; + const endTime = document.getElementById("event_end_time").value; + const isRecurring = document.querySelector('input[name="event[isRecurring]"]:checked').value; + const isInPerson = document.querySelector('input[name="event[remote]"]:checked').value === "false"; + const address = isInPerson ? document.getElementById("event_address").value : null; + + // Validate required fields + if (!title || !description || !date || !startTime) { + return "Please fill in all required fields."; + } + + // Make sure an address is provided if the event is in-person + if (isInPerson && !address) { + return "Please provide an address for in-person events."; + } + + // Make sure the start time is before the end time if end time is provided + if (endTime) { + const startDateTime = new Date(`${date}T${startTime}`); + const endDateTime = new Date(`${date}T${endTime}`); + if (startDateTime >= endDateTime) { + return "Start time must be before end time. If you've entered an end time by mistake, please clear it and re-submit."; + } + } + + return null; // No validation errors + } + + function onPublishEvent(eventId, title) { + const confirmation = confirm(`Are you sure you want to publish the event "${title}"? This cannot be undone.`); + if (!confirmation) { + return; + } + + fetch(`/events/${eventId}/publish`, { + method: 'POST', + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content') + } + }) + .then(response => { + if (response.ok) { + window.location.reload(); + } else { + alert('Error publishing event'); + } + }); + } + + function submitForm(isDraft) { + // Validate the form + let validityMessage = validateForm(); + if (validityMessage) { + alert(validityMessage); + return; + } + + const form = document.querySelector("form"); + const formData = new FormData(form); + formData.append("event[is_draft]", isDraft); + + let eventTypes = [...document.querySelectorAll('input[name="event[type_of_event]"]:checked')].map(el => el.value); + formData.append("event[type_of_event]", JSON.stringify(eventTypes)); + + // Get date and times from form + const date = document.getElementById("event_date").value; + let startTime = document.getElementById("event_start_time").value; + let endTime = document.getElementById("event_end_time").value; + + // Keep the original date and time values for the backend to handle + if (date && startTime) { + formData.set("event[date]", date); + formData.set("event[start_time]", startTime); + } + + if (endTime) { + formData.set("event[end_time]", endTime); + } + + if (!isDraft) { + const confirmation = confirm(`Are you sure you want to create and publish this event? This cannot be undone.`); + if (!confirmation) { + return; + } + } + + fetch(form.action, { + method: form.method, + body: formData, + headers: { + 'Accept': 'application/json' + } + }) + .then(response => response.json()) + .then(data => { + if (data.event) { + // Redirect to account page on success + window.location.href = '/my_account'; + } else { + // Display errors + alert(data.message + (data.errors ? ': ' + data.errors.join(', ') : '')); + } + }) + .catch(error => { + console.error('Error:', error); + alert('An error occurred while submitting the form.'); + }); + } \ No newline at end of file diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 199f8f5c1..671f3a433 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -18,6 +18,14 @@ 'em': '<%= current_user&.email %>' } }); + + Giving Connection diff --git a/app/views/my_accounts/show.html.slim b/app/views/my_accounts/show.html.slim index b5c0ad72c..fccf41fce 100644 --- a/app/views/my_accounts/show.html.slim +++ b/app/views/my_accounts/show.html.slim @@ -3,16 +3,18 @@ div class="w-full h-full bg-white" h2 class="mb-6 text-2xl font-bold text-center text-grey-2 md:text-left" | My Account div - div data-controller="tabs" data-tabs-active-tab="border-blue-medium text-blue-medium" + div data-controller="tabs" data-tabs-active-tab="border-blue-medium text-blue-medium" data-tabs-inactive-tab="border-transparent text-grey-5" nav class="flex overflow-x-auto px-6 -mb-px space-x-6 text-grey-5" aria-label="Tabs" /! Current: "border-indigo-500 text-indigo-600", Default: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" - a class="px-1 py-4 mb-4 text-sm font-medium whitespace-nowrap border-b-2 border-transparent hover:text-blue-medium hover:border-blue-medium" href="#" data-action="click->tabs#change" data-tabs-target="tab" + a class="px-1 py-4 mb-4 text-sm font-medium whitespace-nowrap border-b-2 border-transparent hover:text-blue-medium hover:border-blue-medium" href="#" data-action="click->tabs#change" data-tabs-target="tab" data-index="0" | My Profile - a class="px-1 py-4 mb-4 text-sm font-medium whitespace-nowrap border-b-2 border-transparent hover:text-blue-medium hover:border-blue-medium" href="#" data-action="click->tabs#change" data-tabs-target="tab" + a class="px-1 py-4 mb-4 text-sm font-medium whitespace-nowrap border-b-2 border-transparent hover:text-blue-medium hover:border-blue-medium" href="#" data-action="click->tabs#change" data-tabs-target="tab" data-index="1" + | My Events + a class="px-1 py-4 mb-4 text-sm font-medium whitespace-nowrap border-b-2 border-transparent hover:text-blue-medium hover:border-blue-medium" href="#" data-action="click->tabs#change" data-tabs-target="tab" data-index="2" | Saved Pages - a class="px-1 py-4 mb-4 text-sm font-medium whitespace-nowrap border-b-2 border-transparent hover:text-blue-medium hover:border-blue-medium" aria-current="page" href="#" data-action="click->tabs#change" data-tabs-target="tab" + a class="px-1 py-4 mb-4 text-sm font-medium whitespace-nowrap border-b-2 border-transparent hover:text-blue-medium hover:border-blue-medium" aria-current="page" href="#" data-action="click->tabs#change" data-tabs-target="tab" data-index="3" | Alerts - a class="px-1 py-4 mb-4 text-sm font-medium whitespace-nowrap border-b-2 border-transparent hover:text-blue-medium hover:border-blue-medium" href="#" data-action="click->tabs#change" data-tabs-target="tab" + a class="px-1 py-4 mb-4 text-sm font-medium whitespace-nowrap border-b-2 border-transparent hover:text-blue-medium hover:border-blue-medium" href="#" data-action="click->tabs#change" data-tabs-target="tab" data-index="4" | My Nonprofit Pages div class="hidden px-6" data-tabs-target="panel" @@ -57,6 +59,65 @@ div class="w-full h-full bg-white" = form.submit "Update", class: "hover:bg-seafoam-light transition mx-auto py-2.5 px-5 text-sm font-bold text-blue-dark bg-seafoam uppercase rounded-6px" div class="col-span-12 py-4 border-t border-gray-8" = link_to "Log out", destroy_user_session_path, method: :delete, class:'hover:text-blue-medium text-xl font-bold text-blue-dark', data: { turbo: false , test_id: "user_logout_link"} + + div class="hidden px-6" data-tabs-target="panel" + h4 class="my-7 text-xl text-grey-2" + | My Events + + - if !current_user.administrated_organizations.blank? + div.w-full.flex.justify-between.place-items-center + div + div.inline-flex.rounded-md.shadow-xs role="group" + button.px-4.py-2.text-sm.font-medium.text-gray-700.bg-gray-200.border.border-gray-300.rounded-l-md.flex.justify-center.items-center.gap-2 onclick="toggleList(true);" id="events_list_button" + svg xmlns="http://www.w3.org/2000/svg" width="15" height="11" viewBox="0 0 15 11" fill="none" + path d="M1 0.75H13.6667M1 5.5H13.6667M1 10.25H13.6667" stroke="black" stroke-linecap="round" stroke-linejoin="round" + p List + button.px-4.py-2.text-sm.font-medium.text-gray-700.bg-gray-100.border.border-gray-300.rounded-r-md.flex.justify-center.items-center.gap-2 onclick="toggleList(false);" id="events_calendar_button" + svg xmlns="http://www.w3.org/2000/svg" width="18" height="17" viewBox="0 0 18 17" fill="none" + path d="M5.44444 4.05556V0.5M12.5556 4.05556V0.5M4.55556 7.61111H13.4444M2.77778 16.5H15.2222C16.2041 16.5 17 15.7041 17 14.7222V4.05556C17 3.07372 16.2041 2.27778 15.2222 2.27778H2.77778C1.79594 2.27778 1 3.07372 1 4.05556V14.7222C1 15.7041 1.79594 16.5 2.77778 16.5Z" stroke="black" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round" + p Calendar + + - if current_user.administrated_organizations.count > 1 + div.relative + button.px-3.py-1.text-sm.font-medium.text-gray-700.bg-gray-100.border.border-gray-300.rounded-md.relative.flex.justify-between.items-center style="background: #9AE2DF;" onclick="document.getElementById('create_event_dropdown').classList.toggle('hidden');" + p Create Event + = inline_svg_tag "down-arrow.svg", size:"12*12", class:"h-7 ms-2 fill-current text-gray-3" + + div.absolute.flex.flex-col.justify-between.z-50.rounded-md.shadow-lg.hidden id="create_event_dropdown" + - for org in current_user.administrated_organizations + = link_to org.name, new_event_path(org_id: org.id), class: "px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 bg-white hover:bg-gray-100" + \ + (org.id == current_user.administrated_organizations.first.id ? " rounded-t-md" : "") + \ + (org.id == current_user.administrated_organizations.last.id ? " rounded-b-md" : "") + - else + = link_to "Create Event", new_event_path(org_id: current_user.administrated_organizations.first.id), class: "px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 rounded-md", style: "background: #9AE2DF;" + + + div id="events_list" class="h-full" + div.max-w-4xl.mx-auto.bg-gray-200.mt-2.p-1.sticky.z-10.ps-4.rounded-md + div.flex.justify-between.font-bold.border-black + div Events + div.mr-12 Status + = hidden_field_tag 'org_ids', current_user.administrated_organizations.pluck(:id) + - if @events.present? + ul class="flex flex-col divide-y divide-gray-8" + - @events.each do |event| + li + = render EventListCard::Component.new(admin: true, small: false, event: event, edit_url: edit_event_path(event), view_url: event_path(event)) + - else + p class="text-center text-grey-2 pt-10 h-full" + | Your upcoming events will appear here. + + div id="events_calendar" class="hidden h-0 w-0" + = render Calendar::Component.new(admin: true) + - else + div class="flex justify-center my-16 w-full" + = inline_svg_tag 'empty_state_2.svg', size:'294*145' + div class="flex flex-col gap-4 justify-center items-center my-4 mb-16 w-full text-grey-2" + p class="text-xl font-bold text-center" + | You don't have any organizations to manage events for. + p class="px-4 max-w-xs text-base text-center" + | Contact us to add or claim a nonprofit page. + div class="hidden" data-tabs-target="panel" h4 class="px-6 my-7 text-xl text-grey-2" | My Saved Nonprofit Pages @@ -138,3 +199,34 @@ div class="w-full h-full bg-white" | You haven't claimed or added any Nonprofit Pages. p class="px-4 max-w-xs text-base text-center" | Click "Add A Nonprofit" on the top menu bar to contact us about adding or claiming a nonprofit page. + +javascript: + function toggleList(isList) { + if (isList) { + // Show list and hide calendar + document.getElementById('events_list').classList.remove('hidden'); + document.getElementById('events_calendar').classList.add('hidden'); + document.getElementById('events_list').classList.remove('h-full'); + document.getElementById('events_calendar').classList.add('h-0'); + document.getElementById('events_calendar').classList.add('w-0'); + + // Change button styles + document.getElementById('events_list_button').classList.add('bg-gray-200'); + document.getElementById('events_calendar_button').classList.remove('bg-gray-200'); + } else { + document.getElementById('events_list').classList.add('hidden'); + document.getElementById('events_calendar').classList.remove('hidden'); + document.getElementById('events_list').classList.add('h-full'); + document.getElementById('events_calendar').classList.remove('h-0'); + document.getElementById('events_calendar').classList.remove('w-0'); + + document.getElementById('events_list_button').classList.remove('bg-gray-200'); + document.getElementById('events_calendar_button').classList.add('bg-gray-200'); + + if (window.calendar) { + window.calendar.render(); + } + } + } + + diff --git a/app/views/review_and_publish/review_and_publish.erb b/app/views/review_and_publish/review_and_publish.erb new file mode 100644 index 000000000..31560f1a7 --- /dev/null +++ b/app/views/review_and_publish/review_and_publish.erb @@ -0,0 +1,38 @@ +
+

<%= "We found #{@new_locations.count} new #{'organization'.pluralize(@new_locations.count)} matching your search." %>

+
+ <% if @alert.keyword.present? %> +

Your search alert for <%= @alert.keyword %>

+ <% end %> +

Filters: <%=@alert_filters%>

+
+
+
+ <% @new_locations.each do |location| %> +
+
+ <%= link_to (image_tag(attachments[location.organization.logo.blob.filename.to_s].url, style: "max-width: 100%; height: 96px; object-fit: contain;")), location_url(location.id), target: "_blank", style: "margin: 0px; text-decoration: none; color: inherit;" %> +
+
+ target="_blank" style="margin: 0px; text-decoration: none; color: inherit;">
<%= location.name %>
+
+ <%= link_to location.decorate.street, "https://www.google.com/maps/search/#{location.formatted_address}", target: "blank", style: "margin: 0px; text-decoration: none; color: inherit;" %> + <%= link_to location.decorate.city_state_zipcode, "https://www.google.com/maps/search/#{location.formatted_address}", target: "blank", style: "margin: 0px; text-decoration: none; color: inherit;" %> +
+ target="_blank" style="margin: 0px; text-decoration: none; color: inherit; margin-top: 16px;"><%= location.organization.website %> +
+

<%= location.phone_number&.number %>

+
+
+
+ <% end %> +
+
+
+ <%= link_to 'MORE NONPROFITS', search_url, target: "_blank", style: "width: 200px; height: 39px; margin-left: 0; margin-right: 0; padding: 10px 20px; font-weight: 700; text-decoration: none; white-space: nowrap; background: #0782D0; border-radius: 6px; color: white;" %> +
+
+

<%= "This email was intended for #{@alert.user.email}" %>

+

You are receiving search alerts. <%= link_to 'Manage Alerts', my_account_url, target: "_blank", style: "margin: 0px; font-size: 14px;"%> +

+
\ No newline at end of file diff --git a/app/views/review_and_publish/show.html.slim b/app/views/review_and_publish/show.html.slim new file mode 100644 index 000000000..43c573d59 --- /dev/null +++ b/app/views/review_and_publish/show.html.slim @@ -0,0 +1,30 @@ +div class="flex flex-col text-gray-2 items-center" + /* Banner */ + div class="relative bg-white w-4/5 m-10 p-8" + h3 class="m-4 font-bold" Review and Publish + h4 class="m-4" [title of npo] + img class="bg-gray w-4/5 h-60 m-auto" + div class="flex flex-hor" + p class="m-4 w-1/2 font-semibold" event name + p class="m-4 w-1/2 text-xs font-light" event type + + div class="m-4 flex flex-col text-xs" + p class="my-2" Description + p class="my-2" insert_description + p class="my-2" Date: + p class="my-2" Time: + p class="my-2" External Link for RSVP: + p class="my-2" Location: + + p class="m-4 w-1/2 text-sm" Map View + img class="m-4 bg-gray w-60 h-60" + + div class="m-4 flex flex-hor w-3/5 items-center ml-auto mr-auto" + = link_to "Go Back", search_path, class:'b-button inline-block text-white bg-[#707070]' + = link_to "Save as a Draft", search_path, class:'b-button inline-block text-white bg-[#AAA]' + = link_to "Publish", search_path, class:'b-button inline-block text-white bg-[#707070]' + + + + + \ No newline at end of file diff --git a/config/application.rb b/config/application.rb index d4347bd7f..c6d1fc0b6 100644 --- a/config/application.rb +++ b/config/application.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative "boot" +require_relative "../app/middleware/timezone_detector" require "rails/all" @@ -27,5 +28,6 @@ class Application < Rails::Application config.i18n.available_locales = %i[en es] config.autoload_paths += Dir.glob("#{config.root}/app/lib") config.assets.css_compressor = nil + config.middleware.use TimezoneDetector end end diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index d8c88a8bf..3ab8cb06c 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -3,7 +3,7 @@ elsif Rails.env.staging? ENV["REDIS_URL"] || Rails.application.credentials.staging[:redis_url] else - ENV["REDISCLOUD_URL"] || Rails.application.credentials.development[:redis_url] + ENV["REDISCLOUD_URL"] || Rails.application.credentials.development&.dig(:redis_url) || "redis://localhost:6379" end Sidekiq.configure_server do |config| diff --git a/config/initializers/timezone.rb b/config/initializers/timezone.rb index 518231cc7..79dddc5ab 100644 --- a/config/initializers/timezone.rb +++ b/config/initializers/timezone.rb @@ -1,3 +1,3 @@ Timezone::Lookup.config(:geonames) do |c| - c.username = Rails.application.credentials.dig(:geonames, :username) || ENV.fetch("GEONAMES_USERNAME") + c.username = Rails.application.credentials.dig(:geonames, :username) || ENV["GEONAMES_USERNAME"] || "geonames_username" end diff --git a/config/routes.rb b/config/routes.rb index f3dd28500..f5da56c64 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -65,12 +65,22 @@ get "discover/:name" => "causes#show", :as => :discover_show resource :my_account, only: %i[show] resource :about_us, only: %i[show] + resource :review_and_publish, controller: "review_and_publish" resource :faqs, only: %i[show] resource :donate, only: %i[show] resource :privacy_policy, only: %i[show] resource :infowindow, only: :new resources :autocomplete, only: %i[index] + get "events/explore(/:id)", to: "events#discover", as: :discover_events + + resources :events, only: [:index, :new, :create, :destroy, :edit, :update, :show], + constraints: {id: /\d+/} do + member do + post :publish + end + end + root to: "home#index" # Custom routes for city-based search @@ -79,4 +89,15 @@ get "/los-angeles", to: "cities#show", city: "Los Angeles", as: :los_angeles get "redirect", to: "redirection#notice_external_link", as: :redirect + + # Non-profit events listing route (accepts orgId as query parameter) + get "/events", to: "events#index" + + # Non-profit Event Calendar + get "event_calendar", to: "event_calendar#index" + + # non-profit events + post "/events/:org_id", to: "events#create" + delete "/events/:id", to: "events#destroy" + put "/events/:id", to: "events#update" end diff --git a/db/migrate/20250227181921_create_events.rb b/db/migrate/20250227181921_create_events.rb new file mode 100644 index 000000000..2a1ed8c31 --- /dev/null +++ b/db/migrate/20250227181921_create_events.rb @@ -0,0 +1,26 @@ +class CreateEvents < ActiveRecord::Migration[6.1] + def change + create_table :events do |t| + t.string :title, null: false + t.text :description + t.datetime :start_time, null: false + t.datetime :end_time, null: false + t.string :link + t.string :image_link + t.string :location + t.boolean :published, default: false + t.boolean :isRecurring, default: false + + # Arrays for categorization + t.string :type_of_event, array: true, default: [] + t.string :tags, array: true, default: [] + t.string :categories, array: true, default: [] + t.string :subcategories, array: true, default: [] + + # Foreign key to tie events to organizations + t.references :organization, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/migrate/20250404165614_change_end_time_null_constraint_on_events.rb b/db/migrate/20250404165614_change_end_time_null_constraint_on_events.rb new file mode 100644 index 000000000..40cf41181 --- /dev/null +++ b/db/migrate/20250404165614_change_end_time_null_constraint_on_events.rb @@ -0,0 +1,5 @@ +class ChangeEndTimeNullConstraintOnEvents < ActiveRecord::Migration[6.1] + def change + change_column_null :events, :end_time, true + end +end diff --git a/db/schema.rb b/db/schema.rb index 82c24e684..8d28e9df0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2025_01_17_173849) do +ActiveRecord::Schema[7.2].define(version: 2025_07_23_171704) do # These are extensions that must be enabled in order to support this database enable_extension "fuzzystrmatch" enable_extension "pg_trgm" @@ -130,6 +130,26 @@ t.datetime "updated_at", null: false end + create_table "events", force: :cascade do |t| + t.string "title", null: false + t.text "description" + t.datetime "start_time", precision: nil, null: false + t.datetime "end_time", precision: nil + t.string "link" + t.string "image_link" + t.string "location" + t.boolean "published", default: false + t.boolean "isRecurring", default: false + t.string "type_of_event", default: [], array: true + t.string "tags", default: [], array: true + t.string "categories", default: [], array: true + t.string "subcategories", default: [], array: true + t.bigint "organization_id", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["organization_id"], name: "index_events_on_organization_id" + end + create_table "favorite_locations", force: :cascade do |t| t.bigint "user_id", null: false t.bigint "location_id", null: false @@ -366,6 +386,7 @@ add_foreign_key "alert_services", "services" add_foreign_key "alerts", "users" add_foreign_key "beneficiary_subcategories", "beneficiary_groups" + add_foreign_key "events", "organizations" add_foreign_key "favorite_locations", "locations" add_foreign_key "favorite_locations", "users" add_foreign_key "location_services", "locations"