@endora-commerce/mod-catalog 0.100.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +63 -0
- package/dist/admin/components/PackagingUnitsEditor.d.ts +12 -0
- package/dist/admin/components/PackagingUnitsEditor.d.ts.map +1 -0
- package/dist/admin/components/PackagingUnitsEditor.js +99 -0
- package/dist/admin/components/PackagingUnitsEditor.js.map +1 -0
- package/dist/admin/components/ProductAttributesTab.d.ts +13 -0
- package/dist/admin/components/ProductAttributesTab.d.ts.map +1 -0
- package/dist/admin/components/ProductAttributesTab.js +140 -0
- package/dist/admin/components/ProductAttributesTab.js.map +1 -0
- package/dist/admin/components/ProductInventoryTab.d.ts +14 -0
- package/dist/admin/components/ProductInventoryTab.d.ts.map +1 -0
- package/dist/admin/components/ProductInventoryTab.js +284 -0
- package/dist/admin/components/ProductInventoryTab.js.map +1 -0
- package/dist/admin/components/ProductScopeEditor.d.ts +94 -0
- package/dist/admin/components/ProductScopeEditor.d.ts.map +1 -0
- package/dist/admin/components/ProductScopeEditor.js +395 -0
- package/dist/admin/components/ProductScopeEditor.js.map +1 -0
- package/dist/admin/components/ProductsBulkEditDialog.d.ts +11 -0
- package/dist/admin/components/ProductsBulkEditDialog.d.ts.map +1 -0
- package/dist/admin/components/ProductsBulkEditDialog.js +304 -0
- package/dist/admin/components/ProductsBulkEditDialog.js.map +1 -0
- package/dist/admin/index.d.ts +45 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +184 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/lib/resolve-product-selection.d.ts +21 -0
- package/dist/admin/lib/resolve-product-selection.d.ts.map +1 -0
- package/dist/admin/lib/resolve-product-selection.js +25 -0
- package/dist/admin/lib/resolve-product-selection.js.map +1 -0
- package/dist/admin/pages/AttachmentTypesPage.d.ts +18 -0
- package/dist/admin/pages/AttachmentTypesPage.d.ts.map +1 -0
- package/dist/admin/pages/AttachmentTypesPage.js +92 -0
- package/dist/admin/pages/AttachmentTypesPage.js.map +1 -0
- package/dist/admin/pages/AttributeSetsPage.d.ts +21 -0
- package/dist/admin/pages/AttributeSetsPage.d.ts.map +1 -0
- package/dist/admin/pages/AttributeSetsPage.js +158 -0
- package/dist/admin/pages/AttributeSetsPage.js.map +1 -0
- package/dist/admin/pages/AttributesManager.d.ts +9 -0
- package/dist/admin/pages/AttributesManager.d.ts.map +1 -0
- package/dist/admin/pages/AttributesManager.js +305 -0
- package/dist/admin/pages/AttributesManager.js.map +1 -0
- package/dist/admin/pages/BulkOperationDetailPage.d.ts +9 -0
- package/dist/admin/pages/BulkOperationDetailPage.d.ts.map +1 -0
- package/dist/admin/pages/BulkOperationDetailPage.js +187 -0
- package/dist/admin/pages/BulkOperationDetailPage.js.map +1 -0
- package/dist/admin/pages/BulkOperationsPage.d.ts +9 -0
- package/dist/admin/pages/BulkOperationsPage.d.ts.map +1 -0
- package/dist/admin/pages/BulkOperationsPage.js +98 -0
- package/dist/admin/pages/BulkOperationsPage.js.map +1 -0
- package/dist/admin/pages/CategoriesTree.d.ts +9 -0
- package/dist/admin/pages/CategoriesTree.d.ts.map +1 -0
- package/dist/admin/pages/CategoriesTree.js +176 -0
- package/dist/admin/pages/CategoriesTree.js.map +1 -0
- package/dist/admin/pages/ProductEditor.d.ts +13 -0
- package/dist/admin/pages/ProductEditor.d.ts.map +1 -0
- package/dist/admin/pages/ProductEditor.js +1050 -0
- package/dist/admin/pages/ProductEditor.js.map +1 -0
- package/dist/admin/pages/ProductsList.d.ts +9 -0
- package/dist/admin/pages/ProductsList.d.ts.map +1 -0
- package/dist/admin/pages/ProductsList.js +399 -0
- package/dist/admin/pages/ProductsList.js.map +1 -0
- package/dist/backend/commands/attribute-commands.d.ts +111 -0
- package/dist/backend/commands/attribute-commands.d.ts.map +1 -0
- package/dist/backend/commands/attribute-commands.js +482 -0
- package/dist/backend/commands/attribute-commands.js.map +1 -0
- package/dist/backend/demo/reset.d.ts +23 -0
- package/dist/backend/demo/reset.d.ts.map +1 -0
- package/dist/backend/demo/reset.js +41 -0
- package/dist/backend/demo/reset.js.map +1 -0
- package/dist/backend/demo/rows.d.ts +103 -0
- package/dist/backend/demo/rows.d.ts.map +1 -0
- package/dist/backend/demo/rows.js +162 -0
- package/dist/backend/demo/rows.js.map +1 -0
- package/dist/backend/demo/seed.d.ts +35 -0
- package/dist/backend/demo/seed.d.ts.map +1 -0
- package/dist/backend/demo/seed.js +177 -0
- package/dist/backend/demo/seed.js.map +1 -0
- package/dist/backend/entities/attachment-type.entity.d.ts +16 -0
- package/dist/backend/entities/attachment-type.entity.d.ts.map +1 -0
- package/dist/backend/entities/attachment-type.entity.js +57 -0
- package/dist/backend/entities/attachment-type.entity.js.map +1 -0
- package/dist/backend/entities/attribute-set-attribute.entity.d.ts +26 -0
- package/dist/backend/entities/attribute-set-attribute.entity.d.ts.map +1 -0
- package/dist/backend/entities/attribute-set-attribute.entity.js +55 -0
- package/dist/backend/entities/attribute-set-attribute.entity.js.map +1 -0
- package/dist/backend/entities/attribute-set.entity.d.ts +22 -0
- package/dist/backend/entities/attribute-set.entity.d.ts.map +1 -0
- package/dist/backend/entities/attribute-set.entity.js +67 -0
- package/dist/backend/entities/attribute-set.entity.js.map +1 -0
- package/dist/backend/entities/bulk-operation.entity.d.ts +78 -0
- package/dist/backend/entities/bulk-operation.entity.d.ts.map +1 -0
- package/dist/backend/entities/bulk-operation.entity.js +138 -0
- package/dist/backend/entities/bulk-operation.entity.js.map +1 -0
- package/dist/backend/entities/bundle-slot-option.entity.d.ts +23 -0
- package/dist/backend/entities/bundle-slot-option.entity.d.ts.map +1 -0
- package/dist/backend/entities/bundle-slot-option.entity.js +68 -0
- package/dist/backend/entities/bundle-slot-option.entity.js.map +1 -0
- package/dist/backend/entities/bundle-slot.entity.d.ts +25 -0
- package/dist/backend/entities/bundle-slot.entity.d.ts.map +1 -0
- package/dist/backend/entities/bundle-slot.entity.js +74 -0
- package/dist/backend/entities/bundle-slot.entity.js.map +1 -0
- package/dist/backend/entities/category.entity.d.ts +26 -0
- package/dist/backend/entities/category.entity.d.ts.map +1 -0
- package/dist/backend/entities/category.entity.js +119 -0
- package/dist/backend/entities/category.entity.js.map +1 -0
- package/dist/backend/entities/gallery-item-label.entity.d.ts +22 -0
- package/dist/backend/entities/gallery-item-label.entity.d.ts.map +1 -0
- package/dist/backend/entities/gallery-item-label.entity.js +50 -0
- package/dist/backend/entities/gallery-item-label.entity.js.map +1 -0
- package/dist/backend/entities/gallery-item.entity.d.ts +17 -0
- package/dist/backend/entities/gallery-item.entity.d.ts.map +1 -0
- package/dist/backend/entities/gallery-item.entity.js +58 -0
- package/dist/backend/entities/gallery-item.entity.js.map +1 -0
- package/dist/backend/entities/grouped-item.entity.d.ts +23 -0
- package/dist/backend/entities/grouped-item.entity.d.ts.map +1 -0
- package/dist/backend/entities/grouped-item.entity.js +68 -0
- package/dist/backend/entities/grouped-item.entity.js.map +1 -0
- package/dist/backend/entities/product-attachment.entity.d.ts +23 -0
- package/dist/backend/entities/product-attachment.entity.d.ts.map +1 -0
- package/dist/backend/entities/product-attachment.entity.js +77 -0
- package/dist/backend/entities/product-attachment.entity.js.map +1 -0
- package/dist/backend/entities/product-attribute.entity.d.ts +72 -0
- package/dist/backend/entities/product-attribute.entity.d.ts.map +1 -0
- package/dist/backend/entities/product-attribute.entity.js +161 -0
- package/dist/backend/entities/product-attribute.entity.js.map +1 -0
- package/dist/backend/entities/product-editor-preference.entity.d.ts +28 -0
- package/dist/backend/entities/product-editor-preference.entity.d.ts.map +1 -0
- package/dist/backend/entities/product-editor-preference.entity.js +63 -0
- package/dist/backend/entities/product-editor-preference.entity.js.map +1 -0
- package/dist/backend/entities/product-link.entity.d.ts +25 -0
- package/dist/backend/entities/product-link.entity.d.ts.map +1 -0
- package/dist/backend/entities/product-link.entity.js +70 -0
- package/dist/backend/entities/product-link.entity.js.map +1 -0
- package/dist/backend/entities/product-packaging-unit.entity.d.ts +26 -0
- package/dist/backend/entities/product-packaging-unit.entity.d.ts.map +1 -0
- package/dist/backend/entities/product-packaging-unit.entity.js +76 -0
- package/dist/backend/entities/product-packaging-unit.entity.js.map +1 -0
- package/dist/backend/entities/product-value-override.entity.d.ts +43 -0
- package/dist/backend/entities/product-value-override.entity.d.ts.map +1 -0
- package/dist/backend/entities/product-value-override.entity.js +90 -0
- package/dist/backend/entities/product-value-override.entity.js.map +1 -0
- package/dist/backend/entities/product-variant.entity.d.ts +18 -0
- package/dist/backend/entities/product-variant.entity.d.ts.map +1 -0
- package/dist/backend/entities/product-variant.entity.js +68 -0
- package/dist/backend/entities/product-variant.entity.js.map +1 -0
- package/dist/backend/entities/product.entity.d.ts +108 -0
- package/dist/backend/entities/product.entity.d.ts.map +1 -0
- package/dist/backend/entities/product.entity.js +230 -0
- package/dist/backend/entities/product.entity.js.map +1 -0
- package/dist/backend/index.d.ts +262 -0
- package/dist/backend/index.d.ts.map +1 -0
- package/dist/backend/index.js +642 -0
- package/dist/backend/index.js.map +1 -0
- package/dist/backend/plugin.d.ts +216 -0
- package/dist/backend/plugin.d.ts.map +1 -0
- package/dist/backend/plugin.js +223 -0
- package/dist/backend/plugin.js.map +1 -0
- package/dist/backend/prompt-tools.d.ts +47 -0
- package/dist/backend/prompt-tools.d.ts.map +1 -0
- package/dist/backend/prompt-tools.js +284 -0
- package/dist/backend/prompt-tools.js.map +1 -0
- package/dist/backend/routes.admin.d.ts +105 -0
- package/dist/backend/routes.admin.d.ts.map +1 -0
- package/dist/backend/routes.admin.js +1162 -0
- package/dist/backend/routes.admin.js.map +1 -0
- package/dist/backend/routes.api-key.d.ts +33 -0
- package/dist/backend/routes.api-key.d.ts.map +1 -0
- package/dist/backend/routes.api-key.js +43 -0
- package/dist/backend/routes.api-key.js.map +1 -0
- package/dist/backend/routes.external.d.ts +32 -0
- package/dist/backend/routes.external.d.ts.map +1 -0
- package/dist/backend/routes.external.js +115 -0
- package/dist/backend/routes.external.js.map +1 -0
- package/dist/backend/routes.public.d.ts +46 -0
- package/dist/backend/routes.public.d.ts.map +1 -0
- package/dist/backend/routes.public.js +318 -0
- package/dist/backend/routes.public.js.map +1 -0
- package/dist/backend/services/asset-references.d.ts +4 -0
- package/dist/backend/services/asset-references.d.ts.map +1 -0
- package/dist/backend/services/asset-references.js +104 -0
- package/dist/backend/services/asset-references.js.map +1 -0
- package/dist/backend/services/attachment.service.d.ts +45 -0
- package/dist/backend/services/attachment.service.d.ts.map +1 -0
- package/dist/backend/services/attachment.service.js +268 -0
- package/dist/backend/services/attachment.service.js.map +1 -0
- package/dist/backend/services/attribute-option-validator.d.ts +47 -0
- package/dist/backend/services/attribute-option-validator.d.ts.map +1 -0
- package/dist/backend/services/attribute-option-validator.js +69 -0
- package/dist/backend/services/attribute-option-validator.js.map +1 -0
- package/dist/backend/services/attribute-set-validations.d.ts +65 -0
- package/dist/backend/services/attribute-set-validations.d.ts.map +1 -0
- package/dist/backend/services/attribute-set-validations.js +89 -0
- package/dist/backend/services/attribute-set-validations.js.map +1 -0
- package/dist/backend/services/attribute-set.service.d.ts +50 -0
- package/dist/backend/services/attribute-set.service.d.ts.map +1 -0
- package/dist/backend/services/attribute-set.service.js +401 -0
- package/dist/backend/services/attribute-set.service.js.map +1 -0
- package/dist/backend/services/attribute-type-mapping.d.ts +66 -0
- package/dist/backend/services/attribute-type-mapping.d.ts.map +1 -0
- package/dist/backend/services/attribute-type-mapping.js +121 -0
- package/dist/backend/services/attribute-type-mapping.js.map +1 -0
- package/dist/backend/services/attribute-value-key.service.d.ts +48 -0
- package/dist/backend/services/attribute-value-key.service.d.ts.map +1 -0
- package/dist/backend/services/attribute-value-key.service.js +118 -0
- package/dist/backend/services/attribute-value-key.service.js.map +1 -0
- package/dist/backend/services/audit-references.d.ts +13 -0
- package/dist/backend/services/audit-references.d.ts.map +1 -0
- package/dist/backend/services/audit-references.js +51 -0
- package/dist/backend/services/audit-references.js.map +1 -0
- package/dist/backend/services/bulk-operation-queue.d.ts +26 -0
- package/dist/backend/services/bulk-operation-queue.d.ts.map +1 -0
- package/dist/backend/services/bulk-operation-queue.js +34 -0
- package/dist/backend/services/bulk-operation-queue.js.map +1 -0
- package/dist/backend/services/bulk-operation.service.d.ts +195 -0
- package/dist/backend/services/bulk-operation.service.d.ts.map +1 -0
- package/dist/backend/services/bulk-operation.service.js +510 -0
- package/dist/backend/services/bulk-operation.service.js.map +1 -0
- package/dist/backend/services/bundle.service.d.ts +87 -0
- package/dist/backend/services/bundle.service.d.ts.map +1 -0
- package/dist/backend/services/bundle.service.js +286 -0
- package/dist/backend/services/bundle.service.js.map +1 -0
- package/dist/backend/services/catalog-admin.service.d.ts +398 -0
- package/dist/backend/services/catalog-admin.service.d.ts.map +1 -0
- package/dist/backend/services/catalog-admin.service.js +1516 -0
- package/dist/backend/services/catalog-admin.service.js.map +1 -0
- package/dist/backend/services/catalog-attribute-read.service.d.ts +78 -0
- package/dist/backend/services/catalog-attribute-read.service.d.ts.map +1 -0
- package/dist/backend/services/catalog-attribute-read.service.js +216 -0
- package/dist/backend/services/catalog-attribute-read.service.js.map +1 -0
- package/dist/backend/services/catalog-bulk-import.service.d.ts +30 -0
- package/dist/backend/services/catalog-bulk-import.service.d.ts.map +1 -0
- package/dist/backend/services/catalog-bulk-import.service.js +136 -0
- package/dist/backend/services/catalog-bulk-import.service.js.map +1 -0
- package/dist/backend/services/catalog-bulk-update.service.d.ts +91 -0
- package/dist/backend/services/catalog-bulk-update.service.d.ts.map +1 -0
- package/dist/backend/services/catalog-bulk-update.service.js +330 -0
- package/dist/backend/services/catalog-bulk-update.service.js.map +1 -0
- package/dist/backend/services/catalog-category-read.service.d.ts +66 -0
- package/dist/backend/services/catalog-category-read.service.d.ts.map +1 -0
- package/dist/backend/services/catalog-category-read.service.js +248 -0
- package/dist/backend/services/catalog-category-read.service.js.map +1 -0
- package/dist/backend/services/catalog-org-price-decorator.d.ts +90 -0
- package/dist/backend/services/catalog-org-price-decorator.d.ts.map +1 -0
- package/dist/backend/services/catalog-org-price-decorator.js +184 -0
- package/dist/backend/services/catalog-org-price-decorator.js.map +1 -0
- package/dist/backend/services/catalog-product-filter.service.d.ts +82 -0
- package/dist/backend/services/catalog-product-filter.service.d.ts.map +1 -0
- package/dist/backend/services/catalog-product-filter.service.js +275 -0
- package/dist/backend/services/catalog-product-filter.service.js.map +1 -0
- package/dist/backend/services/catalog-product-read.service.d.ts +46 -0
- package/dist/backend/services/catalog-product-read.service.d.ts.map +1 -0
- package/dist/backend/services/catalog-product-read.service.js +197 -0
- package/dist/backend/services/catalog-product-read.service.js.map +1 -0
- package/dist/backend/services/catalog-query.service.d.ts +444 -0
- package/dist/backend/services/catalog-query.service.d.ts.map +1 -0
- package/dist/backend/services/catalog-query.service.js +1633 -0
- package/dist/backend/services/catalog-query.service.js.map +1 -0
- package/dist/backend/services/catalog-quick-search.service.d.ts +78 -0
- package/dist/backend/services/catalog-quick-search.service.d.ts.map +1 -0
- package/dist/backend/services/catalog-quick-search.service.js +159 -0
- package/dist/backend/services/catalog-quick-search.service.js.map +1 -0
- package/dist/backend/services/catalog-write-ports.d.ts +25 -0
- package/dist/backend/services/catalog-write-ports.d.ts.map +1 -0
- package/dist/backend/services/catalog-write-ports.js +63 -0
- package/dist/backend/services/catalog-write-ports.js.map +1 -0
- package/dist/backend/services/category-admin.service.d.ts +102 -0
- package/dist/backend/services/category-admin.service.d.ts.map +1 -0
- package/dist/backend/services/category-admin.service.js +271 -0
- package/dist/backend/services/category-admin.service.js.map +1 -0
- package/dist/backend/services/gallery.service.d.ts +85 -0
- package/dist/backend/services/gallery.service.d.ts.map +1 -0
- package/dist/backend/services/gallery.service.js +326 -0
- package/dist/backend/services/gallery.service.js.map +1 -0
- package/dist/backend/services/grouped.service.d.ts +39 -0
- package/dist/backend/services/grouped.service.d.ts.map +1 -0
- package/dist/backend/services/grouped.service.js +136 -0
- package/dist/backend/services/grouped.service.js.map +1 -0
- package/dist/backend/services/label-resolver.d.ts +16 -0
- package/dist/backend/services/label-resolver.d.ts.map +1 -0
- package/dist/backend/services/label-resolver.js +43 -0
- package/dist/backend/services/label-resolver.js.map +1 -0
- package/dist/backend/services/packaging-unit.service.d.ts +28 -0
- package/dist/backend/services/packaging-unit.service.d.ts.map +1 -0
- package/dist/backend/services/packaging-unit.service.js +197 -0
- package/dist/backend/services/packaging-unit.service.js.map +1 -0
- package/dist/backend/services/primary-asset-url.d.ts +4 -0
- package/dist/backend/services/primary-asset-url.d.ts.map +1 -0
- package/dist/backend/services/primary-asset-url.js +55 -0
- package/dist/backend/services/primary-asset-url.js.map +1 -0
- package/dist/backend/services/product-editor-preferences.service.d.ts +20 -0
- package/dist/backend/services/product-editor-preferences.service.d.ts.map +1 -0
- package/dist/backend/services/product-editor-preferences.service.js +44 -0
- package/dist/backend/services/product-editor-preferences.service.js.map +1 -0
- package/dist/backend/services/product-link.service.d.ts +169 -0
- package/dist/backend/services/product-link.service.d.ts.map +1 -0
- package/dist/backend/services/product-link.service.js +297 -0
- package/dist/backend/services/product-link.service.js.map +1 -0
- package/dist/backend/services/product-overrides.service.d.ts +77 -0
- package/dist/backend/services/product-overrides.service.d.ts.map +1 -0
- package/dist/backend/services/product-overrides.service.js +204 -0
- package/dist/backend/services/product-overrides.service.js.map +1 -0
- package/dist/backend/services/product-scope-context.service.d.ts +36 -0
- package/dist/backend/services/product-scope-context.service.d.ts.map +1 -0
- package/dist/backend/services/product-scope-context.service.js +106 -0
- package/dist/backend/services/product-scope-context.service.js.map +1 -0
- package/dist/backend/services/product-type-validations.d.ts +42 -0
- package/dist/backend/services/product-type-validations.d.ts.map +1 -0
- package/dist/backend/services/product-type-validations.js +55 -0
- package/dist/backend/services/product-type-validations.js.map +1 -0
- package/dist/backend/services/product-value-resolver.service.d.ts +59 -0
- package/dist/backend/services/product-value-resolver.service.d.ts.map +1 -0
- package/dist/backend/services/product-value-resolver.service.js +111 -0
- package/dist/backend/services/product-value-resolver.service.js.map +1 -0
- package/dist/backend/services/system-attribute-scopes.d.ts +18 -0
- package/dist/backend/services/system-attribute-scopes.d.ts.map +1 -0
- package/dist/backend/services/system-attribute-scopes.js +18 -0
- package/dist/backend/services/system-attribute-scopes.js.map +1 -0
- package/dist/backend/services/viewer-organization.d.ts +25 -0
- package/dist/backend/services/viewer-organization.d.ts.map +1 -0
- package/dist/backend/services/viewer-organization.js +31 -0
- package/dist/backend/services/viewer-organization.js.map +1 -0
- package/dist/manifest.d.ts +204 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +686 -0
- package/dist/manifest.js.map +1 -0
- package/dist/migrations/20260429T064146_catalog_attribute_sets_init.d.ts +33 -0
- package/dist/migrations/20260429T064146_catalog_attribute_sets_init.d.ts.map +1 -0
- package/dist/migrations/20260429T064146_catalog_attribute_sets_init.js +118 -0
- package/dist/migrations/20260429T064146_catalog_attribute_sets_init.js.map +1 -0
- package/dist/migrations/20260429T070004_catalog_product_attribute_extensions.d.ts +24 -0
- package/dist/migrations/20260429T070004_catalog_product_attribute_extensions.d.ts.map +1 -0
- package/dist/migrations/20260429T070004_catalog_product_attribute_extensions.js +31 -0
- package/dist/migrations/20260429T070004_catalog_product_attribute_extensions.js.map +1 -0
- package/dist/migrations/20260429T102322_catalog_product_type_and_virtual_fields.d.ts +32 -0
- package/dist/migrations/20260429T102322_catalog_product_type_and_virtual_fields.d.ts.map +1 -0
- package/dist/migrations/20260429T102322_catalog_product_type_and_virtual_fields.js +58 -0
- package/dist/migrations/20260429T102322_catalog_product_type_and_virtual_fields.js.map +1 -0
- package/dist/migrations/20260429T111839_catalog_gallery_items_and_labels.d.ts +22 -0
- package/dist/migrations/20260429T111839_catalog_gallery_items_and_labels.d.ts.map +1 -0
- package/dist/migrations/20260429T111839_catalog_gallery_items_and_labels.js +66 -0
- package/dist/migrations/20260429T111839_catalog_gallery_items_and_labels.js.map +1 -0
- package/dist/migrations/20260429T112543_catalog_product_attachments.d.ts +18 -0
- package/dist/migrations/20260429T112543_catalog_product_attachments.d.ts.map +1 -0
- package/dist/migrations/20260429T112543_catalog_product_attachments.js +73 -0
- package/dist/migrations/20260429T112543_catalog_product_attachments.js.map +1 -0
- package/dist/migrations/20260429T123726_catalog_product_links.d.ts +21 -0
- package/dist/migrations/20260429T123726_catalog_product_links.d.ts.map +1 -0
- package/dist/migrations/20260429T123726_catalog_product_links.js +48 -0
- package/dist/migrations/20260429T123726_catalog_product_links.js.map +1 -0
- package/dist/migrations/20260429T130803_catalog_grouped_and_bundle.d.ts +23 -0
- package/dist/migrations/20260429T130803_catalog_grouped_and_bundle.d.ts.map +1 -0
- package/dist/migrations/20260429T130803_catalog_grouped_and_bundle.js +94 -0
- package/dist/migrations/20260429T130803_catalog_grouped_and_bundle.js.map +1 -0
- package/dist/migrations/20260501T185835_catalog_product_attribute_is_comparable.d.ts +24 -0
- package/dist/migrations/20260501T185835_catalog_product_attribute_is_comparable.d.ts.map +1 -0
- package/dist/migrations/20260501T185835_catalog_product_attribute_is_comparable.js +31 -0
- package/dist/migrations/20260501T185835_catalog_product_attribute_is_comparable.js.map +1 -0
- package/dist/migrations/20260505T060113_catalog_attribute_options_and_flags.d.ts +31 -0
- package/dist/migrations/20260505T060113_catalog_attribute_options_and_flags.d.ts.map +1 -0
- package/dist/migrations/20260505T060113_catalog_attribute_options_and_flags.js +133 -0
- package/dist/migrations/20260505T060113_catalog_attribute_options_and_flags.js.map +1 -0
- package/dist/migrations/20260515T082629_catalog_attribute_mass_editable.d.ts +16 -0
- package/dist/migrations/20260515T082629_catalog_attribute_mass_editable.d.ts.map +1 -0
- package/dist/migrations/20260515T082629_catalog_attribute_mass_editable.js +23 -0
- package/dist/migrations/20260515T082629_catalog_attribute_mass_editable.js.map +1 -0
- package/dist/migrations/20260526T124736_catalog_product_status_inactive.d.ts +10 -0
- package/dist/migrations/20260526T124736_catalog_product_status_inactive.d.ts.map +1 -0
- package/dist/migrations/20260526T124736_catalog_product_status_inactive.js +22 -0
- package/dist/migrations/20260526T124736_catalog_product_status_inactive.js.map +1 -0
- package/dist/migrations/20260611T140346_catalog_product_value_overrides_init.d.ts +38 -0
- package/dist/migrations/20260611T140346_catalog_product_value_overrides_init.d.ts.map +1 -0
- package/dist/migrations/20260611T140346_catalog_product_value_overrides_init.js +98 -0
- package/dist/migrations/20260611T140346_catalog_product_value_overrides_init.js.map +1 -0
- package/dist/migrations/20260611T140400_catalog_attribute_quick_searchable.d.ts +13 -0
- package/dist/migrations/20260611T140400_catalog_attribute_quick_searchable.d.ts.map +1 -0
- package/dist/migrations/20260611T140400_catalog_attribute_quick_searchable.js +17 -0
- package/dist/migrations/20260611T140400_catalog_attribute_quick_searchable.js.map +1 -0
- package/dist/migrations/20260611T140407_catalog_bulk_operations.d.ts +16 -0
- package/dist/migrations/20260611T140407_catalog_bulk_operations.d.ts.map +1 -0
- package/dist/migrations/20260611T140407_catalog_bulk_operations.js +42 -0
- package/dist/migrations/20260611T140407_catalog_bulk_operations.js.map +1 -0
- package/dist/migrations/20260611T140408_catalog_bulk_operation_logs.d.ts +14 -0
- package/dist/migrations/20260611T140408_catalog_bulk_operation_logs.d.ts.map +1 -0
- package/dist/migrations/20260611T140408_catalog_bulk_operation_logs.js +18 -0
- package/dist/migrations/20260611T140408_catalog_bulk_operation_logs.js.map +1 -0
- package/dist/migrations/20260611T140412_catalog_product_packaging_units.d.ts +11 -0
- package/dist/migrations/20260611T140412_catalog_product_packaging_units.d.ts.map +1 -0
- package/dist/migrations/20260611T140412_catalog_product_packaging_units.js +29 -0
- package/dist/migrations/20260611T140412_catalog_product_packaging_units.js.map +1 -0
- package/dist/migrations/20260718T060659_catalog_bulk_operation_revert_state.d.ts +23 -0
- package/dist/migrations/20260718T060659_catalog_bulk_operation_revert_state.d.ts.map +1 -0
- package/dist/migrations/20260718T060659_catalog_bulk_operation_revert_state.js +35 -0
- package/dist/migrations/20260718T060659_catalog_bulk_operation_revert_state.js.map +1 -0
- package/dist/migrations/20260718T200343_catalog_category_custom_field_values.d.ts +12 -0
- package/dist/migrations/20260718T200343_catalog_category_custom_field_values.d.ts.map +1 -0
- package/dist/migrations/20260718T200343_catalog_category_custom_field_values.js +16 -0
- package/dist/migrations/20260718T200343_catalog_category_custom_field_values.js.map +1 -0
- package/dist/migrations/20260723T230401_catalog_attributes_on_custom_fields.d.ts +32 -0
- package/dist/migrations/20260723T230401_catalog_attributes_on_custom_fields.d.ts.map +1 -0
- package/dist/migrations/20260723T230401_catalog_attributes_on_custom_fields.js +286 -0
- package/dist/migrations/20260723T230401_catalog_attributes_on_custom_fields.js.map +1 -0
- package/dist/migrations/20260804T152604_catalog_widen_product_sku.d.ts +36 -0
- package/dist/migrations/20260804T152604_catalog_widen_product_sku.d.ts.map +1 -0
- package/dist/migrations/20260804T152604_catalog_widen_product_sku.js +63 -0
- package/dist/migrations/20260804T152604_catalog_widen_product_sku.js.map +1 -0
- package/dist/migrations/20260804T160244_catalog_category_activation.d.ts +18 -0
- package/dist/migrations/20260804T160244_catalog_category_activation.d.ts.map +1 -0
- package/dist/migrations/20260804T160244_catalog_category_activation.js +22 -0
- package/dist/migrations/20260804T160244_catalog_category_activation.js.map +1 -0
- package/dist/migrations/20260912T094557_catalog_sales_channel_products.d.ts +27 -0
- package/dist/migrations/20260912T094557_catalog_sales_channel_products.d.ts.map +1 -0
- package/dist/migrations/20260912T094557_catalog_sales_channel_products.js +44 -0
- package/dist/migrations/20260912T094557_catalog_sales_channel_products.js.map +1 -0
- package/dist/migrations/20260912T094623_catalog_sales_channel_categories.d.ts +27 -0
- package/dist/migrations/20260912T094623_catalog_sales_channel_categories.d.ts.map +1 -0
- package/dist/migrations/20260912T094623_catalog_sales_channel_categories.js +44 -0
- package/dist/migrations/20260912T094623_catalog_sales_channel_categories.js.map +1 -0
- package/dist/migrations/20260925T125527_catalog_inventory_columns.d.ts +36 -0
- package/dist/migrations/20260925T125527_catalog_inventory_columns.d.ts.map +1 -0
- package/dist/migrations/20260925T125527_catalog_inventory_columns.js +87 -0
- package/dist/migrations/20260925T125527_catalog_inventory_columns.js.map +1 -0
- package/dist/migrations/index.d.ts +55 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/migrations/index.js +80 -0
- package/dist/migrations/index.js.map +1 -0
- package/dist/ports/index.d.ts +96 -0
- package/dist/ports/index.d.ts.map +1 -0
- package/dist/ports/index.js +2 -0
- package/dist/ports/index.js.map +1 -0
- package/docs/catalog/attachments.md +69 -0
- package/docs/catalog/attribute-sets.md +63 -0
- package/docs/catalog/attributes.md +244 -0
- package/docs/catalog/composite-products.md +115 -0
- package/docs/catalog/gallery-and-labels.md +73 -0
- package/docs/catalog/packaging-units.md +67 -0
- package/docs/catalog/per-channel-per-language-overrides.md +234 -0
- package/docs/catalog/product-links.md +79 -0
- package/docs/catalog.md +207 -0
- package/i18n/en.json +649 -0
- package/i18n/pl.json +649 -0
- package/package.json +109 -0
- package/tailwind.css +14 -0
|
@@ -0,0 +1,1050 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react';
|
|
3
|
+
import { useNavigate, useParams } from 'react-router-dom';
|
|
4
|
+
import { Trash2, ChevronLeft, Copy, Eye, FileText as FileTextIcon, Globe, GripVertical, Image as ImageIcon, CircleDollarSign, Layers, Link as LinkIcon, Paperclip, Save, SlidersHorizontal, Store as StoreIcon, Warehouse as WarehouseIcon, } from 'lucide-react';
|
|
5
|
+
import { ApiError, apiClient, useUnsavedChangesPrompt, toAbsoluteAssetUrl, cn } from '@endora-commerce/admin-kit/lib';
|
|
6
|
+
import { Alert, AlertDescription, Button, Card, CardContent, CardHeader, CardTitle, Input, Label, Select, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@endora-commerce/admin-kit/ui';
|
|
7
|
+
import { AssetPicker, StickyFormActions, TouchReorderButtons, CategoryTreePicker, ProductPicker } from '@endora-commerce/admin-kit/components';
|
|
8
|
+
import { useTranslation } from '@endora-commerce/admin-kit/i18n';
|
|
9
|
+
import { AdminZone, useAdminZone } from '@endora-commerce/admin-kit/zones';
|
|
10
|
+
import { ProductInventoryTab } from '../components/ProductInventoryTab.js';
|
|
11
|
+
import { PackagingUnitsEditor } from '../components/PackagingUnitsEditor.js';
|
|
12
|
+
import { ProductAttributesTab } from '../components/ProductAttributesTab.js';
|
|
13
|
+
import { ProductScopeEditor, } from '../components/ProductScopeEditor.js';
|
|
14
|
+
const LOCALES = ['en-US', 'pl-PL'];
|
|
15
|
+
const PRODUCT_TYPES = [
|
|
16
|
+
'simple',
|
|
17
|
+
'configurable',
|
|
18
|
+
'grouped',
|
|
19
|
+
'bundle',
|
|
20
|
+
'virtual',
|
|
21
|
+
];
|
|
22
|
+
const VISIBILITIES = ['public', 'logged_in_only', 'organization_restricted'];
|
|
23
|
+
const STATUSES = ['draft', 'active', 'inactive'];
|
|
24
|
+
/**
|
|
25
|
+
* Order-insensitive JSON snapshot of the editable form fields, used to detect
|
|
26
|
+
* unsaved changes (object keys + array members are sorted so re-ordering alone
|
|
27
|
+
* never reads as a change).
|
|
28
|
+
*/
|
|
29
|
+
function stableSnapshot(values) {
|
|
30
|
+
return JSON.stringify(values, (_key, value) => {
|
|
31
|
+
if (Array.isArray(value))
|
|
32
|
+
return [...value].sort();
|
|
33
|
+
if (value && typeof value === 'object') {
|
|
34
|
+
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)));
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
export function ProductEditor() {
|
|
40
|
+
const t = useTranslation('catalog');
|
|
41
|
+
const params = useParams();
|
|
42
|
+
const navigate = useNavigate();
|
|
43
|
+
const isNew = params.id === 'new';
|
|
44
|
+
const id = isNew ? null : params.id ?? null;
|
|
45
|
+
// Feature 091 / P7a, Z15 — the Channels tab's button is a count of its own
|
|
46
|
+
// body. Declared here rather than beside the tab table because the table is
|
|
47
|
+
// built below an early return and a hook may not be.
|
|
48
|
+
const channelContributions = useAdminZone('product.editor.channels', {
|
|
49
|
+
productId: id ?? '',
|
|
50
|
+
});
|
|
51
|
+
const [loading, setLoading] = useState(!isNew);
|
|
52
|
+
const [error, setError] = useState(null);
|
|
53
|
+
const [info, setInfo] = useState(null);
|
|
54
|
+
const [categories, setCategories] = useState([]);
|
|
55
|
+
const [sku, setSku] = useState('');
|
|
56
|
+
const [type, setType] = useState('simple');
|
|
57
|
+
const [status, setStatus] = useState('draft');
|
|
58
|
+
const [visibility, setVisibility] = useState('public');
|
|
59
|
+
const [name, setName] = useState({ 'en-US': '', 'pl-PL': '' });
|
|
60
|
+
const [description, setDescription] = useState({
|
|
61
|
+
'en-US': '',
|
|
62
|
+
'pl-PL': '',
|
|
63
|
+
});
|
|
64
|
+
const [defaultPrice, setDefaultPrice] = useState('');
|
|
65
|
+
const [categoryIds, setCategoryIds] = useState([]);
|
|
66
|
+
const [activeTab, setActiveTab] = useState('details');
|
|
67
|
+
// Feature 002 (T034) — Attribute Set selector
|
|
68
|
+
const [attributeSets, setAttributeSets] = useState([]);
|
|
69
|
+
const [attributeSetId, setAttributeSetId] = useState('');
|
|
70
|
+
// Editable attribute values keyed by attribute `key` (Attributes tab).
|
|
71
|
+
const [attrValues, setAttrValues] = useState({});
|
|
72
|
+
// Feature 023 — imperative handle on the scope-editor so the page Save
|
|
73
|
+
// can flush every pending channel-scoped override in a single PATCH.
|
|
74
|
+
const scopeEditorRef = useRef(null);
|
|
75
|
+
// Media tab — page-Save flushes staged gallery label/order edits via this
|
|
76
|
+
// imperative handle (mirrors the scope-editor pattern above).
|
|
77
|
+
const galleryRef = useRef(null);
|
|
78
|
+
// Unsaved-changes guard — a stable snapshot of the editable fields is taken
|
|
79
|
+
// on load (and re-taken after each save via refresh()); the form is "dirty"
|
|
80
|
+
// whenever the live values diverge from it.
|
|
81
|
+
const initialSnapshotRef = useRef('');
|
|
82
|
+
const currentSnapshot = stableSnapshot({
|
|
83
|
+
sku,
|
|
84
|
+
type,
|
|
85
|
+
status,
|
|
86
|
+
visibility,
|
|
87
|
+
name,
|
|
88
|
+
description,
|
|
89
|
+
defaultPrice,
|
|
90
|
+
categoryIds,
|
|
91
|
+
attributeSetId,
|
|
92
|
+
attrValues,
|
|
93
|
+
});
|
|
94
|
+
const dirty = !loading &&
|
|
95
|
+
initialSnapshotRef.current !== '' &&
|
|
96
|
+
currentSnapshot !== initialSnapshotRef.current;
|
|
97
|
+
useUnsavedChangesPrompt(dirty);
|
|
98
|
+
const refresh = useCallback(async () => {
|
|
99
|
+
setLoading(true);
|
|
100
|
+
setError(null);
|
|
101
|
+
try {
|
|
102
|
+
const [cats, sets] = await Promise.all([
|
|
103
|
+
apiClient.get('/api/v1/admin/catalog/categories'),
|
|
104
|
+
apiClient.get('/api/v1/admin/catalog/attribute-sets'),
|
|
105
|
+
]);
|
|
106
|
+
setCategories(cats.data);
|
|
107
|
+
setAttributeSets(sets.data);
|
|
108
|
+
// Pick the system Default as the initial selection for new Products.
|
|
109
|
+
const defaultSet = sets.data.find((s) => s.code === 'default');
|
|
110
|
+
if (isNew && defaultSet)
|
|
111
|
+
setAttributeSetId(defaultSet.id);
|
|
112
|
+
if (isNew) {
|
|
113
|
+
// Baseline for a brand-new product matches the useState defaults.
|
|
114
|
+
initialSnapshotRef.current = stableSnapshot({
|
|
115
|
+
sku: '',
|
|
116
|
+
type: 'simple',
|
|
117
|
+
status: 'draft',
|
|
118
|
+
visibility: 'public',
|
|
119
|
+
name: { 'en-US': '', 'pl-PL': '' },
|
|
120
|
+
description: { 'en-US': '', 'pl-PL': '' },
|
|
121
|
+
defaultPrice: '',
|
|
122
|
+
categoryIds: [],
|
|
123
|
+
attributeSetId: defaultSet?.id ?? '',
|
|
124
|
+
attrValues: {},
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
if (id) {
|
|
128
|
+
const res = await apiClient.get(`/api/v1/admin/catalog/products/${id}`);
|
|
129
|
+
const p = res.data;
|
|
130
|
+
setSku(p.sku);
|
|
131
|
+
setType(p.type);
|
|
132
|
+
setStatus(p.status);
|
|
133
|
+
setVisibility(p.visibility);
|
|
134
|
+
const loadedName = {
|
|
135
|
+
'en-US': p.name['en-US'] ?? '',
|
|
136
|
+
'pl-PL': p.name['pl-PL'] ?? '',
|
|
137
|
+
};
|
|
138
|
+
setName(loadedName);
|
|
139
|
+
const loadedDescription = {
|
|
140
|
+
'en-US': p.description['en-US'] ?? '',
|
|
141
|
+
'pl-PL': p.description['pl-PL'] ?? '',
|
|
142
|
+
};
|
|
143
|
+
setDescription(loadedDescription);
|
|
144
|
+
const price = p.attributeValues['defaultPrice'] ?? null;
|
|
145
|
+
const loadedPrice = price != null ? String(price) : '';
|
|
146
|
+
setDefaultPrice(loadedPrice);
|
|
147
|
+
setAttrValues(p.attributeValues ?? {});
|
|
148
|
+
setAttributeSetId(p.attributeSetId);
|
|
149
|
+
setCategoryIds(p.categoryIds ?? []);
|
|
150
|
+
// Capture the loaded state as the clean baseline for dirty-tracking.
|
|
151
|
+
initialSnapshotRef.current = stableSnapshot({
|
|
152
|
+
sku: p.sku,
|
|
153
|
+
type: p.type,
|
|
154
|
+
status: p.status,
|
|
155
|
+
visibility: p.visibility,
|
|
156
|
+
name: loadedName,
|
|
157
|
+
description: loadedDescription,
|
|
158
|
+
defaultPrice: loadedPrice,
|
|
159
|
+
categoryIds: p.categoryIds ?? [],
|
|
160
|
+
attributeSetId: p.attributeSetId,
|
|
161
|
+
attrValues: p.attributeValues ?? {},
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
setError(err instanceof ApiError ? err.envelope.error.message : 'Failed to load.');
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
setLoading(false);
|
|
170
|
+
}
|
|
171
|
+
}, [id, isNew]);
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
void refresh();
|
|
174
|
+
}, [refresh]);
|
|
175
|
+
const handleSave = useCallback(async (e) => {
|
|
176
|
+
e.preventDefault();
|
|
177
|
+
setError(null);
|
|
178
|
+
setInfo(null);
|
|
179
|
+
const trimmedName = {};
|
|
180
|
+
const trimmedDescription = {};
|
|
181
|
+
for (const l of LOCALES) {
|
|
182
|
+
if (name[l])
|
|
183
|
+
trimmedName[l] = name[l];
|
|
184
|
+
if (description[l])
|
|
185
|
+
trimmedDescription[l] = description[l];
|
|
186
|
+
}
|
|
187
|
+
if (Object.keys(trimmedName).length === 0) {
|
|
188
|
+
setError('At least one locale name is required.');
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// Seed from the Attributes-tab edits; `defaultPrice` is a virtual
|
|
192
|
+
// attribute owned by the Details tab and re-applied just below.
|
|
193
|
+
const attributeValues = { ...attrValues };
|
|
194
|
+
delete attributeValues['defaultPrice'];
|
|
195
|
+
if (defaultPrice.trim()) {
|
|
196
|
+
const v = Number(defaultPrice);
|
|
197
|
+
if (!Number.isFinite(v) || v < 0) {
|
|
198
|
+
setError('Default price must be a number ≥ 0.');
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
attributeValues['defaultPrice'] = v;
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
if (isNew) {
|
|
205
|
+
const res = await apiClient.post('/api/v1/admin/catalog/products', {
|
|
206
|
+
sku,
|
|
207
|
+
type,
|
|
208
|
+
name: trimmedName,
|
|
209
|
+
description: trimmedDescription,
|
|
210
|
+
categoryIds,
|
|
211
|
+
attributeValues,
|
|
212
|
+
visibility,
|
|
213
|
+
...(attributeSetId ? { attributeSetId } : {}),
|
|
214
|
+
});
|
|
215
|
+
navigate(`/catalog/products/${res.data.id}`);
|
|
216
|
+
}
|
|
217
|
+
else if (id) {
|
|
218
|
+
// Feature 023 — page-Save commits both the per-language baseline
|
|
219
|
+
// (carried by `trimmedName` / `trimmedDescription` via the existing
|
|
220
|
+
// PATCH) AND any pending channel-scoped override drafts held in
|
|
221
|
+
// the ProductScopeEditor panel. Run them in parallel; if the
|
|
222
|
+
// override flush rejects, the page PATCH still committed.
|
|
223
|
+
await Promise.all([
|
|
224
|
+
apiClient.patch(`/api/v1/admin/catalog/products/${id}`, {
|
|
225
|
+
sku: sku.trim(),
|
|
226
|
+
status,
|
|
227
|
+
name: trimmedName,
|
|
228
|
+
description: trimmedDescription,
|
|
229
|
+
categoryIds,
|
|
230
|
+
attributeValues,
|
|
231
|
+
visibility,
|
|
232
|
+
...(attributeSetId ? { attributeSetId } : {}),
|
|
233
|
+
}),
|
|
234
|
+
scopeEditorRef.current?.flushOverrides() ?? Promise.resolve(),
|
|
235
|
+
galleryRef.current?.flushGallery() ?? Promise.resolve(),
|
|
236
|
+
]);
|
|
237
|
+
setInfo('Saved.');
|
|
238
|
+
await refresh();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
setError(err instanceof ApiError ? err.envelope.error.message : 'Save failed.');
|
|
243
|
+
}
|
|
244
|
+
}, [isNew, id, sku, status, type, name, description, categoryIds, defaultPrice, attrValues, attributeSetId, visibility, navigate, refresh]);
|
|
245
|
+
const handleAttrChange = useCallback((key, value) => {
|
|
246
|
+
setAttrValues((prev) => ({ ...prev, [key]: value }));
|
|
247
|
+
}, []);
|
|
248
|
+
// Swapping the Attribute Set drops the values whose keys are not in the
|
|
249
|
+
// newly-selected set, so the save payload doesn't carry orphan keys left
|
|
250
|
+
// over from the previous set. `defaultPrice` / `price` are virtual keys
|
|
251
|
+
// owned by the Details tab (not bound to any set) and are always kept.
|
|
252
|
+
const handleAttributeSetChange = useCallback(async (newSetId) => {
|
|
253
|
+
setAttributeSetId(newSetId);
|
|
254
|
+
if (!newSetId)
|
|
255
|
+
return;
|
|
256
|
+
try {
|
|
257
|
+
const res = await apiClient.get(`/api/v1/admin/catalog/attribute-sets/${newSetId}`);
|
|
258
|
+
const allowed = new Set(res.data.attributes.map((a) => a.key));
|
|
259
|
+
setAttrValues((prev) => {
|
|
260
|
+
const next = {};
|
|
261
|
+
for (const [k, v] of Object.entries(prev)) {
|
|
262
|
+
if (allowed.has(k) || k === 'defaultPrice' || k === 'price') {
|
|
263
|
+
next[k] = v;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return next;
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// Non-fatal — the backend tolerates round-tripped keys, so leaving
|
|
271
|
+
// the values untouched on a fetch failure still saves correctly.
|
|
272
|
+
}
|
|
273
|
+
}, []);
|
|
274
|
+
const handleDeleteProduct = useCallback(async () => {
|
|
275
|
+
if (!id)
|
|
276
|
+
return;
|
|
277
|
+
if (!confirm(t('productEditor.deleteConfirm')))
|
|
278
|
+
return;
|
|
279
|
+
try {
|
|
280
|
+
await apiClient.delete(`/api/v1/admin/catalog/products/${id}`);
|
|
281
|
+
navigate('/catalog/products');
|
|
282
|
+
}
|
|
283
|
+
catch (err) {
|
|
284
|
+
setError(err instanceof ApiError ? err.envelope.error.message : 'Delete failed.');
|
|
285
|
+
}
|
|
286
|
+
}, [id, navigate, t]);
|
|
287
|
+
const [duplicating, setDuplicating] = useState(false);
|
|
288
|
+
const handleDuplicate = useCallback(async () => {
|
|
289
|
+
if (!id || duplicating)
|
|
290
|
+
return;
|
|
291
|
+
if (!confirm('Duplicate this product? A draft copy will be created.'))
|
|
292
|
+
return;
|
|
293
|
+
setDuplicating(true);
|
|
294
|
+
setError(null);
|
|
295
|
+
try {
|
|
296
|
+
const res = await apiClient.post(`/api/v1/admin/catalog/products/${id}/duplicate`, {});
|
|
297
|
+
navigate(`/catalog/products/${res.data.id}`);
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
setError(err instanceof ApiError ? err.envelope.error.message : 'Duplicate failed.');
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
setDuplicating(false);
|
|
304
|
+
}
|
|
305
|
+
}, [id, duplicating, navigate]);
|
|
306
|
+
if (loading)
|
|
307
|
+
return (_jsx("div", { className: "b2b-page b2b-page--wide", children: _jsx("div", { style: { padding: 32, color: 'var(--fg-muted)', fontSize: 13 }, children: t('productEditor.loading') }) }));
|
|
308
|
+
const tabs = [
|
|
309
|
+
{ id: 'details', label: 'Details', icon: _jsx(FileTextIcon, { size: 14 }), show: true },
|
|
310
|
+
{ id: 'attributes', label: 'Attributes', icon: _jsx(SlidersHorizontal, { size: 14 }), show: !isNew },
|
|
311
|
+
{ id: 'pricing', label: 'Pricing', icon: _jsx(CircleDollarSign, { size: 14 }), show: !isNew },
|
|
312
|
+
{
|
|
313
|
+
id: 'variants',
|
|
314
|
+
label: 'Variants',
|
|
315
|
+
icon: _jsx(Layers, { size: 14 }),
|
|
316
|
+
show: !isNew && type === 'configurable',
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
id: 'composite',
|
|
320
|
+
label: type === 'grouped' ? 'Grouped items' : 'Bundle slots',
|
|
321
|
+
icon: _jsx(Layers, { size: 14 }),
|
|
322
|
+
show: !isNew && (type === 'grouped' || type === 'bundle'),
|
|
323
|
+
},
|
|
324
|
+
{ id: 'media', label: 'Media', icon: _jsx(ImageIcon, { size: 14 }), show: !isNew },
|
|
325
|
+
{ id: 'inventory', label: 'Inventory', icon: _jsx(WarehouseIcon, { size: 14 }), show: !isNew },
|
|
326
|
+
{ id: 'attachments', label: 'Attachments', icon: _jsx(Paperclip, { size: 14 }), show: !isNew },
|
|
327
|
+
{ id: 'links', label: 'Related', icon: _jsx(LinkIcon, { size: 14 }), show: !isNew },
|
|
328
|
+
// Feature 091 / P7a, Z15 — the tab body is a zone, so the button is shown
|
|
329
|
+
// by counting it. `useAdminZone` has already applied both presence axes and
|
|
330
|
+
// each contributor's permission, so its length is the honest answer to "is
|
|
331
|
+
// there anything behind this tab" — and a tab that opens on an empty panel
|
|
332
|
+
// is worse than no tab. The label stays this screen's own word for the
|
|
333
|
+
// place (Z14); what it stops knowing is which module fills it.
|
|
334
|
+
{
|
|
335
|
+
id: 'channels',
|
|
336
|
+
label: 'Channels',
|
|
337
|
+
icon: _jsx(StoreIcon, { size: 14 }),
|
|
338
|
+
show: !isNew && channelContributions.length > 0,
|
|
339
|
+
},
|
|
340
|
+
{ id: 'seo', label: 'SEO', icon: _jsx(Globe, { size: 14 }), show: !isNew },
|
|
341
|
+
];
|
|
342
|
+
const visibleTabs = tabs.filter((t) => t.show);
|
|
343
|
+
return (_jsxs("div", { className: "b2b-page b2b-page--wide", children: [_jsxs("div", { className: "b2b-page-head", children: [_jsxs("div", { className: "b2b-grow", children: [_jsxs("button", { type: "button", className: "b2b-page-head__back", onClick: () => { navigate('/catalog/products'); }, children: [_jsx(ChevronLeft, { size: 14 }), " Products"] }), _jsxs("div", { className: "b2b-page-head__title", children: [_jsx("span", { children: isNew ? 'New product' : pickName(name) || sku }), !isNew ? _jsx(StatusPill, { status: status }) : null] }), !isNew ? (_jsxs("div", { className: "b2b-page-head__sub", children: [_jsx("span", { className: "b2b-mono", children: sku }), " \u00B7 ", _jsx("span", { style: { textTransform: 'capitalize' }, children: type })] })) : null] }), _jsxs(StickyFormActions, { className: "b2b-page-head__actions", children: [!isNew ? (_jsxs(_Fragment, { children: [_jsxs("button", { type: "button", className: "b2b-btn b2b-btn--default b2b-btn--sm", children: [_jsx(Eye, { size: 13 }), " Preview"] }), _jsxs("button", { type: "button", className: "b2b-btn b2b-btn--default b2b-btn--sm", onClick: () => void handleDuplicate(), disabled: duplicating, children: [_jsx(Copy, { size: 13 }), " ", duplicating ? 'Duplicating…' : 'Duplicate'] }), _jsxs("button", { type: "button", className: "b2b-btn b2b-btn--danger b2b-btn--sm", onClick: () => void handleDeleteProduct(), children: [_jsx(Trash2, { size: 13 }), " ", t('productEditor.action.delete')] })] })) : null, _jsxs("button", { type: "button", className: "b2b-btn b2b-btn--primary", onClick: (e) => {
|
|
344
|
+
void handleSave(e);
|
|
345
|
+
}, children: [_jsx(Save, { size: 14 }), " Save"] })] })] }), error ? (_jsx(Alert, { variant: "destructive", className: "mb-4", children: _jsx(AlertDescription, { children: error }) })) : null, info ? (_jsx(Alert, { variant: "success", className: "mb-4", children: _jsx(AlertDescription, { children: info }) })) : null, _jsxs("div", { className: "b2b-card", children: [_jsx("div", { style: { padding: '4px 4px 0', overflow: 'hidden' }, children: _jsx("div", { className: "b2b-tabs-scroll", children: _jsx("div", { className: "b2b-tabs", role: "tablist", children: visibleTabs.map((t) => (_jsxs("button", { type: "button", role: "tab", "aria-selected": activeTab === t.id, className: cn('b2b-tab', activeTab === t.id && 'is-active'), onClick: () => setActiveTab(t.id), children: [t.icon, t.label] }, t.id))) }) }) }), _jsxs("div", { className: "b2b-card__body", children: [activeTab === 'details' ? (_jsx("form", { id: "product-details-form", onSubmit: handleSave, children: _jsxs("div", { className: "b2b-col", style: { gap: 18 }, children: [id ? (_jsx(AdminZone, { name: "product.editor.details.before", props: { productId: id } })) : null, id ? (_jsxs("div", { className: "flex flex-wrap gap-x-4 gap-y-1", children: [_jsx(AdminZone, { name: "product.editor.field.after", props: { productId: id, fieldPath: 'name', languageCodes: LOCALES } }), _jsx(AdminZone, { name: "product.editor.field.after", props: { productId: id, fieldPath: 'description', languageCodes: LOCALES } })] })) : null, id ? (_jsx(ProductScopeEditor, { ref: scopeEditorRef, productId: id, baselineName: name, baselineDescription: description, onBaselineNameChange: (next) => setName({
|
|
346
|
+
'en-US': next['en-US'] ?? '',
|
|
347
|
+
'pl-PL': next['pl-PL'] ?? '',
|
|
348
|
+
}), onBaselineDescriptionChange: (next) => setDescription({
|
|
349
|
+
'en-US': next['en-US'] ?? '',
|
|
350
|
+
'pl-PL': next['pl-PL'] ?? '',
|
|
351
|
+
}) })) : null, _jsxs("div", { children: [_jsx("div", { className: "b2b-label", children: t('productEditor.section.identity') }), _jsxs("div", { style: { display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }, children: [_jsxs("div", { children: [_jsx(Label, { htmlFor: "sku", children: t('productEditor.field.sku') }), _jsx(Input, { id: "sku", value: sku, onChange: (e) => setSku(e.target.value), required: true })] }), _jsxs("div", { children: [_jsx(Label, { htmlFor: "ptype", children: t('productEditor.field.type') }), _jsx(Select, { id: "ptype", value: type, onChange: (e) => setType(e.target.value), disabled: !isNew, children: PRODUCT_TYPES.map((pt) => (_jsx("option", { value: pt, children: pt }, pt))) })] }), _jsxs("div", { children: [_jsx(Label, { htmlFor: "pvis", children: t('productEditor.field.visibility') }), _jsx(Select, { id: "pvis", value: visibility, onChange: (e) => setVisibility(e.target.value), children: VISIBILITIES.map((v) => (_jsx("option", { value: v, children: v }, v))) })] }), _jsxs("div", { children: [_jsx(Label, { htmlFor: "pattrset", children: t('productEditor.field.attributeSet') }), _jsx(Select, { id: "pattrset", value: attributeSetId, onChange: (e) => void handleAttributeSetChange(e.target.value), children: attributeSets.length === 0 ? (_jsx("option", { value: "", children: t('productEditor.attributeSet.loading') })) : (attributeSets.map((s) => (_jsxs("option", { value: s.id, children: [s.code, s.isSystem ? t('productEditor.attributeSet.systemSuffix') : ''] }, s.id)))) })] }), _jsxs("div", { children: [_jsx(Label, { htmlFor: "pstatus", children: t('productEditor.field.status') }), _jsx(Select, { id: "pstatus", value: status, onChange: (e) => setStatus(e.target.value), children: STATUSES.map((s) => (_jsx("option", { value: s, children: s }, s))) }), _jsx("p", { className: "b2b-help", children: t('productEditor.field.status.help') })] }), _jsxs("div", { children: [_jsx(Label, { htmlFor: "dp", children: t('productEditor.field.defaultPrice') }), _jsx(Input, { id: "dp", type: "number", step: "0.01", min: "0", value: defaultPrice, onChange: (e) => setDefaultPrice(e.target.value) }), _jsx("p", { className: "b2b-help", children: t('productEditor.field.defaultPrice.help') })] })] })] }), isNew ? (_jsxs(_Fragment, { children: [_jsx("hr", { className: "b2b-hr" }), _jsxs("div", { children: [_jsx("div", { className: "b2b-label", children: t('productEditor.section.localizedContent') }), _jsx("p", { className: "b2b-help", style: { marginTop: 0, marginBottom: 12 }, children: t('productEditor.section.localizedContent.help') }), _jsx("div", { className: "b2b-col", style: { gap: 18 }, children: LOCALES.map((l) => (_jsxs("div", { className: "b2b-col", style: { gap: 8 }, children: [_jsxs("div", { children: [_jsx(Label, { htmlFor: `name-${l}`, children: t('productEditor.field.localizedName', { locale: l }) }), _jsx(Input, { id: `name-${l}`, value: name[l], onChange: (e) => setName((prev) => ({ ...prev, [l]: e.target.value })) })] }), _jsxs("div", { children: [_jsx(Label, { htmlFor: `desc-${l}`, children: t('productEditor.field.localizedDescription', { locale: l }) }), _jsx("textarea", { id: `desc-${l}`, rows: 3, className: "b2b-field", value: description[l], onChange: (e) => setDescription((prev) => ({ ...prev, [l]: e.target.value })) })] })] }, l))) })] })] })) : null, _jsx("hr", { className: "b2b-hr" }), _jsxs("div", { children: [_jsxs("div", { className: "b2b-label", children: [t('productEditor.section.categories'), id ? (_jsx(AdminZone, { name: "product.editor.field.after", props: { productId: id, fieldPath: 'categories', languageCodes: null } })) : null] }), _jsx(CategoryTreePicker, { categories: categories, selectedIds: categoryIds, onChange: setCategoryIds, loading: loading }), _jsx("p", { className: "b2b-help", children: t('productEditor.section.categories.help') })] })] }) })) : null, activeTab === 'attributes' && id ? (_jsx(ProductAttributesTab, { productId: id, attributeSetId: attributeSetId, values: attrValues, onChange: handleAttrChange })) : null, activeTab === 'pricing' && id ? (_jsxs("div", { className: "b2b-col", style: { gap: 16 }, children: [_jsx(AdminZone, { name: "product.editor.pricing.before", props: { productId: id } }), _jsx(AdminZone, { name: "product.editor.pricing.after", props: { productId: id } })] })) : null, activeTab === 'variants' && id ? _jsx(VariantsSection, { productId: id }) : null, activeTab === 'composite' && id ? (type === 'grouped' ? (_jsx(GroupedItemsSection, { productId: id })) : (_jsx(BundleSlotsSection, { productId: id }))) : null, activeTab === 'media' && id ? (_jsxs(_Fragment, { children: [_jsx(AdminZone, { name: "product.editor.field.after", props: { productId: id, fieldPath: 'gallery', languageCodes: null } }), _jsx(GallerySection, { ref: galleryRef, productId: id })] })) : null, activeTab === 'inventory' && id ? (_jsxs("div", { className: "b2b-col", style: { gap: 16 }, children: [_jsx(ProductInventoryTab, { productId: id }), type === 'simple' || type === 'configurable' ? (_jsx(PackagingUnitsEditor, { productId: id })) : null] })) : null, activeTab === 'attachments' && id ? (_jsxs(_Fragment, { children: [_jsx(AdminZone, { name: "product.editor.field.after", props: { productId: id, fieldPath: 'attachments', languageCodes: null } }), _jsx(AttachmentsSection, { productId: id })] })) : null, activeTab === 'links' && id ? _jsx(ProductLinksSection, { productId: id }) : null, activeTab === 'channels' && id ? (_jsx(AdminZone, { name: "product.editor.channels", props: { productId: id } })) : null, activeTab === 'seo' ? _jsx(SeoStub, { name: name }) : null] })] })] }));
|
|
352
|
+
}
|
|
353
|
+
function StatusPill({ status }) {
|
|
354
|
+
const map = {
|
|
355
|
+
active: { cls: 'b2b-badge--success', label: 'Active' },
|
|
356
|
+
draft: { cls: 'b2b-badge--warn', label: 'Draft' },
|
|
357
|
+
inactive: { cls: '', label: 'Inactive' },
|
|
358
|
+
};
|
|
359
|
+
const v = map[status];
|
|
360
|
+
return _jsx("span", { className: cn('b2b-badge', 'b2b-badge--dot', v.cls), children: v.label });
|
|
361
|
+
}
|
|
362
|
+
function pickName(name) {
|
|
363
|
+
return name['en-US'] ?? Object.values(name)[0] ?? '';
|
|
364
|
+
}
|
|
365
|
+
function SeoStub({ name }) {
|
|
366
|
+
const t = useTranslation('catalog');
|
|
367
|
+
return (_jsxs("div", { className: "b2b-col", style: { gap: 12 }, children: [_jsx("div", { className: "b2b-label", children: t('productEditor.seo.title') }), _jsx("div", { className: "b2b-help", style: { marginTop: 0 }, children: t('productEditor.seo.description') }), _jsxs("div", { children: [_jsx(Label, { htmlFor: "seo-title", children: t('productEditor.seo.pageTitlePreview') }), _jsx(Input, { id: "seo-title", defaultValue: pickName(name) })] })] }));
|
|
368
|
+
}
|
|
369
|
+
function VariantsSection({ productId }) {
|
|
370
|
+
const t = useTranslation('catalog');
|
|
371
|
+
const [variants, setVariants] = useState([]);
|
|
372
|
+
const [loading, setLoading] = useState(true);
|
|
373
|
+
const [error, setError] = useState(null);
|
|
374
|
+
const [info, setInfo] = useState(null);
|
|
375
|
+
const refresh = useCallback(async () => {
|
|
376
|
+
setLoading(true);
|
|
377
|
+
setError(null);
|
|
378
|
+
try {
|
|
379
|
+
// The PDP returns variants nested in productDetail; admin needs a
|
|
380
|
+
// standalone read to keep the forms quick. Foundation didn't ship
|
|
381
|
+
// a GET /admin/.../variants, but the public productDetail under
|
|
382
|
+
// /api/v1/catalog/products/<idOrSlug> includes them — use that.
|
|
383
|
+
const res = await apiClient.get(`/api/v1/catalog/products/${productId}`);
|
|
384
|
+
setVariants(res.data.variants ?? []);
|
|
385
|
+
}
|
|
386
|
+
catch (err) {
|
|
387
|
+
setError(err instanceof ApiError ? err.envelope.error.message : t('productEditor.variants.error.load'));
|
|
388
|
+
}
|
|
389
|
+
finally {
|
|
390
|
+
setLoading(false);
|
|
391
|
+
}
|
|
392
|
+
}, [productId, t]);
|
|
393
|
+
useEffect(() => {
|
|
394
|
+
void refresh();
|
|
395
|
+
}, [refresh]);
|
|
396
|
+
const handleCreate = useCallback(async (input) => {
|
|
397
|
+
// attrs is a comma-separated list of `key=value` pairs.
|
|
398
|
+
const variantAttributeValues = {};
|
|
399
|
+
for (const pair of input.attrs.split(',')) {
|
|
400
|
+
const trimmed = pair.trim();
|
|
401
|
+
if (!trimmed)
|
|
402
|
+
continue;
|
|
403
|
+
const [k, v] = trimmed.split('=');
|
|
404
|
+
if (k && v !== undefined)
|
|
405
|
+
variantAttributeValues[k.trim()] = v.trim();
|
|
406
|
+
}
|
|
407
|
+
try {
|
|
408
|
+
const payload = {
|
|
409
|
+
sku: input.sku,
|
|
410
|
+
variantAttributeValues,
|
|
411
|
+
};
|
|
412
|
+
if (input.priceOverride.trim()) {
|
|
413
|
+
payload['priceOverride'] = Number(input.priceOverride);
|
|
414
|
+
}
|
|
415
|
+
if (input.stockLevel.trim()) {
|
|
416
|
+
payload['stockLevel'] = Number(input.stockLevel);
|
|
417
|
+
}
|
|
418
|
+
await apiClient.post(`/api/v1/admin/catalog/products/${productId}/variants`, payload);
|
|
419
|
+
setInfo(t('productEditor.variants.success.create', { sku: input.sku }));
|
|
420
|
+
await refresh();
|
|
421
|
+
}
|
|
422
|
+
catch (err) {
|
|
423
|
+
setError(err instanceof ApiError ? err.envelope.error.message : t('productEditor.variants.error.create'));
|
|
424
|
+
}
|
|
425
|
+
}, [productId, refresh, t]);
|
|
426
|
+
const handleDelete = useCallback(async (variantId, sku) => {
|
|
427
|
+
if (!confirm(t('productEditor.variants.deleteConfirm', { sku })))
|
|
428
|
+
return;
|
|
429
|
+
try {
|
|
430
|
+
await apiClient.delete(`/api/v1/admin/catalog/products/${productId}/variants/${variantId}`);
|
|
431
|
+
setInfo(t('productEditor.variants.success.delete', { sku }));
|
|
432
|
+
await refresh();
|
|
433
|
+
}
|
|
434
|
+
catch (err) {
|
|
435
|
+
setError(err instanceof ApiError ? err.envelope.error.message : t('productEditor.variants.error.delete'));
|
|
436
|
+
}
|
|
437
|
+
}, [productId, refresh, t]);
|
|
438
|
+
return (_jsxs(Card, { className: "mt-4", children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: t('productEditor.variants.title') }) }), _jsxs(CardContent, { className: "space-y-4", children: [error ? (_jsx(Alert, { variant: "destructive", children: _jsx(AlertDescription, { children: error }) })) : null, info ? (_jsx(Alert, { children: _jsx(AlertDescription, { children: info }) })) : null, loading ? (_jsx("p", { className: "text-sm text-muted-foreground", children: t('productEditor.loading') })) : variants.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: t('productEditor.variants.empty') })) : (_jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { children: t('productEditor.variants.column.sku') }), _jsx(TableHead, { children: t('productEditor.variants.column.attributes') }), _jsx(TableHead, { children: t('productEditor.variants.column.priceOverride') }), _jsx(TableHead, { children: t('productEditor.variants.column.stock') }), _jsx(TableHead, { className: "w-[1%] whitespace-nowrap", children: t('productEditor.variants.column.actions') })] }) }), _jsx(TableBody, { children: variants.map((v) => (_jsxs(TableRow, { children: [_jsx(TableCell, { className: "font-mono", children: v.sku }), _jsx(TableCell, { children: Object.entries(v.variantAttributeValues)
|
|
439
|
+
.map(([k, val]) => `${k}=${String(val)}`)
|
|
440
|
+
.join(', ') || '—' }), _jsx(TableCell, { children: v.priceOverride ?? '—' }), _jsx(TableCell, { children: v.stockLevel ?? '—' }), _jsx(TableCell, { children: _jsx(Button, { type: "button", variant: "destructive", size: "sm", onClick: () => void handleDelete(v.id, v.sku), children: t('productEditor.variants.action.delete') }) })] }, v.id))) })] })), _jsx(CreateVariantInline, { onCreate: handleCreate })] })] }));
|
|
441
|
+
}
|
|
442
|
+
function CreateVariantInline({ onCreate, }) {
|
|
443
|
+
const t = useTranslation('catalog');
|
|
444
|
+
const [sku, setSku] = useState('');
|
|
445
|
+
const [attrs, setAttrs] = useState('');
|
|
446
|
+
const [priceOverride, setPriceOverride] = useState('');
|
|
447
|
+
const [stockLevel, setStockLevel] = useState('');
|
|
448
|
+
return (_jsxs("div", { className: "grid gap-3 md:grid-cols-5 border-t pt-4", role: "group", "aria-label": t('productEditor.variants.createTitle'), children: [_jsxs("div", { className: "space-y-1", children: [_jsx(Label, { htmlFor: "vsku", children: t('productEditor.variants.field.sku') }), _jsx(Input, { id: "vsku", value: sku, onChange: (e) => setSku(e.target.value), required: true })] }), _jsxs("div", { className: "space-y-1 md:col-span-2", children: [_jsx(Label, { htmlFor: "vattrs", children: t('productEditor.variants.field.attributes') }), _jsx(Input, { id: "vattrs", value: attrs, onChange: (e) => setAttrs(e.target.value), placeholder: t('productEditor.variants.field.attributesPlaceholder') })] }), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { htmlFor: "vprice", children: t('productEditor.variants.field.priceOverride') }), _jsx(Input, { id: "vprice", type: "number", step: "0.01", min: "0", value: priceOverride, onChange: (e) => setPriceOverride(e.target.value) })] }), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { htmlFor: "vstock", children: t('productEditor.variants.field.stock') }), _jsx(Input, { id: "vstock", type: "number", min: "0", value: stockLevel, onChange: (e) => setStockLevel(e.target.value) })] }), _jsx("div", { className: "md:col-span-5", children: _jsx(Button, { type: "button", onClick: () => {
|
|
449
|
+
void onCreate({ sku, attrs, priceOverride, stockLevel }).then(() => {
|
|
450
|
+
setSku('');
|
|
451
|
+
setAttrs('');
|
|
452
|
+
setPriceOverride('');
|
|
453
|
+
setStockLevel('');
|
|
454
|
+
});
|
|
455
|
+
}, children: t('productEditor.variants.action.add') }) })] }));
|
|
456
|
+
}
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
// Gallery section (T085) — admin CRUD for the Product's gallery items.
|
|
459
|
+
// Foundation lacks an admin Asset upload endpoint, so for now the form
|
|
460
|
+
// accepts an existing assetId; future work adds drag-and-drop on top of
|
|
461
|
+
// an Assets module admin upload route.
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
const GALLERY_LABELS = ['base_image', 'small_image', 'thumbnail'];
|
|
464
|
+
/** Immutable array move used by drag-and-drop / arrow reordering. */
|
|
465
|
+
function moveGalleryItem(items, from, to) {
|
|
466
|
+
if (to < 0 || to >= items.length || from === to)
|
|
467
|
+
return items;
|
|
468
|
+
const next = items.slice();
|
|
469
|
+
const [moved] = next.splice(from, 1);
|
|
470
|
+
if (moved === undefined)
|
|
471
|
+
return items;
|
|
472
|
+
next.splice(to, 0, moved);
|
|
473
|
+
return next;
|
|
474
|
+
}
|
|
475
|
+
const GallerySection = forwardRef(function GallerySection({ productId }, ref) {
|
|
476
|
+
const t = useTranslation('catalog');
|
|
477
|
+
const [items, setItems] = useState([]);
|
|
478
|
+
const [images, setImages] = useState([]);
|
|
479
|
+
const [loading, setLoading] = useState(true);
|
|
480
|
+
const [busy, setBusy] = useState(false);
|
|
481
|
+
const [error, setError] = useState(null);
|
|
482
|
+
const [info, setInfo] = useState(null);
|
|
483
|
+
const [previewUrl, setPreviewUrl] = useState(null);
|
|
484
|
+
const dragIdRef = useRef(null);
|
|
485
|
+
// Server-truth snapshot (id order + labels) used to diff the staged edits
|
|
486
|
+
// that the page-Save button flushes.
|
|
487
|
+
const baselineRef = useRef('');
|
|
488
|
+
const snapshot = (list) => JSON.stringify(list.map((g) => ({ id: g.id, labels: [...g.labels].sort() })));
|
|
489
|
+
const refresh = useCallback(async () => {
|
|
490
|
+
setLoading(true);
|
|
491
|
+
setError(null);
|
|
492
|
+
try {
|
|
493
|
+
// Public PDP carries the resolved gallery (with asset urls) so
|
|
494
|
+
// admins see the same shape customers do; the admin GET returns
|
|
495
|
+
// bare ids without urls. The PDP also carries the product's flat
|
|
496
|
+
// `assets` (legacy product_assets) — the storefront renders those
|
|
497
|
+
// when the label-aware gallery is empty, so we surface them here
|
|
498
|
+
// too, otherwise a product whose images live on product_assets
|
|
499
|
+
// looks empty in the admin while showing fine on the storefront.
|
|
500
|
+
const res = await apiClient.get(`/api/v1/catalog/products/${productId}`);
|
|
501
|
+
const gallery = res.data.gallery ?? [];
|
|
502
|
+
const mapped = gallery
|
|
503
|
+
.slice()
|
|
504
|
+
.sort((a, b) => a.position - b.position)
|
|
505
|
+
.map((g) => ({
|
|
506
|
+
id: g.id,
|
|
507
|
+
productId,
|
|
508
|
+
assetId: g.asset.id,
|
|
509
|
+
position: g.position,
|
|
510
|
+
labels: g.labels,
|
|
511
|
+
asset: { url: g.asset.url, kind: g.asset.kind },
|
|
512
|
+
}));
|
|
513
|
+
setItems(mapped);
|
|
514
|
+
baselineRef.current = snapshot(mapped);
|
|
515
|
+
const assets = res.data.assets ?? [];
|
|
516
|
+
setImages(assets
|
|
517
|
+
.filter((a) => a.kind === 'image' || a.kind === 'video')
|
|
518
|
+
.map((a) => ({
|
|
519
|
+
id: a.id,
|
|
520
|
+
kind: a.kind,
|
|
521
|
+
url: a.url,
|
|
522
|
+
altText: a.altText ?? null,
|
|
523
|
+
})));
|
|
524
|
+
}
|
|
525
|
+
catch (err) {
|
|
526
|
+
setError(err instanceof ApiError ? err.envelope.error.message : t('productEditor.gallery.error.load'));
|
|
527
|
+
}
|
|
528
|
+
finally {
|
|
529
|
+
setLoading(false);
|
|
530
|
+
}
|
|
531
|
+
}, [productId, t]);
|
|
532
|
+
useEffect(() => {
|
|
533
|
+
void refresh();
|
|
534
|
+
}, [refresh]);
|
|
535
|
+
// --- Staged label edits: each label lives on at most one item; assigning it
|
|
536
|
+
// to one item strips it from every other (radio semantics). Persisted on
|
|
537
|
+
// page Save, so no per-row Save button and no "already taken" error. ---
|
|
538
|
+
const assignLabel = useCallback((label, itemId) => {
|
|
539
|
+
setInfo(null);
|
|
540
|
+
setItems((prev) => prev.map((it) => {
|
|
541
|
+
const without = it.labels.filter((l) => l !== label);
|
|
542
|
+
return { ...it, labels: it.id === itemId ? [...without, label] : without };
|
|
543
|
+
}));
|
|
544
|
+
}, []);
|
|
545
|
+
const clearLabel = useCallback((label) => {
|
|
546
|
+
setInfo(null);
|
|
547
|
+
setItems((prev) => prev.map((it) => ({ ...it, labels: it.labels.filter((l) => l !== label) })));
|
|
548
|
+
}, []);
|
|
549
|
+
// --- Staged reorder (array order === display order). ---
|
|
550
|
+
const moveItem = useCallback((from, to) => {
|
|
551
|
+
setInfo(null);
|
|
552
|
+
setItems((prev) => moveGalleryItem(prev, from, to));
|
|
553
|
+
}, []);
|
|
554
|
+
const handleDrop = useCallback((index) => {
|
|
555
|
+
const draggedId = dragIdRef.current;
|
|
556
|
+
if (!draggedId)
|
|
557
|
+
return;
|
|
558
|
+
setItems((prev) => {
|
|
559
|
+
const from = prev.findIndex((it) => it.id === draggedId);
|
|
560
|
+
return from < 0 || from === index ? prev : moveGalleryItem(prev, from, index);
|
|
561
|
+
});
|
|
562
|
+
}, []);
|
|
563
|
+
// --- Immediate CRUD (add / remove) — these hit the server directly and
|
|
564
|
+
// re-load, which also resets the staged baseline. ---
|
|
565
|
+
const handleCreate = useCallback(async (assetId) => {
|
|
566
|
+
setBusy(true);
|
|
567
|
+
try {
|
|
568
|
+
await apiClient.post(`/api/v1/admin/catalog/products/${productId}/gallery`, {
|
|
569
|
+
assetId,
|
|
570
|
+
labels: [],
|
|
571
|
+
});
|
|
572
|
+
setInfo(t('productEditor.gallery.success.add'));
|
|
573
|
+
await refresh();
|
|
574
|
+
}
|
|
575
|
+
catch (err) {
|
|
576
|
+
setError(err instanceof ApiError ? err.envelope.error.message : t('productEditor.gallery.error.add'));
|
|
577
|
+
}
|
|
578
|
+
finally {
|
|
579
|
+
setBusy(false);
|
|
580
|
+
}
|
|
581
|
+
}, [productId, refresh, t]);
|
|
582
|
+
const handleDelete = useCallback(async (itemId) => {
|
|
583
|
+
if (!confirm(t('productEditor.gallery.removeConfirm')))
|
|
584
|
+
return;
|
|
585
|
+
setBusy(true);
|
|
586
|
+
try {
|
|
587
|
+
await apiClient.delete(`/api/v1/admin/catalog/products/${productId}/gallery/${itemId}`);
|
|
588
|
+
setInfo(t('productEditor.gallery.success.remove'));
|
|
589
|
+
await refresh();
|
|
590
|
+
}
|
|
591
|
+
catch (err) {
|
|
592
|
+
setError(err instanceof ApiError ? err.envelope.error.message : t('productEditor.gallery.error.delete'));
|
|
593
|
+
}
|
|
594
|
+
finally {
|
|
595
|
+
setBusy(false);
|
|
596
|
+
}
|
|
597
|
+
}, [productId, refresh, t]);
|
|
598
|
+
// --- Page-Save hook: flush staged label + order edits. Labels are patched
|
|
599
|
+
// with `replace=true` so reassigning a label moves it atomically. ---
|
|
600
|
+
const flushGallery = useCallback(async () => {
|
|
601
|
+
const baseline = JSON.parse(baselineRef.current || '[]');
|
|
602
|
+
const baseLabels = new Map(baseline.map((b) => [b.id, [...b.labels].sort().join(',')]));
|
|
603
|
+
const changed = items.filter((it) => baseLabels.get(it.id) !== [...it.labels].sort().join(','));
|
|
604
|
+
for (const it of changed) {
|
|
605
|
+
await apiClient.patch(`/api/v1/admin/catalog/products/${productId}/gallery/${it.id}?replace=true`, { labels: it.labels });
|
|
606
|
+
}
|
|
607
|
+
const baseOrder = baseline.map((b) => b.id).join(',');
|
|
608
|
+
const curOrder = items.map((it) => it.id).join(',');
|
|
609
|
+
if (items.length > 0 && curOrder !== baseOrder) {
|
|
610
|
+
await apiClient.put(`/api/v1/admin/catalog/products/${productId}/gallery/order`, {
|
|
611
|
+
orderedGalleryItemIds: items.map((it) => it.id),
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
if (changed.length > 0 || curOrder !== baseOrder) {
|
|
615
|
+
await refresh();
|
|
616
|
+
}
|
|
617
|
+
}, [items, productId, refresh]);
|
|
618
|
+
useImperativeHandle(ref, () => ({ flushGallery }), [flushGallery]);
|
|
619
|
+
const disabled = busy || loading;
|
|
620
|
+
return (_jsxs(Card, { className: "mt-4", children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: t('productEditor.gallery.title') }) }), _jsxs(CardContent, { className: "space-y-4", children: [error ? (_jsx(Alert, { variant: "destructive", children: _jsx(AlertDescription, { children: error }) })) : null, info ? (_jsx(Alert, { children: _jsx(AlertDescription, { children: info }) })) : null, !loading && images.length > 0 ? (_jsxs("div", { className: "space-y-2", children: [_jsx("p", { className: "text-sm font-medium", children: t('productEditor.gallery.productImages.title') }), _jsx("p", { className: "text-xs text-muted-foreground", children: t('productEditor.gallery.productImages.hint') }), _jsx("div", { className: "flex flex-wrap gap-2", children: images.map((img) => img.kind === 'video' ? (_jsx("video", { src: toAbsoluteAssetUrl(img.url), className: "h-20 w-20 rounded border object-cover", muted: true }, img.id)) : (_jsx("img", { src: toAbsoluteAssetUrl(img.url), alt: img.altText ?? '', className: "h-20 w-20 rounded border object-cover" }, img.id))) })] })) : null, loading ? (_jsx("p", { className: "text-sm text-muted-foreground", children: t('productEditor.loading') })) : items.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: t('productEditor.gallery.empty') })) : (_jsxs(_Fragment, { children: [_jsx("p", { className: "text-xs text-muted-foreground", children: t('productEditor.gallery.dragHint') }), _jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { className: "w-8" }), _jsx(TableHead, { children: t('productEditor.gallery.column.preview') }), GALLERY_LABELS.map((label) => (_jsx(TableHead, { className: "text-center", children: t(`productEditor.gallery.label.${label}`) }, label))), _jsx(TableHead, { children: t('productEditor.gallery.column.assetId') }), _jsx(TableHead, { className: "w-[1%] whitespace-nowrap text-right", children: t('productEditor.gallery.column.actions') })] }) }), _jsx(TableBody, { children: items.map((item, index) => (_jsx(GalleryRow, { item: item, index: index, total: items.length, disabled: disabled, onAssignLabel: assignLabel, onClearLabel: clearLabel, onMove: moveItem, onDragStart: (id) => {
|
|
621
|
+
dragIdRef.current = id;
|
|
622
|
+
}, onDragOver: (e) => e.preventDefault(), onDrop: handleDrop, onDragEnd: () => {
|
|
623
|
+
dragIdRef.current = null;
|
|
624
|
+
}, onPreview: setPreviewUrl, onDelete: handleDelete }, item.id))) })] })] })), _jsx(CreateGalleryItemInline, { disabled: disabled, onCreate: handleCreate })] }), previewUrl ? (_jsxs("div", { className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6", role: "dialog", "aria-modal": "true", "aria-label": t('productEditor.gallery.preview.title'), onClick: () => setPreviewUrl(null), children: [_jsx("img", { src: previewUrl, alt: t('productEditor.gallery.preview.title'), className: "max-h-full max-w-full rounded shadow-lg", onClick: (e) => e.stopPropagation() }), _jsx("button", { type: "button", className: "b2b-btn b2b-btn--sm", style: { position: 'fixed', top: 16, right: 16 }, onClick: () => setPreviewUrl(null), children: t('productEditor.gallery.preview.close') })] })) : null] }));
|
|
625
|
+
});
|
|
626
|
+
function GalleryRow({ item, index, total, disabled, onAssignLabel, onClearLabel, onMove, onDragStart, onDragOver, onDrop, onDragEnd, onPreview, onDelete, }) {
|
|
627
|
+
const t = useTranslation('catalog');
|
|
628
|
+
const url = item.asset?.url ? toAbsoluteAssetUrl(item.asset.url) : null;
|
|
629
|
+
return (_jsxs(TableRow, { draggable: !disabled, onDragStart: () => onDragStart(item.id), onDragOver: onDragOver, onDrop: () => onDrop(index), onDragEnd: onDragEnd, children: [_jsx(TableCell, { className: "w-8", children: _jsx("span", { className: "inline-flex cursor-grab items-center text-muted-foreground", title: t('productEditor.gallery.dragHint'), "aria-hidden": true, children: _jsx(GripVertical, { className: "h-4 w-4" }) }) }), _jsx(TableCell, { children: url ? (item.asset?.kind === 'video' ? (_jsx("video", { src: url, className: "h-12 w-12 rounded border object-cover", muted: true })) : (_jsx("button", { type: "button", className: "block rounded border p-0", title: t('productEditor.gallery.preview.open'), onClick: () => onPreview(url), children: _jsx("img", { src: url, alt: "", className: "h-12 w-12 rounded object-cover" }) }))) : (_jsx("span", { className: "text-muted-foreground text-xs", children: "\u2014" })) }), GALLERY_LABELS.map((label) => {
|
|
630
|
+
const checked = item.labels.includes(label);
|
|
631
|
+
return (_jsx(TableCell, { className: "text-center", children: _jsx("input", { type: "radio", name: `gallery-label-${label}`, checked: checked, disabled: disabled, "aria-label": t(`productEditor.gallery.label.${label}`), onChange: () => { }, onClick: () => checked ? onClearLabel(label) : onAssignLabel(label, item.id) }) }, label));
|
|
632
|
+
}), _jsxs(TableCell, { className: "font-mono text-xs", children: [item.assetId.slice(0, 8), "\u2026"] }), _jsxs(TableCell, { className: "space-x-2 whitespace-nowrap text-right", children: [_jsx(TouchReorderButtons, { disabled: disabled, onMoveUp: () => onMove(index, index - 1), onMoveDown: () => onMove(index, index + 1), disableUp: index === 0, disableDown: index === total - 1 }), _jsx(Button, { type: "button", size: "sm", variant: "destructive", disabled: disabled, onClick: () => void onDelete(item.id), children: t('productEditor.gallery.action.remove') })] })] }));
|
|
633
|
+
}
|
|
634
|
+
function CreateGalleryItemInline({ disabled, onCreate, }) {
|
|
635
|
+
const t = useTranslation('catalog');
|
|
636
|
+
const [assetId, setAssetId] = useState('');
|
|
637
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
638
|
+
const [pickedFilename, setPickedFilename] = useState(null);
|
|
639
|
+
const onPick = (a) => {
|
|
640
|
+
setAssetId(a.id);
|
|
641
|
+
setPickedFilename(a.filename);
|
|
642
|
+
setPickerOpen(false);
|
|
643
|
+
};
|
|
644
|
+
return (_jsxs("div", { className: "space-y-3 border-t pt-4", role: "group", "aria-label": t('productEditor.gallery.addTitle'), children: [pickerOpen ? (_jsx(AssetPicker, { acceptMimePrefix: "image/", allowUpload: true, onSelect: onPick, onClose: () => setPickerOpen(false) })) : null, _jsxs("div", { className: "grid gap-3 md:grid-cols-4", children: [_jsxs("div", { className: "space-y-1 md:col-span-3", children: [_jsx(Label, { htmlFor: "gasset", children: t('productEditor.gallery.field.asset') }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Input, { id: "gasset", value: pickedFilename ?? assetId, readOnly: true, placeholder: t('productEditor.gallery.field.noAsset') }), _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setPickerOpen(true), children: t('productEditor.gallery.action.pickOrUpload') })] })] }), _jsx("div", { className: "flex items-end", children: _jsx(Button, { type: "button", disabled: disabled || !assetId, onClick: () => {
|
|
645
|
+
if (!assetId)
|
|
646
|
+
return;
|
|
647
|
+
void onCreate(assetId).then(() => {
|
|
648
|
+
setAssetId('');
|
|
649
|
+
setPickedFilename(null);
|
|
650
|
+
});
|
|
651
|
+
}, children: t('productEditor.gallery.action.add') }) })] })] }));
|
|
652
|
+
}
|
|
653
|
+
function AttachmentsSection({ productId }) {
|
|
654
|
+
const t = useTranslation('catalog');
|
|
655
|
+
const [attachments, setAttachments] = useState([]);
|
|
656
|
+
const [types, setTypes] = useState([]);
|
|
657
|
+
const [loading, setLoading] = useState(true);
|
|
658
|
+
const [error, setError] = useState(null);
|
|
659
|
+
const [info, setInfo] = useState(null);
|
|
660
|
+
const refresh = useCallback(async () => {
|
|
661
|
+
setLoading(true);
|
|
662
|
+
setError(null);
|
|
663
|
+
try {
|
|
664
|
+
const [detail, typesRes] = await Promise.all([
|
|
665
|
+
apiClient.get(`/api/v1/catalog/products/${productId}`),
|
|
666
|
+
apiClient.get('/api/v1/admin/catalog/attachment-types'),
|
|
667
|
+
]);
|
|
668
|
+
const detailAttachments = detail.data.attachments ?? [];
|
|
669
|
+
setAttachments(detailAttachments.map((a) => ({
|
|
670
|
+
id: a.id,
|
|
671
|
+
productId,
|
|
672
|
+
assetId: a.asset.id,
|
|
673
|
+
attachmentTypeId: a.type.id,
|
|
674
|
+
name: a.name,
|
|
675
|
+
description: a.description,
|
|
676
|
+
position: a.position,
|
|
677
|
+
asset: {
|
|
678
|
+
url: a.asset.url,
|
|
679
|
+
filename: a.asset.filename,
|
|
680
|
+
mimeType: a.asset.mimeType,
|
|
681
|
+
},
|
|
682
|
+
type: { code: a.type.code, name: a.type.name },
|
|
683
|
+
})));
|
|
684
|
+
setTypes(typesRes.data);
|
|
685
|
+
}
|
|
686
|
+
catch (err) {
|
|
687
|
+
setError(err instanceof ApiError ? err.envelope.error.message : t('productEditor.attachments.error.load'));
|
|
688
|
+
}
|
|
689
|
+
finally {
|
|
690
|
+
setLoading(false);
|
|
691
|
+
}
|
|
692
|
+
}, [productId, t]);
|
|
693
|
+
useEffect(() => {
|
|
694
|
+
void refresh();
|
|
695
|
+
}, [refresh]);
|
|
696
|
+
const handleCreate = useCallback(async (input) => {
|
|
697
|
+
try {
|
|
698
|
+
await apiClient.post(`/api/v1/admin/catalog/products/${productId}/attachments`, {
|
|
699
|
+
assetId: input.assetId,
|
|
700
|
+
attachmentTypeId: input.attachmentTypeId,
|
|
701
|
+
name: input.name,
|
|
702
|
+
...(input.description ? { description: input.description } : {}),
|
|
703
|
+
});
|
|
704
|
+
setInfo(t('productEditor.attachments.success.add'));
|
|
705
|
+
await refresh();
|
|
706
|
+
}
|
|
707
|
+
catch (err) {
|
|
708
|
+
setError(err instanceof ApiError ? err.envelope.error.message : t('productEditor.attachments.error.add'));
|
|
709
|
+
}
|
|
710
|
+
}, [productId, refresh, t]);
|
|
711
|
+
const handleDelete = useCallback(async (attachmentId, name) => {
|
|
712
|
+
if (!confirm(t('productEditor.attachments.deleteConfirm', { name })))
|
|
713
|
+
return;
|
|
714
|
+
try {
|
|
715
|
+
await apiClient.delete(`/api/v1/admin/catalog/products/${productId}/attachments/${attachmentId}`);
|
|
716
|
+
setInfo(t('productEditor.attachments.success.delete', { name }));
|
|
717
|
+
await refresh();
|
|
718
|
+
}
|
|
719
|
+
catch (err) {
|
|
720
|
+
setError(err instanceof ApiError ? err.envelope.error.message : t('productEditor.attachments.error.delete'));
|
|
721
|
+
}
|
|
722
|
+
}, [productId, refresh, t]);
|
|
723
|
+
return (_jsxs(Card, { className: "mt-4", children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: t('productEditor.attachments.title') }) }), _jsxs(CardContent, { className: "space-y-4", children: [error ? (_jsx(Alert, { variant: "destructive", children: _jsx(AlertDescription, { children: error }) })) : null, info ? (_jsx(Alert, { children: _jsx(AlertDescription, { children: info }) })) : null, loading ? (_jsx("p", { className: "text-sm text-muted-foreground", children: t('productEditor.loading') })) : attachments.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: t('productEditor.attachments.empty') })) : (_jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { children: t('productEditor.attachments.column.type') }), _jsx(TableHead, { children: t('productEditor.attachments.column.name') }), _jsx(TableHead, { children: t('productEditor.attachments.column.description') }), _jsx(TableHead, { children: t('productEditor.attachments.column.file') }), _jsx(TableHead, { className: "w-[1%] whitespace-nowrap", children: t('productEditor.attachments.column.actions') })] }) }), _jsx(TableBody, { children: attachments.map((att) => (_jsxs(TableRow, { children: [_jsx(TableCell, { children: att.type?.name['en-US'] ?? att.type?.code ?? '—' }), _jsx(TableCell, { className: "font-medium", children: att.name }), _jsx(TableCell, { className: "text-muted-foreground", children: att.description ?? '—' }), _jsx(TableCell, { children: att.asset?.url ? (_jsx("a", { href: att.asset.url, target: "_blank", rel: "noopener", className: "text-xs underline", children: att.asset.filename })) : ('—') }), _jsx(TableCell, { children: _jsx(Button, { type: "button", size: "sm", variant: "destructive", onClick: () => void handleDelete(att.id, att.name), children: t('productEditor.attachments.action.delete') }) })] }, att.id))) })] })), _jsx(CreateAttachmentInline, { types: types, onCreate: handleCreate })] })] }));
|
|
724
|
+
}
|
|
725
|
+
function CreateAttachmentInline({ types, onCreate, }) {
|
|
726
|
+
const [assetId, setAssetId] = useState('');
|
|
727
|
+
const [attachmentTypeId, setAttachmentTypeId] = useState('');
|
|
728
|
+
const [name, setName] = useState('');
|
|
729
|
+
const [description, setDescription] = useState('');
|
|
730
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
731
|
+
const [pickedFilename, setPickedFilename] = useState(null);
|
|
732
|
+
const t = useTranslation('catalog');
|
|
733
|
+
return (_jsxs("div", { className: "space-y-3 border-t pt-4", role: "group", "aria-label": t('productEditor.attachments.addTitle'), children: [pickerOpen ? (_jsx(AssetPicker, { allowUpload: true, onSelect: (a) => {
|
|
734
|
+
setAssetId(a.id);
|
|
735
|
+
setPickedFilename(a.filename);
|
|
736
|
+
setPickerOpen(false);
|
|
737
|
+
}, onClose: () => setPickerOpen(false) })) : null, _jsxs("div", { className: "grid gap-3 md:grid-cols-4", children: [_jsxs("div", { className: "space-y-1", children: [_jsx(Label, { htmlFor: "aasset", children: t('productEditor.attachments.field.asset') }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Input, { id: "aasset", value: pickedFilename ?? assetId, readOnly: true, placeholder: t('productEditor.attachments.field.noAsset') }), _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setPickerOpen(true), children: t('productEditor.attachments.action.pickOrUpload') })] })] }), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { htmlFor: "atype", children: t('productEditor.attachments.field.type') }), _jsxs(Select, { id: "atype", value: attachmentTypeId, onChange: (e) => setAttachmentTypeId(e.target.value), children: [_jsx("option", { value: "", children: "\u2014" }), types.map((typ) => (_jsx("option", { value: typ.id, children: typ.name['en-US'] ?? typ.code }, typ.id)))] })] }), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { htmlFor: "aname", children: t('productEditor.attachments.field.name') }), _jsx(Input, { id: "aname", value: name, onChange: (e) => setName(e.target.value), placeholder: t('productEditor.attachments.field.namePlaceholder') })] }), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { htmlFor: "adesc", children: t('productEditor.attachments.field.description') }), _jsx(Input, { id: "adesc", value: description, onChange: (e) => setDescription(e.target.value), placeholder: t('productEditor.attachments.field.descriptionPlaceholder') })] }), _jsx("div", { className: "md:col-span-4", children: _jsx(Button, { type: "button", onClick: () => {
|
|
738
|
+
if (!assetId || !attachmentTypeId || !name)
|
|
739
|
+
return;
|
|
740
|
+
void onCreate({ assetId, attachmentTypeId, name, description }).then(() => {
|
|
741
|
+
setAssetId('');
|
|
742
|
+
setPickedFilename(null);
|
|
743
|
+
setAttachmentTypeId('');
|
|
744
|
+
setName('');
|
|
745
|
+
setDescription('');
|
|
746
|
+
});
|
|
747
|
+
}, children: t('productEditor.attachments.action.add') }) })] })] }));
|
|
748
|
+
}
|
|
749
|
+
// ---------------------------------------------------------------------------
|
|
750
|
+
// Product Links section (T109, US4) — Related / Up-sell / Cross-sell.
|
|
751
|
+
// Three sub-lists, one product picker (paste id) + kind selector +
|
|
752
|
+
// "Add" button per kind. Backend bulkCreate is all-or-nothing and
|
|
753
|
+
// surfaces SELF_LINK_NOT_ALLOWED / LINK_ALREADY_EXISTS / TARGET_NOT_FOUND.
|
|
754
|
+
// ---------------------------------------------------------------------------
|
|
755
|
+
const LINK_KINDS = ['related', 'up_sell', 'cross_sell'];
|
|
756
|
+
function pickProductName(name, fallback) {
|
|
757
|
+
if (!name)
|
|
758
|
+
return fallback;
|
|
759
|
+
return name['en-US'] ?? Object.values(name)[0] ?? fallback;
|
|
760
|
+
}
|
|
761
|
+
const TARGETS_BATCH_PAGE_SIZE = 200;
|
|
762
|
+
function ProductLinksSection({ productId }) {
|
|
763
|
+
const t = useTranslation('catalog');
|
|
764
|
+
const [links, setLinks] = useState([]);
|
|
765
|
+
const [targetsById, setTargetsById] = useState(() => new Map());
|
|
766
|
+
const [loading, setLoading] = useState(true);
|
|
767
|
+
const [error, setError] = useState(null);
|
|
768
|
+
const [info, setInfo] = useState(null);
|
|
769
|
+
const refresh = useCallback(async () => {
|
|
770
|
+
setLoading(true);
|
|
771
|
+
setError(null);
|
|
772
|
+
try {
|
|
773
|
+
const res = await apiClient.get(`/api/v1/admin/catalog/products/${productId}/links`);
|
|
774
|
+
setLinks(res.data);
|
|
775
|
+
// Fetch target product details across all three kinds in one shot.
|
|
776
|
+
// Paginates with TARGETS_BATCH_PAGE_SIZE so a product with hundreds
|
|
777
|
+
// of links streams across multiple requests without ballooning the
|
|
778
|
+
// body. In practice a single page is enough.
|
|
779
|
+
const uniqueTargetIds = Array.from(new Set(res.data.map((l) => l.targetProductId)));
|
|
780
|
+
const nextById = new Map();
|
|
781
|
+
if (uniqueTargetIds.length > 0) {
|
|
782
|
+
let page = 0;
|
|
783
|
+
// Bounded loop guards against an unexpected backend response that
|
|
784
|
+
// never sets `hasMore: false`.
|
|
785
|
+
for (;;) {
|
|
786
|
+
const batch = await apiClient.post('/api/v1/admin/catalog/products/batch-by-id', {
|
|
787
|
+
ids: uniqueTargetIds,
|
|
788
|
+
page,
|
|
789
|
+
pageSize: TARGETS_BATCH_PAGE_SIZE,
|
|
790
|
+
});
|
|
791
|
+
for (const p of batch.data)
|
|
792
|
+
nextById.set(p.id, p);
|
|
793
|
+
if (!batch.pagination.hasMore)
|
|
794
|
+
break;
|
|
795
|
+
page += 1;
|
|
796
|
+
if (page > 50)
|
|
797
|
+
break;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
setTargetsById(nextById);
|
|
801
|
+
}
|
|
802
|
+
catch (err) {
|
|
803
|
+
setError(err instanceof ApiError ? err.envelope.error.message : t('productEditor.links.error.load'));
|
|
804
|
+
}
|
|
805
|
+
finally {
|
|
806
|
+
setLoading(false);
|
|
807
|
+
}
|
|
808
|
+
}, [productId, t]);
|
|
809
|
+
useEffect(() => {
|
|
810
|
+
void refresh();
|
|
811
|
+
}, [refresh]);
|
|
812
|
+
const handleCreate = useCallback(async (input) => {
|
|
813
|
+
try {
|
|
814
|
+
await apiClient.post(`/api/v1/admin/catalog/products/${productId}/links`, {
|
|
815
|
+
links: [{ targetProductId: input.targetProductId, kind: input.kind }],
|
|
816
|
+
});
|
|
817
|
+
setInfo(t('productEditor.links.success.add', { kind: input.kind }));
|
|
818
|
+
await refresh();
|
|
819
|
+
}
|
|
820
|
+
catch (err) {
|
|
821
|
+
setError(err instanceof ApiError ? err.envelope.error.message : t('productEditor.links.error.add'));
|
|
822
|
+
}
|
|
823
|
+
}, [productId, refresh, t]);
|
|
824
|
+
const handleDelete = useCallback(async (linkId) => {
|
|
825
|
+
if (!confirm(t('productEditor.links.removeConfirm')))
|
|
826
|
+
return;
|
|
827
|
+
try {
|
|
828
|
+
await apiClient.delete(`/api/v1/admin/catalog/products/${productId}/links/${linkId}`);
|
|
829
|
+
setInfo(t('productEditor.links.success.remove'));
|
|
830
|
+
await refresh();
|
|
831
|
+
}
|
|
832
|
+
catch (err) {
|
|
833
|
+
setError(err instanceof ApiError ? err.envelope.error.message : t('productEditor.links.error.delete'));
|
|
834
|
+
}
|
|
835
|
+
}, [productId, refresh, t]);
|
|
836
|
+
const byKind = useMemo(() => {
|
|
837
|
+
const result = {
|
|
838
|
+
related: [],
|
|
839
|
+
up_sell: [],
|
|
840
|
+
cross_sell: [],
|
|
841
|
+
};
|
|
842
|
+
for (const link of links)
|
|
843
|
+
result[link.kind].push(link);
|
|
844
|
+
for (const kind of LINK_KINDS) {
|
|
845
|
+
result[kind].sort((a, b) => a.position - b.position);
|
|
846
|
+
}
|
|
847
|
+
return result;
|
|
848
|
+
}, [links]);
|
|
849
|
+
return (_jsxs(Card, { className: "mt-4", children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: t('productEditor.links.title') }) }), _jsxs(CardContent, { className: "space-y-4", children: [error ? (_jsx(Alert, { variant: "destructive", children: _jsx(AlertDescription, { children: error }) })) : null, info ? (_jsx(Alert, { children: _jsx(AlertDescription, { children: info }) })) : null, loading ? (_jsx("p", { className: "text-sm text-muted-foreground", children: t('productEditor.loading') })) : (_jsx("div", { className: "space-y-6", children: LINK_KINDS.map((kind) => (_jsxs("div", { className: "space-y-2", children: [_jsxs("h3", { className: "text-sm font-medium uppercase tracking-wide", children: [t(`productEditor.links.kind.${kind}`), " (", byKind[kind].length, ")"] }), byKind[kind].length === 0 ? (_jsx("p", { className: "text-xs text-muted-foreground", children: t('productEditor.links.empty') })) : (_jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { children: t('productEditor.links.column.target') }), _jsx(TableHead, { children: t('productEditor.links.column.sku') }), _jsx(TableHead, { children: t('productEditor.links.column.position') }), _jsx(TableHead, { className: "w-[1%] whitespace-nowrap", children: t('productEditor.links.column.actions') })] }) }), _jsx(TableBody, { children: byKind[kind].map((link) => {
|
|
850
|
+
const target = targetsById.get(link.targetProductId);
|
|
851
|
+
const isMissing = !target;
|
|
852
|
+
const isInactive = target?.status === 'inactive';
|
|
853
|
+
return (_jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: isMissing || isInactive ? 'text-muted-foreground' : '', children: target
|
|
854
|
+
? pickProductName(target.name, target.slug)
|
|
855
|
+
: `${link.targetProductId.slice(0, 8)}…` }), isMissing ? (_jsx("span", { className: "rounded border px-1.5 py-0.5 text-[10px] uppercase text-muted-foreground", children: t('productEditor.links.badge.missing') })) : null, isInactive ? (_jsx("span", { className: "rounded border px-1.5 py-0.5 text-[10px] uppercase text-muted-foreground", children: t('productEditor.links.badge.inactive') })) : null] }) }), _jsx(TableCell, { className: "font-mono text-xs", children: target?.sku ?? '—' }), _jsx(TableCell, { children: link.position }), _jsx(TableCell, { children: _jsx(Button, { type: "button", size: "sm", variant: "destructive", onClick: () => void handleDelete(link.id), children: t('productEditor.links.action.remove') }) })] }, link.id));
|
|
856
|
+
}) })] }))] }, kind))) })), _jsx(CreateProductLinkInline, { onCreate: handleCreate, sourceProductId: productId })] })] }));
|
|
857
|
+
}
|
|
858
|
+
function CreateProductLinkInline({ onCreate, sourceProductId, }) {
|
|
859
|
+
const t = useTranslation('catalog');
|
|
860
|
+
const [targetProductId, setTargetProductId] = useState(null);
|
|
861
|
+
const [kind, setKind] = useState('related');
|
|
862
|
+
return (_jsxs("div", { className: "grid gap-3 md:grid-cols-3 border-t pt-4", role: "group", "aria-label": t('productEditor.links.addTitle'), children: [_jsxs("div", { className: "space-y-1 md:col-span-2", children: [_jsx(Label, { htmlFor: "ltarget", children: t('productEditor.links.column.target') }), _jsx(ProductPicker, { id: "ltarget", mode: "select", value: targetProductId, onChange: setTargetProductId, excludeIds: [sourceProductId], placeholder: t('productEditor.links.field.targetPlaceholder'), ariaLabel: t('productEditor.links.column.target') })] }), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { htmlFor: "lkind", children: t('productEditor.links.field.kind') }), _jsx(Select, { id: "lkind", value: kind, onChange: (e) => setKind(e.target.value), children: LINK_KINDS.map((k) => (_jsx("option", { value: k, children: t(`productEditor.links.kind.${k}`) }, k))) })] }), _jsx("div", { className: "md:col-span-3", children: _jsx(Button, { type: "button", disabled: targetProductId === null, onClick: () => {
|
|
863
|
+
if (targetProductId === null)
|
|
864
|
+
return;
|
|
865
|
+
void onCreate({ targetProductId, kind }).then(() => {
|
|
866
|
+
setTargetProductId(null);
|
|
867
|
+
});
|
|
868
|
+
}, children: t('productEditor.links.action.add') }) })] }));
|
|
869
|
+
}
|
|
870
|
+
function GroupedItemsSection({ productId }) {
|
|
871
|
+
const t = useTranslation('catalog');
|
|
872
|
+
const [items, setItems] = useState([]);
|
|
873
|
+
const [loading, setLoading] = useState(true);
|
|
874
|
+
const [error, setError] = useState(null);
|
|
875
|
+
const [info, setInfo] = useState(null);
|
|
876
|
+
const refresh = useCallback(async () => {
|
|
877
|
+
setLoading(true);
|
|
878
|
+
setError(null);
|
|
879
|
+
try {
|
|
880
|
+
const res = await apiClient.get(`/api/v1/admin/catalog/products/${productId}/grouped-items`);
|
|
881
|
+
setItems(res.data);
|
|
882
|
+
}
|
|
883
|
+
catch (err) {
|
|
884
|
+
setError(err instanceof ApiError ? err.envelope.error.message : t('productEditor.grouped.error.load'));
|
|
885
|
+
}
|
|
886
|
+
finally {
|
|
887
|
+
setLoading(false);
|
|
888
|
+
}
|
|
889
|
+
}, [productId, t]);
|
|
890
|
+
useEffect(() => {
|
|
891
|
+
void refresh();
|
|
892
|
+
}, [refresh]);
|
|
893
|
+
const handleCreate = useCallback(async (input) => {
|
|
894
|
+
const qty = Number(input.quantity);
|
|
895
|
+
if (!Number.isFinite(qty) || qty <= 0) {
|
|
896
|
+
setError(t('productEditor.grouped.error.invalidQuantity'));
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
try {
|
|
900
|
+
await apiClient.post(`/api/v1/admin/catalog/products/${productId}/grouped-items`, { childProductId: input.childProductId, quantity: qty });
|
|
901
|
+
setInfo(t('productEditor.grouped.success.add'));
|
|
902
|
+
await refresh();
|
|
903
|
+
}
|
|
904
|
+
catch (err) {
|
|
905
|
+
setError(err instanceof ApiError ? err.envelope.error.message : t('productEditor.grouped.error.add'));
|
|
906
|
+
}
|
|
907
|
+
}, [productId, refresh, t]);
|
|
908
|
+
const handleDelete = useCallback(async (itemId) => {
|
|
909
|
+
if (!confirm(t('productEditor.grouped.removeConfirm')))
|
|
910
|
+
return;
|
|
911
|
+
try {
|
|
912
|
+
await apiClient.delete(`/api/v1/admin/catalog/products/${productId}/grouped-items/${itemId}`);
|
|
913
|
+
setInfo(t('productEditor.grouped.success.remove'));
|
|
914
|
+
await refresh();
|
|
915
|
+
}
|
|
916
|
+
catch (err) {
|
|
917
|
+
setError(err instanceof ApiError ? err.envelope.error.message : t('productEditor.grouped.error.delete'));
|
|
918
|
+
}
|
|
919
|
+
}, [productId, refresh, t]);
|
|
920
|
+
return (_jsxs(Card, { className: "mt-4", children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: t('productEditor.grouped.title') }) }), _jsxs(CardContent, { className: "space-y-4", children: [error ? (_jsx(Alert, { variant: "destructive", children: _jsx(AlertDescription, { children: error }) })) : null, info ? (_jsx(Alert, { children: _jsx(AlertDescription, { children: info }) })) : null, loading ? (_jsx("p", { className: "text-sm text-muted-foreground", children: t('productEditor.loading') })) : items.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: t('productEditor.grouped.empty') })) : (_jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { children: t('productEditor.grouped.column.child') }), _jsx(TableHead, { children: t('productEditor.grouped.column.quantity') }), _jsx(TableHead, { children: t('productEditor.grouped.column.position') }), _jsx(TableHead, { className: "w-[1%] whitespace-nowrap", children: t('productEditor.grouped.column.actions') })] }) }), _jsx(TableBody, { children: items.map((item) => (_jsxs(TableRow, { children: [_jsx(TableCell, { className: "font-mono text-xs", children: item.childProductId }), _jsx(TableCell, { children: item.quantity }), _jsx(TableCell, { children: item.position }), _jsx(TableCell, { children: _jsx(Button, { type: "button", size: "sm", variant: "destructive", onClick: () => void handleDelete(item.id), children: t('productEditor.grouped.action.remove') }) })] }, item.id))) })] })), _jsx(CreateGroupedItemInline, { onCreate: handleCreate })] })] }));
|
|
921
|
+
}
|
|
922
|
+
function CreateGroupedItemInline({ onCreate, }) {
|
|
923
|
+
const t = useTranslation('catalog');
|
|
924
|
+
const [childProductId, setChildProductId] = useState('');
|
|
925
|
+
const [quantity, setQuantity] = useState('1');
|
|
926
|
+
return (_jsxs("div", { className: "grid gap-3 md:grid-cols-3 border-t pt-4", role: "group", "aria-label": t('productEditor.grouped.addTitle'), children: [_jsxs("div", { className: "space-y-1 md:col-span-2", children: [_jsx(Label, { htmlFor: "gchild", children: t('productEditor.grouped.column.child') }), _jsx(ProductPicker, { id: "gchild", mode: "select", value: childProductId || null, onChange: (v) => setChildProductId(v ?? '') })] }), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { htmlFor: "gquantity", children: t('productEditor.grouped.column.quantity') }), _jsx(Input, { id: "gquantity", type: "number", min: "1", value: quantity, onChange: (e) => setQuantity(e.target.value) })] }), _jsx("div", { className: "md:col-span-3", children: _jsx(Button, { type: "button", onClick: () => {
|
|
927
|
+
if (!childProductId)
|
|
928
|
+
return;
|
|
929
|
+
void onCreate({ childProductId, quantity }).then(() => {
|
|
930
|
+
setChildProductId('');
|
|
931
|
+
setQuantity('1');
|
|
932
|
+
});
|
|
933
|
+
}, children: t('productEditor.grouped.action.add') }) })] }));
|
|
934
|
+
}
|
|
935
|
+
function BundleSlotsSection({ productId }) {
|
|
936
|
+
const t = useTranslation('catalog');
|
|
937
|
+
const [slots, setSlots] = useState([]);
|
|
938
|
+
const [loading, setLoading] = useState(true);
|
|
939
|
+
const [error, setError] = useState(null);
|
|
940
|
+
const [info, setInfo] = useState(null);
|
|
941
|
+
const refresh = useCallback(async () => {
|
|
942
|
+
setLoading(true);
|
|
943
|
+
setError(null);
|
|
944
|
+
try {
|
|
945
|
+
const res = await apiClient.get(`/api/v1/admin/catalog/products/${productId}/bundle-slots`);
|
|
946
|
+
setSlots(res.data);
|
|
947
|
+
}
|
|
948
|
+
catch (err) {
|
|
949
|
+
setError(err instanceof ApiError ? err.envelope.error.message : t('productEditor.bundle.error.load'));
|
|
950
|
+
}
|
|
951
|
+
finally {
|
|
952
|
+
setLoading(false);
|
|
953
|
+
}
|
|
954
|
+
}, [productId, t]);
|
|
955
|
+
useEffect(() => {
|
|
956
|
+
void refresh();
|
|
957
|
+
}, [refresh]);
|
|
958
|
+
const handleCreateSlot = useCallback(async (input) => {
|
|
959
|
+
const min = Number(input.minQuantity);
|
|
960
|
+
const max = Number(input.maxQuantity);
|
|
961
|
+
if (!Number.isFinite(max) || max <= 0) {
|
|
962
|
+
setError(t('productEditor.bundle.error.invalidMax'));
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
try {
|
|
966
|
+
await apiClient.post(`/api/v1/admin/catalog/products/${productId}/bundle-slots`, {
|
|
967
|
+
name: { 'en-US': input.name },
|
|
968
|
+
...(Number.isFinite(min) ? { minQuantity: min } : {}),
|
|
969
|
+
maxQuantity: max,
|
|
970
|
+
});
|
|
971
|
+
setInfo(t('productEditor.bundle.success.addSlot', { name: input.name }));
|
|
972
|
+
await refresh();
|
|
973
|
+
}
|
|
974
|
+
catch (err) {
|
|
975
|
+
setError(err instanceof ApiError ? err.envelope.error.message : t('productEditor.bundle.error.addSlot'));
|
|
976
|
+
}
|
|
977
|
+
}, [productId, refresh, t]);
|
|
978
|
+
const handleDeleteSlot = useCallback(async (slotId) => {
|
|
979
|
+
if (!confirm(t('productEditor.bundle.removeSlotConfirm')))
|
|
980
|
+
return;
|
|
981
|
+
try {
|
|
982
|
+
await apiClient.delete(`/api/v1/admin/catalog/products/${productId}/bundle-slots/${slotId}`);
|
|
983
|
+
setInfo(t('productEditor.bundle.success.removeSlot'));
|
|
984
|
+
await refresh();
|
|
985
|
+
}
|
|
986
|
+
catch (err) {
|
|
987
|
+
setError(err instanceof ApiError ? err.envelope.error.message : t('productEditor.bundle.error.deleteSlot'));
|
|
988
|
+
}
|
|
989
|
+
}, [productId, refresh, t]);
|
|
990
|
+
const handleAddOption = useCallback(async (slotId, optionProductId) => {
|
|
991
|
+
if (!optionProductId)
|
|
992
|
+
return;
|
|
993
|
+
try {
|
|
994
|
+
await apiClient.post(`/api/v1/admin/catalog/products/${productId}/bundle-slots/${slotId}/options`, { optionProductId });
|
|
995
|
+
setInfo(t('productEditor.bundle.success.addOption'));
|
|
996
|
+
await refresh();
|
|
997
|
+
}
|
|
998
|
+
catch (err) {
|
|
999
|
+
setError(err instanceof ApiError ? err.envelope.error.message : t('productEditor.bundle.error.addOption'));
|
|
1000
|
+
}
|
|
1001
|
+
}, [productId, refresh, t]);
|
|
1002
|
+
const handleRemoveOption = useCallback(async (slotId, optionId) => {
|
|
1003
|
+
if (!confirm(t('productEditor.bundle.removeOptionConfirm')))
|
|
1004
|
+
return;
|
|
1005
|
+
try {
|
|
1006
|
+
await apiClient.delete(`/api/v1/admin/catalog/products/${productId}/bundle-slots/${slotId}/options/${optionId}`);
|
|
1007
|
+
setInfo(t('productEditor.bundle.success.removeOption'));
|
|
1008
|
+
await refresh();
|
|
1009
|
+
}
|
|
1010
|
+
catch (err) {
|
|
1011
|
+
setError(err instanceof ApiError ? err.envelope.error.message : t('productEditor.bundle.error.deleteOption'));
|
|
1012
|
+
}
|
|
1013
|
+
}, [productId, refresh, t]);
|
|
1014
|
+
return (_jsxs(Card, { className: "mt-4", children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: t('productEditor.bundle.title') }) }), _jsxs(CardContent, { className: "space-y-4", children: [error ? (_jsx(Alert, { variant: "destructive", children: _jsx(AlertDescription, { children: error }) })) : null, info ? (_jsx(Alert, { children: _jsx(AlertDescription, { children: info }) })) : null, loading ? (_jsx("p", { className: "text-sm text-muted-foreground", children: t('productEditor.loading') })) : slots.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: t('productEditor.bundle.empty') })) : (_jsx("div", { className: "space-y-4", children: slots.map((slot) => (_jsx(BundleSlotCard, { slot: slot, onAddOption: handleAddOption, onRemoveOption: handleRemoveOption, onDeleteSlot: handleDeleteSlot }, slot.id))) })), _jsx(CreateBundleSlotInline, { onCreate: handleCreateSlot })] })] }));
|
|
1015
|
+
}
|
|
1016
|
+
function BundleSlotCard({ slot, onAddOption, onRemoveOption, onDeleteSlot, }) {
|
|
1017
|
+
const t = useTranslation('catalog');
|
|
1018
|
+
const [optionId, setOptionId] = useState('');
|
|
1019
|
+
return (_jsxs("div", { className: "rounded border p-3", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("h3", { className: "font-medium", children: [slot.name['en-US'] ?? Object.values(slot.name)[0] ?? '—', ' ', _jsx("span", { className: "text-xs text-muted-foreground", children: t('productEditor.bundle.slotMeta', {
|
|
1020
|
+
min: slot.minQuantity,
|
|
1021
|
+
max: slot.maxQuantity,
|
|
1022
|
+
position: slot.position,
|
|
1023
|
+
}) })] }), _jsx(Button, { type: "button", size: "sm", variant: "destructive", onClick: () => void onDeleteSlot(slot.id), children: t('productEditor.bundle.action.deleteSlot') })] }), _jsx("div", { className: "mt-2", children: slot.options.length === 0 ? (_jsx("p", { className: "text-xs text-muted-foreground", children: t('productEditor.bundle.optionsEmpty') })) : (_jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { children: t('productEditor.bundle.column.optionProductId') }), _jsx(TableHead, { children: t('productEditor.bundle.column.defaultQuantity') }), _jsx(TableHead, { children: t('productEditor.bundle.column.position') }), _jsx(TableHead, { className: "w-[1%] whitespace-nowrap", children: t('productEditor.bundle.column.actions') })] }) }), _jsx(TableBody, { children: slot.options.map((opt) => (_jsxs(TableRow, { children: [_jsx(TableCell, { className: "font-mono text-xs", children: opt.optionProductId }), _jsx(TableCell, { children: opt.defaultQuantity }), _jsx(TableCell, { children: opt.position }), _jsx(TableCell, { children: _jsx(Button, { type: "button", size: "sm", variant: "destructive", onClick: () => void onRemoveOption(slot.id, opt.id), children: t('productEditor.bundle.action.removeOption') }) })] }, opt.id))) })] })) }), _jsxs("div", { className: "mt-3 flex items-end gap-2", children: [_jsxs("div", { className: "flex-1 space-y-1", children: [_jsx(Label, { htmlFor: `opt-${slot.id}`, children: t('productEditor.bundle.column.optionProductId') }), _jsx(ProductPicker, { id: `opt-${slot.id}`, mode: "select", value: optionId || null, onChange: (v) => setOptionId(v ?? '') })] }), _jsx(Button, { type: "button", onClick: () => {
|
|
1024
|
+
void onAddOption(slot.id, optionId).then(() => {
|
|
1025
|
+
setOptionId('');
|
|
1026
|
+
});
|
|
1027
|
+
}, children: t('productEditor.bundle.action.addOption') })] })] }));
|
|
1028
|
+
}
|
|
1029
|
+
function CreateBundleSlotInline({ onCreate, }) {
|
|
1030
|
+
const t = useTranslation('catalog');
|
|
1031
|
+
const [name, setName] = useState('');
|
|
1032
|
+
const [minQuantity, setMinQuantity] = useState('0');
|
|
1033
|
+
const [maxQuantity, setMaxQuantity] = useState('1');
|
|
1034
|
+
return (_jsxs("div", { className: "grid gap-3 md:grid-cols-4 border-t pt-4", role: "group", "aria-label": t('productEditor.bundle.addTitle'), children: [_jsxs("div", { className: "space-y-1 md:col-span-2", children: [_jsx(Label, { htmlFor: "bsname", children: t('productEditor.bundle.field.slotName') }), _jsx(Input, { id: "bsname", value: name, onChange: (e) => setName(e.target.value), placeholder: t('productEditor.bundle.field.slotNamePlaceholder') })] }), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { htmlFor: "bsmin", children: t('productEditor.bundle.field.min') }), _jsx(Input, { id: "bsmin", type: "number", min: "0", value: minQuantity, onChange: (e) => setMinQuantity(e.target.value) })] }), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { htmlFor: "bsmax", children: t('productEditor.bundle.field.max') }), _jsx(Input, { id: "bsmax", type: "number", min: "1", value: maxQuantity, onChange: (e) => setMaxQuantity(e.target.value) })] }), _jsx("div", { className: "md:col-span-4", children: _jsx(Button, { type: "button", onClick: () => {
|
|
1035
|
+
if (!name)
|
|
1036
|
+
return;
|
|
1037
|
+
void onCreate({ name, minQuantity, maxQuantity }).then(() => {
|
|
1038
|
+
setName('');
|
|
1039
|
+
setMinQuantity('0');
|
|
1040
|
+
setMaxQuantity('1');
|
|
1041
|
+
});
|
|
1042
|
+
}, children: t('productEditor.bundle.action.add') }) })] }));
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* The default export a route declaration's dynamic-import factory resolves
|
|
1046
|
+
* (feature 091, R6). The named export stays: it is the spelling this module's
|
|
1047
|
+
* own siblings and tests use.
|
|
1048
|
+
*/
|
|
1049
|
+
export default ProductEditor;
|
|
1050
|
+
//# sourceMappingURL=ProductEditor.js.map
|