diff --git a/app/api/listings/translate/route.ts b/app/api/listings/translate/route.ts new file mode 100644 index 0000000..fc8c503 --- /dev/null +++ b/app/api/listings/translate/route.ts @@ -0,0 +1,128 @@ +import { NextResponse } from 'next/server'; +import fs from 'fs'; +import path from 'path'; +import { requireAuth } from '../../../../lib/jwt'; +import type { Locale } from '../../../../lib/i18n'; + +type LocaleFields = { title: string; teaser: string; description: string }; +const SUPPORTED_LOCALES: Locale[] = ['en', 'fi', 'sv']; + +function loadApiKey() { + if (process.env.OPENAI_API_KEY) return process.env.OPENAI_API_KEY; + const keyPath = path.join(process.cwd(), 'creds', 'openai.key'); + try { + return fs.readFileSync(keyPath, 'utf8').trim(); + } catch { + return null; + } +} + +export async function POST(req: Request) { + try { + await requireAuth(req); + } catch (err) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const apiKey = loadApiKey(); + if (!apiKey) { + return NextResponse.json({ error: 'Missing OpenAI API key' }, { status: 500 }); + } + + let body: any; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); + } + + const incoming = body?.translations as Record | undefined; + const currentLocale = (body?.currentLocale as Locale) ?? 'en'; + if (!incoming) { + return NextResponse.json({ error: 'Missing translations' }, { status: 400 }); + } + + const payload = SUPPORTED_LOCALES.reduce( + (acc, loc) => ({ + ...acc, + [loc]: { + title: incoming[loc]?.title || '', + teaser: incoming[loc]?.teaser || '', + description: incoming[loc]?.description || '', + }, + }), + {} as Record, + ); + + const messages = [ + { + role: 'system', + content: + 'You are translating holiday rental listing copy between Finnish, Swedish, and English. Fill in missing locales, keep existing text unchanged, preserve meaning and tone, and respond with JSON only.', + }, + { + role: 'user', + content: JSON.stringify( + { + sourceLocale: currentLocale, + targetLocales: SUPPORTED_LOCALES.filter((l) => l !== currentLocale), + locales: payload, + }, + null, + 2, + ), + }, + { + role: 'system', + content: + 'Return JSON with top-level "locales" containing keys en, fi, sv. Each locale has title, teaser, description. Do not include explanations.', + }, + ]; + + let content = ''; + try { + const res = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: 'gpt-4o-mini', + temperature: 0.2, + messages, + }), + }); + + if (!res.ok) { + const errText = await res.text(); + return NextResponse.json({ error: 'AI request failed', detail: errText }, { status: 500 }); + } + + const data = await res.json(); + content = data?.choices?.[0]?.message?.content ?? ''; + } catch (err) { + return NextResponse.json({ error: 'AI request failed' }, { status: 500 }); + } + + const jsonText = content.match(/\{[\s\S]*\}/)?.[0] ?? content; + try { + const parsed = JSON.parse(jsonText); + const locales = parsed?.locales || parsed; + if (!locales) throw new Error('missing locales'); + const result = SUPPORTED_LOCALES.reduce( + (acc, loc) => ({ + ...acc, + [loc]: { + title: locales[loc]?.title ?? '', + teaser: locales[loc]?.teaser ?? '', + description: locales[loc]?.description ?? '', + }, + }), + {} as Record, + ); + return NextResponse.json({ translations: result }); + } catch (err) { + return NextResponse.json({ error: 'Could not parse AI response' }, { status: 500 }); + } +} diff --git a/app/listings/new/page.tsx b/app/listings/new/page.tsx index a6566fc..f22b700 100644 --- a/app/listings/new/page.tsx +++ b/app/listings/new/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useI18n } from '../../components/I18nProvider'; import type { Locale } from '../../../lib/i18n'; @@ -58,8 +58,7 @@ export default function NewListingPage() { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [isAuthed, setIsAuthed] = useState(false); - const [aiResponse, setAiResponse] = useState(''); - const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'error'>('idle'); + const [aiLoading, setAiLoading] = useState(false); const [slugStatus, setSlugStatus] = useState<'idle' | 'checking' | 'available' | 'taken' | 'error'>('idle'); useEffect(() => { @@ -143,67 +142,39 @@ export default function NewListingPage() { return 'partial'; } - const aiPrompt = useMemo(() => { - const payload = { - task: 'Translate this localization file for a holiday rental listing.', - instructions: [ - 'Preserve meaning, tone, numbers, and any markup.', - 'Return valid JSON only with the same keys.', - 'Fill missing translations; keep existing text unchanged.', - 'Leave out the task part from output', - ], - sourceLocale: currentLocale, - targetLocales: SUPPORTED_LOCALES.filter((loc) => loc !== currentLocale), - locales: SUPPORTED_LOCALES.reduce( - (acc, loc) => ({ - ...acc, - [loc]: { - title: translations[loc].title, - teaser: translations[loc].teaser, - description: translations[loc].description, - }, - }), - {} as Record - ), - }; - return JSON.stringify(payload, null, 2); - }, [translations, currentLocale]); - - function applyAiResponse() { + async function autoTranslate() { + if (aiLoading) return; setMessage(null); setError(null); + setAiLoading(true); try { - const parsed = JSON.parse(aiResponse); - if (!parsed?.locales || typeof parsed.locales !== 'object') { - throw new Error('Missing locales'); - } - const next = { ...translations }; - SUPPORTED_LOCALES.forEach((loc) => { - if (parsed.locales[loc]) { - const incoming = parsed.locales[loc]; - next[loc] = { - title: typeof incoming.title === 'string' ? incoming.title : next[loc].title, - teaser: typeof incoming.teaser === 'string' ? incoming.teaser : next[loc].teaser, - description: typeof incoming.description === 'string' ? incoming.description : next[loc].description, - }; - } + const res = await fetch('/api/listings/translate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ translations, currentLocale }), }); - setTranslations(next); - setMessage(t('aiApplySuccess')); + const data = await res.json(); + if (!res.ok || !data.translations) { + throw new Error('bad response'); + } + const incoming = data.translations as Record; + setTranslations((prev) => { + const next = { ...prev }; + SUPPORTED_LOCALES.forEach((loc) => { + const aiLoc = incoming?.[loc]; + next[loc] = { + title: prev[loc].title || aiLoc?.title || '', + teaser: prev[loc].teaser || aiLoc?.teaser || '', + description: prev[loc].description || aiLoc?.description || '', + }; + }); + return next; + }); + setMessage(t('aiAutoSuccess')); } catch (err) { - setError(t('aiApplyError')); - } - } - - async function copyAiPrompt() { - try { - if (!navigator?.clipboard) throw new Error('clipboard unavailable'); - await navigator.clipboard.writeText(aiPrompt); - setCopyStatus('copied'); - setTimeout(() => setCopyStatus('idle'), 1500); - } catch (err) { - setCopyStatus('error'); - setTimeout(() => setCopyStatus('idle'), 2000); + setError(t('aiAutoError')); + } finally { + setAiLoading(false); } } @@ -401,40 +372,10 @@ export default function NewListingPage() {

{t('aiHelperTitle')}

-

{t('aiHelperLead')}

-
-
- {t('aiPromptLabel')} -
- {copyStatus === 'copied' ? ( - {t('aiPromptCopied')} - ) : null} - {copyStatus === 'error' ? {t('aiCopyError')} : null} - -
-
-