Skip to content

Commit 378f335

Browse files
authored
Merge branch 'master' into james/better-error-handling
2 parents 79748ed + 60ad9fd commit 378f335

10 files changed

Lines changed: 520 additions & 54 deletions

File tree

.github/workflows/build.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
name: Build Marketplace
22

3-
on: push
3+
on:
4+
push:
5+
pull_request:
46

57
jobs:
68
backend-check:

frontend/app/api/geocode/route.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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 addressParts = [
9+
props.housenumber && props.street
10+
? `${props.housenumber} ${props.street}`
11+
: (props.street ?? props.name),
12+
props.city,
13+
[props.state, props.postcode].filter(Boolean).join(" "),
14+
].filter(Boolean);
15+
16+
const displayName = addressParts.join(", ");
17+
18+
return {
19+
placeId: props.osm_id,
20+
lat: lat.toString(),
21+
lon: lon.toString(),
22+
displayName,
23+
address: {
24+
housenumber: props.housenumber,
25+
road: props.street,
26+
city: props.city,
27+
state: props.state,
28+
postCode: props.postcode,
29+
country: props.country,
30+
countryCode: props.countrycode,
31+
},
32+
};
33+
}
34+
35+
export async function GET(request: NextRequest) {
36+
const query = request.nextUrl.searchParams.get("q");
37+
38+
if (!query || query.trim().length < 3) {
39+
return NextResponse.json([]);
40+
}
41+
42+
try {
43+
// Philadelphia bounding box
44+
const bbox = "-75.28,39.87,-75.0,40.14";
45+
46+
const params = new URLSearchParams({
47+
q: query,
48+
limit: "5", // maximum number of results returned
49+
lang: "en",
50+
bbox: bbox,
51+
});
52+
53+
const response = await fetch(`https://photon.komoot.io/api/?${params.toString()}`, {
54+
headers: {
55+
Accept: "application/json",
56+
},
57+
});
58+
59+
if (!response.ok) {
60+
throw new Error(`HTTP error! status: ${response.status}`);
61+
}
62+
63+
const data: PhotonReponse = await response.json();
64+
const results = data.features.map(photonFeatureToAddressResult);
65+
66+
return NextResponse.json(results);
67+
} catch (error) {
68+
console.error("Geocode API error:", error);
69+
return NextResponse.json({ error: "Failed to fetch addresses" }, { status: 500 });
70+
}
71+
}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
"use client";
2+
3+
import { useState, useRef, useEffect } from "react";
4+
import { X } from "lucide-react";
5+
import type { AddressResult, ValidatedAddress } from "@/lib/types";
6+
import { useAddressAutocomplete } from "@/hooks/useAddressAutocomplete";
7+
import { Input } from "@/components/ui/input";
8+
import { AddressDropdown } from "@/components/listings/address/AddressDropdown";
9+
import { cn } from "@/lib/utils";
10+
11+
interface AddressAutocompleteProps {
12+
value: string;
13+
onChange: (value: string) => void;
14+
onValidatedAddressChange: (address: ValidatedAddress | null) => void;
15+
disabled?: boolean;
16+
error?: boolean;
17+
placeholder?: string;
18+
}
19+
20+
export function AddressAutocomplete({
21+
value,
22+
onChange,
23+
onValidatedAddressChange,
24+
disabled = false,
25+
error = false,
26+
placeholder = "123 Main St, Philadelphia, PA 19104",
27+
}: AddressAutocompleteProps) {
28+
const [isOpen, setIsOpen] = useState(false);
29+
const [highlightedIndex, setHighlightedIndex] = useState(-1);
30+
const [hasValidatedAddress, setHasValidatedAddress] = useState(false);
31+
const inputRef = useRef<HTMLInputElement>(null);
32+
const containerRef = useRef<HTMLDivElement>(null);
33+
const {
34+
suggestions,
35+
isLoading,
36+
error: apiError,
37+
search,
38+
clearSuggestions,
39+
} = useAddressAutocomplete();
40+
41+
useEffect(() => {
42+
if (value.trim().length >= 3 && !hasValidatedAddress) {
43+
search(value);
44+
setIsOpen(true);
45+
setHighlightedIndex(-1);
46+
} else if (!hasValidatedAddress) {
47+
clearSuggestions();
48+
setIsOpen(false);
49+
}
50+
}, [value, hasValidatedAddress, search, clearSuggestions]);
51+
52+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
53+
const newValue = e.target.value;
54+
onChange(newValue);
55+
56+
if (hasValidatedAddress) {
57+
onValidatedAddressChange(null);
58+
setHasValidatedAddress(false);
59+
}
60+
};
61+
62+
const handleSelect = (address: AddressResult) => {
63+
const validatedAddress: ValidatedAddress = {
64+
displayName: address.displayName,
65+
lat: address.lat,
66+
lon: address.lon,
67+
placeId: address.placeId,
68+
};
69+
70+
onChange(address.displayName);
71+
onValidatedAddressChange(validatedAddress);
72+
setHasValidatedAddress(true);
73+
setIsOpen(false);
74+
clearSuggestions();
75+
inputRef.current?.blur();
76+
};
77+
78+
const handleClear = () => {
79+
onChange("");
80+
onValidatedAddressChange(null);
81+
setHasValidatedAddress(false);
82+
setIsOpen(false);
83+
clearSuggestions();
84+
inputRef.current?.focus();
85+
};
86+
87+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
88+
if (!isOpen || suggestions.length === 0) {
89+
return;
90+
}
91+
92+
switch (e.key) {
93+
case "ArrowDown":
94+
e.preventDefault();
95+
setHighlightedIndex((prev) => (prev < suggestions.length - 1 ? prev + 1 : 0));
96+
break;
97+
98+
case "ArrowUp":
99+
e.preventDefault();
100+
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : suggestions.length - 1));
101+
break;
102+
103+
case "Enter":
104+
e.preventDefault();
105+
if (highlightedIndex >= 0 && highlightedIndex < suggestions.length) {
106+
handleSelect(suggestions[highlightedIndex]);
107+
}
108+
break;
109+
110+
case "Escape":
111+
e.preventDefault();
112+
setIsOpen(false);
113+
clearSuggestions();
114+
inputRef.current?.blur();
115+
break;
116+
}
117+
};
118+
119+
useEffect(() => {
120+
const handleClickOutside = (event: MouseEvent) => {
121+
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
122+
setIsOpen(false);
123+
}
124+
};
125+
126+
document.addEventListener("mousedown", handleClickOutside);
127+
return () => document.removeEventListener("mousedown", handleClickOutside);
128+
}, []);
129+
130+
return (
131+
<div className={"relative"} ref={containerRef}>
132+
<div className={"relative"}>
133+
<Input
134+
ref={inputRef}
135+
type={"text"}
136+
value={value}
137+
onChange={handleInputChange}
138+
onKeyDown={handleKeyDown}
139+
onFocus={() => {
140+
if (value.trim().length >= 3) {
141+
setIsOpen(true);
142+
}
143+
}}
144+
disabled={disabled}
145+
placeholder={placeholder}
146+
className={cn(
147+
hasValidatedAddress && "pr-9",
148+
error && "border-destructive ring-destructive/20"
149+
)}
150+
role={"combobox"}
151+
aria-expanded={isOpen}
152+
aria-autocomplete={"list"}
153+
aria-controls={"address-dropdown"}
154+
aria-activedescendant={
155+
highlightedIndex >= 0 ? `address-option-${highlightedIndex}` : undefined
156+
}
157+
/>
158+
159+
{hasValidatedAddress && !disabled && (
160+
<button
161+
type={"button"}
162+
onClick={handleClear}
163+
className={cn(
164+
"absolute top-1/2 right-2 -translate-y-1/2",
165+
"flex size-5 items-center justify-center rounded-sm",
166+
"text-muted-foreground hover:text-foreground",
167+
"transition-colors"
168+
)}
169+
aria-label={"Clear address"}
170+
>
171+
<X className={"size-4"} />
172+
</button>
173+
)}
174+
</div>
175+
176+
<AddressDropdown
177+
suggestions={suggestions}
178+
isLoading={isLoading}
179+
error={apiError}
180+
isOpen={isOpen}
181+
onSelect={handleSelect}
182+
highlightedIndex={highlightedIndex}
183+
/>
184+
</div>
185+
);
186+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { Loader2 } from "lucide-react";
2+
import type { AddressResult } from "@/lib/types";
3+
import { cn } from "@/lib/utils";
4+
5+
interface AddressDropdownProps {
6+
suggestions: AddressResult[];
7+
isLoading: boolean;
8+
error: string | null;
9+
isOpen: boolean;
10+
onSelect: (address: AddressResult) => void;
11+
highlightedIndex: number;
12+
}
13+
14+
export function AddressDropdown({
15+
suggestions,
16+
isLoading,
17+
error,
18+
isOpen,
19+
onSelect,
20+
highlightedIndex,
21+
}: AddressDropdownProps) {
22+
if (!isOpen) return null;
23+
24+
return (
25+
<div
26+
className={cn(
27+
"absolute top-full right-0 left-0 z-50 mt-1",
28+
"bg-popover max-h-[300px] overflow-y-auto rounded-md border shadow-md",
29+
"animate-in fade-in-0 zoom-in-95"
30+
)}
31+
role={"listbox"}
32+
>
33+
{isLoading && (
34+
<div className={"text-muted-foreground flex items-center justify-center gap-2 p-4 text-sm"}>
35+
<Loader2 className={"size-4 animate-spin"} />
36+
<span>Searching addresses...</span>
37+
</div>
38+
)}
39+
40+
{!isLoading && error && <div className={"text-destructive p-4 text-sm"}>{error}</div>}
41+
42+
{!isLoading && !error && suggestions.length === 0 && (
43+
<div className={"text-muted-foreground p-4 text-sm"}>
44+
Start typing to search addresses...
45+
</div>
46+
)}
47+
48+
{!isLoading && !error && suggestions.length > 0 && (
49+
<div className={"p-1"}>
50+
{suggestions.map((address, index) => (
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+
)}
60+
onClick={() => onSelect(address)}
61+
onMouseDown={(e) => {
62+
e.preventDefault();
63+
}}
64+
>
65+
<span className={"truncate"}>{address.displayName}</span>
66+
</div>
67+
))}
68+
</div>
69+
)}
70+
</div>
71+
);
72+
}

frontend/components/listings/detail/ListingInfo.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { formatDate } from "@/lib/utils";
88
interface Props {
99
title: string;
1010
price: number;
11-
description: string;
11+
description?: string;
1212
category?: string;
1313
condition?: string;
1414
priceLabel?: string;
@@ -19,6 +19,7 @@ interface Props {
1919
}
2020

2121
const MAX_DESCRIPTION_LENGTH = 250;
22+
const NO_DESCRIPTION = "No description";
2223

2324
export const ListingInfo = ({
2425
title,
@@ -33,11 +34,13 @@ export const ListingInfo = ({
3334
end_date,
3435
}: Props) => {
3536
const [isExpanded, setIsExpanded] = useState(false);
36-
const shouldTruncate = description.length > MAX_DESCRIPTION_LENGTH;
37+
const hasDescription = description && description.trim().length > 0;
38+
const descriptionText = hasDescription ? description : NO_DESCRIPTION;
39+
const shouldTruncate = hasDescription && descriptionText.length > MAX_DESCRIPTION_LENGTH;
3740
const displayDescription =
3841
!shouldTruncate || isExpanded
39-
? description
40-
: `${description.slice(0, MAX_DESCRIPTION_LENGTH)}...`;
42+
? descriptionText
43+
: `${descriptionText.slice(0, MAX_DESCRIPTION_LENGTH)}...`;
4144

4245
return (
4346
<div className="space-y-3">

frontend/components/listings/form/BaseListingForm.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ export const BaseListingForm = <T extends BaseCreatePayload>({
135135
>
136136
<Textarea
137137
{...field}
138-
placeholder={`Enter ${displayLabel} Description`}
138+
placeholder={`Enter description (optional)`}
139139
className="min-h-[120px] resize-y"
140140
aria-invalid={!!errors.description}
141141
disabled={disabled}

0 commit comments

Comments
 (0)