feat(back-end): new translation api with openai
This commit is contained in:
@@ -669,8 +669,30 @@
|
||||
<h3>Contenuti localizzati</h3>
|
||||
<p>
|
||||
Nome obbligatorio in tutte le lingue. Descrizioni opzionali.
|
||||
La traduzione usa la lingua editor come sorgente e compila il
|
||||
form senza salvare.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="ui-button ui-button--ghost"
|
||||
(click)="translateProductFromCurrentLanguage()"
|
||||
[disabled]="!canTranslateProductFromCurrentLanguage()"
|
||||
>
|
||||
{{ translatingProduct ? "Traduco..." : "Traduci" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row toggle-row--compact">
|
||||
<label class="ui-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="overwriteExistingTranslations"
|
||||
name="productOverwriteExistingTranslations"
|
||||
/>
|
||||
<span class="ui-checkbox__mark" aria-hidden="true"></span>
|
||||
<span>Sovrascrivi traduzioni esistenti</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="ui-language-toolbar">
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
AdminShopProductModel,
|
||||
AdminShopProductVariant,
|
||||
AdminShopService,
|
||||
AdminTranslateShopProductPayload,
|
||||
AdminTranslateShopProductResponse,
|
||||
AdminUpsertShopCategoryPayload,
|
||||
AdminUpsertShopProductPayload,
|
||||
AdminUpsertShopProductVariantPayload,
|
||||
@@ -174,6 +176,7 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
||||
loading = false;
|
||||
detailLoading = false;
|
||||
savingProduct = false;
|
||||
translatingProduct = false;
|
||||
deletingProduct = false;
|
||||
savingCategory = false;
|
||||
deletingCategory = false;
|
||||
@@ -188,6 +191,7 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
||||
productStatusFilter: ProductStatusFilter = 'ALL';
|
||||
showCategoryManager = false;
|
||||
activeContentLanguage: ShopLanguage = 'it';
|
||||
overwriteExistingTranslations = false;
|
||||
|
||||
errorMessage: string | null = null;
|
||||
successMessage: string | null = null;
|
||||
@@ -560,6 +564,52 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
||||
this.categoryForm.slug = this.slugify(source);
|
||||
}
|
||||
|
||||
translateProductFromCurrentLanguage(): void {
|
||||
if (this.translatingProduct) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.syncDescriptionFromEditor(this.descriptionEditorElement, true);
|
||||
|
||||
const sourceLanguage = this.activeContentLanguage;
|
||||
if (!this.productForm.names[sourceLanguage].trim()) {
|
||||
this.errorMessage = `Il nome prodotto ${this.languageLabels[sourceLanguage]} e obbligatorio per avviare la traduzione.`;
|
||||
this.successMessage = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = this.buildProductTranslationPayload(sourceLanguage);
|
||||
this.translatingProduct = true;
|
||||
this.errorMessage = null;
|
||||
this.successMessage = null;
|
||||
|
||||
this.adminShopService.translateProduct(payload).subscribe({
|
||||
next: (response) => {
|
||||
this.translatingProduct = false;
|
||||
this.applyProductTranslation(response, payload.overwriteExisting);
|
||||
this.successMessage = response.targetLanguages.length
|
||||
? `Traduzioni ${response.targetLanguages
|
||||
.map((language) => this.languageLabels[language])
|
||||
.join(' / ')} aggiornate nel form.`
|
||||
: 'Nessun campo da tradurre.';
|
||||
},
|
||||
error: (error) => {
|
||||
this.translatingProduct = false;
|
||||
this.errorMessage = this.extractErrorMessage(
|
||||
error,
|
||||
'Traduzione prodotto non riuscita.',
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
canTranslateProductFromCurrentLanguage(): boolean {
|
||||
return (
|
||||
!this.translatingProduct &&
|
||||
!!this.productForm.names[this.activeContentLanguage].trim()
|
||||
);
|
||||
}
|
||||
|
||||
setActiveContentLanguage(language: ShopLanguage): void {
|
||||
this.syncDescriptionFromEditor(this.descriptionEditorElement, true);
|
||||
this.activeContentLanguage = language;
|
||||
@@ -1669,6 +1719,98 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
}
|
||||
|
||||
private buildProductTranslationPayload(
|
||||
sourceLanguage: ShopLanguage,
|
||||
): AdminTranslateShopProductPayload {
|
||||
const materialCodes = Array.from(
|
||||
new Set(
|
||||
this.productForm.materials
|
||||
.map((material) => material.materialCode.trim().toUpperCase())
|
||||
.filter((materialCode) => !!materialCode),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
categoryId: this.productForm.categoryId || undefined,
|
||||
sourceLanguage,
|
||||
overwriteExisting: this.overwriteExistingTranslations,
|
||||
materialCodes,
|
||||
names: { ...this.productForm.names },
|
||||
excerpts: { ...this.productForm.excerpts },
|
||||
descriptions: { ...this.productForm.descriptions },
|
||||
seoTitles: { ...this.productForm.seoTitles },
|
||||
seoDescriptions: { ...this.productForm.seoDescriptions },
|
||||
};
|
||||
}
|
||||
|
||||
private applyProductTranslation(
|
||||
response: AdminTranslateShopProductResponse,
|
||||
overwriteExisting: boolean,
|
||||
): void {
|
||||
for (const language of response.targetLanguages) {
|
||||
this.mergeLocalizedText(
|
||||
this.productForm.names,
|
||||
response.names,
|
||||
language,
|
||||
overwriteExisting,
|
||||
);
|
||||
this.mergeLocalizedText(
|
||||
this.productForm.excerpts,
|
||||
response.excerpts,
|
||||
language,
|
||||
overwriteExisting,
|
||||
);
|
||||
this.mergeLocalizedText(
|
||||
this.productForm.descriptions,
|
||||
response.descriptions,
|
||||
language,
|
||||
overwriteExisting,
|
||||
true,
|
||||
);
|
||||
this.mergeLocalizedText(
|
||||
this.productForm.seoTitles,
|
||||
response.seoTitles,
|
||||
language,
|
||||
overwriteExisting,
|
||||
);
|
||||
this.mergeLocalizedText(
|
||||
this.productForm.seoDescriptions,
|
||||
response.seoDescriptions,
|
||||
language,
|
||||
overwriteExisting,
|
||||
);
|
||||
}
|
||||
|
||||
this.renderActiveDescriptionInEditor();
|
||||
}
|
||||
|
||||
private mergeLocalizedText(
|
||||
target: Record<ShopLanguage, string>,
|
||||
translated:
|
||||
| Partial<Record<ShopLanguage, string>>
|
||||
| Record<ShopLanguage, string>
|
||||
| undefined,
|
||||
language: ShopLanguage,
|
||||
overwriteExisting: boolean,
|
||||
richText = false,
|
||||
): void {
|
||||
const incoming = translated?.[language];
|
||||
if (incoming === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasCurrentValue = richText
|
||||
? this.hasMeaningfulRichText(target[language] ?? '')
|
||||
: !!target[language]?.trim();
|
||||
if (hasCurrentValue && !overwriteExisting) {
|
||||
return;
|
||||
}
|
||||
|
||||
target[language] = richText
|
||||
? this.normalizeDescriptionForEditor(incoming)
|
||||
: incoming.trim();
|
||||
}
|
||||
|
||||
private buildVariantsFromMaterials(): AdminUpsertShopProductVariantPayload[] {
|
||||
const existingVariantsByKey = new Map(
|
||||
(this.selectedProduct?.variants ?? []).map((variant) => [
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
HttpClientTestingModule,
|
||||
HttpTestingController,
|
||||
} from '@angular/common/http/testing';
|
||||
import {
|
||||
AdminShopService,
|
||||
AdminTranslateShopProductPayload,
|
||||
} from './admin-shop.service';
|
||||
|
||||
describe('AdminShopService', () => {
|
||||
let service: AdminShopService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [AdminShopService],
|
||||
});
|
||||
|
||||
service = TestBed.inject(AdminShopService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('posts product translation requests with credentials', () => {
|
||||
const payload: AdminTranslateShopProductPayload = {
|
||||
categoryId: 'category-1',
|
||||
sourceLanguage: 'it',
|
||||
overwriteExisting: false,
|
||||
materialCodes: ['PLA', 'PETG'],
|
||||
names: {
|
||||
it: 'Supporto cavo scrivania',
|
||||
en: '',
|
||||
de: '',
|
||||
fr: '',
|
||||
},
|
||||
excerpts: {
|
||||
it: 'Accessorio tecnico',
|
||||
en: '',
|
||||
de: '',
|
||||
fr: '',
|
||||
},
|
||||
descriptions: {
|
||||
it: '<p>Descrizione prodotto</p>',
|
||||
en: '',
|
||||
de: '',
|
||||
fr: '',
|
||||
},
|
||||
seoTitles: {
|
||||
it: 'Supporto cavo scrivania | 3D fab',
|
||||
en: '',
|
||||
de: '',
|
||||
fr: '',
|
||||
},
|
||||
seoDescriptions: {
|
||||
it: 'Supporto tecnico stampato in 3D per scrivania.',
|
||||
en: '',
|
||||
de: '',
|
||||
fr: '',
|
||||
},
|
||||
};
|
||||
|
||||
service.translateProduct(payload).subscribe((response) => {
|
||||
expect(response.targetLanguages).toEqual(['en', 'de', 'fr']);
|
||||
expect(response.names.en).toBe('Desk cable clip');
|
||||
});
|
||||
|
||||
const request = httpMock.expectOne(
|
||||
'http://localhost:8000/api/admin/shop/products/translate',
|
||||
);
|
||||
expect(request.request.method).toBe('POST');
|
||||
expect(request.request.withCredentials).toBeTrue();
|
||||
expect(request.request.body).toEqual(payload);
|
||||
|
||||
request.flush({
|
||||
sourceLanguage: 'it',
|
||||
targetLanguages: ['en', 'de', 'fr'],
|
||||
names: {
|
||||
en: 'Desk cable clip',
|
||||
de: 'Schreibtisch-Kabelhalter',
|
||||
fr: 'Support de cable de bureau',
|
||||
},
|
||||
excerpts: {},
|
||||
descriptions: {},
|
||||
seoTitles: {},
|
||||
seoDescriptions: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,8 @@ export interface AdminMediaTextTranslation {
|
||||
altText: string;
|
||||
}
|
||||
|
||||
export type AdminShopLanguage = 'it' | 'en' | 'de' | 'fr';
|
||||
|
||||
export interface AdminShopCategoryRef {
|
||||
id: string;
|
||||
slug: string;
|
||||
@@ -255,6 +257,28 @@ export interface AdminUpsertShopProductPayload {
|
||||
variants: AdminUpsertShopProductVariantPayload[];
|
||||
}
|
||||
|
||||
export interface AdminTranslateShopProductPayload {
|
||||
categoryId?: string;
|
||||
sourceLanguage: AdminShopLanguage;
|
||||
overwriteExisting: boolean;
|
||||
materialCodes: string[];
|
||||
names: Record<AdminShopLanguage, string>;
|
||||
excerpts: Record<AdminShopLanguage, string>;
|
||||
descriptions: Record<AdminShopLanguage, string>;
|
||||
seoTitles: Record<AdminShopLanguage, string>;
|
||||
seoDescriptions: Record<AdminShopLanguage, string>;
|
||||
}
|
||||
|
||||
export interface AdminTranslateShopProductResponse {
|
||||
sourceLanguage: AdminShopLanguage;
|
||||
targetLanguages: AdminShopLanguage[];
|
||||
names: Partial<Record<AdminShopLanguage, string>>;
|
||||
excerpts: Partial<Record<AdminShopLanguage, string>>;
|
||||
descriptions: Partial<Record<AdminShopLanguage, string>>;
|
||||
seoTitles: Partial<Record<AdminShopLanguage, string>>;
|
||||
seoDescriptions: Partial<Record<AdminShopLanguage, string>>;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
@@ -351,6 +375,18 @@ export class AdminShopService {
|
||||
});
|
||||
}
|
||||
|
||||
translateProduct(
|
||||
payload: AdminTranslateShopProductPayload,
|
||||
): Observable<AdminTranslateShopProductResponse> {
|
||||
return this.http.post<AdminTranslateShopProductResponse>(
|
||||
`${this.productsBaseUrl}/translate`,
|
||||
payload,
|
||||
{
|
||||
withCredentials: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
uploadProductModel(
|
||||
productId: string,
|
||||
file: File,
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
width: min(100%, 340px);
|
||||
padding: 1rem 1.1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 4px solid var(--swiss-red);
|
||||
border-left: 4px solid var(--color-brand);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
animation: fadeUp 0.85s ease both;
|
||||
|
||||
Reference in New Issue
Block a user