← back to Jill Website
public/js/activity-links.js
429 lines
/**
* Activity Links Component
* Displays activity links (website, maps, reviews) with loading states
*/
class ActivityLinks {
constructor(options = {}) {
this.activityId = options.activityId;
this.container = options.container;
this.timeout = options.timeout || 10000; // 10 second timeout
this.showLabels = options.showLabels !== false;
this.compact = options.compact || false;
this.state = {
loading: true,
error: null,
data: null,
timedOut: false
};
this.init();
}
/**
* Initialize the component
*/
init() {
if (!this.container) {
console.error('ActivityLinks: Container element is required');
return;
}
if (!this.activityId) {
console.error('ActivityLinks: activityId is required');
this.showError('Missing activity ID');
return;
}
this.render();
this.fetchLinks();
}
/**
* Fetch activity links from API
*/
async fetchLinks() {
const timeoutId = setTimeout(() => {
if (this.state.loading) {
this.state.timedOut = true;
this.state.loading = false;
this.state.error = 'Request timed out. Please check your connection.';
this.render();
}
}, this.timeout);
try {
const response = await fetch(`/api/activities/${this.activityId}`);
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Failed to load activity links');
}
this.state.loading = false;
this.state.data = result.data;
this.state.error = null;
this.render();
} catch (error) {
clearTimeout(timeoutId);
if (!this.state.timedOut) {
this.state.loading = false;
this.state.error = error.message || 'Failed to load activity links';
this.render();
}
console.error('ActivityLinks fetch error:', error);
}
}
/**
* Render the component based on current state
*/
render() {
if (!this.container) return;
if (this.state.loading) {
this.renderLoading();
} else if (this.state.error) {
this.renderError();
} else if (this.state.data) {
this.renderLinks();
}
}
/**
* Render loading skeleton
*/
renderLoading() {
const skeletonClass = this.compact ? 'compact' : '';
this.container.innerHTML = `
<div class="activity-links-loading ${skeletonClass}">
<div class="activity-link-skeleton">
<div class="skeleton-icon"></div>
${this.showLabels ? '<div class="skeleton-label"></div>' : ''}
</div>
<div class="activity-link-skeleton">
<div class="skeleton-icon"></div>
${this.showLabels ? '<div class="skeleton-label"></div>' : ''}
</div>
<div class="activity-link-skeleton">
<div class="skeleton-icon"></div>
${this.showLabels ? '<div class="skeleton-label"></div>' : ''}
</div>
</div>
`;
this.container.setAttribute('aria-busy', 'true');
this.container.setAttribute('aria-label', 'Loading activity links');
}
/**
* Render error state
*/
renderError() {
const retryButton = this.state.timedOut
? '<button class="activity-links-retry-btn">Retry</button>'
: '';
this.container.innerHTML = `
<div class="activity-links-error">
<i class="fas fa-exclamation-triangle"></i>
<p>${this.state.error}</p>
${retryButton}
</div>
`;
this.container.setAttribute('aria-busy', 'false');
this.container.setAttribute('aria-label', `Error: ${this.state.error}`);
// Add retry button handler
if (this.state.timedOut) {
const retryBtn = this.container.querySelector('.activity-links-retry-btn');
if (retryBtn) {
retryBtn.addEventListener('click', () => this.retry());
}
}
}
/**
* Render activity links
*/
renderLinks() {
const { website, mapsLink, reviewLink, phone, name } = this.state.data;
const links = [];
const availableTypes = [];
const missingTypes = [];
// Phone link (if available) - triggers device dialer on mobile
if (phone) {
links.push({
url: `tel:${phone.replace(/[^\d+]/g, '')}`,
icon: 'fas fa-phone',
label: 'Call',
title: `Call ${name}`,
target: '_self' // Opens in same window to trigger dialer
});
availableTypes.push('phone');
} else {
missingTypes.push('phone');
}
// Website link
if (website) {
links.push({
url: website,
icon: 'fas fa-globe',
label: 'Website',
title: `Visit ${name} website`,
target: '_blank'
});
availableTypes.push('website');
} else {
missingTypes.push('website');
}
// Maps link - device-aware URL handling
if (mapsLink) {
const mapsUrl = this.getDeviceOptimizedMapsUrl(mapsLink);
links.push({
url: mapsUrl,
icon: 'fas fa-map-marker-alt',
label: 'Directions',
title: `Get directions to ${name}`,
target: '_self' // Opens in same window to allow device maps app
});
availableTypes.push('directions');
} else {
missingTypes.push('directions');
}
// Review link
if (reviewLink) {
links.push({
url: reviewLink,
icon: 'fas fa-star',
label: 'Reviews',
title: `Read ${name} reviews`,
target: '_blank'
});
availableTypes.push('reviews');
} else {
missingTypes.push('reviews');
}
// If no links available
if (links.length === 0) {
this.renderNoLinksMessage();
return;
}
// If partial information (some links available, some missing)
const hasPartialInfo = links.length > 0 && links.length < 4;
const compactClass = this.compact ? 'compact' : '';
const linksHtml = links.map(link => {
const targetAttr = link.target === '_blank' ? 'target="_blank" rel="noopener noreferrer"' : '';
return `
<a href="${link.url}"
class="activity-link"
${targetAttr}
title="${link.title}"
aria-label="${link.label}">
<i class="${link.icon}"></i>
${this.showLabels ? `<span>${link.label}</span>` : ''}
</a>
`;
}).join('');
// Add partial info message if applicable
const partialInfoMessage = hasPartialInfo ? this.getPartialInfoMessage(missingTypes) : '';
this.container.innerHTML = `
<div class="activity-links ${compactClass}">
${linksHtml}
</div>
${partialInfoMessage}
`;
this.container.setAttribute('aria-busy', 'false');
this.container.setAttribute('aria-label', `Activity links loaded. ${availableTypes.length} of 4 links available`);
}
/**
* Get device-optimized maps URL
* On mobile devices, returns URL that will open in device's maps app
*/
getDeviceOptimizedMapsUrl(mapsLink) {
// Check if user is on mobile device
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
if (!isMobile) {
return mapsLink; // Desktop: use regular Google Maps URL
}
// Mobile: Try to detect iOS vs Android for optimal app handling
const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent);
if (isIOS) {
// iOS: Convert to Apple Maps URL if it's a Google Maps link
if (mapsLink.includes('google.com/maps')) {
// Extract coordinates or place name from Google Maps URL
// For simplicity, return the Google Maps URL which iOS will handle
return mapsLink;
}
}
// Android and other mobile devices: Return Google Maps URL as-is
// Android will prompt to open in Google Maps app
return mapsLink;
}
/**
* Render message when no links are available
*/
renderNoLinksMessage() {
this.container.innerHTML = `
<div class="activity-links-empty" role="status" aria-live="polite">
<i class="fas fa-info-circle" aria-hidden="true"></i>
<div class="activity-links-empty-content">
<p class="activity-links-empty-title">Contact information not available</p>
<p class="activity-links-empty-description">Business contact details for this activity are currently unavailable. Please check back later.</p>
</div>
</div>
`;
this.container.setAttribute('aria-busy', 'false');
this.container.setAttribute('aria-label', 'No contact information available for this activity');
}
/**
* Generate partial information message
*/
getPartialInfoMessage(missingTypes) {
const missingText = missingTypes.length === 1
? missingTypes[0]
: missingTypes.length === 2
? `${missingTypes[0]} and ${missingTypes[1]}`
: missingTypes.join(', ');
return `
<div class="activity-links-partial-info" role="status" aria-live="polite">
<i class="fas fa-exclamation-circle" aria-hidden="true"></i>
<p>Some contact information (${missingText}) is not yet available.</p>
</div>
`;
}
/**
* Retry loading links
*/
retry() {
this.state = {
loading: true,
error: null,
data: null,
timedOut: false
};
this.render();
this.fetchLinks();
}
/**
* Show error message
*/
showError(message) {
this.state.loading = false;
this.state.error = message;
this.render();
}
/**
* Update activity ID and reload
*/
updateActivity(activityId) {
this.activityId = activityId;
this.retry();
}
/**
* Destroy the component
*/
destroy() {
if (this.container) {
this.container.innerHTML = '';
this.container.removeAttribute('aria-busy');
this.container.removeAttribute('aria-label');
}
}
}
/**
* Batch loader for multiple activity links
*/
class ActivityLinksBatch {
constructor() {
this.instances = [];
}
/**
* Load all activity links on the page
*/
loadAll() {
const containers = document.querySelectorAll('[data-activity-links]');
containers.forEach(container => {
const activityId = container.getAttribute('data-activity-id');
const showLabels = container.getAttribute('data-show-labels') !== 'false';
const compact = container.hasAttribute('data-compact');
const timeout = parseInt(container.getAttribute('data-timeout')) || 10000;
if (activityId) {
const instance = new ActivityLinks({
activityId,
container,
showLabels,
compact,
timeout
});
this.instances.push(instance);
}
});
}
/**
* Destroy all instances
*/
destroyAll() {
this.instances.forEach(instance => instance.destroy());
this.instances = [];
}
}
// Export for use in other scripts
window.ActivityLinks = ActivityLinks;
window.ActivityLinksBatch = ActivityLinksBatch;
// Auto-initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
const batch = new ActivityLinksBatch();
batch.loadAll();
});
} else {
const batch = new ActivityLinksBatch();
batch.loadAll();
}