269 lines
8.0 KiB
TypeScript
269 lines
8.0 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useId, useRef, useState } from 'react';
|
|
import { MapPin } from 'lucide-react';
|
|
import {
|
|
fetchGeoapifyAddressSuggestions,
|
|
isDeliverableGeoapifyFeature,
|
|
shouldUseGeoapifyAutocomplete,
|
|
} from '@/infrastructure/geocoding/geoapify-autocomplete';
|
|
import type { GeoapifyFeature } from '@/infrastructure/geocoding/geoapify-types';
|
|
|
|
export interface DeliveryAddressAutocompleteLabels {
|
|
placeholder: string;
|
|
hint: string;
|
|
fallbackPlaceholder: string;
|
|
fallbackHint: string;
|
|
searching: string;
|
|
noResults: string;
|
|
outsideGothenburg: string;
|
|
selectSuggestion: string;
|
|
}
|
|
|
|
interface DeliveryAddressAutocompleteProps {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
onValidationChange?: (isValid: boolean) => void;
|
|
language: 'sv' | 'en';
|
|
labels: DeliveryAddressAutocompleteLabels;
|
|
inputClassName: string;
|
|
}
|
|
|
|
function formatSuggestion(feature: GeoapifyFeature): string {
|
|
return (
|
|
feature.properties.formatted ??
|
|
[feature.properties.address_line1, feature.properties.address_line2]
|
|
.filter(Boolean)
|
|
.join(', ')
|
|
);
|
|
}
|
|
|
|
export default function DeliveryAddressAutocomplete({
|
|
value,
|
|
onChange,
|
|
onValidationChange,
|
|
language,
|
|
labels,
|
|
inputClassName,
|
|
}: DeliveryAddressAutocompleteProps) {
|
|
const listboxId = useId();
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
const fetchGenerationRef = useRef(0);
|
|
const [query, setQuery] = useState(value);
|
|
const [suggestions, setSuggestions] = useState<GeoapifyFeature[]>([]);
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [selectedFromList, setSelectedFromList] = useState(false);
|
|
const [autocompleteActive, setAutocompleteActive] = useState<boolean | null>(null);
|
|
|
|
const deactivateAutocomplete = () => {
|
|
setAutocompleteActive(false);
|
|
setSuggestions([]);
|
|
setIsOpen(false);
|
|
setIsLoading(false);
|
|
setSelectedFromList(false);
|
|
setError('');
|
|
onValidationChange?.(true);
|
|
};
|
|
|
|
useEffect(() => {
|
|
const active = shouldUseGeoapifyAutocomplete();
|
|
setAutocompleteActive(active);
|
|
if (!active) onValidationChange?.(true);
|
|
}, [onValidationChange]);
|
|
|
|
useEffect(() => {
|
|
setQuery(value);
|
|
if (autocompleteActive) {
|
|
setSelectedFromList(!!value.trim());
|
|
}
|
|
setError('');
|
|
}, [value, autocompleteActive]);
|
|
|
|
useEffect(() => {
|
|
if (autocompleteActive !== true) return;
|
|
|
|
if (!query.trim()) {
|
|
setSuggestions([]);
|
|
setError('');
|
|
setSelectedFromList(false);
|
|
onValidationChange?.(true);
|
|
return;
|
|
}
|
|
|
|
if (selectedFromList) {
|
|
onValidationChange?.(!error);
|
|
return;
|
|
}
|
|
|
|
onValidationChange?.(false);
|
|
|
|
const generation = ++fetchGenerationRef.current;
|
|
|
|
const handle = window.setTimeout(async () => {
|
|
const trimmedQuery = query.trim();
|
|
if (trimmedQuery.length < 3) {
|
|
if (generation === fetchGenerationRef.current) {
|
|
setSuggestions([]);
|
|
setIsLoading(false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
const result = await fetchGeoapifyAddressSuggestions(trimmedQuery, language);
|
|
if (generation !== fetchGenerationRef.current) return;
|
|
|
|
if (result.status === 'unavailable') {
|
|
deactivateAutocomplete();
|
|
return;
|
|
}
|
|
setSuggestions(result.features);
|
|
setIsOpen(result.features.length > 0);
|
|
} finally {
|
|
if (generation === fetchGenerationRef.current) {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
}, 600);
|
|
|
|
return () => window.clearTimeout(handle);
|
|
}, [query, language, selectedFromList, error, onValidationChange, autocompleteActive]);
|
|
|
|
useEffect(() => {
|
|
if (autocompleteActive !== true) return;
|
|
|
|
const handlePointerDown = (event: MouseEvent) => {
|
|
if (!rootRef.current?.contains(event.target as Node)) {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handlePointerDown);
|
|
return () => document.removeEventListener('mousedown', handlePointerDown);
|
|
}, [autocompleteActive]);
|
|
|
|
const applySuggestion = (feature: GeoapifyFeature) => {
|
|
if (!isDeliverableGeoapifyFeature(feature)) {
|
|
setError(labels.outsideGothenburg);
|
|
setSelectedFromList(false);
|
|
onValidationChange?.(false);
|
|
return;
|
|
}
|
|
|
|
const formatted = formatSuggestion(feature);
|
|
setQuery(formatted);
|
|
onChange(formatted);
|
|
setSelectedFromList(true);
|
|
setSuggestions([]);
|
|
setIsOpen(false);
|
|
setError('');
|
|
onValidationChange?.(true);
|
|
};
|
|
|
|
const handleInputChange = (next: string) => {
|
|
setQuery(next);
|
|
onChange(next);
|
|
if (autocompleteActive) {
|
|
setSelectedFromList(false);
|
|
setError('');
|
|
onValidationChange?.(!next.trim());
|
|
} else {
|
|
onValidationChange?.(true);
|
|
}
|
|
};
|
|
|
|
if (autocompleteActive === false) {
|
|
return (
|
|
<div className="relative">
|
|
<div className="relative">
|
|
<MapPin className="pointer-events-none absolute start-4 top-4 z-10 h-5 w-5 text-[#B38B4D]" />
|
|
<textarea
|
|
value={query}
|
|
onChange={(e) => handleInputChange(e.target.value)}
|
|
placeholder={labels.fallbackPlaceholder}
|
|
autoComplete="street-address"
|
|
rows={3}
|
|
className={`${inputClassName} min-h-[5.5rem] resize-y ps-12`}
|
|
/>
|
|
</div>
|
|
<p className="mt-1.5 text-xs text-[#6B665F]">{labels.fallbackHint}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div ref={rootRef} className="relative">
|
|
<div className="relative">
|
|
<MapPin className="pointer-events-none absolute start-4 top-4 z-10 h-5 w-5 text-[#B38B4D]" />
|
|
<input
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => handleInputChange(e.target.value)}
|
|
onFocus={() => {
|
|
if (suggestions.length > 0) setIsOpen(true);
|
|
}}
|
|
placeholder={labels.placeholder}
|
|
autoComplete="street-address"
|
|
role="combobox"
|
|
aria-expanded={isOpen}
|
|
aria-controls={listboxId}
|
|
className={`${inputClassName} ps-12`}
|
|
/>
|
|
</div>
|
|
|
|
<p className="mt-1.5 text-xs text-[#6B665F]">{labels.hint}</p>
|
|
|
|
{isLoading && (
|
|
<p className="mt-1 text-xs text-[#8A8478]">{labels.searching}</p>
|
|
)}
|
|
|
|
{isOpen && suggestions.length > 0 && (
|
|
<ul
|
|
id={listboxId}
|
|
role="listbox"
|
|
className="absolute z-20 mt-1 max-h-56 w-full overflow-auto rounded-xl border border-[#EDE6D9] bg-white py-1 shadow-lg"
|
|
>
|
|
{suggestions.map((feature, index) => {
|
|
const label = formatSuggestion(feature);
|
|
const secondary = [feature.properties.postcode, feature.properties.city]
|
|
.filter(Boolean)
|
|
.join(' · ');
|
|
|
|
return (
|
|
<li key={`${label}-${index}`} role="option">
|
|
<button
|
|
type="button"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onTouchStart={(e) => e.preventDefault()}
|
|
onClick={() => applySuggestion(feature)}
|
|
className="flex w-full flex-col px-4 py-3.5 text-left hover:bg-[#F8F5F0] active:bg-[#EDE6D9] min-h-[48px] touch-manipulation"
|
|
>
|
|
<span className="truncate text-sm font-medium text-[#101724]" title={label}>{label}</span>
|
|
{secondary && (
|
|
<span className="text-xs text-[#6B665F]">{secondary}</span>
|
|
)}
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
|
|
{!isLoading && query.trim().length >= 3 && suggestions.length === 0 && !selectedFromList && (
|
|
<p className="mt-1 text-xs text-[#8A8478]">{labels.noResults}</p>
|
|
)}
|
|
|
|
{error && (
|
|
<p className="mt-1 text-xs text-red-600" role="alert">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
{!selectedFromList && query.trim().length > 0 && !error && (
|
|
<p className="mt-1 text-xs text-[#8A8478]">{labels.selectSuggestion}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
} |