"use client" import * as React from 'react' import type { InjectionWidgetComponentProps } from '@open-mercato/shared/modules/widgets/injection' import { useT } from '@open-mercato/shared/lib/i18n/context' import { useAppEvent } from '@open-mercato/ui/backend/injection/useAppEvent' import { apiCall } from '@open-mercato/ui/backend/utils/apiCall' import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog' import { flash } from '@open-mercato/ui/backend/FlashMessages' import { NextStepCallout } from '@open-mercato/ui/backend/NextStepCallout' import { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives/alert' import { Badge } from '@open-mercato/ui/primitives/badge' import { Button } from '@open-mercato/ui/primitives/button' import { Progress } from '@open-mercato/ui/primitives/progress' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@open-mercato/ui/primitives/dialog' import { Input } from '@open-mercato/ui/primitives/input' import { Label } from '@open-mercato/ui/primitives/label' import { AlertCircle, CheckCircle2, PencilLine, Plus, RefreshCw, Save, Sparkles, Trash2, Wand2, X } from 'lucide-react' import { buildAkeneoFieldsetCode, buildDefaultAkeneoMapping, buildProductFieldMappings, dedupeStrings, normalizeAkeneoMapping, type AkeneoReconciliationSettings } from '../../../lib/shared' import { inferAkeneoProductMapping } from '../../../lib/inference' import type { AkeneoDiscoveryResponse } from '../../../data/validators' type MappingRecordResponse = { items?: Array<{ id: string mapping: Record }> } type CustomFieldStatusResponse = { ok: boolean productKeys: string[] variantKeys: string[] createdKeys?: string[] message?: string } type DeleteImportedProductsResponse = { ok: boolean progressJobId: string | null message: string } type DeleteImportedProductsJobResponse = { id: string status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' progressPercent: number processedCount: number totalCount?: number | null resultSummary?: Record | null errorMessage?: string | null } type FirstImportSequenceResponse = { ok: boolean hasCompletedProductImport: boolean sequence: { progressJobId: string status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' currentStep: FirstImportStepKey | null currentRunId: string | null currentRunProgressJobId: string | null currentRunStatus: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | null progressPercent: number | null processedCount: number | null totalCount: number | null errorMessage: string | null } | null } type FirstImportStepKey = 'categories' | 'attributes' | 'products' type ProgressEventPayload = { jobId?: string jobType?: string status?: string progressPercent?: number processedCount?: number totalCount?: number | null meta?: Record | null errorMessage?: string | null resultSummary?: Record | null } type FirstImportProgressState = { phase: 'idle' | 'running' | 'completed' | 'failed' progressJobId: string | null step: FirstImportStepKey | null runId: string | null runProgressJobId: string | null progressPercent: number | null processedCount: number | null totalCount: number | null errorMessage: string | null } type CustomFieldRow = { attributeCode: string target: 'product' | 'variant' fieldKey: string kind: '' | 'text' | 'multiline' | 'integer' | 'float' | 'boolean' | 'select' skip: boolean } type PriceMappingRow = { attributeCode: string priceKindCode: string akeneoChannel: string localChannelCode: string } type MediaMappingRow = { attributeCode: string target: 'product' | 'variant' kind: 'image' | 'file' } type FieldsetMappingRow = { sourceType: 'family' | 'familyVariant' sourceCode: string target: 'product' | 'variant' fieldsetCode: string fieldsetLabel: string description: string } type AkeneoWidgetContext = { state?: { isEnabled?: boolean } | null } type AkeneoWidgetData = { hasCredentials?: boolean } type FormState = { productLocale: string productChannel: string productChannels: string[] importAllProductChannels: boolean categoryLocale: string includeTextAttributes: boolean includeNumericAttributes: boolean familyCodeFilter: string customFieldMappings: string priceMappings: string mediaMappings: string fieldsetMappings: string createMissingChannels: boolean syncAssociations: boolean reconciliation: AkeneoReconciliationSettings fieldMap: { title: string subtitle: string description: string sku: string barcode: string weight: string variantName: string } } function readDeleteImportedProductsSummaryMessage(summary: Record | null | undefined): string | null { if (!summary || typeof summary !== 'object') return null const message = summary.message if (typeof message !== 'string') return null const trimmed = message.trim() return trimmed.length > 0 ? trimmed : null } function readProgressMetaString(meta: Record | null | undefined, key: string): string | null { if (!meta || typeof meta[key] !== 'string') return null const value = String(meta[key]).trim() return value.length > 0 ? value : null } function readProgressMetaNumber(meta: Record | null | undefined, key: string): number | null { const value = meta?.[key] return typeof value === 'number' && Number.isFinite(value) ? value : null } const PRODUCT_FIELD_KEYS: Array<{ key: keyof FormState['fieldMap'] label: string help: string }> = [ { key: 'title', label: 'Title attribute', help: 'Used for the Open Mercato product title.' }, { key: 'subtitle', label: 'Subtitle attribute', help: 'Optional secondary title field.' }, { key: 'description', label: 'Description attribute', help: 'Used for the product description.' }, { key: 'sku', label: 'SKU attribute', help: 'Used for both simple-product and variant SKU matching.' }, { key: 'barcode', label: 'Barcode attribute', help: 'Optional barcode/EAN source.' }, { key: 'weight', label: 'Weight attribute', help: 'Metric attribute used for product and default-variant weight.' }, { key: 'variantName', label: 'Variant label attribute', help: 'Fallback label for generated variant names.' }, ] function buildInitialState(): FormState { const products = buildDefaultAkeneoMapping('products') const categories = buildDefaultAkeneoMapping('categories') const attributes = buildDefaultAkeneoMapping('attributes') const productSettings = products.settings?.products return { productLocale: productSettings?.locale ?? 'en_US', productChannel: productSettings?.channel ?? '', productChannels: productSettings?.channels ?? [], importAllProductChannels: productSettings?.importAllChannels ?? true, categoryLocale: categories.settings?.categories?.locale ?? 'en_US', includeTextAttributes: attributes.settings?.attributes?.includeTextAttributes ?? true, includeNumericAttributes: attributes.settings?.attributes?.includeNumericAttributes ?? true, familyCodeFilter: '', customFieldMappings: '', priceMappings: serializePriceMappingRows( (productSettings?.priceMappings ?? []).map((entry) => ({ attributeCode: entry.attributeCode, priceKindCode: entry.priceKindCode, akeneoChannel: entry.akeneoChannel ?? '', localChannelCode: entry.localChannelCode, })), ), mediaMappings: serializeMediaMappingRows( (productSettings?.mediaMappings ?? []).map((entry) => ({ attributeCode: entry.attributeCode, target: entry.target, kind: entry.kind, })), ), fieldsetMappings: serializeFieldsetMappingRows( (productSettings?.fieldsetMappings ?? []).map((entry) => ({ sourceType: entry.sourceType, sourceCode: entry.sourceCode, target: entry.target, fieldsetCode: entry.fieldsetCode, fieldsetLabel: entry.fieldsetLabel, description: entry.description ?? '', })), ), createMissingChannels: productSettings?.createMissingChannels ?? true, syncAssociations: productSettings?.syncAssociations ?? true, reconciliation: productSettings?.reconciliation ?? { deactivateMissingCategories: true, deactivateMissingProducts: true, deactivateMissingAttributes: true, deleteMissingOffers: true, deleteMissingPrices: true, deleteMissingMedia: true, deleteMissingAttachments: true, }, fieldMap: { title: productSettings?.fieldMap.title ?? 'name', subtitle: productSettings?.fieldMap.subtitle ?? 'subtitle', description: productSettings?.fieldMap.description ?? 'description', sku: productSettings?.fieldMap.sku ?? 'sku', barcode: productSettings?.fieldMap.barcode ?? 'ean', weight: productSettings?.fieldMap.weight ?? 'weight', variantName: productSettings?.fieldMap.variantName ?? 'name', }, } } function mergeMappingsIntoState( state: FormState, productsMapping: Record | undefined, categoriesMapping: Record | undefined, attributesMapping: Record | undefined, ): FormState { const normalizedProducts = normalizeAkeneoMapping('products', productsMapping ?? null) const normalizedCategories = normalizeAkeneoMapping('categories', categoriesMapping ?? null) const normalizedAttributes = normalizeAkeneoMapping('attributes', attributesMapping ?? null) return { ...state, productLocale: normalizedProducts.settings?.products?.locale ?? state.productLocale, productChannel: normalizedProducts.settings?.products?.channel ?? '', productChannels: normalizedProducts.settings?.products?.channels ?? state.productChannels, importAllProductChannels: normalizedProducts.settings?.products?.importAllChannels ?? state.importAllProductChannels, categoryLocale: normalizedCategories.settings?.categories?.locale ?? state.categoryLocale, includeTextAttributes: normalizedAttributes.settings?.attributes?.includeTextAttributes ?? state.includeTextAttributes, includeNumericAttributes: normalizedAttributes.settings?.attributes?.includeNumericAttributes ?? state.includeNumericAttributes, familyCodeFilter: (normalizedAttributes.settings?.attributes?.familyCodeFilter ?? []).join(', '), customFieldMappings: (normalizedProducts.settings?.products?.customFieldMappings ?? []) .map((entry) => `${entry.attributeCode},${entry.target},${entry.fieldKey},${entry.kind ?? ''},${entry.skip === true ? 'skip' : ''}`) .join('\n'), priceMappings: (normalizedProducts.settings?.products?.priceMappings ?? []) .map((entry) => `${entry.attributeCode},${entry.priceKindCode},${entry.akeneoChannel ?? ''},${entry.localChannelCode}`) .join('\n'), mediaMappings: (normalizedProducts.settings?.products?.mediaMappings ?? []) .map((entry) => `${entry.attributeCode},${entry.target},${entry.kind}`) .join('\n'), fieldsetMappings: (normalizedProducts.settings?.products?.fieldsetMappings ?? []) .map((entry) => `${entry.sourceType},${entry.sourceCode},${entry.target},${entry.fieldsetCode},${entry.fieldsetLabel}${entry.description ? `,${entry.description}` : ''}`) .join('\n'), createMissingChannels: normalizedProducts.settings?.products?.createMissingChannels ?? state.createMissingChannels, syncAssociations: normalizedProducts.settings?.products?.syncAssociations ?? state.syncAssociations, reconciliation: normalizedProducts.settings?.products?.reconciliation ?? state.reconciliation, fieldMap: { title: normalizedProducts.settings?.products?.fieldMap.title ?? state.fieldMap.title, subtitle: normalizedProducts.settings?.products?.fieldMap.subtitle ?? state.fieldMap.subtitle, description: normalizedProducts.settings?.products?.fieldMap.description ?? state.fieldMap.description, sku: normalizedProducts.settings?.products?.fieldMap.sku ?? state.fieldMap.sku, barcode: normalizedProducts.settings?.products?.fieldMap.barcode ?? state.fieldMap.barcode, weight: normalizedProducts.settings?.products?.fieldMap.weight ?? state.fieldMap.weight, variantName: normalizedProducts.settings?.products?.fieldMap.variantName ?? state.fieldMap.variantName, }, } } function parseRows(value: string): string[][] { return value .split('\n') .map((row) => row.trim()) .filter((row) => row.length > 0) .map((row) => row.split(',').map((cell) => cell.trim())) } function parseCustomFieldRows(value: string): CustomFieldRow[] { return parseRows(value) .map(([attributeCode, target, fieldKey, kind, skipFlag]) => { const normalizedTarget: CustomFieldRow['target'] = target === 'variant' ? 'variant' : 'product' const normalizedKind: CustomFieldRow['kind'] = kind === 'text' || kind === 'multiline' || kind === 'integer' || kind === 'float' || kind === 'boolean' || kind === 'select' ? kind : '' return { attributeCode, target: normalizedTarget, fieldKey, kind: normalizedKind, skip: skipFlag === 'skip' || skipFlag === 'true', } }) .filter((row) => row.attributeCode.length > 0 || row.fieldKey.length > 0) } function serializeCustomFieldRows(rows: CustomFieldRow[]): string { return rows .filter((row) => row.attributeCode.trim().length > 0 && (row.fieldKey.trim().length > 0 || row.skip)) .map((row) => [ row.attributeCode.trim(), row.target, row.fieldKey.trim() || normalizeFieldKey(row.attributeCode), row.kind, row.skip ? 'skip' : '', ].filter((cell, index) => index < 4 || cell.length > 0).join(',')) .join('\n') } function mergeCustomFieldRows(existingRows: CustomFieldRow[], discoveredRows: CustomFieldRow[]): CustomFieldRow[] { const merged = [...existingRows] const existingAttributeCodes = new Set( existingRows.map((row) => row.attributeCode.trim()).filter((value) => value.length > 0), ) for (const row of discoveredRows) { const attributeCode = row.attributeCode.trim() if (!attributeCode || existingAttributeCodes.has(attributeCode)) continue merged.push(row) existingAttributeCodes.add(attributeCode) } return merged } function parsePriceMappingRows(value: string): PriceMappingRow[] { return parseRows(value) .map(([attributeCode, priceKindCode, akeneoChannel, localChannelCode]) => ({ attributeCode, priceKindCode, akeneoChannel: akeneoChannel ?? '', localChannelCode, })) } function serializePriceMappingRows(rows: PriceMappingRow[]): string { return rows .map((row) => [ row.attributeCode.trim(), row.priceKindCode.trim(), row.akeneoChannel.trim(), row.localChannelCode.trim(), ].join(',')) .join('\n') } function parseMediaMappingRows(value: string): MediaMappingRow[] { return parseRows(value) .map(([attributeCode, target, kind]) => ({ attributeCode, target: target === 'variant' ? 'variant' : 'product', kind: kind === 'file' ? 'file' : 'image', })) } function serializeMediaMappingRows(rows: MediaMappingRow[]): string { return rows .map((row) => `${row.attributeCode.trim()},${row.target},${row.kind}`) .join('\n') } function parseFieldsetMappingRows(value: string): FieldsetMappingRow[] { return parseRows(value) .map(([sourceType, sourceCode, target, fieldsetCode, fieldsetLabel, ...descriptionParts]): FieldsetMappingRow => ({ sourceType: sourceType === 'familyVariant' ? 'familyVariant' : 'family', sourceCode: sourceCode ?? '', target: target === 'variant' ? 'variant' : 'product', fieldsetCode: fieldsetCode ?? '', fieldsetLabel: fieldsetLabel ?? '', description: descriptionParts.join(','), })) .filter((row) => row.sourceCode.trim().length > 0 || row.fieldsetCode.trim().length > 0 || row.fieldsetLabel.trim().length > 0) } function serializeFieldsetMappingRows(rows: FieldsetMappingRow[]): string { return rows .filter((row) => row.sourceCode.trim().length > 0 && row.fieldsetCode.trim().length > 0 && row.fieldsetLabel.trim().length > 0) .map((row) => [ row.sourceType, row.sourceCode.trim(), row.target, row.fieldsetCode.trim(), row.fieldsetLabel.trim(), row.description.trim(), ].filter((cell, index) => index < 5 || cell.length > 0).join(',')) .join('\n') } function normalizeFieldKey(value: string): string { return value .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '_') .replace(/^_+|_+$/g, '') .slice(0, 100) } function inferCustomFieldKind(attributeType: string): CustomFieldRow['kind'] { if (attributeType === 'pim_catalog_boolean') return 'boolean' if (attributeType === 'pim_catalog_number') return 'float' if (attributeType === 'pim_catalog_metric') return 'float' if (attributeType === 'pim_catalog_textarea') return 'multiline' if ( attributeType === 'pim_catalog_simpleselect' || attributeType === 'pim_catalog_multiselect' || attributeType === 'akeneo_reference_entity' || attributeType === 'akeneo_reference_entity_collection' ) { return 'select' } return 'text' } function inferPriceKindCode(attributeCode: string): string { const normalized = attributeCode.trim().toLowerCase() return normalized.includes('sale') || normalized.includes('promo') || normalized.includes('special') || normalized.includes('discount') ? 'sale' : 'regular' } function buildSelectValues(values: Array, currentValue?: string | null): string[] { return dedupeStrings([ ...values, currentValue ?? null, ]) } function buildDiscoveredFieldsetMappings(discovery: AkeneoDiscoveryResponse): FieldsetMappingRow[] { const familyRows = (discovery.families ?? []).flatMap((family) => { const productFieldsetCode = buildAkeneoFieldsetCode('product', family.code) return productFieldsetCode ? [{ sourceType: 'family' as const, sourceCode: family.code, target: 'product' as const, fieldsetCode: productFieldsetCode, fieldsetLabel: family.label || family.code, description: `Akeneo family ${family.code}`, }] : [] }) const familyVariantRows = (discovery.familyVariants ?? []).flatMap((familyVariant) => { const variantFieldsetCode = buildAkeneoFieldsetCode('variant', familyVariant.code) return variantFieldsetCode ? [{ sourceType: 'familyVariant' as const, sourceCode: familyVariant.code, target: 'variant' as const, fieldsetCode: variantFieldsetCode, fieldsetLabel: familyVariant.label || familyVariant.code, description: `Akeneo family variant ${familyVariant.code} from family ${familyVariant.familyCode}`, }] : [] }) return [...familyRows, ...familyVariantRows] } function applyDiscoveryDefaults( state: FormState, discovery: AkeneoDiscoveryResponse | null, ): FormState { if (!discovery?.ok) return state const discoveredFamilyVariant = (discovery.familyVariants ?? []).length > 0 ? { code: '__discovery__', variant_attribute_sets: (discovery.familyVariants ?? []).map((familyVariant, index) => ({ level: index + 1, axes: familyVariant.axes, attributes: familyVariant.attributes, })), } : null const inferred = inferAkeneoProductMapping({ attributes: (discovery.attributes ?? []).map((attribute) => ({ code: attribute.code, type: attribute.type, labels: attribute.label ? { inferred: attribute.label } : undefined, localizable: attribute.localizable, scopable: attribute.scopable, group: attribute.group, metric_family: attribute.metricFamily, })), family: null, familyVariant: discoveredFamilyVariant, fieldMap: state.fieldMap, explicitCustomFieldMappings: parseCustomFieldRows(state.customFieldMappings).map((row) => ({ attributeCode: row.attributeCode.trim(), target: row.target, fieldKey: row.fieldKey.trim() || normalizeFieldKey(row.attributeCode), kind: row.kind || null, skip: row.skip, })), explicitMediaMappings: parseMediaMappingRows(state.mediaMappings).map((row) => ({ attributeCode: row.attributeCode.trim(), target: row.target, kind: row.kind, })), }) const hasCustomFields = parseCustomFieldRows(state.customFieldMappings).length > 0 const hasMediaMappings = parseMediaMappingRows(state.mediaMappings).length > 0 const hasPriceMappings = parsePriceMappingRows(state.priceMappings).some((row) => row.attributeCode.trim().length > 0) const hasFieldsetMappings = parseFieldsetMappingRows(state.fieldsetMappings).length > 0 const currentCustomFieldRows = parseCustomFieldRows(state.customFieldMappings) const discoveredCustomFieldRows = inferred.autoCustomFieldMappings.map((mapping) => ({ attributeCode: mapping.attributeCode, target: mapping.target, fieldKey: mapping.fieldKey, kind: mapping.kind ?? '', skip: false, }) satisfies CustomFieldRow) const preferredLocalChannel = discovery.localChannels.find((channel) => ['web', 'online', 'ecommerce', 'default'].includes(channel.code.trim().toLowerCase())) ?? discovery.localChannels[0] ?? null const preferredAkeneoChannel = discovery.channels[0]?.code ?? '' const preferredLocale = discovery.locales.find((locale) => locale.enabled)?.code ?? discovery.locales[0]?.code ?? state.productLocale return { ...state, productLocale: state.productLocale, categoryLocale: state.categoryLocale, productChannel: state.productChannel || preferredAkeneoChannel, productChannels: state.productChannels.length > 0 ? state.productChannels : (preferredAkeneoChannel ? [preferredAkeneoChannel] : []), importAllProductChannels: state.importAllProductChannels, fieldMap: inferred.fieldMap, customFieldMappings: serializeCustomFieldRows( mergeCustomFieldRows(currentCustomFieldRows, discoveredCustomFieldRows), ), mediaMappings: hasMediaMappings ? state.mediaMappings : serializeMediaMappingRows( inferred.autoMediaMappings.map((mapping) => ({ attributeCode: mapping.attributeCode, target: mapping.target, kind: mapping.kind, })), ), fieldsetMappings: hasFieldsetMappings ? state.fieldsetMappings : serializeFieldsetMappingRows(buildDiscoveredFieldsetMappings(discovery)), priceMappings: hasPriceMappings || !preferredLocalChannel ? state.priceMappings : serializePriceMappingRows( inferred.autoPriceAttributeCodes.map((attributeCode) => ({ attributeCode, priceKindCode: inferPriceKindCode(attributeCode), akeneoChannel: preferredAkeneoChannel, localChannelCode: preferredLocalChannel.code, })), ), } } const FIRST_IMPORT_STEPS: Array<{ entityType: FirstImportStepKey fullSync: boolean }> = [ { entityType: 'categories', fullSync: true }, { entityType: 'attributes', fullSync: true }, { entityType: 'products', fullSync: true }, ] export default function AkeneoConfigWidget({ context, data }: InjectionWidgetComponentProps) { const t = useT() const { confirm, ConfirmDialogElement } = useConfirmDialog() const [state, setState] = React.useState(() => buildInitialState()) const [discovery, setDiscovery] = React.useState(null) const [customFieldStatus, setCustomFieldStatus] = React.useState(null) const [customFieldDialogOpen, setCustomFieldDialogOpen] = React.useState(false) const [customFieldEditorRows, setCustomFieldEditorRows] = React.useState([]) const [isCreatingCustomFields, setIsCreatingCustomFields] = React.useState(false) const [isLoading, setIsLoading] = React.useState(true) const [isSaving, setIsSaving] = React.useState(false) const [isStartingDeleteImportedProducts, setIsStartingDeleteImportedProducts] = React.useState(false) const [deleteImportedProductsJobId, setDeleteImportedProductsJobId] = React.useState(null) const [deleteImportedProductsJob, setDeleteImportedProductsJob] = React.useState(null) const [hasCompletedProductImport, setHasCompletedProductImport] = React.useState(false) const [firstImportProgress, setFirstImportProgress] = React.useState({ phase: 'idle', progressJobId: null, step: null, runId: null, runProgressJobId: null, progressPercent: null, processedCount: null, totalCount: null, errorMessage: null, }) const mountedRef = React.useRef(true) const firstImportTerminalNoticeRef = React.useRef(null) React.useEffect(() => { return () => { mountedRef.current = false } }, []) const syncFirstImportStatus = React.useCallback((response: FirstImportSequenceResponse | null | undefined) => { setHasCompletedProductImport(response?.hasCompletedProductImport === true) const sequence = response?.sequence if (!sequence) { setFirstImportProgress({ phase: 'idle', progressJobId: null, step: null, runId: null, runProgressJobId: null, progressPercent: null, processedCount: null, totalCount: null, errorMessage: null, }) return } setFirstImportProgress({ phase: sequence.status === 'failed' ? 'failed' : sequence.status === 'pending' || sequence.status === 'running' ? 'running' : sequence.status === 'completed' ? 'completed' : 'idle', progressJobId: sequence.progressJobId, step: sequence.currentStep, runId: sequence.currentRunId, runProgressJobId: sequence.currentRunProgressJobId, progressPercent: sequence.progressPercent, processedCount: sequence.processedCount, totalCount: sequence.totalCount, errorMessage: sequence.errorMessage, }) }, []) const loadFirstImportStatus = React.useCallback(async () => { const result = await apiCall('/api/sync_akeneo/first-import') if (!result.ok) { throw new Error(t('sync_akeneo.firstImport.errors.progress', 'Failed to load sync run progress.')) } syncFirstImportStatus(result.result ?? null) }, [syncFirstImportStatus, t]) const buildMappingPayloads = React.useCallback((currentState: FormState) => { const customFieldMappings = parseRows(currentState.customFieldMappings) .map(([attributeCode, target, fieldKey, kind, skipFlag]) => { const normalizedTarget = target === 'variant' ? 'variant' : target === 'product' ? 'product' : null const normalizedKind = kind === 'text' || kind === 'multiline' || kind === 'integer' || kind === 'float' || kind === 'boolean' || kind === 'select' ? kind : null const normalizedFieldKey = fieldKey || normalizeFieldKey(attributeCode) if (!attributeCode || !normalizedFieldKey || !normalizedTarget) return null return { attributeCode, target: normalizedTarget, fieldKey: normalizedFieldKey, kind: normalizedKind, skip: skipFlag === 'skip' || skipFlag === 'true', } as const }) .filter((entry): entry is NonNullable => Boolean(entry)) const priceMappings = parseRows(currentState.priceMappings) .map(([attributeCode, priceKindCode, akeneoChannel, localChannelCode]) => { if (!attributeCode || !priceKindCode || !localChannelCode) return null return { attributeCode, priceKindCode, akeneoChannel: akeneoChannel || null, localChannelCode, } as const }) .filter((entry): entry is NonNullable => Boolean(entry)) const mediaMappings = parseRows(currentState.mediaMappings) .map(([attributeCode, target, kind]) => { const normalizedTarget = target === 'variant' ? 'variant' : target === 'product' ? 'product' : null const normalizedKind = kind === 'image' ? 'image' : kind === 'file' ? 'file' : null if (!attributeCode || !normalizedTarget || !normalizedKind) return null return { attributeCode, target: normalizedTarget, kind: normalizedKind, } as const }) .filter((entry): entry is NonNullable => Boolean(entry)) const fieldsetMappings = parseFieldsetMappingRows(currentState.fieldsetMappings) .map((row) => { if (!row.sourceCode || !row.fieldsetCode || !row.fieldsetLabel) return null return { sourceType: row.sourceType, sourceCode: row.sourceCode.trim(), target: row.target, fieldsetCode: row.fieldsetCode.trim(), fieldsetLabel: row.fieldsetLabel.trim(), description: row.description.trim() || null, } as const }) .filter((entry): entry is NonNullable => Boolean(entry)) const productsMapping = { ...buildDefaultAkeneoMapping('products'), settings: { products: { locale: currentState.productLocale, channel: currentState.productChannel || null, channels: currentState.importAllProductChannels ? [] : currentState.productChannels, importAllChannels: currentState.importAllProductChannels, fieldMap: { ...currentState.fieldMap }, customFieldMappings, priceMappings, mediaMappings, fieldsetMappings, createMissingChannels: currentState.createMissingChannels, syncAssociations: currentState.syncAssociations, reconciliation: { ...currentState.reconciliation }, }, }, } productsMapping.fields = buildProductFieldMappings(productsMapping.settings.products) const categoriesMapping = { ...buildDefaultAkeneoMapping('categories'), settings: { categories: { locale: currentState.categoryLocale, }, }, } const attributesMapping = { ...buildDefaultAkeneoMapping('attributes'), settings: { attributes: { includeTextAttributes: currentState.includeTextAttributes, includeNumericAttributes: currentState.includeNumericAttributes, familyCodeFilter: currentState.familyCodeFilter .split(',') .map((value) => value.trim()) .filter((value) => value.length > 0), }, }, } return { productsMapping, categoriesMapping, attributesMapping } }, []) const persistMappings = React.useCallback(async ( currentState: FormState, options?: { successMessage?: string | null }, ) => { const { productsMapping, categoriesMapping, attributesMapping } = buildMappingPayloads(currentState) const saves = await Promise.all([ apiCall('/api/data_sync/mappings', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ integrationId: 'sync_akeneo', entityType: 'products', mapping: productsMapping, }), }), apiCall('/api/data_sync/mappings', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ integrationId: 'sync_akeneo', entityType: 'categories', mapping: categoriesMapping, }), }), apiCall('/api/data_sync/mappings', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ integrationId: 'sync_akeneo', entityType: 'attributes', mapping: attributesMapping, }), }), ]) if (saves.some((result) => !result.ok)) { throw new Error('Failed to save one or more Akeneo mapping records') } if (options?.successMessage) { flash(options.successMessage, 'success') } }, [buildMappingPayloads]) const runFirstFullImport = React.useCallback(async () => { firstImportTerminalNoticeRef.current = null setFirstImportProgress({ phase: 'running', progressJobId: null, step: null, runId: null, runProgressJobId: null, progressPercent: null, processedCount: null, totalCount: null, errorMessage: null, }) try { await persistMappings(state) const result = await apiCall<{ ok?: boolean; progressJobId?: string; error?: string }>('/api/sync_akeneo/first-import', { method: 'POST', }) if (!result.ok) { if (result.status === 409) { await loadFirstImportStatus() return } throw new Error( (result.result as { error?: string } | null)?.error ?? t('sync_akeneo.firstImport.errors.start', 'Failed to start the sync run.'), ) } setFirstImportProgress((current) => ({ ...current, progressJobId: result.result?.progressJobId ?? current.progressJobId, })) } catch (error) { if (!mountedRef.current) return const message = error instanceof Error ? error.message : t('sync_akeneo.firstImport.errors.generic', 'The first full Akeneo import could not be completed.') setFirstImportProgress((current) => ({ ...current, phase: 'failed', errorMessage: message, })) flash(message, 'error') } }, [loadFirstImportStatus, persistMappings, state, t]) const load = React.useCallback(async (refresh = false) => { setIsLoading(true) try { const [discoveryCall, productsCall, categoriesCall, attributesCall, customFieldsCall, firstImportStatusCall] = await Promise.all([ apiCall(`/api/sync_akeneo/discovery${refresh ? '?refresh=true' : ''}`), apiCall('/api/data_sync/mappings?integrationId=sync_akeneo&entityType=products&page=1&pageSize=1'), apiCall('/api/data_sync/mappings?integrationId=sync_akeneo&entityType=categories&page=1&pageSize=1'), apiCall('/api/data_sync/mappings?integrationId=sync_akeneo&entityType=attributes&page=1&pageSize=1'), apiCall('/api/sync_akeneo/custom-fields'), apiCall('/api/sync_akeneo/first-import').catch(() => null), ]) let nextState = mergeMappingsIntoState( buildInitialState(), productsCall.result?.items?.[0]?.mapping, categoriesCall.result?.items?.[0]?.mapping, attributesCall.result?.items?.[0]?.mapping, ) nextState = applyDiscoveryDefaults(nextState, discoveryCall.result ?? null) const hasSavedMappings = Boolean(productsCall.result?.items?.[0] || categoriesCall.result?.items?.[0] || attributesCall.result?.items?.[0]) if (!hasSavedMappings && discoveryCall.result?.ok) { try { await persistMappings(nextState) } catch (error) { const message = error instanceof Error ? error.message : 'Failed to save discovered Akeneo mappings' flash(message, 'error') } } setState(nextState) setDiscovery(discoveryCall.result ?? null) setCustomFieldStatus(customFieldsCall.result ?? null) syncFirstImportStatus(firstImportStatusCall?.result ?? null) } catch (error) { const message = error instanceof Error ? error.message : 'Failed to load Akeneo configuration' flash(message, 'error') } finally { setIsLoading(false) } }, [persistMappings, syncFirstImportStatus]) const applyFirstImportProgressEvent = React.useCallback((payload: ProgressEventPayload) => { const meta = payload.meta && typeof payload.meta === 'object' ? payload.meta : null const workflow = readProgressMetaString(meta, 'workflow') const integrationId = readProgressMetaString(meta, 'integrationId') const isFirstImportEvent = payload.jobType === 'sync_akeneo.first_import' || (workflow === 'first_import' && integrationId === 'sync_akeneo') if (!isFirstImportEvent || !payload.jobId) return const currentStep = readProgressMetaString(meta, 'currentStep') const nextPhase = payload.status === 'failed' ? 'failed' : payload.status === 'pending' || payload.status === 'running' ? 'running' : payload.status === 'completed' ? 'completed' : 'idle' setFirstImportProgress({ phase: nextPhase, progressJobId: payload.jobId, step: currentStep === 'categories' || currentStep === 'attributes' || currentStep === 'products' ? currentStep : null, runId: readProgressMetaString(meta, 'currentRunId'), runProgressJobId: readProgressMetaString(meta, 'currentRunProgressJobId'), progressPercent: readProgressMetaNumber(meta, 'currentRunProgressPercent'), processedCount: readProgressMetaNumber(meta, 'currentRunProcessedCount'), totalCount: readProgressMetaNumber(meta, 'currentRunTotalCount'), errorMessage: typeof payload.errorMessage === 'string' ? payload.errorMessage : null, }) if (nextPhase === 'running') { firstImportTerminalNoticeRef.current = null return } const terminalNoticeKey = `${payload.jobId}:${payload.status ?? 'unknown'}` if (firstImportTerminalNoticeRef.current === terminalNoticeKey) return firstImportTerminalNoticeRef.current = terminalNoticeKey if (nextPhase === 'completed') { setHasCompletedProductImport(true) flash(t('sync_akeneo.firstImport.completed', 'The first full Akeneo import finished successfully.'), 'success') void load(false) return } if (nextPhase === 'failed') { flash( typeof payload.errorMessage === 'string' && payload.errorMessage.trim().length > 0 ? payload.errorMessage : t('sync_akeneo.firstImport.errors.failed', 'The sync run failed.'), 'error', ) void loadFirstImportStatus().catch(() => undefined) return } if (payload.status === 'cancelled') { flash(t('sync_akeneo.firstImport.errors.cancelled', 'The first full Akeneo import was cancelled.'), 'error') void loadFirstImportStatus().catch(() => undefined) } }, [load, loadFirstImportStatus, t]) const applyFirstImportChildRunProgressEvent = React.useCallback((payload: ProgressEventPayload) => { const jobId = typeof payload.jobId === 'string' ? payload.jobId : null if (!jobId) return setFirstImportProgress((current) => { if (!current.runProgressJobId || current.runProgressJobId !== jobId) { return current } return { ...current, phase: payload.status === 'failed' || payload.status === 'cancelled' ? 'failed' : current.phase === 'idle' ? 'running' : current.phase, progressPercent: typeof payload.progressPercent === 'number' ? payload.progressPercent : current.progressPercent, processedCount: typeof payload.processedCount === 'number' ? payload.processedCount : current.processedCount, totalCount: typeof payload.totalCount === 'number' ? payload.totalCount : current.totalCount, errorMessage: typeof payload.errorMessage === 'string' ? payload.errorMessage : current.errorMessage, } }) }, []) const applyDeleteImportedProductsEvent = React.useCallback((payload: ProgressEventPayload) => { if (!deleteImportedProductsJobId || payload.jobId !== deleteImportedProductsJobId) return const nextJob: DeleteImportedProductsJobResponse = { id: payload.jobId, status: payload.status === 'pending' || payload.status === 'running' || payload.status === 'completed' || payload.status === 'failed' || payload.status === 'cancelled' ? payload.status : 'running', progressPercent: typeof payload.progressPercent === 'number' ? payload.progressPercent : 0, processedCount: typeof payload.processedCount === 'number' ? payload.processedCount : 0, totalCount: typeof payload.totalCount === 'number' ? payload.totalCount : null, resultSummary: payload.resultSummary ?? null, errorMessage: typeof payload.errorMessage === 'string' ? payload.errorMessage : null, } setDeleteImportedProductsJob(nextJob) if (nextJob.status === 'completed') { setDeleteImportedProductsJobId(null) setDeleteImportedProductsJob(null) flash( readDeleteImportedProductsSummaryMessage(nextJob.resultSummary) ?? t('sync_akeneo.deleteImportedProducts.completed', 'Imported Akeneo product deletion completed.'), 'success', ) void load(false) return } if (nextJob.status === 'failed') { setDeleteImportedProductsJobId(null) setDeleteImportedProductsJob(null) flash( nextJob.errorMessage ?? t('sync_akeneo.deleteImportedProducts.failed', 'Imported Akeneo product deletion failed.'), 'error', ) return } if (nextJob.status === 'cancelled') { setDeleteImportedProductsJobId(null) setDeleteImportedProductsJob(null) flash( t('sync_akeneo.deleteImportedProducts.cancelled', 'Imported Akeneo product deletion was cancelled.'), 'warning', ) } }, [deleteImportedProductsJobId, load, t]) React.useEffect(() => { void load(false) }, [load]) useAppEvent('progress.job.created', (event) => { const payload = event.payload as ProgressEventPayload applyFirstImportProgressEvent(payload) applyFirstImportChildRunProgressEvent(payload) applyDeleteImportedProductsEvent(payload) }, [applyDeleteImportedProductsEvent, applyFirstImportChildRunProgressEvent, applyFirstImportProgressEvent]) useAppEvent('progress.job.started', (event) => { const payload = event.payload as ProgressEventPayload applyFirstImportProgressEvent(payload) applyFirstImportChildRunProgressEvent(payload) applyDeleteImportedProductsEvent(payload) }, [applyDeleteImportedProductsEvent, applyFirstImportChildRunProgressEvent, applyFirstImportProgressEvent]) useAppEvent('progress.job.updated', (event) => { const payload = event.payload as ProgressEventPayload applyFirstImportProgressEvent(payload) applyFirstImportChildRunProgressEvent(payload) applyDeleteImportedProductsEvent(payload) }, [applyDeleteImportedProductsEvent, applyFirstImportChildRunProgressEvent, applyFirstImportProgressEvent]) useAppEvent('progress.job.completed', (event) => { const payload = event.payload as ProgressEventPayload applyFirstImportProgressEvent(payload) applyFirstImportChildRunProgressEvent(payload) applyDeleteImportedProductsEvent(payload) }, [applyDeleteImportedProductsEvent, applyFirstImportChildRunProgressEvent, applyFirstImportProgressEvent]) useAppEvent('progress.job.failed', (event) => { const payload = event.payload as ProgressEventPayload applyFirstImportProgressEvent(payload) applyFirstImportChildRunProgressEvent(payload) applyDeleteImportedProductsEvent(payload) }, [applyDeleteImportedProductsEvent, applyFirstImportChildRunProgressEvent, applyFirstImportProgressEvent]) useAppEvent('progress.job.cancelled', (event) => { const payload = event.payload as ProgressEventPayload applyFirstImportProgressEvent(payload) applyFirstImportChildRunProgressEvent(payload) applyDeleteImportedProductsEvent(payload) }, [applyDeleteImportedProductsEvent, applyFirstImportChildRunProgressEvent, applyFirstImportProgressEvent]) useAppEvent('om:bridge:reconnected', () => { void loadFirstImportStatus().catch(() => undefined) }, [loadFirstImportStatus]) const attributeCodes = React.useMemo( () => (discovery?.attributes ?? []).map((attribute) => attribute.code).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)), [discovery], ) const customFieldRows = React.useMemo( () => parseCustomFieldRows(state.customFieldMappings), [state.customFieldMappings], ) const priceMappingRows = React.useMemo( () => parsePriceMappingRows(state.priceMappings), [state.priceMappings], ) const mediaMappingRows = React.useMemo( () => parseMediaMappingRows(state.mediaMappings), [state.mediaMappings], ) const fieldsetMappingRows = React.useMemo( () => parseFieldsetMappingRows(state.fieldsetMappings), [state.fieldsetMappings], ) const discoveredAkeneoChannelCodes = React.useMemo( () => (discovery?.channels ?? []).map((channel) => channel.code), [discovery], ) const selectedAkeneoChannelSet = React.useMemo( () => new Set(state.productChannels), [state.productChannels], ) const discoveredLocalChannelCodes = React.useMemo( () => (discovery?.localChannels ?? []).map((channel) => channel.code), [discovery], ) const deleteImportedProductsBusy = isStartingDeleteImportedProducts || deleteImportedProductsJob?.status === 'pending' || deleteImportedProductsJob?.status === 'running' const firstImportBusy = firstImportProgress.phase === 'running' const firstImportReady = Boolean(context?.state?.isEnabled) && Boolean(data?.hasCredentials) const shouldShowFirstImportCallout = !isLoading && ( firstImportBusy || firstImportProgress.phase === 'failed' || !hasCompletedProductImport ) const firstImportCurrentStepLabel = firstImportProgress.step ? t(`sync_akeneo.firstImport.steps.${firstImportProgress.step}`, firstImportProgress.step) : null const discoveredPriceKindCodes = React.useMemo( () => (discovery?.priceKinds ?? []).map((priceKind) => priceKind.code), [discovery], ) const productFieldKeys = React.useMemo( () => new Set(customFieldStatus?.productKeys ?? []), [customFieldStatus], ) const variantFieldKeys = React.useMemo( () => new Set(customFieldStatus?.variantKeys ?? []), [customFieldStatus], ) const missingCustomFieldCount = React.useMemo( () => customFieldRows.filter((row) => { if (row.skip) return false if (!row.fieldKey.trim()) return false return row.target === 'product' ? !productFieldKeys.has(row.fieldKey.trim()) : !variantFieldKeys.has(row.fieldKey.trim()) }).length, [customFieldRows, productFieldKeys, variantFieldKeys], ) const editorMappedAttributeCodes = React.useMemo( () => new Set(customFieldEditorRows.map((row) => row.attributeCode).filter((value) => value.length > 0)), [customFieldEditorRows], ) const suggestedCustomFieldAttributes = React.useMemo( () => (discovery?.attributes ?? []).filter((attribute) => !editorMappedAttributeCodes.has(attribute.code)).slice(0, 24), [discovery, editorMappedAttributeCodes], ) const dialogMissingCustomFieldCount = React.useMemo( () => customFieldEditorRows.filter((row) => { if (row.skip) return false if (!row.fieldKey.trim()) return false return row.target === 'product' ? !productFieldKeys.has(row.fieldKey.trim()) : !variantFieldKeys.has(row.fieldKey.trim()) }).length, [customFieldEditorRows, productFieldKeys, variantFieldKeys], ) const skippedCustomFieldCount = React.useMemo( () => customFieldRows.filter((row) => row.skip).length, [customFieldRows], ) const renderCustomFieldStatusBadge = React.useCallback((exists: boolean) => ( {exists ? : } {exists ? t('sync_akeneo.customFields.status.ready', 'Exists') : t('sync_akeneo.customFields.status.missing', 'Missing')} ), [t]) const renderSkippedCustomFieldBadge = React.useCallback(() => ( {t('sync_akeneo.customFields.status.skipped', 'Skipped')} ), [t]) function openCustomFieldDialog() { setCustomFieldEditorRows(parseCustomFieldRows(state.customFieldMappings)) setCustomFieldDialogOpen(true) } function toggleImportedAkeneoChannel(code: string, checked: boolean) { setState((current) => { const next = checked ? Array.from(new Set([...current.productChannels, code])) : current.productChannels.filter((entry) => entry !== code) const nextPrimary = next.includes(current.productChannel) ? current.productChannel : next[0] ?? '' return { ...current, productChannels: next, productChannel: nextPrimary, } }) } async function createMissingCustomFields(sourceRows?: CustomFieldRow[]) { setIsCreatingCustomFields(true) try { const customFieldMappings = (sourceRows ?? parseCustomFieldRows(state.customFieldMappings)) .filter((row) => !row.skip) if (customFieldMappings.length === 0) { flash(t('sync_akeneo.customFields.createNothing', 'There are no non-skipped custom-field mappings to create.'), 'info') return } const currentProductsMapping = { ...buildDefaultAkeneoMapping('products'), settings: { products: { locale: state.productLocale, channel: state.productChannel || null, channels: state.importAllProductChannels ? [] : state.productChannels, importAllChannels: state.importAllProductChannels, fieldMap: { ...state.fieldMap }, customFieldMappings: customFieldMappings.map((row) => ({ attributeCode: row.attributeCode.trim(), target: row.target, fieldKey: row.fieldKey.trim() || normalizeFieldKey(row.attributeCode), kind: row.kind || null, skip: false, })), priceMappings: [], mediaMappings: [], fieldsetMappings: parseFieldsetMappingRows(state.fieldsetMappings).map((row) => ({ sourceType: row.sourceType, sourceCode: row.sourceCode.trim(), target: row.target, fieldsetCode: row.fieldsetCode.trim(), fieldsetLabel: row.fieldsetLabel.trim(), description: row.description.trim() || null, })), createMissingChannels: state.createMissingChannels, syncAssociations: state.syncAssociations, reconciliation: { ...state.reconciliation }, }, }, } currentProductsMapping.fields = buildProductFieldMappings(currentProductsMapping.settings.products) const result = await apiCall('/api/sync_akeneo/custom-fields', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mapping: currentProductsMapping, }), }) setCustomFieldStatus(result.result ?? null) flash( result.result?.message ?? t('sync_akeneo.customFields.created', 'Akeneo-backed custom fields created.'), 'success', ) } catch (error) { const message = error instanceof Error ? error.message : 'Failed to create custom fields' flash(message, 'error') } finally { setIsCreatingCustomFields(false) } } function applyCustomFieldEditor() { setState((current) => ({ ...current, customFieldMappings: serializeCustomFieldRows(customFieldEditorRows), })) setCustomFieldDialogOpen(false) } function updatePriceMappingRows(nextRows: PriceMappingRow[]) { setState((current) => ({ ...current, priceMappings: serializePriceMappingRows(nextRows), })) } function updateMediaMappingRows(nextRows: MediaMappingRow[]) { setState((current) => ({ ...current, mediaMappings: serializeMediaMappingRows(nextRows), })) } function updateFieldsetMappingRows(nextRows: FieldsetMappingRow[]) { setState((current) => ({ ...current, fieldsetMappings: serializeFieldsetMappingRows(nextRows), })) } async function refreshDiscoveredMappings() { setIsSaving(true) try { const discoveryCall = await apiCall('/api/sync_akeneo/discovery?refresh=true') const discovered = discoveryCall.result ?? null if (!discovered?.ok) { throw new Error(discovered?.message ?? 'Failed to load Akeneo discovery metadata') } const nextState = applyDiscoveryDefaults({ ...state }, discovered) await persistMappings(nextState, { successMessage: t('sync_akeneo.discovery.rediscovered', 'Akeneo fields refreshed and missing mappings added.'), }) setState(nextState) setDiscovery(discovered) const customFieldsCall = await apiCall('/api/sync_akeneo/custom-fields') setCustomFieldStatus(customFieldsCall.result ?? null) } catch (error) { const message = error instanceof Error ? error.message : 'Failed to refresh Akeneo mappings' flash(message, 'error') } finally { setIsSaving(false) } } async function saveMappings() { setIsSaving(true) try { await persistMappings(state, { successMessage: t('sync_akeneo.saved', 'Akeneo sync settings saved.'), }) await load(false) } catch (error) { const message = error instanceof Error ? error.message : 'Failed to save Akeneo mapping' flash(message, 'error') } finally { setIsSaving(false) } } async function deleteImportedProducts() { const confirmed = await confirm({ title: t('sync_akeneo.deleteImportedProducts.confirmTitle', 'Force delete all imported Akeneo products?'), text: t('sync_akeneo.deleteImportedProducts.confirmText', 'This will permanently delete products imported by the Akeneo integration for the current organization, together with their imported variants and related catalog data. The Akeneo product cursor will also be reset so a fresh import can start from scratch.'), confirmText: t('sync_akeneo.deleteImportedProducts.confirmAction', 'Force delete'), cancelText: t('sync_akeneo.deleteImportedProducts.cancel', 'Cancel'), variant: 'destructive', }) if (!confirmed) return setIsStartingDeleteImportedProducts(true) try { const result = await apiCall('/api/sync_akeneo/delete-products', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ confirm: true }), }) if (!result.ok || !result.result) { throw new Error('Failed to delete imported Akeneo products') } if (!result.result.progressJobId) { flash(result.result.message, 'success') await load(false) return } setDeleteImportedProductsJob({ id: result.result.progressJobId, status: 'pending', progressPercent: 0, processedCount: 0, totalCount: null, }) setDeleteImportedProductsJobId(result.result.progressJobId) } catch (error) { const message = error instanceof Error ? error.message : 'Failed to delete imported Akeneo products' flash(message, 'error') } finally { setIsStartingDeleteImportedProducts(false) } } return (

{t('sync_akeneo.setup.heading', 'Akeneo API setup')}

{t('sync_akeneo.setup.scheduleTab', 'Run once and recurring schedules now live in the dedicated Sync schedules tab, shared by all data sync integrations.')} {t('sync_akeneo.setup.order.title', 'Recommended sync order')} {t( 'sync_akeneo.setup.order.message', 'Run category sync first, attribute sync second, and product sync last. Product import expects local categories and family-based schemas to exist already.', )} {shouldShowFirstImportCallout ? ( } title={t('sync_akeneo.firstImport.title', 'Run the first full import')} description={t( 'sync_akeneo.firstImport.help', 'Launch the standard Akeneo sync sequence in the recommended order: categories first, attributes second, products last.', )} steps={FIRST_IMPORT_STEPS.map((step, stepIndex) => { const currentStepIndex = firstImportProgress.step ? FIRST_IMPORT_STEPS.findIndex((item) => item.entityType === firstImportProgress.step) : -1 const isActive = firstImportProgress.step === step.entityType && firstImportProgress.phase === 'running' const isDone = firstImportProgress.phase === 'running' && currentStepIndex > stepIndex return { id: step.entityType, label: t(`sync_akeneo.firstImport.steps.${step.entityType}`, step.entityType), state: isActive ? 'active' : isDone ? 'completed' : 'pending', } })} actionLabel={firstImportBusy ? t('sync_akeneo.firstImport.running', 'Running full import sequence...') : t('sync_akeneo.firstImport.action', 'Run the first full import')} actionIcon={firstImportBusy ? : } onAction={() => void runFirstFullImport()} disabled={isLoading || isSaving || deleteImportedProductsBusy || !firstImportReady} busy={firstImportBusy} disabledMessage={!firstImportReady ? context?.state?.isEnabled ? t('sync_akeneo.firstImport.missingCredentials', 'Save valid Akeneo credentials before starting the first full import.') : t('sync_akeneo.firstImport.integrationDisabled', 'Enable the integration and save valid Akeneo credentials before starting the first full import.') : undefined} status={firstImportProgress.phase !== 'idle' ? { tone: firstImportProgress.phase === 'failed' ? 'danger' : 'info', icon: firstImportProgress.phase === 'failed' ? : , label: firstImportProgress.phase === 'failed' ? t('sync_akeneo.firstImport.status.failed', 'Full import failed') : t('sync_akeneo.firstImport.status.running', 'Full import sequence running'), badge: firstImportCurrentStepLabel ? {firstImportCurrentStepLabel} : null, progressValue: typeof firstImportProgress.progressPercent === 'number' ? firstImportProgress.progressPercent : null, progressDescription: firstImportBusy ? firstImportProgress.totalCount && firstImportProgress.totalCount > 0 ? t( 'sync_akeneo.firstImport.progressCount', '{processed} of {total} items processed in the current run.', { processed: firstImportProgress.processedCount ?? 0, total: firstImportProgress.totalCount, }, ) : t('sync_akeneo.firstImport.progressUnknown', 'The current run is active. Live progress also appears in the global operations bar.') : undefined, errorMessage: firstImportProgress.errorMessage, } : null} /> ) : null}

{t('sync_akeneo.setup.credentials.title', 'Create API credentials in Akeneo')}

  1. {t('sync_akeneo.setup.credentials.step1', 'In Akeneo, open Settings -> Connections and create a new API connection or connected app for Open Mercato.')}
  2. {t('sync_akeneo.setup.credentials.step2', 'Copy the Akeneo base URL, client id, and client secret into the Integration credentials tab in Open Mercato.')}
  3. {t('sync_akeneo.setup.credentials.step3', 'Create a dedicated Akeneo API user, assign it to the right catalog permissions, and use that username/password in the credential form.')}
  4. {t('sync_akeneo.setup.credentials.step4', 'Grant the API user access to products, categories, attributes, families, family variants, locales, and channels. Limit write permissions if you only import.')}

{t('sync_akeneo.setup.docs.title', 'Operational notes')}

  • {t('sync_akeneo.setup.docs.note1', 'Akeneo authentication uses client id, client secret, username, and password. Open Mercato exchanges those for access/refresh tokens automatically.')}
  • {t('sync_akeneo.setup.docs.note2', 'For large catalogs, keep batch sizes at 100 and prefer incremental product syncs. The importer resumes from the last processed Akeneo updated timestamp plus search_after pagination state.')}
  • {t('sync_akeneo.setup.docs.note3', 'Simple Akeneo products still create a default Open Mercato variant. Variant Akeneo products flatten the full Akeneo product-model tree into one configurable product plus child variants.')}
  • {t('sync_akeneo.setup.docs.note4', 'If you want channel offers and prices, create matching Sales Channels and Catalog Price Kinds in Open Mercato first, then reference their codes in the mapping blocks below.')}

{t('sync_akeneo.mapping.heading', 'Field mapping')}

{t('sync_akeneo.mapping.help', 'Mappings are discovered automatically from the saved Akeneo credentials. Use refresh discovery to pull the latest Akeneo metadata and add any missing mappings without deleting your existing overrides.')}

{deleteImportedProductsBusy && deleteImportedProductsJob ? (
{t('sync_akeneo.deleteImportedProducts.progressTitle', 'Deleting imported Akeneo products')}
{deleteImportedProductsJob.totalCount && deleteImportedProductsJob.totalCount > 0 ? t( 'sync_akeneo.deleteImportedProducts.progressCount', '{processed} / {total}', { processed: deleteImportedProductsJob.processedCount, total: deleteImportedProductsJob.totalCount, }, ) : t('sync_akeneo.deleteImportedProducts.preparing', 'Preparing...')}
{deleteImportedProductsJob.totalCount && deleteImportedProductsJob.totalCount > 0 ? ( ) : (
)}

{deleteImportedProductsJob.status === 'pending' ? t('sync_akeneo.deleteImportedProducts.pending', 'The deletion job is being prepared.') : t( 'sync_akeneo.deleteImportedProducts.running', 'The imported product cleanup is running in the background. Progress also appears in the global progress bar.', )}

) : null} {discovery?.message ? ( {discovery.message} ) : null}

{t('sync_akeneo.mapping.productLocale.help', 'Only this Akeneo locale is imported into the base Open Mercato fields for now. Other locales are ignored until translation import is added.')}

{t('sync_akeneo.mapping.productChannel.help', 'This single Akeneo channel is used for base product content, custom fields, media, and variant labels when attributes are channel-scoped.')}

{t('sync_akeneo.mapping.importChannels.help', 'Choose which Akeneo channels should be imported for channel-scoped data like offers and prices. Locale stays single-select.')}

{!state.importAllProductChannels ? (
{(discovery?.channels ?? []).map((channel) => ( ))}
) : null}
{PRODUCT_FIELD_KEYS.map((field) => (
{ const value = event.target.value setState((current) => ({ ...current, fieldMap: { ...current.fieldMap, [field.key]: value, }, })) }} disabled={isLoading || isSaving} />

{t(`sync_akeneo.mapping.${field.key}.help`, field.help)}

))}
setState((current) => ({ ...current, familyCodeFilter: event.target.value }))} disabled={isLoading || isSaving} placeholder={t('sync_akeneo.mapping.familyFilterPlaceholder', 'family_a, family_b')} />

{t('sync_akeneo.mapping.familyFilter.help', 'Leave empty to sync family-driven schemas for all Akeneo families.')}

{t('sync_akeneo.mapping.customFields.help', 'One mapping per line: attribute_code,target(product|variant),field_key[,kind][,skip]. After valid Akeneo credentials are saved, Open Mercato discovers mappings automatically: variant axes stay as product options, and other relevant Akeneo attributes are auto-mapped as custom fields unless you override them here. Use the editor skip checkbox when an Akeneo attribute should be ignored entirely. Product imports create or update missing Open Mercato custom field definitions automatically and store the Akeneo metadata, validation rules, and groups on those fields.')}

{t('sync_akeneo.mapping.customFields.autoCreate', 'Running the Akeneo product import also creates any missing mapped fields automatically. Use the manual action only when you want to provision them before the first import.')}
{customFieldRows.length > 0 ? (
{customFieldRows.map((row, index) => { const exists = row.target === 'product' ? productFieldKeys.has(row.fieldKey.trim()) : variantFieldKeys.has(row.fieldKey.trim()) return ( ) })}
{t('sync_akeneo.mapping.from', 'From')} {t('sync_akeneo.customFields.columns.target', 'Target')} {t('sync_akeneo.customFields.columns.key', 'Field key')} {t('sync_akeneo.customFields.columns.kind', 'Kind')} {t('sync_akeneo.customFields.columns.status', 'Status')}
{row.attributeCode} {row.target} {row.fieldKey} {row.kind || t('sync_akeneo.customFields.kinds.auto', 'Auto')} {row.skip ? renderSkippedCustomFieldBadge() : renderCustomFieldStatusBadge(exists)}
) : ( {t('sync_akeneo.customFields.empty', 'No Akeneo custom fields are shown yet. They are discovered and mapped automatically after credentials are saved or when you use Refresh discovery. Use the editor only if you want to review or override the automatic mapping.')} )} {customFieldRows.length > 0 ? (
{t('sync_akeneo.mapping.customFields.total', `${customFieldRows.length} mapped`)} {skippedCustomFieldCount > 0 ? ( {t('sync_akeneo.mapping.customFields.skipped', `${skippedCustomFieldCount} skipped`)} ) : null} 0 ? 'border-amber-300 text-amber-700' : ''}`}> {missingCustomFieldCount > 0 ? t('sync_akeneo.mapping.customFields.missing', `${missingCustomFieldCount} missing locally`) : t('sync_akeneo.mapping.customFields.ready', 'All mapped fields already exist')}
) : null}

{t('sync_akeneo.mapping.prices.help', 'One mapping per line: price_attribute,price_kind_code,akeneo_channel,local_channel_code. Each distinct local channel creates or updates an offer, and each price collection entry becomes a Catalog Product Price in the matching currency.')}

{priceMappingRows.length > 0 ? priceMappingRows.map((row, index) => ( )) : ( )}
{t('sync_akeneo.mapping.from', 'From')} {t('sync_akeneo.mapping.priceKind', 'Price kind')} {t('sync_akeneo.mapping.akeneoChannel', 'Akeneo channel')} {t('sync_akeneo.mapping.localChannel', 'Open Mercato channel')} {t('sync_akeneo.customFields.columns.actions', 'Actions')}
{ const nextRows = [...priceMappingRows] nextRows[index] = { ...row, attributeCode: event.target.value } updatePriceMappingRows(nextRows) }} disabled={isLoading || isSaving} />
{t('sync_akeneo.mapping.emptyPrices', 'No price mappings configured yet.')}

{t('sync_akeneo.mapping.media.help', 'One mapping per line: attribute_code,target(product|variant),kind(image|file). Image mappings are re-hosted into Open Mercato attachments and can become default media; file mappings are imported as attachments.')}

{mediaMappingRows.length > 0 ? mediaMappingRows.map((row, index) => ( )) : ( )}
{t('sync_akeneo.mapping.from', 'From')} {t('sync_akeneo.customFields.columns.target', 'Target')} {t('sync_akeneo.customFields.columns.kind', 'Kind')} {t('sync_akeneo.customFields.columns.actions', 'Actions')}
{ const nextRows = [...mediaMappingRows] nextRows[index] = { ...row, attributeCode: event.target.value } updateMediaMappingRows(nextRows) }} disabled={isLoading || isSaving} />
{t('sync_akeneo.mapping.emptyMedia', 'No media mappings configured yet.')}

{t('sync_akeneo.mapping.fieldsets.help', 'Discovered Akeneo families become Open Mercato fieldsets automatically. Adjust the fieldset code, label, or description here, and add manual family-variant rows when a specific Akeneo family variant should use a different variant fieldset.')}

{fieldsetMappingRows.length > 0 ? fieldsetMappingRows.map((row, index) => ( )) : ( )}
{t('sync_akeneo.mapping.sourceType', 'Source type')} {t('sync_akeneo.mapping.sourceCode', 'Akeneo code')} {t('sync_akeneo.customFields.columns.target', 'Target')} {t('sync_akeneo.mapping.fieldsetCode', 'Fieldset code')} {t('sync_akeneo.mapping.fieldsetLabel', 'Fieldset label')} {t('sync_akeneo.mapping.fieldsetDescription', 'Description')} {t('sync_akeneo.customFields.columns.actions', 'Actions')}
{ const nextRows = [...fieldsetMappingRows] const sourceCode = event.target.value nextRows[index] = { ...row, sourceCode, fieldsetCode: row.fieldsetCode || buildAkeneoFieldsetCode(row.target, sourceCode) || '', } updateFieldsetMappingRows(nextRows) }} disabled={isLoading || isSaving} list={row.sourceType === 'family' ? 'akeneo-families' : undefined} /> { const nextRows = [...fieldsetMappingRows] nextRows[index] = { ...row, fieldsetCode: normalizeFieldKey(event.target.value) } updateFieldsetMappingRows(nextRows) }} disabled={isLoading || isSaving} /> { const nextRows = [...fieldsetMappingRows] nextRows[index] = { ...row, fieldsetLabel: event.target.value } updateFieldsetMappingRows(nextRows) }} disabled={isLoading || isSaving} /> { const nextRows = [...fieldsetMappingRows] nextRows[index] = { ...row, description: event.target.value } updateFieldsetMappingRows(nextRows) }} disabled={isLoading || isSaving} />
{t('sync_akeneo.mapping.emptyFieldsets', 'No family fieldset mappings discovered yet. Save credentials and use Refresh discovery to generate them.')}

{t('sync_akeneo.mapping.reconciliation.title', 'Reconciliation and deletions')}

{t('sync_akeneo.mapping.reconciliation.help', 'These cleanup rules apply during full syncs. Incremental syncs continue to upsert changed records, while stale Akeneo-managed offers, prices, and media are reconciled per product on every product import.')}

{[ ['deactivateMissingCategories', 'Deactivate categories missing from Akeneo'], ['deactivateMissingProducts', 'Deactivate products and variants missing from Akeneo'], ['deactivateMissingAttributes', 'Deactivate Akeneo-managed schemas and custom fields missing from Akeneo'], ['deleteMissingOffers', 'Delete Akeneo-managed offers no longer produced by mapping'], ['deleteMissingPrices', 'Delete Akeneo-managed prices no longer produced by mapping'], ['deleteMissingMedia', 'Delete stale Akeneo-managed images/default media'], ['deleteMissingAttachments', 'Delete stale Akeneo-managed file attachments'], ].map(([key, label]) => ( ))}

{t('sync_akeneo.discovery.heading', 'Discovered Akeneo fields')}

{t('sync_akeneo.discovery.help', 'These values come from the current Akeneo credentials and are safe to paste into the mapping inputs.')}

{t('sync_akeneo.discovery.locales', 'Locales')}
{(discovery?.locales ?? []).map((locale) => ( {locale.code} ))}
{t('sync_akeneo.discovery.channels', 'Channels')}
{(discovery?.channels ?? []).map((channel) => ( {channel.code} ))}
{t('sync_akeneo.discovery.families', 'Families')}
{(discovery?.families ?? []).slice(0, 20).map((family) => ( {family.code} ))}
{t('sync_akeneo.discovery.localChannels', 'Open Mercato channels')}
{(discovery?.localChannels ?? []).map((channel) => ( {channel.code} ))}
{t('sync_akeneo.discovery.priceKinds', 'Catalog price kinds')}
{(discovery?.priceKinds ?? []).map((priceKind) => ( {priceKind.code} ))}
{t('sync_akeneo.discovery.attributes', 'Attributes')}
{(discovery?.attributes ?? []).map((attribute) => (
{attribute.code}
{attribute.type} {attribute.localizable ? ' | localizable' : ''} {attribute.scopable ? ' | channel-scoped' : ''} {attribute.group ? ` | group:${attribute.group}` : ''} {attribute.metricFamily ? ` | metric:${attribute.metricFamily}` : ''}
))}
{attributeCodes.map((code) => ( {(discovery?.locales ?? []).map((locale) => ( {(discovery?.channels ?? []).map((channel) => ( {(discovery?.families ?? []).map((family) => (
{ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { event.preventDefault() applyCustomFieldEditor() } }} > {t('sync_akeneo.customFields.dialog.title', 'Akeneo custom field editor')} {t('sync_akeneo.customFields.dialog.description', 'Edit structured custom-field mappings, review which local field definitions already exist, generate missing ones immediately, or mark specific Akeneo attributes to be skipped during import.')}
{t('sync_akeneo.customFields.dialog.summary.total', `${customFieldEditorRows.length} mapping rows`)} 0 ? 'border-amber-300 text-amber-700' : ''}`}> {dialogMissingCustomFieldCount > 0 ? t('sync_akeneo.customFields.dialog.summary.missing', `${dialogMissingCustomFieldCount} rows missing local fields`) : t('sync_akeneo.customFields.dialog.summary.ready', 'Everything in this mapping already exists locally')}
{customFieldEditorRows.length > 0 ? customFieldEditorRows.map((row, index) => { const exists = row.target === 'product' ? productFieldKeys.has(row.fieldKey.trim()) : variantFieldKeys.has(row.fieldKey.trim()) return ( ) }) : ( )}
{t('sync_akeneo.customFields.columns.attribute', 'Akeneo attribute')} {t('sync_akeneo.customFields.columns.skip', 'Skip import')} {t('sync_akeneo.customFields.columns.target', 'Target')} {t('sync_akeneo.customFields.columns.key', 'Field key')} {t('sync_akeneo.customFields.columns.kind', 'Kind')} {t('sync_akeneo.customFields.columns.status', 'Status')} {t('sync_akeneo.customFields.columns.actions', 'Actions')}
{ const nextRows = [...customFieldEditorRows] nextRows[index] = { ...row, attributeCode: event.target.value } setCustomFieldEditorRows(nextRows) }} disabled={isLoading || isSaving || isCreatingCustomFields} /> { const nextRows = [...customFieldEditorRows] nextRows[index] = { ...row, fieldKey: normalizeFieldKey(event.target.value) } setCustomFieldEditorRows(nextRows) }} disabled={isLoading || isSaving || isCreatingCustomFields} /> {row.skip ? renderSkippedCustomFieldBadge() : renderCustomFieldStatusBadge(exists)}
{t('sync_akeneo.customFields.empty', 'No Akeneo custom fields are mapped yet. Use the suggestions below or add a row manually.')}

{t('sync_akeneo.customFields.suggestions.title', 'Suggested Akeneo attributes')}

{t('sync_akeneo.customFields.suggestions.help', 'Quick-add discovered attributes that are not yet part of the Akeneo custom-field mapping.')}

{suggestedCustomFieldAttributes.map((attribute) => ( ))}
{ConfirmDialogElement}
) }