feat(bookings): enhance operation request flow and add location selection for first/last mile

This commit is contained in:
Marshal
2026-06-24 00:35:37 +00:00
parent 8ef7641048
commit 5be181aba8
12 changed files with 444 additions and 120 deletions

View File

@@ -41,19 +41,27 @@ const PINNED_ZOOM = 14;
const NOMINATIM_URL = "https://nominatim.openstreetmap.org/search";
const NOMINATIM_REVERSE_URL = "https://nominatim.openstreetmap.org/reverse";
const SEARCH_DEBOUNCE_MS = 400;
// Search only fires once the user pauses typing for this long. Slightly longer
// than a keystroke burst so we make one request per pause, not per character —
// and it keeps us within Nominatim's 1 req/s fair-use limit.
const SEARCH_DEBOUNCE_MS = 450;
const MIN_QUERY_LEN = 2;
// Bias geocoding toward the EDR corridor countries so local addresses surface
// first (Nominatim still returns global matches if nothing local fits).
const SEARCH_COUNTRYCODES = "et,dj";
/** Forward-geocode a free-text query to candidate places (free Nominatim API). */
async function searchPlaces(query: string, signal: AbortSignal): Promise<GeocodeResult[]> {
const params = new URLSearchParams({
q: query,
format: "json",
format: "jsonv2",
addressdetails: "0",
limit: "6",
limit: "8",
countrycodes: SEARCH_COUNTRYCODES,
});
const res = await fetch(`${NOMINATIM_URL}?${params}`, {
signal,
headers: { Accept: "application/json" },
headers: { Accept: "application/json", "Accept-Language": "en" },
});
if (!res.ok) return [];
const data = (await res.json()) as Array<{
@@ -136,15 +144,20 @@ export function LocationPicker({
const hasPin = value.lat != null && value.lng != null;
// Debounced forward search as the user types.
// Debounced forward search — fires only after the user stops typing
// (SEARCH_DEBOUNCE_MS of silence), so we make one request per pause rather
// than one per keystroke. The dropdown is kept open the whole time so the
// user sees the "Searching…" state and then the live results for what they
// typed.
useEffect(() => {
const q = query.trim();
if (q.length < 3) {
if (q.length < MIN_QUERY_LEN) {
setResults([]);
setSearching(false);
return;
}
setSearching(true);
combobox.openDropdown();
const handle = setTimeout(async () => {
abortRef.current?.abort();
const controller = new AbortController();
@@ -152,14 +165,16 @@ export function LocationPicker({
try {
const found = await searchPlaces(q, controller.signal);
setResults(found);
} catch {
setResults([]);
combobox.openDropdown();
} catch (err) {
// Ignore aborts (a newer keystroke superseded this request).
if ((err as Error)?.name !== "AbortError") setResults([]);
} finally {
setSearching(false);
}
}, SEARCH_DEBOUNCE_MS);
return () => clearTimeout(handle);
}, [query]);
}, [query, combobox]);
const selectResult = useCallback(
(r: GeocodeResult) => {
@@ -210,18 +225,20 @@ export function LocationPicker({
setQuery(e.currentTarget.value);
combobox.openDropdown();
}}
onFocus={() => results.length > 0 && combobox.openDropdown()}
onFocus={() => {
if (query.trim().length >= MIN_QUERY_LEN) combobox.openDropdown();
}}
/>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
<Combobox.Options mah={240} style={{ overflowY: "auto" }}>
{searching ? (
<Combobox.Empty>Searching</Combobox.Empty>
<Combobox.Empty>Searching {query.trim()}</Combobox.Empty>
) : results.length === 0 ? (
<Combobox.Empty>
{query.trim().length < 3
? "Type at least 3 characters"
{query.trim().length < MIN_QUERY_LEN
? `Type at least ${MIN_QUERY_LEN} characters`
: "No matching places"}
</Combobox.Empty>
) : (

View File

@@ -1,5 +1,5 @@
import { Box, Text } from "@mantine/core";
import { Banknote, DollarSign } from "lucide-react";
import { Box, Group, Text } from "@mantine/core";
import { Banknote, Check, DollarSign } from "lucide-react";
import { Controller, type Control } from "react-hook-form";
import {
PAYMENT_CURRENCY_OPTIONS,
@@ -7,14 +7,14 @@ import {
type BookingFormValues,
type PaymentCurrency,
} from "./schema";
import { OptionCard, OptionFieldError, StepLabel } from "./shared";
import { OptionFieldError, StepLabel } from "./shared";
const CURRENCY_ICONS: Record<
PaymentCurrency,
{ icon: typeof DollarSign; bg: string; color: string }
{ icon: typeof DollarSign; color: string }
> = {
USD: { icon: DollarSign, bg: "#EEF0FB", color: "#4F46E5" },
ETB: { icon: Banknote, bg: "#ECF6F1", color: "#0A6F4D" },
USD: { icon: DollarSign, color: "#4F46E5" },
ETB: { icon: Banknote, color: "#0A6F4D" },
};
export function PaymentCurrencyField({
@@ -33,23 +33,75 @@ export function PaymentCurrencyField({
control={control}
render={({ field, fieldState }) => (
<div>
<div className="grid gap-4 md:grid-cols-2">
{/* Compact segmented pill selector — lighter than full option cards. */}
<Group
gap={6}
wrap="nowrap"
p={4}
style={{
borderRadius: 12,
background: "#F1F4F7",
border: "1px solid #E6ECF2",
}}
>
{PAYMENT_CURRENCY_OPTIONS.map((option) => {
const Icon = CURRENCY_ICONS[option.value].icon;
const selected = field.value === option.value;
return (
<OptionCard
<button
key={option.value}
selected={field.value === option.value}
type="button"
onClick={() => field.onChange(option.value)}
icon={<Icon className="h-5 w-5" />}
iconBg={CURRENCY_ICONS[option.value].bg}
iconColor={CURRENCY_ICONS[option.value].color}
title={option.label}
description={option.description}
/>
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
padding: "10px 14px",
borderRadius: 9,
cursor: "pointer",
border: "none",
background: selected ? "#fff" : "transparent",
boxShadow: selected
? "0 1px 3px rgba(16,32,47,0.10)"
: "none",
transition: "all 150ms ease",
}}
>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
color: selected
? CURRENCY_ICONS[option.value].color
: "#94A3B8",
}}
>
<Icon className="h-4 w-4" />
</Box>
<Text
fz={14}
fw={selected ? 700 : 600}
c={selected ? "#10202F" : "#64748B"}
>
{option.label}
</Text>
{selected && (
<Check size={15} color={CURRENCY_ICONS[option.value].color} />
)}
</button>
);
})}
</div>
</Group>
{/* Description for the active currency, kept subtle. */}
<Text fz={11.5} c="#6B7C8E" mt={8}>
{
PAYMENT_CURRENCY_OPTIONS.find((o) => o.value === field.value)
?.description
}
</Text>
<OptionFieldError error={fieldState.error} />
</div>
)}

View File

@@ -13,6 +13,7 @@ import {
StepLabel,
} from "./shared";
import { PaymentCurrencyField } from "./payment-currency-field";
import { LocationPicker } from "./LocationPicker";
import type { Freight } from "@edr/types";
@@ -45,7 +46,7 @@ export function Step2ServiceType({
useEffect(() => {
form.setValue(
"firstMile",
{ enabled: false, pickUpAddress: "" },
{ enabled: false, pickUpAddress: "", lat: null, lng: null },
{ shouldValidate: true },
);
}, [includesFirstMile]);
@@ -53,7 +54,7 @@ export function Step2ServiceType({
useEffect(() => {
form.setValue(
"lastMile",
{ enabled: false, deliveryAddress: "" },
{ enabled: false, deliveryAddress: "", lat: null, lng: null },
{ shouldValidate: true },
);
}, [includesLastMile]);
@@ -133,10 +134,39 @@ export function Step2ServiceType({
}}
>
{firstMileEnabled && (
<Text fz={12} c="#6B7C8E" mt="sm">
Youll pick the exact pick-up location on the map in the
Route step.
</Text>
<Box mt="md">
<Controller
name="firstMile"
control={form.control}
render={({ field: mf, fieldState }) => (
<LocationPicker
label="Pick-up location"
placeholder="Search the pick-up address…"
error={
(
fieldState.error as {
pickUpAddress?: { message?: string };
}
)?.pickUpAddress?.message
}
value={{
address: mf.value?.pickUpAddress ?? "",
lat: mf.value?.lat ?? null,
lng: mf.value?.lng ?? null,
}}
onChange={(loc) =>
mf.onChange({
...mf.value,
enabled: true,
pickUpAddress: loc.address,
lat: loc.lat,
lng: loc.lng,
})
}
/>
)}
/>
</Box>
)}
</ServiceToggle>
)}
@@ -170,10 +200,39 @@ export function Step2ServiceType({
}}
>
{lastMileEnabled && (
<Text fz={12} c="#6B7C8E" mt="sm">
Youll pick the exact delivery location on the map in the
Route step.
</Text>
<Box mt="md">
<Controller
name="lastMile"
control={form.control}
render={({ field: mf, fieldState }) => (
<LocationPicker
label="Delivery location"
placeholder="Search the delivery address…"
error={
(
fieldState.error as {
deliveryAddress?: { message?: string };
}
)?.deliveryAddress?.message
}
value={{
address: mf.value?.deliveryAddress ?? "",
lat: mf.value?.lat ?? null,
lng: mf.value?.lng ?? null,
}}
onChange={(loc) =>
mf.onChange({
...mf.value,
enabled: true,
deliveryAddress: loc.address,
lat: loc.lat,
lng: loc.lng,
})
}
/>
)}
/>
</Box>
)}
</ServiceToggle>
)}

View File

@@ -300,78 +300,6 @@ export function Step4Route({
</Box>
)}
{(showFirstMile || showLastMile) && (
<Box mt={20}>
<StepLabel>Trucking locations</StepLabel>
<Text fz={12} c="#6B7C8E" mb={12}>
Search for an address or click the map to drop a pin for your
door-to-port and port-to-door trucking.
</Text>
<Stack gap={18}>
{showFirstMile && (
<Controller
name="firstMile"
control={form.control}
render={({ field, fieldState }) => (
<LocationPicker
label="First mile — pick-up location"
placeholder="Search the pick-up address…"
error={
(fieldState.error as { pickUpAddress?: { message?: string } })
?.pickUpAddress?.message
}
value={{
address: field.value?.pickUpAddress ?? "",
lat: field.value?.lat ?? null,
lng: field.value?.lng ?? null,
}}
onChange={(loc) =>
field.onChange({
...field.value,
enabled: true,
pickUpAddress: loc.address,
lat: loc.lat,
lng: loc.lng,
})
}
/>
)}
/>
)}
{showLastMile && (
<Controller
name="lastMile"
control={form.control}
render={({ field, fieldState }) => (
<LocationPicker
label="Last mile — delivery location"
placeholder="Search the delivery address…"
error={
(fieldState.error as { deliveryAddress?: { message?: string } })
?.deliveryAddress?.message
}
value={{
address: field.value?.deliveryAddress ?? "",
lat: field.value?.lat ?? null,
lng: field.value?.lng ?? null,
}}
onChange={(loc) =>
field.onChange({
...field.value,
enabled: true,
deliveryAddress: loc.address,
lat: loc.lat,
lng: loc.lng,
})
}
/>
)}
/>
)}
</Stack>
</Box>
)}
<Divider my={22} color="#EEF2F6" />
<StepLabel>Cargo handling</StepLabel>