← 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();
}