Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions frontend/app/api/geocode/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
186 changes: 186 additions & 0 deletions frontend/components/listings/address/AddressAutocomplete.tsx
Comment thread
i30101 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const {
suggestions,
isLoading,
error: apiError,
search,
clearSuggestions,
} = useAddressAutocomplete();
Comment thread
i30101 marked this conversation as resolved.

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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<div className={"relative"} ref={containerRef}>
<div className={"relative"}>
<Input
ref={inputRef}
type={"text"}
value={value}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={() => {
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 && (
<button
type={"button"}
onClick={handleClear}
className={cn(
"absolute top-1/2 right-2 -translate-y-1/2",
"flex size-5 items-center justify-center rounded-sm",
"text-muted-foreground hover:text-foreground",
"transition-colors"
)}
aria-label={"Clear address"}
>
<X className={"size-4"} />
</button>
)}
</div>

<AddressDropdown
suggestions={suggestions}
isLoading={isLoading}
error={apiError}
isOpen={isOpen}
onSelect={handleSelect}
highlightedIndex={highlightedIndex}
/>
</div>
);
}
72 changes: 72 additions & 0 deletions frontend/components/listings/address/AddressDropdown.tsx
Comment thread
i30101 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={cn(
"absolute top-full right-0 left-0 z-50 mt-1",
"bg-popover max-h-[300px] overflow-y-auto rounded-md border shadow-md",
"animate-in fade-in-0 zoom-in-95"
)}
role={"listbox"}
>
{isLoading && (
<div className={"text-muted-foreground flex items-center justify-center gap-2 p-4 text-sm"}>
<Loader2 className={"size-4 animate-spin"} />
<span>Searching addresses...</span>
</div>
)}

{!isLoading && error && <div className={"text-destructive p-4 text-sm"}>{error}</div>}

{!isLoading && !error && suggestions.length === 0 && (
<div className={"text-muted-foreground p-4 text-sm"}>
Start typing to search addresses...
</div>
)}

{!isLoading && !error && suggestions.length > 0 && (
<div className={"p-1"}>
{suggestions.map((address, index) => (
<div
key={address.placeId}
role="option"
aria-selected={index === highlightedIndex}
className={cn(
"relative flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none",
"hover:bg-accent hover:text-accent-foreground",
index === highlightedIndex && "bg-accent text-accent-foreground"
)}
onClick={() => onSelect(address)}
onMouseDown={(e) => {
e.preventDefault();
}}
>
<span className={"truncate"}>{address.displayName}</span>
</div>
))}
</div>
)}
</div>
);
}
Loading
Loading