feat: implement dynamic categories, admin category CRUD, fix routing and cleanup
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export default function DetailTracker({ listingId }: { listingId: string }) {
|
||||
useEffect(() => {
|
||||
// Send fire-and-forget page view tracking event on load
|
||||
fetch('/api/events', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ listingId, actionType: 'views' })
|
||||
}).catch(err => console.error('Tracking views error:', err))
|
||||
|
||||
const trackClick = (action: 'phone' | 'whatsapp' | 'menu') => {
|
||||
fetch('/api/events', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ listingId, actionType: action })
|
||||
}).catch(err => console.error(`Tracking ${action} error:`, err))
|
||||
}
|
||||
|
||||
const telBtn = document.getElementById('listing-contact-phone')
|
||||
const waBtn = document.getElementById('listing-contact-whatsapp')
|
||||
const menuBtn = document.getElementById('listing-contact-menu')
|
||||
|
||||
const handleTel = () => trackClick('phone')
|
||||
const handleWa = () => trackClick('whatsapp')
|
||||
const handleMenu = () => trackClick('menu')
|
||||
|
||||
if (telBtn) telBtn.addEventListener('click', handleTel)
|
||||
if (waBtn) waBtn.addEventListener('click', handleWa)
|
||||
if (menuBtn) menuBtn.addEventListener('click', handleMenu)
|
||||
|
||||
return () => {
|
||||
if (telBtn) telBtn.removeEventListener('click', handleTel)
|
||||
if (waBtn) waBtn.removeEventListener('click', handleWa)
|
||||
if (menuBtn) menuBtn.removeEventListener('click', handleMenu)
|
||||
}
|
||||
}, [listingId])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -6,16 +6,19 @@ import Image from 'next/image'
|
||||
import { Link } from '@/i18n/routing'
|
||||
import { Phone, Globe, MapPin, Clock, Star, MessageSquare, Share2 } from 'lucide-react'
|
||||
import SaveButton from './SaveButton'
|
||||
import DetailTracker from './DetailTracker'
|
||||
|
||||
interface DetailPageProps {
|
||||
params: Promise<{ locale: string; category: string; slug: string }>
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function generateMetadata({ params }: DetailPageProps) {
|
||||
const { slug } = await params
|
||||
const listing = await mockDb.getListingBySlug(slug)
|
||||
if (!listing) return {}
|
||||
|
||||
|
||||
return {
|
||||
title: `${listing.nameTr} — Marmaris Local`,
|
||||
description: listing.descriptionTr
|
||||
@@ -27,7 +30,7 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
|
||||
setRequestLocale(locale)
|
||||
|
||||
const t = await getTranslations('detail')
|
||||
|
||||
|
||||
const listing = await mockDb.getListingBySlug(slug)
|
||||
if (!listing) {
|
||||
notFound()
|
||||
@@ -56,19 +59,21 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
|
||||
locale === 'ru'
|
||||
? listing.nameRu
|
||||
: locale === 'en'
|
||||
? listing.nameEn
|
||||
: listing.nameTr
|
||||
? listing.nameEn
|
||||
: listing.nameTr
|
||||
|
||||
const description =
|
||||
locale === 'ru'
|
||||
? listing.descriptionRu
|
||||
: locale === 'en'
|
||||
? listing.descriptionEn
|
||||
: listing.descriptionTr
|
||||
? listing.descriptionEn
|
||||
: listing.descriptionTr
|
||||
|
||||
const priceSymbols = '₺'.repeat(listing.priceRange)
|
||||
const categorySlug = listing.category?.slug || 'isletme'
|
||||
|
||||
const rawId = listing.id; // örn: "gm-ChIJY_2Q8tbJvxQRs7xwaSne0is"
|
||||
const hasGooglePlaceId = rawId.startsWith('gm-');
|
||||
const placeId = hasGooglePlaceId ? rawId.replace('gm-', '') : null;
|
||||
// Format WhatsApp Link
|
||||
const getWhatsAppLink = (number: string) => {
|
||||
const cleanNum = number.replace(/\D/g, '')
|
||||
@@ -83,9 +88,10 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink">
|
||||
<DetailTracker listingId={listing.id} />
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 flex-1 space-y-10">
|
||||
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="text-xs font-mono uppercase tracking-wider text-shutter flex items-center gap-2">
|
||||
<span className="hover:text-turquoise transition-colors">marmaris local</span>
|
||||
@@ -100,7 +106,7 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
|
||||
{/* Hero Details Block */}
|
||||
<div className="bg-paper rounded-3xl border border-pine/8 p-6 sm:p-10 shadow-sm space-y-8">
|
||||
<div className="flex flex-col lg:flex-row gap-10">
|
||||
|
||||
|
||||
{/* Left: Gallery Panel */}
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="aspect-[16/10] w-full relative rounded-2xl overflow-hidden bg-stone-deep shadow-sm">
|
||||
@@ -137,7 +143,7 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
|
||||
<span className="text-[10px] font-mono text-bougainvillea font-bold uppercase tracking-wider bg-bougainvillea/5 border border-bougainvillea/10 px-2.5 py-1 rounded-full">
|
||||
{locale === 'ru' ? listing.category?.nameRu : locale === 'en' ? listing.category?.nameEn : listing.category?.nameTr}
|
||||
</span>
|
||||
|
||||
|
||||
{listing.isLocalApproved && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-mono text-turquoise font-bold uppercase tracking-wider bg-turquoise/5 border border-turquoise/10 px-2.5 py-1 rounded-full">
|
||||
★ {t('approved')}
|
||||
@@ -165,16 +171,50 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
|
||||
|
||||
{/* Description */}
|
||||
<div className="text-ink/80 text-sm sm:text-base leading-relaxed font-medium">
|
||||
{description}
|
||||
{description ? description : <span className="italic text-ink/50">{t('no_description', { defaultValue: 'Bu işletme için henüz bir açıklama eklenmemiştir.' })}</span>}
|
||||
</div>
|
||||
|
||||
{/* Contact Info (Only if at least one exists) */}
|
||||
{(listing.phone || listing.website || listing.instagram) && (
|
||||
<div className="space-y-4 pt-6 border-t border-pine/10">
|
||||
<h3 className="font-heading font-bold text-xs text-pine uppercase tracking-wider">{t('contact')}</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{listing.phone && (
|
||||
<a id="listing-contact-phone" href={`tel:${listing.phone}`} className="flex items-center gap-3 p-3 rounded-xl border border-pine/10 hover:border-turquoise/30 hover:bg-turquoise/5 transition-colors group">
|
||||
<div className="w-8 h-8 rounded-full bg-pine/5 flex items-center justify-center group-hover:bg-turquoise/10 transition-colors">
|
||||
<Phone className="w-4 h-4 text-pine group-hover:text-turquoise transition-colors" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-bold text-pine/50 uppercase tracking-wider">{t('phone')}</span>
|
||||
<span className="text-sm font-medium text-ink group-hover:text-pine transition-colors">{listing.phone}</span>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
{listing.website && (
|
||||
<a id="listing-contact-website" href={listing.website.startsWith('http') ? listing.website : `https://${listing.website}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-3 p-3 rounded-xl border border-pine/10 hover:border-turquoise/30 hover:bg-turquoise/5 transition-colors group">
|
||||
<div className="w-8 h-8 rounded-full bg-pine/5 flex items-center justify-center group-hover:bg-turquoise/10 transition-colors">
|
||||
<Globe className="w-4 h-4 text-pine group-hover:text-turquoise transition-colors" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-bold text-pine/50 uppercase tracking-wider">{t('website')}</span>
|
||||
<span className="text-sm font-medium text-ink group-hover:text-pine transition-colors truncate max-w-[150px]">
|
||||
{listing.website.replace(/^https?:\/\//, '').replace(/\/$/, '')}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact & Actions Grid */}
|
||||
<div className="space-y-4 pt-2">
|
||||
<h3 className="font-heading font-bold text-xs uppercase tracking-wider text-shutter">{t('contact')}</h3>
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{listing.phone && (
|
||||
<a
|
||||
id="listing-contact-phone"
|
||||
href={`tel:${listing.phone}`}
|
||||
className="flex items-center justify-center gap-2 bg-pine hover:bg-pine/90 text-stone font-bold text-xs py-3.5 px-4 rounded-xl transition"
|
||||
>
|
||||
@@ -185,6 +225,7 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
|
||||
|
||||
{listing.whatsapp && (
|
||||
<a
|
||||
id="listing-contact-whatsapp"
|
||||
href={getWhatsAppLink(listing.whatsapp)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -198,6 +239,7 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
|
||||
{/* Menu Link */}
|
||||
{listing.menuUrl && (
|
||||
<a
|
||||
id="listing-contact-menu"
|
||||
href={listing.menuUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -272,13 +314,13 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
|
||||
</div>
|
||||
|
||||
{/* Map Frame */}
|
||||
{listing.latitude && listing.longitude && (
|
||||
{((listing.latitude && listing.longitude) || hasGooglePlaceId) && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 font-heading font-bold text-xs text-pine uppercase tracking-wider">
|
||||
<Globe className="w-4 h-4 text-turquoise" />
|
||||
<span>{t('location')}</span>
|
||||
</div>
|
||||
<div className="rounded-xl overflow-hidden border border-pine/8 aspect-[16/10] sm:aspect-auto sm:h-36">
|
||||
<div className="rounded-xl overflow-hidden border border-pine/8 aspect-[16/10] sm:aspect-auto sm:h-36 relative group">
|
||||
<iframe
|
||||
width="100%"
|
||||
height="100%"
|
||||
@@ -286,8 +328,14 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
|
||||
scrolling="no"
|
||||
marginHeight={0}
|
||||
marginWidth={0}
|
||||
src={`https://maps.google.com/maps?q=${listing.latitude},${listing.longitude}&t=&z=15&ie=UTF8&iwloc=&output=embed`}
|
||||
src={
|
||||
hasGooglePlaceId
|
||||
? `https://maps.google.com/maps?q=place_id:${placeId}&z=16&output=embed`
|
||||
: `https://maps.google.com/maps?q=${listing.latitude},${listing.longitude}+(${encodeURIComponent(name)})&t=&z=16&ie=UTF8&output=embed`
|
||||
}
|
||||
className="w-full h-full shadow-sm"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -307,7 +355,7 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
|
||||
<p className="text-[10px] font-mono text-shutter uppercase tracking-wider mt-0.5">{t('instagramFeed')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{instagramPosts.slice(0, 3).map((post: any, idx: number) => (
|
||||
<a
|
||||
@@ -344,10 +392,10 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
|
||||
{t('newlyAddedSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{latestListings.map((newest) => {
|
||||
const catSlug = newest.category?.slug || 'isletmeler'
|
||||
const catSlug = newest.category?.slug || 'businesses'
|
||||
const newestName = locale === 'en' ? newest.nameEn : locale === 'ru' ? newest.nameRu : newest.nameTr
|
||||
const newestImg = newest.images && newest.images.length > 0 ? newest.images[0].url : 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&auto=format&fit=crop&q=80'
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { mockDb } from '@/lib/mockDb'
|
||||
import ListingCard from '@/components/ListingCard'
|
||||
import { Link } from '@/i18n/routing'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { MapPin, SlidersHorizontal, Check } from 'lucide-react'
|
||||
import LiveFilterForm from '@/components/LiveFilterForm'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ locale: string, category: string }>
|
||||
searchParams: Promise<{
|
||||
search?: string
|
||||
neighborhood?: string
|
||||
price?: string
|
||||
approved?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function DynamicCategoryPage({ params, searchParams }: PageProps) {
|
||||
const { locale, category: categorySlug } = await params
|
||||
setRequestLocale(locale)
|
||||
|
||||
const { search, neighborhood, price, approved } = await searchParams
|
||||
|
||||
const t = await getTranslations('categories')
|
||||
const navT = await getTranslations('nav')
|
||||
|
||||
// Find Category Restoran
|
||||
const categories = await mockDb.getCategories()
|
||||
const currentCategory = categories.find(c => c.slug === categorySlug)
|
||||
if (!currentCategory) notFound()
|
||||
const categoryId = currentCategory?.id
|
||||
|
||||
// Get active neighborhoods for filter
|
||||
const neighborhoods = await mockDb.getNeighborhoods()
|
||||
|
||||
// Selected filters
|
||||
const selectedNeighborhoodId = neighborhood || undefined
|
||||
const selectedPriceRange = price ? parseInt(price) : undefined
|
||||
const isApprovedOnly = approved === 'true'
|
||||
|
||||
const listings = await mockDb.getListings({
|
||||
categoryId,
|
||||
neighborhoodId: selectedNeighborhoodId,
|
||||
priceRange: selectedPriceRange,
|
||||
isLocalApproved: isApprovedOnly ? true : undefined,
|
||||
search: search
|
||||
})
|
||||
|
||||
const getLocalizedName = (obj: any) => {
|
||||
if (!obj) return ''
|
||||
return locale === 'ru' ? obj.nameRu : locale === 'en' ? obj.nameEn : obj.nameTr
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink">
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 flex-1">
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-heading font-extrabold text-pine lowercase">
|
||||
{getLocalizedName(currentCategory)}
|
||||
</h1>
|
||||
<p className="text-xs text-shutter font-mono uppercase tracking-wider mt-1">
|
||||
marmaris local • {listings.length} {locale === 'tr' ? 'sonuç' : locale === 'en' ? 'results' : 'результатов'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters Panel */}
|
||||
<div className="bg-paper p-5 rounded-2xl border border-pine/8 shadow-sm mb-10">
|
||||
<div className="flex items-center gap-2 mb-4 font-heading font-bold text-sm text-pine lowercase border-b border-dashed border-pine/8 pb-3">
|
||||
<SlidersHorizontal className="w-4 h-4 text-turquoise" />
|
||||
<span>filtreler</span>
|
||||
</div>
|
||||
|
||||
<LiveFilterForm>
|
||||
{/* Search Input */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-[11px] font-mono uppercase tracking-wider text-shutter">Arama</label>
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
defaultValue={search || ''}
|
||||
placeholder="İsim veya adres..."
|
||||
className="w-full bg-stone border border-pine/10 rounded-xl px-3 py-2 text-xs focus:ring-1 focus:ring-turquoise outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Neighborhood select */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-[11px] font-mono uppercase tracking-wider text-shutter">{t('filterNeighborhood')}</label>
|
||||
<select
|
||||
name="neighborhood"
|
||||
defaultValue={neighborhood || ''}
|
||||
className="w-full bg-stone border border-pine/10 rounded-xl px-3 py-2 text-xs focus:ring-1 focus:ring-turquoise outline-none appearance-none"
|
||||
>
|
||||
<option value="">{t('allNeighborhoods')}</option>
|
||||
{neighborhoods.map((n) => (
|
||||
<option key={n.id} value={n.id}>
|
||||
{getLocalizedName(n)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Price range select */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-[11px] font-mono uppercase tracking-wider text-shutter">{t('filterPrice')}</label>
|
||||
<select
|
||||
name="price"
|
||||
defaultValue={price || ''}
|
||||
className="w-full bg-stone border border-pine/10 rounded-xl px-3 py-2 text-xs focus:ring-1 focus:ring-turquoise outline-none"
|
||||
>
|
||||
<option value="">{t('allPrices')}</option>
|
||||
<option value="1">₺ (Ekonomik)</option>
|
||||
<option value="2">₺₺ (Orta)</option>
|
||||
<option value="3">₺₺₺ (Lüks)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Submit / Checkbox area */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none text-xs font-semibold py-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="approved"
|
||||
value="true"
|
||||
defaultChecked={isApprovedOnly}
|
||||
className="rounded border-pine/10 text-turquoise focus:ring-turquoise w-4 h-4"
|
||||
/>
|
||||
<span className="text-pine">{t('filterApproved')}</span>
|
||||
</label>
|
||||
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
</LiveFilterForm>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{listings.length === 0 ? (
|
||||
<div className="bg-paper/50 rounded-2xl border border-dashed border-pine/12 p-12 text-center text-shutter">
|
||||
<p className="text-sm font-medium">{t('noResults')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{listings.map((listing) => (
|
||||
<ListingCard key={listing.id} listing={listing} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user