feat: Implement Phase 2 features including blog, collections, saved listings, manifest, and api cron sync

This commit is contained in:
AyrisAI
2026-07-12 20:49:41 +03:00
parent 862544b5a1
commit f696d359b0
29 changed files with 3066 additions and 194 deletions
+478 -103
View File
@@ -51,6 +51,11 @@ export interface Listing {
createdAt: Date
updatedAt: Date
deletedAt?: Date | null
// Phase 2
menuUrl?: string | null
isFeatured: boolean
featuredUntil?: Date | null
}
export interface BusinessSubmission {
@@ -81,12 +86,62 @@ export interface ContactMessage {
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 InstagramFeedCache {
id: string
listingId: string
handle: string
posts: Array<{
imageUrl: string
caption?: string
permalink?: string
postedAt: string
}>
fetchedAt: Date
}
const globalForMockDb = globalThis as unknown as {
categories: Category[]
neighborhoods: Neighborhood[]
listings: Listing[]
submissions: BusinessSubmission[]
messages: ContactMessage[]
blogPosts: BlogPost[]
collections: Collection[]
instagramFeedCaches: InstagramFeedCache[]
initialized: boolean
}
@@ -108,7 +163,7 @@ if (!globalForMockDb.initialized) {
globalForMockDb.submissions = []
globalForMockDb.messages = []
// Pre-populate with realistic Marmaris data
// Seed Listings with Phase 2 fields
globalForMockDb.listings = [
{
id: 'list-1',
@@ -126,7 +181,7 @@ if (!globalForMockDb.initialized) {
phone: '+90 252 412 34 56',
whatsapp: '+90 532 123 45 67',
website: 'https://iskelemarmaris.com',
instagram: 'https://instagram.com/iskele_marmaris',
instagram: 'iskele_marmaris',
priceRange: 3,
rating: 4.8,
isLocalApproved: true,
@@ -137,8 +192,10 @@ if (!globalForMockDb.initialized) {
{ 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(),
updatedAt: new Date()
createdAt: new Date(Date.now() - 3600000 * 24 * 5),
updatedAt: new Date(),
menuUrl: 'https://iskelemarmaris.com/menu',
isFeatured: true
},
{
id: 'list-2',
@@ -156,7 +213,7 @@ if (!globalForMockDb.initialized) {
phone: '+90 252 412 78 90',
whatsapp: '+90 533 987 65 43',
website: 'https://mavibeyazmarmaris.com',
instagram: 'https://instagram.com/mavibeyaz_marmaris',
instagram: 'mavibeyaz_marmaris',
priceRange: 2,
rating: 4.5,
isLocalApproved: true,
@@ -166,8 +223,10 @@ if (!globalForMockDb.initialized) {
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(),
updatedAt: new Date()
createdAt: new Date(Date.now() - 3600000 * 24 * 4),
updatedAt: new Date(),
menuUrl: 'https://mavibeyazmarmaris.com/digital-menu',
isFeatured: false
},
{
id: 'list-3',
@@ -185,7 +244,7 @@ if (!globalForMockDb.initialized) {
phone: '+90 252 413 11 22',
whatsapp: null,
website: null,
instagram: 'https://instagram.com/dostlarkebap_marmaris',
instagram: 'dostlarkebap_marmaris',
priceRange: 1,
rating: 4.7,
isLocalApproved: false,
@@ -195,8 +254,9 @@ if (!globalForMockDb.initialized) {
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(),
updatedAt: new Date()
createdAt: new Date(Date.now() - 3600000 * 24 * 3),
updatedAt: new Date(),
isFeatured: false
},
{
id: 'list-4',
@@ -225,8 +285,9 @@ if (!globalForMockDb.initialized) {
{ 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(),
updatedAt: new Date()
createdAt: new Date(Date.now() - 3600000 * 24 * 2),
updatedAt: new Date(),
isFeatured: true
},
{
id: 'list-5',
@@ -254,55 +315,57 @@ if (!globalForMockDb.initialized) {
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(),
updatedAt: new Date()
createdAt: new Date(Date.now() - 3600000 * 24 * 1),
updatedAt: new Date(),
isFeatured: false
},
{
id: 'list-6',
slug: 'marina-dalis-merkezi',
slug: 'marina-diving-center',
categoryId: 'cat-3',
neighborhoodId: 'neigh-2',
neighborhoodId: 'neigh-1',
city: 'marmaris',
nameTr: 'Marina Dalış Merkezi',
nameEn: 'Marina Diving Center',
nameRu: 'Дайвинг-центр Марина',
descriptionTr: 'Marmaris\'in eşsiz koylarında profesyonel eğitmenler eşliğinde tüplü dalış deneyimi. Başlangıç seviyesinden PADI sertifikasyonuna kadar hizmet.',
descriptionEn: 'Scuba diving experience in unique bays of Marmaris accompanied by professional instructors. Service from discovery dive to PADI certification.',
descriptionRu: 'Опыт подводного плавания в уникальных бухтах Мармариса в сопровождении профессиональных инструкторов. Услуги от ознакомительного погружения до сертификации PADI.',
address: 'Liman Yolu No:32, İçmeler, Marmaris',
phone: '+90 252 455 22 33',
whatsapp: '+90 536 777 88 99',
descriptionTr: 'Marmaris\'in kristal netliğindeki sularında profesyonel eğitmenlerle dalış eğitimleri ve günk 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 и PADI.',
address: 'Yat Limanı Belediye İskelesi, Marmaris',
phone: '+90 532 234 56 78',
whatsapp: '+90 532 234 56 78',
website: 'https://marinadivingmarmaris.com',
instagram: 'https://instagram.com/marinadiving_marmaris',
priceRange: 3,
instagram: null,
priceRange: 2,
rating: 4.9,
isLocalApproved: true,
latitude: 36.8020,
longitude: 28.2320,
openingHours: { all: '08:30 - 19:30' },
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(),
updatedAt: new Date()
createdAt: new Date(Date.now() - 3600000 * 12),
updatedAt: new Date(),
isFeatured: false
},
{
id: 'list-7',
slug: 'ege-ruzgari-tekne-kiralama',
slug: 'aegean-wind-yacht-charter',
categoryId: 'cat-3',
neighborhoodId: 'neigh-1',
city: 'marmaris',
nameTr: 'Ege Rüzgarı Tekne Kiralama',
nameEn: 'Aegean Wind Boat Rental',
nameRu: 'Аренда Лодок Эгейский Ветер',
descriptionTr: 'Kaptanlı veya kaptansız günlük ve haftalık özel tekne kiralama hizmeti. Marmaris koylarını kendi rotanızda özgürce keşfedin.',
descriptionEn: 'Daily and weekly private boat rental service with or without skipper. Discover Marmaris bays freely on your own route.',
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: 'https://instagram.com/egeruzgariboat',
instagram: 'egeruzgariboat',
priceRange: 3,
rating: 4.8,
isLocalApproved: true,
@@ -312,8 +375,9 @@ if (!globalForMockDb.initialized) {
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(),
updatedAt: new Date()
createdAt: new Date(Date.now() - 3600000 * 6),
updatedAt: new Date(),
isFeatured: false
},
{
id: 'list-8',
@@ -341,8 +405,102 @@ if (!globalForMockDb.initialized) {
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
}
]
// 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()
}
]
@@ -358,7 +516,7 @@ export const mockDb = {
if (this.isMock()) {
return globalForMockDb.categories
}
return db.category.findMany({ orderBy: { nameTr: 'asc' } })
return db.category.findMany({ orderBy: { id: 'asc' } })
},
async getCategoryBySlug(slug: string) {
@@ -368,25 +526,11 @@ export const mockDb = {
return db.category.findUnique({ where: { slug } })
},
async createCategory(data: { slug: string; nameTr: string; nameEn: string; nameRu: string }) {
async getCategoryById(id: string) {
if (this.isMock()) {
const newCat = { id: `cat-${Date.now()}`, ...data }
globalForMockDb.categories.push(newCat)
return newCat
return globalForMockDb.categories.find(c => c.id === id) || null
}
return db.category.create({ data })
},
async updateCategory(id: string, data: { slug: string; nameTr: string; nameEn: string; nameRu: string }) {
if (this.isMock()) {
const idx = globalForMockDb.categories.findIndex(c => c.id === id)
if (idx !== -1) {
globalForMockDb.categories[idx] = { ...globalForMockDb.categories[idx], ...data }
return globalForMockDb.categories[idx]
}
return null
}
return db.category.update({ where: { id }, data })
return db.category.findUnique({ where: { id } })
},
// Neighborhoods
@@ -394,7 +538,7 @@ export const mockDb = {
if (this.isMock()) {
return globalForMockDb.neighborhoods
}
return db.neighborhood.findMany({ orderBy: { nameTr: 'asc' } })
return db.neighborhood.findMany({ orderBy: { id: 'asc' } })
},
async getNeighborhoodBySlug(slug: string) {
@@ -404,52 +548,49 @@ export const mockDb = {
return db.neighborhood.findUnique({ where: { slug } })
},
async createNeighborhood(data: { slug: string; nameTr: string; nameEn: string; nameRu: string }) {
async getNeighborhoodById(id: string) {
if (this.isMock()) {
const newNeigh = { id: `neigh-${Date.now()}`, ...data }
globalForMockDb.neighborhoods.push(newNeigh)
return newNeigh
return globalForMockDb.neighborhoods.find(n => n.id === id) || null
}
return db.neighborhood.create({ data })
return db.neighborhood.findUnique({ where: { id } })
},
async updateNeighborhood(id: string, data: { slug: string; nameTr: string; nameEn: string; nameRu: string }) {
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]
}
return null
}
return db.neighborhood.update({ where: { id }, data })
},
// Listings
// 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)
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)
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),
@@ -463,6 +604,7 @@ export const mockDb = {
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' } },
@@ -476,7 +618,10 @@ export const mockDb = {
return db.listing.findMany({
where,
include: { category: true, neighborhood: true, images: true },
orderBy: { createdAt: 'desc' }
orderBy: [
{ isFeatured: 'desc' },
{ createdAt: 'desc' }
]
})
},
@@ -522,18 +667,19 @@ export const mockDb = {
...rest,
images: gallery,
createdAt: new Date(),
updatedAt: new Date()
updatedAt: new Date(),
isFeatured: data.isFeatured || false
}
globalForMockDb.listings.unshift(newListing)
globalForMockDb.listings.push(newListing)
return newListing
}
return db.listing.create({
data: {
...rest,
images: images ? { create: images.map(url => ({ url })) } : undefined
},
include: { images: true }
images: {
create: (images || []).map(url => ({ url }))
}
}
})
},
@@ -542,33 +688,35 @@ export const mockDb = {
if (this.isMock()) {
const idx = globalForMockDb.listings.findIndex(l => l.id === id)
if (idx !== -1) {
const oldListing = globalForMockDb.listings[idx]
const updatedGallery = images
? images.map((url, i) => ({ id: `img-${id}-${i}`, listingId: id, url }))
: oldListing.images
const updated = {
...oldListing,
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: updatedGallery,
images: gallery,
updatedAt: new Date()
} as Listing
globalForMockDb.listings[idx] = updated
return updated
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,
images: images ? { create: images.map(url => ({ url })) } : undefined
},
include: { images: true }
data: rest
})
},
@@ -671,5 +819,232 @@ export const mockDb = {
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() }
})
}
}