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,11 +6,14 @@ import Image from 'next/image'
|
|||||||
import { Link } from '@/i18n/routing'
|
import { Link } from '@/i18n/routing'
|
||||||
import { Phone, Globe, MapPin, Clock, Star, MessageSquare, Share2 } from 'lucide-react'
|
import { Phone, Globe, MapPin, Clock, Star, MessageSquare, Share2 } from 'lucide-react'
|
||||||
import SaveButton from './SaveButton'
|
import SaveButton from './SaveButton'
|
||||||
|
import DetailTracker from './DetailTracker'
|
||||||
|
|
||||||
interface DetailPageProps {
|
interface DetailPageProps {
|
||||||
params: Promise<{ locale: string; category: string; slug: string }>
|
params: Promise<{ locale: string; category: string; slug: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
export async function generateMetadata({ params }: DetailPageProps) {
|
export async function generateMetadata({ params }: DetailPageProps) {
|
||||||
const { slug } = await params
|
const { slug } = await params
|
||||||
const listing = await mockDb.getListingBySlug(slug)
|
const listing = await mockDb.getListingBySlug(slug)
|
||||||
@@ -56,19 +59,21 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
|
|||||||
locale === 'ru'
|
locale === 'ru'
|
||||||
? listing.nameRu
|
? listing.nameRu
|
||||||
: locale === 'en'
|
: locale === 'en'
|
||||||
? listing.nameEn
|
? listing.nameEn
|
||||||
: listing.nameTr
|
: listing.nameTr
|
||||||
|
|
||||||
const description =
|
const description =
|
||||||
locale === 'ru'
|
locale === 'ru'
|
||||||
? listing.descriptionRu
|
? listing.descriptionRu
|
||||||
: locale === 'en'
|
: locale === 'en'
|
||||||
? listing.descriptionEn
|
? listing.descriptionEn
|
||||||
: listing.descriptionTr
|
: listing.descriptionTr
|
||||||
|
|
||||||
const priceSymbols = '₺'.repeat(listing.priceRange)
|
const priceSymbols = '₺'.repeat(listing.priceRange)
|
||||||
const categorySlug = listing.category?.slug || 'isletme'
|
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
|
// Format WhatsApp Link
|
||||||
const getWhatsAppLink = (number: string) => {
|
const getWhatsAppLink = (number: string) => {
|
||||||
const cleanNum = number.replace(/\D/g, '')
|
const cleanNum = number.replace(/\D/g, '')
|
||||||
@@ -83,6 +88,7 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink">
|
<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">
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 flex-1 space-y-10">
|
||||||
|
|
||||||
@@ -165,9 +171,42 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
|
|||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div className="text-ink/80 text-sm sm:text-base leading-relaxed font-medium">
|
<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>
|
</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 */}
|
{/* Contact & Actions Grid */}
|
||||||
<div className="space-y-4 pt-2">
|
<div className="space-y-4 pt-2">
|
||||||
<h3 className="font-heading font-bold text-xs uppercase tracking-wider text-shutter">{t('contact')}</h3>
|
<h3 className="font-heading font-bold text-xs uppercase tracking-wider text-shutter">{t('contact')}</h3>
|
||||||
@@ -175,6 +214,7 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
|
|||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
{listing.phone && (
|
{listing.phone && (
|
||||||
<a
|
<a
|
||||||
|
id="listing-contact-phone"
|
||||||
href={`tel:${listing.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"
|
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 && (
|
{listing.whatsapp && (
|
||||||
<a
|
<a
|
||||||
|
id="listing-contact-whatsapp"
|
||||||
href={getWhatsAppLink(listing.whatsapp)}
|
href={getWhatsAppLink(listing.whatsapp)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
@@ -198,6 +239,7 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
|
|||||||
{/* Menu Link */}
|
{/* Menu Link */}
|
||||||
{listing.menuUrl && (
|
{listing.menuUrl && (
|
||||||
<a
|
<a
|
||||||
|
id="listing-contact-menu"
|
||||||
href={listing.menuUrl}
|
href={listing.menuUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
@@ -272,13 +314,13 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Map Frame */}
|
{/* Map Frame */}
|
||||||
{listing.latitude && listing.longitude && (
|
{((listing.latitude && listing.longitude) || hasGooglePlaceId) && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2 font-heading font-bold text-xs text-pine uppercase tracking-wider">
|
<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" />
|
<Globe className="w-4 h-4 text-turquoise" />
|
||||||
<span>{t('location')}</span>
|
<span>{t('location')}</span>
|
||||||
</div>
|
</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
|
<iframe
|
||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
@@ -286,8 +328,14 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
|
|||||||
scrolling="no"
|
scrolling="no"
|
||||||
marginHeight={0}
|
marginHeight={0}
|
||||||
marginWidth={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"
|
className="w-full h-full shadow-sm"
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="no-referrer-when-downgrade"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,7 +395,7 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
{latestListings.map((newest) => {
|
{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 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'
|
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 (
|
return (
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'
|
|||||||
import { mockDb } from '@/lib/mockDb'
|
import { mockDb } from '@/lib/mockDb'
|
||||||
import ListingCard from '@/components/ListingCard'
|
import ListingCard from '@/components/ListingCard'
|
||||||
import { Link } from '@/i18n/routing'
|
import { Link } from '@/i18n/routing'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
import { MapPin, SlidersHorizontal, Check } from 'lucide-react'
|
import { MapPin, SlidersHorizontal, Check } from 'lucide-react'
|
||||||
|
import LiveFilterForm from '@/components/LiveFilterForm'
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ locale: string }>
|
params: Promise<{ locale: string, category: string }>
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
search?: string
|
search?: string
|
||||||
neighborhood?: string
|
neighborhood?: string
|
||||||
@@ -14,8 +16,10 @@ interface PageProps {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function RestaurantsPage({ params, searchParams }: PageProps) {
|
export const dynamic = 'force-dynamic'
|
||||||
const { locale } = await params
|
|
||||||
|
export default async function DynamicCategoryPage({ params, searchParams }: PageProps) {
|
||||||
|
const { locale, category: categorySlug } = await params
|
||||||
setRequestLocale(locale)
|
setRequestLocale(locale)
|
||||||
|
|
||||||
const { search, neighborhood, price, approved } = await searchParams
|
const { search, neighborhood, price, approved } = await searchParams
|
||||||
@@ -25,7 +29,8 @@ export default async function RestaurantsPage({ params, searchParams }: PageProp
|
|||||||
|
|
||||||
// Find Category Restoran
|
// Find Category Restoran
|
||||||
const categories = await mockDb.getCategories()
|
const categories = await mockDb.getCategories()
|
||||||
const currentCategory = categories.find(c => c.slug === 'restoran')
|
const currentCategory = categories.find(c => c.slug === categorySlug)
|
||||||
|
if (!currentCategory) notFound()
|
||||||
const categoryId = currentCategory?.id
|
const categoryId = currentCategory?.id
|
||||||
|
|
||||||
// Get active neighborhoods for filter
|
// Get active neighborhoods for filter
|
||||||
@@ -57,7 +62,7 @@ export default async function RestaurantsPage({ params, searchParams }: PageProp
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-heading font-extrabold text-pine lowercase">
|
<h1 className="text-3xl font-heading font-extrabold text-pine lowercase">
|
||||||
{t('restoran')}
|
{getLocalizedName(currentCategory)}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-shutter font-mono uppercase tracking-wider mt-1">
|
<p className="text-xs text-shutter font-mono uppercase tracking-wider mt-1">
|
||||||
marmaris local • {listings.length} {locale === 'tr' ? 'sonuç' : locale === 'en' ? 'results' : 'результатов'}
|
marmaris local • {listings.length} {locale === 'tr' ? 'sonuç' : locale === 'en' ? 'results' : 'результатов'}
|
||||||
@@ -71,7 +76,7 @@ export default async function RestaurantsPage({ params, searchParams }: PageProp
|
|||||||
<span>filtreler</span>
|
<span>filtreler</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="GET" className="grid grid-cols-1 sm:grid-cols-4 gap-4 items-end">
|
<LiveFilterForm>
|
||||||
{/* Search Input */}
|
{/* Search Input */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-[11px] font-mono uppercase tracking-wider text-shutter">Arama</label>
|
<label className="block text-[11px] font-mono uppercase tracking-wider text-shutter">Arama</label>
|
||||||
@@ -129,14 +134,9 @@ export default async function RestaurantsPage({ params, searchParams }: PageProp
|
|||||||
<span className="text-pine">{t('filterApproved')}</span>
|
<span className="text-pine">{t('filterApproved')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button
|
<div className="flex-1" />
|
||||||
type="submit"
|
|
||||||
className="flex-1 bg-turquoise hover:bg-turquoise/90 text-paper text-xs font-bold py-2.5 px-4 rounded-xl transition text-center"
|
|
||||||
>
|
|
||||||
Filtrele
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</LiveFilterForm>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results */}
|
{/* Results */}
|
||||||
@@ -107,7 +107,7 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
|||||||
{locale === 'tr' ? 'kendi işletmenizi önermek ister misiniz?' : locale === 'en' ? 'would you like to suggest your own business?' : 'хотите предложить свой бизнес?'}
|
{locale === 'tr' ? 'kendi işletmenizi önermek ister misiniz?' : locale === 'en' ? 'would you like to suggest your own business?' : 'хотите предложить свой бизнес?'}
|
||||||
</h3>
|
</h3>
|
||||||
<Link
|
<Link
|
||||||
href="/isletme-ekle"
|
href="/add-business"
|
||||||
className="inline-flex items-center gap-2 bg-turquoise hover:bg-turquoise/90 text-paper font-bold text-xs py-3.5 px-6 rounded-xl transition shadow-sm"
|
className="inline-flex items-center gap-2 bg-turquoise hover:bg-turquoise/90 text-paper font-bold text-xs py-3.5 px-6 rounded-xl transition shadow-sm"
|
||||||
>
|
>
|
||||||
{navT('addBusiness')}
|
{navT('addBusiness')}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { mockDb } from '@/lib/mockDb'
|
||||||
|
import { createOrUpdateCategoryAction } from '@/app/actions'
|
||||||
|
import { Link } from '@/i18n/routing'
|
||||||
|
import { ArrowLeft } from 'lucide-react'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export default async function CategoryFormPage({ params }: { params: Promise<{ id: string, locale: string }> }) {
|
||||||
|
const resolvedParams = await params
|
||||||
|
const isNew = resolvedParams.id === 'new'
|
||||||
|
let category = null
|
||||||
|
|
||||||
|
if (!isNew) {
|
||||||
|
category = await mockDb.getCategoryById(resolvedParams.id)
|
||||||
|
if (!category) {
|
||||||
|
redirect(`/${resolvedParams.locale}/admin/categories`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/admin/categories" className="p-2 hover:bg-stone-deep rounded-full transition-colors text-shutter hover:text-ink">
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-heading font-extrabold text-pine lowercase">
|
||||||
|
{isNew ? 'yeni kategori' : 'kategoriyi düzenle'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-paper p-6 rounded-2xl shadow-sm border border-pine/8">
|
||||||
|
<form action={createOrUpdateCategoryAction} className="space-y-5">
|
||||||
|
<input type="hidden" name="id" value={resolvedParams.id} />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-pine uppercase tracking-wider mb-1.5">Slug (URL)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="slug"
|
||||||
|
defaultValue={category?.slug || ''}
|
||||||
|
required
|
||||||
|
className="w-full bg-stone-deep/30 border border-pine/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-turquoise/50"
|
||||||
|
placeholder="orn: restoran"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-pine uppercase tracking-wider mb-1.5">Adı (TR)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="nameTr"
|
||||||
|
defaultValue={category?.nameTr || ''}
|
||||||
|
required
|
||||||
|
className="w-full bg-stone-deep/30 border border-pine/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-turquoise/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-pine uppercase tracking-wider mb-1.5">Adı (EN)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="nameEn"
|
||||||
|
defaultValue={category?.nameEn || ''}
|
||||||
|
required
|
||||||
|
className="w-full bg-stone-deep/30 border border-pine/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-turquoise/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-pine uppercase tracking-wider mb-1.5">Adı (RU)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="nameRu"
|
||||||
|
defaultValue={category?.nameRu || ''}
|
||||||
|
required
|
||||||
|
className="w-full bg-stone-deep/30 border border-pine/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-turquoise/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 flex items-center justify-end gap-3 border-t border-dashed border-pine/10">
|
||||||
|
<Link
|
||||||
|
href="/admin/categories"
|
||||||
|
className="px-6 py-2.5 rounded-xl font-bold text-xs text-shutter hover:text-ink hover:bg-stone-deep transition-colors"
|
||||||
|
>
|
||||||
|
İptal
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-turquoise hover:bg-turquoise/90 text-paper px-6 py-2.5 rounded-xl font-bold text-xs transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
{isNew ? 'Oluştur' : 'Kaydet'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
import { mockDb } from '@/lib/mockDb'
|
import { mockDb } from '@/lib/mockDb'
|
||||||
|
import { Link } from '@/i18n/routing'
|
||||||
|
import { Plus, Edit2, Trash2 } from 'lucide-react'
|
||||||
|
import { deleteCategoryAction } from '@/app/actions'
|
||||||
|
import DeleteButton from '@/components/DeleteButton'
|
||||||
|
|
||||||
export default async function AdminCategoriesPage() {
|
export default async function AdminCategoriesPage() {
|
||||||
const categories = await mockDb.getCategories()
|
const categories = await mockDb.getCategories()
|
||||||
@@ -6,12 +10,19 @@ export default async function AdminCategoriesPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">kategoriler</h2>
|
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">kategoriler</h2>
|
||||||
<p className="text-ink/65 text-xs font-medium mt-1">
|
<p className="text-ink/65 text-xs font-medium mt-1">
|
||||||
Sistemde listelenen işletmelerin sınıflandırıldığı ana kategoriler.
|
Sistemde listelenen işletmelerin sınıflandırıldığı ana kategoriler.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/admin/categories/new"
|
||||||
|
className="flex items-center gap-2 bg-turquoise hover:bg-turquoise/90 text-paper px-4 py-2.5 rounded-xl font-bold text-sm transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Yeni Kategori</span>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-paper border border-pine/8 rounded-2xl shadow-sm overflow-hidden max-w-4xl">
|
<div className="bg-paper border border-pine/8 rounded-2xl shadow-sm overflow-hidden max-w-4xl">
|
||||||
@@ -23,6 +34,7 @@ export default async function AdminCategoriesPage() {
|
|||||||
<th className="px-6 py-4 text-left">Adı (TR)</th>
|
<th className="px-6 py-4 text-left">Adı (TR)</th>
|
||||||
<th className="px-6 py-4 text-left">Name (EN)</th>
|
<th className="px-6 py-4 text-left">Name (EN)</th>
|
||||||
<th className="px-6 py-4 text-left">Имя (RU)</th>
|
<th className="px-6 py-4 text-left">Имя (RU)</th>
|
||||||
|
<th className="px-6 py-4 text-right">İşlemler</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-dashed divide-pine/8 text-ink/85 font-medium">
|
<tbody className="divide-y divide-dashed divide-pine/8 text-ink/85 font-medium">
|
||||||
@@ -33,6 +45,19 @@ export default async function AdminCategoriesPage() {
|
|||||||
<td className="px-6 py-4 font-heading font-bold text-pine lowercase text-sm">{cat.nameTr}</td>
|
<td className="px-6 py-4 font-heading font-bold text-pine lowercase text-sm">{cat.nameTr}</td>
|
||||||
<td className="px-6 py-4 text-xs">{cat.nameEn}</td>
|
<td className="px-6 py-4 text-xs">{cat.nameEn}</td>
|
||||||
<td className="px-6 py-4 text-xs">{cat.nameRu}</td>
|
<td className="px-6 py-4 text-xs">{cat.nameRu}</td>
|
||||||
|
<td className="px-6 py-4 text-right space-x-2">
|
||||||
|
<Link
|
||||||
|
href={`/admin/categories/${cat.id}`}
|
||||||
|
className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-stone hover:bg-turquoise/10 text-shutter hover:text-turquoise transition-colors"
|
||||||
|
title="Düzenle"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
<form action={deleteCategoryAction} className="inline-block">
|
||||||
|
<input type="hidden" name="id" value={cat.id} />
|
||||||
|
<DeleteButton />
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export default async function AdminCollectionsPage() {
|
|||||||
<div className="text-xs text-ink/65 mt-0.5 line-clamp-1 max-w-xs">{col.descriptionTr}</div>
|
<div className="text-xs text-ink/65 mt-0.5 line-clamp-1 max-w-xs">{col.descriptionTr}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 font-mono text-xs text-turquoise">
|
<td className="px-6 py-4 font-mono text-xs text-turquoise">
|
||||||
/secki/{col.slug}
|
/collection/{col.slug}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 font-mono text-xs text-pine font-semibold">
|
<td className="px-6 py-4 font-mono text-xs text-pine font-semibold">
|
||||||
{listingCount} Mekan
|
{listingCount} Mekan
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react'
|
||||||
|
import { useRouter } from '@/i18n/routing'
|
||||||
|
import { createOrUpdateEventAction } from '@/app/actions'
|
||||||
|
import { ArrowLeft, Save, Loader2, Sparkles } from 'lucide-react'
|
||||||
|
import { Link } from '@/i18n/routing'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
event: any | null
|
||||||
|
listings: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventFormClient({ event, listings }: Props) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Format dates for datetime-local input (YYYY-MM-DDTHH:MM)
|
||||||
|
const formatDateForInput = (dateVal: any) => {
|
||||||
|
if (!dateVal) return ''
|
||||||
|
const d = new Date(dateVal)
|
||||||
|
const pad = (num: number) => String(num).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
id: event?.id || 'new',
|
||||||
|
slug: event?.slug || '',
|
||||||
|
listingId: event?.listingId || '',
|
||||||
|
titleTr: event?.titleTr || '',
|
||||||
|
titleEn: event?.titleEn || '',
|
||||||
|
titleRu: event?.titleRu || '',
|
||||||
|
descriptionTr: event?.descriptionTr || '',
|
||||||
|
descriptionEn: event?.descriptionEn || '',
|
||||||
|
descriptionRu: event?.descriptionRu || '',
|
||||||
|
startDate: formatDateForInput(event?.startDate),
|
||||||
|
endDate: formatDateForInput(event?.endDate),
|
||||||
|
isSponsored: event?.isSponsored ? 'true' : 'false',
|
||||||
|
coverImageUrl: event?.coverImage || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||||
|
|
||||||
|
const generateSlug = (text: string) => {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^\w\s-]/g, '')
|
||||||
|
.replace(/[\s_-]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||||
|
const { name, value } = e.target
|
||||||
|
setFormData(prev => {
|
||||||
|
if (name === 'titleTr' && !event) {
|
||||||
|
return { ...prev, [name]: value, slug: generateSlug(value) }
|
||||||
|
}
|
||||||
|
return { ...prev, [name]: value }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
if (!formData.slug || !formData.titleTr || !formData.startDate) {
|
||||||
|
setError('Lütfen zorunlu alanları doldurun (Başlık (TR), Slug, Başlangıç Tarihi).')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const data = new FormData()
|
||||||
|
Object.entries(formData).forEach(([key, val]) => {
|
||||||
|
data.append(key, val)
|
||||||
|
})
|
||||||
|
if (selectedFile) {
|
||||||
|
data.append('coverImageFile', selectedFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await createOrUpdateEventAction(data)
|
||||||
|
if (res.success) {
|
||||||
|
router.push('/admin/events')
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
setError(res.error || 'Kaydetme işlemi başarısız.')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-bougainvillea/5 border border-bougainvillea/20 text-bougainvillea text-xs font-semibold p-4 rounded-xl">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-paper border border-pine/8 rounded-3xl p-6 sm:p-8 shadow-sm space-y-6">
|
||||||
|
{/* Core fields */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-shutter uppercase tracking-wider mb-2">Başlık (Türkçe) *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="titleTr"
|
||||||
|
value={formData.titleTr}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full text-xs font-semibold px-4 py-3 bg-stone/50 border border-pine/8 rounded-xl focus:border-turquoise focus:outline-none"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-shutter uppercase tracking-wider mb-2">Slug *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="slug"
|
||||||
|
value={formData.slug}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full text-xs font-semibold px-4 py-3 bg-stone/50 border border-pine/8 rounded-xl focus:border-turquoise focus:outline-none"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-shutter uppercase tracking-wider mb-2">Başlık (İngilizce)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="titleEn"
|
||||||
|
value={formData.titleEn}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full text-xs font-semibold px-4 py-3 bg-stone/50 border border-pine/8 rounded-xl focus:border-turquoise focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-shutter uppercase tracking-wider mb-2">Başlık (Rusça)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="titleRu"
|
||||||
|
value={formData.titleRu}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full text-xs font-semibold px-4 py-3 bg-stone/50 border border-pine/8 rounded-xl focus:border-turquoise focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Association & Sponsored */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-shutter uppercase tracking-wider mb-2">Düzenleyen Mekan</label>
|
||||||
|
<select
|
||||||
|
name="listingId"
|
||||||
|
value={formData.listingId}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full text-xs font-semibold px-4 py-3 bg-stone/50 border border-pine/8 rounded-xl focus:border-turquoise focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Seçilmedi</option>
|
||||||
|
{listings.map(l => (
|
||||||
|
<option key={l.id} value={l.id}>{l.nameTr}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-shutter uppercase tracking-wider mb-2">Öne Çıkar / Sponsorlu</label>
|
||||||
|
<select
|
||||||
|
name="isSponsored"
|
||||||
|
value={formData.isSponsored}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full text-xs font-semibold px-4 py-3 bg-stone/50 border border-pine/8 rounded-xl focus:border-turquoise focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="false">Hayır</option>
|
||||||
|
<option value="true">Evet (Sponsorlu Etiketli)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dates */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-shutter uppercase tracking-wider mb-2">Başlangıç Tarihi *</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
name="startDate"
|
||||||
|
value={formData.startDate}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full text-xs font-semibold px-4 py-3 bg-stone/50 border border-pine/8 rounded-xl focus:border-turquoise focus:outline-none"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-shutter uppercase tracking-wider mb-2">Bitiş Tarihi (Opsiyonel)</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
name="endDate"
|
||||||
|
value={formData.endDate}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full text-xs font-semibold px-4 py-3 bg-stone/50 border border-pine/8 rounded-xl focus:border-turquoise focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Descriptions */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-shutter uppercase tracking-wider mb-2">Açıklama (Türkçe)</label>
|
||||||
|
<textarea
|
||||||
|
name="descriptionTr"
|
||||||
|
value={formData.descriptionTr}
|
||||||
|
onChange={handleChange}
|
||||||
|
rows={3}
|
||||||
|
className="w-full text-xs font-semibold px-4 py-3 bg-stone/50 border border-pine/8 rounded-xl focus:border-turquoise focus:outline-none resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-shutter uppercase tracking-wider mb-2">Açıklama (İngilizce)</label>
|
||||||
|
<textarea
|
||||||
|
name="descriptionEn"
|
||||||
|
value={formData.descriptionEn}
|
||||||
|
onChange={handleChange}
|
||||||
|
rows={3}
|
||||||
|
className="w-full text-xs font-semibold px-4 py-3 bg-stone/50 border border-pine/8 rounded-xl focus:border-turquoise focus:outline-none resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-shutter uppercase tracking-wider mb-2">Açıklama (Rusça)</label>
|
||||||
|
<textarea
|
||||||
|
name="descriptionRu"
|
||||||
|
value={formData.descriptionRu}
|
||||||
|
onChange={handleChange}
|
||||||
|
rows={3}
|
||||||
|
className="w-full text-xs font-semibold px-4 py-3 bg-stone/50 border border-pine/8 rounded-xl focus:border-turquoise focus:outline-none resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cover Image */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-shutter uppercase tracking-wider mb-2">Kapak Görseli</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
|
||||||
|
className="w-full text-xs font-semibold file:mr-4 file:py-2 file:px-4 file:rounded-xl file:border-0 file:bg-turquoise/10 file:text-turquoise file:font-bold hover:file:bg-turquoise/20 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{formData.coverImageUrl && !selectedFile && (
|
||||||
|
<div className="text-xs font-semibold text-shutter">
|
||||||
|
Mevcut Görsel: <a href={formData.coverImageUrl} target="_blank" rel="noopener noreferrer" className="text-turquoise underline">{formData.coverImageUrl}</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Actions */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Link
|
||||||
|
href="/admin/events"
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs text-shutter hover:text-pine font-bold transition"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-3.5 h-3.5" />
|
||||||
|
Vazgeç
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="inline-flex items-center gap-1.5 bg-turquoise hover:bg-turquoise/90 text-paper text-xs font-bold py-3.5 px-6 rounded-xl shadow-sm transition active:scale-95 duration-150 disabled:opacity-50 disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
Kaydediliyor...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
Etkinliği Kaydet
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { mockDb } from '@/lib/mockDb'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import EventFormClient from './EventFormClient'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ locale: string; id: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminEventDetailPage({ params }: Props) {
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
let event = null
|
||||||
|
if (id !== 'new') {
|
||||||
|
event = await mockDb.getEventById(id)
|
||||||
|
if (!event) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get listings so the admin can associate the event with a venue
|
||||||
|
const listings = await mockDb.getListings()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">
|
||||||
|
{id === 'new' ? 'yeni etkinlik ekle' : 'etkinliği düzenle'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-ink/65 text-xs font-medium mt-1">
|
||||||
|
Etkinlik adını, tarih aralığını ve sponsorluk durumunu belirtin. Kapak görseli eklemeyi unutmayın.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EventFormClient event={event} listings={listings} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { mockDb } from '@/lib/mockDb'
|
||||||
|
import { deleteEventAction } from '@/app/actions'
|
||||||
|
import { Link } from '@/i18n/routing'
|
||||||
|
import { Edit, Trash, Plus, Calendar, CheckCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
export default async function AdminEventsPage() {
|
||||||
|
const events = await mockDb.getEvents()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">etkinlikler</h2>
|
||||||
|
<p className="text-ink/65 text-xs font-medium mt-1">
|
||||||
|
Canlı müzik, caz, festival ve gastronomi günlerinin yönetimi.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/admin/events/new"
|
||||||
|
className="inline-flex items-center gap-1.5 bg-turquoise hover:bg-turquoise/90 text-paper text-xs font-bold py-3 px-5 rounded-xl shadow-sm transition active:scale-95 duration-150"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Yeni Etkinlik Ekle
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Events Table */}
|
||||||
|
<div className="bg-paper border border-pine/8 rounded-2xl shadow-sm overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-pine/8 text-sm">
|
||||||
|
<thead className="bg-stone-deep/40 text-shutter font-mono text-[10px] uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 text-left">Başlık</th>
|
||||||
|
<th className="px-6 py-4 text-left">Mekan</th>
|
||||||
|
<th className="px-6 py-4 text-left">Tarih</th>
|
||||||
|
<th className="px-6 py-4 text-left">Öne Çıkarılan</th>
|
||||||
|
<th className="px-6 py-4 text-right">Aksiyonlar</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-dashed divide-pine/8 text-ink/80">
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-6 py-12 text-center text-xs text-shutter font-medium">
|
||||||
|
Kayıtlı etkinlik bulunmuyor.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
events.map((e) => {
|
||||||
|
return (
|
||||||
|
<tr key={e.id} className="hover:bg-stone/20 transition duration-150">
|
||||||
|
<td className="px-6 py-4 font-medium">
|
||||||
|
<Link href={`/admin/events/${e.id}`} className="font-heading font-bold text-pine lowercase text-sm hover:text-turquoise transition-colors">
|
||||||
|
{e.titleTr}
|
||||||
|
</Link>
|
||||||
|
<div className="text-[10px] text-shutter font-mono uppercase tracking-wider mt-0.5">{e.slug}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-xs font-semibold text-pine">
|
||||||
|
{e.listing ? e.listing.nameTr : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 font-mono text-xs">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar className="w-3.5 h-3.5 text-shutter/60" />
|
||||||
|
{new Date(e.startDate).toLocaleDateString('tr-TR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
{e.isSponsored ? (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-paper px-2.5 py-0.5 text-[10px] font-mono font-bold text-turquoise border border-turquoise/20 uppercase tracking-wider">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5" />
|
||||||
|
Sponsorlu
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-paper px-2.5 py-0.5 text-[10px] font-mono font-medium text-shutter/60 border border-pine/8 uppercase tracking-wider">
|
||||||
|
Normal
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right whitespace-nowrap">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/admin/events/${e.id}`}
|
||||||
|
className="p-1.5 bg-paper text-shutter hover:text-turquoise hover:bg-turquoise/5 rounded-lg border border-pine/10 hover:border-turquoise/25 transition shadow-sm"
|
||||||
|
title="Düzenle"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<form action={async () => {
|
||||||
|
'use server'
|
||||||
|
await deleteEventAction(e.id)
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="p-1.5 bg-paper text-shutter hover:text-bougainvillea hover:bg-bougainvillea/5 rounded-lg border border-pine/10 hover:border-bougainvillea/25 transition shadow-sm"
|
||||||
|
title="Sil"
|
||||||
|
>
|
||||||
|
<Trash className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { signOut } from 'next-auth/react'
|
import { signOut } from 'next-auth/react'
|
||||||
import { Link, usePathname } from '@/i18n/routing'
|
import { Link, usePathname } from '@/i18n/routing'
|
||||||
import { LayoutDashboard, FileText, Inbox, ClipboardList, Map, LogOut, Menu, X } from 'lucide-react'
|
import { LayoutDashboard, FileText, Inbox, ClipboardList, Map, LogOut, Menu, X, Globe, Calendar, Trash2 } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
@@ -12,12 +12,15 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: 'Dashboard', href: '/admin', icon: LayoutDashboard },
|
{ name: 'Dashboard', href: '/admin', icon: LayoutDashboard },
|
||||||
{ name: 'Mekanlar', href: '/admin/listings', icon: ClipboardList },
|
{ name: 'Mekanlar', href: '/admin/listings', icon: ClipboardList },
|
||||||
|
{ name: 'Etkinlikler', href: '/admin/events', icon: Calendar },
|
||||||
{ name: 'Yazılar (Blog)', href: '/admin/blog', icon: FileText },
|
{ name: 'Yazılar (Blog)', href: '/admin/blog', icon: FileText },
|
||||||
{ name: 'Seçkiler (Kürasyon)', href: '/admin/collections', icon: ClipboardList },
|
{ name: 'Seçkiler (Kürasyon)', href: '/admin/collections', icon: ClipboardList },
|
||||||
|
{ name: 'Widget Ortakları', href: '/admin/widget-partners', icon: Globe },
|
||||||
{ name: 'Başvurular', href: '/admin/submissions', icon: FileText },
|
{ name: 'Başvurular', href: '/admin/submissions', icon: FileText },
|
||||||
{ name: 'Mesajlar', href: '/admin/messages', icon: Inbox },
|
{ name: 'Mesajlar', href: '/admin/messages', icon: Inbox },
|
||||||
{ name: 'Kategoriler', href: '/admin/categories', icon: LayoutDashboard },
|
{ name: 'Kategoriler', href: '/admin/categories', icon: LayoutDashboard },
|
||||||
{ name: 'Mahalleler', href: '/admin/neighborhoods', icon: Map },
|
{ name: 'Mahalleler', href: '/admin/neighborhoods', icon: Map },
|
||||||
|
{ name: 'Çöp Kutusu', href: '/admin/trash', icon: Trash2 },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { mockDb } from '@/lib/mockDb'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { Link } from '@/i18n/routing'
|
||||||
|
import { BarChart3, Lock, ArrowLeft, ArrowUpRight, Zap } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ locale: string; id: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ListingAnalyticsPage({ params }: Props) {
|
||||||
|
const { id } = await params
|
||||||
|
const listing = await mockDb.getListingById(id)
|
||||||
|
|
||||||
|
if (!listing) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get daily analytics logs
|
||||||
|
const analytics = await mockDb.getAnalytics(id)
|
||||||
|
|
||||||
|
// Aggregated totals
|
||||||
|
const totalViews = analytics.reduce((sum, item) => sum + item.views, 0)
|
||||||
|
const totalWhatsapp = analytics.reduce((sum, item) => sum + item.whatsappClicks, 0)
|
||||||
|
const totalPhone = analytics.reduce((sum, item) => sum + item.phoneClicks, 0)
|
||||||
|
const totalMenu = analytics.reduce((sum, item) => sum + item.menuClicks, 0)
|
||||||
|
|
||||||
|
// Max views for visual scaling of bar charts
|
||||||
|
const maxViews = Math.max(...analytics.map(a => a.views), 1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-4xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Link
|
||||||
|
href="/admin/listings"
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs text-shutter hover:text-pine font-bold transition"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-3.5 h-3.5" />
|
||||||
|
Mekanlara Geri Dön
|
||||||
|
</Link>
|
||||||
|
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">
|
||||||
|
{listing.nameTr} <span className="text-turquoise">analitik</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-ink/65 text-xs font-medium">
|
||||||
|
İşletmenin aldığı görüntülenme, arama ve WhatsApp iletişim tıklama detayları.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feature badge */}
|
||||||
|
<div>
|
||||||
|
{listing.isFeatured ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-paper px-3.5 py-1.5 text-[10px] font-mono font-bold text-turquoise border border-turquoise/25 uppercase tracking-wider shadow-sm">
|
||||||
|
<Zap className="w-3.5 h-3.5 fill-turquoise" />
|
||||||
|
Öne Çıkan Paket
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-paper px-3.5 py-1.5 text-[10px] font-mono font-medium text-shutter/65 border border-pine/8 uppercase tracking-wider shadow-sm">
|
||||||
|
Standart Paket
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Freemium view lock if standard package */}
|
||||||
|
{!listing.isFeatured ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Freemium summary card (always visible) */}
|
||||||
|
<div className="bg-paper border border-pine/8 rounded-2xl p-6 sm:p-8 shadow-sm">
|
||||||
|
<span className="text-[10px] font-mono text-shutter uppercase tracking-wider block mb-1">Toplam Görüntülenme (Views)</span>
|
||||||
|
<span className="text-4xl font-heading font-extrabold text-pine leading-none">{totalViews || 142}</span>
|
||||||
|
<p className="text-ink/60 text-xs font-medium mt-2 leading-relaxed">
|
||||||
|
Standart listelemede sadece toplam görüntülenme sayısı paylaşılır. Ayrıntılı iletişim istatistikleri ve grafikler kilitlidir.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Locked paywall overlay container */}
|
||||||
|
<div className="bg-paper border border-pine/8 rounded-3xl p-8 text-center relative overflow-hidden shadow-sm flex flex-col items-center justify-center space-y-6">
|
||||||
|
<div className="absolute inset-0 bg-stone/20 backdrop-blur-[2px] pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="relative z-10 w-14 h-14 rounded-full bg-bougainvillea/5 border border-bougainvillea/15 flex items-center justify-center text-bougainvillea shadow-sm animate-pulse">
|
||||||
|
<Lock className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 max-w-md space-y-2">
|
||||||
|
<h3 className="font-heading font-extrabold text-lg text-pine lowercase">Detaylı İstatistiklerin Kilidini Açın</h3>
|
||||||
|
<p className="text-ink/65 text-xs font-medium leading-relaxed">
|
||||||
|
Mekanınızı <strong>Öne Çıkan Paket'e</strong> yükselterek günlük tıklama grafiklerini, telefon aramalarını, WhatsApp yönlendirmelerini ve menü görüntülenme oranlarını inceleyin.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex flex-wrap gap-4 justify-center pt-2">
|
||||||
|
<Link
|
||||||
|
href={`/admin/listings/${listing.id}`}
|
||||||
|
className="inline-flex items-center gap-1.5 bg-turquoise hover:bg-turquoise/90 text-paper text-xs font-bold py-3 px-5 rounded-xl transition shadow-sm active:scale-95 duration-150"
|
||||||
|
>
|
||||||
|
<Zap className="w-4 h-4" />
|
||||||
|
Öne Çıkan Pakete Yükselt
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Stats overview cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-paper p-5 rounded-2xl border border-pine/8 shadow-sm">
|
||||||
|
<span className="text-[9px] font-mono text-shutter uppercase tracking-wider block mb-1">Görüntülenme</span>
|
||||||
|
<span className="text-2xl font-heading font-extrabold text-pine">{totalViews}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-paper p-5 rounded-2xl border border-pine/8 shadow-sm">
|
||||||
|
<span className="text-[9px] font-mono text-turquoise uppercase tracking-wider block mb-1">WhatsApp Clicks</span>
|
||||||
|
<span className="text-2xl font-heading font-extrabold text-turquoise">{totalWhatsapp}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-paper p-5 rounded-2xl border border-pine/8 shadow-sm">
|
||||||
|
<span className="text-[9px] font-mono text-pine uppercase tracking-wider block mb-1">Aramalar (Phone)</span>
|
||||||
|
<span className="text-2xl font-heading font-extrabold text-pine">{totalPhone}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-paper p-5 rounded-2xl border border-pine/8 shadow-sm">
|
||||||
|
<span className="text-[9px] font-mono text-bougainvillea uppercase tracking-wider block mb-1">Menü Tıklama</span>
|
||||||
|
<span className="text-2xl font-heading font-extrabold text-bougainvillea">{totalMenu}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Graphics block */}
|
||||||
|
<div className="bg-paper border border-pine/8 rounded-3xl p-6 sm:p-8 shadow-sm space-y-6">
|
||||||
|
<h3 className="font-heading font-extrabold text-base text-pine lowercase border-b border-dashed border-pine/8 pb-4 flex items-center gap-2">
|
||||||
|
<BarChart3 className="w-5 h-5 text-turquoise" />
|
||||||
|
Günlük Görüntülenme Dağılımı
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Custom pure styled HTML bars chart */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{analytics.map((item) => {
|
||||||
|
const percentage = Math.max((item.views / maxViews) * 100, 4)
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="space-y-1">
|
||||||
|
<div className="flex justify-between items-center text-xs font-mono">
|
||||||
|
<span className="text-shutter/80">{new Date(item.date).toLocaleDateString('tr-TR', { day: 'numeric', month: 'long', year: 'numeric' })}</span>
|
||||||
|
<span className="text-pine font-bold">{item.views} Görüntülenme</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-6 w-full bg-stone/40 rounded-lg overflow-hidden border border-pine/5 flex">
|
||||||
|
<div
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
className="bg-turquoise h-full rounded-l-lg transition-all duration-500 relative flex items-center justify-end pr-2"
|
||||||
|
>
|
||||||
|
<span className="text-[8px] font-mono font-bold text-paper">{Math.round(percentage)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 text-[9px] font-mono text-shutter/65 px-1 pt-0.5">
|
||||||
|
<span>WhatsApp: {item.whatsappClicks}</span>
|
||||||
|
<span>Arama: {item.phoneClicks}</span>
|
||||||
|
<span>Menü: {item.menuClicks}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { mockDb } from '@/lib/mockDb'
|
import { mockDb } from '@/lib/mockDb'
|
||||||
import { deleteListingAction } from '@/app/actions'
|
import { deleteListingAction } from '@/app/actions'
|
||||||
import { Link } from '@/i18n/routing'
|
import { Link } from '@/i18n/routing'
|
||||||
import { Edit, Trash, Plus, CheckCircle } from 'lucide-react'
|
import { Edit, Trash, Plus, CheckCircle, BarChart3 } from 'lucide-react'
|
||||||
|
|
||||||
export default async function AdminListingsPage() {
|
export default async function AdminListingsPage() {
|
||||||
const listings = await mockDb.getListings()
|
const listings = await mockDb.getListings()
|
||||||
@@ -80,6 +80,14 @@ export default async function AdminListingsPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-right whitespace-nowrap">
|
<td className="px-6 py-4 text-right whitespace-nowrap">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/admin/listings/${l.id}/analytics`}
|
||||||
|
className="p-1.5 bg-paper text-shutter hover:text-turquoise hover:bg-turquoise/5 rounded-lg border border-pine/10 hover:border-turquoise/25 transition shadow-sm"
|
||||||
|
title="Analitik İstatistikleri"
|
||||||
|
>
|
||||||
|
<BarChart3 className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/listings/${l.id}`}
|
href={`/admin/listings/${l.id}`}
|
||||||
className="p-1.5 bg-paper text-shutter hover:text-turquoise hover:bg-turquoise/5 rounded-lg border border-pine/10 hover:border-turquoise/25 transition shadow-sm"
|
className="p-1.5 bg-paper text-shutter hover:text-turquoise hover:bg-turquoise/5 rounded-lg border border-pine/10 hover:border-turquoise/25 transition shadow-sm"
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { deleteNeighborhoodAction } from '@/app/actions'
|
||||||
|
import { Trash } from 'lucide-react'
|
||||||
|
|
||||||
|
export function DeleteButton({ id }: { id: string }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!confirm('Bu mahalleyi silmek istediğinize emin misiniz?')) return
|
||||||
|
const res = await deleteNeighborhoodAction(id)
|
||||||
|
if (res?.error) {
|
||||||
|
alert(res.error)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-2 text-shutter hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
|
||||||
|
title="Sil"
|
||||||
|
>
|
||||||
|
<Trash className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { mockDb } from '@/lib/mockDb'
|
||||||
|
import { createOrUpdateNeighborhoodAction } from '@/app/actions'
|
||||||
|
import { Link } from '@/i18n/routing'
|
||||||
|
import { ArrowLeft } from 'lucide-react'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export default async function NeighborhoodFormPage({ params }: { params: Promise<{ id: string, locale: string }> }) {
|
||||||
|
const resolvedParams = await params
|
||||||
|
const isNew = resolvedParams.id === 'new'
|
||||||
|
let neighborhood = null
|
||||||
|
|
||||||
|
if (!isNew) {
|
||||||
|
neighborhood = await mockDb.getNeighborhoodById(resolvedParams.id)
|
||||||
|
if (!neighborhood) {
|
||||||
|
redirect(`/${resolvedParams.locale}/admin/neighborhoods`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/admin/neighborhoods" className="p-2 hover:bg-stone-deep rounded-full transition-colors text-shutter hover:text-ink">
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-heading font-extrabold text-pine lowercase">
|
||||||
|
{isNew ? 'yeni mahalle' : 'mahalleyi düzenle'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-paper p-6 rounded-2xl shadow-sm border border-pine/8">
|
||||||
|
<form action={createOrUpdateNeighborhoodAction} className="space-y-5">
|
||||||
|
<input type="hidden" name="id" value={resolvedParams.id} />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-pine uppercase tracking-wider mb-1.5">Slug (URL)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="slug"
|
||||||
|
defaultValue={neighborhood?.slug || ''}
|
||||||
|
required
|
||||||
|
className="w-full bg-stone-deep/30 border border-pine/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-turquoise/50"
|
||||||
|
placeholder="orn: tepe-mahallesi"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-pine uppercase tracking-wider mb-1.5">Adı (TR)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="nameTr"
|
||||||
|
defaultValue={neighborhood?.nameTr || ''}
|
||||||
|
required
|
||||||
|
className="w-full bg-stone-deep/30 border border-pine/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-turquoise/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-pine uppercase tracking-wider mb-1.5">Adı (EN)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="nameEn"
|
||||||
|
defaultValue={neighborhood?.nameEn || ''}
|
||||||
|
required
|
||||||
|
className="w-full bg-stone-deep/30 border border-pine/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-turquoise/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-pine uppercase tracking-wider mb-1.5">Adı (RU)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="nameRu"
|
||||||
|
defaultValue={neighborhood?.nameRu || ''}
|
||||||
|
required
|
||||||
|
className="w-full bg-stone-deep/30 border border-pine/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-turquoise/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 flex justify-end gap-3">
|
||||||
|
<Link
|
||||||
|
href="/admin/neighborhoods"
|
||||||
|
className="px-6 py-3 text-sm font-bold text-shutter hover:text-ink transition-colors"
|
||||||
|
>
|
||||||
|
İptal
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-6 py-3 bg-pine hover:bg-pine/90 text-paper text-sm font-bold rounded-xl transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
Kaydet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,42 +1,67 @@
|
|||||||
import { mockDb } from '@/lib/mockDb'
|
import { mockDb } from '@/lib/mockDb'
|
||||||
|
import { Link } from '@/i18n/routing'
|
||||||
|
import { Plus, Edit } from 'lucide-react'
|
||||||
|
import { DeleteButton } from './DeleteButton'
|
||||||
|
|
||||||
export default async function AdminNeighborhoodsPage() {
|
export default async function AdminNeighborhoodsPage() {
|
||||||
const neighborhoods = await mockDb.getNeighborhoods()
|
const neighborhoods = await mockDb.getNeighborhoods()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">mahalleler</h2>
|
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">mahalleler</h2>
|
||||||
<p className="text-ink/65 text-xs font-medium mt-1">
|
<p className="text-ink/65 text-xs font-medium mt-1">
|
||||||
Marmaris Local rehberindeki mekanların filtrelendiği mahalle/bölgeler.
|
Marmaris Local rehberindeki mekanların filtrelendiği mahalle/bölgeler.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/admin/neighborhoods/new"
|
||||||
|
className="inline-flex items-center gap-1.5 bg-turquoise hover:bg-turquoise/90 text-paper text-xs font-bold py-3 px-5 rounded-xl shadow-sm transition active:scale-95 duration-150"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Yeni Mahalle Ekle
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-paper border border-pine/8 rounded-2xl shadow-sm overflow-hidden max-w-4xl">
|
<div className="bg-paper border border-pine/8 rounded-2xl shadow-sm overflow-hidden max-w-5xl">
|
||||||
<table className="min-w-full divide-y divide-pine/8 text-sm">
|
<div className="overflow-x-auto">
|
||||||
<thead className="bg-stone-deep/40 text-shutter font-mono text-[10px] uppercase tracking-wider">
|
<table className="min-w-full divide-y divide-pine/8 text-sm">
|
||||||
<tr>
|
<thead className="bg-stone-deep/40 text-shutter font-mono text-[10px] uppercase tracking-wider">
|
||||||
<th className="px-6 py-4 text-left">ID</th>
|
<tr>
|
||||||
<th className="px-6 py-4 text-left">Slug</th>
|
<th className="px-6 py-4 text-left">ID</th>
|
||||||
<th className="px-6 py-4 text-left">Adı (TR)</th>
|
<th className="px-6 py-4 text-left">Slug</th>
|
||||||
<th className="px-6 py-4 text-left">Name (EN)</th>
|
<th className="px-6 py-4 text-left">Adı (TR)</th>
|
||||||
<th className="px-6 py-4 text-left">Имя (RU)</th>
|
<th className="px-6 py-4 text-left">Name (EN)</th>
|
||||||
</tr>
|
<th className="px-6 py-4 text-left">Имя (RU)</th>
|
||||||
</thead>
|
<th className="px-6 py-4 text-right">İşlemler</th>
|
||||||
<tbody className="divide-y divide-dashed divide-pine/8 text-ink/85 font-medium">
|
|
||||||
{neighborhoods.map((neigh) => (
|
|
||||||
<tr key={neigh.id} className="hover:bg-stone/20 transition duration-150">
|
|
||||||
<td className="px-6 py-4 font-mono text-xs text-shutter">{neigh.id}</td>
|
|
||||||
<td className="px-6 py-4 font-mono text-xs font-bold text-turquoise">{neigh.slug}</td>
|
|
||||||
<td className="px-6 py-4 font-heading font-bold text-pine lowercase text-sm">{neigh.nameTr}</td>
|
|
||||||
<td className="px-6 py-4 text-xs">{neigh.nameEn}</td>
|
|
||||||
<td className="px-6 py-4 text-xs">{neigh.nameRu}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody className="divide-y divide-dashed divide-pine/8 text-ink/85 font-medium">
|
||||||
</table>
|
{neighborhoods.map((neigh) => (
|
||||||
|
<tr key={neigh.id} className="hover:bg-stone/20 transition duration-150">
|
||||||
|
<td className="px-6 py-4 font-mono text-xs text-shutter">{neigh.id}</td>
|
||||||
|
<td className="px-6 py-4 font-mono text-xs font-bold text-turquoise">{neigh.slug}</td>
|
||||||
|
<td className="px-6 py-4 font-heading font-bold text-pine lowercase text-sm">{neigh.nameTr}</td>
|
||||||
|
<td className="px-6 py-4 text-xs">{neigh.nameEn}</td>
|
||||||
|
<td className="px-6 py-4 text-xs">{neigh.nameRu}</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/admin/neighborhoods/${neigh.id}`}
|
||||||
|
className="p-2 text-shutter hover:text-turquoise hover:bg-turquoise/10 rounded-lg transition-colors"
|
||||||
|
title="Düzenle"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
<DeleteButton id={neigh.id} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { restoreListingAction, hardDeleteListingAction } from '@/app/actions'
|
||||||
|
import { RotateCcw, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
|
export function TrashActionButtons({ id, type }: { id: string; type: 'listing' | 'event' }) {
|
||||||
|
const handleRestore = async () => {
|
||||||
|
if (!confirm('Bu ögeyi geri yüklemek istediğinize emin misiniz? Ana listeye eklenecektir.')) return
|
||||||
|
|
||||||
|
if (type === 'listing') {
|
||||||
|
const res = await restoreListingAction(id)
|
||||||
|
if (res?.error) alert(res.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHardDelete = async () => {
|
||||||
|
if (!confirm('DİKKAT: Bu ögeyi kalıcı olarak silmek istediğinize emin misiniz? Bu işlem geri alınamaz!')) return
|
||||||
|
|
||||||
|
if (type === 'listing') {
|
||||||
|
const res = await hardDeleteListingAction(id)
|
||||||
|
if (res?.error) alert(res.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleRestore}
|
||||||
|
className="p-2 text-shutter hover:text-turquoise hover:bg-turquoise/10 rounded-lg transition-colors"
|
||||||
|
title="Geri Yükle"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleHardDelete}
|
||||||
|
className="p-2 text-shutter hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
|
||||||
|
title="Kalıcı Olarak Sil"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { mockDb } from '@/lib/mockDb'
|
||||||
|
import { Link } from '@/i18n/routing'
|
||||||
|
import { TrashActionButtons } from './TrashActionButtons'
|
||||||
|
import { RotateCcw, Trash2, Calendar, MapPin } from 'lucide-react'
|
||||||
|
|
||||||
|
export default async function AdminTrashPage() {
|
||||||
|
const deletedListings = await mockDb.getDeletedListings()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">çöp kutusu</h2>
|
||||||
|
<p className="text-ink/65 text-xs font-medium mt-1">
|
||||||
|
Silinmiş mekanları buradan görebilir, geri yükleyebilir veya kalıcı olarak silebilirsiniz.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-paper border border-pine/8 rounded-2xl shadow-sm overflow-hidden max-w-5xl">
|
||||||
|
<div className="p-4 border-b border-pine/5 bg-stone-deep/20">
|
||||||
|
<h3 className="font-heading font-bold text-pine text-sm uppercase tracking-wider">Silinmiş Mekanlar</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deletedListings.length === 0 ? (
|
||||||
|
<div className="p-12 text-center text-shutter">
|
||||||
|
<Trash2 className="w-12 h-12 mx-auto mb-3 opacity-20" />
|
||||||
|
<p className="text-sm font-medium">Çöp kutusu boş.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-pine/8 text-sm">
|
||||||
|
<thead className="bg-stone-deep/40 text-shutter font-mono text-[10px] uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 text-left">Mekan Adı</th>
|
||||||
|
<th className="px-6 py-4 text-left">Kategori & Mahalle</th>
|
||||||
|
<th className="px-6 py-4 text-left">Silinme Tarihi</th>
|
||||||
|
<th className="px-6 py-4 text-right">İşlemler</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-dashed divide-pine/8 text-ink/85 font-medium">
|
||||||
|
{deletedListings.map((listing) => (
|
||||||
|
<tr key={listing.id} className="hover:bg-stone/20 transition duration-150">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="font-heading font-bold text-pine lowercase text-sm">
|
||||||
|
{listing.nameTr}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-shutter font-mono uppercase tracking-wider mt-0.5">
|
||||||
|
{listing.slug}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-xs">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{listing.category && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-turquoise">
|
||||||
|
{listing.category.nameTr}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{listing.neighborhood && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-shutter">
|
||||||
|
<MapPin className="w-3 h-3" />
|
||||||
|
{listing.neighborhood.nameTr}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 font-mono text-xs text-shutter">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Calendar className="w-3.5 h-3.5" />
|
||||||
|
{listing.deletedAt ? new Date(listing.deletedAt).toLocaleDateString('tr-TR', {
|
||||||
|
day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||||
|
}) : '-'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<TrashActionButtons id={listing.id} type="listing" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { mockDb } from '@/lib/mockDb'
|
||||||
|
import { Link } from '@/i18n/routing'
|
||||||
|
import { Globe, Calendar, CheckCircle, ExternalLink } from 'lucide-react'
|
||||||
|
|
||||||
|
export default async function AdminWidgetPartnersPage() {
|
||||||
|
const partners = await mockDb.getWidgetPartners()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">widget ortakları (backlinks)</h2>
|
||||||
|
<p className="text-ink/65 text-xs font-medium mt-1">
|
||||||
|
Ajans sinerjisi kapsamında Marmaris Local bölgesel öneri widget'ını kendi web sitelerine yerleştiren anlaşmalı işletmeler.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats row */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<div className="bg-paper p-6 rounded-2xl border border-pine/8 shadow-sm">
|
||||||
|
<span className="text-[10px] font-mono text-shutter uppercase tracking-wider block mb-1">Toplam Partner</span>
|
||||||
|
<span className="text-3xl font-heading font-extrabold text-pine leading-none">{partners.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-paper p-6 rounded-2xl border border-pine/8 shadow-sm">
|
||||||
|
<span className="text-[10px] font-mono text-shutter uppercase tracking-wider block mb-1">Aktif Backlinks</span>
|
||||||
|
<span className="text-3xl font-heading font-extrabold text-turquoise leading-none">{partners.filter(p => p.widgetSiteUrl).length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-paper p-6 rounded-2xl border border-pine/8 shadow-sm">
|
||||||
|
<span className="text-[10px] font-mono text-shutter uppercase tracking-wider block mb-1">Pilot Bölge</span>
|
||||||
|
<span className="text-3xl font-heading font-extrabold text-bougainvillea leading-none">yat limanı</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Partner List Table */}
|
||||||
|
<div className="bg-paper border border-pine/8 rounded-2xl shadow-sm overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-pine/8 text-sm">
|
||||||
|
<thead className="bg-stone-deep/40 text-shutter font-mono text-[10px] uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 text-left">Mekan</th>
|
||||||
|
<th className="px-6 py-4 text-left">Mahalle</th>
|
||||||
|
<th className="px-6 py-4 text-left">Kurulum Tarihi</th>
|
||||||
|
<th className="px-6 py-4 text-left">Müşteri Web Sitesi</th>
|
||||||
|
<th className="px-6 py-4 text-left">Durum</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-dashed divide-pine/8 text-ink/80">
|
||||||
|
{partners.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-6 py-12 text-center text-xs text-shutter font-medium">
|
||||||
|
Henüz widget kurulumu yapılmış partner bulunmuyor.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
partners.map((partner) => {
|
||||||
|
return (
|
||||||
|
<tr key={partner.id} className="hover:bg-stone/20 transition duration-150">
|
||||||
|
<td className="px-6 py-4 font-medium">
|
||||||
|
<Link href={`/admin/listings/${partner.id}`} className="font-heading font-bold text-pine lowercase text-sm hover:text-turquoise transition-colors">
|
||||||
|
{partner.nameTr}
|
||||||
|
</Link>
|
||||||
|
<div className="text-[10px] text-shutter font-mono uppercase tracking-wider mt-0.5">{partner.category?.nameTr}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 font-medium text-xs">
|
||||||
|
{partner.neighborhood?.nameTr}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 font-mono text-xs">
|
||||||
|
{partner.widgetInstalledAt ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar className="w-3.5 h-3.5 text-shutter/60" />
|
||||||
|
{new Date(partner.widgetInstalledAt).toLocaleDateString('tr-TR')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 font-mono text-xs">
|
||||||
|
{partner.widgetSiteUrl ? (
|
||||||
|
<a
|
||||||
|
href={partner.widgetSiteUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-turquoise hover:underline flex items-center gap-1 font-medium"
|
||||||
|
>
|
||||||
|
<Globe className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
{partner.widgetSiteUrl.replace('https://', '')}
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-paper px-2.5 py-0.5 text-[10px] font-mono font-bold text-turquoise border border-turquoise/20 uppercase tracking-wider">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5" />
|
||||||
|
widget aktif
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
|
||||||
import { mockDb } from '@/lib/mockDb'
|
|
||||||
import ListingCard from '@/components/ListingCard'
|
|
||||||
import { SlidersHorizontal } from 'lucide-react'
|
|
||||||
|
|
||||||
interface PageProps {
|
|
||||||
params: Promise<{ locale: string }>
|
|
||||||
searchParams: Promise<{
|
|
||||||
search?: string
|
|
||||||
neighborhood?: string
|
|
||||||
price?: string
|
|
||||||
approved?: string
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ApartsPage({ params, searchParams }: PageProps) {
|
|
||||||
const { locale } = await params
|
|
||||||
setRequestLocale(locale)
|
|
||||||
|
|
||||||
const { search, neighborhood, price, approved } = await searchParams
|
|
||||||
|
|
||||||
const t = await getTranslations('categories')
|
|
||||||
|
|
||||||
// Find Category Apart
|
|
||||||
const categories = await mockDb.getCategories()
|
|
||||||
const currentCategory = categories.find(c => c.slug === 'apart')
|
|
||||||
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">
|
|
||||||
{t('apart')}
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<form method="GET" className="grid grid-cols-1 sm:grid-cols-4 gap-4 items-end">
|
|
||||||
{/* 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>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="flex-1 bg-turquoise hover:bg-turquoise/90 text-paper text-xs font-bold py-2.5 px-4 rounded-xl transition text-center"
|
|
||||||
>
|
|
||||||
Filtrele
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -37,7 +37,7 @@ export default async function CollectionDetailPage({ params }: Props) {
|
|||||||
|
|
||||||
{/* Back Link */}
|
{/* Back Link */}
|
||||||
<Link
|
<Link
|
||||||
href="/seckiler"
|
href="/collections"
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 border border-pine/10 rounded-xl text-xs font-mono font-bold text-pine hover:bg-paper transition"
|
className="inline-flex items-center gap-1.5 px-4 py-2 border border-pine/10 rounded-xl text-xs font-mono font-bold text-pine hover:bg-paper transition"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-3.5 h-3.5" />
|
<ArrowLeft className="w-3.5 h-3.5" />
|
||||||
@@ -67,7 +67,7 @@ export default async function CollectionsIndexPage({ params }: { params: Promise
|
|||||||
|
|
||||||
<div className="p-6 sm:p-8 space-y-3">
|
<div className="p-6 sm:p-8 space-y-3">
|
||||||
<h2 className="font-heading font-extrabold text-lg text-pine group-hover:text-turquoise transition duration-150 lowercase leading-snug">
|
<h2 className="font-heading font-extrabold text-lg text-pine group-hover:text-turquoise transition duration-150 lowercase leading-snug">
|
||||||
<Link href={`/secki/${col.slug}`}>
|
<Link href={`/collection/${col.slug}`}>
|
||||||
{title}
|
{title}
|
||||||
</Link>
|
</Link>
|
||||||
</h2>
|
</h2>
|
||||||
@@ -80,7 +80,7 @@ export default async function CollectionsIndexPage({ params }: { params: Promise
|
|||||||
|
|
||||||
<div className="px-6 sm:px-8 pb-6 sm:pb-8 pt-2">
|
<div className="px-6 sm:px-8 pb-6 sm:pb-8 pt-2">
|
||||||
<Link
|
<Link
|
||||||
href={`/secki/${col.slug}`}
|
href={`/collection/${col.slug}`}
|
||||||
className="inline-flex items-center gap-1.5 text-xs font-mono font-bold text-turquoise hover:underline uppercase tracking-wider"
|
className="inline-flex items-center gap-1.5 text-xs font-mono font-bold text-turquoise hover:underline uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
listeyi incele →
|
listeyi incele →
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { mockDb } from '@/lib/mockDb'
|
||||||
|
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||||
|
import { Link } from '@/i18n/routing'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { Calendar, MapPin, Zap } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ locale: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EventsPage({ params }: Props) {
|
||||||
|
const { locale } = await params
|
||||||
|
setRequestLocale(locale)
|
||||||
|
const t = await getTranslations('events')
|
||||||
|
|
||||||
|
// Get active events from DB
|
||||||
|
const events = await mockDb.getEvents(true)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 flex-1 space-y-8">
|
||||||
|
{/* Intro section */}
|
||||||
|
<div className="max-w-2xl space-y-2">
|
||||||
|
<h1 className="font-heading font-extrabold text-3xl sm:text-5xl text-pine lowercase">
|
||||||
|
{t('title')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-ink/75 text-sm sm:text-base font-medium">
|
||||||
|
{t('subtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Events Grid */}
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<div className="bg-paper border border-pine/8 rounded-3xl p-12 text-center max-w-lg mx-auto">
|
||||||
|
<p className="text-shutter text-sm font-semibold">{t('noEvents')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{events.map((event) => {
|
||||||
|
const title = locale === 'tr' ? event.titleTr : locale === 'ru' ? event.titleRu : event.titleEn
|
||||||
|
const desc = locale === 'tr' ? event.descriptionTr : locale === 'ru' ? event.descriptionRu : event.descriptionEn
|
||||||
|
const venueName = event.listing ? event.listing.nameTr : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
className="bg-paper border border-pine/8 rounded-3xl overflow-hidden shadow-sm flex flex-col group hover:border-turquoise/30 hover:shadow-md transition duration-200"
|
||||||
|
>
|
||||||
|
{/* Image Cover */}
|
||||||
|
<div className="relative aspect-video bg-stone/40 overflow-hidden">
|
||||||
|
{event.coverImage ? (
|
||||||
|
<Image
|
||||||
|
src={event.coverImage}
|
||||||
|
alt={title}
|
||||||
|
fill
|
||||||
|
className="object-cover group-hover:scale-[1.02] transition duration-300"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-shutter">
|
||||||
|
<Calendar className="w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sponsored badge */}
|
||||||
|
{event.isSponsored && (
|
||||||
|
<span className="absolute top-4 left-4 inline-flex items-center gap-1 rounded-full bg-paper px-2.5 py-0.5 text-[9px] font-mono font-bold text-turquoise border border-turquoise/20 uppercase tracking-wider shadow-sm">
|
||||||
|
<Zap className="w-3 h-3 fill-turquoise text-turquoise" />
|
||||||
|
{t('sponsored')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event Details */}
|
||||||
|
<div className="p-6 flex-1 flex flex-col justify-between space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Date badge */}
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] font-mono text-shutter font-bold uppercase tracking-wider">
|
||||||
|
<Calendar className="w-3.5 h-3.5" />
|
||||||
|
<span>
|
||||||
|
{new Date(event.startDate).toLocaleDateString(locale, {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="font-heading font-extrabold text-lg text-pine lowercase group-hover:text-turquoise transition-colors leading-tight">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-ink/70 text-xs leading-relaxed font-medium line-clamp-3">
|
||||||
|
{desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hosting Venue info */}
|
||||||
|
{event.listing && (
|
||||||
|
<div className="border-t border-dashed border-pine/8 pt-4 flex items-center justify-between text-xs">
|
||||||
|
<div className="flex items-center gap-1 text-ink/75 font-semibold">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-shutter/65" />
|
||||||
|
<span className="lowercase text-shutter">{t('venue')}:</span>
|
||||||
|
<Link
|
||||||
|
href={`/${event.listing.category?.slug || 'isletme'}/${event.listing.slug}`}
|
||||||
|
className="text-pine hover:text-turquoise transition-colors"
|
||||||
|
>
|
||||||
|
{venueName}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
|
||||||
import { mockDb } from '@/lib/mockDb'
|
|
||||||
import ListingCard from '@/components/ListingCard'
|
|
||||||
import { SlidersHorizontal } from 'lucide-react'
|
|
||||||
|
|
||||||
interface PageProps {
|
|
||||||
params: Promise<{ locale: string }>
|
|
||||||
searchParams: Promise<{
|
|
||||||
search?: string
|
|
||||||
neighborhood?: string
|
|
||||||
price?: string
|
|
||||||
approved?: string
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function BusinessesPage({ params, searchParams }: PageProps) {
|
|
||||||
const { locale } = await params
|
|
||||||
setRequestLocale(locale)
|
|
||||||
|
|
||||||
const { search, neighborhood, price, approved } = await searchParams
|
|
||||||
|
|
||||||
const t = await getTranslations('categories')
|
|
||||||
|
|
||||||
// Find Category Isletme
|
|
||||||
const categories = await mockDb.getCategories()
|
|
||||||
const currentCategory = categories.find(c => c.slug === 'isletme')
|
|
||||||
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">
|
|
||||||
{t('isletme')}
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<form method="GET" className="grid grid-cols-1 sm:grid-cols-4 gap-4 items-end">
|
|
||||||
{/* 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>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="flex-1 bg-turquoise hover:bg-turquoise/90 text-paper text-xs font-bold py-2.5 px-4 rounded-xl transition text-center"
|
|
||||||
>
|
|
||||||
Filtrele
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { mockDb } from '../../lib/mockDb';
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Unbounded, Golos_Text, IBM_Plex_Mono } from "next/font/google";
|
import { Unbounded, Golos_Text, IBM_Plex_Mono } from "next/font/google";
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
@@ -57,6 +58,8 @@ export default async function RootLayout({
|
|||||||
const pathname = headersList.get('x-pathname') || '';
|
const pathname = headersList.get('x-pathname') || '';
|
||||||
const hideNavbarFooter = pathname.includes('/admin') || pathname.includes('/login');
|
const hideNavbarFooter = pathname.includes('/admin') || pathname.includes('/login');
|
||||||
|
|
||||||
|
const categories = await mockDb.getCategories();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang={locale}
|
lang={locale}
|
||||||
@@ -64,7 +67,7 @@ export default async function RootLayout({
|
|||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col font-sans" suppressHydrationWarning>
|
<body className="min-h-full flex flex-col font-sans" suppressHydrationWarning>
|
||||||
<NextIntlClientProvider messages={messages}>
|
<NextIntlClientProvider messages={messages}>
|
||||||
{!hideNavbarFooter && <Navbar />}
|
{!hideNavbarFooter && <Navbar categories={categories} />}
|
||||||
{children}
|
{children}
|
||||||
{!hideNavbarFooter && <Footer />}
|
{!hideNavbarFooter && <Footer />}
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
|
|||||||
@@ -32,11 +32,7 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
|
|||||||
isletme: 'https://images.unsplash.com/photo-1544551763-46a013bb70d5?w=800&auto=format&fit=crop&q=80'
|
isletme: 'https://images.unsplash.com/photo-1544551763-46a013bb70d5?w=800&auto=format&fit=crop&q=80'
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryPaths: Record<string, string> = {
|
|
||||||
restoran: '/restoranlar',
|
|
||||||
apart: '/apartlar',
|
|
||||||
isletme: '/isletmeler'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink">
|
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink">
|
||||||
@@ -77,7 +73,7 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
|
|||||||
|
|
||||||
{/* Search Form */}
|
{/* Search Form */}
|
||||||
<form
|
<form
|
||||||
action={`/${locale}/restoranlar`}
|
action={`/${locale}/restoran`}
|
||||||
method="GET"
|
method="GET"
|
||||||
className="max-w-xl mx-auto bg-paper p-2 rounded-2xl flex items-center shadow-lg border border-white/10"
|
className="max-w-xl mx-auto bg-paper p-2 rounded-2xl flex items-center shadow-lg border border-white/10"
|
||||||
>
|
>
|
||||||
@@ -122,7 +118,7 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
{categories.map((cat) => {
|
{categories.map((cat) => {
|
||||||
const imageUrl = categoryImages[cat.slug] || categoryImages.isletme
|
const imageUrl = categoryImages[cat.slug] || categoryImages.isletme
|
||||||
const path = categoryPaths[cat.slug] || '/isletmeler'
|
const path = `/${cat.slug}`
|
||||||
const catName = getLocalizedName(cat)
|
const catName = getLocalizedName(cat)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -172,7 +168,7 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/restoranlar?approved=true"
|
href="/restaurants?approved=true"
|
||||||
className="flex items-center gap-1 text-xs font-bold text-pine hover:text-turquoise transition-colors border-b border-pine/20 hover:border-turquoise pb-1 w-fit"
|
className="flex items-center gap-1 text-xs font-bold text-pine hover:text-turquoise transition-colors border-b border-pine/20 hover:border-turquoise pb-1 w-fit"
|
||||||
>
|
>
|
||||||
<span>{locale === 'tr' ? 'tüm onaylı mekanlar' : locale === 'en' ? 'all approved places' : 'все проверенные места'}</span>
|
<span>{locale === 'tr' ? 'tüm onaylı mekanlar' : locale === 'en' ? 'all approved places' : 'все проверенные места'}</span>
|
||||||
@@ -206,7 +202,7 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={neigh.id}
|
key={neigh.id}
|
||||||
href={`/mahalle/${neigh.slug}`}
|
href={`/neighborhood/${neigh.slug}`}
|
||||||
className="bg-paper p-5 rounded-xl border border-pine/8 text-center hover:border-turquoise/40 hover:bg-paper/90 transition shadow-sm group flex flex-col items-center gap-2"
|
className="bg-paper p-5 rounded-xl border border-pine/8 text-center hover:border-turquoise/40 hover:bg-paper/90 transition shadow-sm group flex flex-col items-center gap-2"
|
||||||
>
|
>
|
||||||
<MapPin className="w-5 h-5 text-shutter group-hover:text-turquoise transition-colors" />
|
<MapPin className="w-5 h-5 text-shutter group-hover:text-turquoise transition-colors" />
|
||||||
|
|||||||
+256
-13
@@ -1,4 +1,6 @@
|
|||||||
'use server'
|
"use server"
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
import { mockDb } from '@/lib/mockDb'
|
import { mockDb } from '@/lib/mockDb'
|
||||||
import { uploadToOpeninary } from '@/lib/openinary'
|
import { uploadToOpeninary } from '@/lib/openinary'
|
||||||
@@ -100,9 +102,9 @@ export async function approveSubmissionAction(id: string) {
|
|||||||
await mockDb.updateSubmissionStatus(id, 'APPROVED')
|
await mockDb.updateSubmissionStatus(id, 'APPROVED')
|
||||||
|
|
||||||
revalidatePath('/admin/submissions')
|
revalidatePath('/admin/submissions')
|
||||||
revalidatePath('/restoranlar')
|
revalidatePath('/restaurants')
|
||||||
revalidatePath('/apartlar')
|
revalidatePath('/aparts')
|
||||||
revalidatePath('/isletmeler')
|
revalidatePath('/businesses')
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,12 +117,32 @@ export async function rejectSubmissionAction(id: string) {
|
|||||||
export async function deleteListingAction(id: string) {
|
export async function deleteListingAction(id: string) {
|
||||||
await mockDb.deleteListing(id)
|
await mockDb.deleteListing(id)
|
||||||
revalidatePath('/admin/listings')
|
revalidatePath('/admin/listings')
|
||||||
revalidatePath('/restoranlar')
|
revalidatePath('/restaurants')
|
||||||
revalidatePath('/apartlar')
|
revalidatePath('/aparts')
|
||||||
revalidatePath('/isletmeler')
|
revalidatePath('/businesses')
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function restoreListingAction(id: string) {
|
||||||
|
await mockDb.restoreListing(id)
|
||||||
|
revalidatePath('/admin/trash')
|
||||||
|
revalidatePath('/admin/listings')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hardDeleteListingAction(id: string) {
|
||||||
|
try {
|
||||||
|
await mockDb.hardDeleteListing(id)
|
||||||
|
revalidatePath('/admin/trash')
|
||||||
|
return { success: true }
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.message?.includes('RESTRICT') || err.message?.includes('Foreign key constraint')) {
|
||||||
|
return { success: false, error: 'Silme başarısız: Bu mekana bağlı etkinlik (Event) veya kayıtlar var.' }
|
||||||
|
}
|
||||||
|
return { success: false, error: err.message || 'Bilinmeyen bir hata oluştu.' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function markMessageReadAction(id: string) {
|
export async function markMessageReadAction(id: string) {
|
||||||
await mockDb.markMessageAsRead(id)
|
await mockDb.markMessageAsRead(id)
|
||||||
revalidatePath('/admin/messages')
|
revalidatePath('/admin/messages')
|
||||||
@@ -225,9 +247,9 @@ export async function createOrUpdateListingAction(formData: FormData) {
|
|||||||
await mockDb.createListing(data)
|
await mockDb.createListing(data)
|
||||||
}
|
}
|
||||||
revalidatePath('/admin/listings')
|
revalidatePath('/admin/listings')
|
||||||
revalidatePath('/restoranlar')
|
revalidatePath('/restaurants')
|
||||||
revalidatePath('/apartlar')
|
revalidatePath('/aparts')
|
||||||
revalidatePath('/isletmeler')
|
revalidatePath('/businesses')
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return { success: false, error: err.message || 'Mekan kaydedilemedi.' }
|
return { success: false, error: err.message || 'Mekan kaydedilemedi.' }
|
||||||
@@ -307,7 +329,7 @@ export async function createOrUpdateBlogPostAction(formData: FormData) {
|
|||||||
export async function deleteCollectionAction(id: string) {
|
export async function deleteCollectionAction(id: string) {
|
||||||
await mockDb.deleteCollection(id)
|
await mockDb.deleteCollection(id)
|
||||||
revalidatePath('/admin/collections')
|
revalidatePath('/admin/collections')
|
||||||
revalidatePath('/seckiler')
|
revalidatePath('/collections')
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,10 +378,231 @@ export async function createOrUpdateCollectionAction(formData: FormData) {
|
|||||||
await mockDb.createCollection(data)
|
await mockDb.createCollection(data)
|
||||||
}
|
}
|
||||||
revalidatePath('/admin/collections')
|
revalidatePath('/admin/collections')
|
||||||
revalidatePath('/seckiler')
|
revalidatePath('/collections')
|
||||||
revalidatePath(`/secki/${slug}`)
|
revalidatePath(`/collection/${slug}`)
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return { success: false, error: err.message || 'Koleksiyon kaydedilemedi.' }
|
return { success: false, error: err.message || 'Koleksiyon kaydedilemedi.' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Events Actions (Phase 3)
|
||||||
|
export async function deleteEventAction(id: string) {
|
||||||
|
await mockDb.deleteEvent(id)
|
||||||
|
revalidatePath('/admin/events')
|
||||||
|
revalidatePath('/events')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOrUpdateEventAction(formData: FormData) {
|
||||||
|
const id = formData.get('id') as string | null
|
||||||
|
const slug = formData.get('slug') as string
|
||||||
|
const listingId = formData.get('listingId') as string | null || null
|
||||||
|
const titleTr = formData.get('titleTr') as string
|
||||||
|
const titleEn = formData.get('titleEn') as string
|
||||||
|
const titleRu = formData.get('titleRu') as string
|
||||||
|
const descriptionTr = formData.get('descriptionTr') as string
|
||||||
|
const descriptionEn = formData.get('descriptionEn') as string
|
||||||
|
const descriptionRu = formData.get('descriptionRu') as string
|
||||||
|
|
||||||
|
const startDateStr = formData.get('startDate') as string
|
||||||
|
const endDateStr = formData.get('endDate') as string | null
|
||||||
|
const isSponsored = formData.get('isSponsored') === 'true'
|
||||||
|
|
||||||
|
const startDate = new Date(startDateStr)
|
||||||
|
const endDate = endDateStr ? new Date(endDateStr) : null
|
||||||
|
|
||||||
|
// Handle Cover Image
|
||||||
|
const coverImageFile = formData.get('coverImageFile') as File | null
|
||||||
|
let coverImage = formData.get('coverImageUrl') as string | null || null
|
||||||
|
|
||||||
|
if (coverImageFile && coverImageFile.size > 0) {
|
||||||
|
try {
|
||||||
|
coverImage = await uploadToOpeninary(coverImageFile, `events/${slug}`)
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Event cover upload error:', e)
|
||||||
|
return { success: false, error: `Kapak görseli yüklenemedi: ${e.message}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
slug,
|
||||||
|
listingId,
|
||||||
|
titleTr,
|
||||||
|
titleEn,
|
||||||
|
titleRu,
|
||||||
|
descriptionTr,
|
||||||
|
descriptionEn,
|
||||||
|
descriptionRu,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
coverImage,
|
||||||
|
isSponsored
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (id && id !== 'new') {
|
||||||
|
await mockDb.updateEvent(id, data)
|
||||||
|
} else {
|
||||||
|
await mockDb.createEvent(data)
|
||||||
|
}
|
||||||
|
revalidatePath('/admin/events')
|
||||||
|
revalidatePath('/events')
|
||||||
|
revalidatePath(`/event/${slug}`)
|
||||||
|
return { success: true }
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, error: err.message || 'Etkinlik kaydedilemedi.' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Neighborhood Actions
|
||||||
|
export async function deleteNeighborhoodAction(id: string) {
|
||||||
|
try {
|
||||||
|
const listings = await mockDb.getListings({ neighborhoodId: id })
|
||||||
|
if (listings && listings.length > 0) {
|
||||||
|
return { success: false, error: `Silme başarısız: Bu mahalleye kayıtlı ${listings.length} adet mekan var.` }
|
||||||
|
}
|
||||||
|
|
||||||
|
await mockDb.deleteNeighborhood(id)
|
||||||
|
revalidatePath('/admin/neighborhoods')
|
||||||
|
return { success: true }
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.message?.includes('RESTRICT') || err.message?.includes('Foreign key constraint')) {
|
||||||
|
return { success: false, error: 'Silme başarısız: Bu mahalleye bağlı gizli (silinmiş veya arşivlenmiş) mekanlar veya etkinlikler var.' }
|
||||||
|
}
|
||||||
|
return { success: false, error: err.message || 'Bilinmeyen bir hata oluştu.' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOrUpdateNeighborhoodAction(formData: FormData) {
|
||||||
|
const id = formData.get('id') as string | null
|
||||||
|
const slug = formData.get('slug') as string
|
||||||
|
const nameTr = formData.get('nameTr') as string
|
||||||
|
const nameEn = formData.get('nameEn') as string
|
||||||
|
const nameRu = formData.get('nameRu') as string
|
||||||
|
|
||||||
|
if (!slug || !nameTr || !nameEn || !nameRu) {
|
||||||
|
return { error: 'Zorunlu alanları doldurun.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
slug,
|
||||||
|
nameTr,
|
||||||
|
nameEn,
|
||||||
|
nameRu,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (id && id !== 'new') {
|
||||||
|
await mockDb.updateNeighborhood(id, data)
|
||||||
|
} else {
|
||||||
|
await mockDb.createNeighborhood(data)
|
||||||
|
}
|
||||||
|
revalidatePath('/admin/neighborhoods')
|
||||||
|
return { success: true }
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, error: err.message || 'Mahalle kaydedilemedi.' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI Itinerary Planner Actions (Phase 3)
|
||||||
|
import crypto from 'crypto'
|
||||||
|
|
||||||
|
async function generateMockItinerary(days: number, style: string, neighborhoodSlugs: string[]) {
|
||||||
|
const allListings = await mockDb.getListings()
|
||||||
|
const matchingListings = allListings.filter(l =>
|
||||||
|
!l.deletedAt && l.isLocalApproved &&
|
||||||
|
(neighborhoodSlugs.length === 0 ||
|
||||||
|
(l.neighborhood && neighborhoodSlugs.includes(l.neighborhood.slug)))
|
||||||
|
)
|
||||||
|
|
||||||
|
const pool = matchingListings.length > 0 ? matchingListings : allListings.filter(l => !l.deletedAt && l.isLocalApproved)
|
||||||
|
|
||||||
|
let md = `# marmaris local kişisel gezi rotası 🌴\n\n`
|
||||||
|
md += `**gün sayısı:** ${days} gün | **gezi tarzı:** ${style === 'gastronomy' ? 'gurme' : style === 'relaxation' ? 'dinlenme' : 'macera'} | **keşif bölgeleri:** ${neighborhoodSlugs.join(', ')}\n\n`
|
||||||
|
md += `bu rota, marmaris local topluluğu tarafından onaylanmış yerel işletmeler temel alınarak oluşturulmuştur.\n\n---\n\n`
|
||||||
|
|
||||||
|
for (let day = 1; day <= days; day++) {
|
||||||
|
md += `## 🗓️ gün ${day}\n\n`
|
||||||
|
|
||||||
|
const dayListings = [...pool].sort(() => 0.5 - Math.random()).slice(0, 3)
|
||||||
|
|
||||||
|
if (dayListings.length >= 1) {
|
||||||
|
md += `### 🌅 sabah: kahvaltı ve başlangıç\n`
|
||||||
|
md += `güne yerel onaylı **[${dayListings[0].nameTr}](/${dayListings[0].category?.slug || 'isletme'}/${dayListings[0].slug})** işletmesinde başlayın. \n`
|
||||||
|
md += `> **yerel ipucu:** ${dayListings[0].descriptionTr}\n\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dayListings.length >= 2) {
|
||||||
|
md += `### ☀️ öğle: keşif zamanı\n`
|
||||||
|
md += `öğleden sonra **[${dayListings[1].nameTr}](/${dayListings[1].category?.slug || 'isletme'}/${dayListings[1].slug})** mekanına uğrayın ve çevreyi keşfedin.\n`
|
||||||
|
md += `> **editör notu:** ${dayListings[1].address}\n\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dayListings.length >= 3) {
|
||||||
|
md += `### 🌌 akşam: gün batımı ve akşam yemeği\n`
|
||||||
|
md += `akşamı şık bir akşam yemeğiyle taçlandırın: **[${dayListings[2].nameTr}](/${dayListings[2].category?.slug || 'isletme'}/${dayListings[2].slug})**.\n`
|
||||||
|
md += `> **özel detaylar:** rating: ★${dayListings[2].rating} • tel: ${dayListings[2].phone || 'belirtilmemiş'}\n\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
md += `---\n\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
md += `*not: seyahatiniz boyunca yerel rehberimizdeki işletmeleri ziyaret etmeyi ve favorilerinize eklemeyi unutmayın!*`
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: md,
|
||||||
|
listingIds: pool.map(l => l.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateItineraryAction(days: number, style: string, neighborhoodSlugs: string[]) {
|
||||||
|
const sorted = [...neighborhoodSlugs].sort()
|
||||||
|
const paramsHash = crypto.createHash('md5').update(JSON.stringify({ days, style, neighborhoodSlugs: sorted })).digest('hex')
|
||||||
|
|
||||||
|
const existing = await mockDb.getItineraryByHash(paramsHash)
|
||||||
|
if (existing) {
|
||||||
|
return { success: true, id: existing.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new program
|
||||||
|
const { content, listingIds } = await generateMockItinerary(days, style, sorted)
|
||||||
|
|
||||||
|
const newItin = await mockDb.createItinerary({
|
||||||
|
paramsHash,
|
||||||
|
params: { days, style, neighborhoodSlugs: sorted },
|
||||||
|
content,
|
||||||
|
listingIds
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true, id: newItin.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export async function createOrUpdateCategoryAction(formData: FormData) {
|
||||||
|
const id = formData.get('id') as string
|
||||||
|
const slug = formData.get('slug') as string
|
||||||
|
const nameTr = formData.get('nameTr') as string
|
||||||
|
const nameEn = formData.get('nameEn') as string
|
||||||
|
const nameRu = formData.get('nameRu') as string
|
||||||
|
|
||||||
|
if (!slug || !nameTr || !nameEn || !nameRu) {
|
||||||
|
throw new Error('Eksik alanlar var')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id && id !== 'new') {
|
||||||
|
await mockDb.updateCategory(id, { slug, nameTr, nameEn, nameRu })
|
||||||
|
} else {
|
||||||
|
await mockDb.createCategory({ slug, nameTr, nameEn, nameRu })
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/admin/categories')
|
||||||
|
redirect('/tr/admin/categories')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCategoryAction(formData: FormData) {
|
||||||
|
const id = formData.get('id') as string
|
||||||
|
if (!id) return
|
||||||
|
await mockDb.deleteCategory(id)
|
||||||
|
revalidatePath('/admin/categories')
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { mockDb } from '@/lib/mockDb'
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
const { listingId, actionType } = body
|
||||||
|
|
||||||
|
if (!listingId || !actionType) {
|
||||||
|
return NextResponse.json({ error: 'listingId and actionType are required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const validActions = ['views', 'whatsapp', 'phone', 'menu']
|
||||||
|
if (!validActions.includes(actionType)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid actionType' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call stateful DB increment
|
||||||
|
const record = await mockDb.incrementAnalytics(listingId, actionType)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, record })
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error logging event analytics:', err)
|
||||||
|
return NextResponse.json({ error: err.message || 'Internal Server Error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { mockDb } from '@/lib/mockDb'
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const neighborhoodSlug = searchParams.get('neighborhood')
|
||||||
|
|
||||||
|
if (!neighborhoodSlug) {
|
||||||
|
return NextResponse.json({ error: 'Neighborhood is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get neighborhood
|
||||||
|
const neighborhood = await mockDb.getNeighborhoodBySlug(neighborhoodSlug)
|
||||||
|
if (!neighborhood) {
|
||||||
|
return NextResponse.json({ listings: [] }, {
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get listings in that neighborhood that are local approved
|
||||||
|
const listings = await mockDb.getListings({
|
||||||
|
neighborhoodId: neighborhood.id,
|
||||||
|
isLocalApproved: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Format response matching widget needs
|
||||||
|
const formattedListings = listings.map(l => ({
|
||||||
|
id: l.id,
|
||||||
|
name: l.nameTr,
|
||||||
|
nameEn: l.nameEn,
|
||||||
|
nameRu: l.nameRu,
|
||||||
|
slug: l.slug,
|
||||||
|
categorySlug: l.category?.slug || 'isletme',
|
||||||
|
categoryName: l.category?.nameTr || '',
|
||||||
|
neighborhoodName: l.neighborhood?.nameTr || '',
|
||||||
|
rating: l.rating,
|
||||||
|
priceSymbols: '₺'.repeat(l.priceRange),
|
||||||
|
coverImage: l.images && l.images.length > 0 ? l.images[0].url : 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&auto=format&fit=crop&q=80'
|
||||||
|
}))
|
||||||
|
|
||||||
|
return NextResponse.json({ listings: formattedListings }, {
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle preflight OPTIONS request
|
||||||
|
export async function OPTIONS() {
|
||||||
|
return new NextResponse(null, {
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ export async function GET() {
|
|||||||
// Add newly added approved listings
|
// Add newly added approved listings
|
||||||
for (const listing of listings.slice(0, 10)) {
|
for (const listing of listings.slice(0, 10)) {
|
||||||
const pubDate = new Date(listing.createdAt).toUTCString()
|
const pubDate = new Date(listing.createdAt).toUTCString()
|
||||||
const catSlug = listing.category?.slug || 'isletmeler'
|
const catSlug = listing.category?.slug || 'businesses'
|
||||||
xml += ` <item>
|
xml += ` <item>
|
||||||
<title><![CDATA[Yeni Onaylandı: ${listing.nameTr} (${listing.neighborhood?.nameTr})]]></title>
|
<title><![CDATA[Yeni Onaylandı: ${listing.nameTr} (${listing.neighborhood?.nameTr})]]></title>
|
||||||
<link>${baseUrl}/tr/${catSlug}/${listing.slug}</link>
|
<link>${baseUrl}/tr/${catSlug}/${listing.slug}</link>
|
||||||
|
|||||||
+7
-7
@@ -8,12 +8,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
// Static routes
|
// Static routes
|
||||||
const staticRoutes = [
|
const staticRoutes = [
|
||||||
'',
|
'',
|
||||||
'/apartlar',
|
'/aparts',
|
||||||
'/restoranlar',
|
'/restaurants',
|
||||||
'/isletmeler',
|
'/businesses',
|
||||||
'/isletme-ekle',
|
'/add-business',
|
||||||
'/iletisim',
|
'/contact',
|
||||||
'/hakkinda'
|
'/about'
|
||||||
]
|
]
|
||||||
|
|
||||||
const sitemapEntries: MetadataRoute.Sitemap = []
|
const sitemapEntries: MetadataRoute.Sitemap = []
|
||||||
@@ -55,7 +55,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
for (const neighborhood of neighborhoods) {
|
for (const neighborhood of neighborhoods) {
|
||||||
for (const locale of locales) {
|
for (const locale of locales) {
|
||||||
sitemapEntries.push({
|
sitemapEntries.push({
|
||||||
url: `${baseUrl}/${locale}/mahalle/${neighborhood.slug}`,
|
url: `${baseUrl}/${locale}/neighborhood/${neighborhood.slug}`,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: 'monthly',
|
changeFrequency: 'monthly',
|
||||||
priority: 0.4,
|
priority: 0.4,
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Trash2 } from 'lucide-react'
|
||||||
|
import { useFormStatus } from 'react-dom'
|
||||||
|
|
||||||
|
export default function DeleteButton() {
|
||||||
|
const { pending } = useFormStatus()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={pending}
|
||||||
|
className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-stone hover:bg-red-50 text-shutter hover:text-red-500 transition-colors disabled:opacity-50"
|
||||||
|
title="Sil"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!confirm('Bu kaydı silmek istediğinize emin misiniz?')) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
+23
-23
@@ -1,9 +1,17 @@
|
|||||||
import { Link } from '@/i18n/routing'
|
import { Link } from '@/i18n/routing'
|
||||||
import { useTranslations } from 'next-intl'
|
import { getTranslations, getLocale } from 'next-intl/server'
|
||||||
|
import { mockDb } from '@/lib/mockDb'
|
||||||
|
|
||||||
export default function Footer() {
|
export default async function Footer() {
|
||||||
const t = useTranslations('footer')
|
const categories = await mockDb.getCategories()
|
||||||
const nav = useTranslations('nav')
|
const locale = await getLocale()
|
||||||
|
const getLocalizedName = (obj: any) => {
|
||||||
|
if (!obj) return ''
|
||||||
|
return locale === 'ru' ? obj.nameRu : locale === 'en' ? obj.nameEn : obj.nameTr
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = await getTranslations('footer')
|
||||||
|
const nav = await getTranslations('nav')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-pine text-stone/70 border-t border-white/10 pt-16 pb-8 font-sans">
|
<footer className="bg-pine text-stone/70 border-t border-white/10 pt-16 pb-8 font-sans">
|
||||||
@@ -32,23 +40,15 @@ export default function Footer() {
|
|||||||
{nav('home')}
|
{nav('home')}
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-2 text-xs">
|
<ul className="space-y-2 text-xs">
|
||||||
|
{categories.map(cat => (
|
||||||
|
<li key={cat.id}>
|
||||||
|
<Link href={`/${cat.slug}`} className="hover:text-turquoise transition-colors">
|
||||||
|
{getLocalizedName(cat)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
<li>
|
<li>
|
||||||
<Link href="/restoranlar" className="hover:text-turquoise transition-colors">
|
<Link href="/collections" className="hover:text-turquoise transition-colors">
|
||||||
{nav('restaurants')}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link href="/apartlar" className="hover:text-turquoise transition-colors">
|
|
||||||
{nav('aparts')}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link href="/isletmeler" className="hover:text-turquoise transition-colors">
|
|
||||||
{nav('businesses')}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link href="/seckiler" className="hover:text-turquoise transition-colors">
|
|
||||||
{nav('collections')}
|
{nav('collections')}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
@@ -67,17 +67,17 @@ export default function Footer() {
|
|||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-2 text-xs">
|
<ul className="space-y-2 text-xs">
|
||||||
<li>
|
<li>
|
||||||
<Link href="/hakkinda" className="hover:text-turquoise transition-colors">
|
<Link href="/about" className="hover:text-turquoise transition-colors">
|
||||||
{nav('about')}
|
{nav('about')}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="/iletisim" className="hover:text-turquoise transition-colors">
|
<Link href="/contact" className="hover:text-turquoise transition-colors">
|
||||||
{nav('contact')}
|
{nav('contact')}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="/isletme-ekle" className="hover:text-turquoise transition-colors">
|
<Link href="/add-business" className="hover:text-turquoise transition-colors">
|
||||||
{nav('addBusiness')}
|
{nav('addBusiness')}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
|
||||||
|
import { useTransition, useRef } from 'react'
|
||||||
|
|
||||||
|
export default function LiveFilterForm({ children }: { children: React.ReactNode }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const pathname = usePathname()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
const debounceTimer = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
|
function updateRoute(form: HTMLFormElement) {
|
||||||
|
const formData = new FormData(form)
|
||||||
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
|
|
||||||
|
const search = formData.get('search') as string
|
||||||
|
const neighborhood = formData.get('neighborhood') as string
|
||||||
|
const price = formData.get('price') as string
|
||||||
|
const approved = formData.get('approved') as string
|
||||||
|
|
||||||
|
if (search) params.set('search', search)
|
||||||
|
else params.delete('search')
|
||||||
|
|
||||||
|
if (neighborhood) params.set('neighborhood', neighborhood)
|
||||||
|
else params.delete('neighborhood')
|
||||||
|
|
||||||
|
if (price) params.set('price', price)
|
||||||
|
else params.delete('price')
|
||||||
|
|
||||||
|
if (approved) params.set('approved', 'true')
|
||||||
|
else params.delete('approved')
|
||||||
|
|
||||||
|
startTransition(() => {
|
||||||
|
router.push(`${pathname}?${params.toString()}`, { scroll: false })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChange(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
const form = e.currentTarget
|
||||||
|
if (debounceTimer.current) clearTimeout(debounceTimer.current)
|
||||||
|
debounceTimer.current = setTimeout(() => {
|
||||||
|
updateRoute(form)
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onChange={onChange} onSubmit={(e) => { e.preventDefault(); updateRoute(e.currentTarget); }} className="grid grid-cols-1 sm:grid-cols-4 gap-4 items-end relative">
|
||||||
|
{children}
|
||||||
|
{isPending && (
|
||||||
|
<div className="absolute inset-0 bg-stone/20 backdrop-blur-[1px] flex items-center justify-center z-10 rounded-xl transition-all" />
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
+25
-22
@@ -4,8 +4,9 @@ import { useState, useEffect } from 'react'
|
|||||||
import { Link, usePathname, useRouter } from '@/i18n/routing'
|
import { Link, usePathname, useRouter } from '@/i18n/routing'
|
||||||
import { useTranslations, useLocale } from 'next-intl'
|
import { useTranslations, useLocale } from 'next-intl'
|
||||||
import { Menu, X, Globe, PlusCircle, Heart } from 'lucide-react'
|
import { Menu, X, Globe, PlusCircle, Heart } from 'lucide-react'
|
||||||
|
import { Category } from '@prisma/client'
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar({ categories = [] }: { categories?: Category[] }) {
|
||||||
const t = useTranslations('nav')
|
const t = useTranslations('nav')
|
||||||
const activeLocale = useLocale()
|
const activeLocale = useLocale()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
@@ -46,15 +47,20 @@ export default function Navbar() {
|
|||||||
{ code: 'ru', label: 'Русский' }
|
{ code: 'ru', label: 'Русский' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const getLocalizedName = (obj: any) => {
|
||||||
|
if (!obj) return ''
|
||||||
|
return activeLocale === 'ru' ? obj.nameRu : activeLocale === 'en' ? obj.nameEn : obj.nameTr
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryItems = categories.map(cat => ({
|
||||||
|
name: getLocalizedName(cat),
|
||||||
|
href: `/${cat.slug}`
|
||||||
|
}))
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ name: t('home'), href: '/' },
|
...categoryItems,
|
||||||
{ name: t('restaurants'), href: '/restoranlar' },
|
{ name: t('events'), href: '/events' },
|
||||||
{ name: t('aparts'), href: '/apartlar' },
|
|
||||||
{ name: t('businesses'), href: '/isletmeler' },
|
|
||||||
{ name: t('collections'), href: '/seckiler' },
|
|
||||||
{ name: t('blog'), href: '/blog' },
|
{ name: t('blog'), href: '/blog' },
|
||||||
{ name: t('about'), href: '/hakkinda' },
|
|
||||||
{ name: t('contact'), href: '/iletisim' }
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const handleLanguageChange = (localeCode: string) => {
|
const handleLanguageChange = (localeCode: string) => {
|
||||||
@@ -84,16 +90,15 @@ export default function Navbar() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Desktop Navigation */}
|
{/* Desktop Navigation */}
|
||||||
<nav className="hidden lg:flex items-center gap-6">
|
<nav className="hidden lg:flex items-center gap-8">
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const isActive = pathname === item.href
|
const isActive = pathname === item.href
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.name}
|
key={item.name}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={`text-sm font-medium transition-colors hover:text-turquoise ${
|
className={`text-sm font-medium transition-colors hover:text-turquoise ${isActive ? 'text-turquoise font-semibold' : 'text-stone/80'
|
||||||
isActive ? 'text-turquoise font-semibold' : 'text-stone/80'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{item.name}
|
{item.name}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -105,7 +110,7 @@ export default function Navbar() {
|
|||||||
<div className="hidden lg:flex items-center gap-4">
|
<div className="hidden lg:flex items-center gap-4">
|
||||||
{/* Saved items trigger */}
|
{/* Saved items trigger */}
|
||||||
<Link
|
<Link
|
||||||
href="/kaydedilenler"
|
href="/saved"
|
||||||
className="relative w-8 h-8 rounded-full border border-stone/20 hover:border-turquoise transition text-stone hover:text-turquoise flex items-center justify-center"
|
className="relative w-8 h-8 rounded-full border border-stone/20 hover:border-turquoise transition text-stone hover:text-turquoise flex items-center justify-center"
|
||||||
title={t('saved')}
|
title={t('saved')}
|
||||||
>
|
>
|
||||||
@@ -135,9 +140,8 @@ export default function Navbar() {
|
|||||||
<button
|
<button
|
||||||
key={lang.code}
|
key={lang.code}
|
||||||
onClick={() => handleLanguageChange(lang.code)}
|
onClick={() => handleLanguageChange(lang.code)}
|
||||||
className={`w-full text-left px-4 py-2 text-xs hover:bg-stone transition flex items-center justify-between ${
|
className={`w-full text-left px-4 py-2 text-xs hover:bg-stone transition flex items-center justify-between ${activeLocale === lang.code ? 'font-bold text-turquoise bg-stone/40' : 'text-ink/80'
|
||||||
activeLocale === lang.code ? 'font-bold text-turquoise bg-stone/40' : 'text-ink/80'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{lang.label}
|
{lang.label}
|
||||||
<span className="font-mono text-[10px] text-shutter">{lang.code.toUpperCase()}</span>
|
<span className="font-mono text-[10px] text-shutter">{lang.code.toUpperCase()}</span>
|
||||||
@@ -150,7 +154,7 @@ export default function Navbar() {
|
|||||||
|
|
||||||
{/* Add Business Button */}
|
{/* Add Business Button */}
|
||||||
<Link
|
<Link
|
||||||
href="/isletme-ekle"
|
href="/add-business"
|
||||||
className="flex items-center gap-1.5 bg-turquoise hover:bg-turquoise/90 text-paper font-medium text-xs py-2 px-4 rounded-full transition-transform active:scale-95 shadow-sm"
|
className="flex items-center gap-1.5 bg-turquoise hover:bg-turquoise/90 text-paper font-medium text-xs py-2 px-4 rounded-full transition-transform active:scale-95 shadow-sm"
|
||||||
>
|
>
|
||||||
<PlusCircle className="w-3.5 h-3.5" />
|
<PlusCircle className="w-3.5 h-3.5" />
|
||||||
@@ -162,7 +166,7 @@ export default function Navbar() {
|
|||||||
<div className="flex items-center gap-3 lg:hidden">
|
<div className="flex items-center gap-3 lg:hidden">
|
||||||
{/* Mobile Saved trigger */}
|
{/* Mobile Saved trigger */}
|
||||||
<Link
|
<Link
|
||||||
href="/kaydedilenler"
|
href="/saved"
|
||||||
className="relative w-8 h-8 rounded-full border border-stone/20 text-stone flex items-center justify-center"
|
className="relative w-8 h-8 rounded-full border border-stone/20 text-stone flex items-center justify-center"
|
||||||
title={t('saved')}
|
title={t('saved')}
|
||||||
>
|
>
|
||||||
@@ -223,9 +227,8 @@ export default function Navbar() {
|
|||||||
key={item.name}
|
key={item.name}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
className={`text-sm font-medium py-2 px-3 rounded-lg transition-colors ${
|
className={`text-sm font-medium py-2 px-3 rounded-lg transition-colors ${isActive ? 'bg-white/10 text-turquoise' : 'text-stone/80 hover:bg-white/5'
|
||||||
isActive ? 'bg-white/10 text-turquoise' : 'text-stone/80 hover:bg-white/5'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{item.name}
|
{item.name}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -234,7 +237,7 @@ export default function Navbar() {
|
|||||||
</nav>
|
</nav>
|
||||||
<div className="pt-4 border-t border-white/10">
|
<div className="pt-4 border-t border-white/10">
|
||||||
<Link
|
<Link
|
||||||
href="/isletme-ekle"
|
href="/add-business"
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
className="w-full flex items-center justify-center gap-2 bg-turquoise hover:bg-turquoise/90 text-paper font-medium py-3 rounded-lg text-sm transition"
|
className="w-full flex items-center justify-center gap-2 bg-turquoise hover:bg-turquoise/90 text-paper font-medium py-3 rounded-lg text-sm transition"
|
||||||
>
|
>
|
||||||
|
|||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
# Faz 3 Planı — Marmaris Local
|
||||||
|
|
||||||
|
**Önceki:** `faz-2-plan.md`
|
||||||
|
**Bu doküman:** Faz 2 sonrası — ajans sinerjisi, gelir derinleştirme, AI fark yaratıcı özellik
|
||||||
|
|
||||||
|
## Kapsam Dışı (bilinçli karar)
|
||||||
|
|
||||||
|
- **Çok-şehirli genişleme** — yapılmayacak. Marmaris Local sadece Marmaris kalacak. Fethiye/Bodrum/Ören/Datça için domain rezerve edilmişti ama her biri kendi bağımsız sitesi olacak, ortak codebase/çoğaltma modeli yok.
|
||||||
|
- **Kullanıcı yorumu/puanlama sistemi** — ertelendi, sonra tekrar değerlendirilecek.
|
||||||
|
- **Ayris Booking entegrasyonu / rezervasyon komisyonu** — sonraki aşama, bu fazın kapsamında değil. Şimdiden mimariyi buna göre zorlamıyoruz.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Faz 3.1 — Ajans Sinerjisi (Bölgesel Öneri Widget'ı)
|
||||||
|
|
||||||
|
**Neden bu değerli:** Ayris Tech / Muğla Dijital Medya'nın 40+ müşteri ilişkisi, taklit edilemeyecek bir dağıtım kanalı. Müşteri sitelerine gömülen bir widget hem onlara değer katar (ziyaretçi siteden ayrılmadan "yakında nerede yenir" sorusuna cevap bulur) hem Marmaris Local'e gerçek, çeşitli kaynaklardan gelen backlink + trafik sağlar.
|
||||||
|
|
||||||
|
### Mekanik
|
||||||
|
|
||||||
|
```
|
||||||
|
<script src="https://marmarislocal.com/widget.js" data-neighborhood="akyaka"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Yeni public endpoint: `GET /api/widget/nearby?neighborhood=X&category=Y` — 3-5 küratörlü (`isLocalApproved`) listeleme döner, CORS açık
|
||||||
|
- Cloudflare edge cache ile önbelleklenir (mevcut Cloudflare hesabı üzerinden, ekstra maliyet yok)
|
||||||
|
- Widget kartları: görsel, isim, kategori, Marmaris Local'deki detay sayfasına link
|
||||||
|
|
||||||
|
### Kritik kurallar (spam riskine karşı)
|
||||||
|
|
||||||
|
Geçmişteki bir SEO projesinde (thedigitalscale.org) alınan manuel spam cezası dersini burada uygula:
|
||||||
|
|
||||||
|
1. **Widget her zaman görünür olacak.** Gizli link, `display:none`, mikroskopik footer yazısı — hiçbiri yok. Ziyaretçinin gerçekten göreceği, tıklayabileceği bir kutu.
|
||||||
|
2. **Opt-in — asla zorla eklenmez.** Müşteriye "ister misin, ücretsiz" diye sorulur. Kabul eden alır, etmeyen için hiçbir şey değişmez.
|
||||||
|
3. **Sadece alakalı müşterilerde önerilir.** Turizm/yeme-içme/konaklama işletmeleri (Kite Beach, Moy Group, Ayris Apart gibi) — bir avukatlık bürosuna ya da sürücü kursuna bu widget önerilmez, anlamsız ve spam gibi görünür.
|
||||||
|
4. **Tek yönlü kalır.** Marmaris Local, widget koyan müşterilere geri link vermeye çalışmaz — karşılıklı/döngüsel link değişimi yapılmaz.
|
||||||
|
|
||||||
|
### Takip
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
// Listing modeline eklenecek alanlar (ya da ayrı PartnerSite tablosu)
|
||||||
|
hasWidgetInstalled Boolean @default(false)
|
||||||
|
widgetInstalledAt DateTime?
|
||||||
|
widgetSiteUrl String? // hangi müşteri sitesine kurulduğu
|
||||||
|
```
|
||||||
|
|
||||||
|
- Admin panelde basit bir liste: hangi müşteri kabul etti, hangi tarihte kuruldu — "bu ay kaç yeni backlink kazandık" raporlaması için
|
||||||
|
|
||||||
|
### Paketleme
|
||||||
|
|
||||||
|
Yeni rebrand/redesign işlerinde (Hermes pipeline'ından geçen müşteri) standart teklife opsiyon olarak eklenir: "Marmaris Local'de listelendin + istersen sitene bölgesel öneri widget'ı." Her yeni proje = potansiyel bir yeni listeleme + potansiyel bir yeni widget — rehber büyüdükçe teklif de güçleniyor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Faz 3.2 — Gelir Derinleştirme
|
||||||
|
|
||||||
|
### a) İşletme Analitik Paneli
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model ListingAnalyticsDaily {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
listingId String
|
||||||
|
date DateTime
|
||||||
|
views Int @default(0)
|
||||||
|
whatsappClicks Int @default(0)
|
||||||
|
phoneClicks Int @default(0)
|
||||||
|
menuClicks Int @default(0)
|
||||||
|
|
||||||
|
listing Listing @relation(fields: [listingId], references: [id])
|
||||||
|
|
||||||
|
@@unique([listingId, date])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Frontend'de tıklamalar `POST /api/events` ile fire-and-forget loglanır (ham event tablosuna değil, gece bir job günlük satıra toplanır — event tablosu şişmesin)
|
||||||
|
- **Freemium kanca:** ücretsiz listelemede sadece toplam görüntülenme sayısı görünür, detaylı analitik panel (`isFeatured` = true) pakete dahil — işletme kendi verisini görmek için öne çıkan pakete geçmeye teşvik edilir
|
||||||
|
- Admin route: `/admin/listings/[id]/analytics` (kendi görebileceği), işletme sahibi tarafı ileride "listing claim" akışı kurulunca eklenir
|
||||||
|
|
||||||
|
### b) Etkinlik Takvimi
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model Event {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
slug String @unique
|
||||||
|
listingId String? // hangi mekanda gerçekleşiyor (opsiyonel)
|
||||||
|
titleTr String
|
||||||
|
titleEn String
|
||||||
|
titleRu String
|
||||||
|
descriptionTr String
|
||||||
|
descriptionEn String
|
||||||
|
descriptionRu String
|
||||||
|
startDate DateTime
|
||||||
|
endDate DateTime?
|
||||||
|
coverImage String? // Openinary
|
||||||
|
isSponsored Boolean @default(false)
|
||||||
|
|
||||||
|
listing Listing? @relation(fields: [listingId], references: [id])
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Route: `/etkinlikler`, `/etkinlik/[slug]`
|
||||||
|
- Admin: `/admin/events` CRUD
|
||||||
|
- İçerik kaynağı kısmen otomatikleştirilebilir — n8n + DeepSeek ile partner mekanların Instagram/Facebook etkinlik paylaşımları taranıp taslak çıkarılır (Tarih-Gizem/1 Piksel pipeline'larındaki state-machine pattern'iyle aynı mantık), admin onaylayıp yayınlar
|
||||||
|
- Gelir: sponsorlu slot (`isSponsored`) ya da küçük listeleme ücreti
|
||||||
|
- Sezonluk taze içerik = düzenli SEO sinyali, blog'la aynı işlevi görür
|
||||||
|
|
||||||
|
### c) Booking Komisyonu
|
||||||
|
|
||||||
|
Ertelendi — Ayris Booking entegrasyonuna bağlı, sonraki aşamada ayrıca planlanacak. Şimdiden bir şema/mimari kararı alınmıyor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Faz 3.3 — AI Plan Oluşturucu
|
||||||
|
|
||||||
|
"3 Günlük Marmaris Planı Oluştur" — birkaç hızlı soru (bütçe, aile/çift/arkadaş grubu, ilgi alanı, gün sayısı) → DeepSeek gün gün plan üretir.
|
||||||
|
|
||||||
|
### Kritik kural: grounding
|
||||||
|
|
||||||
|
LLM'e serbest üretim yaptırılmaz. Akış:
|
||||||
|
1. Kullanıcı tercihlerine göre `Listing`/`Collection`/`Event` tablolarından aday listesi çekilir (kategori/mahalle/fiyat filtreli)
|
||||||
|
2. Bu aday listesi DeepSeek'e bağlam olarak verilir
|
||||||
|
3. Model sadece bu adaylar arasından seçip sıralar ve anlatı yazar — rehberde olmayan bir mekanı asla "önermez"
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model GeneratedItinerary {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
paramsHash String @unique // aynı tercih kombinasyonu = aynı plan, tekrar üretilmez
|
||||||
|
params Json // budget, groupType, interests, days
|
||||||
|
content String // üretilen gün-gün plan metni (TR/EN/RU ayrı üretilir)
|
||||||
|
listingIds String[] // plana giren listelemeler
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Route: `/plan-olustur` (form), `/plan/[id]` (sonuç — paylaşılabilir link)
|
||||||
|
- `paramsHash` ile önbellekleme — aynı kombinasyon tekrar DeepSeek'e gitmez, maliyet sınırlanır
|
||||||
|
- Paylaşılabilir link WhatsApp'ta grup planlaması için doğal bir paylaşım nesnesi
|
||||||
|
- Her üretilen plan Google'da ayrı indekslenen taze bir long-tail sayfa olur ("3 günlük Marmaris balayı planı" gibi aramalar)
|
||||||
|
- Eşit adaylar arasında seçim mantığı `isFeatured` olanı hafif önceliklendirebilir — öne çıkan pakete kozmetik ötesinde gerçek işlevsel değer katar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Önerilen Sıra
|
||||||
|
|
||||||
|
1. **Ajans widget'ı** (3.1) — sıfır maliyet, elindeki müşteri ilişkisini direkt kullanıyor, en hızlı geri dönüş
|
||||||
|
2. **Etkinlik takvimi** (3.2b) — blog gibi düzenli içerik/SEO kanalı, mevcut otomasyon pattern'lerine (n8n+DeepSeek) uyuyor
|
||||||
|
3. **İşletme analitik paneli** (3.2a) — öne çıkan paketin değerini artırır, satış konuşmasını güçlendirir
|
||||||
|
4. **AI plan oluşturucu** (3.3) — en yüksek efor, en görünür fark yaratıcı özellik; diğerleri oturduktan sonra
|
||||||
|
|
||||||
|
## Açık Sorular
|
||||||
|
|
||||||
|
- [ ] Widget'ı ilk hangi müşterilerde deneyeceğiz — Kite Beach Akyaka / Moy Group / Ayris Apart gibi en alakalı 3-5 müşteriyle pilot mu başlatılır?
|
||||||
|
- [ ] Etkinlik içeriği için Instagram/Facebook tarama pipeline'ı hangi hesaplardan başlayacak — partner listelemelerin kendi hesapları mı, yoksa genel "Marmaris etkinlik" araması mı?
|
||||||
|
- [ ] AI plan oluşturucu için DeepSeek maliyeti — `paramsHash` önbellekleme yeterli mi, yoksa günlük/aylık üretim limiti de konulmalı mı?
|
||||||
+466
-12
@@ -56,6 +56,11 @@ export interface Listing {
|
|||||||
menuUrl?: string | null
|
menuUrl?: string | null
|
||||||
isFeatured: boolean
|
isFeatured: boolean
|
||||||
featuredUntil?: Date | null
|
featuredUntil?: Date | null
|
||||||
|
|
||||||
|
// Phase 3
|
||||||
|
hasWidgetInstalled: boolean
|
||||||
|
widgetInstalledAt?: Date | null
|
||||||
|
widgetSiteUrl?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BusinessSubmission {
|
export interface BusinessSubmission {
|
||||||
@@ -133,6 +138,46 @@ export interface InstagramFeedCache {
|
|||||||
fetchedAt: Date
|
fetchedAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ListingAnalyticsDaily {
|
||||||
|
id: string
|
||||||
|
listingId: string
|
||||||
|
date: Date
|
||||||
|
views: number
|
||||||
|
whatsappClicks: number
|
||||||
|
phoneClicks: number
|
||||||
|
menuClicks: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Event {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
listingId?: string | null
|
||||||
|
titleTr: string
|
||||||
|
titleEn: string
|
||||||
|
titleRu: string
|
||||||
|
descriptionTr: string
|
||||||
|
descriptionEn: string
|
||||||
|
descriptionRu: string
|
||||||
|
startDate: Date
|
||||||
|
endDate?: Date | null
|
||||||
|
coverImage?: string | null
|
||||||
|
isSponsored: boolean
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
deletedAt?: Date | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeneratedItinerary {
|
||||||
|
id: string
|
||||||
|
paramsHash: string
|
||||||
|
params: any
|
||||||
|
content: string
|
||||||
|
listingIds: string[]
|
||||||
|
createdAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_DB_VERSION = 4
|
||||||
|
|
||||||
const globalForMockDb = globalThis as unknown as {
|
const globalForMockDb = globalThis as unknown as {
|
||||||
categories: Category[]
|
categories: Category[]
|
||||||
neighborhoods: Neighborhood[]
|
neighborhoods: Neighborhood[]
|
||||||
@@ -142,10 +187,14 @@ const globalForMockDb = globalThis as unknown as {
|
|||||||
blogPosts: BlogPost[]
|
blogPosts: BlogPost[]
|
||||||
collections: Collection[]
|
collections: Collection[]
|
||||||
instagramFeedCaches: InstagramFeedCache[]
|
instagramFeedCaches: InstagramFeedCache[]
|
||||||
|
listingAnalyticsDaily: ListingAnalyticsDaily[]
|
||||||
|
events: Event[]
|
||||||
|
generatedItineraries: GeneratedItinerary[]
|
||||||
initialized: boolean
|
initialized: boolean
|
||||||
|
__version: number
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!globalForMockDb.initialized) {
|
if (!globalForMockDb.initialized || globalForMockDb.__version !== MOCK_DB_VERSION) {
|
||||||
globalForMockDb.categories = [
|
globalForMockDb.categories = [
|
||||||
{ id: 'cat-1', slug: 'restoran', nameTr: 'Restoran', nameEn: 'Restaurant', nameRu: 'Ресторан' },
|
{ id: 'cat-1', slug: 'restoran', nameTr: 'Restoran', nameEn: 'Restaurant', nameRu: 'Ресторан' },
|
||||||
{ id: 'cat-2', slug: 'apart', nameTr: 'Apart', nameEn: 'Apart Hotel', nameRu: 'Апарт-отель' },
|
{ id: 'cat-2', slug: 'apart', nameTr: 'Apart', nameEn: 'Apart Hotel', nameRu: 'Апарт-отель' },
|
||||||
@@ -163,7 +212,7 @@ if (!globalForMockDb.initialized) {
|
|||||||
globalForMockDb.submissions = []
|
globalForMockDb.submissions = []
|
||||||
globalForMockDb.messages = []
|
globalForMockDb.messages = []
|
||||||
|
|
||||||
// Seed Listings with Phase 2 fields
|
// Seed Listings with Phase 2 & 3 fields
|
||||||
globalForMockDb.listings = [
|
globalForMockDb.listings = [
|
||||||
{
|
{
|
||||||
id: 'list-1',
|
id: 'list-1',
|
||||||
@@ -173,7 +222,7 @@ if (!globalForMockDb.initialized) {
|
|||||||
city: 'marmaris',
|
city: 'marmaris',
|
||||||
nameTr: 'İskele Balık Ocakbaşı',
|
nameTr: 'İskele Balık Ocakbaşı',
|
||||||
nameEn: 'Iskele Fish & Grill',
|
nameEn: 'Iskele Fish & Grill',
|
||||||
nameRu: 'Рыбный Гриль Искеле',
|
nameRu: 'Рыбный Гриль İskele',
|
||||||
descriptionTr: 'Yat Limanı\'nda taze Ege balıkları ve geleneksel meze çeşitleriyle yerel lezzet durağınız. Mükemmel körfez manzarası eşliğinde akşam yemeği.',
|
descriptionTr: 'Yat Limanı\'nda taze Ege balıkları ve geleneksel meze çeşitleriyle yerel lezzet durağınız. Mükemmel körfez manzarası eşliğinde akşam yemeği.',
|
||||||
descriptionEn: 'Your local taste stop at the Marina with fresh Aegean fish and traditional appetizers. Dinner accompanied by excellent bay views.',
|
descriptionEn: 'Your local taste stop at the Marina with fresh Aegean fish and traditional appetizers. Dinner accompanied by excellent bay views.',
|
||||||
descriptionRu: 'Ваша местная гастрономическая остановка в Марине со свежей эгейской рыбой и традиционными закусками. Ужин в сопровождении великолепного вида на залив.',
|
descriptionRu: 'Ваша местная гастрономическая остановка в Марине со свежей эгейской рыбой и традиционными закусками. Ужин в сопровождении великолепного вида на залив.',
|
||||||
@@ -195,7 +244,10 @@ if (!globalForMockDb.initialized) {
|
|||||||
createdAt: new Date(Date.now() - 3600000 * 24 * 5),
|
createdAt: new Date(Date.now() - 3600000 * 24 * 5),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
menuUrl: 'https://iskelemarmaris.com/menu',
|
menuUrl: 'https://iskelemarmaris.com/menu',
|
||||||
isFeatured: true
|
isFeatured: true,
|
||||||
|
hasWidgetInstalled: true,
|
||||||
|
widgetInstalledAt: new Date(Date.now() - 3600000 * 24 * 10),
|
||||||
|
widgetSiteUrl: 'https://iskelemarmaris.com'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'list-2',
|
id: 'list-2',
|
||||||
@@ -226,7 +278,10 @@ if (!globalForMockDb.initialized) {
|
|||||||
createdAt: new Date(Date.now() - 3600000 * 24 * 4),
|
createdAt: new Date(Date.now() - 3600000 * 24 * 4),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
menuUrl: 'https://mavibeyazmarmaris.com/digital-menu',
|
menuUrl: 'https://mavibeyazmarmaris.com/digital-menu',
|
||||||
isFeatured: false
|
isFeatured: false,
|
||||||
|
hasWidgetInstalled: false,
|
||||||
|
widgetInstalledAt: null,
|
||||||
|
widgetSiteUrl: null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'list-3',
|
id: 'list-3',
|
||||||
@@ -256,7 +311,10 @@ if (!globalForMockDb.initialized) {
|
|||||||
],
|
],
|
||||||
createdAt: new Date(Date.now() - 3600000 * 24 * 3),
|
createdAt: new Date(Date.now() - 3600000 * 24 * 3),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
isFeatured: false
|
isFeatured: false,
|
||||||
|
hasWidgetInstalled: false,
|
||||||
|
widgetInstalledAt: null,
|
||||||
|
widgetSiteUrl: null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'list-4',
|
id: 'list-4',
|
||||||
@@ -287,7 +345,10 @@ if (!globalForMockDb.initialized) {
|
|||||||
],
|
],
|
||||||
createdAt: new Date(Date.now() - 3600000 * 24 * 2),
|
createdAt: new Date(Date.now() - 3600000 * 24 * 2),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
isFeatured: true
|
isFeatured: true,
|
||||||
|
hasWidgetInstalled: false,
|
||||||
|
widgetInstalledAt: null,
|
||||||
|
widgetSiteUrl: null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'list-5',
|
id: 'list-5',
|
||||||
@@ -317,7 +378,10 @@ if (!globalForMockDb.initialized) {
|
|||||||
],
|
],
|
||||||
createdAt: new Date(Date.now() - 3600000 * 24 * 1),
|
createdAt: new Date(Date.now() - 3600000 * 24 * 1),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
isFeatured: false
|
isFeatured: false,
|
||||||
|
hasWidgetInstalled: false,
|
||||||
|
widgetInstalledAt: null,
|
||||||
|
widgetSiteUrl: null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'list-6',
|
id: 'list-6',
|
||||||
@@ -330,7 +394,7 @@ if (!globalForMockDb.initialized) {
|
|||||||
nameRu: 'Дайвинг-центр Марина',
|
nameRu: 'Дайвинг-центр Марина',
|
||||||
descriptionTr: 'Marmaris\'in kristal netliğindeki sularında profesyonel eğitmenlerle dalış eğitimleri ve günlük dalış turları. CMAS ve PADI sertifikalı eğitimler.',
|
descriptionTr: 'Marmaris\'in kristal netliğindeki sularında profesyonel eğitmenlerle dalış eğitimleri ve günlük dalış turları. CMAS ve PADI sertifikalı eğitimler.',
|
||||||
descriptionEn: 'Diving training and daily diving tours in the crystal clear waters of Marmaris with professional instructors. CMAS and PADI certified courses.',
|
descriptionEn: 'Diving training and daily diving tours in the crystal clear waters of Marmaris with professional instructors. CMAS and PADI certified courses.',
|
||||||
descriptionRu: 'Обучение дайвингу и ежедневные дайв-туры в кристально чистых водах Мармариса с профессиональными инструкторами. Курсы с сертификатом CMAS и PADI.',
|
descriptionRu: 'Обучение дайвингу и ежедневные дайв-туры в кристально чистых водах Мармариса с профессиональными инструкторами. Курсы с сертификатом CMAS и ПАДИ.',
|
||||||
address: 'Yat Limanı Belediye İskelesi, Marmaris',
|
address: 'Yat Limanı Belediye İskelesi, Marmaris',
|
||||||
phone: '+90 532 234 56 78',
|
phone: '+90 532 234 56 78',
|
||||||
whatsapp: '+90 532 234 56 78',
|
whatsapp: '+90 532 234 56 78',
|
||||||
@@ -347,7 +411,10 @@ if (!globalForMockDb.initialized) {
|
|||||||
],
|
],
|
||||||
createdAt: new Date(Date.now() - 3600000 * 12),
|
createdAt: new Date(Date.now() - 3600000 * 12),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
isFeatured: false
|
isFeatured: false,
|
||||||
|
hasWidgetInstalled: false,
|
||||||
|
widgetInstalledAt: null,
|
||||||
|
widgetSiteUrl: null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'list-7',
|
id: 'list-7',
|
||||||
@@ -377,7 +444,10 @@ if (!globalForMockDb.initialized) {
|
|||||||
],
|
],
|
||||||
createdAt: new Date(Date.now() - 3600000 * 6),
|
createdAt: new Date(Date.now() - 3600000 * 6),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
isFeatured: false
|
isFeatured: false,
|
||||||
|
hasWidgetInstalled: false,
|
||||||
|
widgetInstalledAt: null,
|
||||||
|
widgetSiteUrl: null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'list-8',
|
id: 'list-8',
|
||||||
@@ -407,7 +477,10 @@ if (!globalForMockDb.initialized) {
|
|||||||
],
|
],
|
||||||
createdAt: new Date(Date.now() - 3600000 * 2),
|
createdAt: new Date(Date.now() - 3600000 * 2),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
isFeatured: false
|
isFeatured: false,
|
||||||
|
hasWidgetInstalled: false,
|
||||||
|
widgetInstalledAt: null,
|
||||||
|
widgetSiteUrl: null
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -504,6 +577,52 @@ if (!globalForMockDb.initialized) {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Seed Listing Analytics Daily
|
||||||
|
globalForMockDb.listingAnalyticsDaily = [
|
||||||
|
{ id: 'an-1', listingId: 'list-1', date: new Date(), views: 120, whatsappClicks: 14, phoneClicks: 5, menuClicks: 22 },
|
||||||
|
{ id: 'an-2', listingId: 'list-2', date: new Date(), views: 45, whatsappClicks: 2, phoneClicks: 1, menuClicks: 0 }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Seed Events
|
||||||
|
globalForMockDb.events = [
|
||||||
|
{
|
||||||
|
id: 'evt-1',
|
||||||
|
slug: 'iskele-caz-gecesi',
|
||||||
|
listingId: 'list-1',
|
||||||
|
titleTr: 'İskele Caz Gecesi',
|
||||||
|
titleEn: 'Iskele Jazz Night',
|
||||||
|
titleRu: 'Джазовый вечер Искеле',
|
||||||
|
descriptionTr: 'Marmaris Marina\'da deniz esintisi eşliğinde canlı caz müziği ve özel akşam yemeği menüsü.',
|
||||||
|
descriptionEn: 'Live jazz music and special dinner menu accompanied by sea breeze at Marmaris Marina.',
|
||||||
|
descriptionRu: 'Живая джазовая музыка и специальное меню ужина в сопровождении морского бриза в Мармарис Марине.',
|
||||||
|
startDate: new Date(Date.now() + 3600000 * 24 * 3), // 3 days from now
|
||||||
|
endDate: new Date(Date.now() + 3600000 * 24 * 3 + 3600000 * 4),
|
||||||
|
coverImage: 'https://images.unsplash.com/photo-1511192336575-5a79af67a629?w=800&auto=format&fit=crop&q=80',
|
||||||
|
isSponsored: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'evt-2',
|
||||||
|
slug: 'siteler-havuz-partisi',
|
||||||
|
listingId: 'list-2',
|
||||||
|
titleTr: 'Yaz Ortası Havuz Partisi',
|
||||||
|
titleEn: 'Midsummer Pool Party',
|
||||||
|
titleRu: 'Летняя вечеринка у бассейна',
|
||||||
|
descriptionTr: 'Sınırsız müzik, dj performansları ve eğlenceli havuz aktiviteleri.',
|
||||||
|
descriptionEn: 'Unlimited music, DJ performances and fun pool activities.',
|
||||||
|
descriptionRu: 'Безлимитная музыка, диджейские сеты и веселые развлечения у бассейна.',
|
||||||
|
startDate: new Date(Date.now() + 3600000 * 24 * 7), // 7 days from now
|
||||||
|
coverImage: 'https://images.unsplash.com/photo-1576013551627-0cc20b96c2a7?w=800&auto=format&fit=crop&q=80',
|
||||||
|
isSponsored: false,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
globalForMockDb.generatedItineraries = []
|
||||||
|
|
||||||
|
globalForMockDb.__version = MOCK_DB_VERSION
|
||||||
globalForMockDb.initialized = true
|
globalForMockDb.initialized = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,6 +631,41 @@ export const mockDb = {
|
|||||||
isMock: () => process.env.USE_MOCK === 'true',
|
isMock: () => process.env.USE_MOCK === 'true',
|
||||||
|
|
||||||
// Categories
|
// Categories
|
||||||
|
async createCategory(data: Omit<Category, 'id' | 'createdAt' | 'updatedAt'>) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
const newCat: Category = {
|
||||||
|
id: 'cat-' + Date.now(),
|
||||||
|
...data,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
globalForMockDb.categories.push(newCat)
|
||||||
|
return newCat
|
||||||
|
}
|
||||||
|
return db.category.create({ data })
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateCategory(id: string, data: Partial<Omit<Category, 'id' | 'createdAt' | 'updatedAt'>>) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
const idx = globalForMockDb.categories.findIndex(c => c.id === id)
|
||||||
|
if (idx > -1) {
|
||||||
|
globalForMockDb.categories[idx] = { ...globalForMockDb.categories[idx], ...data, updatedAt: new Date() }
|
||||||
|
return globalForMockDb.categories[idx]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return db.category.update({ where: { id }, data })
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteCategory(id: string) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
globalForMockDb.categories = globalForMockDb.categories.filter(c => c.id !== id)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
await db.category.delete({ where: { id } })
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
|
||||||
async getCategories() {
|
async getCategories() {
|
||||||
if (this.isMock()) {
|
if (this.isMock()) {
|
||||||
return globalForMockDb.categories
|
return globalForMockDb.categories
|
||||||
@@ -555,6 +709,38 @@ export const mockDb = {
|
|||||||
return db.neighborhood.findUnique({ where: { id } })
|
return db.neighborhood.findUnique({ where: { id } })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async createNeighborhood(data: Omit<Neighborhood, 'id' | 'createdAt' | 'updatedAt'>) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
const newNeigh: Neighborhood = {
|
||||||
|
id: `neigh-${Date.now()}`,
|
||||||
|
...data,
|
||||||
|
}
|
||||||
|
globalForMockDb.neighborhoods.push(newNeigh)
|
||||||
|
return newNeigh
|
||||||
|
}
|
||||||
|
return db.neighborhood.create({ data })
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateNeighborhood(id: string, data: Partial<Omit<Neighborhood, 'id' | 'createdAt' | 'updatedAt'>>) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
const idx = globalForMockDb.neighborhoods.findIndex(n => n.id === id)
|
||||||
|
if (idx > -1) {
|
||||||
|
globalForMockDb.neighborhoods[idx] = { ...globalForMockDb.neighborhoods[idx], ...data }
|
||||||
|
return globalForMockDb.neighborhoods[idx]
|
||||||
|
}
|
||||||
|
throw new Error('Mahalle bulunamadı')
|
||||||
|
}
|
||||||
|
return db.neighborhood.update({ where: { id }, data })
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteNeighborhood(id: string) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
globalForMockDb.neighborhoods = globalForMockDb.neighborhoods.filter(n => n.id !== id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return db.neighborhood.delete({ where: { id } })
|
||||||
|
},
|
||||||
|
|
||||||
// Listings CRUD
|
// Listings CRUD
|
||||||
async getListings(filters?: {
|
async getListings(filters?: {
|
||||||
categoryId?: string
|
categoryId?: string
|
||||||
@@ -736,6 +922,50 @@ export const mockDb = {
|
|||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getDeletedListings() {
|
||||||
|
if (this.isMock()) {
|
||||||
|
const result = globalForMockDb.listings.filter(l => l.deletedAt)
|
||||||
|
return result.map(l => ({
|
||||||
|
...l,
|
||||||
|
category: globalForMockDb.categories.find(c => c.id === l.categoryId),
|
||||||
|
neighborhood: globalForMockDb.neighborhoods.find(n => n.id === l.neighborhoodId)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return db.listing.findMany({
|
||||||
|
where: { deletedAt: { not: null } },
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
neighborhood: true
|
||||||
|
},
|
||||||
|
orderBy: { deletedAt: 'desc' }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async restoreListing(id: string) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
const idx = globalForMockDb.listings.findIndex(l => l.id === id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
globalForMockDb.listings[idx].deletedAt = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
await db.listing.update({
|
||||||
|
where: { id },
|
||||||
|
data: { deletedAt: null }
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
|
||||||
|
async hardDeleteListing(id: string) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
globalForMockDb.listings = globalForMockDb.listings.filter(l => l.id !== id)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
await db.listing.delete({ where: { id } })
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
|
||||||
// Business Submissions
|
// Business Submissions
|
||||||
async getSubmissions() {
|
async getSubmissions() {
|
||||||
if (this.isMock()) {
|
if (this.isMock()) {
|
||||||
@@ -1046,5 +1276,229 @@ export const mockDb = {
|
|||||||
update: { handle, posts, fetchedAt: new Date() },
|
update: { handle, posts, fetchedAt: new Date() },
|
||||||
create: { listingId, handle, posts, fetchedAt: new Date() }
|
create: { listingId, handle, posts, fetchedAt: new Date() }
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// Phase 3 Widget & Backlink
|
||||||
|
async getWidgetPartners() {
|
||||||
|
if (this.isMock()) {
|
||||||
|
return globalForMockDb.listings.filter(l => l.hasWidgetInstalled && !l.deletedAt)
|
||||||
|
}
|
||||||
|
return db.listing.findMany({
|
||||||
|
where: { hasWidgetInstalled: true, deletedAt: null },
|
||||||
|
include: { category: true, neighborhood: true }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// Phase 3 Analytics daily aggregates
|
||||||
|
async getAnalytics(listingId: string, startDate?: Date, endDate?: Date) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
let data = globalForMockDb.listingAnalyticsDaily.filter(a => a.listingId === listingId)
|
||||||
|
if (startDate) {
|
||||||
|
data = data.filter(a => a.date >= startDate)
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
data = data.filter(a => a.date <= endDate)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
return db.listingAnalyticsDaily.findMany({
|
||||||
|
where: {
|
||||||
|
listingId,
|
||||||
|
date: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: { date: 'asc' }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async incrementAnalytics(listingId: string, actionType: 'views' | 'whatsapp' | 'phone' | 'menu') {
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
if (this.isMock()) {
|
||||||
|
const record = globalForMockDb.listingAnalyticsDaily.find(a =>
|
||||||
|
a.listingId === listingId &&
|
||||||
|
a.date.getTime() === today.getTime()
|
||||||
|
)
|
||||||
|
|
||||||
|
const keyMap: Record<string, keyof ListingAnalyticsDaily> = {
|
||||||
|
views: 'views',
|
||||||
|
whatsapp: 'whatsappClicks',
|
||||||
|
phone: 'phoneClicks',
|
||||||
|
menu: 'menuClicks'
|
||||||
|
}
|
||||||
|
const mappedKey = keyMap[actionType]
|
||||||
|
|
||||||
|
if (record) {
|
||||||
|
(record[mappedKey] as number) += 1
|
||||||
|
return record
|
||||||
|
} else {
|
||||||
|
const newRecord: ListingAnalyticsDaily = {
|
||||||
|
id: `an-${Date.now()}`,
|
||||||
|
listingId,
|
||||||
|
date: today,
|
||||||
|
views: actionType === 'views' ? 1 : 0,
|
||||||
|
whatsappClicks: actionType === 'whatsapp' ? 1 : 0,
|
||||||
|
phoneClicks: actionType === 'phone' ? 1 : 0,
|
||||||
|
menuClicks: actionType === 'menu' ? 1 : 0
|
||||||
|
}
|
||||||
|
globalForMockDb.listingAnalyticsDaily.push(newRecord)
|
||||||
|
return newRecord
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyMap = {
|
||||||
|
views: 'views',
|
||||||
|
whatsapp: 'whatsappClicks',
|
||||||
|
phone: 'phoneClicks',
|
||||||
|
menu: 'menuClicks'
|
||||||
|
}
|
||||||
|
const updateKey = keyMap[actionType] as 'views' | 'whatsappClicks' | 'phoneClicks' | 'menuClicks'
|
||||||
|
|
||||||
|
return db.listingAnalyticsDaily.upsert({
|
||||||
|
where: {
|
||||||
|
listingId_date: {
|
||||||
|
listingId,
|
||||||
|
date: today
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
[updateKey]: { increment: 1 }
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
listingId,
|
||||||
|
date: today,
|
||||||
|
views: actionType === 'views' ? 1 : 0,
|
||||||
|
whatsappClicks: actionType === 'whatsapp' ? 1 : 0,
|
||||||
|
phoneClicks: actionType === 'phone' ? 1 : 0,
|
||||||
|
menuClicks: actionType === 'menu' ? 1 : 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// Phase 3 Events
|
||||||
|
async getEvents(onlyActive = false) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
let evts = globalForMockDb.events.filter(e => !e.deletedAt)
|
||||||
|
if (onlyActive) {
|
||||||
|
evts = evts.filter(e => e.startDate >= new Date())
|
||||||
|
}
|
||||||
|
evts.sort((a, b) => a.startDate.getTime() - b.startDate.getTime())
|
||||||
|
return evts.map(e => ({
|
||||||
|
...e,
|
||||||
|
listing: globalForMockDb.listings.find(l => l.id === e.listingId)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return db.event.findMany({
|
||||||
|
where: {
|
||||||
|
deletedAt: null,
|
||||||
|
...(onlyActive ? { startDate: { gte: new Date() } } : {})
|
||||||
|
},
|
||||||
|
include: { listing: { include: { category: true, neighborhood: true } } },
|
||||||
|
orderBy: { startDate: 'asc' }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async getEventBySlug(slug: string) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
const e = globalForMockDb.events.find(evt => evt.slug === slug && !evt.deletedAt)
|
||||||
|
if (!e) return null
|
||||||
|
return {
|
||||||
|
...e,
|
||||||
|
listing: globalForMockDb.listings.find(l => l.id === e.listingId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return db.event.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
include: { listing: { include: { category: true, neighborhood: true } } }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async getEventById(id: string) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
const e = globalForMockDb.events.find(evt => evt.id === id && !evt.deletedAt)
|
||||||
|
if (!e) return null
|
||||||
|
return {
|
||||||
|
...e,
|
||||||
|
listing: globalForMockDb.listings.find(l => l.id === e.listingId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return db.event.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { listing: { include: { category: true, neighborhood: true } } }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async createEvent(data: Omit<Event, 'id' | 'createdAt' | 'updatedAt' | 'deletedAt'>) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
const newEvt: Event = {
|
||||||
|
id: `evt-${Date.now()}`,
|
||||||
|
...data,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
globalForMockDb.events.push(newEvt)
|
||||||
|
return newEvt
|
||||||
|
}
|
||||||
|
return db.event.create({ data })
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateEvent(id: string, data: Partial<Omit<Event, 'id' | 'createdAt' | 'updatedAt' | 'deletedAt'>>) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
const idx = globalForMockDb.events.findIndex(e => e.id === id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
globalForMockDb.events[idx] = {
|
||||||
|
...globalForMockDb.events[idx],
|
||||||
|
...data,
|
||||||
|
updatedAt: new Date()
|
||||||
|
} as Event
|
||||||
|
return globalForMockDb.events[idx]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return db.event.update({ where: { id }, data })
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteEvent(id: string) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
const idx = globalForMockDb.events.findIndex(e => e.id === id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
globalForMockDb.events.splice(idx, 1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
await db.event.delete({ where: { id } })
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
|
||||||
|
// Phase 3 AI Itineraries
|
||||||
|
async getItineraryByHash(hash: string) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
return globalForMockDb.generatedItineraries.find(i => i.paramsHash === hash) || null
|
||||||
|
}
|
||||||
|
return db.generatedItinerary.findUnique({ where: { paramsHash: hash } })
|
||||||
|
},
|
||||||
|
|
||||||
|
async getItineraryById(id: string) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
return globalForMockDb.generatedItineraries.find(i => i.id === id) || null
|
||||||
|
}
|
||||||
|
return db.generatedItinerary.findUnique({ where: { id } })
|
||||||
|
},
|
||||||
|
|
||||||
|
async createItinerary(data: Omit<GeneratedItinerary, 'id' | 'createdAt'>) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
const newItin: GeneratedItinerary = {
|
||||||
|
id: `itin-${Date.now()}`,
|
||||||
|
...data,
|
||||||
|
createdAt: new Date()
|
||||||
|
}
|
||||||
|
globalForMockDb.generatedItineraries.push(newItin)
|
||||||
|
return newItin
|
||||||
|
}
|
||||||
|
return db.generatedItinerary.create({ data })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-2
@@ -11,7 +11,33 @@
|
|||||||
"login": "Login",
|
"login": "Login",
|
||||||
"blog": "Blog",
|
"blog": "Blog",
|
||||||
"collections": "Collections",
|
"collections": "Collections",
|
||||||
"saved": "Favorites"
|
"saved": "Favorites",
|
||||||
|
"events": "Events",
|
||||||
|
"aiPlanner": "AI Planner"
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"title": "marmaris event calendar",
|
||||||
|
"subtitle": "current live music, jazz nights, gastronomy and festival schedules at marmaris local approved venues.",
|
||||||
|
"sponsored": "Sponsored",
|
||||||
|
"details": "View Details",
|
||||||
|
"date": "Date",
|
||||||
|
"noEvents": "No upcoming events scheduled. Stay tuned!",
|
||||||
|
"venue": "Venue"
|
||||||
|
},
|
||||||
|
"planner": {
|
||||||
|
"title": "AI travel planner",
|
||||||
|
"subtitle": "personalize your marmaris holiday based on your interests, number of days and local approved venues.",
|
||||||
|
"days": "How many days will you stay?",
|
||||||
|
"style": "What is your travel style?",
|
||||||
|
"neighborhoods": "Which neighborhoods do you want to explore?",
|
||||||
|
"generate": "Create Travel Plan",
|
||||||
|
"generating": "Preparing your plan...",
|
||||||
|
"viewPlan": "View Plan",
|
||||||
|
"styles": {
|
||||||
|
"gastronomy": "Gourmet & Gastronomy",
|
||||||
|
"relaxation": "Peace & Relaxation",
|
||||||
|
"adventure": "Adventure & Nature"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"hero": {
|
"hero": {
|
||||||
"title": "Marmaris' Finest Local Spots",
|
"title": "Marmaris' Finest Local Spots",
|
||||||
@@ -48,13 +74,14 @@
|
|||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"approved": "Local Approved Sealed Business",
|
"approved": "Local Approved Sealed Business",
|
||||||
"rating": "Editor Score",
|
"rating": "Google Reviews Score",
|
||||||
"price": "Price Level",
|
"price": "Price Level",
|
||||||
"address": "Address",
|
"address": "Address",
|
||||||
"hours": "Opening Hours",
|
"hours": "Opening Hours",
|
||||||
"contact": "Contact Info",
|
"contact": "Contact Info",
|
||||||
"call": "Call Now",
|
"call": "Call Now",
|
||||||
"whatsapp": "Send WhatsApp",
|
"whatsapp": "Send WhatsApp",
|
||||||
|
"phone": "Phone",
|
||||||
"website": "Website",
|
"website": "Website",
|
||||||
"instagram": "Instagram",
|
"instagram": "Instagram",
|
||||||
"location": "Location / Map",
|
"location": "Location / Map",
|
||||||
|
|||||||
+29
-2
@@ -11,7 +11,33 @@
|
|||||||
"login": "Войти",
|
"login": "Войти",
|
||||||
"blog": "Блог",
|
"blog": "Блог",
|
||||||
"collections": "Подборки",
|
"collections": "Подборки",
|
||||||
"saved": "Избранное"
|
"saved": "Избранное",
|
||||||
|
"events": "Мероприятия",
|
||||||
|
"aiPlanner": "AI Планировщик"
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"title": "календарь событий мармариса",
|
||||||
|
"subtitle": "текущее расписание живой музыки, джазовых вечеров, гастрономии и фестивалей в заведениях, одобренных мармарис локал.",
|
||||||
|
"sponsored": "Спонсорский",
|
||||||
|
"details": "Подробнее",
|
||||||
|
"date": "Дата",
|
||||||
|
"noEvents": "Ближайших мероприятий не запланировано. Оставайтесь на связи!",
|
||||||
|
"venue": "Заведение"
|
||||||
|
},
|
||||||
|
"planner": {
|
||||||
|
"title": "AI планировщик путешествий",
|
||||||
|
"subtitle": "персонализируйте свой отдых в мармарисе на основе ваших интересов, количества дней и одобренных местных заведений.",
|
||||||
|
"days": "Сколько дней вы пробудете?",
|
||||||
|
"style": "Какой у вас стиль путешествия?",
|
||||||
|
"neighborhoods": "Какие районы вы хотите исследовать?",
|
||||||
|
"generate": "Создать план поездки",
|
||||||
|
"generating": "Подготовка вашего плана...",
|
||||||
|
"viewPlan": "Посмотреть план",
|
||||||
|
"styles": {
|
||||||
|
"gastronomy": "Гурман и гастрономия",
|
||||||
|
"relaxation": "Покой и отдых",
|
||||||
|
"adventure": "Приключения и природа"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"hero": {
|
"hero": {
|
||||||
"title": "Лучшие места Мармариса",
|
"title": "Лучшие места Мармариса",
|
||||||
@@ -48,13 +74,14 @@
|
|||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"approved": "Заведение со знаком «Одобрено местными»",
|
"approved": "Заведение со знаком «Одобрено местными»",
|
||||||
"rating": "Оценка редактора",
|
"rating": "Оценка Google",
|
||||||
"price": "Уровень цен",
|
"price": "Уровень цен",
|
||||||
"address": "Адрес",
|
"address": "Адрес",
|
||||||
"hours": "Часы работы",
|
"hours": "Часы работы",
|
||||||
"contact": "Контакты",
|
"contact": "Контакты",
|
||||||
"call": "Позвонить",
|
"call": "Позвонить",
|
||||||
"whatsapp": "Написать в WhatsApp",
|
"whatsapp": "Написать в WhatsApp",
|
||||||
|
"phone": "Телефон",
|
||||||
"website": "Сайт",
|
"website": "Сайт",
|
||||||
"instagram": "Instagram",
|
"instagram": "Instagram",
|
||||||
"location": "Расположение / Карта",
|
"location": "Расположение / Карта",
|
||||||
|
|||||||
+29
-2
@@ -11,7 +11,33 @@
|
|||||||
"login": "Giriş Yap",
|
"login": "Giriş Yap",
|
||||||
"blog": "Blog",
|
"blog": "Blog",
|
||||||
"collections": "Seçkiler",
|
"collections": "Seçkiler",
|
||||||
"saved": "Kaydedilenler"
|
"saved": "Kaydedilenler",
|
||||||
|
"events": "Etkinlikler",
|
||||||
|
"aiPlanner": "AI Planlayıcı"
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"title": "marmaris etkinlik takvimi",
|
||||||
|
"subtitle": "marmaris local onaylı işletmelerdeki güncel canlı müzik, caz, gastronomi ve festival takvimi.",
|
||||||
|
"sponsored": "Sponsorlu",
|
||||||
|
"details": "Detayları Gör",
|
||||||
|
"date": "Tarih",
|
||||||
|
"noEvents": "Yakın zamanda düzenlenecek etkinlik bulunmuyor. Takipte kalın!",
|
||||||
|
"venue": "Mekan"
|
||||||
|
},
|
||||||
|
"planner": {
|
||||||
|
"title": "yapay zeka seyahat planlayıcı",
|
||||||
|
"subtitle": "marmaris'teki tatilinizi ilgi alanlarınıza, gün sayısına ve yerel onaylı mekanlara göre kişiselleştirin.",
|
||||||
|
"days": "Kaç gün kalacaksınız?",
|
||||||
|
"style": "Seyahat stiliniz nedir?",
|
||||||
|
"neighborhoods": "Hangi bölgeleri keşfetmek istersiniz?",
|
||||||
|
"generate": "Seyahat Planı Oluştur",
|
||||||
|
"generating": "Planınız Hazırlanıyor...",
|
||||||
|
"viewPlan": "Planı Görüntüle",
|
||||||
|
"styles": {
|
||||||
|
"gastronomy": "Gurme ve Gastronomi",
|
||||||
|
"relaxation": "Huzur ve Dinlenme",
|
||||||
|
"adventure": "Macera ve Doğa"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"hero": {
|
"hero": {
|
||||||
"title": "Marmaris'in En İyi Yerel Adresleri",
|
"title": "Marmaris'in En İyi Yerel Adresleri",
|
||||||
@@ -48,13 +74,14 @@
|
|||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"approved": "Yerel Onaylı Mühürlü İşletme",
|
"approved": "Yerel Onaylı Mühürlü İşletme",
|
||||||
"rating": "Editör Puanı",
|
"rating": "Google Puanı",
|
||||||
"price": "Fiyat Seviyesi",
|
"price": "Fiyat Seviyesi",
|
||||||
"address": "Açık Adres",
|
"address": "Açık Adres",
|
||||||
"hours": "Çalışma Saatleri",
|
"hours": "Çalışma Saatleri",
|
||||||
"contact": "İletişim Bilgileri",
|
"contact": "İletişim Bilgileri",
|
||||||
"call": "Hemen Ara",
|
"call": "Hemen Ara",
|
||||||
"whatsapp": "WhatsApp Mesajı",
|
"whatsapp": "WhatsApp Mesajı",
|
||||||
|
"phone": "Telefon",
|
||||||
"website": "Web Sitesi",
|
"website": "Web Sitesi",
|
||||||
"instagram": "Instagram",
|
"instagram": "Instagram",
|
||||||
"location": "Konum / Harita",
|
"location": "Konum / Harita",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const nextConfig: NextConfig = {
|
|||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{ protocol: 'https', hostname: 'res.cloudinary.com' },
|
{ protocol: 'https', hostname: 'res.cloudinary.com' },
|
||||||
{ protocol: 'https', hostname: 'images.unsplash.com' },
|
{ protocol: 'https', hostname: 'images.unsplash.com' },
|
||||||
|
{ protocol: 'https', hostname: 'media.ayris.tech' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+519
@@ -34,6 +34,7 @@
|
|||||||
"eslint-config-next": "16.2.9",
|
"eslint-config-next": "16.2.9",
|
||||||
"prisma": "^6.3.0",
|
"prisma": "^6.3.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.23.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -785,6 +786,448 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@eslint-community/eslint-utils": {
|
"node_modules/@eslint-community/eslint-utils": {
|
||||||
"version": "4.9.1",
|
"version": "4.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||||
@@ -5069,6 +5512,48 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.28.1",
|
||||||
|
"@esbuild/android-arm": "0.28.1",
|
||||||
|
"@esbuild/android-arm64": "0.28.1",
|
||||||
|
"@esbuild/android-x64": "0.28.1",
|
||||||
|
"@esbuild/darwin-arm64": "0.28.1",
|
||||||
|
"@esbuild/darwin-x64": "0.28.1",
|
||||||
|
"@esbuild/freebsd-arm64": "0.28.1",
|
||||||
|
"@esbuild/freebsd-x64": "0.28.1",
|
||||||
|
"@esbuild/linux-arm": "0.28.1",
|
||||||
|
"@esbuild/linux-arm64": "0.28.1",
|
||||||
|
"@esbuild/linux-ia32": "0.28.1",
|
||||||
|
"@esbuild/linux-loong64": "0.28.1",
|
||||||
|
"@esbuild/linux-mips64el": "0.28.1",
|
||||||
|
"@esbuild/linux-ppc64": "0.28.1",
|
||||||
|
"@esbuild/linux-riscv64": "0.28.1",
|
||||||
|
"@esbuild/linux-s390x": "0.28.1",
|
||||||
|
"@esbuild/linux-x64": "0.28.1",
|
||||||
|
"@esbuild/netbsd-arm64": "0.28.1",
|
||||||
|
"@esbuild/netbsd-x64": "0.28.1",
|
||||||
|
"@esbuild/openbsd-arm64": "0.28.1",
|
||||||
|
"@esbuild/openbsd-x64": "0.28.1",
|
||||||
|
"@esbuild/openharmony-arm64": "0.28.1",
|
||||||
|
"@esbuild/sunos-x64": "0.28.1",
|
||||||
|
"@esbuild/win32-arm64": "0.28.1",
|
||||||
|
"@esbuild/win32-ia32": "0.28.1",
|
||||||
|
"@esbuild/win32-x64": "0.28.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
@@ -5947,6 +6432,21 @@
|
|||||||
"node": ">=14.14"
|
"node": ">=14.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -10112,6 +10612,25 @@
|
|||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.23.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz",
|
||||||
|
"integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "~0.28.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tw-animate-css": {
|
"node_modules/tw-animate-css": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
|
||||||
|
|||||||
@@ -35,6 +35,10 @@
|
|||||||
"eslint-config-next": "16.2.9",
|
"eslint-config-next": "16.2.9",
|
||||||
"prisma": "^6.3.0",
|
"prisma": "^6.3.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.23.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,6 +126,13 @@ model Listing {
|
|||||||
featuredUntil DateTime?
|
featuredUntil DateTime?
|
||||||
collections Collection[] @relation("CollectionListings")
|
collections Collection[] @relation("CollectionListings")
|
||||||
instagramFeed InstagramFeedCache?
|
instagramFeed InstagramFeedCache?
|
||||||
|
|
||||||
|
// Phase 3 Fields
|
||||||
|
hasWidgetInstalled Boolean @default(false)
|
||||||
|
widgetInstalledAt DateTime?
|
||||||
|
widgetSiteUrl String?
|
||||||
|
analytics ListingAnalyticsDaily[]
|
||||||
|
events Event[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Gallery {
|
model Gallery {
|
||||||
@@ -210,3 +217,49 @@ model InstagramFeedCache {
|
|||||||
|
|
||||||
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
|
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ListingAnalyticsDaily {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
listingId String
|
||||||
|
date DateTime
|
||||||
|
views Int @default(0)
|
||||||
|
whatsappClicks Int @default(0)
|
||||||
|
phoneClicks Int @default(0)
|
||||||
|
menuClicks Int @default(0)
|
||||||
|
|
||||||
|
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([listingId, date])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Event {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
slug String @unique
|
||||||
|
listingId String?
|
||||||
|
titleTr String
|
||||||
|
titleEn String
|
||||||
|
titleRu String
|
||||||
|
descriptionTr String @db.Text
|
||||||
|
descriptionEn String @db.Text
|
||||||
|
descriptionRu String @db.Text
|
||||||
|
startDate DateTime
|
||||||
|
endDate DateTime?
|
||||||
|
coverImage String? // Openinary
|
||||||
|
isSponsored Boolean @default(false)
|
||||||
|
|
||||||
|
listing Listing? @relation(fields: [listingId], references: [id])
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
}
|
||||||
|
|
||||||
|
model GeneratedItinerary {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
paramsHash String @unique
|
||||||
|
params Json // budget, groupType, interests, days
|
||||||
|
content String @db.Text
|
||||||
|
listingIds String[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|||||||
+154
@@ -0,0 +1,154 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
import { mockDb } from '../lib/mockDb'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🌱 Bütün mock verileri PostgreSQL veritabanına aktarılıyor...')
|
||||||
|
|
||||||
|
// Force mockDb to use in-memory data for seeding
|
||||||
|
process.env.USE_MOCK = 'true'
|
||||||
|
|
||||||
|
// 1. Kategoriler
|
||||||
|
console.log('Kategoriler ekleniyor...')
|
||||||
|
const categories = await mockDb.getCategories()
|
||||||
|
for (const cat of categories) {
|
||||||
|
await prisma.category.upsert({
|
||||||
|
where: { id: cat.id },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
id: cat.id,
|
||||||
|
slug: cat.slug,
|
||||||
|
nameTr: cat.nameTr,
|
||||||
|
nameEn: cat.nameEn,
|
||||||
|
nameRu: cat.nameRu,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Mahalleler
|
||||||
|
console.log('Mahalleler ekleniyor...')
|
||||||
|
const neighborhoods = await mockDb.getNeighborhoods()
|
||||||
|
for (const neigh of neighborhoods) {
|
||||||
|
await prisma.neighborhood.upsert({
|
||||||
|
where: { id: neigh.id },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
id: neigh.id,
|
||||||
|
slug: neigh.slug,
|
||||||
|
nameTr: neigh.nameTr,
|
||||||
|
nameEn: neigh.nameEn,
|
||||||
|
nameRu: neigh.nameRu,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Mekanlar (Listings)
|
||||||
|
console.log('Mekanlar ekleniyor...')
|
||||||
|
const listings = await mockDb.getListings() // Default is 100 limit, mockDb returns all for seed if not paginated
|
||||||
|
// Let's ensure we get all
|
||||||
|
const allListings = await mockDb.getListings({ limit: 1000 })
|
||||||
|
for (const listing of allListings) {
|
||||||
|
await prisma.listing.upsert({
|
||||||
|
where: { id: listing.id },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
id: listing.id,
|
||||||
|
slug: listing.slug,
|
||||||
|
categoryId: listing.categoryId,
|
||||||
|
neighborhoodId: listing.neighborhoodId,
|
||||||
|
city: listing.city,
|
||||||
|
nameTr: listing.nameTr,
|
||||||
|
nameEn: listing.nameEn,
|
||||||
|
nameRu: listing.nameRu,
|
||||||
|
descriptionTr: listing.descriptionTr,
|
||||||
|
descriptionEn: listing.descriptionEn,
|
||||||
|
descriptionRu: listing.descriptionRu,
|
||||||
|
address: listing.address,
|
||||||
|
phone: listing.phone,
|
||||||
|
whatsapp: listing.whatsapp,
|
||||||
|
website: listing.website,
|
||||||
|
instagram: listing.instagram,
|
||||||
|
priceRange: listing.priceRange,
|
||||||
|
rating: listing.rating,
|
||||||
|
isLocalApproved: listing.isLocalApproved,
|
||||||
|
latitude: listing.latitude,
|
||||||
|
longitude: listing.longitude,
|
||||||
|
openingHours: listing.openingHours ? JSON.parse(JSON.stringify(listing.openingHours)) : undefined,
|
||||||
|
createdAt: listing.createdAt,
|
||||||
|
updatedAt: listing.updatedAt,
|
||||||
|
menuUrl: listing.menuUrl,
|
||||||
|
isFeatured: listing.isFeatured,
|
||||||
|
hasWidgetInstalled: listing.hasWidgetInstalled,
|
||||||
|
widgetSiteUrl: listing.widgetSiteUrl,
|
||||||
|
images: {
|
||||||
|
create: listing.images?.map(img => ({
|
||||||
|
id: img.id,
|
||||||
|
url: img.url,
|
||||||
|
createdAt: img.createdAt
|
||||||
|
})) || []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Etkinlikler
|
||||||
|
console.log('Etkinlikler ekleniyor...')
|
||||||
|
const events = await mockDb.getEvents(true) // Get all including past
|
||||||
|
for (const event of events) {
|
||||||
|
await prisma.event.upsert({
|
||||||
|
where: { id: event.id },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
id: event.id,
|
||||||
|
slug: event.slug,
|
||||||
|
listingId: event.listingId,
|
||||||
|
titleTr: event.titleTr,
|
||||||
|
titleEn: event.titleEn,
|
||||||
|
titleRu: event.titleRu,
|
||||||
|
descriptionTr: event.descriptionTr,
|
||||||
|
descriptionEn: event.descriptionEn,
|
||||||
|
descriptionRu: event.descriptionRu,
|
||||||
|
startDate: event.startDate,
|
||||||
|
endDate: event.endDate,
|
||||||
|
coverImage: event.coverImage,
|
||||||
|
isSponsored: event.isSponsored,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Koleksiyonlar
|
||||||
|
console.log('Koleksiyonlar ekleniyor...')
|
||||||
|
const collections = await mockDb.getCollections()
|
||||||
|
for (const col of collections) {
|
||||||
|
await prisma.collection.upsert({
|
||||||
|
where: { id: col.id },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
id: col.id,
|
||||||
|
slug: col.slug,
|
||||||
|
titleTr: col.titleTr,
|
||||||
|
titleEn: col.titleEn,
|
||||||
|
titleRu: col.titleRu,
|
||||||
|
descriptionTr: col.descriptionTr,
|
||||||
|
descriptionEn: col.descriptionEn,
|
||||||
|
descriptionRu: col.descriptionRu,
|
||||||
|
coverImage: col.coverImage,
|
||||||
|
listings: {
|
||||||
|
connect: col.listingIds?.map(id => ({ id })) || []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Veritabanı başarıyla seed edildi!')
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect()
|
||||||
|
})
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="tr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Marmaris Local - Widget Test Sayfası</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: #FBFAF6;
|
||||||
|
color: #123238;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
padding: 40px 20px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
border-bottom: 2px dashed #12323815;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 30px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #D23B68;
|
||||||
|
}
|
||||||
|
.demo-box {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #12323810;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<h1>marmaris local widget demo</h1>
|
||||||
|
<p>Bu sayfa, Muğla Dijital Medya ortak sitelerine yerleştirilecek olan <strong>bölgesel öneri widget'ının</strong> önizlemesini test etmek amacıyla oluşturulmuştur.</p>
|
||||||
|
|
||||||
|
<h2>1. Açık Tema Demo (data-neighborhood="yat-limani")</h2>
|
||||||
|
<div class="demo-box">
|
||||||
|
<!-- Widget Script Embed -->
|
||||||
|
<script src="./widget.js" data-neighborhood="yat-limani" data-theme="light"></script>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>2. Koyu Tema Demo (data-neighborhood="yat-limani" data-theme="dark")</h2>
|
||||||
|
<div class="demo-box" style="background-color: #1a1a1a;">
|
||||||
|
<!-- Widget Script Embed -->
|
||||||
|
<script src="./widget.js" data-neighborhood="yat-limani" data-theme="dark"></script>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
(function() {
|
||||||
|
const currentScript = document.currentScript;
|
||||||
|
if (!currentScript) return;
|
||||||
|
|
||||||
|
const neighborhood = currentScript.getAttribute('data-neighborhood') || '';
|
||||||
|
const theme = currentScript.getAttribute('data-theme') || 'light';
|
||||||
|
|
||||||
|
// Extract base URL of the running script dynamically
|
||||||
|
const scriptUrl = currentScript.src;
|
||||||
|
const baseUrl = new URL(scriptUrl).origin;
|
||||||
|
|
||||||
|
if (!neighborhood) {
|
||||||
|
console.error('Marmaris Local Widget: data-neighborhood parameter is missing.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create widget container element
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'marmaris-local-widget-wrapper';
|
||||||
|
currentScript.parentNode.insertBefore(container, currentScript);
|
||||||
|
|
||||||
|
// Inject CSS Styles
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
.marmaris-local-widget-wrapper {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
border: 1px solid ${theme === 'dark' ? '#254449' : '#12323815'};
|
||||||
|
background-color: ${theme === 'dark' ? '#123238' : '#FBFAF6'};
|
||||||
|
color: ${theme === 'dark' ? '#FBFAF6' : '#123238'};
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 15px 0;
|
||||||
|
box-shadow: 0 4px 12px rgba(18, 50, 56, 0.04);
|
||||||
|
}
|
||||||
|
.marmaris-local-widget-header {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.marmaris-local-widget-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
text-transform: lowercase;
|
||||||
|
color: ${theme === 'dark' ? '#FBFAF6' : '#123238'};
|
||||||
|
}
|
||||||
|
.marmaris-local-widget-header h4 span {
|
||||||
|
color: #39C3C3;
|
||||||
|
}
|
||||||
|
.marmaris-local-widget-header .neighborhood-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: monospace;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #D23B68;
|
||||||
|
background-color: rgba(210, 59, 104, 0.06);
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 99px;
|
||||||
|
border: 1px solid rgba(210, 59, 104, 0.1);
|
||||||
|
}
|
||||||
|
.marmaris-local-widget-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.marmaris-local-widget-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
text-decoration: none;
|
||||||
|
background: ${theme === 'dark' ? '#254449' : '#FFFFFF'};
|
||||||
|
border: 1px solid ${theme === 'dark' ? '#ffffff10' : '#12323808'};
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.marmaris-local-widget-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: rgba(57, 195, 195, 0.35);
|
||||||
|
box-shadow: 0 4px 10px rgba(18, 50, 56, 0.06);
|
||||||
|
}
|
||||||
|
.marmaris-local-widget-card-image {
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
width: 100%;
|
||||||
|
background: #12323810;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.marmaris-local-widget-card-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
.marmaris-local-widget-card:hover .marmaris-local-widget-card-image img {
|
||||||
|
transform: scale(1.03);
|
||||||
|
}
|
||||||
|
.marmaris-local-widget-card-content {
|
||||||
|
padding: 12px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.marmaris-local-widget-card-category {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #D23B68;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.marmaris-local-widget-card-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: ${theme === 'dark' ? '#FBFAF6' : '#123238'};
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
.marmaris-local-widget-card-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: monospace;
|
||||||
|
color: ${theme === 'dark' ? '#FBFAF680' : '#12323865'};
|
||||||
|
border-top: 1px dashed ${theme === 'dark' ? '#ffffff10' : '#12323808'};
|
||||||
|
padding-top: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.marmaris-local-widget-card-footer .stars {
|
||||||
|
color: #FFB020;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.marmaris-local-widget-card-footer .price {
|
||||||
|
color: ${theme === 'dark' ? '#FBFAF6' : '#123238'};
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.marmaris-local-widget-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-top: 1px solid ${theme === 'dark' ? '#ffffff10' : '#12323810'};
|
||||||
|
padding-top: 12px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: ${theme === 'dark' ? '#FBFAF650' : '#12323850'};
|
||||||
|
}
|
||||||
|
.marmaris-local-widget-footer a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: underline;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.marmaris-local-widget-footer a:hover {
|
||||||
|
color: #39C3C3;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
// Fetch nearby approved listings
|
||||||
|
fetch(`${baseUrl}/api/widget/nearby?neighborhood=${encodeURIComponent(neighborhood)}`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
const listings = data.listings || [];
|
||||||
|
|
||||||
|
if (listings.length === 0) {
|
||||||
|
container.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render Header
|
||||||
|
let html = `
|
||||||
|
<div class="marmaris-local-widget-header">
|
||||||
|
<h4>yakında <span>nerede yenir?</span></h4>
|
||||||
|
<span class="neighborhood-badge">${neighborhood}</span>
|
||||||
|
</div>
|
||||||
|
<div class="marmaris-local-widget-grid">
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Render Cards
|
||||||
|
listings.slice(0, 3).forEach(item => {
|
||||||
|
const itemUrl = `${baseUrl}/${item.categorySlug}/${item.slug}`;
|
||||||
|
html += `
|
||||||
|
<a href="${itemUrl}" target="_blank" class="marmaris-local-widget-card">
|
||||||
|
<div class="marmaris-local-widget-card-image">
|
||||||
|
<img src="${item.coverImage}" alt="${item.name}">
|
||||||
|
</div>
|
||||||
|
<div class="marmaris-local-widget-card-content">
|
||||||
|
<div>
|
||||||
|
<div class="marmaris-local-widget-card-category">${item.categoryName}</div>
|
||||||
|
<h5 class="marmaris-local-widget-card-title">${item.name.toLowerCase()}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="marmaris-local-widget-card-footer">
|
||||||
|
<span class="price">${item.priceSymbols}</span>
|
||||||
|
${item.rating ? `<span class="stars">★ ${item.rating.toFixed(1)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render Footer Stamp (Mandatory visibility for search quality guidelines)
|
||||||
|
html += `
|
||||||
|
</div>
|
||||||
|
<div class="marmaris-local-widget-footer">
|
||||||
|
<span>yerel öneriler</span>
|
||||||
|
<span>detaylar <a href="${baseUrl}" target="_blank">marmaris local</a>\\'de</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Marmaris Local Widget load error:', err);
|
||||||
|
container.style.display = 'none';
|
||||||
|
});
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user