diff --git a/frontend/app/api/geocode/route.ts b/frontend/app/api/geocode/route.ts new file mode 100644 index 0000000..dc501ba --- /dev/null +++ b/frontend/app/api/geocode/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from "next/server"; +import type { PhotonReponse, AddressResult, PhotonFeature } from "@/lib/types"; + +function photonFeatureToAddressResult(feature: PhotonFeature): AddressResult { + const props = feature.properties; + const [lon, lat] = feature.geometry.coordinates; + + const addressParts = [ + props.housenumber && props.street + ? `${props.housenumber} ${props.street}` + : (props.street ?? props.name), + props.city, + [props.state, props.postcode].filter(Boolean).join(" "), + ].filter(Boolean); + + const displayName = addressParts.join(", "); + + return { + placeId: props.osm_id, + lat: lat.toString(), + lon: lon.toString(), + displayName, + address: { + housenumber: props.housenumber, + road: props.street, + city: props.city, + state: props.state, + postCode: props.postcode, + country: props.country, + countryCode: props.countrycode, + }, + }; +} + +export async function GET(request: NextRequest) { + const query = request.nextUrl.searchParams.get("q"); + + if (!query || query.trim().length < 3) { + return NextResponse.json([]); + } + + try { + // Philadelphia bounding box + const bbox = "-75.28,39.87,-75.0,40.14"; + + const params = new URLSearchParams({ + q: query, + limit: "5", // maximum number of results returned + lang: "en", + bbox: bbox, + }); + + const response = await fetch(`https://photon.komoot.io/api/?${params.toString()}`, { + headers: { + Accept: "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data: PhotonReponse = await response.json(); + const results = data.features.map(photonFeatureToAddressResult); + + return NextResponse.json(results); + } catch (error) { + console.error("Geocode API error:", error); + return NextResponse.json({ error: "Failed to fetch addresses" }, { status: 500 }); + } +} diff --git a/frontend/components/listings/address/AddressAutocomplete.tsx b/frontend/components/listings/address/AddressAutocomplete.tsx new file mode 100644 index 0000000..3a898ce --- /dev/null +++ b/frontend/components/listings/address/AddressAutocomplete.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { X } from "lucide-react"; +import type { AddressResult, ValidatedAddress } from "@/lib/types"; +import { useAddressAutocomplete } from "@/hooks/useAddressAutocomplete"; +import { Input } from "@/components/ui/input"; +import { AddressDropdown } from "@/components/listings/address/AddressDropdown"; +import { cn } from "@/lib/utils"; + +interface AddressAutocompleteProps { + value: string; + onChange: (value: string) => void; + onValidatedAddressChange: (address: ValidatedAddress | null) => void; + disabled?: boolean; + error?: boolean; + placeholder?: string; +} + +export function AddressAutocomplete({ + value, + onChange, + onValidatedAddressChange, + disabled = false, + error = false, + placeholder = "123 Main St, Philadelphia, PA 19104", +}: AddressAutocompleteProps) { + const [isOpen, setIsOpen] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const [hasValidatedAddress, setHasValidatedAddress] = useState(false); + const inputRef = useRef(null); + const containerRef = useRef(null); + const { + suggestions, + isLoading, + error: apiError, + search, + clearSuggestions, + } = useAddressAutocomplete(); + + useEffect(() => { + if (value.trim().length >= 3 && !hasValidatedAddress) { + search(value); + setIsOpen(true); + setHighlightedIndex(-1); + } else if (!hasValidatedAddress) { + clearSuggestions(); + setIsOpen(false); + } + }, [value, hasValidatedAddress, search, clearSuggestions]); + + const handleInputChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + onChange(newValue); + + if (hasValidatedAddress) { + onValidatedAddressChange(null); + setHasValidatedAddress(false); + } + }; + + const handleSelect = (address: AddressResult) => { + const validatedAddress: ValidatedAddress = { + displayName: address.displayName, + lat: address.lat, + lon: address.lon, + placeId: address.placeId, + }; + + onChange(address.displayName); + onValidatedAddressChange(validatedAddress); + setHasValidatedAddress(true); + setIsOpen(false); + clearSuggestions(); + inputRef.current?.blur(); + }; + + const handleClear = () => { + onChange(""); + onValidatedAddressChange(null); + setHasValidatedAddress(false); + setIsOpen(false); + clearSuggestions(); + inputRef.current?.focus(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isOpen || suggestions.length === 0) { + return; + } + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setHighlightedIndex((prev) => (prev < suggestions.length - 1 ? prev + 1 : 0)); + break; + + case "ArrowUp": + e.preventDefault(); + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : suggestions.length - 1)); + break; + + case "Enter": + e.preventDefault(); + if (highlightedIndex >= 0 && highlightedIndex < suggestions.length) { + handleSelect(suggestions[highlightedIndex]); + } + break; + + case "Escape": + e.preventDefault(); + setIsOpen(false); + clearSuggestions(); + inputRef.current?.blur(); + break; + } + }; + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + return ( +
+
+ { + if (value.trim().length >= 3) { + setIsOpen(true); + } + }} + disabled={disabled} + placeholder={placeholder} + className={cn( + hasValidatedAddress && "pr-9", + error && "border-destructive ring-destructive/20" + )} + role={"combobox"} + aria-expanded={isOpen} + aria-autocomplete={"list"} + aria-controls={"address-dropdown"} + aria-activedescendant={ + highlightedIndex >= 0 ? `address-option-${highlightedIndex}` : undefined + } + /> + + {hasValidatedAddress && !disabled && ( + + )} +
+ + +
+ ); +} diff --git a/frontend/components/listings/address/AddressDropdown.tsx b/frontend/components/listings/address/AddressDropdown.tsx new file mode 100644 index 0000000..bfdb403 --- /dev/null +++ b/frontend/components/listings/address/AddressDropdown.tsx @@ -0,0 +1,72 @@ +import { Loader2 } from "lucide-react"; +import type { AddressResult } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +interface AddressDropdownProps { + suggestions: AddressResult[]; + isLoading: boolean; + error: string | null; + isOpen: boolean; + onSelect: (address: AddressResult) => void; + highlightedIndex: number; +} + +export function AddressDropdown({ + suggestions, + isLoading, + error, + isOpen, + onSelect, + highlightedIndex, +}: AddressDropdownProps) { + if (!isOpen) return null; + + return ( +
+ {isLoading && ( +
+ + Searching addresses... +
+ )} + + {!isLoading && error &&
{error}
} + + {!isLoading && !error && suggestions.length === 0 && ( +
+ Start typing to search addresses... +
+ )} + + {!isLoading && !error && suggestions.length > 0 && ( +
+ {suggestions.map((address, index) => ( +
onSelect(address)} + onMouseDown={(e) => { + e.preventDefault(); + }} + > + {address.displayName} +
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/components/listings/form/SubletForm.tsx b/frontend/components/listings/form/SubletForm.tsx index 7111a75..69732db 100644 --- a/frontend/components/listings/form/SubletForm.tsx +++ b/frontend/components/listings/form/SubletForm.tsx @@ -9,6 +9,7 @@ import { Info } from "lucide-react"; import { Input } from "@/components/ui/input"; import { FormField } from "@/components/common/FormField"; import { FormSelect } from "@/components/common/FormSelect"; +import { AddressAutocomplete } from "@/components/listings/address/AddressAutocomplete"; import { BaseListingForm } from "@/components/listings/form/BaseListingForm"; import { ListingFormShell } from "@/components/listings/form/ListingFormShell"; import { useImageUpload } from "@/hooks/useImageUpload"; @@ -40,7 +41,9 @@ export function SubletForm() { price: "", description: "", tags: [], - streetAddress: "", + street_address: "", + latitude: 0, + longitude: 0, beds: 0, baths: 0, startDate: "", @@ -75,7 +78,9 @@ export function SubletForm() { price: String(parsePriceString(data.price)), listing_type: "sublet", additional_data: { - street_address: data.streetAddress, + street_address: data.street_address, + latitude: data.latitude, + longitude: data.longitude, beds: data.beds, baths: data.baths, start_date: data.startDate, @@ -90,36 +95,57 @@ export function SubletForm() { const subletFieldsAfterPrice = ( <> ( - - - - Your address will not be visible to the public. Only an approximate location will - be shown on the map. - - - } - > - - + render={({ field: streetField }) => ( + ( + ( + + + + Your address will not be visible to the public. Only an approximate + location will be shown on the map. + + + } + > + { + latField.onChange(addr ? parseFloat(addr.lat) : 0); + lonField.onChange(addr ? parseFloat(addr.lon) : 0); + }} + disabled={isFormDisabled} + error={!!errors.street_address} + placeholder={"123 Main St 19104"} + /> + + )} + /> + )} + /> )} /> +
{ + const handler = setTimeout(() => setDebouncedQuery(query), 300); + return () => clearTimeout(handler); + }, [query]); + + const { + data: suggestions = [], + isLoading, + error, + } = useQuery({ + queryKey: ["geocode", debouncedQuery], + queryFn: () => + fetch(`/api/geocode?q=${encodeURIComponent(debouncedQuery)}`).then((r) => { + if (!r.ok) throw new Error("failed to fetch addresses"); + return r.json(); + }), + enabled: debouncedQuery.length >= 3, + }); + + const search = useCallback((newQuery: string) => { + setQuery(newQuery); + }, []); + + const clearSuggestions = useCallback(() => { + setQuery(""); + setDebouncedQuery(""); + }, []); + + return { suggestions, isLoading, error: error?.message || null, search, clearSuggestions }; +} diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index afed485..69342ad 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -40,12 +40,75 @@ export type ItemAdditionalData = { export type SubletAdditionalData = { street_address: string; + latitude: number; + longitude: number; beds: number; baths: number; start_date: string; end_date: string; }; +// ------------------------------------------------------------ +// address autocomplete types +// ------------------------------------------------------------ + +export type PhotonGeometry = { + type: "Point"; + coordinates: [number, number]; // longitude, latitude +}; + +export type PhotonProperties = { + osm_id: number; + osm_type: string; + osm_value: string; + name?: string; + street?: string; + housenumber?: string; + postcode?: string; + city?: string; + state?: string; + country?: string; + countrycode?: string; + extent?: [number, number, number]; + county?: string; + district?: string; + locality?: string; +}; + +export type PhotonFeature = { + type: "Feature"; + geometry: PhotonGeometry; + properties: PhotonProperties; +}; + +export type PhotonReponse = { + type: "FeatureCollection"; + features: PhotonFeature[]; +}; + +export type AddressResult = { + placeId: number; + lat: string; + lon: string; + displayName: string; + address: { + housenumber?: string; + road?: string; + city?: string; + state?: string; + postCode?: string; + country?: string; + countryCode?: string; + }; +}; + +export type ValidatedAddress = { + displayName: string; + lat: string; + lon: string; + placeId: number; +}; + // ------------------------------------------------------------ // base listing fields (shared by all listings) // ------------------------------------------------------------ diff --git a/frontend/lib/validations.ts b/frontend/lib/validations.ts index 31af9ad..e81dc10 100644 --- a/frontend/lib/validations.ts +++ b/frontend/lib/validations.ts @@ -125,7 +125,9 @@ export const createSubletSchema = z .optional(), price: priceSchema, tags: z.array(z.string().trim()), - streetAddress: z.string().trim().min(1, "Street address is required"), + street_address: z.string().trim().min(1, "Street address is required"), + latitude: z.number(), + longitude: z.number(), beds: z.number().int().min(0, "Beds must be 0 or more"), baths: z.number().int().min(0, "Baths must be 0 or more"), startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Start date is required"), @@ -137,7 +139,11 @@ export const createSubletSchema = z const end = Date.parse(data.endDate); return Number.isFinite(start) && Number.isFinite(end) && end > start; }, - { message: "End date must be after start date", path: ["endDate"] } - ); + { message: "End date must be after start date", path: ["end_date"] } + ) + .refine((data) => data.latitude !== 0 && data.longitude !== 0, { + message: "Please select an address from the dropdown", + path: ["street_address"], + }); export type CreateSubletFormData = z.infer;