Files
marmarislocal/app/actions.ts
T
AyrisAIandClaude Sonnet 5 1b8cfeda95 feat: harden admin security, add AI trip planner, map view, and SEO/notification improvements
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>
2026-08-24 00:01:06 +03:00

770 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use server"
import { redirect } from "next/navigation"
import { mockDb } from '@/lib/mockDb'
import { uploadToOpeninary } from '@/lib/openinary'
import { requireAdmin } from '@/lib/auth'
import { HONEYPOT_FIELD_NAME } from '@/components/HoneypotField'
import { checkRateLimit } from '@/lib/rateLimit'
import { sendTelegramMessage, formatContactMessageNotification, formatBusinessSubmissionNotification } from '@/lib/telegram'
import { headers } from 'next/headers'
import { revalidatePath } from 'next/cache'
async function getClientIp() {
const h = await headers()
return h.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
}
export async function submitBusinessAction(formData: FormData) {
if (formData.get(HONEYPOT_FIELD_NAME)) {
// Bot trap tripped — pretend success so it doesn't learn to skip this field.
return { success: true, error: undefined }
}
const ip = await getClientIp()
if (!checkRateLimit(`submit-business:${ip}`, 5, 10 * 60_000)) {
return { error: 'Çok fazla başvuru gönderildi. Lütfen daha sonra tekrar deneyin.' }
}
const businessName = formData.get('businessName') as string
const categoryId = formData.get('categoryId') as string
const neighborhoodId = formData.get('neighborhoodId') as string
const address = formData.get('address') as string
const phone = formData.get('phone') as string
const whatsapp = formData.get('whatsapp') as string
const description = formData.get('description') as string
const contactName = formData.get('contactName') as string
const contactEmail = formData.get('contactEmail') as string
const imageFile = formData.get('imageFile') as File | null
if (!businessName || !categoryId || !neighborhoodId || !address || !description || !contactName || !contactEmail) {
return { error: 'Lütfen tüm zorunlu alanları doldurun.' }
}
let imageUrl = null
if (imageFile && imageFile.size > 0) {
try {
imageUrl = await uploadToOpeninary(imageFile, 'submissions')
} catch (e: any) {
console.error('Openinary upload error:', e)
return { error: `Görsel yüklenemedi: ${e.message}` }
}
}
await mockDb.createSubmission({
businessName,
categoryId,
neighborhoodId,
address,
phone,
whatsapp,
description,
contactName,
contactEmail,
imageUrl
})
const [categories, neighborhoods] = await Promise.all([mockDb.getCategories(), mockDb.getNeighborhoods()])
await sendTelegramMessage(
formatBusinessSubmissionNotification({
businessName,
categoryName: categories.find(c => c.id === categoryId)?.nameTr || categoryId,
neighborhoodName: neighborhoods.find(n => n.id === neighborhoodId)?.nameTr || neighborhoodId,
address,
phone,
whatsapp,
description,
contactName,
contactEmail,
ip,
})
)
revalidatePath('/admin/submissions')
return { success: true, error: undefined }
}
export async function submitContactMessageAction(formData: FormData) {
if (formData.get(HONEYPOT_FIELD_NAME)) {
return { success: true, error: undefined }
}
const ip = await getClientIp()
if (!checkRateLimit(`submit-contact:${ip}`, 5, 10 * 60_000)) {
return { error: 'Çok fazla mesaj gönderildi. Lütfen daha sonra tekrar deneyin.' }
}
const name = formData.get('name') as string
const email = formData.get('email') as string
const subject = formData.get('subject') as string
const message = formData.get('message') as string
if (!name || !email || !subject || !message) {
return { error: 'Lütfen tüm alanları doldurun.' }
}
await mockDb.createMessage({
name,
email,
subject,
message
})
await sendTelegramMessage(formatContactMessageNotification({ name, email, subject, message, ip }))
revalidatePath('/admin/messages')
return { success: true, error: undefined }
}
export async function approveSubmissionAction(id: string) {
await requireAdmin()
const submission = await mockDb.getSubmissionById(id)
if (!submission) return { error: 'Başvuru bulunamadı' }
const slug = submission.businessName.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
await mockDb.createListing({
slug,
categoryId: submission.categoryId,
neighborhoodId: submission.neighborhoodId,
city: 'marmaris',
nameTr: submission.businessName,
nameEn: submission.businessName,
nameRu: submission.businessName,
descriptionTr: submission.description,
descriptionEn: submission.description,
descriptionRu: submission.description,
address: submission.address,
phone: submission.phone,
whatsapp: submission.whatsapp,
priceRange: 2,
rating: 5.0,
isLocalApproved: false,
isFeatured: false,
hasWidgetInstalled: false,
images: submission.imageUrl ? [submission.imageUrl] : []
})
await mockDb.updateSubmissionStatus(id, 'APPROVED')
revalidatePath('/admin/submissions')
revalidatePath('/restaurants')
revalidatePath('/aparts')
revalidatePath('/businesses')
return { success: true, error: undefined }
}
export async function rejectSubmissionAction(id: string) {
await requireAdmin()
await mockDb.updateSubmissionStatus(id, 'REJECTED')
revalidatePath('/admin/submissions')
return { success: true, error: undefined }
}
export async function deleteListingAction(id: string) {
await requireAdmin()
await mockDb.deleteListing(id)
revalidatePath('/admin/listings')
revalidatePath('/restaurants')
revalidatePath('/aparts')
revalidatePath('/businesses')
return { success: true, error: undefined }
}
export async function restoreListingAction(id: string) {
await requireAdmin()
await mockDb.restoreListing(id)
revalidatePath('/admin/trash')
revalidatePath('/admin/listings')
return { success: true, error: undefined }
}
export async function hardDeleteListingAction(id: string) {
await requireAdmin()
try {
await mockDb.hardDeleteListing(id)
revalidatePath('/admin/trash')
return { success: true, error: undefined }
} catch (err: any) {
if (err.message?.includes('RESTRICT') || err.message?.includes('Foreign key constraint')) {
return { success: false, error: 'Silme başarısız: Bu mekana bağlı etkinlik (Event) veya kayıtlar var.' }
}
return { success: false, error: err.message || 'Bilinmeyen bir hata oluştu.' }
}
}
export async function markMessageReadAction(id: string) {
await requireAdmin()
await mockDb.markMessageAsRead(id)
revalidatePath('/admin/messages')
return { success: true, error: undefined }
}
export async function createOrUpdateListingAction(formData: FormData) {
await requireAdmin()
const id = formData.get('id') as string | null
const slug = formData.get('slug') as string
const categoryId = formData.get('categoryId') as string
const neighborhoodId = formData.get('neighborhoodId') as string
const nameTr = formData.get('nameTr') as string
const nameEn = formData.get('nameEn') as string
const nameRu = formData.get('nameRu') as string
const descriptionTr = formData.get('descriptionTr') as string
const descriptionEn = formData.get('descriptionEn') as string
const descriptionRu = formData.get('descriptionRu') as string
const address = formData.get('address') as string
if (!slug || !categoryId || !neighborhoodId || !nameTr || !address) {
return { success: false, error: 'Lütfen zorunlu alanları (Slug, Kategori, Mahalle, İsim, Adres) doldurun.' }
}
const phone = formData.get('phone') as string || null
const whatsapp = formData.get('whatsapp') as string || null
const website = formData.get('website') as string || null
const instagram = formData.get('instagram') as string || null
const priceRange = parseInt(formData.get('priceRange') as string)
const rating = formData.get('rating') ? parseFloat(formData.get('rating') as string) : null
const isLocalApproved = formData.get('isLocalApproved') === 'true'
const isFeatured = formData.get('isFeatured') === 'true'
const latitude = formData.get('latitude') ? parseFloat(formData.get('latitude') as string) : null
const longitude = formData.get('longitude') ? parseFloat(formData.get('longitude') as string) : null
const openingHoursStr = formData.get('openingHours') as string
const openingHours = openingHoursStr ? { all: openingHoursStr } : null
// Handle images
const images: string[] = []
const imageFile1 = formData.get('imageFile1') as File | null
const imageUrl1 = formData.get('imageUrl1') as string | null
const imageFile2 = formData.get('imageFile2') as File | null
const imageUrl2 = formData.get('imageUrl2') as string | null
// Process first image
if (imageFile1 && imageFile1.size > 0) {
try {
const url = await uploadToOpeninary(imageFile1, `listings/${slug}`)
images.push(url)
} catch (e: any) {
console.error('Image 1 upload error:', e)
return { success: false, error: `1. Görsel yüklenemedi: ${e.message}` }
}
} else if (imageUrl1) {
images.push(imageUrl1)
}
// Process second image
if (imageFile2 && imageFile2.size > 0) {
try {
const url = await uploadToOpeninary(imageFile2, `listings/${slug}`)
images.push(url)
} catch (e: any) {
console.error('Image 2 upload error:', e)
return { success: false, error: `2. Görsel yüklenemedi: ${e.message}` }
}
} else if (imageUrl2) {
images.push(imageUrl2)
}
const data = {
slug,
categoryId,
neighborhoodId,
city: 'marmaris',
nameTr,
nameEn,
nameRu,
descriptionTr,
descriptionEn,
descriptionRu,
address,
phone,
whatsapp,
website,
instagram,
priceRange,
rating,
isLocalApproved,
isFeatured,
latitude,
longitude,
openingHours,
images,
hasWidgetInstalled: false
}
try {
if (id && id !== 'new') {
await mockDb.updateListing(id, data)
} else {
await mockDb.createListing(data)
}
revalidatePath('/admin/listings')
revalidatePath('/restaurants')
revalidatePath('/aparts')
revalidatePath('/businesses')
return { success: true, error: undefined }
} catch (err: any) {
return { success: false, error: err.message || 'Mekan kaydedilemedi.' }
}
}
// Blog Actions
export async function deleteBlogPostAction(id: string) {
await requireAdmin()
await mockDb.deleteBlogPost(id)
revalidatePath('/admin/blog')
revalidatePath('/blog')
return { success: true, error: undefined }
}
export async function createOrUpdateBlogPostAction(formData: FormData) {
await requireAdmin()
const id = formData.get('id') as string | null
const slug = formData.get('slug') as string
const titleTr = formData.get('titleTr') as string
const titleEn = formData.get('titleEn') as string
const titleRu = formData.get('titleRu') as string
const contentTr = formData.get('contentTr') as string
const contentEn = formData.get('contentEn') as string
const contentRu = formData.get('contentRu') as string
if (!slug || !titleTr) {
return { success: false, error: 'Lütfen zorunlu alanları (Slug, Başlık) doldurun.' }
}
const tagsStr = formData.get('tags') as string || ''
const tags = tagsStr.split(',').map(t => t.trim()).filter(Boolean)
const relatedListingIdsStr = formData.get('relatedListingIds') as string || ''
const relatedListingIds = relatedListingIdsStr.split(',').map(i => i.trim()).filter(Boolean)
const isPublished = formData.get('isPublished') === 'true'
const publishedAt = isPublished ? new Date() : null
// Handle Cover Image
const coverImageFile = formData.get('coverImageFile') as File | null
let coverImage = formData.get('coverImageUrl') as string | null || null
if (coverImageFile && coverImageFile.size > 0) {
try {
coverImage = await uploadToOpeninary(coverImageFile, `blog/${slug}`)
} catch (e: any) {
console.error('Blog cover upload error:', e)
return { success: false, error: `Kapak görseli yüklenemedi: ${e.message}` }
}
}
const data = {
slug,
titleTr,
titleEn,
titleRu,
contentTr,
contentEn,
contentRu,
coverImage,
tags,
relatedListingIds,
publishedAt
}
try {
if (id && id !== 'new') {
await mockDb.updateBlogPost(id, data)
} else {
await mockDb.createBlogPost(data)
}
revalidatePath('/admin/blog')
revalidatePath('/blog')
revalidatePath(`/blog/${slug}`)
return { success: true, error: undefined }
} catch (err: any) {
return { success: false, error: err.message || 'Yazı kaydedilemedi.' }
}
}
// Collections Actions
export async function deleteCollectionAction(id: string) {
await requireAdmin()
await mockDb.deleteCollection(id)
revalidatePath('/admin/collections')
revalidatePath('/collections')
return { success: true, error: undefined }
}
export async function createOrUpdateCollectionAction(formData: FormData) {
await requireAdmin()
const id = formData.get('id') as string | null
const slug = formData.get('slug') as string
const titleTr = formData.get('titleTr') as string
const titleEn = formData.get('titleEn') as string
const titleRu = formData.get('titleRu') as string
const descriptionTr = formData.get('descriptionTr') as string
const descriptionEn = formData.get('descriptionEn') as string
const descriptionRu = formData.get('descriptionRu') as string
if (!slug || !titleTr) {
return { success: false, error: 'Lütfen zorunlu alanları (Slug, Başlık) doldurun.' }
}
const listingIdsStr = formData.get('listingIds') as string || ''
const listingIds = listingIdsStr.split(',').map(i => i.trim()).filter(Boolean)
// Handle Cover Image
const coverImageFile = formData.get('coverImageFile') as File | null
let coverImage = formData.get('coverImageUrl') as string | null || null
if (coverImageFile && coverImageFile.size > 0) {
try {
coverImage = await uploadToOpeninary(coverImageFile, `collections/${slug}`)
} catch (e: any) {
console.error('Collection cover upload error:', e)
return { success: false, error: `Kapak görseli yüklenemedi: ${e.message}` }
}
}
const data = {
slug,
titleTr,
titleEn,
titleRu,
descriptionTr,
descriptionEn,
descriptionRu,
coverImage,
listingIds
}
try {
if (id && id !== 'new') {
await mockDb.updateCollection(id, data)
} else {
await mockDb.createCollection(data)
}
revalidatePath('/admin/collections')
revalidatePath('/collections')
revalidatePath(`/collection/${slug}`)
return { success: true, error: undefined }
} catch (err: any) {
return { success: false, error: err.message || 'Koleksiyon kaydedilemedi.' }
}
}
// Events Actions (Phase 3)
export async function deleteEventAction(id: string) {
await requireAdmin()
await mockDb.deleteEvent(id)
revalidatePath('/admin/events')
revalidatePath('/events')
return { success: true, error: undefined }
}
export async function createOrUpdateEventAction(formData: FormData) {
await requireAdmin()
const id = formData.get('id') as string | null
const slug = formData.get('slug') as string
const listingId = formData.get('listingId') as string | null || null
const titleTr = formData.get('titleTr') as string
const titleEn = formData.get('titleEn') as string
const titleRu = formData.get('titleRu') as string
const descriptionTr = formData.get('descriptionTr') as string
const descriptionEn = formData.get('descriptionEn') as string
const descriptionRu = formData.get('descriptionRu') as string
const startDateStr = formData.get('startDate') as string
const endDateStr = formData.get('endDate') as string | null
if (!slug || !titleTr || !startDateStr) {
return { success: false, error: 'Lütfen zorunlu alanları (Slug, Başlık, Başlangıç Tarihi) doldurun.' }
}
const isSponsored = formData.get('isSponsored') === 'true'
const startDate = new Date(startDateStr)
const endDate = endDateStr ? new Date(endDateStr) : null
// Handle Cover Image
const coverImageFile = formData.get('coverImageFile') as File | null
let coverImage = formData.get('coverImageUrl') as string | null || null
if (coverImageFile && coverImageFile.size > 0) {
try {
coverImage = await uploadToOpeninary(coverImageFile, `events/${slug}`)
} catch (e: any) {
console.error('Event cover upload error:', e)
return { success: false, error: `Kapak görseli yüklenemedi: ${e.message}` }
}
}
const data = {
slug,
listingId,
titleTr,
titleEn,
titleRu,
descriptionTr,
descriptionEn,
descriptionRu,
startDate,
endDate,
coverImage,
isSponsored
}
try {
if (id && id !== 'new') {
await mockDb.updateEvent(id, data)
} else {
await mockDb.createEvent(data)
}
revalidatePath('/admin/events')
revalidatePath('/events')
revalidatePath(`/event/${slug}`)
return { success: true, error: undefined }
} catch (err: any) {
return { success: false, error: err.message || 'Etkinlik kaydedilemedi.' }
}
}
// Neighborhood Actions
export async function deleteNeighborhoodAction(id: string) {
await requireAdmin()
try {
const listings = await mockDb.getListings({ neighborhoodId: id })
if (listings && listings.length > 0) {
return { success: false, error: `Silme başarısız: Bu mahalleye kayıtlı ${listings.length} adet mekan var.` }
}
await mockDb.deleteNeighborhood(id)
revalidatePath('/admin/neighborhoods')
return { success: true, error: undefined }
} catch (err: any) {
if (err.message?.includes('RESTRICT') || err.message?.includes('Foreign key constraint')) {
return { success: false, error: 'Silme başarısız: Bu mahalleye bağlı gizli (silinmiş veya arşivlenmiş) mekanlar veya etkinlikler var.' }
}
return { success: false, error: err.message || 'Bilinmeyen bir hata oluştu.' }
}
}
export async function createOrUpdateNeighborhoodAction(formData: FormData) {
await requireAdmin()
const id = formData.get('id') as string | null
const slug = formData.get('slug') as string
const nameTr = formData.get('nameTr') as string
const nameEn = formData.get('nameEn') as string
const nameRu = formData.get('nameRu') as string
if (!slug || !nameTr || !nameEn || !nameRu) {
return { error: 'Zorunlu alanları doldurun.' }
}
const data = {
slug,
nameTr,
nameEn,
nameRu,
}
try {
if (id && id !== 'new') {
await mockDb.updateNeighborhood(id, data)
} else {
await mockDb.createNeighborhood(data)
}
revalidatePath('/admin/neighborhoods')
return { success: true, error: undefined }
} catch (err: any) {
return { success: false, error: err.message || 'Mahalle kaydedilemedi.' }
}
}
// AI Itinerary Planner Actions (Phase 3)
import crypto from 'crypto'
import { generateItineraryContent, type ItineraryCandidate } from '@/lib/deepseek'
const MAX_ITINERARY_CANDIDATES = 30
export async function generateItineraryAction(days: number, style: string, neighborhoodSlugs: string[], locale: string) {
const sorted = [...neighborhoodSlugs].sort()
const paramsHash = crypto.createHash('md5').update(JSON.stringify({ days, style, neighborhoodSlugs: sorted, locale })).digest('hex')
const existing = await mockDb.getItineraryByHash(paramsHash)
if (existing) {
return { success: true, id: existing.id }
}
// Cache hit above is free; only a genuinely new combination reaches the
// paid DeepSeek call, so rate-limit per IP to bound abuse cost.
const ip = await getClientIp()
if (!checkRateLimit(`itinerary:${ip}`, 10, 60 * 60_000)) {
return { success: false, error: 'Çok fazla plan oluşturuldu. Lütfen bir süre sonra tekrar deneyin.' }
}
const allListings = await mockDb.getListings()
const matchingListings = allListings.filter(l =>
!l.deletedAt && l.isLocalApproved &&
(sorted.length === 0 ||
(l.neighborhood && sorted.includes(l.neighborhood.slug)))
)
const pool = (matchingListings.length > 0 ? matchingListings : allListings.filter(l => !l.deletedAt && l.isLocalApproved))
.sort((a, b) => Number(b.isFeatured) - Number(a.isFeatured))
.slice(0, MAX_ITINERARY_CANDIDATES)
if (pool.length === 0) {
return { success: false, error: 'Seçtiğiniz bölgelerde yerel onaylı mekan bulunamadı.' }
}
const nameKey = locale === 'en' ? 'nameEn' : locale === 'ru' ? 'nameRu' : 'nameTr'
const descKey = locale === 'en' ? 'descriptionEn' : locale === 'ru' ? 'descriptionRu' : 'descriptionTr'
const candidates: ItineraryCandidate[] = pool.map(l => ({
id: l.id,
name: (l as any)[nameKey] || l.nameTr,
description: (l as any)[descKey] || l.descriptionTr,
category: l.category?.nameTr || '',
categorySlug: l.category?.slug || 'isletme',
neighborhood: l.neighborhood?.nameTr || '',
address: l.address,
priceRange: l.priceRange,
rating: l.rating ?? null,
slug: l.slug,
isFeatured: l.isFeatured,
}))
let content: string
try {
content = await generateItineraryContent({ days, style, locale, candidates })
} catch (e: any) {
console.error('DeepSeek itinerary generation error:', e)
return { success: false, error: 'Planınız oluşturulamadı, lütfen birazdan tekrar deneyin.' }
}
const newItin = await mockDb.createItinerary({
paramsHash,
params: { days, style, neighborhoodSlugs: sorted, locale },
content,
listingIds: pool.map(l => l.id)
})
return { success: true, id: newItin.id }
}
export async function createOrUpdateCategoryAction(formData: FormData) {
await requireAdmin()
const id = formData.get('id') as string
const slug = formData.get('slug') as string
const nameTr = formData.get('nameTr') as string
const nameEn = formData.get('nameEn') as string
const nameRu = formData.get('nameRu') as string
if (!slug || !nameTr || !nameEn || !nameRu) {
throw new Error('Eksik alanlar var')
}
if (id && id !== 'new') {
await mockDb.updateCategory(id, { slug, nameTr, nameEn, nameRu })
} else {
await mockDb.createCategory({ slug, nameTr, nameEn, nameRu })
}
revalidatePath('/admin/categories')
redirect('/tr/admin/categories')
}
export async function deleteCategoryAction(formData: FormData) {
await requireAdmin()
const id = formData.get('id') as string
if (!id) return
await mockDb.deleteCategory(id)
revalidatePath('/admin/categories')
}
export async function deleteSubmissionAction(id: string) {
await requireAdmin()
await mockDb.deleteSubmission(id)
revalidatePath('/admin/submissions')
return { success: true, error: undefined }
}
export async function deleteMessageAction(id: string) {
await requireAdmin()
await mockDb.deleteMessage(id)
revalidatePath('/admin/messages')
return { success: true, error: undefined }
}
export async function searchListingsAction(query: string) {
if (!query || query.length < 2) return []
const allListings = await mockDb.getListings()
const q = query.toLowerCase()
const results = allListings.filter(l =>
!l.deletedAt && (
l.nameTr.toLowerCase().includes(q) ||
l.nameEn?.toLowerCase().includes(q) ||
l.nameRu?.toLowerCase().includes(q)
)
).slice(0, 5) // Return max 5 suggestions
return results.map(l => ({
id: l.id,
nameTr: l.nameTr,
nameEn: l.nameEn,
nameRu: l.nameRu,
slug: l.slug,
categorySlug: l.category?.slug || 'restoran'
}))
}
export async function saveWidgetPartnerAction(formData: FormData) {
await requireAdmin()
const id = formData.get('id') as string | null
const name = formData.get('name') as string
const url = formData.get('url') as string
const neighborhoodSlug = formData.get('neighborhoodSlug') as string | null
const isActive = formData.get('isActive') === 'true'
const isHidden = formData.get('isHidden') === 'true'
if (!name || !url) {
return { error: 'Lütfen zorunlu alanları doldurun.' }
}
const data = {
name,
url,
neighborhoodSlug: neighborhoodSlug || null,
isActive,
isHidden
}
try {
if (id && id !== 'new') {
await mockDb.updateWidgetPartner(id, data)
} else {
await mockDb.createWidgetPartner(data)
}
revalidatePath('/admin/widget-partners')
return { success: true, error: undefined }
} catch (error: any) {
return { error: error.message || 'Bir hata oluştu.' }
}
}
export async function deleteWidgetPartnerAction(id: string) {
await requireAdmin()
await mockDb.deleteWidgetPartner(id)
revalidatePath('/admin/widget-partners')
return { success: true, error: undefined }
}