Files
shahikitchen-prod/components/DeliveryAddressAutocomplete.tsx

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>
);
}