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>
124 lines
4.4 KiB
TypeScript
124 lines
4.4 KiB
TypeScript
const DEEPSEEK_API_URL = 'https://api.deepseek.com/chat/completions'
|
||
|
||
export interface ItineraryCandidate {
|
||
id: string
|
||
name: string
|
||
description: string
|
||
category: string
|
||
categorySlug: string
|
||
neighborhood: string
|
||
address: string
|
||
priceRange: number
|
||
rating: number | null
|
||
slug: string
|
||
isFeatured: boolean
|
||
}
|
||
|
||
interface GenerateItineraryParams {
|
||
days: number
|
||
style: string
|
||
locale: string
|
||
candidates: ItineraryCandidate[]
|
||
}
|
||
|
||
const LANGUAGE_NAMES: Record<string, string> = {
|
||
tr: 'Turkish',
|
||
en: 'English',
|
||
ru: 'Russian',
|
||
}
|
||
|
||
const STYLE_LABELS: Record<string, string> = {
|
||
gastronomy: 'gastronomy and food-focused',
|
||
relaxation: 'relaxation and slow-paced',
|
||
adventure: 'adventure and outdoor-focused',
|
||
}
|
||
|
||
/**
|
||
* Grounded itinerary generation: the model may ONLY recommend businesses from
|
||
* `candidates` — it never invents a place. This is the core anti-hallucination
|
||
* rule from the product spec (docs/prd-3.md §3.3).
|
||
*/
|
||
export async function generateItineraryContent(params: GenerateItineraryParams): Promise<string> {
|
||
const apiKey = process.env.DEEPSEEK_API_KEY
|
||
if (!apiKey) {
|
||
throw new Error('DEEPSEEK_API_KEY tanımlı değil')
|
||
}
|
||
|
||
const languageName = LANGUAGE_NAMES[params.locale] || LANGUAGE_NAMES.tr
|
||
const styleLabel = STYLE_LABELS[params.style] || params.style
|
||
|
||
const candidateList = params.candidates
|
||
.map((c) => {
|
||
const price = '₺'.repeat(c.priceRange)
|
||
const featured = c.isFeatured ? ' [featured]' : ''
|
||
return `- id:${c.id}${featured} | ${c.name} | ${c.category} | ${c.neighborhood} | ${price} | rating:${c.rating ?? 'n/a'} | link:/${c.categorySlug}/${c.slug}\n ${c.description}`
|
||
})
|
||
.join('\n')
|
||
|
||
const systemPrompt = `You are the local guide writer for "Marmaris Local", a curated directory of Marmaris, Turkey. You write personalized multi-day itineraries.
|
||
|
||
STRICT RULE: you may only recommend businesses from the CANDIDATE LIST below. Never invent, assume, or mention any place that is not in this list. If the list has fewer suitable places than needed, reuse the best candidates rather than inventing new ones.
|
||
|
||
For every place you recommend, include its markdown link exactly as given in the candidate list (e.g. [Place Name](/category-slug/place-slug)).
|
||
|
||
Write in ${languageName}. Output valid Markdown: use "## gün N" / "## day N" style headings per day (translate "day" to ${languageName}), short warm paragraphs, and occasional > blockquote for a "local tip". Avoid generic travel-blog clichés — be specific and grounded in the actual candidate descriptions. Places marked [featured] may be given slight preference when they fit, but relevance always comes first.`
|
||
|
||
const userPrompt = `Number of days: ${params.days}
|
||
Travel style: ${styleLabel}
|
||
|
||
CANDIDATE LIST (choose only from these):
|
||
${candidateList}
|
||
|
||
Write the full ${params.days}-day itinerary now, in Markdown, in ${languageName}.`
|
||
|
||
const res = await fetch(DEEPSEEK_API_URL, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${apiKey}`,
|
||
},
|
||
body: JSON.stringify({
|
||
model: 'deepseek-chat',
|
||
messages: [
|
||
{ role: 'system', content: systemPrompt },
|
||
{ role: 'user', content: userPrompt },
|
||
],
|
||
temperature: 0.7,
|
||
}),
|
||
})
|
||
|
||
if (!res.ok) {
|
||
const errText = await res.text()
|
||
throw new Error(`DeepSeek API hatası (${res.status}): ${errText}`)
|
||
}
|
||
|
||
const json = await res.json()
|
||
const content = json?.choices?.[0]?.message?.content
|
||
if (!content) {
|
||
throw new Error('DeepSeek yanıtı boş döndü')
|
||
}
|
||
|
||
return linkifyItineraryContent(content, params.candidates)
|
||
}
|
||
|
||
/**
|
||
* The model reliably mentions candidate names but doesn't always wrap them in
|
||
* the requested markdown link syntax. This deterministically links the first
|
||
* mention of each candidate as a safety net, so every generated plan actually
|
||
* drives traffic to listing pages regardless of how well the model complied.
|
||
*/
|
||
function linkifyItineraryContent(content: string, candidates: ItineraryCandidate[]): string {
|
||
let result = content
|
||
for (const c of candidates) {
|
||
const href = `/${c.categorySlug}/${c.slug}`
|
||
if (result.includes(`](${href})`)) continue // model already linked it correctly
|
||
|
||
const escapedName = c.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||
const pattern = new RegExp(`\\*{0,2}${escapedName}\\*{0,2}`)
|
||
if (pattern.test(result)) {
|
||
result = result.replace(pattern, `**[${c.name}](${href})**`)
|
||
}
|
||
}
|
||
return result
|
||
}
|