c77d0a8e89
Build & Push / Build & Push Docker Image (push) Failing after 10m39s
Replace storefront naming across source, tests, docs, config, and production scripts. Rename the legacy top-level app directory and Carplace component files, remove duplicate storefront startup scripts, and refresh the lockfile.
296 lines
14 KiB
TypeScript
296 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import { CalendarDays, Clock3, Info, MapPin, TicketPercent } from 'lucide-react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { useState } from 'react'
|
|
|
|
type ExploreSearchFormDict = {
|
|
searchTitle: string
|
|
pickupLocation: string
|
|
dropoffLocation: string
|
|
sameDropoff: string
|
|
differentDropoff: string
|
|
sameAsPickup: string
|
|
pickupDate: string
|
|
returnDate: string
|
|
hour: string
|
|
promoCode: string
|
|
myAge: string
|
|
ageDescription: string
|
|
search: string
|
|
ageOptions: string[]
|
|
}
|
|
|
|
function toUtcDateTime(date: string, time: string) {
|
|
if (!date) return ''
|
|
return `${date}T${time || '10:00'}:00.000Z`
|
|
}
|
|
|
|
const inputBase = 'w-full border-0 bg-white dark:bg-blue-900/30 px-4 py-4 text-sm text-stone-900 dark:text-stone-100 outline-none placeholder:text-stone-400 dark:placeholder:text-stone-500'
|
|
const selectBase = 'w-full border-0 bg-white dark:bg-blue-900/30 py-4 text-sm text-stone-900 dark:text-stone-100 outline-none'
|
|
|
|
export default function ExploreSearchForm({
|
|
pickupLocation,
|
|
dropoffLocation,
|
|
dropoffMode,
|
|
pickupDate,
|
|
pickupTime,
|
|
returnDate,
|
|
returnTime,
|
|
promoCode,
|
|
driverAge,
|
|
cities,
|
|
dict,
|
|
}: {
|
|
pickupLocation: string
|
|
dropoffLocation: string
|
|
dropoffMode: 'same' | 'different'
|
|
pickupDate: string
|
|
pickupTime: string
|
|
returnDate: string
|
|
returnTime: string
|
|
promoCode: string
|
|
driverAge: string
|
|
cities: string[]
|
|
dict: ExploreSearchFormDict
|
|
}) {
|
|
const router = useRouter()
|
|
const cityOptions = Array.isArray(cities) ? cities : []
|
|
const ageOptions = Array.isArray(dict.ageOptions) ? dict.ageOptions : []
|
|
const [filters, setFilters] = useState({
|
|
pickupLocation,
|
|
dropoffLocation,
|
|
dropoffMode,
|
|
pickupDate,
|
|
pickupTime: pickupTime || '10:00',
|
|
returnDate,
|
|
returnTime: returnTime || '10:00',
|
|
promoCode,
|
|
driverAge: driverAge || '25+',
|
|
})
|
|
const [ageHelpOpen, setAgeHelpOpen] = useState(false)
|
|
|
|
function updateFilter(name: keyof typeof filters, value: string) {
|
|
setFilters((current) => ({ ...current, [name]: value }))
|
|
}
|
|
|
|
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
|
event.preventDefault()
|
|
|
|
const query = new URLSearchParams()
|
|
const normalizedPickup = filters.pickupLocation.trim()
|
|
const normalizedDropoff = filters.dropoffMode === 'different'
|
|
? filters.dropoffLocation.trim()
|
|
: normalizedPickup
|
|
const normalizedPromo = filters.promoCode.trim()
|
|
|
|
if (normalizedPickup) {
|
|
query.set('pickupLocation', normalizedPickup)
|
|
query.set('city', normalizedPickup)
|
|
}
|
|
query.set('dropoffMode', filters.dropoffMode)
|
|
if (filters.dropoffMode === 'different' && normalizedDropoff) {
|
|
query.set('dropoffLocation', normalizedDropoff)
|
|
}
|
|
if (filters.pickupDate) query.set('pickupDate', filters.pickupDate)
|
|
if (filters.pickupTime) query.set('pickupTime', filters.pickupTime)
|
|
if (filters.returnDate) query.set('returnDate', filters.returnDate)
|
|
if (filters.returnTime) query.set('returnTime', filters.returnTime)
|
|
if (normalizedPromo) query.set('promoCode', normalizedPromo)
|
|
if (filters.driverAge) query.set('driverAge', filters.driverAge)
|
|
|
|
const startDateTime = toUtcDateTime(filters.pickupDate, filters.pickupTime)
|
|
const endDateTime = toUtcDateTime(filters.returnDate, filters.returnTime)
|
|
if (startDateTime) query.set('startDate', startDateTime)
|
|
if (endDateTime) query.set('endDate', endDateTime)
|
|
|
|
const target = query.toString() ? `/explore?${query.toString()}` : '/explore'
|
|
router.push(target)
|
|
}
|
|
|
|
return (
|
|
<div className="mt-8 rounded-[1.75rem] bg-white dark:bg-blue-950 p-5 text-stone-900 dark:text-stone-100 shadow-[0_24px_80px_rgba(0,0,0,0.16)]">
|
|
<h2 className="text-3xl font-black tracking-tight text-stone-900 dark:text-stone-100">{dict.searchTitle}</h2>
|
|
<form onSubmit={handleSubmit} className="mt-5 space-y-4">
|
|
<div className="grid gap-px overflow-hidden rounded-[1.35rem] border border-stone-200 dark:border-blue-800 bg-stone-200 dark:bg-stone-700 md:grid-cols-2">
|
|
<label className="flex items-center gap-3 bg-white dark:bg-blue-900/30 px-4">
|
|
<MapPin className="h-5 w-5 text-stone-300 dark:text-stone-500" />
|
|
<select
|
|
name="pickupLocation"
|
|
value={filters.pickupLocation}
|
|
onChange={(event) => updateFilter('pickupLocation', event.target.value)}
|
|
className={selectBase}
|
|
>
|
|
<option value="">{dict.pickupLocation}</option>
|
|
{cityOptions.map((city) => (
|
|
<option key={`pickup-${city}`} value={city}>{city}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<div className="bg-white dark:bg-blue-900/30 px-4 py-3">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.dropoffLocation}</span>
|
|
<div className="inline-flex rounded-full border border-stone-200 dark:border-blue-800 bg-stone-100 dark:bg-blue-950 p-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setFilters((current) => ({ ...current, dropoffMode: 'same', dropoffLocation: '' }))}
|
|
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
|
filters.dropoffMode === 'same'
|
|
? 'bg-blue-900 text-white dark:bg-orange-400 dark:text-white'
|
|
: 'text-stone-500 dark:text-stone-400'
|
|
}`}
|
|
>
|
|
{dict.sameDropoff}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFilters((current) => ({ ...current, dropoffMode: 'different' }))}
|
|
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
|
filters.dropoffMode === 'different'
|
|
? 'bg-blue-900 text-white dark:bg-orange-400 dark:text-white'
|
|
: 'text-stone-500 dark:text-stone-400'
|
|
}`}
|
|
>
|
|
{dict.differentDropoff}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{filters.dropoffMode === 'different' ? (
|
|
<label className="mt-3 flex items-center gap-3">
|
|
<MapPin className="h-5 w-5 text-stone-300 dark:text-stone-500" />
|
|
<select
|
|
name="dropoffLocation"
|
|
value={filters.dropoffLocation}
|
|
onChange={(event) => updateFilter('dropoffLocation', event.target.value)}
|
|
className={selectBase}
|
|
>
|
|
<option value="">{dict.dropoffLocation}</option>
|
|
{cityOptions.map((city) => (
|
|
<option key={`dropoff-${city}`} value={city}>{city}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
) : (
|
|
<div className="mt-3 flex items-center gap-3 rounded-2xl bg-stone-50 px-4 py-4 text-sm font-medium text-stone-500 dark:bg-blue-950 dark:text-stone-300">
|
|
<MapPin className="h-5 w-5 text-stone-300 dark:text-stone-500" />
|
|
<span>{filters.pickupLocation || dict.sameAsPickup}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-px overflow-hidden rounded-[1.35rem] border border-stone-200 dark:border-blue-800 bg-stone-200 dark:bg-stone-700 md:grid-cols-4">
|
|
<label className="flex items-center gap-3 bg-white dark:bg-blue-900/30 px-4">
|
|
<CalendarDays className="h-5 w-5 text-stone-500 dark:text-stone-400" />
|
|
<div className="flex w-full flex-col py-3">
|
|
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.pickupDate}</span>
|
|
<input
|
|
name="pickupDate"
|
|
type="date"
|
|
value={filters.pickupDate}
|
|
min={new Date().toISOString().slice(0, 10)}
|
|
onChange={(event) => updateFilter('pickupDate', event.target.value)}
|
|
className="border-0 bg-transparent px-0 py-0 text-sm text-stone-900 dark:text-stone-100 outline-none [color-scheme:light] dark:[color-scheme:dark]"
|
|
/>
|
|
</div>
|
|
</label>
|
|
<label className="flex items-center gap-3 bg-white dark:bg-blue-900/30 px-4">
|
|
<Clock3 className="h-5 w-5 text-stone-500 dark:text-stone-400" />
|
|
<div className="flex w-full flex-col py-3">
|
|
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.hour}</span>
|
|
<input
|
|
name="pickupTime"
|
|
type="time"
|
|
value={filters.pickupTime}
|
|
onChange={(event) => updateFilter('pickupTime', event.target.value)}
|
|
className="border-0 bg-transparent px-0 py-0 text-sm text-stone-900 dark:text-stone-100 outline-none [color-scheme:light] dark:[color-scheme:dark]"
|
|
/>
|
|
</div>
|
|
</label>
|
|
<label className="flex items-center gap-3 bg-white dark:bg-blue-900/30 px-4">
|
|
<CalendarDays className="h-5 w-5 text-stone-500 dark:text-stone-400" />
|
|
<div className="flex w-full flex-col py-3">
|
|
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.returnDate}</span>
|
|
<input
|
|
name="returnDate"
|
|
type="date"
|
|
value={filters.returnDate}
|
|
min={filters.pickupDate || new Date().toISOString().slice(0, 10)}
|
|
onChange={(event) => updateFilter('returnDate', event.target.value)}
|
|
className="border-0 bg-transparent px-0 py-0 text-sm text-stone-900 dark:text-stone-100 outline-none [color-scheme:light] dark:[color-scheme:dark]"
|
|
/>
|
|
</div>
|
|
</label>
|
|
<label className="flex items-center gap-3 bg-white dark:bg-blue-900/30 px-4">
|
|
<Clock3 className="h-5 w-5 text-stone-500 dark:text-stone-400" />
|
|
<div className="flex w-full flex-col py-3">
|
|
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.hour}</span>
|
|
<input
|
|
name="returnTime"
|
|
type="time"
|
|
value={filters.returnTime}
|
|
onChange={(event) => updateFilter('returnTime', event.target.value)}
|
|
className="border-0 bg-transparent px-0 py-0 text-sm text-stone-900 dark:text-stone-100 outline-none [color-scheme:light] dark:[color-scheme:dark]"
|
|
/>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="relative z-10 grid gap-px overflow-visible rounded-[1.35rem] border border-stone-200 dark:border-blue-800 bg-stone-200 dark:bg-stone-700 lg:grid-cols-[1.1fr_1.1fr_1.35fr]">
|
|
<label className="flex items-center gap-3 rounded-t-[1.35rem] bg-white dark:bg-blue-900/30 px-4 lg:rounded-l-[1.35rem] lg:rounded-tr-none">
|
|
<TicketPercent className="h-5 w-5 text-stone-300 dark:text-stone-500" />
|
|
<input
|
|
name="promoCode"
|
|
value={filters.promoCode}
|
|
onChange={(event) => updateFilter('promoCode', event.target.value)}
|
|
placeholder={dict.promoCode}
|
|
className={`${inputBase} pl-2`}
|
|
/>
|
|
</label>
|
|
<label className="flex items-center justify-between gap-3 bg-white dark:bg-blue-900/30 px-4 py-3">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<span className="text-sm font-semibold text-stone-600 dark:text-stone-300">{dict.myAge}</span>
|
|
<select
|
|
name="driverAge"
|
|
value={filters.driverAge}
|
|
onChange={(event) => updateFilter('driverAge', event.target.value)}
|
|
className="border-0 bg-transparent text-sm font-semibold text-stone-900 dark:text-stone-100 outline-none"
|
|
>
|
|
{ageOptions.map((option) => (
|
|
<option key={option} value={option}>{option}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div
|
|
className="relative shrink-0"
|
|
onMouseEnter={() => setAgeHelpOpen(true)}
|
|
onMouseLeave={() => setAgeHelpOpen(false)}
|
|
>
|
|
<button
|
|
type="button"
|
|
aria-label={dict.ageDescription}
|
|
onClick={() => setAgeHelpOpen((open) => !open)}
|
|
className="text-stone-400 dark:text-stone-500 transition hover:text-stone-600 dark:hover:text-stone-300"
|
|
>
|
|
<Info className="h-5 w-5" />
|
|
</button>
|
|
{ageHelpOpen ? (
|
|
<div className="absolute bottom-[calc(100%+0.85rem)] right-0 z-30 w-72 rounded-2xl border border-orange-300 dark:border-orange-600 bg-white dark:bg-blue-950 px-4 py-3 text-left text-sm leading-6 text-stone-700 dark:text-stone-200 shadow-[0_16px_40px_rgba(0,0,0,0.14)]">
|
|
{dict.ageDescription}
|
|
<span className="absolute right-6 top-full h-3 w-3 -translate-y-1/2 rotate-45 border-b border-r border-orange-300 dark:border-orange-600 bg-white dark:bg-blue-950" />
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</label>
|
|
<button
|
|
type="submit"
|
|
className="min-h-[76px] rounded-b-[1.35rem] bg-orange-600 px-6 text-sm font-black uppercase tracking-[0.08em] text-white transition hover:bg-orange-700 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300 lg:rounded-b-none lg:rounded-r-[1.35rem]"
|
|
>
|
|
{dict.search}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)
|
|
}
|