forked from
smokesignal.events/smokesignal
The smokesignal.events web application
1/**
2 * LFG Form Alpine.js Component
3 *
4 * Looking For Group form with map location selection.
5 * This is an Alpine.js component factory that returns the component definition.
6 */
7
8import type { Map as MaplibreMap, MapMouseEvent } from 'maplibre-gl'
9import { authPostJson, SessionExpiredError } from '../../core/auth'
10import {
11 loadMapLibraries,
12 createMap,
13 h3ToGeoJsonFeature,
14 addHexagonLayers,
15 updateHexagonSource,
16 toMapCenter,
17 getH3Module,
18} from '../maps/map-utils'
19
20// H3 resolution 7 gives ~5 km² area (roughly 1.2km edge)
21const H3_RESOLUTION = 7
22
23interface LfgFormState {
24 latitude: string
25 longitude: string
26 h3Index: string
27 tags: string[]
28 tagInput: string
29 durationHours: string
30 map: MaplibreMap | null
31 submitting: boolean
32 errors: {
33 location: string | null
34 tags: string | null
35 duration: string | null
36 general: string | null
37 }
38 // Methods
39 init(): void
40 initMap(): Promise<void>
41 handleMapClick(lng: number, lat: number): void
42 selectLocation(h3Index: string): void
43 clearLocation(): void
44 addTag(): void
45 addTagFromSuggestion(tag: string): void
46 removeTag(index: number): void
47 canSubmit(): boolean
48 clearErrors(): void
49 submitForm(): Promise<void>
50}
51
52type AlpineThis = LfgFormState & {
53 $el: HTMLElement
54 $nextTick: (fn: () => void) => void
55}
56
57/**
58 * LFG form Alpine.js component factory
59 *
60 * This function is called by Alpine.js to create the component.
61 */
62export function lfgForm(): LfgFormState {
63 return {
64 latitude: '',
65 longitude: '',
66 h3Index: '',
67 tags: [],
68 tagInput: '',
69 durationHours: '48',
70 map: null,
71 submitting: false,
72 errors: {
73 location: null,
74 tags: null,
75 duration: null,
76 general: null,
77 },
78
79 init(this: AlpineThis) {
80 // Read default duration from data attribute
81 const el = this.$el
82 if (el.dataset.defaultDuration) {
83 this.durationHours = el.dataset.defaultDuration
84 }
85
86 this.$nextTick(() => {
87 this.initMap()
88 })
89 },
90
91 async initMap(this: LfgFormState): Promise<void> {
92 try {
93 const { maplibregl } = await loadMapLibraries()
94
95 // Initialize map centered on default location
96 const defaultLat = 40.7128
97 const defaultLon = -74.006
98
99 this.map = createMap(maplibregl, {
100 container: 'lfg-map',
101 center: toMapCenter(defaultLat, defaultLon),
102 zoom: 12,
103 })
104
105 this.map.on('load', () => {
106 if (!this.map) return
107
108 // Add hexagon layers
109 addHexagonLayers(this.map)
110
111 // Handle map clicks
112 this.map.on('click', (e: MapMouseEvent) => {
113 // Check if clicking on an existing hex
114 const features = this.map?.queryRenderedFeatures(e.point, {
115 layers: ['hexagons-fill'],
116 })
117
118 if (features && features.length > 0) {
119 // Clicked on existing hex - toggle off
120 this.clearLocation()
121 } else {
122 // Clicked on empty space - select new location
123 this.handleMapClick(e.lngLat.lng, e.lngLat.lat)
124 }
125 })
126
127 // Cursor style when hovering over hex
128 this.map.on('mouseenter', 'hexagons-fill', () => {
129 if (this.map) {
130 this.map.getCanvas().style.cursor = 'pointer'
131 }
132 })
133
134 this.map.on('mouseleave', 'hexagons-fill', () => {
135 if (this.map) {
136 this.map.getCanvas().style.cursor = ''
137 }
138 })
139 })
140
141 // Try to get user's location
142 if (navigator.geolocation) {
143 navigator.geolocation.getCurrentPosition(
144 (position) => {
145 const lat = position.coords.latitude
146 const lon = position.coords.longitude
147 this.map?.flyTo({ center: toMapCenter(lat, lon), zoom: 12 })
148 },
149 () => {
150 // Geolocation denied or failed, use default
151 }
152 )
153 }
154 } catch (err) {
155 console.error('Failed to load map libraries:', err)
156 }
157 },
158
159 handleMapClick(this: LfgFormState, lng: number, lat: number): void {
160 const h3 = getH3Module()
161 if (!h3) {
162 console.warn('H3 not loaded')
163 return
164 }
165
166 // Get H3 cell at resolution
167 const clickedH3Index = h3.latLngToCell(lat, lng, H3_RESOLUTION)
168
169 // Check if clicking on already selected cell - toggle off
170 if (this.h3Index === clickedH3Index) {
171 this.clearLocation()
172 return
173 }
174
175 // Select new location
176 this.selectLocation(clickedH3Index)
177 this.errors.location = null
178 },
179
180 selectLocation(this: LfgFormState, h3Index: string): void {
181 const h3 = getH3Module()
182 if (!this.map || !h3) return
183
184 this.h3Index = h3Index
185
186 // Create feature for the selected hex
187 const feature = h3ToGeoJsonFeature(h3, h3Index, {
188 fillColor: '#00d1b2',
189 fillOpacity: 0.3,
190 strokeColor: '#00d1b2',
191 strokeWidth: 3,
192 })
193
194 // Update the source
195 updateHexagonSource(this.map, [feature])
196
197 // Get center coordinates for the API
198 const center = h3.cellToLatLng(h3Index)
199 this.latitude = center[0].toString()
200 this.longitude = center[1].toString()
201 },
202
203 clearLocation(this: LfgFormState): void {
204 if (this.map) {
205 // Clear the hexagon source
206 updateHexagonSource(this.map, [])
207 }
208 this.h3Index = ''
209 this.latitude = ''
210 this.longitude = ''
211 },
212
213 addTag(this: LfgFormState): void {
214 const tag = this.tagInput.trim().replace(/[^a-zA-Z0-9-]/g, '')
215 // Check for duplicates case-insensitively
216 const tagLower = tag.toLowerCase()
217 const isDuplicate = this.tags.some((t) => t.toLowerCase() === tagLower)
218 if (tag && !isDuplicate && this.tags.length < 10) {
219 this.tags.push(tag)
220 this.errors.tags = null
221 }
222 this.tagInput = ''
223 },
224
225 addTagFromSuggestion(this: LfgFormState, tag: string): void {
226 // Check for duplicates case-insensitively
227 const tagLower = tag.toLowerCase()
228 const isDuplicate = this.tags.some((t) => t.toLowerCase() === tagLower)
229 if (!isDuplicate && this.tags.length < 10) {
230 this.tags.push(tag)
231 this.errors.tags = null
232 }
233 },
234
235 removeTag(this: LfgFormState, index: number): void {
236 this.tags.splice(index, 1)
237 },
238
239 canSubmit(this: LfgFormState): boolean {
240 return !!this.h3Index && this.tags.length >= 1
241 },
242
243 clearErrors(this: LfgFormState): void {
244 this.errors = {
245 location: null,
246 tags: null,
247 duration: null,
248 general: null,
249 }
250 },
251
252 async submitForm(this: LfgFormState): Promise<void> {
253 if (!this.canSubmit() || this.submitting) return
254
255 this.clearErrors()
256 this.submitting = true
257
258 const payload = {
259 latitude: parseFloat(this.latitude),
260 longitude: parseFloat(this.longitude),
261 tags: this.tags,
262 duration_hours: parseInt(this.durationHours, 10),
263 }
264
265 try {
266 const response = await authPostJson('/lfg', payload)
267
268 if (response.ok) {
269 // Success - redirect to LFG page which will show matches view
270 window.location.href = '/lfg'
271 } else {
272 const data = (await response.json()) as { error?: string; message?: string }
273 if (data.error) {
274 // Map error codes to fields
275 if (data.error.includes('location') || data.error.includes('coordinate')) {
276 this.errors.location = data.message || 'Invalid location'
277 } else if (data.error.includes('tag')) {
278 this.errors.tags = data.message || 'Invalid tags'
279 } else if (data.error.includes('duration')) {
280 this.errors.duration = data.message || 'Invalid duration'
281 } else {
282 this.errors.general = data.message || 'An error occurred'
283 }
284 } else {
285 this.errors.general = 'An error occurred. Please try again.'
286 }
287 }
288 } catch (err) {
289 if (err instanceof SessionExpiredError) {
290 this.errors.general = err.message
291 } else {
292 console.error('LFG submission error:', err)
293 this.errors.general = 'Network error. Please check your connection and try again.'
294 }
295 } finally {
296 this.submitting = false
297 }
298 },
299 }
300}