1422 lines
52 KiB
JavaScript

// Free Alberta Food - Main Application
class FoodResourceApp {
constructor() {
this.resources = []; // Resources displayed in the list (accumulated for infinite scroll)
this.mapResources = []; // All resources for map display
this.filteredResources = [];
this.currentPage = 1;
this.pageSize = 50;
this.totalPages = 1;
this.totalResources = 0;
this.isLoadingMore = false; // Prevent multiple simultaneous loads
this.map = null;
this.directionsMap = null;
this.markers = [];
this.routeLayer = null;
this.userLocation = null;
this.currentRoute = null;
this.currentDestination = null;
this.filters = {
cities: [], // Multi-select: array of city values
types: [], // Multi-select: array of type values
contact: [], // Multi-select: array of contact methods (phone, email, website)
search: ''
};
this.init();
}
async init() {
this.bindEvents();
this.initMap();
this.setupInfiniteScroll();
await this.loadFilters();
await Promise.all([
this.loadResources(),
this.loadMapResources()
]);
}
bindEvents() {
// Search
document.getElementById('searchBtn').addEventListener('click', () => this.handleSearch());
document.getElementById('searchInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.handleSearch();
});
// Location
document.getElementById('locationBtn').addEventListener('click', () => this.handleLocation());
// Closest Resource
document.getElementById('closestBtn').addEventListener('click', () => this.handleClosestResource());
// Multi-select filters
this.initMultiSelectFilters();
// Resource Modal
document.querySelectorAll('#resourceModal .modal-overlay, #resourceModal .modal-close').forEach(el => {
el.addEventListener('click', () => this.closeModal('resourceModal'));
});
// Directions Modal
document.querySelectorAll('#directionsModal .modal-overlay, #directionsModal .modal-close').forEach(el => {
el.addEventListener('click', () => this.closeModal('directionsModal'));
});
// Update Modal
document.querySelectorAll('#updateModal .modal-overlay, #updateModal .modal-close').forEach(el => {
el.addEventListener('click', () => this.closeModal('updateModal'));
});
// Update form submission
document.getElementById('updateForm').addEventListener('submit', (e) => {
e.preventDefault();
this.submitUpdateRequest();
});
// Travel mode change
document.getElementById('travelMode').addEventListener('change', (e) => {
if (this.currentDestination && this.userLocation) {
this.getDirections(this.currentDestination, e.target.value);
}
});
// Print directions
document.getElementById('printDirectionsBtn').addEventListener('click', () => this.printDirections());
// Escape key to close modals
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.closeModal('resourceModal');
this.closeModal('directionsModal');
this.closeModal('updateModal');
this.closeModal('addListingModal');
}
});
// Event delegation for resource list
document.getElementById('resourceList').addEventListener('click', (e) => {
// Handle directions button click
const directionsBtn = e.target.closest('.directions-btn');
if (directionsBtn) {
e.stopPropagation();
const resourceId = parseInt(directionsBtn.dataset.resourceId);
this.showDirectionsTo(resourceId);
return;
}
// Handle update button click on card
const updateBtn = e.target.closest('.update-btn');
if (updateBtn) {
e.stopPropagation();
const resourceId = parseInt(updateBtn.dataset.resourceId);
this.openUpdateModal(resourceId);
return;
}
// Handle retry button click
const retryBtn = e.target.closest('.retry-btn');
if (retryBtn) {
this.loadResources();
return;
}
// Handle resource card click (open modal)
const resourceCard = e.target.closest('.resource-card');
if (resourceCard && !e.target.closest('a')) {
const resourceId = parseInt(resourceCard.dataset.resourceId);
this.openModal(resourceId);
}
});
// Event delegation for modal body (directions and update buttons in modal)
document.getElementById('modalBody').addEventListener('click', (e) => {
const directionsBtn = e.target.closest('.modal-directions-btn');
if (directionsBtn) {
const resourceId = parseInt(directionsBtn.dataset.resourceId);
this.showDirectionsTo(resourceId);
this.closeModal('resourceModal');
}
const updateBtn = e.target.closest('.modal-update-btn');
if (updateBtn) {
const resourceId = parseInt(updateBtn.dataset.resourceId);
this.openUpdateModal(resourceId);
this.closeModal('resourceModal');
}
});
// Event delegation for direction steps
document.getElementById('directionsSteps').addEventListener('click', (e) => {
const step = e.target.closest('.direction-step');
if (step) {
const stepIndex = parseInt(step.dataset.stepIndex);
this.zoomToStep(stepIndex);
}
});
// Add Listing button
document.getElementById('addListingBtn').addEventListener('click', () => this.openAddListingModal());
// Add Listing modal
document.querySelectorAll('#addListingModal .modal-overlay, #addListingModal .modal-close').forEach(el => {
el.addEventListener('click', () => this.closeModal('addListingModal'));
});
// Add Listing form submission
document.getElementById('addListingForm').addEventListener('submit', (e) => {
e.preventDefault();
this.submitNewListing();
});
}
initMultiSelectFilters() {
// Initialize each multi-select
['cityFilterContainer', 'typeFilterContainer', 'contactFilterContainer'].forEach(containerId => {
const container = document.getElementById(containerId);
const btn = container.querySelector('.multi-select-btn');
const dropdown = container.querySelector('.multi-select-dropdown');
const clearBtn = container.querySelector('.multi-select-clear');
const applyBtn = container.querySelector('.multi-select-apply');
const searchInput = container.querySelector('.multi-select-search-input');
// Toggle dropdown
btn.addEventListener('click', (e) => {
e.stopPropagation();
this.closeAllMultiSelects(container);
container.classList.toggle('open');
dropdown.classList.toggle('hidden');
if (searchInput && !dropdown.classList.contains('hidden')) {
searchInput.focus();
}
});
// Search filter (for city filter)
if (searchInput) {
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
const options = container.querySelectorAll('.multi-select-option');
options.forEach(opt => {
const text = opt.textContent.toLowerCase();
opt.style.display = text.includes(query) ? '' : 'none';
});
});
}
// Clear button
clearBtn.addEventListener('click', () => {
const checkboxes = container.querySelectorAll('input[type="checkbox"]');
checkboxes.forEach(cb => cb.checked = false);
});
// Apply button
applyBtn.addEventListener('click', () => {
this.applyMultiSelectFilter(containerId);
container.classList.remove('open');
dropdown.classList.add('hidden');
});
});
// Close dropdowns when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.multi-select')) {
this.closeAllMultiSelects();
}
});
}
closeAllMultiSelects(except = null) {
document.querySelectorAll('.multi-select').forEach(ms => {
if (ms !== except) {
ms.classList.remove('open');
ms.querySelector('.multi-select-dropdown').classList.add('hidden');
}
});
}
applyMultiSelectFilter(containerId) {
const container = document.getElementById(containerId);
const checkboxes = container.querySelectorAll('input[type="checkbox"]:checked');
const values = Array.from(checkboxes).map(cb => cb.value);
const label = container.querySelector('.multi-select-label');
if (containerId === 'cityFilterContainer') {
this.filters.cities = values;
label.innerHTML = values.length === 0 ? 'All Cities' :
values.length === 1 ? values[0] :
`Cities <span class="multi-select-badge">${values.length}</span>`;
} else if (containerId === 'typeFilterContainer') {
this.filters.types = values;
label.innerHTML = values.length === 0 ? 'All Types' :
values.length === 1 ? this.formatType(values[0]) :
`Types <span class="multi-select-badge">${values.length}</span>`;
} else if (containerId === 'contactFilterContainer') {
this.filters.contact = values;
label.innerHTML = values.length === 0 ? 'Any Contact' :
values.length === 1 ? `Has ${values[0].charAt(0).toUpperCase() + values[0].slice(1)}` :
`Contact <span class="multi-select-badge">${values.length}</span>`;
}
this.resetAndReload();
}
async loadFilters() {
try {
const [citiesRes, typesRes] = await Promise.all([
fetch('/api/cities'),
fetch('/api/types')
]);
const citiesData = await citiesRes.json();
const typesData = await typesRes.json();
// Populate city filter options
const cityOptions = document.getElementById('cityFilterOptions');
cityOptions.innerHTML = citiesData.cities.map(city => `
<label class="multi-select-option">
<input type="checkbox" value="${this.escapeHtml(city.city)}">
<span>${this.escapeHtml(city.city)}</span>
<span class="count">(${city.count})</span>
</label>
`).join('');
// Populate type filter options
const typeOptions = document.getElementById('typeFilterOptions');
typeOptions.innerHTML = typesData.types.map(type => `
<label class="multi-select-option">
<input type="checkbox" value="${type.value}">
<span>${this.escapeHtml(type.label)}</span>
<span class="count">(${type.count})</span>
</label>
`).join('');
} catch (error) {
console.error('Failed to load filters:', error);
}
}
async loadResources(append = false) {
if (this.isLoadingMore) return;
this.isLoadingMore = true;
if (!append) {
this.showLoading(true);
} else {
this.showLoadingMore(true);
}
try {
const params = new URLSearchParams({
page: this.currentPage,
limit: this.pageSize
});
// Multi-select filters: pass as comma-separated values
if (this.filters.cities.length > 0) params.append('cities', this.filters.cities.join(','));
if (this.filters.types.length > 0) params.append('types', this.filters.types.join(','));
if (this.filters.contact.length > 0) params.append('contact', this.filters.contact.join(','));
let url = '/api/resources?' + params.toString();
if (this.filters.search) {
url = `/api/resources/search?q=${encodeURIComponent(this.filters.search)}`;
if (this.filters.cities.length > 0) url += `&cities=${encodeURIComponent(this.filters.cities.join(','))}`;
if (this.filters.types.length > 0) url += `&types=${encodeURIComponent(this.filters.types.join(','))}`;
if (this.filters.contact.length > 0) url += `&contact=${encodeURIComponent(this.filters.contact.join(','))}`;
}
const response = await fetch(url);
const data = await response.json();
const newResources = data.resources || [];
if (append) {
this.resources = [...this.resources, ...newResources];
} else {
this.resources = newResources;
}
if (data.pagination) {
this.totalPages = data.pagination.pages;
this.totalResources = data.pagination.total;
this.currentPage = data.pagination.page;
}
this.renderResources(append);
this.updateResultCount();
} catch (error) {
console.error('Failed to load resources:', error);
if (!append) {
this.showError('Failed to load resources. Please try again.');
}
} finally {
this.isLoadingMore = false;
if (!append) {
this.showLoading(false);
} else {
this.showLoadingMore(false);
}
}
}
async loadMapResources() {
try {
const params = new URLSearchParams();
if (this.filters.cities.length > 0) params.append('cities', this.filters.cities.join(','));
if (this.filters.types.length > 0) params.append('types', this.filters.types.join(','));
if (this.filters.contact.length > 0) params.append('contact', this.filters.contact.join(','));
const url = '/api/resources/map' + (params.toString() ? '?' + params.toString() : '');
const response = await fetch(url);
const data = await response.json();
this.mapResources = data.resources || [];
this.updateMapMarkers();
} catch (error) {
console.error('Failed to load map resources:', error);
}
}
resetAndReload() {
this.currentPage = 1;
this.resources = [];
Promise.all([
this.loadResources(),
this.loadMapResources()
]);
}
setupInfiniteScroll() {
const listContainer = document.getElementById('listContainer');
listContainer.addEventListener('scroll', () => {
if (this.isLoadingMore) return;
const { scrollTop, scrollHeight, clientHeight } = listContainer;
// Load more when user scrolls to within 200px of the bottom
if (scrollTop + clientHeight >= scrollHeight - 200) {
this.loadMoreResources();
}
});
}
loadMoreResources() {
if (this.isLoadingMore) return;
if (this.currentPage >= this.totalPages) return;
this.currentPage++;
this.loadResources(true);
}
showLoadingMore(show) {
let loadingMore = document.getElementById('loadingMoreIndicator');
if (!loadingMore) {
loadingMore = document.createElement('div');
loadingMore.id = 'loadingMoreIndicator';
loadingMore.className = 'loading-more-indicator hidden';
loadingMore.innerHTML = '<div class="spinner"></div><span>Loading more...</span>';
document.getElementById('resourceList').after(loadingMore);
}
if (show) {
loadingMore.classList.remove('hidden');
} else {
loadingMore.classList.add('hidden');
}
}
async handleSearch() {
const searchInput = document.getElementById('searchInput');
this.filters.search = searchInput.value.trim();
this.resetAndReload();
}
async handleLocation() {
if (!navigator.geolocation) {
alert('Geolocation is not supported by your browser');
return;
}
const btn = document.getElementById('locationBtn');
btn.disabled = true;
navigator.geolocation.getCurrentPosition(
async (position) => {
const { latitude, longitude } = position.coords;
this.userLocation = { lat: latitude, lng: longitude };
try {
const response = await fetch(
`/api/resources/nearby?lat=${latitude}&lng=${longitude}&radius=25`
);
const data = await response.json();
this.resources = data.resources || [];
this.renderResources();
this.updateResultCount();
this.updateMapMarkers();
if (this.map) {
this.map.setView([latitude, longitude], 14);
this.addUserLocationMarker();
}
} catch (error) {
console.error('Failed to fetch nearby resources:', error);
}
btn.disabled = false;
},
(error) => {
let message = 'Unable to get your location.';
if (error.message.includes('secure origins') || error.code === 1) {
message = 'Location access requires HTTPS. Please use the "Open in Maps" button to navigate, or access this site via HTTPS.';
} else {
message += ' ' + error.message;
}
alert(message);
btn.disabled = false;
}
);
}
async handleClosestResource() {
if (!navigator.geolocation) {
alert('Geolocation is not supported by your browser');
return;
}
const btn = document.getElementById('closestBtn');
btn.disabled = true;
navigator.geolocation.getCurrentPosition(
async (position) => {
const { latitude, longitude } = position.coords;
this.userLocation = { lat: latitude, lng: longitude };
try {
// Fetch nearby resources and find the closest with a full address
const response = await fetch(
`/api/resources/nearby?lat=${latitude}&lng=${longitude}&radius=100&limit=20`
);
const data = await response.json();
// Filter to only resources with a full street address (not just coordinates)
const resourcesWithAddress = (data.resources || []).filter(r => {
if (!r.address || !r.city) return false;
const addr = r.address.trim();
// Must have address with street number, not PO Box or general delivery
return addr &&
!addr.toLowerCase().startsWith('po box') &&
!addr.toLowerCase().startsWith('p.o. box') &&
!addr.toLowerCase().startsWith('general delivery') &&
/\d/.test(addr);
});
// Debug: show all resources with distances and address info
console.log('Nearby resources with distances:');
data.resources.forEach((r, i) => {
const hasAddr = r.address && r.city && /\d/.test(r.address);
console.log(` ${i + 1}. [${r.distance_km?.toFixed(2)} km] ${r.name} (ID: ${r.id}) ${hasAddr ? '✓' : '✗ no address'}`);
});
// Check if UofA/University is in the list
const uniResources = data.resources.filter(r =>
r.name.toLowerCase().includes('university') ||
r.name.toLowerCase().includes('uofa') ||
r.name.toLowerCase().includes('u of a')
);
if (uniResources.length > 0) {
console.log('University-related resources found:', uniResources.map(r => `${r.name} at ${r.distance_km?.toFixed(2)} km`));
}
console.log('Filtered resources with address:', resourcesWithAddress.length);
if (resourcesWithAddress.length > 0) {
const closest = resourcesWithAddress[0];
console.log('Closest resource:', closest.id, closest.name, `${closest.distance_km?.toFixed(2)} km`);
// Fetch the full resource details first to ensure consistency
const detailResponse = await fetch(`/api/resources/${closest.id}`);
const detailData = await detailResponse.json();
const resource = detailData.resource;
if (resource) {
console.log('Fetched resource:', resource.id, resource.name, resource.latitude, resource.longitude);
// Open the modal for this resource
await this.openModal(resource.id);
// Zoom to show both user and the fetched resource's coordinates
if (this.map && resource.latitude && resource.longitude) {
this.addUserLocationMarker();
const bounds = L.latLngBounds(
[latitude, longitude],
[resource.latitude, resource.longitude]
);
this.map.fitBounds(bounds.pad(0.3));
}
} else {
alert('Could not load resource details.');
}
} else {
alert('No resources with full addresses found nearby. Try expanding your search.');
}
} catch (error) {
console.error('Failed to fetch closest resource:', error);
alert('Failed to find closest resource. Please try again.');
}
btn.disabled = false;
},
(error) => {
let message = 'Unable to get your location.';
if (error.message.includes('secure origins') || error.code === 1) {
message = 'Location access requires HTTPS or permission. Please enable location access.';
} else {
message += ' ' + error.message;
}
alert(message);
btn.disabled = false;
}
);
}
addUserLocationMarker() {
if (!this.map || !this.userLocation) return;
// Remove existing user marker if any
if (this.userMarker) {
this.userMarker.remove();
}
this.userMarker = L.marker([this.userLocation.lat, this.userLocation.lng], {
icon: L.divIcon({
className: 'user-location-marker',
html: '<div style="background:#3b82f6;width:16px;height:16px;border-radius:50%;border:3px solid white;box-shadow:0 2px 4px rgba(0,0,0,0.3)"></div>',
iconSize: [16, 16]
})
}).addTo(this.map).bindPopup('Your location');
}
initMap() {
this.map = L.map('map').setView([53.9333, -116.5765], 6);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(this.map);
}
initDirectionsMap() {
if (this.directionsMap) {
this.directionsMap.remove();
}
this.directionsMap = L.map('directionsMap').setView([53.9333, -116.5765], 10);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(this.directionsMap);
}
updateMapMarkers() {
if (!this.map) return;
this.markers.forEach(marker => marker.remove());
this.markers = [];
// Use mapResources (all resources) for map display
const resourcesWithCoords = this.mapResources.filter(r => r.latitude && r.longitude);
resourcesWithCoords.forEach(resource => {
const popupContent = document.createElement('div');
popupContent.innerHTML = `
<div class="popup-title">${this.escapeHtml(resource.name)}</div>
<div class="popup-address">${this.escapeHtml(resource.address || resource.city || '')}</div>
<div style="margin-top:8px;display:flex;gap:4px">
<button class="popup-details-btn" data-resource-id="${resource.id}" style="padding:4px 8px;cursor:pointer;font-size:12px">Details</button>
<button class="popup-directions-btn" data-resource-id="${resource.id}" style="padding:4px 8px;cursor:pointer;font-size:12px;background:#2563eb;color:white;border:none;border-radius:4px">Directions</button>
</div>
`;
// Bind events to popup buttons
popupContent.querySelector('.popup-details-btn').addEventListener('click', (e) => {
this.openModal(parseInt(e.target.dataset.resourceId));
});
popupContent.querySelector('.popup-directions-btn').addEventListener('click', (e) => {
this.showDirectionsTo(parseInt(e.target.dataset.resourceId));
});
const marker = L.marker([resource.latitude, resource.longitude])
.addTo(this.map)
.bindPopup(popupContent);
this.markers.push(marker);
});
if (this.markers.length > 0) {
const group = L.featureGroup(this.markers);
this.map.fitBounds(group.getBounds().pad(0.1));
}
}
renderResources(append = false) {
const container = document.getElementById('resourceList');
const noResults = document.getElementById('noResults');
if (this.resources.length === 0) {
container.innerHTML = '';
noResults.classList.remove('hidden');
return;
}
noResults.classList.add('hidden');
const resourcesHtml = this.resources.map(resource => `
<div class="resource-card" data-resource-id="${resource.id}">
<div class="resource-header">
<h3 class="resource-name">${this.escapeHtml(resource.name)}</h3>
<span class="resource-type ${resource.resource_type}">${this.formatType(resource.resource_type)}</span>
</div>
<div class="resource-click-hint">Click for more details</div>
<div class="resource-info">
${resource.address ? `
<div class="resource-info-row">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
<circle cx="12" cy="10" r="3"></circle>
</svg>
<span>${this.escapeHtml(resource.address)}${resource.city ? `, ${resource.city}` : ''}</span>
</div>
` : resource.city ? `
<div class="resource-info-row">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
<circle cx="12" cy="10" r="3"></circle>
</svg>
<span>${this.escapeHtml(resource.city)}</span>
</div>
` : ''}
${resource.phone ? `
<div class="resource-info-row">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path>
</svg>
<span>${this.escapeHtml(resource.phone)}</span>
</div>
` : ''}
${resource.email ? `
<div class="resource-info-row">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
<span>${this.escapeHtml(resource.email)}</span>
</div>
` : ''}
${resource.hours_of_operation ? `
<div class="resource-info-row">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
<span>${this.escapeHtml(resource.hours_of_operation.substring(0, 60))}${resource.hours_of_operation.length > 60 ? '...' : ''}</span>
</div>
` : ''}
</div>
<div class="resource-actions">
${resource.phone ? `
<a href="tel:${resource.phone}" class="resource-action-btn primary">Call</a>
` : ''}
${this.hasLocationData(resource) ? `
<button class="resource-action-btn directions-btn" data-resource-id="${resource.id}">Directions</button>
<a href="${this.getMapsUrl(resource)}" target="_blank" rel="noopener" class="resource-action-btn maps-btn">Open in Maps</a>
` : ''}
${resource.website ? `
<a href="${resource.website}" target="_blank" rel="noopener" class="resource-action-btn">Website</a>
` : ''}
${this.hasIncompleteInfo(resource) ? `
<button class="resource-action-btn update-btn" data-resource-id="${resource.id}" title="Help improve this listing">Update</button>
` : ''}
</div>
</div>
`).join('');
// For infinite scroll: always replace since we accumulate in this.resources
container.innerHTML = resourcesHtml;
}
async openModal(id) {
try {
const response = await fetch(`/api/resources/${id}`);
const data = await response.json();
const resource = data.resource;
if (!resource) {
alert('Resource not found');
return;
}
const modalBody = document.getElementById('modalBody');
modalBody.innerHTML = `
<div class="modal-header">
<h2 class="modal-title">${this.escapeHtml(resource.name)}</h2>
<span class="resource-type ${resource.resource_type}">${this.formatType(resource.resource_type)}</span>
</div>
${resource.description ? `
<div class="modal-section">
<div class="modal-section-title">Description</div>
<div class="modal-section-content">${this.escapeHtml(resource.description)}</div>
</div>
` : ''}
${resource.address || resource.city ? `
<div class="modal-section">
<div class="modal-section-title">Location</div>
<div class="modal-section-content">
${resource.address ? this.escapeHtml(resource.address) + '<br>' : ''}
${resource.city ? this.escapeHtml(resource.city) : ''}${resource.province ? ', ' + resource.province : ''}
${resource.postal_code ? ' ' + resource.postal_code : ''}
</div>
</div>
` : ''}
${resource.phone || resource.email || resource.website ? `
<div class="modal-section">
<div class="modal-section-title">Contact</div>
<div class="modal-section-content">
${resource.phone ? `Phone: <a href="tel:${resource.phone}">${this.escapeHtml(resource.phone)}</a><br>` : ''}
${resource.email ? `Email: <a href="mailto:${resource.email}">${this.escapeHtml(resource.email)}</a><br>` : ''}
${resource.website ? `Website: <a href="${resource.website}" target="_blank" rel="noopener">${this.escapeHtml(resource.website)}</a>` : ''}
</div>
</div>
` : ''}
${resource.hours_of_operation ? `
<div class="modal-section">
<div class="modal-section-title">Hours of Operation</div>
<div class="modal-section-content">${this.escapeHtml(resource.hours_of_operation)}</div>
</div>
` : ''}
${resource.eligibility ? `
<div class="modal-section">
<div class="modal-section-title">Eligibility</div>
<div class="modal-section-content">${this.escapeHtml(resource.eligibility)}</div>
</div>
` : ''}
${resource.services_offered ? `
<div class="modal-section">
<div class="modal-section-title">Services Offered</div>
<div class="modal-section-content">${this.escapeHtml(resource.services_offered)}</div>
</div>
` : ''}
<div class="modal-actions">
${resource.phone ? `<a href="tel:${resource.phone}" class="resource-action-btn primary">Call Now</a>` : ''}
${this.hasLocationData(resource) ? `
<button class="resource-action-btn modal-directions-btn" data-resource-id="${resource.id}">Get Directions</button>
<a href="${this.getMapsUrl(resource)}" target="_blank" rel="noopener" class="resource-action-btn maps-btn">Open in Maps</a>
` : ''}
${resource.website ? `<a href="${resource.website}" target="_blank" rel="noopener" class="resource-action-btn">Visit Website</a>` : ''}
</div>
<div class="modal-update-section">
<button class="resource-action-btn modal-update-btn" data-resource-id="${resource.id}">Update This Listing</button>
</div>
<div style="margin-top:1rem;font-size:0.75rem;color:var(--text-light)">
Last updated: ${new Date(resource.updated_at).toLocaleDateString()}
${resource.source_url ? ` | <a href="${resource.source_url}" target="_blank" rel="noopener">Source</a>` : ''}
</div>
`;
document.getElementById('resourceModal').classList.remove('hidden');
document.body.style.overflow = 'hidden';
} catch (error) {
console.error('Failed to load resource details:', error);
alert('Failed to load resource details');
}
}
async showDirectionsTo(resourceId) {
// Get user location first if not available
if (!this.userLocation) {
const gotLocation = await this.getUserLocation();
if (!gotLocation) {
alert('Please enable location access to get directions');
return;
}
}
// Get resource details
try {
const response = await fetch(`/api/resources/${resourceId}`);
const data = await response.json();
const resource = data.resource;
if (!resource || (!resource.latitude && !resource.address)) {
alert('Location not available for this resource');
return;
}
this.currentDestination = resource;
// If no coordinates, try to geocode the address
if (!resource.latitude || !resource.longitude) {
try {
const geoResponse = await fetch(`/api/geocode?address=${encodeURIComponent(resource.address + ', ' + (resource.city || 'Alberta'))}`);
const geoData = await geoResponse.json();
if (geoData.success) {
resource.latitude = geoData.result.latitude;
resource.longitude = geoData.result.longitude;
} else {
alert('Could not locate this address');
return;
}
} catch (e) {
alert('Could not locate this address');
return;
}
}
this.currentDestination = resource;
const mode = document.getElementById('travelMode').value;
await this.getDirections(resource, mode);
} catch (error) {
console.error('Failed to get directions:', error);
alert('Failed to get directions');
}
}
getUserLocation() {
return new Promise((resolve) => {
if (this.userLocation) {
resolve(true);
return;
}
if (!navigator.geolocation) {
resolve(false);
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
this.userLocation = {
lat: position.coords.latitude,
lng: position.coords.longitude
};
resolve(true);
},
() => {
resolve(false);
},
{ timeout: 10000 }
);
});
}
async getDirections(destination, mode = 'driving') {
try {
const response = await fetch(
`/api/directions?startLat=${this.userLocation.lat}&startLng=${this.userLocation.lng}&endLat=${destination.latitude}&endLng=${destination.longitude}&profile=${mode}`
);
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to get directions');
}
this.currentRoute = data.route;
this.displayDirections(destination, data.route, mode);
} catch (error) {
console.error('Directions error:', error);
alert('Could not calculate route: ' + error.message);
}
}
displayDirections(destination, route, mode) {
// Show modal
document.getElementById('directionsModal').classList.remove('hidden');
document.body.style.overflow = 'hidden';
// Update summary
const modeText = mode === 'driving' ? 'Drive' : mode === 'walking' ? 'Walk' : 'Cycle';
document.getElementById('directionsSummary').innerHTML = `
<div class="directions-summary-item">
<span class="directions-summary-label">Distance</span>
<span class="directions-summary-value">${route.distanceText}</span>
</div>
<div class="directions-summary-item">
<span class="directions-summary-label">${modeText} Time</span>
<span class="directions-summary-value">${route.durationText}</span>
</div>
<div class="directions-summary-item">
<span class="directions-summary-label">To</span>
<span class="directions-summary-value">${this.escapeHtml(destination.name)}</span>
</div>
`;
// Initialize directions map
setTimeout(() => {
this.initDirectionsMap();
this.drawRoute(route, destination);
}, 100);
// Display turn-by-turn steps
this.displaySteps(route.steps);
}
drawRoute(route, destination) {
if (!this.directionsMap) return;
// Clear existing route
if (this.routeLayer) {
this.routeLayer.remove();
}
// Draw route line
const coordinates = route.geometry.coordinates.map(coord => [coord[1], coord[0]]);
this.routeLayer = L.polyline(coordinates, {
color: '#2563eb',
weight: 5,
opacity: 0.8
}).addTo(this.directionsMap);
// Add start marker (user location)
L.marker([this.userLocation.lat, this.userLocation.lng], {
icon: L.divIcon({
className: 'start-marker',
html: '<div style="background:#10b981;width:20px;height:20px;border-radius:50%;border:3px solid white;box-shadow:0 2px 4px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;color:white;font-size:12px;font-weight:bold">A</div>',
iconSize: [20, 20],
iconAnchor: [10, 10]
})
}).addTo(this.directionsMap).bindPopup('Your location');
// Add destination marker
L.marker([destination.latitude, destination.longitude], {
icon: L.divIcon({
className: 'end-marker',
html: '<div style="background:#dc2626;width:20px;height:20px;border-radius:50%;border:3px solid white;box-shadow:0 2px 4px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;color:white;font-size:12px;font-weight:bold">B</div>',
iconSize: [20, 20],
iconAnchor: [10, 10]
})
}).addTo(this.directionsMap).bindPopup(destination.name);
// Fit map to route bounds
this.directionsMap.fitBounds(route.bounds, { padding: [30, 30] });
}
displaySteps(steps) {
const container = document.getElementById('directionsSteps');
container.innerHTML = steps.map((step, index) => {
let numberClass = '';
if (index === 0) numberClass = 'start';
else if (index === steps.length - 1) numberClass = 'end';
return `
<div class="direction-step" data-step-index="${index}">
<div class="direction-step-number ${numberClass}">${index + 1}</div>
<div class="direction-step-content">
<div class="direction-step-instruction">${this.escapeHtml(step.instruction)}</div>
<div class="direction-step-meta">${step.distanceText} - ${step.durationText}</div>
</div>
</div>
`;
}).join('');
}
zoomToStep(stepIndex) {
if (!this.directionsMap || !this.currentRoute) return;
const step = this.currentRoute.steps[stepIndex];
if (step && step.maneuver && step.maneuver.location) {
this.directionsMap.setView(step.maneuver.location, 16);
}
}
printDirections() {
if (!this.currentRoute || !this.currentDestination) return;
const printContent = document.getElementById('printDirections');
const printDate = document.getElementById('printDate');
const route = this.currentRoute;
const dest = this.currentDestination;
const mode = document.getElementById('travelMode').value;
const modeText = mode === 'driving' ? 'Drive' : mode === 'walking' ? 'Walk' : 'Cycle';
const modeIcon = mode === 'driving' ? 'By car' : mode === 'walking' ? 'On foot' : 'By bicycle';
// Set the print date
const now = new Date();
printDate.textContent = `Generated on ${now.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
})}`;
// Build address string
const addressParts = [];
if (dest.address) addressParts.push(dest.address);
if (dest.city) addressParts.push(dest.city);
if (dest.province) addressParts.push(dest.province);
if (dest.postal_code) addressParts.push(dest.postal_code);
const fullAddress = addressParts.join(', ');
printContent.innerHTML = `
<div class="print-summary">
<div class="print-summary-item">
<span class="print-summary-label">Distance</span>
<span class="print-summary-value">${route.distanceText}</span>
</div>
<div class="print-summary-item">
<span class="print-summary-label">${modeText} Time</span>
<span class="print-summary-value">${route.durationText}</span>
</div>
<div class="print-summary-item">
<span class="print-summary-label">Travel Mode</span>
<span class="print-summary-value">${modeIcon}</span>
</div>
</div>
<div class="print-destination">
<div class="print-destination-label">Your Destination</div>
<h2>${this.escapeHtml(dest.name)}</h2>
${fullAddress ? `<p>${this.escapeHtml(fullAddress)}</p>` : ''}
${(dest.phone || dest.email || dest.website) ? `
<div class="print-destination-contact">
${dest.phone ? `<p><strong>Phone:</strong> ${this.escapeHtml(dest.phone)}</p>` : ''}
${dest.email ? `<p><strong>Email:</strong> ${this.escapeHtml(dest.email)}</p>` : ''}
${dest.website ? `<p><strong>Website:</strong> ${this.escapeHtml(dest.website)}</p>` : ''}
</div>
` : ''}
${dest.hours_of_operation ? `<p><strong>Hours:</strong> ${this.escapeHtml(dest.hours_of_operation)}</p>` : ''}
</div>
<div class="print-steps-header">Turn-by-Turn Directions</div>
<div class="print-steps">
${route.steps.map((step, index) => {
let stepClass = '';
if (index === 0) stepClass = 'start';
else if (index === route.steps.length - 1) stepClass = 'end';
return `
<div class="print-step">
<div class="print-step-number ${stepClass}">${index + 1}</div>
<div class="print-step-content">
<div class="print-step-instruction">${this.escapeHtml(step.instruction)}</div>
<div class="print-step-distance">${step.distanceText}</div>
</div>
</div>
`;
}).join('')}
</div>
`;
window.print();
}
async openUpdateModal(resourceId) {
try {
const response = await fetch(`/api/resources/${resourceId}`);
const data = await response.json();
const resource = data.resource;
if (!resource) {
alert('Resource not found');
return;
}
// Store resource ID
document.getElementById('updateResourceId').value = resourceId;
// Clear form
document.getElementById('updateForm').reset();
// Pre-populate fields with current values as placeholders
document.getElementById('proposedName').placeholder = resource.name || 'Resource name';
document.getElementById('proposedResourceType').value = '';
document.getElementById('proposedCity').placeholder = resource.city || 'City';
document.getElementById('proposedAddress').placeholder = resource.address || 'Street address';
document.getElementById('proposedPhone').placeholder = resource.phone || 'Phone number';
document.getElementById('proposedEmail').placeholder = resource.email || 'Contact email';
document.getElementById('proposedWebsite').placeholder = resource.website || 'https://...';
document.getElementById('proposedHours').placeholder = resource.hours_of_operation || 'Hours of operation';
document.getElementById('proposedDescription').placeholder = resource.description || 'Description';
document.getElementById('proposedEligibility').placeholder = resource.eligibility || 'Eligibility requirements';
document.getElementById('proposedServices').placeholder = resource.services_offered || 'Services offered';
// Show modal
document.getElementById('updateModal').classList.remove('hidden');
document.body.style.overflow = 'hidden';
} catch (error) {
console.error('Failed to open update modal:', error);
alert('Failed to load resource details');
}
}
async submitUpdateRequest() {
const resourceId = document.getElementById('updateResourceId').value;
const submitterEmail = document.getElementById('submitterEmail').value.trim();
const submitterName = document.getElementById('submitterName').value.trim();
if (!submitterEmail) {
alert('Please enter your email address');
return;
}
const data = {
submitter_email: submitterEmail,
submitter_name: submitterName || null,
proposed_name: document.getElementById('proposedName').value.trim() || null,
proposed_resource_type: document.getElementById('proposedResourceType').value || null,
proposed_city: document.getElementById('proposedCity').value.trim() || null,
proposed_address: document.getElementById('proposedAddress').value.trim() || null,
proposed_phone: document.getElementById('proposedPhone').value.trim() || null,
proposed_email: document.getElementById('proposedEmail').value.trim() || null,
proposed_website: document.getElementById('proposedWebsite').value.trim() || null,
proposed_hours_of_operation: document.getElementById('proposedHours').value.trim() || null,
proposed_description: document.getElementById('proposedDescription').value.trim() || null,
proposed_eligibility: document.getElementById('proposedEligibility').value.trim() || null,
proposed_services_offered: document.getElementById('proposedServices').value.trim() || null,
additional_notes: document.getElementById('additionalNotes').value.trim() || null
};
// Check if at least one change is proposed
const hasChanges = Object.entries(data)
.filter(([key]) => key.startsWith('proposed_'))
.some(([, value]) => value !== null);
if (!hasChanges && !data.additional_notes) {
alert('Please enter at least one proposed change or note');
return;
}
try {
const response = await fetch(`/api/resources/${resourceId}/update-request`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok) {
alert('Thank you! Your update request has been submitted and will be reviewed by our team.');
this.closeModal('updateModal');
} else {
alert(result.error || 'Failed to submit update request');
}
} catch (error) {
console.error('Failed to submit update request:', error);
alert('Failed to submit update request. Please try again.');
}
}
openAddListingModal() {
// Clear form
document.getElementById('addListingForm').reset();
// Show modal
document.getElementById('addListingModal').classList.remove('hidden');
document.body.style.overflow = 'hidden';
}
async submitNewListing() {
const submitterEmail = document.getElementById('newListingSubmitterEmail').value.trim();
const name = document.getElementById('newListingName').value.trim();
if (!submitterEmail) {
alert('Please enter your email address');
return;
}
if (!name) {
alert('Please enter a name for the listing');
return;
}
const data = {
submitter_email: submitterEmail,
submitter_name: document.getElementById('newListingSubmitterName').value.trim() || null,
name: name,
resource_type: document.getElementById('newListingResourceType').value || 'other',
city: document.getElementById('newListingCity').value.trim() || null,
address: document.getElementById('newListingAddress').value.trim() || null,
phone: document.getElementById('newListingPhone').value.trim() || null,
email: document.getElementById('newListingEmail').value.trim() || null,
website: document.getElementById('newListingWebsite').value.trim() || null,
hours_of_operation: document.getElementById('newListingHours').value.trim() || null,
description: document.getElementById('newListingDescription').value.trim() || null,
eligibility: document.getElementById('newListingEligibility').value.trim() || null,
services_offered: document.getElementById('newListingServices').value.trim() || null,
additional_notes: document.getElementById('newListingNotes').value.trim() || null
};
try {
const response = await fetch('/api/listings/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok) {
alert('Thank you! Your listing has been submitted and will be reviewed by our team.');
this.closeModal('addListingModal');
} else {
alert(result.error || 'Failed to submit listing');
}
} catch (error) {
console.error('Failed to submit listing:', error);
alert('Failed to submit listing. Please try again.');
}
}
closeModal(modalId) {
document.getElementById(modalId).classList.add('hidden');
document.body.style.overflow = '';
// Clean up directions map
if (modalId === 'directionsModal' && this.directionsMap) {
this.directionsMap.remove();
this.directionsMap = null;
}
}
updateResultCount() {
const count = document.getElementById('resultCount');
if (this.totalResources > 0 && this.resources.length < this.totalResources) {
count.textContent = `Showing ${this.resources.length} of ${this.totalResources} resources`;
} else {
count.textContent = `${this.resources.length} resource${this.resources.length !== 1 ? 's' : ''}`;
}
}
showLoading(show) {
const loading = document.getElementById('loadingIndicator');
const list = document.getElementById('resourceList');
if (show) {
loading.classList.remove('hidden');
list.innerHTML = '';
} else {
loading.classList.add('hidden');
}
}
showError(message) {
const container = document.getElementById('resourceList');
container.innerHTML = `
<div class="no-results">
<p>${message}</p>
<button class="resource-action-btn retry-btn" style="margin-top:1rem">Try Again</button>
</div>
`;
}
formatType(type) {
const types = {
'food_bank': 'Food Bank',
'community_meal': 'Community Meal',
'hamper': 'Food Hamper',
'pantry': 'Food Pantry',
'soup_kitchen': 'Soup Kitchen',
'mobile_food': 'Mobile Food',
'grocery_program': 'Grocery Program',
'other': 'Other'
};
return types[type] || type;
}
escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
hasLocationData(resource) {
// Check if resource has enough location data for directions/maps
// Must have a valid street address (with number) or valid coordinates
// Check for valid coordinates
if (resource.latitude && resource.longitude) {
const lat = parseFloat(resource.latitude);
const lng = parseFloat(resource.longitude);
// Validate coordinates are in Alberta region (roughly)
if (!isNaN(lat) && !isNaN(lng) && lat >= 48 && lat <= 60 && lng >= -120 && lng <= -110) {
return true;
}
}
// Check for valid street address
if (resource.address && resource.city) {
const addr = resource.address.trim();
// Skip PO Boxes, general delivery, and addresses without street numbers
if (addr &&
!addr.toLowerCase().startsWith('po box') &&
!addr.toLowerCase().startsWith('p.o. box') &&
!addr.toLowerCase().startsWith('general delivery') &&
/\d/.test(addr)) { // Must contain at least one digit (street number)
return true;
}
}
return false;
}
hasIncompleteInfo(resource) {
// Check if resource is missing important information
return !resource.phone ||
!resource.address ||
!this.hasLocationData(resource);
}
getMapsUrl(resource) {
// Build a search query for maps - prefer address for better label in Maps app
const parts = [];
if (resource.address) parts.push(resource.address);
if (resource.city) parts.push(resource.city);
parts.push(resource.province || 'Alberta');
if (resource.postal_code) parts.push(resource.postal_code);
// Use address if we have at least city
if (parts.length >= 2) {
const query = encodeURIComponent(parts.join(', '));
return `https://www.google.com/maps/search/?api=1&query=${query}`;
}
// Fall back to coordinates if no address info
if (resource.latitude && resource.longitude) {
return `https://www.google.com/maps/search/?api=1&query=${resource.latitude},${resource.longitude}`;
}
// Last resort - just the name
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(resource.name + ', Alberta')}`;
}
}
// Initialize app
const app = new FoodResourceApp();