Add multi-language listing editor with AI helper
This commit is contained in:
parent
2525bb850c
commit
7dcc39f36e
4 changed files with 209 additions and 31 deletions
|
|
@ -65,3 +65,4 @@
|
||||||
- Availability calendars: listings can store iCal URLs, merged into a combined availability calendar on detail pages; availability filtering added to search along with amenity filters; new migration `20251127_calendar_urls`.
|
- Availability calendars: listings can store iCal URLs, merged into a combined availability calendar on detail pages; availability filtering added to search along with amenity filters; new migration `20251127_calendar_urls`.
|
||||||
- Browse amenity filters now show the same icons as listing detail; image `registry.halla-aho.net/thalla/lomavuokraus-web:e95d9e0` built/pushed and rolled out to staging.
|
- Browse amenity filters now show the same icons as listing detail; image `registry.halla-aho.net/thalla/lomavuokraus-web:e95d9e0` built/pushed and rolled out to staging.
|
||||||
- Home hero cleaned up (removed sample/browse CTAs), hero FI text updated, and health check link moved to About page runtime section.
|
- Home hero cleaned up (removed sample/browse CTAs), hero FI text updated, and health check link moved to About page runtime section.
|
||||||
|
- Listing creation form now supports editing all locales at once with language tabs, per-locale readiness badges, and an AI JSON helper to translate and apply copy across languages; API accepts multiple translations in one request.
|
||||||
|
|
|
||||||
|
|
@ -184,9 +184,6 @@ export async function POST(req: Request) {
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const slug = String(body.slug ?? '').trim().toLowerCase();
|
const slug = String(body.slug ?? '').trim().toLowerCase();
|
||||||
const locale = String(body.locale ?? 'en').toLowerCase();
|
|
||||||
const title = String(body.title ?? '').trim();
|
|
||||||
const description = String(body.description ?? '').trim();
|
|
||||||
const country = String(body.country ?? '').trim();
|
const country = String(body.country ?? '').trim();
|
||||||
const region = String(body.region ?? '').trim();
|
const region = String(body.region ?? '').trim();
|
||||||
const city = String(body.city ?? '').trim();
|
const city = String(body.city ?? '').trim();
|
||||||
|
|
@ -194,7 +191,7 @@ export async function POST(req: Request) {
|
||||||
const contactName = String(body.contactName ?? '').trim();
|
const contactName = String(body.contactName ?? '').trim();
|
||||||
const contactEmail = String(body.contactEmail ?? '').trim();
|
const contactEmail = String(body.contactEmail ?? '').trim();
|
||||||
|
|
||||||
if (!slug || !title || !description || !country || !region || !city || !contactEmail || !contactName) {
|
if (!slug || !country || !region || !city || !contactEmail || !contactName) {
|
||||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -204,6 +201,36 @@ export async function POST(req: Request) {
|
||||||
const bathrooms = Number(body.bathrooms ?? 1);
|
const bathrooms = Number(body.bathrooms ?? 1);
|
||||||
const priceHintPerNightEuros = body.priceHintPerNightEuros !== undefined && body.priceHintPerNightEuros !== null && body.priceHintPerNightEuros !== '' ? Math.round(Number(body.priceHintPerNightEuros)) : null;
|
const priceHintPerNightEuros = body.priceHintPerNightEuros !== undefined && body.priceHintPerNightEuros !== null && body.priceHintPerNightEuros !== '' ? Math.round(Number(body.priceHintPerNightEuros)) : null;
|
||||||
const calendarUrls = normalizeCalendarUrls(body.calendarUrls);
|
const calendarUrls = normalizeCalendarUrls(body.calendarUrls);
|
||||||
|
const translationsInputRaw = Array.isArray(body.translations) ? body.translations : [];
|
||||||
|
const translationsInput =
|
||||||
|
translationsInputRaw
|
||||||
|
.map((item: any) => ({
|
||||||
|
locale: String(item.locale ?? '').toLowerCase(),
|
||||||
|
title: typeof item.title === 'string' ? item.title.trim() : '',
|
||||||
|
description: typeof item.description === 'string' ? item.description.trim() : '',
|
||||||
|
teaser: typeof item.teaser === 'string' ? item.teaser.trim() : null,
|
||||||
|
slug: String(item.slug ?? slug).trim().toLowerCase(),
|
||||||
|
}))
|
||||||
|
.filter((t: any) => t.locale && t.title && t.description) || [];
|
||||||
|
|
||||||
|
const fallbackLocale = String(body.locale ?? 'en').toLowerCase();
|
||||||
|
const fallbackTranslationTitle = typeof body.title === 'string' ? body.title.trim() : '';
|
||||||
|
const fallbackTranslationDescription = typeof body.description === 'string' ? body.description.trim() : '';
|
||||||
|
const fallbackTranslationTeaser = typeof body.teaser === 'string' ? body.teaser.trim() : null;
|
||||||
|
|
||||||
|
if (translationsInput.length === 0 && fallbackTranslationTitle && fallbackTranslationDescription) {
|
||||||
|
translationsInput.push({
|
||||||
|
locale: fallbackLocale,
|
||||||
|
title: fallbackTranslationTitle,
|
||||||
|
description: fallbackTranslationDescription,
|
||||||
|
teaser: fallbackTranslationTeaser,
|
||||||
|
slug,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!translationsInput.length) {
|
||||||
|
return NextResponse.json({ error: 'Missing translation fields (title/description)' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
const images = Array.isArray(body.images) ? body.images.slice(0, MAX_IMAGES) : [];
|
const images = Array.isArray(body.images) ? body.images.slice(0, MAX_IMAGES) : [];
|
||||||
if (Array.isArray(body.images) && body.images.length > MAX_IMAGES) {
|
if (Array.isArray(body.images) && body.images.length > MAX_IMAGES) {
|
||||||
|
|
@ -305,13 +332,13 @@ export async function POST(req: Request) {
|
||||||
published: status === ListingStatus.PUBLISHED,
|
published: status === ListingStatus.PUBLISHED,
|
||||||
isSample,
|
isSample,
|
||||||
translations: {
|
translations: {
|
||||||
create: {
|
create: translationsInput.map((t) => ({
|
||||||
locale,
|
locale: t.locale,
|
||||||
slug,
|
slug: t.slug || slug,
|
||||||
title,
|
title: t.title,
|
||||||
description,
|
description: t.description,
|
||||||
teaser: body.teaser ?? null,
|
teaser: t.teaser ?? null,
|
||||||
},
|
})),
|
||||||
},
|
},
|
||||||
images: parsedImages.length
|
images: parsedImages.length
|
||||||
? {
|
? {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useI18n } from '../../components/I18nProvider';
|
import { useI18n } from '../../components/I18nProvider';
|
||||||
import type { Locale } from '../../../lib/i18n';
|
import type { Locale } from '../../../lib/i18n';
|
||||||
|
|
||||||
|
|
@ -14,14 +14,17 @@ type SelectedImage = {
|
||||||
|
|
||||||
const MAX_IMAGES = 6;
|
const MAX_IMAGES = 6;
|
||||||
const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5 MB per image
|
const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5 MB per image
|
||||||
|
const SUPPORTED_LOCALES: Locale[] = ['en', 'fi'];
|
||||||
|
type LocaleFields = { title: string; description: string; teaser: string };
|
||||||
|
|
||||||
export default function NewListingPage() {
|
export default function NewListingPage() {
|
||||||
const { t, locale: uiLocale } = useI18n();
|
const { t, locale: uiLocale } = useI18n();
|
||||||
const [slug, setSlug] = useState('');
|
const [slug, setSlug] = useState('');
|
||||||
const [locale, setLocale] = useState(uiLocale);
|
const [currentLocale, setCurrentLocale] = useState<Locale>(uiLocale as Locale);
|
||||||
const [title, setTitle] = useState('');
|
const [translations, setTranslations] = useState<Record<Locale, LocaleFields>>({
|
||||||
const [description, setDescription] = useState('');
|
en: { title: '', description: '', teaser: '' },
|
||||||
const [teaser, setTeaser] = useState('');
|
fi: { title: '', description: '', teaser: '' },
|
||||||
|
});
|
||||||
const [country, setCountry] = useState('Finland');
|
const [country, setCountry] = useState('Finland');
|
||||||
const [region, setRegion] = useState('');
|
const [region, setRegion] = useState('');
|
||||||
const [city, setCity] = useState('');
|
const [city, setCity] = useState('');
|
||||||
|
|
@ -54,9 +57,10 @@ export default function NewListingPage() {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [isAuthed, setIsAuthed] = useState(false);
|
const [isAuthed, setIsAuthed] = useState(false);
|
||||||
|
const [aiResponse, setAiResponse] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocale(uiLocale);
|
setCurrentLocale(uiLocale as Locale);
|
||||||
}, [uiLocale]);
|
}, [uiLocale]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -122,6 +126,71 @@ export default function NewListingPage() {
|
||||||
{ key: 'barbecue', label: t('amenityBarbecue'), icon: '🍖', checked: hasBarbecue, toggle: setHasBarbecue },
|
{ key: 'barbecue', label: t('amenityBarbecue'), icon: '🍖', checked: hasBarbecue, toggle: setHasBarbecue },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function updateTranslation(locale: Locale, field: keyof LocaleFields, value: string) {
|
||||||
|
setTranslations((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[locale]: { ...prev[locale], [field]: value },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function localeStatus(locale: Locale) {
|
||||||
|
const { title, description } = translations[locale];
|
||||||
|
if (!title && !description) return 'missing';
|
||||||
|
if (title && description) return 'ready';
|
||||||
|
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.',
|
||||||
|
],
|
||||||
|
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<Locale, LocaleFields>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
return JSON.stringify(payload, null, 2);
|
||||||
|
}, [translations, currentLocale]);
|
||||||
|
|
||||||
|
function applyAiResponse() {
|
||||||
|
setMessage(null);
|
||||||
|
setError(null);
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setTranslations(next);
|
||||||
|
setMessage(t('aiApplySuccess'));
|
||||||
|
} catch (err) {
|
||||||
|
setError(t('aiApplyError'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseImages(): ImageInput[] {
|
function parseImages(): ImageInput[] {
|
||||||
return selectedImages.map((img) => ({
|
return selectedImages.map((img) => ({
|
||||||
data: img.dataUrl,
|
data: img.dataUrl,
|
||||||
|
|
@ -136,6 +205,19 @@ export default function NewListingPage() {
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
const translationEntries = SUPPORTED_LOCALES.map((loc) => ({
|
||||||
|
locale: loc,
|
||||||
|
title: translations[loc].title.trim(),
|
||||||
|
description: translations[loc].description.trim(),
|
||||||
|
teaser: translations[loc].teaser.trim(),
|
||||||
|
})).filter((t) => t.title && t.description);
|
||||||
|
|
||||||
|
if (translationEntries.length === 0) {
|
||||||
|
setError(t('translationMissing'));
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedImages.length === 0) {
|
if (selectedImages.length === 0) {
|
||||||
setError(t('imagesRequired'));
|
setError(t('imagesRequired'));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -147,10 +229,11 @@ export default function NewListingPage() {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
slug,
|
slug,
|
||||||
locale,
|
translations: translationEntries.map((t) => ({
|
||||||
title,
|
...t,
|
||||||
description,
|
slug,
|
||||||
teaser,
|
teaser: t.teaser || null,
|
||||||
|
})),
|
||||||
country,
|
country,
|
||||||
region,
|
region,
|
||||||
city,
|
city,
|
||||||
|
|
@ -187,9 +270,10 @@ export default function NewListingPage() {
|
||||||
} else {
|
} else {
|
||||||
setMessage(t('createListingSuccess', { id: data.listing.id, status: data.listing.status }));
|
setMessage(t('createListingSuccess', { id: data.listing.id, status: data.listing.status }));
|
||||||
setSlug('');
|
setSlug('');
|
||||||
setTitle('');
|
setTranslations({
|
||||||
setDescription('');
|
en: { title: '', description: '', teaser: '' },
|
||||||
setTeaser('');
|
fi: { title: '', description: '', teaser: '' },
|
||||||
|
});
|
||||||
setRegion('');
|
setRegion('');
|
||||||
setCity('');
|
setCity('');
|
||||||
setStreetAddress('');
|
setStreetAddress('');
|
||||||
|
|
@ -201,6 +285,7 @@ export default function NewListingPage() {
|
||||||
setCalendarUrls('');
|
setCalendarUrls('');
|
||||||
setSelectedImages([]);
|
setSelectedImages([]);
|
||||||
setCoverImageIndex(1);
|
setCoverImageIndex(1);
|
||||||
|
setAiResponse('');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to create listing');
|
setError('Failed to create listing');
|
||||||
|
|
@ -226,22 +311,59 @@ export default function NewListingPage() {
|
||||||
{t('slugLabel')}
|
{t('slugLabel')}
|
||||||
<input value={slug} onChange={(e) => setSlug(e.target.value)} required />
|
<input value={slug} onChange={(e) => setSlug(e.target.value)} required />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<div>
|
||||||
{t('localeInput')}
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginBottom: 6 }}>
|
||||||
<input value={locale} onChange={(e) => setLocale(e.target.value as Locale)} required />
|
<strong>{t('languageTabsLabel')}</strong>
|
||||||
</label>
|
<span style={{ color: '#cbd5e1', fontSize: 13 }}>{t('languageTabsHint')}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
{SUPPORTED_LOCALES.map((loc) => {
|
||||||
|
const status = localeStatus(loc);
|
||||||
|
const badge =
|
||||||
|
status === 'ready' ? t('localeReady') : status === 'partial' ? t('localePartial') : t('localeMissing');
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={loc}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentLocale(loc)}
|
||||||
|
className={`button ${currentLocale === loc ? '' : 'secondary'}`}
|
||||||
|
>
|
||||||
|
{loc.toUpperCase()} · {badge}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label>
|
<label>
|
||||||
{t('titleLabel')}
|
{t('titleLabel')}
|
||||||
<input value={title} onChange={(e) => setTitle(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={description} onChange={(e) => setDescription(e.target.value)} required rows={4} />
|
<textarea value={translations[currentLocale].description} onChange={(e) => updateTranslation(currentLocale, 'description', e.target.value)} required rows={4} />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t('teaserLabel')}
|
{t('teaserLabel')}
|
||||||
<input value={teaser} onChange={(e) => setTeaser(e.target.value)} />
|
<input value={translations[currentLocale].teaser} onChange={(e) => updateTranslation(currentLocale, 'teaser', e.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
|
<div className="panel" style={{ border: '1px solid rgba(148,163,184,0.3)', background: 'rgba(255,255,255,0.02)' }}>
|
||||||
|
<h3 style={{ marginTop: 0 }}>{t('aiHelperTitle')}</h3>
|
||||||
|
<p style={{ color: '#cbd5e1', marginTop: 4 }}>{t('aiHelperLead')}</p>
|
||||||
|
<label style={{ display: 'grid', gap: 6, marginTop: 8 }}>
|
||||||
|
<span>{t('aiPromptLabel')}</span>
|
||||||
|
<textarea value={aiPrompt} readOnly rows={10} style={{ fontFamily: 'monospace' }} />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'grid', gap: 6, marginTop: 8 }}>
|
||||||
|
<span>{t('aiResponseLabel')}</span>
|
||||||
|
<textarea value={aiResponse} onChange={(e) => setAiResponse(e.target.value)} rows={6} placeholder="{\"locales\":{\"fi\":{\"title\":\"...\"}}}" style={{ fontFamily: 'monospace' }} />
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: 10, marginTop: 8, flexWrap: 'wrap' }}>
|
||||||
|
<button type="button" className="button secondary" onClick={() => applyAiResponse()}>
|
||||||
|
{t('aiApply')}
|
||||||
|
</button>
|
||||||
|
<span style={{ color: '#cbd5e1', fontSize: 13 }}>{t('aiHelperNote')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
|
<div style={{ display: 'grid', gap: 8, gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
|
||||||
<label>
|
<label>
|
||||||
{t('countryLabel')}
|
{t('countryLabel')}
|
||||||
|
|
|
||||||
28
lib/i18n.ts
28
lib/i18n.ts
|
|
@ -135,6 +135,20 @@ const allMessages = {
|
||||||
localeLabel: 'Locale',
|
localeLabel: 'Locale',
|
||||||
homeCrumb: 'Home',
|
homeCrumb: 'Home',
|
||||||
createListingTitle: 'Create listing',
|
createListingTitle: 'Create listing',
|
||||||
|
languageTabsLabel: 'Listing languages',
|
||||||
|
languageTabsHint: 'Add translations for each supported language',
|
||||||
|
localeReady: 'Ready',
|
||||||
|
localePartial: 'In progress',
|
||||||
|
localeMissing: 'Missing',
|
||||||
|
aiHelperTitle: 'AI translation helper',
|
||||||
|
aiHelperLead: 'Copy the prompt to your AI assistant, let it translate missing locales, and paste the JSON reply back.',
|
||||||
|
aiPromptLabel: 'Prompt to send to AI',
|
||||||
|
aiResponseLabel: 'Paste AI response (JSON)',
|
||||||
|
aiApply: 'Apply AI response',
|
||||||
|
aiApplyError: 'Could not read AI response. Please ensure it is valid JSON with a locales object.',
|
||||||
|
aiApplySuccess: 'Translations updated from AI response.',
|
||||||
|
aiHelperNote: 'The AI should return only JSON with the same keys.',
|
||||||
|
translationMissing: 'Add at least one language with a title and description.',
|
||||||
loginToCreate: 'Please log in first to create a listing.',
|
loginToCreate: 'Please log in first to create a listing.',
|
||||||
slugLabel: 'Slug',
|
slugLabel: 'Slug',
|
||||||
localeInput: 'Locale',
|
localeInput: 'Locale',
|
||||||
|
|
@ -264,6 +278,20 @@ const allMessages = {
|
||||||
heroEyebrow: 'lomavuokraus.fi',
|
heroEyebrow: 'lomavuokraus.fi',
|
||||||
heroTitle: 'Löydä seuraava mökkilomasi',
|
heroTitle: 'Löydä seuraava mökkilomasi',
|
||||||
heroBody: 'Selaa suomalaisten mökkien, huoneistojen ja villojen ilmoituksia suoraan omistajilta. Jokainen ilmoitus tarkistetaan ennen julkaisua, ja otat vuokranantajaan yhteyttä suoraan — yksinkertaista ja läpinäkyvää.',
|
heroBody: 'Selaa suomalaisten mökkien, huoneistojen ja villojen ilmoituksia suoraan omistajilta. Jokainen ilmoitus tarkistetaan ennen julkaisua, ja otat vuokranantajaan yhteyttä suoraan — yksinkertaista ja läpinäkyvää.',
|
||||||
|
languageTabsLabel: 'Ilmoituksen kielet',
|
||||||
|
languageTabsHint: 'Lisää käännökset kaikille tuetuille kielille',
|
||||||
|
localeReady: 'Valmis',
|
||||||
|
localePartial: 'Kesken',
|
||||||
|
localeMissing: 'Puuttuu',
|
||||||
|
aiHelperTitle: 'AI-käännösapu',
|
||||||
|
aiHelperLead: 'Kopioi prompti tekoälylle, käännä puuttuvat kielet ja liitä JSON-vastaus takaisin.',
|
||||||
|
aiPromptLabel: 'Prompti tekoälylle',
|
||||||
|
aiResponseLabel: 'Liitä tekoälyn vastaus (JSON)',
|
||||||
|
aiApply: 'Käytä AI-vastausta',
|
||||||
|
aiApplyError: 'Vastausta ei voitu lukea. Varmista, että se on kelvollista JSONia ja sisältää locales-avaimen.',
|
||||||
|
aiApplySuccess: 'Käännökset päivitetty AI-vastauksesta.',
|
||||||
|
aiHelperNote: 'Tekoälyn tulisi palauttaa vain samaa avainrakennetta noudattava JSON.',
|
||||||
|
translationMissing: 'Täytä vähintään yhden kielen otsikko ja kuvaus.',
|
||||||
ctaViewSample: 'Katso esimerkkikohde',
|
ctaViewSample: 'Katso esimerkkikohde',
|
||||||
ctaHealth: 'Tarkista health-päätepiste',
|
ctaHealth: 'Tarkista health-päätepiste',
|
||||||
ctaBrowse: 'Selaa kohteita',
|
ctaBrowse: 'Selaa kohteita',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue