Polish AI helper and layout; add AI slug suggestions
This commit is contained in:
parent
54edd9e2e8
commit
4e22995402
3 changed files with 72 additions and 33 deletions
|
|
@ -65,7 +65,7 @@ export async function POST(req: Request) {
|
||||||
{
|
{
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content:
|
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.',
|
'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. Suggest localized slugs if missing.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
|
|
@ -74,6 +74,7 @@ export async function POST(req: Request) {
|
||||||
sourceLocale: currentLocale,
|
sourceLocale: currentLocale,
|
||||||
targetLocales: SUPPORTED_LOCALES.filter((l) => l !== currentLocale),
|
targetLocales: SUPPORTED_LOCALES.filter((l) => l !== currentLocale),
|
||||||
locales: payload,
|
locales: payload,
|
||||||
|
askForSlugs: true,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
|
|
@ -82,7 +83,7 @@ export async function POST(req: Request) {
|
||||||
{
|
{
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content:
|
content:
|
||||||
'Return JSON with top-level "locales" containing keys en, fi, sv. Each locale has title, teaser, description. Do not include explanations.',
|
'Return JSON with top-level "locales" containing keys en, fi, sv. Each locale has title, teaser, description, and slug. Do not include explanations.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ export default function NewListingPage() {
|
||||||
const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'error'>('idle');
|
const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'error'>('idle');
|
||||||
const [aiLoading, setAiLoading] = useState(false);
|
const [aiLoading, setAiLoading] = useState(false);
|
||||||
const [slugStatus, setSlugStatus] = useState<'idle' | 'checking' | 'available' | 'taken' | 'error'>('idle');
|
const [slugStatus, setSlugStatus] = useState<'idle' | 'checking' | 'available' | 'taken' | 'error'>('idle');
|
||||||
|
const [suggestedSlugs, setSuggestedSlugs] = useState<Record<Locale, string>>({ en: '', fi: '', sv: '' });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentLocale(uiLocale as Locale);
|
setCurrentLocale(uiLocale as Locale);
|
||||||
|
|
@ -151,6 +152,8 @@ export default function NewListingPage() {
|
||||||
'Preserve meaning, tone, numbers, and any markup.',
|
'Preserve meaning, tone, numbers, and any markup.',
|
||||||
'Return valid JSON only with the same keys.',
|
'Return valid JSON only with the same keys.',
|
||||||
'Fill missing translations; keep existing text unchanged.',
|
'Fill missing translations; keep existing text unchanged.',
|
||||||
|
'Suggest localized slugs based on the title/description; keep them URL-friendly (kebab-case).',
|
||||||
|
'If teaser or slug is empty, propose one; otherwise keep the existing value.',
|
||||||
],
|
],
|
||||||
sourceLocale: currentLocale,
|
sourceLocale: currentLocale,
|
||||||
targetLocales: SUPPORTED_LOCALES.filter((loc) => loc !== currentLocale),
|
targetLocales: SUPPORTED_LOCALES.filter((loc) => loc !== currentLocale),
|
||||||
|
|
@ -161,13 +164,14 @@ export default function NewListingPage() {
|
||||||
title: translations[loc].title,
|
title: translations[loc].title,
|
||||||
teaser: translations[loc].teaser,
|
teaser: translations[loc].teaser,
|
||||||
description: translations[loc].description,
|
description: translations[loc].description,
|
||||||
|
slug: suggestedSlugs[loc] || slug,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
{} as Record<Locale, LocaleFields>
|
{} as Record<Locale, LocaleFields & { slug?: string }>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
return JSON.stringify(payload, null, 2);
|
return JSON.stringify(payload, null, 2);
|
||||||
}, [translations, currentLocale]);
|
}, [translations, currentLocale, suggestedSlugs, slug]);
|
||||||
|
|
||||||
async function autoTranslate() {
|
async function autoTranslate() {
|
||||||
if (aiLoading) return;
|
if (aiLoading) return;
|
||||||
|
|
@ -184,7 +188,7 @@ export default function NewListingPage() {
|
||||||
if (!res.ok || !data.translations) {
|
if (!res.ok || !data.translations) {
|
||||||
throw new Error('bad response');
|
throw new Error('bad response');
|
||||||
}
|
}
|
||||||
const incoming = data.translations as Record<Locale, LocaleFields>;
|
const incoming = data.translations as Record<Locale, LocaleFields & { slug?: string }>;
|
||||||
setTranslations((prev) => {
|
setTranslations((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
SUPPORTED_LOCALES.forEach((loc) => {
|
SUPPORTED_LOCALES.forEach((loc) => {
|
||||||
|
|
@ -197,6 +201,15 @@ export default function NewListingPage() {
|
||||||
});
|
});
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setSuggestedSlugs((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
SUPPORTED_LOCALES.forEach((loc) => {
|
||||||
|
if (incoming?.[loc]?.slug) {
|
||||||
|
next[loc] = incoming[loc].slug as string;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
setMessage(t('aiAutoSuccess'));
|
setMessage(t('aiAutoSuccess'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(t('aiAutoError'));
|
setError(t('aiAutoError'));
|
||||||
|
|
@ -225,6 +238,16 @@ export default function NewListingPage() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
setSuggestedSlugs((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
SUPPORTED_LOCALES.forEach((loc) => {
|
||||||
|
const incoming = locales[loc];
|
||||||
|
if (incoming && typeof incoming.slug === 'string' && incoming.slug) {
|
||||||
|
next[loc] = incoming.slug;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
setTranslations(next);
|
setTranslations(next);
|
||||||
setMessage(t('aiApplySuccess'));
|
setMessage(t('aiApplySuccess'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -379,17 +402,6 @@ export default function NewListingPage() {
|
||||||
<main className="panel" style={{ maxWidth: 1100, margin: '40px auto' }}>
|
<main className="panel" style={{ maxWidth: 1100, margin: '40px auto' }}>
|
||||||
<h1>{t('createListingTitle')}</h1>
|
<h1>{t('createListingTitle')}</h1>
|
||||||
<form onSubmit={onSubmit} style={{ display: 'grid', gap: 10 }}>
|
<form onSubmit={onSubmit} style={{ display: 'grid', gap: 10 }}>
|
||||||
<label>
|
|
||||||
{t('slugLabel')}
|
|
||||||
<input value={slug} onChange={(e) => setSlug(e.target.value)} onBlur={checkSlugAvailability} required />
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 4, flexWrap: 'wrap' }}>
|
|
||||||
<span style={{ color: '#cbd5e1', fontSize: 12 }}>{t('slugHelp')}</span>
|
|
||||||
{slugStatus === 'checking' ? <span style={{ color: '#cbd5e1', fontSize: 12 }}>{t('slugChecking')}</span> : null}
|
|
||||||
{slugStatus === 'available' ? <span style={{ color: '#34d399', fontSize: 12 }}>{t('slugAvailable')}</span> : null}
|
|
||||||
{slugStatus === 'taken' ? <span style={{ color: '#f87171', fontSize: 12 }}>{t('slugTaken')}</span> : null}
|
|
||||||
{slugStatus === 'error' ? <span style={{ color: '#facc15', fontSize: 12 }}>{t('slugCheckError')}</span> : null}
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginBottom: 6 }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginBottom: 6 }}>
|
||||||
<strong>{t('languageTabsLabel')}</strong>
|
<strong>{t('languageTabsLabel')}</strong>
|
||||||
|
|
@ -417,22 +429,48 @@ export default function NewListingPage() {
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: 12,
|
gap: 12,
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
|
gridTemplateColumns: 'repeat(auto-fit, minmax(340px, 1fr))',
|
||||||
alignItems: 'start',
|
alignItems: 'start',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'grid', gap: 8 }}>
|
<div className="panel" style={{ display: 'grid', gap: 10, border: '1px solid rgba(148,163,184,0.3)', background: 'rgba(255,255,255,0.02)' }}>
|
||||||
|
<h3 style={{ margin: 0 }}>{t('localeSectionTitle')}</h3>
|
||||||
<label>
|
<label>
|
||||||
{t('titleLabel')}
|
{t('titleLabel')}
|
||||||
<input value={translations[currentLocale].title} onChange={(e) => updateTranslation(currentLocale, 'title', e.target.value)} required />
|
<input value={translations[currentLocale].title} onChange={(e) => updateTranslation(currentLocale, 'title', e.target.value)} required />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t('descriptionLabel')}
|
{t('descriptionLabel')}
|
||||||
<textarea value={translations[currentLocale].description} onChange={(e) => updateTranslation(currentLocale, 'description', e.target.value)} required rows={4} />
|
<textarea value={translations[currentLocale].description} onChange={(e) => updateTranslation(currentLocale, 'description', e.target.value)} required rows={6} />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t('teaserLabel')}
|
{t('teaserLabel')}
|
||||||
<input value={translations[currentLocale].teaser} onChange={(e) => updateTranslation(currentLocale, 'teaser', e.target.value)} />
|
<input
|
||||||
|
value={translations[currentLocale].teaser}
|
||||||
|
onChange={(e) => updateTranslation(currentLocale, 'teaser', e.target.value)}
|
||||||
|
placeholder={t('teaserHelp')}
|
||||||
|
/>
|
||||||
|
<div style={{ color: '#cbd5e1', fontSize: 12 }}>{t('aiOptionalHint')}</div>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t('slugLabel')}
|
||||||
|
<input
|
||||||
|
value={suggestedSlugs[currentLocale] || slug}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setSlug(val);
|
||||||
|
setSuggestedSlugs((prev) => ({ ...prev, [currentLocale]: val }));
|
||||||
|
}}
|
||||||
|
onBlur={checkSlugAvailability}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 4, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ color: '#cbd5e1', fontSize: 12 }}>{t('slugHelp')}</span>
|
||||||
|
<span style={{ color: '#cbd5e1', fontSize: 12 }}>{t('aiOptionalHint')}</span>
|
||||||
|
{slugStatus === 'checking' ? <span style={{ color: '#cbd5e1', fontSize: 12 }}>{t('slugChecking')}</span> : null}
|
||||||
|
{slugStatus === 'available' ? <span style={{ color: '#34d399', fontSize: 12 }}>{t('slugAvailable')}</span> : null}
|
||||||
|
{slugStatus === 'taken' ? <span style={{ color: '#f87171', fontSize: 12 }}>{t('slugTaken')}</span> : null}
|
||||||
|
{slugStatus === 'error' ? <span style={{ color: '#facc15', fontSize: 12 }}>{t('slugCheckError')}</span> : null}
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="panel" style={{ border: '1px solid rgba(148,163,184,0.3)', background: 'rgba(255,255,255,0.02)' }}>
|
<div className="panel" style={{ border: '1px solid rgba(148,163,184,0.3)', background: 'rgba(255,255,255,0.02)' }}>
|
||||||
|
|
|
||||||
26
lib/i18n.ts
26
lib/i18n.ts
|
|
@ -147,7 +147,7 @@ const baseMessages = {
|
||||||
aiAutoTranslating: 'Translating…',
|
aiAutoTranslating: 'Translating…',
|
||||||
aiAutoSuccess: 'Translations updated with AI.',
|
aiAutoSuccess: 'Translations updated with AI.',
|
||||||
aiAutoError: 'AI translation failed. Please try again or use manual mode below.',
|
aiAutoError: 'AI translation failed. Please try again or use manual mode below.',
|
||||||
aiHelperNote: 'Uses the OpenAI translations key to fill missing locales.',
|
aiHelperNote: 'Uses OpenAI to translate missing texts.',
|
||||||
aiManualLead: 'If auto-translate fails, copy this prompt to your AI assistant and paste the JSON reply below.',
|
aiManualLead: 'If auto-translate fails, copy this prompt to your AI assistant and paste the JSON reply below.',
|
||||||
aiPromptLabel: 'Prompt to send to AI',
|
aiPromptLabel: 'Prompt to send to AI',
|
||||||
aiCopyPrompt: 'Copy prompt',
|
aiCopyPrompt: 'Copy prompt',
|
||||||
|
|
@ -299,17 +299,17 @@ const baseMessages = {
|
||||||
localeMissing: 'Puuttuu',
|
localeMissing: 'Puuttuu',
|
||||||
aiHelperTitle: 'AI-käännösapu',
|
aiHelperTitle: 'AI-käännösapu',
|
||||||
aiHelperLead: 'Anna tekoälyn täydentää muut kielet puolestasi.',
|
aiHelperLead: 'Anna tekoälyn täydentää muut kielet puolestasi.',
|
||||||
aiAutoExplain: 'Täytä tekstit yhdellä kielellä ja paina nappia; AI täyttää loput kielet.',
|
aiAutoExplain: 'Täytä tekstit yhdellä kielellä ja paina nappia; AI täyttää loput kielet.',
|
||||||
aiAutoTranslate: 'Käännä puuttuvat kielet',
|
aiAutoTranslate: 'Käännä puuttuvat kielet',
|
||||||
aiAutoTranslating: 'Käännetään…',
|
aiAutoTranslating: 'Käännetään…',
|
||||||
aiAutoSuccess: 'Käännökset päivitetty AI:lla.',
|
aiAutoSuccess: 'Käännökset päivitetty AI:lla.',
|
||||||
aiAutoError: 'Käännös epäonnistui. Yritä uudelleen tai käytä manuaalitilaa alla.',
|
aiAutoError: 'Käännös epäonnistui. Yritä uudelleen tai käytä manuaalitilaa alla.',
|
||||||
aiHelperNote: 'Käyttää OpenAI:n käännösavainta puuttuvien kielten täyttämiseen.',
|
aiHelperNote: 'Käyttää OpenAI:ta puuttuvien tekstien kääntämiseen.',
|
||||||
aiManualLead: 'Jos automaattinen käännös ei toimi, kopioi prompti tekoälylle ja liitä JSON-vastaus alle.',
|
aiManualLead: 'Jos automaattinen käännös ei toimi, kopioi prompti tekoälylle ja liitä JSON-vastaus alle.',
|
||||||
aiPromptLabel: 'Prompti tekoälylle',
|
aiPromptLabel: 'Prompti tekoälylle',
|
||||||
aiCopyPrompt: 'Kopioi prompti',
|
aiCopyPrompt: 'Kopioi prompti',
|
||||||
aiPromptCopied: 'Kopioitu',
|
aiPromptCopied: 'Kopioitu',
|
||||||
aiCopyError: 'Kopiointi epäonnistui',
|
aiCopyError: 'Kopiointi epäonnistui',
|
||||||
aiResponseLabel: 'Liitä tekoälyn vastaus (JSON)',
|
aiResponseLabel: 'Liitä tekoälyn vastaus (JSON)',
|
||||||
aiApply: 'Käytä AI-vastausta',
|
aiApply: 'Käytä AI-vastausta',
|
||||||
aiApplyError: 'Vastausta ei voitu lukea. Varmista, että se on kelvollista JSONia ja sisältää locales-avaimen.',
|
aiApplyError: 'Vastausta ei voitu lukea. Varmista, että se on kelvollista JSONia ja sisältää locales-avaimen.',
|
||||||
|
|
@ -582,7 +582,7 @@ const svMessages: Record<keyof typeof baseMessages.en, string> = {
|
||||||
aiAutoTranslating: 'Översätter…',
|
aiAutoTranslating: 'Översätter…',
|
||||||
aiAutoSuccess: 'Översättningar uppdaterades med AI.',
|
aiAutoSuccess: 'Översättningar uppdaterades med AI.',
|
||||||
aiAutoError: 'AI-översättning misslyckades. Försök igen eller använd manuellt läge nedan.',
|
aiAutoError: 'AI-översättning misslyckades. Försök igen eller använd manuellt läge nedan.',
|
||||||
aiHelperNote: 'Använder OpenAI:s översättningsnyckel för att fylla saknade språk.',
|
aiHelperNote: 'Använder OpenAI för att översätta saknade texter.',
|
||||||
aiManualLead: 'Om autokorrespondens misslyckas, kopiera prompten till din AI och klistra in JSON-svaret nedan.',
|
aiManualLead: 'Om autokorrespondens misslyckas, kopiera prompten till din AI och klistra in JSON-svaret nedan.',
|
||||||
aiPromptLabel: 'Prompt till AI',
|
aiPromptLabel: 'Prompt till AI',
|
||||||
aiCopyPrompt: 'Kopiera prompt',
|
aiCopyPrompt: 'Kopiera prompt',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue