Skip to content

Commit 1f30805

Browse files
committed
Implement event hover card feature with AJAX support and relationship icons
1 parent 3faa243 commit 1f30805

File tree

12 files changed

+537
-58
lines changed

12 files changed

+537
-58
lines changed

app/controllers/better_together/events_controller.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ def index
1717
end
1818

1919
def show
20+
# Handle AJAX requests for card format - only our specific hover card requests
21+
card_request = request.headers['X-Card-Request'] == 'true' || request.headers['HTTP_X_CARD_REQUEST'] == 'true'
22+
23+
if request.xhr? && card_request
24+
render partial: 'better_together/events/event', locals: { event: @event }, layout: false
25+
return
26+
end
27+
2028
super
2129
end
2230

app/helpers/better_together/application_helper.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,5 +204,21 @@ def main_app_url_helper?(method)
204204
def better_together_url_helper?(method)
205205
method.to_s.end_with?('_path', '_url') && BetterTogether::Engine.routes.url_helpers.respond_to?(method)
206206
end
207+
208+
# Returns the appropriate icon and color for an event based on the person's relationship to it
209+
def event_relationship_icon(person, event)
210+
relationship = person.event_relationship_for(event)
211+
212+
case relationship
213+
when :created
214+
{ icon: 'fas fa-user-edit', color: '#28a745', tooltip: t('better_together.events.relationship.created', default: 'Created by you') }
215+
when :going
216+
{ icon: 'fas fa-check-circle', color: '#007bff', tooltip: t('better_together.events.relationship.going', default: 'You\'re going') }
217+
when :interested
218+
{ icon: 'fas fa-heart', color: '#e91e63', tooltip: t('better_together.events.relationship.interested', default: 'You\'re interested') }
219+
else
220+
{ icon: 'fas fa-circle', color: '#6c757d', tooltip: t('better_together.events.relationship.calendar', default: 'Calendar event') }
221+
end
222+
end
207223
end
208224
end
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
3+
// Connects to data-controller="better_together--event-hover-card"
4+
export default class extends Controller {
5+
static values = {
6+
eventId: String,
7+
eventUrl: String
8+
}
9+
10+
connect() {
11+
this.popover = null
12+
this.isPopoverVisible = false
13+
this.isNavigating = false
14+
this.showTimeout = null
15+
this.hideTimeout = null
16+
this.eventCardContent = null
17+
this.contentLoaded = false
18+
19+
// Pre-fetch the event card content immediately
20+
this.prefetchEventCard()
21+
this.setupPopover()
22+
}
23+
24+
disconnect() {
25+
this.cleanupPopover()
26+
}
27+
28+
async setupPopover() {
29+
// Setup popover with initial loading content
30+
this.popover = new bootstrap.Popover(this.element, {
31+
content: this.getPopoverContent(),
32+
html: true,
33+
placement: 'auto',
34+
fallbackPlacements: ['top', 'bottom', 'right', 'left'],
35+
trigger: 'manual', // Use manual trigger for better control
36+
delay: { show: 0, hide: 100 }, // No delay for show since content is pre-fetched
37+
customClass: 'event-hover-card-popover',
38+
sanitize: false,
39+
container: 'body', // Render in body to avoid positioning issues
40+
boundary: 'viewport',
41+
offset: [0, 8] // Add some offset from the trigger element
42+
})
43+
44+
// Setup manual hover events
45+
this.setupHoverEvents()
46+
}
47+
48+
getPopoverContent() {
49+
if (this.contentLoaded && this.eventCardContent) {
50+
return this.eventCardContent
51+
} else {
52+
return '<div class="text-center py-3"><div class="spinner-border spinner-border-sm" role="status"><span class="visually-hidden">Loading...</span></div><div class="small text-muted mt-2">Loading event details...</div></div>'
53+
}
54+
}
55+
56+
async prefetchEventCard() {
57+
const requestUrl = `${this.eventUrlValue}?format=card`
58+
59+
try {
60+
const response = await fetch(`${this.eventUrlValue}?format=card`, {
61+
headers: {
62+
'Accept': 'text/html',
63+
'X-Requested-With': 'XMLHttpRequest',
64+
'X-Card-Request': 'true'
65+
}
66+
})
67+
68+
if (response.ok) {
69+
const cardHtml = await response.text()
70+
this.eventCardContent = cardHtml
71+
this.contentLoaded = true
72+
73+
// Update popover content if it exists and is already shown
74+
if (this.popover && this.isPopoverVisible) {
75+
this.updatePopoverContent(cardHtml)
76+
}
77+
} else {
78+
this.eventCardContent = this.generateFallbackContent()
79+
this.contentLoaded = true
80+
}
81+
} catch (error) {
82+
console.error('Error pre-fetching event card:', error)
83+
this.eventCardContent = this.generateFallbackContent()
84+
this.contentLoaded = true
85+
}
86+
}
87+
88+
setupHoverEvents() {
89+
const showPopover = () => {
90+
// Don't show popover if we're navigating
91+
if (this.isNavigating) return
92+
93+
clearTimeout(this.hideTimeout)
94+
this.showTimeout = setTimeout(() => {
95+
if (!this.isNavigating) {
96+
this.isPopoverVisible = true
97+
98+
// Update content if it's been loaded since popover creation
99+
if (this.contentLoaded) {
100+
this.popover.setContent({
101+
'.popover-body': this.eventCardContent
102+
})
103+
}
104+
105+
this.popover.show()
106+
}
107+
}, 300) // Reduced delay since content is pre-fetched
108+
}
109+
110+
const hidePopover = () => {
111+
clearTimeout(this.showTimeout)
112+
113+
// Don't hide if we're navigating (cleanup will handle it)
114+
if (this.isNavigating) return
115+
116+
this.hideTimeout = setTimeout(() => {
117+
if (!this.isNavigating) {
118+
this.isPopoverVisible = false
119+
this.popover.hide()
120+
}
121+
}, 100)
122+
}
123+
124+
// Show on hover
125+
this.element.addEventListener('mouseenter', showPopover)
126+
this.element.addEventListener('focus', showPopover)
127+
128+
// Hide when leaving the trigger element
129+
this.element.addEventListener('mouseleave', hidePopover)
130+
this.element.addEventListener('blur', hidePopover)
131+
132+
// Keep popover open when hovering over it
133+
this.element.addEventListener('shown.bs.popover', () => {
134+
const popoverElement = document.querySelector('.event-hover-card-popover')
135+
if (popoverElement) {
136+
popoverElement.addEventListener('mouseenter', () => {
137+
clearTimeout(this.hideTimeout)
138+
})
139+
popoverElement.addEventListener('mouseleave', hidePopover)
140+
141+
// Intercept link clicks within the popover
142+
this.setupPopoverLinkInterception(popoverElement)
143+
}
144+
})
145+
146+
// Handle popover hiding
147+
this.element.addEventListener('hidden.bs.popover', () => {
148+
this.isPopoverVisible = false
149+
})
150+
}
151+
152+
setupPopoverLinkInterception(popoverElement) {
153+
// Find all links within the popover
154+
const links = popoverElement.querySelectorAll('a[href]')
155+
156+
links.forEach(link => {
157+
link.addEventListener('click', (event) => {
158+
// Set a flag to prevent any hover events from interfering
159+
this.isNavigating = true
160+
161+
// Hide the popover gracefully without disposing immediately
162+
if (this.popover) {
163+
this.popover.hide()
164+
}
165+
166+
// Schedule cleanup after Bootstrap's hide animation completes
167+
setTimeout(() => {
168+
this.safeCleanup()
169+
}, 200)
170+
171+
// Note: We don't preventDefault() here to allow normal navigation
172+
})
173+
})
174+
}
175+
176+
safeCleanup() {
177+
// Clear all timeouts
178+
if (this.showTimeout) clearTimeout(this.showTimeout)
179+
if (this.hideTimeout) clearTimeout(this.hideTimeout)
180+
181+
// Reset state
182+
this.isPopoverVisible = false
183+
this.isNavigating = false
184+
185+
// Only dispose if popover still exists and is not in transition
186+
if (this.popover) {
187+
try {
188+
this.popover.dispose()
189+
this.popover = null
190+
} catch (error) {
191+
console.warn('Error disposing popover:', error)
192+
this.popover = null
193+
}
194+
}
195+
196+
// Clean up any remaining popover DOM elements
197+
const remainingPopovers = document.querySelectorAll('.event-hover-card-popover')
198+
remainingPopovers.forEach(popover => {
199+
try {
200+
popover.remove()
201+
} catch (error) {
202+
console.warn('Error removing popover element:', error)
203+
}
204+
})
205+
}
206+
207+
cleanupPopover() {
208+
// Clear all timeouts
209+
if (this.showTimeout) clearTimeout(this.showTimeout)
210+
if (this.hideTimeout) clearTimeout(this.hideTimeout)
211+
212+
// Reset state
213+
this.isPopoverVisible = false
214+
215+
// Hide and dispose popover safely
216+
if (this.popover) {
217+
try {
218+
this.popover.hide()
219+
// Delay disposal to allow hide animation to complete
220+
setTimeout(() => {
221+
if (this.popover) {
222+
try {
223+
this.popover.dispose()
224+
this.popover = null
225+
} catch (error) {
226+
console.warn('Error disposing popover:', error)
227+
this.popover = null
228+
}
229+
}
230+
}, 150)
231+
} catch (error) {
232+
console.warn('Error hiding popover:', error)
233+
this.popover = null
234+
}
235+
}
236+
237+
// Clean up any remaining popover DOM elements
238+
setTimeout(() => {
239+
const remainingPopovers = document.querySelectorAll('.event-hover-card-popover')
240+
remainingPopovers.forEach(popover => {
241+
try {
242+
popover.remove()
243+
} catch (error) {
244+
console.warn('Error removing popover element:', error)
245+
}
246+
})
247+
}, 200)
248+
}
249+
250+
updatePopoverContent(content) {
251+
if (this.popover) {
252+
this.popover.setContent({
253+
'.popover-body': content
254+
})
255+
}
256+
}
257+
258+
generateFallbackContent() {
259+
return `
260+
<div class="event-hover-card">
261+
<div class="text-center">
262+
<i class="fas fa-exclamation-triangle text-warning"></i>
263+
<p class="mb-0 small">Unable to load event details</p>
264+
<a href="${this.eventUrlValue}" class="btn btn-sm btn-outline-primary mt-2">
265+
<i class="fas fa-eye me-1"></i> View Event
266+
</a>
267+
</div>
268+
</div>
269+
`
270+
}
271+
}

app/models/better_together/event.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ class Event < ApplicationRecord
3535
translates :name
3636
translates :description, backend: :action_text
3737

38+
slugged :name
39+
3840
validates :name, presence: true
3941
validates :registration_url, format: { with: URI::DEFAULT_PARSER.make_regexp(%w[http https]) }, allow_blank: true,
4042
allow_nil: true

app/models/better_together/person.rb

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,40 @@ def after_record_created
162162
community.update!(creator_id: id)
163163
end
164164

165+
# Returns all events relevant to this person's calendar view
166+
# Combines events they're going to, created, and interested in
167+
def all_calendar_events
168+
@all_calendar_events ||= begin
169+
# Events from primary calendar (going)
170+
calendar_events = primary_calendar.events.includes(:string_translations)
171+
172+
# Events they created
173+
created_events = Event.includes(:string_translations).where(creator_id: id)
174+
175+
# Events they're interested in (but not going)
176+
interested_event_ids = event_attendances.where(status: 'interested').pluck(:event_id)
177+
interested_events = Event.includes(:string_translations).where(id: interested_event_ids)
178+
179+
# Combine all events, removing duplicates by ID
180+
all_events = (calendar_events.to_a + created_events.to_a + interested_events.to_a)
181+
all_events.uniq(&:id)
182+
end
183+
end
184+
185+
# Determines the relationship type for an event
186+
# Returns: :going, :created, :interested, or :calendar
187+
def event_relationship_for(event)
188+
return :created if event.creator_id == id
189+
190+
attendance = event_attendances.find_by(event: event)
191+
return attendance.status.to_sym if attendance
192+
193+
# Check if it's in their calendar (fallback)
194+
return :going if primary_calendar.events.include?(event)
195+
196+
:calendar # Default for calendar events
197+
end
198+
165199
include ::BetterTogether::RemoveableAttachment
166200
end
167201
end

app/views/better_together/events/show.html.erb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
<% attendance = BetterTogether::EventAttendance.find_by(event: @event, person: current_person) %>
5555
<div class="container my-3">
5656
<div class="d-flex gap-2">
57-
<%= button_to rsvp_interested_event_path(@event), method: :post, class: "btn btn-outline-primary #{'active' if attendance&.status == 'interested'}" do %>
57+
<%= button_to rsvp_interested_event_path(@event), method: :post, class: "btn btn-outline-danger #{'active' if attendance&.status == 'interested'}", style: "#{attendance&.status == 'interested' ? '--bs-btn-active-bg: #e91e63; --bs-btn-active-border-color: #e91e63;' : '--bs-btn-color: #e91e63; --bs-btn-border-color: #e91e63; --bs-btn-hover-bg: #e91e63; --bs-btn-hover-border-color: #e91e63;'}" do %>
5858
<i class="fas fa-heart me-2" aria-hidden="true"></i><%= t('better_together.events.rsvp_interested', default: 'Interested') %>
5959
<% end %>
6060
<%= button_to rsvp_going_event_path(@event), method: :post, class: "btn btn-primary #{'active' if attendance&.status == 'going'}" do %>

0 commit comments

Comments
 (0)