cropintel / components /FarmSetupForm.tsx
Jaithra Polavarapu
feat: add Firebase auth/farms backend alongside existing frontend
fb030a8
Raw
History Blame Contribute Delete
13.7 kB
'use client'
import { FormEvent, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Loader2, MapPin, Search, Send } from 'lucide-react'
import { CROPS } from '@/lib/crops'
import { US_STATES } from '@/lib/stateDiseaseMap'
import { createFarmForUser, joinFarmByCode, requestFarmAccess, searchFarmsForAccess } from '@/src/lib/farms'
import type { FarmSearchResult } from '@/src/lib/types'
type Mode = 'create' | 'join' | 'request'
type FarmSetupFormProps = {
userId: string
redirectTo?: string
}
function getErrorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback
}
export default function FarmSetupForm({ userId, redirectTo = '/' }: FarmSetupFormProps) {
const router = useRouter()
const cropOptions = useMemo(() => Object.keys(CROPS), [])
const [mode, setMode] = useState<Mode>('create')
const [loading, setLoading] = useState(false)
const [searching, setSearching] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [name, setName] = useState('')
const [address, setAddress] = useState('')
const [stateCode, setStateCode] = useState('IA')
const [selectedCrops, setSelectedCrops] = useState<string[]>([])
const [acreage, setAcreage] = useState('')
const [lat, setLat] = useState('')
const [lng, setLng] = useState('')
const [joinCode, setJoinCode] = useState('')
const [farmSearch, setFarmSearch] = useState('')
const [farmSearchState, setFarmSearchState] = useState('IA')
const [searchResults, setSearchResults] = useState<FarmSearchResult[]>([])
const [requestingFarmId, setRequestingFarmId] = useState<string | null>(null)
const toggleCrop = (crop: string) => {
setSelectedCrops((current) =>
current.includes(crop) ? current.filter((item) => item !== crop) : [...current, crop]
)
}
const useCurrentLocation = () => {
if (!navigator.geolocation) {
setError('Geolocation is not available in this browser. You can enter latitude and longitude manually.')
return
}
setError(null)
navigator.geolocation.getCurrentPosition(
(position) => {
setLat(position.coords.latitude.toFixed(6))
setLng(position.coords.longitude.toFixed(6))
},
() => setError('Could not read your location. You can continue with address and state, or enter coordinates manually.')
)
}
const handleCreate = async (event: FormEvent) => {
event.preventDefault()
if (selectedCrops.length === 0) {
setError('Select at least one crop grown on this farm.')
return
}
setLoading(true)
setError(null)
try {
await createFarmForUser(userId, {
name,
address,
stateCode,
crops: selectedCrops,
acreage: acreage.trim() ? Number(acreage) : null,
lat: lat.trim() ? Number(lat) : null,
lng: lng.trim() ? Number(lng) : null,
})
setSuccess('Farm created.')
router.replace(redirectTo)
} catch (err: unknown) {
setError(getErrorMessage(err, 'Could not create this farm.'))
} finally {
setLoading(false)
}
}
const handleJoin = async (event: FormEvent) => {
event.preventDefault()
setLoading(true)
setError(null)
try {
await joinFarmByCode(userId, joinCode)
setSuccess('Farm joined.')
router.replace(redirectTo)
} catch (err: unknown) {
setError(getErrorMessage(err, 'That join code is invalid or expired.'))
} finally {
setLoading(false)
}
}
const handleSearch = async (event: FormEvent) => {
event.preventDefault()
setSearching(true)
setError(null)
setSuccess(null)
try {
const results = await searchFarmsForAccess(farmSearch, farmSearchState)
setSearchResults(results)
if (results.length === 0) {
setSuccess('No matching farms found. Check the name and state, then try again.')
}
} catch (err: unknown) {
setError(getErrorMessage(err, 'Could not search farms.'))
} finally {
setSearching(false)
}
}
const handleRequestAccess = async (farm: FarmSearchResult) => {
setRequestingFarmId(farm.farmId)
setError(null)
setSuccess(null)
try {
await requestFarmAccess(userId, farm)
setSuccess(`Access request sent to ${farm.name}.`)
setSearchResults((current) => current.filter((item) => item.farmId !== farm.farmId))
} catch (err: unknown) {
setError(getErrorMessage(err, 'Could not request access to this farm.'))
} finally {
setRequestingFarmId(null)
}
}
return (
<>
<div className="mb-6 grid gap-3 sm:grid-cols-3">
{(['create', 'join', 'request'] as Mode[]).map((option) => (
<button
key={option}
type="button"
onClick={() => {
setMode(option)
setError(null)
}}
className={`min-h-[52px] rounded-full border px-4 py-3 text-sm font-bold transition ${
mode === option
? 'border-ink bg-ink text-white'
: 'border-ink/10 bg-white/70 text-primary-900 hover:border-leaf/30'
}`}
>
{option === 'create' ? 'Create Farm' : option === 'join' ? 'Join With Code' : 'Request Access'}
</button>
))}
</div>
{mode === 'create' ? (
<form className="space-y-5" onSubmit={handleCreate}>
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="mb-2 block text-sm font-bold text-primary-900" htmlFor="farm-name">
Farm name
</label>
<input id="farm-name" required value={name} onChange={(e) => setName(e.target.value)} className="field-input" />
</div>
<div>
<label className="mb-2 block text-sm font-bold text-primary-900" htmlFor="state-code">
State
</label>
<select id="state-code" value={stateCode} onChange={(e) => setStateCode(e.target.value)} className="field-input">
{US_STATES.map((state) => (
<option key={state.code} value={state.code}>
{state.name} ({state.code})
</option>
))}
</select>
</div>
</div>
<div>
<label className="mb-2 block text-sm font-bold text-primary-900" htmlFor="address">
Address
</label>
<input id="address" required value={address} onChange={(e) => setAddress(e.target.value)} className="field-input" />
</div>
<div className="grid gap-4 md:grid-cols-3">
<div>
<label className="mb-2 block text-sm font-bold text-primary-900" htmlFor="acreage">
Acreage optional
</label>
<input id="acreage" type="number" min="0" step="0.01" value={acreage} onChange={(e) => setAcreage(e.target.value)} className="field-input" />
</div>
<div>
<label className="mb-2 block text-sm font-bold text-primary-900" htmlFor="lat">
Latitude optional
</label>
<input id="lat" type="number" step="any" value={lat} onChange={(e) => setLat(e.target.value)} className="field-input" />
</div>
<div>
<label className="mb-2 block text-sm font-bold text-primary-900" htmlFor="lng">
Longitude optional
</label>
<input id="lng" type="number" step="any" value={lng} onChange={(e) => setLng(e.target.value)} className="field-input" />
</div>
</div>
<button type="button" onClick={useCurrentLocation} className="btn-secondary w-full sm:w-auto">
<MapPin className="h-4 w-4" />
Use current location
</button>
<div>
<label className="mb-3 block text-sm font-bold text-primary-900">Crops grown</label>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{cropOptions.map((crop) => (
<button
key={crop}
type="button"
onClick={() => toggleCrop(crop)}
className={`rounded-2xl border px-4 py-3 text-sm font-bold capitalize shadow-[0_10px_24px_-18px_rgba(18,38,28,0.45)] ring-1 transition ${
selectedCrops.includes(crop)
? 'border-primary-700 bg-primary-700 text-white ring-primary-700/30'
: 'border-primary-200 bg-white text-primary-900 ring-primary-100/70 hover:border-primary-400 hover:ring-primary-200/80'
}`}
>
{crop}
</button>
))}
</div>
</div>
{error && <p className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-900">{error}</p>}
<button type="submit" disabled={loading} className="btn-primary w-full sm:max-w-sm">
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
Create farm
</button>
</form>
) : mode === 'join' ? (
<form className="space-y-5" onSubmit={handleJoin}>
<div>
<label className="mb-2 block text-sm font-bold text-primary-900" htmlFor="join-code">
Farm join code
</label>
<input
id="join-code"
value={joinCode}
onChange={(e) => setJoinCode(e.target.value.toUpperCase())}
maxLength={6}
required
className="field-input font-mono uppercase tracking-[0.3em]"
placeholder="ABC123"
/>
</div>
{error && <p className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-900">{error}</p>}
{success && <p className="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">{success}</p>}
<button type="submit" disabled={loading} className="btn-primary w-full sm:max-w-sm">
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
Join farm
</button>
</form>
) : (
<div className="space-y-5">
<form className="space-y-4" onSubmit={handleSearch}>
<div className="grid gap-4 md:grid-cols-[1fr_180px]">
<div>
<label className="mb-2 block text-sm font-bold text-primary-900" htmlFor="farm-search">
Farm name or location
</label>
<input
id="farm-search"
value={farmSearch}
onChange={(event) => setFarmSearch(event.target.value)}
required
minLength={2}
maxLength={80}
className="field-input"
placeholder="Smith Family Farm"
/>
</div>
<div>
<label className="mb-2 block text-sm font-bold text-primary-900" htmlFor="farm-search-state">
State
</label>
<select
id="farm-search-state"
value={farmSearchState}
onChange={(event) => setFarmSearchState(event.target.value)}
className="field-input"
>
{US_STATES.map((state) => (
<option key={state.code} value={state.code}>
{state.name} ({state.code})
</option>
))}
</select>
</div>
</div>
{error && <p className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-900">{error}</p>}
{success && <p className="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">{success}</p>}
<button type="submit" disabled={searching} className="btn-primary w-full sm:max-w-sm">
{searching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
Search farms
</button>
</form>
{searchResults.length > 0 && (
<div className="space-y-3">
{searchResults.map((farm) => (
<article key={farm.farmId} className="rounded-2xl border border-primary-100 bg-white/75 p-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h3 className="truncate text-lg font-extrabold text-primary-900">{farm.name}</h3>
<p className="mt-1 text-sm text-field-soil">{farm.address}</p>
<p className="mt-1 text-xs font-bold uppercase tracking-wide text-field-soil">{farm.stateCode}</p>
</div>
<button
type="button"
onClick={() => handleRequestAccess(farm)}
disabled={requestingFarmId === farm.farmId}
className="btn-secondary w-full justify-center sm:w-auto"
>
{requestingFarmId === farm.farmId ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
Request
</button>
</div>
</article>
))}
</div>
)}
</div>
)}
</>
)
}