add list of cities for car pick drop off locations

This commit is contained in:
root
2026-06-29 20:32:08 -04:00
parent 85d1aad956
commit e1e2f55c93
6 changed files with 10757 additions and 54 deletions
@@ -9,6 +9,8 @@ import { apiFetch } from '@/lib/api'
import VehicleCalendar from '@/components/VehicleCalendar'
import VehiclePricingManager from '@/components/VehiclePricingManager'
import { useDashboardI18n } from '@/components/I18nProvider'
import LocationDropdownField from '@/components/LocationDropdownField'
import { getMoroccanCityOptions } from '@/lib/moroccanCities'
interface VehicleDetail {
id: string
@@ -287,6 +289,7 @@ export default function FleetDetailPage() {
const { dict, language } = useDashboardI18n()
const vd = dict.vehicleDetail
const fl = dict.fleet
const cityOptions = getMoroccanCityOptions(language)
const [vehicle, setVehicle] = useState<VehicleDetail | null>(null)
const [error, setError] = useState<string | null>(null)
@@ -754,17 +757,15 @@ export default function FleetDetailPage() {
</div>
<div className="space-y-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{fl.pickupLocations}</label>
<textarea
className="input-field resize-none"
rows={2}
<LocationDropdownField
label={fl.pickupLocations}
placeholder={fl.pickupLocationsPlaceholder}
value={form.pickupLocations}
onChange={(e) => setForm({ ...form, pickupLocations: e.target.value })}
hint={fl.locationHint}
addCustomLabel={fl.addCustomLocation}
options={cityOptions}
value={parseLocationInput(form.pickupLocations)}
onChange={(locations) => setForm({ ...form, pickupLocations: formatLocationInput(locations) })}
/>
<p className="mt-1 text-xs text-slate-400">{fl.locationHint}</p>
</div>
<label className="flex items-center gap-3 rounded-lg border border-slate-200 bg-white px-3 py-3 text-sm font-medium text-slate-700">
<input
type="checkbox"
@@ -779,17 +780,15 @@ export default function FleetDetailPage() {
<span>{fl.allowDifferentDropoff}</span>
</label>
{form.allowDifferentDropoff && (
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{fl.dropoffLocations}</label>
<textarea
className="input-field resize-none"
rows={2}
<LocationDropdownField
label={fl.dropoffLocations}
placeholder={fl.dropoffLocationsPlaceholder}
value={form.dropoffLocations}
onChange={(e) => setForm({ ...form, dropoffLocations: e.target.value })}
hint={fl.locationHint}
addCustomLabel={fl.addCustomLocation}
options={cityOptions}
value={parseLocationInput(form.dropoffLocations)}
onChange={(locations) => setForm({ ...form, dropoffLocations: formatLocationInput(locations) })}
/>
<p className="mt-1 text-xs text-slate-400">{fl.locationHint}</p>
</div>
)}
</div>
@@ -8,6 +8,8 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { formatCurrency } from '@rentaldrivego/types'
import { useDashboardI18n } from '@/components/I18nProvider'
import LocationDropdownField from '@/components/LocationDropdownField'
import { getMoroccanCityOptions } from '@/lib/moroccanCities'
interface Vehicle {
id: string
@@ -214,9 +216,14 @@ function parseLocationInput(value: string) {
.filter(Boolean)
}
function formatLocationInput(values: string[]) {
return values.join(', ')
}
function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
const { dict } = useDashboardI18n()
const { dict, language } = useDashboardI18n()
const f = dict.fleet
const cityOptions = getMoroccanCityOptions(language)
const [form, setForm] = useState({
make: '', model: '', year: new Date().getFullYear(), category: 'ECONOMY',
licensePlate: '', vin: '', color: '', seats: 5, transmission: 'MANUAL',
@@ -451,17 +458,15 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
</div>
<div className="space-y-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.pickupLocations}</label>
<textarea
className="input-field resize-none"
rows={2}
<LocationDropdownField
label={f.pickupLocations}
placeholder={f.pickupLocationsPlaceholder}
value={form.pickupLocations}
onChange={(e) => setForm({ ...form, pickupLocations: e.target.value })}
hint={f.locationHint}
addCustomLabel={f.addCustomLocation}
options={cityOptions}
value={parseLocationInput(form.pickupLocations)}
onChange={(locations) => setForm({ ...form, pickupLocations: formatLocationInput(locations) })}
/>
<p className="mt-1 text-xs text-slate-400">{f.locationHint}</p>
</div>
<label className="flex items-center gap-3 rounded-lg border border-slate-200 bg-white px-3 py-3 text-sm font-medium text-slate-700">
<input
type="checkbox"
@@ -476,17 +481,15 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
<span>{f.allowDifferentDropoff}</span>
</label>
{form.allowDifferentDropoff && (
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.dropoffLocations}</label>
<textarea
className="input-field resize-none"
rows={2}
<LocationDropdownField
label={f.dropoffLocations}
placeholder={f.dropoffLocationsPlaceholder}
value={form.dropoffLocations}
onChange={(e) => setForm({ ...form, dropoffLocations: e.target.value })}
hint={f.locationHint}
addCustomLabel={f.addCustomLocation}
options={cityOptions}
value={parseLocationInput(form.dropoffLocations)}
onChange={(locations) => setForm({ ...form, dropoffLocations: formatLocationInput(locations) })}
/>
<p className="mt-1 text-xs text-slate-400">{f.locationHint}</p>
</div>
)}
</div>
+13 -9
View File
@@ -55,6 +55,7 @@ type FleetDict = {
dropoffLocations: string
dropoffLocationsPlaceholder: string
locationHint: string
addCustomLocation: (location: string) => string
allowDifferentDropoff: string
dropoffLocationsRequired: string
sameDropoffOnly: string
@@ -406,10 +407,11 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
automatic: 'Automatic',
fuelType: 'Fuel Type',
pickupLocations: 'Pick-up locations',
pickupLocationsPlaceholder: 'e.g. Casablanca Airport, Casablanca Downtown',
pickupLocationsPlaceholder: 'Type a pick-up city',
dropoffLocations: 'Drop-off locations',
dropoffLocationsPlaceholder: 'e.g. Rabat Downtown, Marrakech Center',
locationHint: 'Separate multiple locations with commas.',
dropoffLocationsPlaceholder: 'Type a drop-off city',
locationHint: 'Start typing and select one or more cities from the suggestions. Press Enter to add a new city.',
addCustomLocation: (location) => `Add "${location}"`,
allowDifferentDropoff: 'Allow different drop-off location',
dropoffLocationsRequired: 'Please add at least one drop-off location when different drop-off is enabled.',
sameDropoffOnly: 'Same drop-off only',
@@ -763,10 +765,11 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
automatic: 'Automatique',
fuelType: 'Carburant',
pickupLocations: 'Lieux de départ',
pickupLocationsPlaceholder: 'ex. Aéroport de Casablanca, Centre-ville de Casablanca',
pickupLocationsPlaceholder: 'Tapez une ville de départ',
dropoffLocations: 'Lieux de retour',
dropoffLocationsPlaceholder: 'ex. Centre-ville de Rabat, Centre de Marrakech',
locationHint: 'Séparez plusieurs lieux par des virgules.',
dropoffLocationsPlaceholder: 'Tapez une ville de retour',
locationHint: 'Commencez à taper puis sélectionnez une ou plusieurs villes suggérées. Appuyez sur Entrée pour ajouter une nouvelle ville.',
addCustomLocation: (location) => `Ajouter « ${location} »`,
allowDifferentDropoff: 'Autoriser un lieu de retour différent',
dropoffLocationsRequired: 'Ajoutez au moins un lieu de retour lorsque le retour différent est activé.',
sameDropoffOnly: 'Retour au même lieu uniquement',
@@ -1120,10 +1123,11 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
automatic: 'أوتوماتيكي',
fuelType: 'نوع الوقود',
pickupLocations: 'مواقع الاستلام',
pickupLocationsPlaceholder: 'مثال: مطار الدار البيضاء، وسط الدار البيضاء',
pickupLocationsPlaceholder: 'اكتب مدينة الاستلام',
dropoffLocations: 'مواقع الإرجاع',
dropoffLocationsPlaceholder: 'مثال: وسط الرباط، مركز مراكش',
locationHint: 'افصل بين المواقع المتعددة بفواصل.',
dropoffLocationsPlaceholder: 'اكتب مدينة الإرجاع',
locationHint: 'ابدأ بالكتابة ثم اختر مدينة واحدة أو أكثر من الاقتراحات. اضغط Enter لإضافة مدينة جديدة.',
addCustomLocation: (location) => `إضافة "${location}"`,
allowDifferentDropoff: 'السماح بموقع إرجاع مختلف',
dropoffLocationsRequired: 'أضف موقع إرجاع واحداً على الأقل عند تفعيل الإرجاع المختلف.',
sameDropoffOnly: 'الإرجاع في نفس الموقع فقط',
@@ -0,0 +1,215 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { X } from 'lucide-react'
interface LocationDropdownFieldProps {
label: string
placeholder: string
hint: string
addCustomLabel: (location: string) => string
options: string[]
value: string[]
onChange: (locations: string[]) => void
}
const MAX_SUGGESTIONS = 8
const CUSTOM_LOCATION_OPTIONS_KEY = 'dashboard-custom-vehicle-location-options'
const CUSTOM_LOCATION_OPTIONS_CHANGED = 'dashboard-custom-vehicle-location-options-changed'
function normalizeLocationLabel(value: string) {
return value.trim().replace(/\s+/g, ' ')
}
function normalizeSearchValue(value: string) {
return normalizeLocationLabel(value)
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLocaleLowerCase()
}
function readCustomLocationOptions() {
if (typeof window === 'undefined') return []
try {
const parsed = JSON.parse(window.localStorage.getItem(CUSTOM_LOCATION_OPTIONS_KEY) ?? '[]')
if (!Array.isArray(parsed)) return []
return parsed
.map((entry) => (typeof entry === 'string' ? normalizeLocationLabel(entry) : ''))
.filter(Boolean)
} catch {
return []
}
}
function writeCustomLocationOptions(options: string[]) {
if (typeof window === 'undefined') return
window.localStorage.setItem(CUSTOM_LOCATION_OPTIONS_KEY, JSON.stringify(options))
window.dispatchEvent(new Event(CUSTOM_LOCATION_OPTIONS_CHANGED))
}
export default function LocationDropdownField({
label,
placeholder,
hint,
addCustomLabel,
options,
value,
onChange,
}: LocationDropdownFieldProps) {
const [query, setQuery] = useState('')
const [focused, setFocused] = useState(false)
const [customOptions, setCustomOptions] = useState<string[]>([])
const selected = value.map((location) => location.trim()).filter(Boolean)
const selectedSet = new Set(selected.map(normalizeSearchValue))
const allOptions = useMemo(() => {
const seen = new Set<string>()
return [...customOptions, ...options]
.map(normalizeLocationLabel)
.filter(Boolean)
.filter((option) => {
const key = normalizeSearchValue(option)
if (seen.has(key)) return false
seen.add(key)
return true
})
}, [customOptions, options])
const normalizedQuery = normalizeSearchValue(query)
const suggestions = useMemo(() => {
if (!normalizedQuery) return []
return allOptions
.filter((option) => !selectedSet.has(normalizeSearchValue(option)))
.filter((option) => normalizeSearchValue(option).startsWith(normalizedQuery))
.slice(0, MAX_SUGGESTIONS)
}, [allOptions, normalizedQuery, selectedSet])
const normalizedOptionSet = useMemo(() => {
return new Set(allOptions.map(normalizeSearchValue))
}, [allOptions])
const typedLocation = normalizeLocationLabel(query)
const canAddTypedLocation = Boolean(
normalizedQuery &&
!selectedSet.has(normalizedQuery) &&
!normalizedOptionSet.has(normalizedQuery),
)
useEffect(() => {
const syncCustomOptions = () => setCustomOptions(readCustomLocationOptions())
syncCustomOptions()
window.addEventListener('storage', syncCustomOptions)
window.addEventListener(CUSTOM_LOCATION_OPTIONS_CHANGED, syncCustomOptions)
return () => {
window.removeEventListener('storage', syncCustomOptions)
window.removeEventListener(CUSTOM_LOCATION_OPTIONS_CHANGED, syncCustomOptions)
}
}, [])
const saveCustomLocation = (location: string) => {
const normalizedLocation = normalizeLocationLabel(location)
if (!normalizedLocation) return
const existingOptions = readCustomLocationOptions()
const existingKeys = new Set([...existingOptions, ...options].map(normalizeSearchValue))
if (existingKeys.has(normalizeSearchValue(normalizedLocation))) return
const nextOptions = [...existingOptions, normalizedLocation].sort((a, b) => a.localeCompare(b))
writeCustomLocationOptions(nextOptions)
}
const addLocation = (location: string) => {
const normalizedLocation = normalizeLocationLabel(location)
if (!normalizedLocation || selectedSet.has(normalizeSearchValue(normalizedLocation))) return
if (!normalizedOptionSet.has(normalizeSearchValue(normalizedLocation))) {
saveCustomLocation(normalizedLocation)
}
onChange([...selected, normalizedLocation])
setQuery('')
}
const removeLocation = (location: string) => {
onChange(selected.filter((selectedLocation) => selectedLocation !== location))
}
const showSuggestions = focused && (suggestions.length > 0 || canAddTypedLocation)
return (
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{label}</label>
<div className="relative">
<input
type="text"
className="input-field"
placeholder={placeholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onKeyDown={(e) => {
if (e.key !== 'Enter') return
if (suggestions[0]) {
e.preventDefault()
addLocation(suggestions[0])
return
}
if (canAddTypedLocation) {
e.preventDefault()
addLocation(typedLocation)
}
}}
aria-label={label}
autoComplete="off"
/>
{showSuggestions && (
<div className="absolute z-20 mt-1 max-h-56 w-full overflow-y-auto rounded-lg border border-slate-200 bg-white py-1 shadow-lg">
{suggestions.map((location) => (
<button
key={location}
type="button"
onMouseDown={(e) => {
e.preventDefault()
addLocation(location)
}}
className="block w-full px-3 py-2 text-left text-sm text-slate-700 transition hover:bg-slate-50 focus:bg-slate-50 focus:outline-none"
>
{location}
</button>
))}
{canAddTypedLocation && (
<button
type="button"
onMouseDown={(e) => {
e.preventDefault()
addLocation(typedLocation)
}}
className="block w-full border-t border-slate-100 px-3 py-2 text-left text-sm font-medium text-blue-700 transition hover:bg-blue-50 focus:bg-blue-50 focus:outline-none"
>
{addCustomLabel(typedLocation)}
</button>
)}
</div>
)}
</div>
{selected.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{selected.map((location) => (
<span key={location} className="inline-flex max-w-full items-center gap-1 rounded-full bg-white px-2.5 py-1 text-xs font-medium text-slate-700 ring-1 ring-slate-200">
<span className="truncate">{location}</span>
<button
type="button"
onClick={() => removeLocation(location)}
className="rounded-full p-0.5 text-slate-400 transition hover:bg-slate-100 hover:text-slate-700"
aria-label={`Remove ${location}`}
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
<p className="mt-1 text-xs text-slate-400">{hint}</p>
</div>
)
}
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
import citiesDataset from '@/data/morocco-cities-rgph-2024.json'
export type SupportedCityLocale = 'en' | 'fr' | 'ar'
interface LocalizedName {
en: string
fr: string
ar: string
}
interface MoroccanCity {
id: string
name: LocalizedName
province: LocalizedName
}
interface MoroccanCitiesDataset {
cities: MoroccanCity[]
}
export function getMoroccanCityOptions(locale: string) {
const resolvedLocale: SupportedCityLocale = locale === 'fr' || locale === 'ar' ? locale : 'en'
const cityNames = (citiesDataset as MoroccanCitiesDataset).cities.map((city) => city.name[resolvedLocale])
return Array.from(new Set(cityNames)).sort((a, b) => a.localeCompare(b, resolvedLocale))
}
export function mergeLocationOptions(options: string[], selectedLocations: string[]) {
const selected = selectedLocations.map((location) => location.trim()).filter(Boolean)
return Array.from(new Set([...selected, ...options]))
}