feat: implement dynamic categories, admin category CRUD, fix routing and cleanup

This commit is contained in:
AyrisAI
2026-07-13 13:51:48 +03:00
parent 2f2dafcfb9
commit 5b44c78396
54 changed files with 3619 additions and 473 deletions
+466 -12
View File
@@ -56,6 +56,11 @@ export interface Listing {
menuUrl?: string | null
isFeatured: boolean
featuredUntil?: Date | null
// Phase 3
hasWidgetInstalled: boolean
widgetInstalledAt?: Date | null
widgetSiteUrl?: string | null
}
export interface BusinessSubmission {
@@ -133,6 +138,46 @@ export interface InstagramFeedCache {
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[]
@@ -142,10 +187,14 @@ const globalForMockDb = globalThis as unknown as {
blogPosts: BlogPost[]
collections: Collection[]
instagramFeedCaches: InstagramFeedCache[]
listingAnalyticsDaily: ListingAnalyticsDaily[]
events: Event[]
generatedItineraries: GeneratedItinerary[]
initialized: boolean
__version: number
}
if (!globalForMockDb.initialized) {
if (!globalForMockDb.initialized || globalForMockDb.__version !== MOCK_DB_VERSION) {
globalForMockDb.categories = [
{ id: 'cat-1', slug: 'restoran', nameTr: 'Restoran', nameEn: 'Restaurant', nameRu: 'Ресторан' },
{ id: 'cat-2', slug: 'apart', nameTr: 'Apart', nameEn: 'Apart Hotel', nameRu: 'Апарт-отель' },
@@ -163,7 +212,7 @@ if (!globalForMockDb.initialized) {
globalForMockDb.submissions = []
globalForMockDb.messages = []
// Seed Listings with Phase 2 fields
// Seed Listings with Phase 2 & 3 fields
globalForMockDb.listings = [
{
id: 'list-1',
@@ -173,7 +222,7 @@ if (!globalForMockDb.initialized) {
city: 'marmaris',
nameTr: 'İskele Balık Ocakbaşı',
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.',
descriptionEn: 'Your local taste stop at the Marina with fresh Aegean fish and traditional appetizers. Dinner accompanied by excellent bay views.',
descriptionRu: 'Ваша местная гастрономическая остановка в Марине со свежей эгейской рыбой и традиционными закусками. Ужин в сопровождении великолепного вида на залив.',
@@ -195,7 +244,10 @@ if (!globalForMockDb.initialized) {
createdAt: new Date(Date.now() - 3600000 * 24 * 5),
updatedAt: new Date(),
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',
@@ -226,7 +278,10 @@ if (!globalForMockDb.initialized) {
createdAt: new Date(Date.now() - 3600000 * 24 * 4),
updatedAt: new Date(),
menuUrl: 'https://mavibeyazmarmaris.com/digital-menu',
isFeatured: false
isFeatured: false,
hasWidgetInstalled: false,
widgetInstalledAt: null,
widgetSiteUrl: null
},
{
id: 'list-3',
@@ -256,7 +311,10 @@ if (!globalForMockDb.initialized) {
],
createdAt: new Date(Date.now() - 3600000 * 24 * 3),
updatedAt: new Date(),
isFeatured: false
isFeatured: false,
hasWidgetInstalled: false,
widgetInstalledAt: null,
widgetSiteUrl: null
},
{
id: 'list-4',
@@ -287,7 +345,10 @@ if (!globalForMockDb.initialized) {
],
createdAt: new Date(Date.now() - 3600000 * 24 * 2),
updatedAt: new Date(),
isFeatured: true
isFeatured: true,
hasWidgetInstalled: false,
widgetInstalledAt: null,
widgetSiteUrl: null
},
{
id: 'list-5',
@@ -317,7 +378,10 @@ if (!globalForMockDb.initialized) {
],
createdAt: new Date(Date.now() - 3600000 * 24 * 1),
updatedAt: new Date(),
isFeatured: false
isFeatured: false,
hasWidgetInstalled: false,
widgetInstalledAt: null,
widgetSiteUrl: null
},
{
id: 'list-6',
@@ -330,7 +394,7 @@ if (!globalForMockDb.initialized) {
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 и PADI.',
descriptionRu: 'Обучение дайвингу и ежедневные дайв-туры в кристально чистых водах Мармариса с профессиональными инструкторами. Курсы с сертификатом CMAS и ПАДИ.',
address: 'Yat Limanı Belediye İskelesi, Marmaris',
phone: '+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),
updatedAt: new Date(),
isFeatured: false
isFeatured: false,
hasWidgetInstalled: false,
widgetInstalledAt: null,
widgetSiteUrl: null
},
{
id: 'list-7',
@@ -377,7 +444,10 @@ if (!globalForMockDb.initialized) {
],
createdAt: new Date(Date.now() - 3600000 * 6),
updatedAt: new Date(),
isFeatured: false
isFeatured: false,
hasWidgetInstalled: false,
widgetInstalledAt: null,
widgetSiteUrl: null
},
{
id: 'list-8',
@@ -407,7 +477,10 @@ if (!globalForMockDb.initialized) {
],
createdAt: new Date(Date.now() - 3600000 * 2),
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
}
@@ -512,6 +631,41 @@ export const mockDb = {
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
@@ -555,6 +709,38 @@ export const mockDb = {
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
@@ -736,6 +922,50 @@ export const mockDb = {
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()) {
@@ -1046,5 +1276,229 @@ export const mockDb = {
update: { 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 })
}
}