mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
validation of the street name with autocompletion using opensource api
This commit is contained in:
parent
873160e2a2
commit
042cbe8955
3 changed files with 266 additions and 81 deletions
216
src/components/layout/AddressAutocompleteComponent.tsx
Normal file
216
src/components/layout/AddressAutocompleteComponent.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
"use client";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Label } from "../ui/label";
|
||||
import { Input } from "../ui/input";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface NominatimResult {
|
||||
place_id: number;
|
||||
address: {
|
||||
road?: string;
|
||||
house_number?: string;
|
||||
postcode?: string;
|
||||
city?: string;
|
||||
town?: string;
|
||||
village?: string;
|
||||
municipality?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ParsedAddress {
|
||||
street: string;
|
||||
plz: string;
|
||||
place: string;
|
||||
}
|
||||
|
||||
interface AddressAutocompleteProps {
|
||||
streetValue: string;
|
||||
plzValue: string;
|
||||
placeValue: string;
|
||||
onStreetChange: (v: string) => void;
|
||||
onPlzChange: (v: string) => void;
|
||||
onPlaceChange: (v: string) => void;
|
||||
onSelect: (parsed: ParsedAddress) => void;
|
||||
streetError?: string;
|
||||
plzError?: string;
|
||||
placeError?: string;
|
||||
idPrefix?: string;
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 400;
|
||||
|
||||
const AddressAutocompleteComponent: React.FC<AddressAutocompleteProps> = ({
|
||||
streetValue,
|
||||
plzValue,
|
||||
placeValue,
|
||||
onStreetChange,
|
||||
onPlzChange,
|
||||
onPlaceChange,
|
||||
onSelect,
|
||||
streetError,
|
||||
plzError,
|
||||
placeError,
|
||||
idPrefix = "address",
|
||||
}) => {
|
||||
const [suggestions, setSuggestions] = useState<NominatimResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const streetRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (streetRef.current && !streetRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const fetchSuggestions = async (query: string) => {
|
||||
if (query.length < 3) {
|
||||
setSuggestions([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
format: "json",
|
||||
addressdetails: "1",
|
||||
limit: "6",
|
||||
countrycodes: "de",
|
||||
});
|
||||
const res = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, {
|
||||
headers: { "Accept-Language": "de", "User-Agent": "s3-kuma-app/1.0" },
|
||||
});
|
||||
const data: NominatimResult[] = await res.json();
|
||||
const filtered = data.filter((r) => r.address.road);
|
||||
setSuggestions(filtered);
|
||||
setOpen(filtered.length > 0);
|
||||
} catch {
|
||||
setSuggestions([]);
|
||||
setOpen(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStreetChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
onStreetChange(val);
|
||||
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = setTimeout(() => fetchSuggestions(val), DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const handleSelect = (result: NominatimResult) => {
|
||||
const addr = result.address;
|
||||
const street =
|
||||
addr.road && addr.house_number ? `${addr.road} ${addr.house_number}` : (addr.road ?? "");
|
||||
const plz = addr.postcode ?? "";
|
||||
const place = addr.city ?? addr.town ?? addr.village ?? addr.municipality ?? "";
|
||||
|
||||
onStreetChange(street);
|
||||
onPlzChange(plz);
|
||||
onPlaceChange(place);
|
||||
onSelect({ street, plz, place });
|
||||
setOpen(false);
|
||||
setSuggestions([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Straße — with autocomplete dropdown */}
|
||||
<div ref={streetRef} className="relative">
|
||||
<Label htmlFor={`${idPrefix}-street`} className="p-1 mb-2 text-blue text-lg font-semibold">
|
||||
Straße und Hausnummer
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={`${idPrefix}-street`}
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
value={streetValue}
|
||||
onChange={handleStreetChange}
|
||||
onFocus={() => {
|
||||
if (suggestions.length > 0) setOpen(true);
|
||||
}}
|
||||
className="h-[50px] border-blue rounded-xl w-full"
|
||||
/>
|
||||
{loading && (
|
||||
<Loader2
|
||||
size={18}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-blue animate-spin"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{streetError && <p className="text-red-500 text-sm mt-1">{streetError}</p>}
|
||||
|
||||
{open && suggestions.length > 0 && (
|
||||
<ul className="absolute z-50 mt-1 w-full bg-white border border-blue rounded-xl shadow-lg max-h-60 overflow-y-auto">
|
||||
{suggestions.map((s) => {
|
||||
const addr = s.address;
|
||||
const street =
|
||||
addr.road && addr.house_number
|
||||
? `${addr.road} ${addr.house_number}`
|
||||
: (addr.road ?? "");
|
||||
const plz = addr.postcode ?? "";
|
||||
const place = addr.city ?? addr.town ?? addr.village ?? addr.municipality ?? "";
|
||||
return (
|
||||
<li
|
||||
key={s.place_id}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelect(s);
|
||||
}}
|
||||
className="px-4 py-2 cursor-pointer hover:bg-blue/10 transition-colors"
|
||||
>
|
||||
<span className="block font-medium text-sm">{street}</span>
|
||||
<span className="block text-xs text-gray-500">
|
||||
{plz} {place}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PLZ */}
|
||||
<div>
|
||||
<Label htmlFor={`${idPrefix}-plz`} className="p-1 mb-2 text-blue text-lg font-semibold">
|
||||
PLZ
|
||||
</Label>
|
||||
<Input
|
||||
id={`${idPrefix}-plz`}
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
value={plzValue}
|
||||
onChange={(e) => onPlzChange(e.target.value)}
|
||||
className="h-[50px] border-blue rounded-xl w-full"
|
||||
/>
|
||||
{plzError && <p className="text-red-500 text-sm mt-1">{plzError}</p>}
|
||||
</div>
|
||||
|
||||
{/* Wohnort */}
|
||||
<div>
|
||||
<Label htmlFor={`${idPrefix}-place`} className="p-1 mb-2 text-blue text-lg font-semibold">
|
||||
Wohnort
|
||||
</Label>
|
||||
<Input
|
||||
id={`${idPrefix}-place`}
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
value={placeValue}
|
||||
onChange={(e) => onPlaceChange(e.target.value)}
|
||||
className="h-[50px] border-blue rounded-xl w-full"
|
||||
/>
|
||||
{placeError && <p className="text-red-500 text-sm mt-1">{placeError}</p>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddressAutocompleteComponent;
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
import React, { useState } from "react";
|
||||
import AuthSidebarComponent from "../components/AuthSidebarComponent";
|
||||
import InputComponent from "@/src/components/layout/InputComponent";
|
||||
import AddressAutocompleteComponent from "@/src/components/layout/AddressAutocompleteComponent";
|
||||
import PhoneInputComponent from "@/src/components/layout/PhoneInputComponent";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { useAuthFormStore } from "../../store/authFormState";
|
||||
|
|
@ -182,33 +183,22 @@ const RegisterAdmin = () => {
|
|||
|
||||
{step === 2 && (
|
||||
<form onSubmit={handleNext} className="space-y-5">
|
||||
<InputComponent
|
||||
isLabel
|
||||
label="PLZ"
|
||||
type="text"
|
||||
id="register-admin-plz"
|
||||
value={formData.plz}
|
||||
onChange={(e) => handleChange("plz", e.target.value)}
|
||||
error={errors.plz}
|
||||
/>
|
||||
|
||||
<InputComponent
|
||||
isLabel
|
||||
label="Wohnort"
|
||||
type="text"
|
||||
id="register-admin-place"
|
||||
value={formData.place}
|
||||
onChange={(e) => handleChange("place", e.target.value)}
|
||||
error={errors.place}
|
||||
/>
|
||||
<InputComponent
|
||||
isLabel
|
||||
label="Straße und Hausnummer"
|
||||
type="text"
|
||||
id="register-admin-street"
|
||||
value={formData.street}
|
||||
onChange={(e) => handleChange("street", e.target.value)}
|
||||
error={errors.street}
|
||||
<AddressAutocompleteComponent
|
||||
idPrefix="register-admin"
|
||||
streetValue={formData.street}
|
||||
plzValue={formData.plz}
|
||||
placeValue={formData.place}
|
||||
onStreetChange={(v) => handleChange("street", v)}
|
||||
onPlzChange={(v) => handleChange("plz", v)}
|
||||
onPlaceChange={(v) => handleChange("place", v)}
|
||||
onSelect={({ street, plz, place }) => {
|
||||
handleChange("street", street);
|
||||
handleChange("plz", plz);
|
||||
handleChange("place", place);
|
||||
}}
|
||||
streetError={errors.street}
|
||||
plzError={errors.plz}
|
||||
placeError={errors.place}
|
||||
/>
|
||||
|
||||
<Button className="w-full mt-6" type="submit" disabled={loading}>
|
||||
|
|
@ -228,33 +218,22 @@ const RegisterAdmin = () => {
|
|||
onChange={(e) => handleChange("companyName", e.target.value)}
|
||||
error={errors.companyName}
|
||||
/>
|
||||
<InputComponent
|
||||
isLabel
|
||||
label="Unternehmenssitz"
|
||||
type="text"
|
||||
id="register-admin-companyPlace"
|
||||
value={formData.companyPlace}
|
||||
onChange={(e) => handleChange("companyPlace", e.target.value)}
|
||||
error={errors.companyPlace}
|
||||
/>
|
||||
|
||||
<InputComponent
|
||||
isLabel
|
||||
label="PLZ"
|
||||
type="text"
|
||||
id="register-admin-companyPLZ"
|
||||
value={formData.companyPLZ}
|
||||
onChange={(e) => handleChange("companyPLZ", e.target.value)}
|
||||
error={errors.companyPLZ}
|
||||
/>
|
||||
<InputComponent
|
||||
isLabel
|
||||
label="Straße und Hausnummer"
|
||||
type="text"
|
||||
id="register-admin-companyStreet"
|
||||
value={formData.companyStreet}
|
||||
onChange={(e) => handleChange("companyStreet", e.target.value)}
|
||||
error={errors.companyStreet}
|
||||
<AddressAutocompleteComponent
|
||||
idPrefix="register-admin-company"
|
||||
streetValue={formData.companyStreet}
|
||||
plzValue={formData.companyPLZ}
|
||||
placeValue={formData.companyPlace}
|
||||
onStreetChange={(v) => handleChange("companyStreet", v)}
|
||||
onPlzChange={(v) => handleChange("companyPLZ", v)}
|
||||
onPlaceChange={(v) => handleChange("companyPlace", v)}
|
||||
onSelect={({ street, plz, place }) => {
|
||||
handleChange("companyStreet", street);
|
||||
handleChange("companyPLZ", plz);
|
||||
handleChange("companyPlace", place);
|
||||
}}
|
||||
streetError={errors.companyStreet}
|
||||
plzError={errors.companyPLZ}
|
||||
placeError={errors.companyPlace}
|
||||
/>
|
||||
<InputComponent
|
||||
isLabel
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { useState } from "react";
|
||||
import AuthSidebarComponent from "../components/AuthSidebarComponent";
|
||||
import InputComponent from "@/src/components/layout/InputComponent";
|
||||
import AddressAutocompleteComponent from "@/src/components/layout/AddressAutocompleteComponent";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import AuthButton from "../components/AuthButton";
|
||||
import { FaGoogle, FaFacebook } from "react-icons/fa";
|
||||
|
|
@ -148,33 +149,22 @@ const RegisterUserView = () => {
|
|||
onChange={(e) => handleChange("birthday", e.target.value)}
|
||||
error={errors.birthday}
|
||||
/>
|
||||
<InputComponent
|
||||
isLabel
|
||||
label="PLZ"
|
||||
type="text"
|
||||
id="register-user-plz"
|
||||
value={formData.plz}
|
||||
onChange={(e) => handleChange("plz", e.target.value)}
|
||||
error={errors.plz}
|
||||
/>
|
||||
|
||||
<InputComponent
|
||||
isLabel
|
||||
label="Wohnort"
|
||||
type="text"
|
||||
id="register-user-place"
|
||||
value={formData.place}
|
||||
onChange={(e) => handleChange("place", e.target.value)}
|
||||
error={errors.place}
|
||||
/>
|
||||
<InputComponent
|
||||
isLabel
|
||||
label="Straße und Hausnummer"
|
||||
type="text"
|
||||
id="register-user-street"
|
||||
value={formData.street}
|
||||
onChange={(e) => handleChange("street", e.target.value)}
|
||||
error={errors.street}
|
||||
<AddressAutocompleteComponent
|
||||
idPrefix="register-user"
|
||||
streetValue={formData.street}
|
||||
plzValue={formData.plz}
|
||||
placeValue={formData.place}
|
||||
onStreetChange={(v) => handleChange("street", v)}
|
||||
onPlzChange={(v) => handleChange("plz", v)}
|
||||
onPlaceChange={(v) => handleChange("place", v)}
|
||||
onSelect={({ street, plz, place }) => {
|
||||
handleChange("street", street);
|
||||
handleChange("plz", plz);
|
||||
handleChange("place", place);
|
||||
}}
|
||||
streetError={errors.street}
|
||||
plzError={errors.plz}
|
||||
placeError={errors.place}
|
||||
/>
|
||||
|
||||
<DropdownComponent
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue