Files
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

124 lines
4.4 KiB
TypeScript
Raw Permalink 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.
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
}