Initial food blog app
This commit is contained in:
240
app/restaurant/[id]/page.tsx
Normal file
240
app/restaurant/[id]/page.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import { motion } from "framer-motion"
|
||||
import Link from "next/link"
|
||||
import { Restaurant, Visit } from "@/app/types"
|
||||
|
||||
function priceLabel(range: number) {
|
||||
return "€".repeat(range)
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString("en-GB", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
function RatingBar({ rating }: { rating: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 h-1.5 bg-white/[0.08] rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-[#f59e0b] rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(rating / 10) * 100}%` }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[#f59e0b] font-black text-xl leading-none w-8 text-right">
|
||||
{rating}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VisitCard({ visit, index }: { visit: Visit; index: number }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -16 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.4, delay: index * 0.1 }}
|
||||
className="relative pl-6"
|
||||
>
|
||||
{/* Timeline line */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-px bg-white/[0.08]" />
|
||||
{/* Timeline dot */}
|
||||
<div className="absolute left-[-4px] top-3 w-2 h-2 rounded-full bg-[#f59e0b]" />
|
||||
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-5 mb-4">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<p className="text-sm text-white/40">{formatDate(visit.date)}</p>
|
||||
{visit.price_paid && (
|
||||
<span className="text-xs text-white/30 shrink-0">
|
||||
€{visit.price_paid}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<RatingBar rating={visit.rating} />
|
||||
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-white/30">
|
||||
Dishes
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{visit.dishes.map((dish, i) => (
|
||||
<li key={i} className="text-sm text-[#f5f5f5] flex items-start gap-2">
|
||||
<span className="text-[#f59e0b] mt-1 shrink-0">·</span>
|
||||
{dish}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{visit.notes && (
|
||||
<div className="mt-4 pt-4 border-t border-white/[0.06]">
|
||||
<p className="text-sm text-white/60 leading-relaxed italic">
|
||||
“{visit.notes}”
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RestaurantPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const id = params.id as string
|
||||
|
||||
const [restaurant, setRestaurant] = useState<Restaurant | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [notFound, setNotFound] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/restaurants/${id}`)
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
setNotFound(true)
|
||||
setLoading(false)
|
||||
return null
|
||||
}
|
||||
return r.json()
|
||||
})
|
||||
.then((data) => {
|
||||
if (data) {
|
||||
setRestaurant(data)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setNotFound(true)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [id])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
className="w-6 h-6 border-2 border-[#f59e0b] border-t-transparent rounded-full"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (notFound || !restaurant) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
|
||||
<p className="text-white/40">Restaurant not found</p>
|
||||
<Link href="/" className="text-[#f59e0b] text-sm font-medium hover:underline">
|
||||
Back home
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const avgRating =
|
||||
restaurant.visits.length > 0
|
||||
? restaurant.visits.reduce((s, v) => s + v.rating, 0) / restaurant.visits.length
|
||||
: 0
|
||||
|
||||
const sortedVisits = [...restaurant.visits].sort(
|
||||
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-5 md:px-8 py-8 md:py-12">
|
||||
{/* Back */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -12 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-sm text-white/40 hover:text-white/70 transition-colors mb-8 flex items-center gap-2"
|
||||
>
|
||||
← back
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 mb-2">
|
||||
<h1 className="text-4xl md:text-5xl font-black tracking-tight text-[#f5f5f5] leading-none">
|
||||
{restaurant.name}
|
||||
</h1>
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-[#f59e0b] font-black text-3xl leading-none">
|
||||
{avgRating.toFixed(1)}
|
||||
</div>
|
||||
<div className="text-white/25 text-xs mt-1">avg</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-white/40 text-base mt-2">
|
||||
{restaurant.address} · {restaurant.city}, {restaurant.country}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 mt-4">
|
||||
{restaurant.cuisine.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-xs font-semibold uppercase tracking-wider text-[#f59e0b]/70 bg-[#f59e0b]/10 rounded-full px-3 py-1"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
<span className="text-white/30 text-sm font-medium">
|
||||
{priceLabel(restaurant.price_range)}
|
||||
</span>
|
||||
<span className="text-white/25 text-sm">
|
||||
{restaurant.visits.length} {restaurant.visits.length === 1 ? "visit" : "visits"}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="my-8 border-t border-white/[0.06]" />
|
||||
|
||||
{/* Visits timeline */}
|
||||
<div>
|
||||
<h2 className="text-lg font-bold mb-6 text-white/70">Visits</h2>
|
||||
<div className="space-y-0">
|
||||
{sortedVisits.map((visit, i) => (
|
||||
<VisitCard key={visit.id} visit={visit} index={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin link */}
|
||||
<div className="mt-12 pt-8 border-t border-white/[0.06] flex gap-4">
|
||||
<Link
|
||||
href={`/admin?restaurant=${restaurant.id}`}
|
||||
className="text-sm text-white/30 hover:text-[#f59e0b] transition-colors"
|
||||
>
|
||||
+ add visit
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="text-sm text-white/30 hover:text-[#f59e0b] transition-colors"
|
||||
>
|
||||
manage
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user