Security: - requireAdmin() session check added to every admin-only server action (previously relied only on middleware path matching, which Next.js Server Actions don't reliably respect) - Real Prisma + bcrypt admin auth, replacing hardcoded credentials; split into an Edge-safe auth.config.ts (used by proxy.ts) and the full Prisma-backed auth.ts (route handler, server actions, server components) - Removed hardcoded fallback secret on the Instagram sync cron endpoint - Honeypot field + per-IP rate limiting on contact/business-submission forms and the analytics events endpoint Features: - AI trip planner (/plan-olustur, /plan/[id]) backed by DeepSeek, grounded to only recommend isLocalApproved listings, with a deterministic link-injection fallback for anything the model doesn't format as markdown - Interactive Leaflet/OpenStreetMap view on category listing pages - Telegram notifications for new contact messages and business submissions SEO: - Brand-consistent favicon/apple-icon/PWA icons and default Open Graph/ Twitter share images, generated via next/og (replacing default Next.js placeholders) - BreadcrumbList structured data on category and listing detail pages - Fixed two remaining raw <img> tags to use next/image Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1601 lines
57 KiB
TypeScript
1601 lines
57 KiB
TypeScript
import { db } from './db'
|
||
|
||
export interface Category {
|
||
id: string
|
||
slug: string
|
||
nameTr: string
|
||
nameEn: string
|
||
nameRu: string
|
||
createdAt: Date
|
||
updatedAt: Date
|
||
}
|
||
|
||
export interface Neighborhood {
|
||
id: string
|
||
slug: string
|
||
nameTr: string
|
||
nameEn: string
|
||
nameRu: string
|
||
}
|
||
|
||
export interface Gallery {
|
||
id: string
|
||
listingId: string
|
||
url: string
|
||
}
|
||
|
||
export interface Listing {
|
||
id: string
|
||
slug: string
|
||
categoryId: string
|
||
neighborhoodId: string
|
||
city: string
|
||
nameTr: string
|
||
nameEn: string
|
||
nameRu: string
|
||
descriptionTr: string
|
||
descriptionEn: string
|
||
descriptionRu: string
|
||
address: string
|
||
phone?: string | null
|
||
whatsapp?: string | null
|
||
website?: string | null
|
||
instagram?: string | null
|
||
priceRange: number
|
||
rating?: number | null
|
||
isLocalApproved: boolean
|
||
latitude?: number | null
|
||
longitude?: number | null
|
||
openingHours?: any | null
|
||
images: Gallery[]
|
||
category?: Category
|
||
neighborhood?: Neighborhood
|
||
createdAt: Date
|
||
updatedAt: Date
|
||
deletedAt?: Date | null
|
||
|
||
// Phase 2
|
||
menuUrl?: string | null
|
||
isFeatured: boolean
|
||
featuredUntil?: Date | null
|
||
|
||
// Phase 3
|
||
hasWidgetInstalled: boolean
|
||
widgetInstalledAt?: Date | null
|
||
widgetSiteUrl?: string | null
|
||
}
|
||
|
||
export interface BusinessSubmission {
|
||
id: string
|
||
businessName: string
|
||
categoryId: string
|
||
neighborhoodId: string
|
||
address: string
|
||
phone?: string | null
|
||
whatsapp?: string | null
|
||
description: string
|
||
contactName: string
|
||
contactEmail: string
|
||
imageUrl?: string | null
|
||
status: string
|
||
createdAt: Date
|
||
updatedAt: Date
|
||
}
|
||
|
||
export interface ContactMessage {
|
||
id: string
|
||
name: string
|
||
email: string
|
||
subject: string
|
||
message: string
|
||
isRead: boolean
|
||
createdAt: Date
|
||
updatedAt: Date
|
||
}
|
||
|
||
export interface BlogPost {
|
||
id: string
|
||
slug: string
|
||
titleTr: string
|
||
titleEn: string
|
||
titleRu: string
|
||
contentTr: string
|
||
contentEn: string
|
||
contentRu: string
|
||
coverImage?: string | null
|
||
tags: string[]
|
||
relatedListingIds: string[]
|
||
publishedAt?: Date | null
|
||
createdAt: Date
|
||
updatedAt: Date
|
||
deletedAt?: Date | null
|
||
}
|
||
|
||
export interface Collection {
|
||
id: string
|
||
slug: string
|
||
titleTr: string
|
||
titleEn: string
|
||
titleRu: string
|
||
descriptionTr: string
|
||
descriptionEn: string
|
||
descriptionRu: string
|
||
coverImage?: string | null
|
||
listings?: Listing[]
|
||
listingIds: string[]
|
||
createdAt: Date
|
||
updatedAt: Date
|
||
}
|
||
|
||
export interface WidgetPartner {
|
||
id: string
|
||
name: string
|
||
url: string
|
||
neighborhoodSlug: string | null
|
||
isActive: boolean
|
||
isHidden: boolean
|
||
createdAt: Date
|
||
updatedAt: Date
|
||
}
|
||
|
||
export interface InstagramFeedCache {
|
||
id: string
|
||
listingId: string
|
||
handle: string
|
||
posts: Array<{
|
||
imageUrl: string
|
||
caption?: string
|
||
permalink?: string
|
||
postedAt: string
|
||
}>
|
||
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 {
|
||
categories: Category[]
|
||
neighborhoods: Neighborhood[]
|
||
listings: Listing[]
|
||
submissions: BusinessSubmission[]
|
||
messages: ContactMessage[]
|
||
blogPosts: BlogPost[]
|
||
collections: Collection[]
|
||
instagramFeedCaches: InstagramFeedCache[]
|
||
listingAnalyticsDaily: ListingAnalyticsDaily[]
|
||
events: Event[]
|
||
widgetPartners: WidgetPartner[]
|
||
generatedItineraries: GeneratedItinerary[]
|
||
initialized: boolean
|
||
__version: number
|
||
}
|
||
|
||
if (!globalForMockDb.initialized || globalForMockDb.__version !== MOCK_DB_VERSION) {
|
||
globalForMockDb.categories = [
|
||
{ id: 'cat-1', slug: 'restoran', nameTr: 'Restoran', nameEn: 'Restaurant', nameRu: 'Ресторан', createdAt: new Date(), updatedAt: new Date() },
|
||
{ id: 'cat-2', slug: 'apart', nameTr: 'Apart', nameEn: 'Apart Hotel', nameRu: 'Апарт-отель', createdAt: new Date(), updatedAt: new Date() },
|
||
{ id: 'cat-3', slug: 'isletme', nameTr: 'İşletme & Hizmet', nameEn: 'Business & Service', nameRu: 'Бизнес и Услуги', createdAt: new Date(), updatedAt: new Date() }
|
||
]
|
||
|
||
globalForMockDb.neighborhoods = [
|
||
{ id: 'neigh-1', slug: 'yat-limani', nameTr: 'Yat Limanı', nameEn: 'Marina', nameRu: 'Марина' },
|
||
{ id: 'neigh-2', slug: 'icmeler', nameTr: 'İçmeler', nameEn: 'Icmeler', nameRu: 'Ичмелер' },
|
||
{ id: 'neigh-3', slug: 'armutalan', nameTr: 'Armutalan', nameEn: 'Armutalan', nameRu: 'Армуталан' },
|
||
{ id: 'neigh-4', slug: 'siteler', nameTr: 'Siteler', nameEn: 'Siteler', nameRu: 'Сителер' },
|
||
{ id: 'neigh-5', slug: 'turunc', nameTr: 'Turunç', nameEn: 'Turunc', nameRu: 'Турунч' }
|
||
]
|
||
|
||
globalForMockDb.submissions = []
|
||
globalForMockDb.messages = []
|
||
|
||
// Seed Listings with Phase 2 & 3 fields
|
||
globalForMockDb.listings = [
|
||
{
|
||
id: 'list-1',
|
||
slug: 'iskele-balik-ocakbasi',
|
||
categoryId: 'cat-1',
|
||
neighborhoodId: 'neigh-1',
|
||
city: 'marmaris',
|
||
nameTr: 'İskele Balık Ocakbaşı',
|
||
nameEn: 'Iskele Fish & Grill',
|
||
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.',
|
||
descriptionEn: 'Your local taste stop at the Marina with fresh Aegean fish and traditional appetizers. Dinner accompanied by excellent bay views.',
|
||
descriptionRu: 'Ваша местная гастрономическая остановка в Марине со свежей эгейской рыбой и традиционными закусками. Ужин в сопровождении великолепного вида на залив.',
|
||
address: 'Yat Limanı No:12, Marmaris',
|
||
phone: '+90 252 412 34 56',
|
||
whatsapp: '+90 532 123 45 67',
|
||
website: 'https://iskelemarmaris.com',
|
||
instagram: 'iskele_marmaris',
|
||
priceRange: 3,
|
||
rating: 4.8,
|
||
isLocalApproved: true,
|
||
latitude: 36.8524,
|
||
longitude: 28.2741,
|
||
openingHours: { all: '12:00 - 00:00' },
|
||
images: [
|
||
{ id: 'img-1-1', listingId: 'list-1', url: 'https://images.unsplash.com/photo-1519708227418-c8fd9a32b7a2?w=800&auto=format&fit=crop&q=80' },
|
||
{ id: 'img-1-2', listingId: 'list-1', url: 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&auto=format&fit=crop&q=80' }
|
||
],
|
||
createdAt: new Date(Date.now() - 3600000 * 24 * 5),
|
||
updatedAt: new Date(),
|
||
menuUrl: 'https://iskelemarmaris.com/menu',
|
||
isFeatured: true,
|
||
hasWidgetInstalled: true,
|
||
widgetInstalledAt: new Date(Date.now() - 3600000 * 24 * 10),
|
||
widgetSiteUrl: 'https://iskelemarmaris.com'
|
||
},
|
||
{
|
||
id: 'list-2',
|
||
slug: 'mavi-beyaz-restoran',
|
||
categoryId: 'cat-1',
|
||
neighborhoodId: 'neigh-1',
|
||
city: 'marmaris',
|
||
nameTr: 'Mavi Beyaz Restoran',
|
||
nameEn: 'Blue White Restaurant',
|
||
nameRu: 'Ресторан Сине-Белый',
|
||
descriptionTr: 'Ege kıyılarının esintisini taşıyan, deniz ürünleri ağırlıklı menüsü ve gün batımı eşliğindeki eşsiz mezeleriyle ünlüdür.',
|
||
descriptionEn: 'Famous for its seafood-oriented menu carrying the breeze of the Aegean coasts and unique appetizers accompanied by sunset.',
|
||
descriptionRu: 'Славится своим меню из морепродуктов, несущим бриз Эгейского побережья, и уникальными закусками на фоне заката.',
|
||
address: 'Kordon Caddesi No:45, Yat Limanı, Marmaris',
|
||
phone: '+90 252 412 78 90',
|
||
whatsapp: '+90 533 987 65 43',
|
||
website: 'https://mavibeyazmarmaris.com',
|
||
instagram: 'mavibeyaz_marmaris',
|
||
priceRange: 2,
|
||
rating: 4.5,
|
||
isLocalApproved: true,
|
||
latitude: 36.8530,
|
||
longitude: 28.2725,
|
||
openingHours: { all: '11:00 - 23:30' },
|
||
images: [
|
||
{ id: 'img-2-1', listingId: 'list-2', url: 'https://images.unsplash.com/photo-1414235077428-338989a2e8c0?w=800&auto=format&fit=crop&q=80' }
|
||
],
|
||
createdAt: new Date(Date.now() - 3600000 * 24 * 4),
|
||
updatedAt: new Date(),
|
||
menuUrl: 'https://mavibeyazmarmaris.com/digital-menu',
|
||
isFeatured: false,
|
||
hasWidgetInstalled: false,
|
||
widgetInstalledAt: null,
|
||
widgetSiteUrl: null
|
||
},
|
||
{
|
||
id: 'list-3',
|
||
slug: 'dostlar-kebap',
|
||
categoryId: 'cat-1',
|
||
neighborhoodId: 'neigh-3',
|
||
city: 'marmaris',
|
||
nameTr: 'Dostlar Kebap',
|
||
nameEn: 'Dostlar Kebab House',
|
||
nameRu: 'Кебаб Хаус Достлар',
|
||
descriptionTr: 'Marmaris Armutalan\'da yıllardır değişmeyen lezzetiyle gerçek ocakbaşı ve kebap deneyimi sunan samimi bir mahalle restoranı.',
|
||
descriptionEn: 'A cozy neighborhood restaurant offering real grill and kebab experience with its unchanged taste for years in Armutalan, Marmaris.',
|
||
descriptionRu: 'Уютный районный ресторан, предлагающий настоящий гриль и кебаб с неизменным вкусом на протяжении многих лет в Армуталане, Мармарис.',
|
||
address: 'Vatan Caddesi No:18, Armutalan, Marmaris',
|
||
phone: '+90 252 413 11 22',
|
||
whatsapp: null,
|
||
website: null,
|
||
instagram: 'dostlarkebap_marmaris',
|
||
priceRange: 1,
|
||
rating: 4.7,
|
||
isLocalApproved: false,
|
||
latitude: 36.8488,
|
||
longitude: 28.2450,
|
||
openingHours: { all: '11:00 - 22:00' },
|
||
images: [
|
||
{ id: 'img-3-1', listingId: 'list-3', url: 'https://images.unsplash.com/photo-1544025162-d76694265947?w=800&auto=format&fit=crop&q=80' }
|
||
],
|
||
createdAt: new Date(Date.now() - 3600000 * 24 * 3),
|
||
updatedAt: new Date(),
|
||
isFeatured: false,
|
||
hasWidgetInstalled: false,
|
||
widgetInstalledAt: null,
|
||
widgetSiteUrl: null
|
||
},
|
||
{
|
||
id: 'list-4',
|
||
slug: 'zeytin-bahcesi-apart',
|
||
categoryId: 'cat-2',
|
||
neighborhoodId: 'neigh-3',
|
||
city: 'marmaris',
|
||
nameTr: 'Zeytin Bahçesi Apart',
|
||
nameEn: 'Olive Garden Apart',
|
||
nameRu: 'Апарт Оливковый Сад',
|
||
descriptionTr: 'Zeytin ağaçları arasında, sakin ve huzurlu bir ortamda ailece tatil yapmak isteyenler için tasarlanmış geniş mutfaklı apart daireler.',
|
||
descriptionEn: 'Spacious apart apartments with kitchens designed for families who want to have a holiday in a quiet and peaceful environment among olive trees.',
|
||
descriptionRu: 'Просторные апартаменты с кухнями, предназначенные для семей, желающих отдохнуть в тихой и спокойной обстановке среди оливковых деревьев.',
|
||
address: 'Zeytinlik Sokak No:5, Armutalan, Marmaris',
|
||
phone: '+90 252 413 55 66',
|
||
whatsapp: '+90 535 555 66 77',
|
||
website: 'https://zeytinbahcesiapart.com',
|
||
instagram: null,
|
||
priceRange: 2,
|
||
rating: 4.6,
|
||
isLocalApproved: true,
|
||
latitude: 36.8510,
|
||
longitude: 28.2415,
|
||
openingHours: null,
|
||
images: [
|
||
{ id: 'img-4-1', listingId: 'list-4', url: 'https://images.unsplash.com/photo-1566073771259-6a8506099945?w=800&auto=format&fit=crop&q=80' },
|
||
{ id: 'img-4-2', listingId: 'list-4', url: 'https://images.unsplash.com/photo-1520250497591-112f2f40a3f4?w=800&auto=format&fit=crop&q=80' }
|
||
],
|
||
createdAt: new Date(Date.now() - 3600000 * 24 * 2),
|
||
updatedAt: new Date(),
|
||
isFeatured: true,
|
||
hasWidgetInstalled: false,
|
||
widgetInstalledAt: null,
|
||
widgetSiteUrl: null
|
||
},
|
||
{
|
||
id: 'list-5',
|
||
slug: 'deniz-apart',
|
||
categoryId: 'cat-2',
|
||
neighborhoodId: 'neigh-4',
|
||
city: 'marmaris',
|
||
nameTr: 'Deniz Apart',
|
||
nameEn: 'Sea Apart Hotel',
|
||
nameRu: 'Апарт-отель Дениз',
|
||
descriptionTr: 'Denize sadece 100 metre mesafede, bütçe dostu fiyatları ve güler yüzlü yerel işletme sahibiyle Marmaris\'te sıcak konaklama.',
|
||
descriptionEn: 'Cozy accommodation in Marmaris, just 100 meters from the sea, with budget-friendly prices and a friendly local owner.',
|
||
descriptionRu: 'Уютное жилье в Мармарисе, всего в 100 метрах от моря, с доступными ценами и дружелюбным местным владельцем.',
|
||
address: 'Sahil Yolu Caddesi No:88, Siteler, Marmaris',
|
||
phone: '+90 252 417 88 99',
|
||
whatsapp: null,
|
||
website: null,
|
||
instagram: null,
|
||
priceRange: 1,
|
||
rating: 4.2,
|
||
isLocalApproved: false,
|
||
latitude: 36.8375,
|
||
longitude: 28.2580,
|
||
openingHours: null,
|
||
images: [
|
||
{ id: 'img-5-1', listingId: 'list-5', url: 'https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=800&auto=format&fit=crop&q=80' }
|
||
],
|
||
createdAt: new Date(Date.now() - 3600000 * 24 * 1),
|
||
updatedAt: new Date(),
|
||
isFeatured: false,
|
||
hasWidgetInstalled: false,
|
||
widgetInstalledAt: null,
|
||
widgetSiteUrl: null
|
||
},
|
||
{
|
||
id: 'list-6',
|
||
slug: 'marina-diving-center',
|
||
categoryId: 'cat-3',
|
||
neighborhoodId: 'neigh-1',
|
||
city: 'marmaris',
|
||
nameTr: 'Marina Dalış Merkezi',
|
||
nameEn: 'Marina Diving Center',
|
||
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.',
|
||
descriptionEn: 'Diving training and daily diving tours in the crystal clear waters of Marmaris with professional instructors. CMAS and PADI certified courses.',
|
||
descriptionRu: 'Обучение дайвингу и ежедневные дайв-туры в кристально чистых водах Мармариса с профессиональными инструкторами. Курсы с сертификатом CMAS и ПАДИ.',
|
||
address: 'Yat Limanı Belediye İskelesi, Marmaris',
|
||
phone: '+90 532 234 56 78',
|
||
whatsapp: '+90 532 234 56 78',
|
||
website: 'https://marinadivingmarmaris.com',
|
||
instagram: null,
|
||
priceRange: 2,
|
||
rating: 4.9,
|
||
isLocalApproved: true,
|
||
latitude: 36.8521,
|
||
longitude: 28.2748,
|
||
openingHours: { all: '09:00 - 19:00' },
|
||
images: [
|
||
{ id: 'img-6-1', listingId: 'list-6', url: 'https://images.unsplash.com/photo-1544551763-46a013bb70d5?w=800&auto=format&fit=crop&q=80' }
|
||
],
|
||
createdAt: new Date(Date.now() - 3600000 * 12),
|
||
updatedAt: new Date(),
|
||
isFeatured: false,
|
||
hasWidgetInstalled: false,
|
||
widgetInstalledAt: null,
|
||
widgetSiteUrl: null
|
||
},
|
||
{
|
||
id: 'list-7',
|
||
slug: 'aegean-wind-yacht-charter',
|
||
categoryId: 'cat-3',
|
||
neighborhoodId: 'neigh-1',
|
||
city: 'marmaris',
|
||
nameTr: 'Ege Rüzgarı Yat Kiralama',
|
||
nameEn: 'Aegean Wind Yacht Charter',
|
||
nameRu: 'Аренда Яхт Эгейский Ветер',
|
||
descriptionTr: 'Kaptanlı veya kaptansız olarak günlük ve haftalık özel tekne kiralama. Marmaris koylarını kendi rotanızla özgürce keşfedin.',
|
||
descriptionEn: 'Daily and weekly private boat charter with or without skipper. Discover the bays of Marmaris freely with your own route.',
|
||
descriptionRu: 'Ежедневная и еженедельная аренда частных лодок со шкипером или без. Откройте для себя бухты Мармариса свободно по собственному маршруту.',
|
||
address: 'Yat Limanı G İskelesi, Marmaris',
|
||
phone: '+90 532 999 88 77',
|
||
whatsapp: '+90 532 999 88 77',
|
||
website: 'https://egeruzgariboat.com',
|
||
instagram: 'egeruzgariboat',
|
||
priceRange: 3,
|
||
rating: 4.8,
|
||
isLocalApproved: true,
|
||
latitude: 36.8528,
|
||
longitude: 28.2755,
|
||
openingHours: { all: '08:00 - 21:00' },
|
||
images: [
|
||
{ id: 'img-7-1', listingId: 'list-7', url: 'https://images.unsplash.com/photo-1567899378494-47b22a2ae96a?w=800&auto=format&fit=crop&q=80' }
|
||
],
|
||
createdAt: new Date(Date.now() - 3600000 * 6),
|
||
updatedAt: new Date(),
|
||
isFeatured: false,
|
||
hasWidgetInstalled: false,
|
||
widgetInstalledAt: null,
|
||
widgetSiteUrl: null
|
||
},
|
||
{
|
||
id: 'list-8',
|
||
slug: 'marmaris-transfer-tours',
|
||
categoryId: 'cat-3',
|
||
neighborhoodId: 'neigh-3',
|
||
city: 'marmaris',
|
||
nameTr: 'Marmaris VIP Transfer & Tur',
|
||
nameEn: 'Marmaris VIP Transfer & Tours',
|
||
nameRu: 'Marmaris VIP Трансфер и Туры',
|
||
descriptionTr: 'Dalaman Havalimanı transferleri ve Marmaris çevresindeki tarihi/doğal alanlara özel konforlu turlar. Güvenilir ve lüks taşımacılık.',
|
||
descriptionEn: 'Dalaman Airport transfers and comfortable private tours to historical/natural areas around Marmaris. Reliable and luxurious transportation.',
|
||
descriptionRu: 'Трансфер из аэропорта Даламан и комфортабельные частные туры по историческим и природным местам вокруг Мармариса. Надежный и роскошный транспорт.',
|
||
address: 'Atatürk Caddesi No:102, Armutalan, Marmaris',
|
||
phone: '+90 252 413 77 88',
|
||
whatsapp: '+90 541 333 44 55',
|
||
website: 'https://marmarisviptransfer.com',
|
||
instagram: null,
|
||
priceRange: 2,
|
||
rating: 4.6,
|
||
isLocalApproved: false,
|
||
latitude: 36.8495,
|
||
longitude: 28.2430,
|
||
openingHours: { all: '24 Hours Open' },
|
||
images: [
|
||
{ id: 'img-8-1', listingId: 'list-8', url: 'https://images.unsplash.com/photo-1549317661-bd32c8ce0db2?w=800&auto=format&fit=crop&q=80' }
|
||
],
|
||
createdAt: new Date(Date.now() - 3600000 * 2),
|
||
updatedAt: new Date(),
|
||
isFeatured: false,
|
||
hasWidgetInstalled: false,
|
||
widgetInstalledAt: null,
|
||
widgetSiteUrl: null
|
||
}
|
||
]
|
||
|
||
// Seed Blog Posts
|
||
globalForMockDb.blogPosts = [
|
||
{
|
||
id: 'post-1',
|
||
slug: 'marmariste-nerede-yenir-2026',
|
||
titleTr: 'Marmaris\'te Nerede Yenir? 2026 Lezzet Durakları',
|
||
titleEn: 'Where to Eat in Marmaris? 2026 Culinary Hotspots',
|
||
titleRu: 'Где поесть в Мармарисе? Лучшие места 2026 года',
|
||
contentTr: 'Marmaris, Ege ve Akdeniz mutfağının en taze deniz ürünlerini ve mezelerini bulabileceğiniz harika bir sahil kenti. İşte 2026 yılında ziyaret etmeniz gereken en lezzetli mekanlar...\n\n### 1. İskele Balık Ocakbaşı\nYat Limanında yer alan bu harika mekan, taze balıkları ve mezeleriyle ünlüdür.\n\n### 2. Mavi Beyaz Restoran\nSöğüt köyündeki eşsiz manzarası ve gurme Ege yemekleri ile unutulmaz bir akşam sunuyor.',
|
||
contentEn: 'Marmaris is a wonderful coastal town where you can find the freshest seafood and appetizers of Aegean and Mediterranean cuisine. Here are the most delicious places you should visit in 2026...\n\n### 1. Iskele Fish & Grill\nLocated in Yat Limani, this wonderful venue is famous for its fresh fish and appetizers.\n\n### 2. Mavi Beyaz Restaurant\nOffers an unforgettable evening with its unique view and gourmet Aegean dishes in Sogut village.',
|
||
contentRu: 'Мармарис — прекрасный прибрежный город, где вы найдете самые свежие морепродукты и закуски эгейской и средиземноморской кухни. Вот самые вкусные места, которые стоит посетить в 2026 году...\n\n### 1. Искеле Балык Оджакбаши\nЭто замечательное заведение, расположенное в Ят Лимани, славится свежей рыбой и закусками.\n\n### 2. Ресторан Мави Беяз\nПредлагает незабываемый вечер с уникальным видом и изысканными блюдами эгейской кухни в деревне Сегют.',
|
||
coverImage: 'https://images.unsplash.com/photo-1504674900247-0877df9cc836?w=1200&auto=format&fit=crop&q=80',
|
||
tags: ['Restoran', 'Yemek', 'Rehber'],
|
||
relatedListingIds: ['list-1', 'list-2'],
|
||
publishedAt: new Date(),
|
||
createdAt: new Date(),
|
||
updatedAt: new Date()
|
||
},
|
||
{
|
||
id: 'post-2',
|
||
slug: 'gun-batimi-icin-5-mekan',
|
||
titleTr: 'Marmaris\'te Gün Batımını İzleyebileceğiniz En İyi 5 Yer',
|
||
titleEn: 'Top 5 Places to Watch the Sunset in Marmaris',
|
||
titleRu: '5 лучших мест для наблюдения за закатом в Мармарисе',
|
||
contentTr: 'Marmaris\'in en büyüleyici anlarından biri hiç şüphesiz gün batımıdır. Gökyüzünün kızıla büründüğü bu saatlerde unutulmaz kareler yakalayabileceğiniz ve keyifle içeceğinizi yudumlayabileceğiniz en güzel yerleri derledik.\n\n* **Yat Limanı:** Şehir merkezinde en popüler seyir yeri.\n* **İçmeler Sahili:** Adaların arkasından batan güneşi izlemek şahanedir.\n* **Turunç Tepesi:** Kuşbakışı körfez manzarası sunar.',
|
||
contentEn: 'One of the most fascinating moments of Marmaris is undoubtedly the sunset. We have compiled the most beautiful places where you can capture unforgettable frames and enjoy your drink while the sky turns red.\n\n* **Marina:** The most popular viewing point in the city center.\n* **Icmeler Beach:** It is wonderful to watch the sun setting behind the islands.\n* **Turunc Hill:** Offers a panoramic view of the bay.',
|
||
contentRu: 'Один из самых захватывающих моментов в Мармарисе — это, без сомнения, закат. Мы собрали самые красивые места, где вы сможете сделать незабываемые снимки и насладиться напитком, пока небо окрашивается в красный цвет.\n\n* **Марина:** Самая популярная точка обзора в центре города.\n* **Пляж Ичмелер:** Прекрасно наблюдать за закатом солнца за островами.\n* **Холм Турунч:** Панорамный вид на залив.',
|
||
coverImage: 'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?w=1200&auto=format&fit=crop&q=80',
|
||
tags: ['Manzara', 'Gezi', 'Gün Batımı'],
|
||
relatedListingIds: ['list-2', 'list-7'],
|
||
publishedAt: new Date(),
|
||
createdAt: new Date(),
|
||
updatedAt: new Date()
|
||
}
|
||
]
|
||
|
||
// Seed Collections
|
||
globalForMockDb.collections = [
|
||
{
|
||
id: 'col-1',
|
||
slug: 'aile-dostu-restoranlar',
|
||
titleTr: 'Aile Dostu Restoranlar',
|
||
titleEn: 'Family Friendly Restaurants',
|
||
titleRu: 'Семейные рестораны',
|
||
descriptionTr: 'Çocuklarınızla birlikte rahatça yemek yiyebileceğiniz, oyun alanları ve özel çocuk menüleri bulunan en iyi Marmaris mekanları.',
|
||
descriptionEn: 'The best Marmaris restaurants with playgrounds and special kids\' menus where you can comfortably dine with your children.',
|
||
descriptionRu: 'Лучшие рестораны Мармариса с детскими площадками и специальным детским меню, где вы сможете комфортно пообедать с детьми.',
|
||
coverImage: 'https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=1200&auto=format&fit=crop&q=80',
|
||
listingIds: ['list-1', 'list-3'],
|
||
createdAt: new Date(),
|
||
updatedAt: new Date()
|
||
},
|
||
{
|
||
id: 'col-2',
|
||
slug: 'butce-dostu-mekanlar',
|
||
titleTr: 'Bütçe Dostu Mekanlar',
|
||
titleEn: 'Budget-Friendly Venues',
|
||
titleRu: 'Бюджетные заведения',
|
||
descriptionTr: 'Marmaris tatilinizde cebinizi yormayacak, hem kaliteli hizmet sunan hem de uygun fiyatlı lokasyonlar.',
|
||
descriptionEn: 'Budget-friendly locations in Marmaris that offer high-quality service and reasonable prices for your holiday.',
|
||
descriptionRu: 'Доступные заведения в Мармарисе, которые предлагают высококачественный сервис и умеренные цены во время вашего отдыха.',
|
||
coverImage: 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=1200&auto=format&fit=crop&q=80',
|
||
listingIds: ['list-3', 'list-5'],
|
||
createdAt: new Date(),
|
||
updatedAt: new Date()
|
||
}
|
||
]
|
||
|
||
// Seed Instagram Caches
|
||
globalForMockDb.instagramFeedCaches = [
|
||
{
|
||
id: 'insta-1',
|
||
listingId: 'list-1',
|
||
handle: 'iskele_marmaris',
|
||
posts: [
|
||
{ imageUrl: 'https://images.unsplash.com/photo-1544025162-d76694265947?w=500&auto=format&fit=crop&q=80', caption: 'Mezelerimiz taze taze hazırlandı! 🐟', permalink: '#', postedAt: '2026-07-10T12:00:00Z' },
|
||
{ imageUrl: 'https://images.unsplash.com/photo-1519708227418-c8fd9a32b7a2?w=500&auto=format&fit=crop&q=80', caption: 'Bu akşam iskelede gün batımı bir başka güzel... 🌅', permalink: '#', postedAt: '2026-07-09T18:30:00Z' },
|
||
{ imageUrl: 'https://images.unsplash.com/photo-1476224203421-9ac39bcb3327?w=500&auto=format&fit=crop&q=80', caption: 'Balık keyfini kaçırmayın! 🍽️', permalink: '#', postedAt: '2026-07-08T14:15:00Z' }
|
||
],
|
||
fetchedAt: new Date()
|
||
},
|
||
{
|
||
id: 'insta-2',
|
||
listingId: 'list-2',
|
||
handle: 'mavibeyaz_marmaris',
|
||
posts: [
|
||
{ imageUrl: 'https://images.unsplash.com/photo-1414235077428-338989a2e8c0?w=500&auto=format&fit=crop&q=80', caption: 'Kordon keyfi... 🍷', permalink: '#', postedAt: '2026-07-11T16:00:00Z' },
|
||
{ imageUrl: 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=500&auto=format&fit=crop&q=80', caption: 'Taze deniz ürünleri ve meze çeşitleri Mavi Beyaz\'da!', permalink: '#', postedAt: '2026-07-08T15:30:00Z' }
|
||
],
|
||
fetchedAt: new Date()
|
||
}
|
||
]
|
||
|
||
// 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.widgetPartners = []
|
||
|
||
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
|
||
}
|
||
|
||
export const mockDb = {
|
||
// Config helpers
|
||
isMock: () => process.env.USE_MOCK === 'true',
|
||
|
||
// 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() {
|
||
if (this.isMock()) {
|
||
return globalForMockDb.categories
|
||
}
|
||
return db.category.findMany({ orderBy: { id: 'asc' } })
|
||
},
|
||
|
||
async getCategoryBySlug(slug: string) {
|
||
if (this.isMock()) {
|
||
return globalForMockDb.categories.find(c => c.slug === slug) || null
|
||
}
|
||
return db.category.findUnique({ where: { slug } })
|
||
},
|
||
|
||
async getCategoryById(id: string) {
|
||
if (this.isMock()) {
|
||
return globalForMockDb.categories.find(c => c.id === id) || null
|
||
}
|
||
return db.category.findUnique({ where: { id } })
|
||
},
|
||
|
||
// Neighborhoods
|
||
async getNeighborhoods() {
|
||
if (this.isMock()) {
|
||
return globalForMockDb.neighborhoods
|
||
}
|
||
return db.neighborhood.findMany({ orderBy: { id: 'asc' } })
|
||
},
|
||
|
||
async getNeighborhoodBySlug(slug: string) {
|
||
if (this.isMock()) {
|
||
return globalForMockDb.neighborhoods.find(n => n.slug === slug) || null
|
||
}
|
||
return db.neighborhood.findUnique({ where: { slug } })
|
||
},
|
||
|
||
async getNeighborhoodById(id: string) {
|
||
if (this.isMock()) {
|
||
return globalForMockDb.neighborhoods.find(n => n.id === id) || null
|
||
}
|
||
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
|
||
async getListings(filters?: {
|
||
categoryId?: string
|
||
neighborhoodId?: string
|
||
priceRange?: number
|
||
isLocalApproved?: boolean
|
||
search?: string
|
||
isFeatured?: boolean
|
||
}) {
|
||
if (this.isMock()) {
|
||
let result = [...globalForMockDb.listings].filter(l => !l.deletedAt)
|
||
if (filters) {
|
||
if (filters.categoryId) result = result.filter(l => l.categoryId === filters.categoryId)
|
||
if (filters.neighborhoodId) result = result.filter(l => l.neighborhoodId === filters.neighborhoodId)
|
||
if (filters.priceRange) result = result.filter(l => l.priceRange === filters.priceRange)
|
||
if (filters.isLocalApproved !== undefined) result = result.filter(l => l.isLocalApproved === filters.isLocalApproved)
|
||
if (filters.isFeatured !== undefined) result = result.filter(l => l.isFeatured === filters.isFeatured)
|
||
if (filters.search) {
|
||
const s = filters.search.toLowerCase()
|
||
result = result.filter(
|
||
l =>
|
||
l.nameTr.toLowerCase().includes(s) ||
|
||
l.nameEn.toLowerCase().includes(s) ||
|
||
l.nameRu.toLowerCase().includes(s) ||
|
||
l.address.toLowerCase().includes(s)
|
||
)
|
||
}
|
||
}
|
||
|
||
// Sort: Featured items first, then newer listings first
|
||
result.sort((a, b) => {
|
||
if (a.isFeatured && !b.isFeatured) return -1
|
||
if (!a.isFeatured && b.isFeatured) return 1
|
||
return b.createdAt.getTime() - a.createdAt.getTime()
|
||
})
|
||
|
||
return result.map(l => ({
|
||
...l,
|
||
category: globalForMockDb.categories.find(c => c.id === l.categoryId),
|
||
neighborhood: globalForMockDb.neighborhoods.find(n => n.id === l.neighborhoodId)
|
||
}))
|
||
}
|
||
|
||
const where: any = { deletedAt: null }
|
||
if (filters) {
|
||
if (filters.categoryId) where.categoryId = filters.categoryId
|
||
if (filters.neighborhoodId) where.neighborhoodId = filters.neighborhoodId
|
||
if (filters.priceRange) where.priceRange = filters.priceRange
|
||
if (filters.isLocalApproved !== undefined) where.isLocalApproved = filters.isLocalApproved
|
||
if (filters.isFeatured !== undefined) where.isFeatured = filters.isFeatured
|
||
if (filters.search) {
|
||
where.OR = [
|
||
{ nameTr: { contains: filters.search, mode: 'insensitive' } },
|
||
{ nameEn: { contains: filters.search, mode: 'insensitive' } },
|
||
{ nameRu: { contains: filters.search, mode: 'insensitive' } },
|
||
{ address: { contains: filters.search, mode: 'insensitive' } },
|
||
]
|
||
}
|
||
}
|
||
|
||
return db.listing.findMany({
|
||
where,
|
||
include: { category: true, neighborhood: true, images: true },
|
||
orderBy: [
|
||
{ isFeatured: 'desc' },
|
||
{ createdAt: 'desc' }
|
||
]
|
||
})
|
||
},
|
||
|
||
async getListingBySlug(slug: string) {
|
||
if (this.isMock()) {
|
||
const listing = globalForMockDb.listings.find(l => l.slug === slug && !l.deletedAt)
|
||
if (!listing) return null
|
||
return {
|
||
...listing,
|
||
category: globalForMockDb.categories.find(c => c.id === listing.categoryId),
|
||
neighborhood: globalForMockDb.neighborhoods.find(n => n.id === listing.neighborhoodId)
|
||
}
|
||
}
|
||
return db.listing.findUnique({
|
||
where: { slug },
|
||
include: { category: true, neighborhood: true, images: true }
|
||
})
|
||
},
|
||
|
||
async getListingById(id: string) {
|
||
if (this.isMock()) {
|
||
const listing = globalForMockDb.listings.find(l => l.id === id && !l.deletedAt)
|
||
if (!listing) return null
|
||
return {
|
||
...listing,
|
||
category: globalForMockDb.categories.find(c => c.id === listing.categoryId),
|
||
neighborhood: globalForMockDb.neighborhoods.find(n => n.id === listing.neighborhoodId)
|
||
}
|
||
}
|
||
return db.listing.findUnique({
|
||
where: { id },
|
||
include: { category: true, neighborhood: true, images: true }
|
||
})
|
||
},
|
||
|
||
async createListing(data: Omit<Listing, 'id' | 'images' | 'createdAt' | 'updatedAt'> & { images?: string[] }) {
|
||
const { images, category, neighborhood, ...rest } = data
|
||
if (this.isMock()) {
|
||
const newId = `list-${Date.now()}`
|
||
const gallery = (images || []).map((url, i) => ({ id: `img-${newId}-${i}`, listingId: newId, url }))
|
||
const newListing: Listing = {
|
||
id: newId,
|
||
...rest,
|
||
images: gallery,
|
||
createdAt: new Date(),
|
||
updatedAt: new Date(),
|
||
isFeatured: data.isFeatured || false
|
||
}
|
||
globalForMockDb.listings.push(newListing)
|
||
return newListing
|
||
}
|
||
return db.listing.create({
|
||
data: {
|
||
...rest,
|
||
images: {
|
||
create: (images || []).map(url => ({ url }))
|
||
}
|
||
}
|
||
})
|
||
},
|
||
|
||
async updateListing(id: string, data: Partial<Omit<Listing, 'id' | 'images' | 'createdAt' | 'updatedAt'>> & { images?: string[] }) {
|
||
const { images, category, neighborhood, ...rest } = data
|
||
if (this.isMock()) {
|
||
const idx = globalForMockDb.listings.findIndex(l => l.id === id)
|
||
if (idx !== -1) {
|
||
const gallery = images ? images.map((url, i) => ({ id: `img-${id}-${i}`, listingId: id, url })) : globalForMockDb.listings[idx].images
|
||
globalForMockDb.listings[idx] = {
|
||
...globalForMockDb.listings[idx],
|
||
...rest,
|
||
images: gallery,
|
||
updatedAt: new Date()
|
||
} as Listing
|
||
return globalForMockDb.listings[idx]
|
||
}
|
||
return null
|
||
}
|
||
|
||
// Update listing images transaction
|
||
if (images) {
|
||
await db.gallery.deleteMany({ where: { listingId: id } })
|
||
return db.listing.update({
|
||
where: { id },
|
||
data: {
|
||
...rest,
|
||
images: {
|
||
create: images.map(url => ({ url }))
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
return db.listing.update({
|
||
where: { id },
|
||
data: rest
|
||
})
|
||
},
|
||
|
||
async deleteListing(id: string) {
|
||
if (this.isMock()) {
|
||
const idx = globalForMockDb.listings.findIndex(l => l.id === id)
|
||
if (idx !== -1) {
|
||
globalForMockDb.listings[idx].deletedAt = new Date()
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
await db.listing.update({
|
||
where: { id },
|
||
data: { deletedAt: new Date() }
|
||
})
|
||
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
|
||
async getSubmissions() {
|
||
if (this.isMock()) {
|
||
return globalForMockDb.submissions.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||
}
|
||
return db.businessSubmission.findMany({ orderBy: { createdAt: 'desc' } })
|
||
},
|
||
|
||
async deleteSubmission(id: string) {
|
||
if (this.isMock()) {
|
||
const idx = globalForMockDb.submissions.findIndex(s => s.id === id)
|
||
if (idx !== -1) {
|
||
globalForMockDb.submissions.splice(idx, 1)
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
await db.businessSubmission.delete({ where: { id } })
|
||
},
|
||
|
||
async createSubmission(data: Omit<BusinessSubmission, 'id' | 'status' | 'createdAt' | 'updatedAt'>) {
|
||
if (this.isMock()) {
|
||
const newSub: BusinessSubmission = {
|
||
id: `sub-${Date.now()}`,
|
||
...data,
|
||
status: 'PENDING',
|
||
createdAt: new Date(),
|
||
updatedAt: new Date()
|
||
}
|
||
globalForMockDb.submissions.push(newSub)
|
||
return newSub
|
||
}
|
||
return db.businessSubmission.create({ data: { ...data, status: 'PENDING' } })
|
||
},
|
||
|
||
async updateSubmissionStatus(id: string, status: 'APPROVED' | 'REJECTED') {
|
||
if (this.isMock()) {
|
||
const idx = globalForMockDb.submissions.findIndex(s => s.id === id)
|
||
if (idx !== -1) {
|
||
globalForMockDb.submissions[idx].status = status
|
||
globalForMockDb.submissions[idx].updatedAt = new Date()
|
||
return globalForMockDb.submissions[idx]
|
||
}
|
||
return null
|
||
}
|
||
return db.businessSubmission.update({
|
||
where: { id },
|
||
data: { status }
|
||
})
|
||
},
|
||
|
||
async getSubmissionById(id: string) {
|
||
if (this.isMock()) {
|
||
return globalForMockDb.submissions.find(s => s.id === id) || null
|
||
}
|
||
return db.businessSubmission.findUnique({ where: { id } })
|
||
},
|
||
|
||
// Contact Messages
|
||
async getMessages() {
|
||
if (this.isMock()) {
|
||
return globalForMockDb.messages.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||
}
|
||
return db.contactMessage.findMany({ orderBy: { createdAt: 'desc' } })
|
||
},
|
||
|
||
async deleteMessage(id: string) {
|
||
if (this.isMock()) {
|
||
const idx = globalForMockDb.messages.findIndex(m => m.id === id)
|
||
if (idx !== -1) {
|
||
globalForMockDb.messages.splice(idx, 1)
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
await db.contactMessage.delete({ where: { id } })
|
||
},
|
||
|
||
async createMessage(data: Omit<ContactMessage, 'id' | 'isRead' | 'createdAt' | 'updatedAt'>) {
|
||
if (this.isMock()) {
|
||
const newMsg: ContactMessage = {
|
||
id: `msg-${Date.now()}`,
|
||
...data,
|
||
isRead: false,
|
||
createdAt: new Date(),
|
||
updatedAt: new Date()
|
||
}
|
||
globalForMockDb.messages.push(newMsg)
|
||
return newMsg
|
||
}
|
||
return db.contactMessage.create({ data: { ...data, isRead: false } })
|
||
},
|
||
|
||
async markMessageAsRead(id: string) {
|
||
if (this.isMock()) {
|
||
const idx = globalForMockDb.messages.findIndex(m => m.id === id)
|
||
if (idx !== -1) {
|
||
globalForMockDb.messages[idx].isRead = true
|
||
globalForMockDb.messages[idx].updatedAt = new Date()
|
||
return globalForMockDb.messages[idx]
|
||
}
|
||
return null
|
||
}
|
||
return db.contactMessage.update({
|
||
where: { id },
|
||
data: { isRead: true }
|
||
})
|
||
},
|
||
|
||
// BlogPost CRUD (Phase 2)
|
||
async getBlogPosts(onlyPublished: boolean = false) {
|
||
if (this.isMock()) {
|
||
let posts = globalForMockDb.blogPosts.filter(p => !p.deletedAt)
|
||
if (onlyPublished) {
|
||
posts = posts.filter(p => p.publishedAt !== null)
|
||
}
|
||
return posts.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||
}
|
||
const where: any = { deletedAt: null }
|
||
if (onlyPublished) {
|
||
where.publishedAt = { not: null }
|
||
}
|
||
return db.blogPost.findMany({
|
||
where,
|
||
orderBy: { createdAt: 'desc' }
|
||
})
|
||
},
|
||
|
||
async getBlogPostBySlug(slug: string) {
|
||
if (this.isMock()) {
|
||
return globalForMockDb.blogPosts.find(p => p.slug === slug && !p.deletedAt) || null
|
||
}
|
||
return db.blogPost.findUnique({ where: { slug } })
|
||
},
|
||
|
||
async getBlogPostById(id: string) {
|
||
if (this.isMock()) {
|
||
return globalForMockDb.blogPosts.find(p => p.id === id && !p.deletedAt) || null
|
||
}
|
||
return db.blogPost.findUnique({ where: { id } })
|
||
},
|
||
|
||
async createBlogPost(data: Omit<BlogPost, 'id' | 'createdAt' | 'updatedAt'>) {
|
||
if (this.isMock()) {
|
||
const newPost: BlogPost = {
|
||
id: `post-${Date.now()}`,
|
||
...data,
|
||
createdAt: new Date(),
|
||
updatedAt: new Date()
|
||
}
|
||
globalForMockDb.blogPosts.push(newPost)
|
||
return newPost
|
||
}
|
||
return db.blogPost.create({ data })
|
||
},
|
||
|
||
async updateBlogPost(id: string, data: Partial<Omit<BlogPost, 'id' | 'createdAt' | 'updatedAt'>>) {
|
||
if (this.isMock()) {
|
||
const idx = globalForMockDb.blogPosts.findIndex(p => p.id === id)
|
||
if (idx !== -1) {
|
||
globalForMockDb.blogPosts[idx] = {
|
||
...globalForMockDb.blogPosts[idx],
|
||
...data,
|
||
updatedAt: new Date()
|
||
}
|
||
return globalForMockDb.blogPosts[idx]
|
||
}
|
||
return null
|
||
}
|
||
return db.blogPost.update({ where: { id }, data })
|
||
},
|
||
|
||
async deleteBlogPost(id: string) {
|
||
if (this.isMock()) {
|
||
const idx = globalForMockDb.blogPosts.findIndex(p => p.id === id)
|
||
if (idx !== -1) {
|
||
globalForMockDb.blogPosts[idx].deletedAt = new Date()
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
await db.blogPost.update({ where: { id }, data: { deletedAt: new Date() } })
|
||
return true
|
||
},
|
||
|
||
// Collection CRUD (Phase 2)
|
||
async getCollections() {
|
||
if (this.isMock()) {
|
||
const colls = [...globalForMockDb.collections]
|
||
return colls.map(c => ({
|
||
...c,
|
||
listings: globalForMockDb.listings.filter(l => c.listingIds.includes(l.id) && !l.deletedAt)
|
||
}))
|
||
}
|
||
return db.collection.findMany({
|
||
include: { listings: { include: { images: true, category: true, neighborhood: true } } },
|
||
orderBy: { createdAt: 'desc' }
|
||
})
|
||
},
|
||
|
||
async getCollectionBySlug(slug: string) {
|
||
if (this.isMock()) {
|
||
const c = globalForMockDb.collections.find(col => col.slug === slug)
|
||
if (!c) return null
|
||
return {
|
||
...c,
|
||
listings: globalForMockDb.listings.filter(l => c.listingIds.includes(l.id) && !l.deletedAt).map(l => ({
|
||
...l,
|
||
category: globalForMockDb.categories.find(cat => cat.id === l.categoryId),
|
||
neighborhood: globalForMockDb.neighborhoods.find(n => n.id === l.neighborhoodId)
|
||
}))
|
||
}
|
||
}
|
||
return db.collection.findUnique({
|
||
where: { slug },
|
||
include: { listings: { include: { images: true, category: true, neighborhood: true } } }
|
||
})
|
||
},
|
||
|
||
async getCollectionById(id: string) {
|
||
if (this.isMock()) {
|
||
const c = globalForMockDb.collections.find(col => col.id === id)
|
||
if (!c) return null
|
||
return {
|
||
...c,
|
||
listings: globalForMockDb.listings.filter(l => c.listingIds.includes(l.id) && !l.deletedAt)
|
||
}
|
||
}
|
||
return db.collection.findUnique({
|
||
where: { id },
|
||
include: { listings: true }
|
||
})
|
||
},
|
||
|
||
async createCollection(data: Omit<Collection, 'id' | 'createdAt' | 'updatedAt' | 'listings'>) {
|
||
if (this.isMock()) {
|
||
const newCol: Collection = {
|
||
id: `col-${Date.now()}`,
|
||
...data,
|
||
createdAt: new Date(),
|
||
updatedAt: new Date()
|
||
}
|
||
globalForMockDb.collections.push(newCol)
|
||
return newCol
|
||
}
|
||
const { listingIds, ...rest } = data
|
||
return db.collection.create({
|
||
data: {
|
||
...rest,
|
||
listings: {
|
||
connect: listingIds.map(id => ({ id }))
|
||
}
|
||
}
|
||
})
|
||
},
|
||
|
||
async updateCollection(id: string, data: Partial<Omit<Collection, 'id' | 'createdAt' | 'updatedAt' | 'listings'>>) {
|
||
if (this.isMock()) {
|
||
const idx = globalForMockDb.collections.findIndex(c => c.id === id)
|
||
if (idx !== -1) {
|
||
globalForMockDb.collections[idx] = {
|
||
...globalForMockDb.collections[idx],
|
||
...data,
|
||
updatedAt: new Date()
|
||
} as Collection
|
||
return globalForMockDb.collections[idx]
|
||
}
|
||
return null
|
||
}
|
||
const { listingIds, ...rest } = data
|
||
if (listingIds) {
|
||
// For actual DB, first disconnect all and connect new
|
||
const current = await db.collection.findUnique({ where: { id }, include: { listings: true } })
|
||
const disconnectIds = current?.listings.map(l => ({ id: l.id })) || []
|
||
return db.collection.update({
|
||
where: { id },
|
||
data: {
|
||
...rest,
|
||
listings: {
|
||
disconnect: disconnectIds,
|
||
connect: listingIds.map(lid => ({ id: lid }))
|
||
}
|
||
}
|
||
})
|
||
}
|
||
return db.collection.update({ where: { id }, data: rest as any })
|
||
},
|
||
|
||
async deleteCollection(id: string) {
|
||
if (this.isMock()) {
|
||
const idx = globalForMockDb.collections.findIndex(c => c.id === id)
|
||
if (idx !== -1) {
|
||
globalForMockDb.collections.splice(idx, 1)
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
await db.collection.delete({ where: { id } })
|
||
return true
|
||
},
|
||
|
||
// Instagram Feed Cache (Phase 2)
|
||
async getInstagramFeedCacheByListingId(listingId: string) {
|
||
if (this.isMock()) {
|
||
return globalForMockDb.instagramFeedCaches.find(cache => cache.listingId === listingId) || null
|
||
}
|
||
return db.instagramFeedCache.findUnique({ where: { listingId } })
|
||
},
|
||
|
||
async updateInstagramFeedCache(listingId: string, handle: string, posts: any[]) {
|
||
if (this.isMock()) {
|
||
const idx = globalForMockDb.instagramFeedCaches.findIndex(cache => cache.listingId === listingId)
|
||
if (idx !== -1) {
|
||
globalForMockDb.instagramFeedCaches[idx].handle = handle
|
||
globalForMockDb.instagramFeedCaches[idx].posts = posts
|
||
globalForMockDb.instagramFeedCaches[idx].fetchedAt = new Date()
|
||
return globalForMockDb.instagramFeedCaches[idx]
|
||
} else {
|
||
const newCache = {
|
||
id: `insta-${Date.now()}`,
|
||
listingId,
|
||
handle,
|
||
posts,
|
||
fetchedAt: new Date()
|
||
}
|
||
globalForMockDb.instagramFeedCaches.push(newCache)
|
||
return newCache
|
||
}
|
||
}
|
||
return db.instagramFeedCache.upsert({
|
||
where: { listingId },
|
||
update: { handle, posts, fetchedAt: new Date() },
|
||
create: { listingId, handle, posts, fetchedAt: new Date() }
|
||
})
|
||
},
|
||
|
||
// Phase 3 Widget & Backlink
|
||
async getWidgetPartners() {
|
||
if (this.isMock()) {
|
||
return globalForMockDb.widgetPartners.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||
}
|
||
return db.widgetPartner.findMany({
|
||
orderBy: { createdAt: 'desc' }
|
||
})
|
||
},
|
||
|
||
async getWidgetPartner(id: string) {
|
||
if (this.isMock()) {
|
||
return globalForMockDb.widgetPartners.find(w => w.id === id) || null
|
||
}
|
||
return db.widgetPartner.findUnique({ where: { id } })
|
||
},
|
||
|
||
async createWidgetPartner(data: Omit<WidgetPartner, 'id' | 'createdAt' | 'updatedAt'>) {
|
||
if (this.isMock()) {
|
||
const newPartner: WidgetPartner = {
|
||
id: `wp-${Date.now()}`,
|
||
...data,
|
||
createdAt: new Date(),
|
||
updatedAt: new Date()
|
||
}
|
||
globalForMockDb.widgetPartners.push(newPartner)
|
||
return newPartner
|
||
}
|
||
return db.widgetPartner.create({ data })
|
||
},
|
||
|
||
async updateWidgetPartner(id: string, data: Partial<WidgetPartner>) {
|
||
if (this.isMock()) {
|
||
const idx = globalForMockDb.widgetPartners.findIndex(w => w.id === id)
|
||
if (idx !== -1) {
|
||
globalForMockDb.widgetPartners[idx] = {
|
||
...globalForMockDb.widgetPartners[idx],
|
||
...data,
|
||
updatedAt: new Date()
|
||
}
|
||
return globalForMockDb.widgetPartners[idx]
|
||
}
|
||
return null
|
||
}
|
||
return db.widgetPartner.update({ where: { id }, data })
|
||
},
|
||
|
||
async deleteWidgetPartner(id: string) {
|
||
if (this.isMock()) {
|
||
const idx = globalForMockDb.widgetPartners.findIndex(w => w.id === id)
|
||
if (idx !== -1) {
|
||
globalForMockDb.widgetPartners.splice(idx, 1)
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
await db.widgetPartner.delete({ where: { id } })
|
||
return 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 })
|
||
},
|
||
|
||
async getItineraries() {
|
||
if (this.isMock()) {
|
||
return [...globalForMockDb.generatedItineraries]
|
||
}
|
||
return db.generatedItinerary.findMany()
|
||
}
|
||
}
|