1516 lines
56 KiB
JavaScript
1516 lines
56 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.initTheme();
|
|
this.bindEvents();
|
|
this.initMap();
|
|
this.setupInfiniteScroll();
|
|
await this.loadFilters();
|
|
await Promise.all([
|
|
this.loadResources(),
|
|
this.loadMapResources()
|
|
]);
|
|
}
|
|
|
|
// Theme toggle functionality - dark mode is default
|
|
initTheme() {
|
|
// Check for saved theme preference or use system preference
|
|
const savedTheme = localStorage.getItem('theme');
|
|
const prefersLight = window.matchMedia('(prefers-color-scheme: light)').matches;
|
|
|
|
// Only set light mode if explicitly saved or system prefers light
|
|
if (savedTheme === 'light' || (!savedTheme && prefersLight)) {
|
|
document.documentElement.setAttribute('data-theme', 'light');
|
|
}
|
|
// Dark is default, no attribute needed
|
|
|
|
// Listen for system theme changes
|
|
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', (e) => {
|
|
if (!localStorage.getItem('theme')) {
|
|
if (e.matches) {
|
|
document.documentElement.setAttribute('data-theme', 'light');
|
|
} else {
|
|
document.documentElement.removeAttribute('data-theme');
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
toggleTheme() {
|
|
const currentTheme = document.documentElement.getAttribute('data-theme');
|
|
const isLight = currentTheme === 'light';
|
|
|
|
if (isLight) {
|
|
document.documentElement.removeAttribute('data-theme');
|
|
localStorage.setItem('theme', 'dark');
|
|
} else {
|
|
document.documentElement.setAttribute('data-theme', 'light');
|
|
localStorage.setItem('theme', 'light');
|
|
}
|
|
}
|
|
|
|
bindEvents() {
|
|
// Theme toggle
|
|
const themeToggle = document.getElementById('themeToggle');
|
|
if (themeToggle) {
|
|
themeToggle.addEventListener('click', () => this.toggleTheme());
|
|
}
|
|
|
|
// 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');
|
|
const originalContent = btn.innerHTML;
|
|
btn.disabled = true;
|
|
btn.classList.add('loading');
|
|
btn.innerHTML = '<span class="btn-spinner"></span> Finding...';
|
|
|
|
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;
|
|
btn.classList.remove('loading');
|
|
btn.innerHTML = originalContent;
|
|
},
|
|
(error) => {
|
|
btn.disabled = false;
|
|
btn.classList.remove('loading');
|
|
btn.innerHTML = originalContent;
|
|
|
|
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);
|
|
}
|
|
);
|
|
}
|
|
|
|
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: '© <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: '© <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);
|
|
}
|
|
}
|
|
|
|
async printDirections() {
|
|
if (!this.currentRoute || !this.currentDestination) return;
|
|
|
|
const printBtn = document.getElementById('printDirectionsBtn');
|
|
const originalBtnContent = printBtn.innerHTML;
|
|
|
|
// Show loading state
|
|
printBtn.disabled = true;
|
|
printBtn.innerHTML = `
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="btn-spinner-icon">
|
|
<circle cx="12" cy="12" r="10" stroke-dasharray="32" stroke-dashoffset="32">
|
|
<animate attributeName="stroke-dashoffset" values="32;0" dur="1s" repeatCount="indefinite"/>
|
|
</circle>
|
|
</svg>
|
|
Preparing...
|
|
`;
|
|
|
|
const printContent = document.getElementById('printDirections');
|
|
const printDate = document.getElementById('printDate');
|
|
const printMapContainer = document.getElementById('printMapContainer');
|
|
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';
|
|
|
|
// Capture the directions map as an image
|
|
try {
|
|
const mapElement = document.getElementById('directionsMap');
|
|
if (mapElement && typeof html2canvas !== 'undefined') {
|
|
const canvas = await html2canvas(mapElement, {
|
|
useCORS: true,
|
|
allowTaint: true,
|
|
logging: false,
|
|
scale: 2 // Higher quality for print
|
|
});
|
|
const mapImageUrl = canvas.toDataURL('image/png');
|
|
printMapContainer.innerHTML = `<img src="${mapImageUrl}" alt="Route Map">`;
|
|
} else {
|
|
printMapContainer.innerHTML = '';
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to capture map:', error);
|
|
printMapContainer.innerHTML = '<p style="text-align:center;color:#64748b;padding:1rem;">Map image could not be captured</p>';
|
|
}
|
|
|
|
// 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>
|
|
`;
|
|
|
|
// Restore button state
|
|
printBtn.disabled = false;
|
|
printBtn.innerHTML = originalBtnContent;
|
|
|
|
// Small delay to ensure content is rendered before printing
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
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();
|