add list of cities for car pick drop off locations
This commit is contained in:
@@ -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}
|
||||
placeholder={fl.pickupLocationsPlaceholder}
|
||||
value={form.pickupLocations}
|
||||
onChange={(e) => setForm({ ...form, pickupLocations: e.target.value })}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-400">{fl.locationHint}</p>
|
||||
</div>
|
||||
<LocationDropdownField
|
||||
label={fl.pickupLocations}
|
||||
placeholder={fl.pickupLocationsPlaceholder}
|
||||
hint={fl.locationHint}
|
||||
addCustomLabel={fl.addCustomLocation}
|
||||
options={cityOptions}
|
||||
value={parseLocationInput(form.pickupLocations)}
|
||||
onChange={(locations) => setForm({ ...form, pickupLocations: formatLocationInput(locations) })}
|
||||
/>
|
||||
<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}
|
||||
placeholder={fl.dropoffLocationsPlaceholder}
|
||||
value={form.dropoffLocations}
|
||||
onChange={(e) => setForm({ ...form, dropoffLocations: e.target.value })}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-400">{fl.locationHint}</p>
|
||||
</div>
|
||||
<LocationDropdownField
|
||||
label={fl.dropoffLocations}
|
||||
placeholder={fl.dropoffLocationsPlaceholder}
|
||||
hint={fl.locationHint}
|
||||
addCustomLabel={fl.addCustomLocation}
|
||||
options={cityOptions}
|
||||
value={parseLocationInput(form.dropoffLocations)}
|
||||
onChange={(locations) => setForm({ ...form, dropoffLocations: formatLocationInput(locations) })}
|
||||
/>
|
||||
)}
|
||||
</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}
|
||||
placeholder={f.pickupLocationsPlaceholder}
|
||||
value={form.pickupLocations}
|
||||
onChange={(e) => setForm({ ...form, pickupLocations: e.target.value })}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-400">{f.locationHint}</p>
|
||||
</div>
|
||||
<LocationDropdownField
|
||||
label={f.pickupLocations}
|
||||
placeholder={f.pickupLocationsPlaceholder}
|
||||
hint={f.locationHint}
|
||||
addCustomLabel={f.addCustomLocation}
|
||||
options={cityOptions}
|
||||
value={parseLocationInput(form.pickupLocations)}
|
||||
onChange={(locations) => setForm({ ...form, pickupLocations: formatLocationInput(locations) })}
|
||||
/>
|
||||
<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}
|
||||
placeholder={f.dropoffLocationsPlaceholder}
|
||||
value={form.dropoffLocations}
|
||||
onChange={(e) => setForm({ ...form, dropoffLocations: e.target.value })}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-400">{f.locationHint}</p>
|
||||
</div>
|
||||
<LocationDropdownField
|
||||
label={f.dropoffLocations}
|
||||
placeholder={f.dropoffLocationsPlaceholder}
|
||||
hint={f.locationHint}
|
||||
addCustomLabel={f.addCustomLocation}
|
||||
options={cityOptions}
|
||||
value={parseLocationInput(form.dropoffLocations)}
|
||||
onChange={(locations) => setForm({ ...form, dropoffLocations: formatLocationInput(locations) })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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
@@ -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]))
|
||||
}
|
||||
Reference in New Issue
Block a user