199 lines
7.8 KiB
TypeScript
199 lines
7.8 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import { useSearchParams } from 'next/navigation'
|
|
import { Star } from 'lucide-react'
|
|
|
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
|
|
|
interface ReviewInfo {
|
|
reservationId: string
|
|
companyName: string
|
|
companyLogoUrl: string | null
|
|
vehicle: string
|
|
vehiclePhoto: string | null
|
|
}
|
|
|
|
function StarRating({ value, onChange, label }: { value: number; onChange: (v: number) => void; label: string }) {
|
|
const [hovered, setHovered] = useState(0)
|
|
return (
|
|
<div>
|
|
<p className="text-sm font-medium text-stone-700 mb-1.5">{label}</p>
|
|
<div className="flex gap-1">
|
|
{[1, 2, 3, 4, 5].map((n) => (
|
|
<button
|
|
key={n}
|
|
type="button"
|
|
onClick={() => onChange(n)}
|
|
onMouseEnter={() => setHovered(n)}
|
|
onMouseLeave={() => setHovered(0)}
|
|
className="p-0.5 transition-transform hover:scale-110"
|
|
aria-label={`${n} star${n > 1 ? 's' : ''}`}
|
|
>
|
|
<Star
|
|
className={`h-8 w-8 transition-colors ${
|
|
n <= (hovered || value)
|
|
? 'fill-amber-400 text-amber-400'
|
|
: 'fill-stone-200 text-stone-200'
|
|
}`}
|
|
/>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function ReviewPage() {
|
|
const params = useSearchParams()
|
|
const token = params.get('token') ?? ''
|
|
|
|
const [info, setInfo] = useState<ReviewInfo | null>(null)
|
|
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'already_reviewed' | 'invalid'>('loading')
|
|
|
|
const [overall, setOverall] = useState(0)
|
|
const [vehicle, setVehicle] = useState(0)
|
|
const [service, setService] = useState(0)
|
|
const [comment, setComment] = useState('')
|
|
const [submitState, setSubmitState] = useState<'idle' | 'submitting' | 'done' | 'error'>('idle')
|
|
const [errorMsg, setErrorMsg] = useState('')
|
|
|
|
useEffect(() => {
|
|
if (!token) { setLoadState('invalid'); return }
|
|
fetch(`${API_BASE}/marketplace/review/${token}`)
|
|
.then(async (r) => {
|
|
if (r.status === 409) { setLoadState('already_reviewed'); return }
|
|
if (!r.ok) { setLoadState('invalid'); return }
|
|
const json = await r.json()
|
|
setInfo(json.data)
|
|
setLoadState('ready')
|
|
})
|
|
.catch(() => setLoadState('invalid'))
|
|
}, [token])
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
if (overall === 0) { setErrorMsg('Please select an overall rating.'); return }
|
|
setSubmitState('submitting')
|
|
setErrorMsg('')
|
|
try {
|
|
const res = await fetch(`${API_BASE}/marketplace/review/${token}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
overallRating: overall,
|
|
vehicleRating: vehicle || undefined,
|
|
serviceRating: service || undefined,
|
|
comment: comment.trim() || undefined,
|
|
}),
|
|
})
|
|
if (res.status === 409) { setLoadState('already_reviewed'); return }
|
|
if (!res.ok) {
|
|
const json = await res.json()
|
|
throw new Error(json.message ?? 'Something went wrong.')
|
|
}
|
|
setSubmitState('done')
|
|
} catch (err: any) {
|
|
setSubmitState('error')
|
|
setErrorMsg(err.message ?? 'Something went wrong. Please try again.')
|
|
}
|
|
}
|
|
|
|
if (loadState === 'loading') {
|
|
return (
|
|
<main className="flex min-h-screen items-center justify-center bg-stone-50 p-6">
|
|
<p className="text-stone-500 text-sm">Loading…</p>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
if (loadState === 'invalid') {
|
|
return (
|
|
<main className="flex min-h-screen items-center justify-center bg-stone-50 p-6">
|
|
<div className="card max-w-md w-full p-8 text-center space-y-3">
|
|
<p className="text-2xl font-black text-stone-900">Link not found</p>
|
|
<p className="text-stone-500 text-sm">This review link is invalid or has already been used.</p>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
if (loadState === 'already_reviewed') {
|
|
return (
|
|
<main className="flex min-h-screen items-center justify-center bg-stone-50 p-6">
|
|
<div className="card max-w-md w-full p-8 text-center space-y-3">
|
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100">
|
|
<svg className="h-7 w-7 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
|
|
</div>
|
|
<p className="text-xl font-bold text-stone-900">Already reviewed</p>
|
|
<p className="text-stone-500 text-sm">You have already submitted a review for this rental. Thank you!</p>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
if (submitState === 'done') {
|
|
return (
|
|
<main className="flex min-h-screen items-center justify-center bg-stone-50 p-6">
|
|
<div className="card max-w-md w-full p-8 text-center space-y-4">
|
|
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100">
|
|
<svg className="h-8 w-8 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
|
|
</div>
|
|
<h1 className="text-2xl font-black text-stone-900">Thank you!</h1>
|
|
<p className="text-stone-500">Your review has been submitted and will be visible on {info?.companyName}'s profile.</p>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<main className="flex min-h-screen items-center justify-center bg-stone-50 p-6">
|
|
<div className="card max-w-lg w-full overflow-hidden">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-4 border-b border-stone-100 p-6">
|
|
{info?.vehiclePhoto ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img src={info.vehiclePhoto} alt={info.vehicle} className="h-16 w-24 rounded-xl object-cover flex-shrink-0" />
|
|
) : (
|
|
<div className="h-16 w-24 rounded-xl bg-stone-100 flex-shrink-0" />
|
|
)}
|
|
<div className="min-w-0">
|
|
<p className="text-xs uppercase tracking-widest text-stone-400 truncate">{info?.companyName}</p>
|
|
<h1 className="mt-0.5 text-lg font-bold text-stone-900 truncate">{info?.vehicle}</h1>
|
|
<p className="mt-0.5 text-sm text-stone-500">How was your experience?</p>
|
|
</div>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
|
<StarRating value={overall} onChange={setOverall} label="Overall experience *" />
|
|
<StarRating value={vehicle} onChange={setVehicle} label="Vehicle condition (optional)" />
|
|
<StarRating value={service} onChange={setService} label="Customer service (optional)" />
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-stone-700 mb-1.5">Comments (optional)</label>
|
|
<textarea
|
|
rows={4}
|
|
value={comment}
|
|
onChange={(e) => setComment(e.target.value)}
|
|
placeholder="Tell us about your rental experience…"
|
|
className="w-full rounded-xl border border-stone-200 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900 resize-none"
|
|
/>
|
|
</div>
|
|
|
|
{errorMsg && (
|
|
<p className="rounded-xl bg-rose-50 px-4 py-3 text-sm text-rose-700">{errorMsg}</p>
|
|
)}
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={submitState === 'submitting'}
|
|
className="w-full rounded-full bg-stone-900 py-3 text-sm font-semibold text-white transition-colors hover:bg-stone-700 disabled:opacity-60"
|
|
>
|
|
{submitState === 'submitting' ? 'Submitting…' : 'Submit review'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|