Skip to content

Commit 7f4b94b

Browse files
committed
finished address autocomplete
1 parent 0dcda83 commit 7f4b94b

8 files changed

Lines changed: 226 additions & 203 deletions

File tree

frontend/app/api/geocode/route.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import type { PhotonReponse, AddressResult, PhotonFeature } from "@/lib/types";
3+
4+
function photonFeatureToAddressResult(feature: PhotonFeature): AddressResult {
5+
const props = feature.properties;
6+
const [lon, lat] = feature.geometry.coordinates;
7+
8+
const displayParts: string[] = [];
9+
10+
if (props.housenumber && props.street) {
11+
displayParts.push(`${props.housenumber} ${props.street}`);
12+
} else if (props.street) {
13+
displayParts.push(props.street);
14+
} else if (props.name) {
15+
displayParts.push(props.name);
16+
}
17+
18+
if (props.city) displayParts.push(props.city);
19+
if (props.state) displayParts.push(props.state);
20+
21+
const displayName = `${displayParts.join(", ")} + ${props.postcode ? props.postcode : ""}`;
22+
23+
return {
24+
placeId: props.osm_id,
25+
lat: lat.toString(),
26+
lon: lon.toString(),
27+
displayName,
28+
address: {
29+
housenumber: props.housenumber,
30+
road: props.street,
31+
city: props.city,
32+
state: props.state,
33+
postCode: props.postcode,
34+
country: props.country,
35+
countryCode: props.countrycode,
36+
},
37+
};
38+
}
39+
40+
export async function GET(request: NextRequest) {
41+
const query = request.nextUrl.searchParams.get("q");
42+
43+
if (!query || query.trim().length < 3) {
44+
return NextResponse.json([]);
45+
}
46+
47+
try {
48+
// Philadelphia bounding box
49+
const bbox = "-75.28,39.87,-75.0,40.14";
50+
51+
const params = new URLSearchParams({
52+
q: query,
53+
limit: "5",
54+
lang: "en",
55+
bbox: bbox,
56+
});
57+
58+
const response = await fetch(`https://photon.komoot.io/api/?${params.toString()}`, {
59+
headers: {
60+
Accept: "application/json",
61+
},
62+
});
63+
64+
if (!response.ok) {
65+
throw new Error(`HTTP error! status: ${response.status}`);
66+
}
67+
68+
const data: PhotonReponse = await response.json();
69+
const results = data.features.map(photonFeatureToAddressResult);
70+
71+
return NextResponse.json(results);
72+
} catch (error) {
73+
console.error("Geocode API error:", error);
74+
return NextResponse.json({ error: "Failed to fetch addresses" }, { status: 500 });
75+
}
76+
}

frontend/components/listings/address/address-autocomplete.tsx renamed to frontend/components/listings/address/AddressAutocomplete.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22

33
import { useState, useRef, useEffect } from "react";
44
import { X } from "lucide-react";
5-
import type { NominatimAddress, ValidatedAddress } from "@/lib/types";
5+
import type { AddressResult, ValidatedAddress } from "@/lib/types";
66
import { useAddressAutocomplete } from "@/hooks/useAddressAutocomplete";
77
import { Input } from "@/components/ui/input";
8-
import { AddressDropdown } from "./address-dropdown";
8+
import { AddressDropdown } from "@/components/listings/address/AddressDropdown";
99
import { cn } from "@/lib/utils";
1010

1111
interface AddressAutocompleteProps {
@@ -39,15 +39,15 @@ export function AddressAutocomplete({
3939
} = useAddressAutocomplete();
4040

4141
useEffect(() => {
42-
if (value.trim().length > 0 && !hasValidatedAddress) {
42+
if (value.trim().length >= 3 && !hasValidatedAddress) {
4343
search(value);
4444
setIsOpen(true);
4545
setHighlightedIndex(-1);
4646
} else if (!hasValidatedAddress) {
4747
clearSuggestions();
4848
setIsOpen(false);
4949
}
50-
}, [value, search, clearSuggestions, hasValidatedAddress]);
50+
}, [value, hasValidatedAddress, search, clearSuggestions]);
5151

5252
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
5353
const newValue = e.target.value;
@@ -59,15 +59,15 @@ export function AddressAutocomplete({
5959
}
6060
};
6161

62-
const handleSelect = (address: NominatimAddress) => {
62+
const handleSelect = (address: AddressResult) => {
6363
const validatedAddress: ValidatedAddress = {
64-
display_name: address.display_name,
64+
displayName: address.displayName,
6565
lat: address.lat,
6666
lon: address.lon,
67-
place_id: address.place_id,
67+
placeId: address.placeId,
6868
};
6969

70-
onChange(address.display_name);
70+
onChange(address.displayName);
7171
onValidatedAddressChange(validatedAddress);
7272
setHasValidatedAddress(true);
7373
setIsOpen(false);

frontend/components/listings/address/address-dropdown.tsx renamed to frontend/components/listings/address/AddressDropdown.tsx

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import { Loader2 } from "lucide-react";
2-
import type { NominatimAddress } from "@/lib/types";
3-
import { AddressOption } from "./address-option";
2+
import type { AddressResult } from "@/lib/types";
43
import { cn } from "@/lib/utils";
54

65
interface AddressDropdownProps {
7-
suggestions: NominatimAddress[];
6+
suggestions: AddressResult[];
87
isLoading: boolean;
98
error: string | null;
109
isOpen: boolean;
11-
onSelect: (address: NominatimAddress) => void;
10+
onSelect: (address: AddressResult) => void;
1211
highlightedIndex: number;
1312
}
1413

@@ -49,12 +48,22 @@ export function AddressDropdown({
4948
{!isLoading && !error && suggestions.length > 0 && (
5049
<div className={"p-1"}>
5150
{suggestions.map((address, index) => (
52-
<AddressOption
53-
key={address.place_id}
54-
address={address}
55-
isHighlighted={index === highlightedIndex}
51+
<div
52+
key={address.placeId}
53+
role="option"
54+
aria-selected={index === highlightedIndex}
55+
className={cn(
56+
"relative flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none",
57+
"hover:bg-accent hover:text-accent-foreground",
58+
index === highlightedIndex && "bg-accent text-accent-foreground"
59+
)}
5660
onClick={() => onSelect(address)}
57-
/>
61+
onMouseDown={(e) => {
62+
e.preventDefault();
63+
}}
64+
>
65+
<span className={"truncate"}>{address.displayName}</span>
66+
</div>
5867
))}
5968
</div>
6069
)}

frontend/components/listings/address/address-option.tsx

Lines changed: 0 additions & 28 deletions
This file was deleted.

frontend/components/listings/form/SubletForm.tsx

Lines changed: 43 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { parsePriceString } from "@/lib/utils";
1818
import { createSubletSchema } from "@/lib/validations";
1919
import type { CreateSubletPayload } from "@/lib/types";
2020
import type { CreateSubletFormData } from "@/lib/validations";
21-
import { AddressAutocomplete } from "../address/address-autocomplete";
21+
import { AddressAutocomplete } from "@/components/listings/address/AddressAutocomplete";
2222

2323
const DISPLAY_LABEL = "Listing";
2424
const EXAMPLE_TITLE = "e.g., Spacious 2BR near campus";
@@ -42,7 +42,8 @@ export function SubletForm() {
4242
description: "",
4343
tags: [],
4444
street_address: "",
45-
validated_address: null,
45+
latitude: 0,
46+
longitude: 0,
4647
beds: 0,
4748
baths: 0,
4849
start_date: "",
@@ -96,39 +97,48 @@ export function SubletForm() {
9697
control={control}
9798
render={({ field: streetField }) => (
9899
<Controller
99-
name="validated_address"
100+
name="latitude"
100101
control={control}
101-
render={({ field: validatedField }) => (
102-
<FormField
103-
label={"Street Address"}
104-
error={errors.street_address?.message}
105-
touched={touchedFields.street_address}
106-
labelSupplement={
107-
<span className={"group relative inline-flex"}>
108-
<Info
109-
className={"text-muted-foreground h-4 w-4 shrink-0 cursor-help"}
110-
aria-label="address privacy info"
102+
render={({ field: latField }) => (
103+
<Controller
104+
name="longitude"
105+
control={control}
106+
render={({ field: lonField }) => (
107+
<FormField
108+
label={"Street Address"}
109+
error={errors.street_address?.message}
110+
touched={touchedFields.street_address}
111+
labelSupplement={
112+
<span className={"group relative inline-flex"}>
113+
<Info
114+
className={"text-muted-foreground h-4 w-4 shrink-0 cursor-help"}
115+
aria-label="address privacy info"
116+
/>
117+
<span
118+
className={
119+
"bg-popover text-popover-foreground pointer-events-none absolute top-full left-0 z-10 mt-1.5 w-56 rounded-md border px-3 py-2 text-xs opacity-0 shadow-md transition-opacity duration-150 group-hover:opacity-100"
120+
}
121+
>
122+
Your address will not be visible to the public. Only an approximate
123+
location will be shown on the map.
124+
</span>
125+
</span>
126+
}
127+
>
128+
<AddressAutocomplete
129+
value={streetField.value}
130+
onChange={streetField.onChange}
131+
onValidatedAddressChange={(addr) => {
132+
latField.onChange(addr ? parseFloat(addr.lat) : 0);
133+
lonField.onChange(addr ? parseFloat(addr.lon) : 0);
134+
}}
135+
disabled={isFormDisabled}
136+
error={!!errors.street_address}
137+
placeholder={"123 Main St 19104"}
111138
/>
112-
<span
113-
className={
114-
"bg-popover text-popover-foreground pointer-events-none absolute top-full left-0 z-10 mt-1.5 w-56 rounded-md border px-3 py-2 text-xs opacity-0 shadow-md transition-opacity duration-150 group-hover:opacity-100"
115-
}
116-
>
117-
Your address will not be visible to the public. Only an approximate location
118-
will be shown on the map.
119-
</span>
120-
</span>
121-
}
122-
>
123-
<AddressAutocomplete
124-
value={streetField.value}
125-
onChange={streetField.onChange}
126-
onValidatedAddressChange={validatedField.onChange}
127-
disabled={isFormDisabled}
128-
error={!!errors.street_address}
129-
placeholder={"123 Main St, Philadelphia, PA 19104"}
130-
/>
131-
</FormField>
139+
</FormField>
140+
)}
141+
/>
132142
)}
133143
/>
134144
)}

0 commit comments

Comments
 (0)