/** * LFG Form Alpine.js Component * * Looking For Group form with map location selection. * This is an Alpine.js component factory that returns the component definition. */ import type { Map as MaplibreMap, MapMouseEvent } from 'maplibre-gl' import { authPostJson, SessionExpiredError } from '../../core/auth' import { loadMapLibraries, createMap, h3ToGeoJsonFeature, addHexagonLayers, updateHexagonSource, toMapCenter, getH3Module, } from '../maps/map-utils' // H3 resolution 7 gives ~5 km² area (roughly 1.2km edge) const H3_RESOLUTION = 7 interface LfgFormState { latitude: string longitude: string h3Index: string tags: string[] tagInput: string durationHours: string map: MaplibreMap | null submitting: boolean errors: { location: string | null tags: string | null duration: string | null general: string | null } // Methods init(): void initMap(): Promise handleMapClick(lng: number, lat: number): void selectLocation(h3Index: string): void clearLocation(): void addTag(): void addTagFromSuggestion(tag: string): void removeTag(index: number): void canSubmit(): boolean clearErrors(): void submitForm(): Promise } type AlpineThis = LfgFormState & { $el: HTMLElement $nextTick: (fn: () => void) => void } /** * LFG form Alpine.js component factory * * This function is called by Alpine.js to create the component. */ export function lfgForm(): LfgFormState { return { latitude: '', longitude: '', h3Index: '', tags: [], tagInput: '', durationHours: '48', map: null, submitting: false, errors: { location: null, tags: null, duration: null, general: null, }, init(this: AlpineThis) { // Read default duration from data attribute const el = this.$el if (el.dataset.defaultDuration) { this.durationHours = el.dataset.defaultDuration } this.$nextTick(() => { this.initMap() }) }, async initMap(this: LfgFormState): Promise { try { const { maplibregl } = await loadMapLibraries() // Initialize map centered on default location const defaultLat = 40.7128 const defaultLon = -74.006 this.map = createMap(maplibregl, { container: 'lfg-map', center: toMapCenter(defaultLat, defaultLon), zoom: 12, }) this.map.on('load', () => { if (!this.map) return // Add hexagon layers addHexagonLayers(this.map) // Handle map clicks this.map.on('click', (e: MapMouseEvent) => { // Check if clicking on an existing hex const features = this.map?.queryRenderedFeatures(e.point, { layers: ['hexagons-fill'], }) if (features && features.length > 0) { // Clicked on existing hex - toggle off this.clearLocation() } else { // Clicked on empty space - select new location this.handleMapClick(e.lngLat.lng, e.lngLat.lat) } }) // Cursor style when hovering over hex this.map.on('mouseenter', 'hexagons-fill', () => { if (this.map) { this.map.getCanvas().style.cursor = 'pointer' } }) this.map.on('mouseleave', 'hexagons-fill', () => { if (this.map) { this.map.getCanvas().style.cursor = '' } }) }) // Try to get user's location if (navigator.geolocation) { navigator.geolocation.getCurrentPosition( (position) => { const lat = position.coords.latitude const lon = position.coords.longitude this.map?.flyTo({ center: toMapCenter(lat, lon), zoom: 12 }) }, () => { // Geolocation denied or failed, use default } ) } } catch (err) { console.error('Failed to load map libraries:', err) } }, handleMapClick(this: LfgFormState, lng: number, lat: number): void { const h3 = getH3Module() if (!h3) { console.warn('H3 not loaded') return } // Get H3 cell at resolution const clickedH3Index = h3.latLngToCell(lat, lng, H3_RESOLUTION) // Check if clicking on already selected cell - toggle off if (this.h3Index === clickedH3Index) { this.clearLocation() return } // Select new location this.selectLocation(clickedH3Index) this.errors.location = null }, selectLocation(this: LfgFormState, h3Index: string): void { const h3 = getH3Module() if (!this.map || !h3) return this.h3Index = h3Index // Create feature for the selected hex const feature = h3ToGeoJsonFeature(h3, h3Index, { fillColor: '#00d1b2', fillOpacity: 0.3, strokeColor: '#00d1b2', strokeWidth: 3, }) // Update the source updateHexagonSource(this.map, [feature]) // Get center coordinates for the API const center = h3.cellToLatLng(h3Index) this.latitude = center[0].toString() this.longitude = center[1].toString() }, clearLocation(this: LfgFormState): void { if (this.map) { // Clear the hexagon source updateHexagonSource(this.map, []) } this.h3Index = '' this.latitude = '' this.longitude = '' }, addTag(this: LfgFormState): void { const tag = this.tagInput.trim().replace(/[^a-zA-Z0-9-]/g, '') // Check for duplicates case-insensitively const tagLower = tag.toLowerCase() const isDuplicate = this.tags.some((t) => t.toLowerCase() === tagLower) if (tag && !isDuplicate && this.tags.length < 10) { this.tags.push(tag) this.errors.tags = null } this.tagInput = '' }, addTagFromSuggestion(this: LfgFormState, tag: string): void { // Check for duplicates case-insensitively const tagLower = tag.toLowerCase() const isDuplicate = this.tags.some((t) => t.toLowerCase() === tagLower) if (!isDuplicate && this.tags.length < 10) { this.tags.push(tag) this.errors.tags = null } }, removeTag(this: LfgFormState, index: number): void { this.tags.splice(index, 1) }, canSubmit(this: LfgFormState): boolean { return !!this.h3Index && this.tags.length >= 1 }, clearErrors(this: LfgFormState): void { this.errors = { location: null, tags: null, duration: null, general: null, } }, async submitForm(this: LfgFormState): Promise { if (!this.canSubmit() || this.submitting) return this.clearErrors() this.submitting = true const payload = { latitude: parseFloat(this.latitude), longitude: parseFloat(this.longitude), tags: this.tags, duration_hours: parseInt(this.durationHours, 10), } try { const response = await authPostJson('/lfg', payload) if (response.ok) { // Success - redirect to LFG page which will show matches view window.location.href = '/lfg' } else { const data = (await response.json()) as { error?: string; message?: string } if (data.error) { // Map error codes to fields if (data.error.includes('location') || data.error.includes('coordinate')) { this.errors.location = data.message || 'Invalid location' } else if (data.error.includes('tag')) { this.errors.tags = data.message || 'Invalid tags' } else if (data.error.includes('duration')) { this.errors.duration = data.message || 'Invalid duration' } else { this.errors.general = data.message || 'An error occurred' } } else { this.errors.general = 'An error occurred. Please try again.' } } } catch (err) { if (err instanceof SessionExpiredError) { this.errors.general = err.message } else { console.error('LFG submission error:', err) this.errors.general = 'Network error. Please check your connection and try again.' } } finally { this.submitting = false } }, } }