updates on the bookings page and updated ai and more thngs

This commit is contained in:
2026-09-11 11:20:38 +05:30
parent 1caa7b71a9
commit 49ee0c5076
40 changed files with 5796 additions and 359 deletions

View File

@@ -1,8 +1,13 @@
import PropTypes from 'prop-types';
import React, { useEffect, useRef, useState } from 'react';
import { Loader2, MapPin, X } from 'lucide-react';
import { Compass, Loader2, MapPin, Navigation, X } from 'lucide-react';
import { getAddressSuggestions, geocodeAddress as serviceGeocodeAddress, standardizePlace } from '@/lib/geocodingService';
const NOMINATIM_SEARCH_URL = 'https://nominatim.openstreetmap.org/search';
export const toPlace = (result) => standardizePlace(result);
export const geocodeAddress = async (address, options = {}) => {
return await serviceGeocodeAddress(address, options);
};
const useDebouncedCallback = (callback, delay) => {
const timeoutRef = useRef(null);
@@ -17,68 +22,14 @@ const useDebouncedCallback = (callback, delay) => {
);
};
const buildAddressComponents = (addr = {}) => {
const components = [];
const push = (longName, types) => {
if (longName) components.push({ long_name: longName, short_name: longName, types });
};
push(addr.house_number, ['street_number']);
push(addr.road || addr.pedestrian, ['route']);
push(addr.suburb || addr.neighbourhood || addr.quarter, ['sublocality_level_1', 'sublocality']);
push(addr.city_district || addr.county, ['administrative_area_level_3']);
push(addr.city || addr.town || addr.village, ['locality']);
push(addr.state, ['administrative_area_level_1']);
push(addr.country, ['country']);
push(addr.postcode, ['postal_code']);
return components;
};
export const toPlace = (result) => ({
formatted_address: result.display_name || '',
name: result.display_name?.split(',')[0] || '',
geometry: {
location: {
lat: () => parseFloat(result.lat),
lng: () => parseFloat(result.lon)
}
},
address_components: buildAddressComponents(result.address)
});
const buildParams = (query, bias) => {
const params = new URLSearchParams({ q: query, format: 'json', addressdetails: '1', limit: '5' });
if (bias?.lat && bias?.lng) {
const d = 0.5; // ~55km soft bias bounding box
params.set('viewbox', `${bias.lng - d},${bias.lat + d},${bias.lng + d},${bias.lat - d}`);
}
return params;
};
export async function geocodeAddress(address, { bias } = {}) {
if (!address || !address.trim()) return null;
try {
const params = buildParams(address.trim(), bias);
params.set('limit', '1');
const res = await fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, {
headers: { Accept: 'application/json', 'Accept-Language': 'en-GB,en;q=0.9' }
});
if (!res.ok) return null;
const results = await res.json();
if (!results?.length) return null;
return toPlace(results[0]);
} catch (err) {
console.error('geocodeAddress error:', err);
return null;
}
}
const AddressAutocomplete = ({
id,
label,
placeholder = 'Search address…',
placeholder = 'Search address or locality…',
value,
onChange,
onPlaceSelected,
onOpenMapPin,
bias,
fullWidth = true,
disabled = false,
@@ -90,14 +41,10 @@ const AddressAutocomplete = ({
const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
/* Which suggestion the arrow keys are on. -1 is "none highlighted", the state
the list opens in, so Enter never picks a result the operator hasn't moved
to. */
const [activeOption, setActiveOption] = useState(-1);
const wrapRef = useRef(null);
const activeRef = useRef(0);
// Set by pick(); consumed by the search effect. See the comment there.
const skipNextSearchRef = useRef(false);
const listId = `${id || 'address'}-suggestions`;
const optionId = (index) => `${listId}-option-${index}`;
@@ -112,11 +59,7 @@ const AddressAutocomplete = ({
const fetchPredictions = useDebouncedCallback((query) => {
const seq = ++activeRef.current;
const params = buildParams(query, bias);
fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, {
headers: { Accept: 'application/json', 'Accept-Language': 'en-GB,en;q=0.9' }
})
.then((res) => (res.ok ? res.json() : []))
getAddressSuggestions(query, { bias, limit: 6 })
.then((results) => {
if (seq !== activeRef.current) return;
setLoading(false);
@@ -128,19 +71,14 @@ const AddressAutocomplete = ({
setLoading(false);
setOptions([]);
});
}, 450);
}, 220);
useEffect(() => {
/* Picking a suggestion writes the resolved address back into inputValue,
which used to re-trigger this search: the list reopened over the form the
instant it was dismissed, and every selection cost a second Nominatim
request against a service that asks for one per second. The pick is not a
new query, so skip exactly one run. */
if (skipNextSearchRef.current) {
skipNextSearchRef.current = false;
return;
}
if (!inputValue || inputValue.length < 3) {
if (!inputValue || inputValue.length < 2) {
activeRef.current++;
setOptions([]);
setLoading(false);
@@ -151,8 +89,6 @@ const AddressAutocomplete = ({
fetchPredictions(inputValue);
}, [inputValue, biasKey]);
/* A fresh result set invalidates the highlight — otherwise index 2 of the old
list silently becomes index 2 of the new one. */
useEffect(() => {
setActiveOption(-1);
}, [options]);
@@ -168,23 +104,16 @@ const AddressAutocomplete = ({
}, []);
const pick = (option) => {
const formatted = option.display_name || '';
const formatted = option.formatted_address || option.name || '';
skipNextSearchRef.current = true;
activeRef.current++; // drop any in-flight response that would reopen the list
activeRef.current++;
setInputValue(formatted);
onChange?.(formatted);
onPlaceSelected?.(toPlace(option));
onPlaceSelected?.(option);
setIsOpen(false);
setActiveOption(-1);
};
/**
* Arrow keys, Enter and Escape over the suggestion list.
*
* Not a nicety: an order cannot be created from typed text — the submit gate
* requires the coordinates that only picking a suggestion supplies — so
* without this the whole create-order flow needed a mouse.
*/
const onKeyDown = (e) => {
const listOpen = isOpen && options.length > 0;
if (e.key === 'Escape') {
@@ -200,8 +129,6 @@ const AddressAutocomplete = ({
e.preventDefault();
setActiveOption((current) => (current <= 0 ? options.length - 1 : current - 1));
} else if (e.key === 'Enter' && activeOption >= 0) {
// Only swallow Enter when a suggestion is actually highlighted, so Enter
// on a bare query still submits the surrounding form.
e.preventDefault();
pick(options[activeOption]);
}
@@ -209,11 +136,25 @@ const AddressAutocomplete = ({
return (
<div ref={wrapRef} className={`relative ${className}`} style={{ width: fullWidth ? '100%' : undefined }}>
{label && (
<label htmlFor={id} className="block text-xs font-semibold text-slate-800 mb-1">
{label} {required && <span className="text-red-500">*</span>}
</label>
)}
<div className="flex items-center justify-between mb-1">
{label && (
<label htmlFor={id} className="block text-xs font-semibold text-slate-800">
{label} {required && <span className="text-red-500">*</span>}
</label>
)}
{onOpenMapPin && (
<button
type="button"
onClick={onOpenMapPin}
className="text-[11px] font-semibold text-blue-600 hover:text-blue-800 flex items-center gap-1 cursor-pointer transition-colors"
title="Choose point on map"
>
<Compass className="w-3 h-3" />
<span>Pin on Map</span>
</button>
)}
</div>
<div className="relative">
<input
id={id}
@@ -234,66 +175,94 @@ const AddressAutocomplete = ({
onKeyDown(e);
if (e.key === 'Escape') setIsOpen(false);
}}
className={`w-full h-9 pl-3 pr-8 bg-white border border-slate-200 rounded-lg text-xs font-medium text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black transition-colors disabled:opacity-50 disabled:bg-slate-50 ${inputClassName}`}
className={`w-full h-9 pl-3 pr-16 bg-white border border-slate-200 rounded-lg text-xs font-medium text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black transition-colors disabled:opacity-50 disabled:bg-slate-50 ${inputClassName}`}
/>
{loading ? (
<span className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
</span>
) : inputValue && !disabled ? (
<button
type="button"
onClick={() => {
setInputValue('');
onChange?.('');
setIsOpen(false);
}}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 p-0.5 rounded-full hover:bg-slate-100"
title="Clear address"
>
<X className="w-3 h-3" />
</button>
) : null}
{/* Action icons on the right */}
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
{loading ? (
<span className="text-slate-400 p-0.5">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
</span>
) : inputValue && !disabled ? (
<button
type="button"
onClick={() => {
setInputValue('');
onChange?.('');
setIsOpen(false);
}}
className="text-slate-400 hover:text-slate-600 p-1 rounded-full hover:bg-slate-100 cursor-pointer"
title="Clear address"
>
<X className="w-3 h-3" />
</button>
) : null}
{onOpenMapPin && !disabled && (
<button
type="button"
onClick={onOpenMapPin}
className="text-slate-400 hover:text-black p-1 rounded-md hover:bg-slate-100 transition-colors cursor-pointer"
title="Open map to pin exact location"
>
<Navigation className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{isOpen && options.length > 0 && (
<ul
id={listId}
className="absolute z-[9999] left-0 right-0 mt-1 bg-white border border-slate-200 rounded-lg shadow-xl max-h-60 overflow-y-auto divide-y divide-slate-100 animate-in fade-in zoom-in-95 duration-100"
className="absolute z-[9999] left-0 right-0 mt-1.5 bg-white border border-slate-200 rounded-xl shadow-2xl max-h-72 overflow-y-auto divide-y divide-slate-100 animate-in fade-in zoom-in-95 duration-100"
role="listbox"
aria-label="Address suggestions"
>
{options.map((option, index) => (
/* The option role sits on the <li>, which is what aria-activedescendant
points at; the button inside stays for the mouse. Without this the
popup announced as an empty listbox. */
<li
key={option.place_id}
id={optionId(index)}
role="option"
aria-selected={index === activeOption}
className={index === activeOption ? 'bg-slate-100' : undefined}
>
<button
type="button"
tabIndex={-1}
onMouseEnter={() => setActiveOption(index)}
onClick={() => pick(option)}
className="w-full text-left px-3 py-2.5 hover:bg-slate-50 flex items-start gap-2.5 transition-colors cursor-pointer group"
{options.map((option, index) => {
const isHighlight = index === activeOption;
return (
<li
key={`${option.latitude}-${option.longitude}-${index}`}
id={optionId(index)}
role="option"
aria-selected={isHighlight}
className={isHighlight ? 'bg-slate-50' : undefined}
>
<MapPin className="w-3.5 h-3.5 text-slate-400 group-hover:text-black mt-0.5 shrink-0 transition-colors" />
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-slate-800 truncate">
{option.display_name?.split(',')[0]}
<button
type="button"
tabIndex={-1}
onMouseEnter={() => setActiveOption(index)}
onClick={() => pick(option)}
className="w-full text-left px-3.5 py-2.5 hover:bg-slate-50/80 flex items-start gap-2.5 transition-colors cursor-pointer group"
>
<div className="w-6 h-6 rounded-md bg-slate-100 group-hover:bg-black text-slate-500 group-hover:text-white flex items-center justify-center shrink-0 mt-0.5 transition-colors">
<MapPin className="w-3.5 h-3.5" />
</div>
<div className="text-[11px] text-slate-500 line-clamp-1">
{option.display_name}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-slate-900 truncate">
{option.name || option.formatted_address.split(',')[0]}
</span>
{option.suburb && (
<span className="text-[10px] font-medium px-1.5 py-0.2 rounded bg-slate-100 text-slate-600 shrink-0">
{option.suburb}
</span>
)}
{option.postcode && (
<span className="text-[10px] font-mono text-slate-400 shrink-0">
{option.postcode}
</span>
)}
</div>
<div className="text-[11px] text-slate-500 line-clamp-1 mt-0.5">
{option.formatted_address}
</div>
</div>
</div>
</button>
</li>
))}
</button>
</li>
);
})}
</ul>
)}
</div>
@@ -307,6 +276,7 @@ AddressAutocomplete.propTypes = {
value: PropTypes.string,
onChange: PropTypes.func,
onPlaceSelected: PropTypes.func,
onOpenMapPin: PropTypes.func,
bias: PropTypes.object,
fullWidth: PropTypes.bool,
disabled: PropTypes.bool,