Browse Source

feat(admin-ui): Implement new product detail view

Michael Bromley 2 years ago
parent
commit
85418af575
50 changed files with 1032 additions and 152 deletions
  1. 26 26
      packages/admin-ui/i18n-coverage.json
  2. 9 1
      packages/admin-ui/src/lib/catalog/src/catalog.module.ts
  3. 3 2
      packages/admin-ui/src/lib/catalog/src/catalog.routes.ts
  4. 19 17
      packages/admin-ui/src/lib/catalog/src/components/assets/assets.component.html
  5. 12 1
      packages/admin-ui/src/lib/catalog/src/components/assets/assets.component.scss
  6. 0 4
      packages/admin-ui/src/lib/catalog/src/components/collection-data-table/collection-data-table.component.ts
  7. 21 6
      packages/admin-ui/src/lib/catalog/src/components/collection-list/collection-list.component.html
  8. 0 9
      packages/admin-ui/src/lib/catalog/src/components/collection-list/collection-list.component.ts
  9. 1 1
      packages/admin-ui/src/lib/catalog/src/components/facet-list/facet-list.component.html
  10. 188 0
      packages/admin-ui/src/lib/catalog/src/components/product-detail2/product-detail.component.html
  11. 64 0
      packages/admin-ui/src/lib/catalog/src/components/product-detail2/product-detail.component.scss
  12. 457 0
      packages/admin-ui/src/lib/catalog/src/components/product-detail2/product-detail.component.ts
  13. 30 0
      packages/admin-ui/src/lib/catalog/src/components/product-detail2/product-detail.types.ts
  14. 20 5
      packages/admin-ui/src/lib/catalog/src/components/product-list/product-list.component.html
  15. 0 10
      packages/admin-ui/src/lib/catalog/src/components/product-list/product-list.component.ts
  16. 2 2
      packages/admin-ui/src/lib/catalog/src/components/product-variant-list/product-variant-list.component.html
  17. 4 1
      packages/admin-ui/src/lib/catalog/src/components/product-variant-list/product-variant-list.component.ts
  18. 14 24
      packages/admin-ui/src/lib/core/src/components/ui-language-switcher-dialog/ui-language-switcher-dialog.component.html
  19. 14 5
      packages/admin-ui/src/lib/core/src/providers/page/page.service.ts
  20. 1 1
      packages/admin-ui/src/lib/core/src/shared/components/card/card.component.html
  21. 10 1
      packages/admin-ui/src/lib/core/src/shared/components/card/card.component.scss
  22. 1 0
      packages/admin-ui/src/lib/core/src/shared/components/card/card.component.ts
  23. 1 1
      packages/admin-ui/src/lib/core/src/shared/components/data-table-2/data-table2.component.scss
  24. 1 1
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/select-form-input/select-form-input.component.html
  25. 1 1
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/textarea-form-input/textarea-form-input.component.scss
  26. 19 11
      packages/admin-ui/src/lib/core/src/shared/shared.module.ts
  27. 1 1
      packages/admin-ui/src/lib/marketing/src/components/promotion-list/promotion-list.component.html
  28. 1 1
      packages/admin-ui/src/lib/order/src/components/order-list/order-list.component.html
  29. 1 1
      packages/admin-ui/src/lib/settings/src/components/administrator-list/administrator-list.component.html
  30. 1 1
      packages/admin-ui/src/lib/settings/src/components/channel-list/channel-list.component.html
  31. 1 1
      packages/admin-ui/src/lib/settings/src/components/country-list/country-list.component.html
  32. 1 1
      packages/admin-ui/src/lib/settings/src/components/payment-method-list/payment-method-list.component.html
  33. 1 1
      packages/admin-ui/src/lib/settings/src/components/seller-list/seller-list.component.html
  34. 1 1
      packages/admin-ui/src/lib/settings/src/components/shipping-method-list/shipping-method-list.component.html
  35. 1 1
      packages/admin-ui/src/lib/settings/src/components/tax-category-list/tax-category-list.component.html
  36. 1 1
      packages/admin-ui/src/lib/settings/src/components/tax-rate-list/tax-rate-list.component.html
  37. 1 0
      packages/admin-ui/src/lib/settings/src/components/zone-list/zone-list.component.html
  38. 8 1
      packages/admin-ui/src/lib/static/i18n-messages/cs.json
  39. 8 1
      packages/admin-ui/src/lib/static/i18n-messages/de.json
  40. 7 0
      packages/admin-ui/src/lib/static/i18n-messages/en.json
  41. 8 1
      packages/admin-ui/src/lib/static/i18n-messages/es.json
  42. 8 1
      packages/admin-ui/src/lib/static/i18n-messages/fr.json
  43. 8 1
      packages/admin-ui/src/lib/static/i18n-messages/it.json
  44. 8 1
      packages/admin-ui/src/lib/static/i18n-messages/pl.json
  45. 8 1
      packages/admin-ui/src/lib/static/i18n-messages/pt_BR.json
  46. 8 1
      packages/admin-ui/src/lib/static/i18n-messages/pt_PT.json
  47. 8 1
      packages/admin-ui/src/lib/static/i18n-messages/ru.json
  48. 8 1
      packages/admin-ui/src/lib/static/i18n-messages/uk.json
  49. 8 1
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hans.json
  50. 8 1
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hant.json

+ 26 - 26
packages/admin-ui/i18n-coverage.json

@@ -1,71 +1,71 @@
 {
-  "generatedOn": "2023-05-16T11:06:46.976Z",
-  "lastCommit": "b458c0c43132e11b5605b3d62cfcbb9763f618ce",
+  "generatedOn": "2023-05-18T09:17:32.713Z",
+  "lastCommit": "4057a5852ccf199b5fd6b9fd37bdda65a198d4f5",
   "translationStatus": {
     "cs": {
-      "tokenCount": 706,
+      "tokenCount": 711,
       "translatedCount": 566,
       "percentage": 80
     },
     "de": {
-      "tokenCount": 706,
+      "tokenCount": 711,
       "translatedCount": 549,
-      "percentage": 78
+      "percentage": 77
     },
     "en": {
-      "tokenCount": 706,
-      "translatedCount": 703,
-      "percentage": 100
+      "tokenCount": 711,
+      "translatedCount": 705,
+      "percentage": 99
     },
     "es": {
-      "tokenCount": 706,
+      "tokenCount": 711,
       "translatedCount": 594,
       "percentage": 84
     },
     "fr": {
-      "tokenCount": 706,
+      "tokenCount": 711,
       "translatedCount": 586,
-      "percentage": 83
+      "percentage": 82
     },
     "it": {
-      "tokenCount": 706,
+      "tokenCount": 711,
       "translatedCount": 592,
-      "percentage": 84
+      "percentage": 83
     },
     "pl": {
-      "tokenCount": 706,
+      "tokenCount": 711,
       "translatedCount": 393,
-      "percentage": 56
+      "percentage": 55
     },
     "pt_BR": {
-      "tokenCount": 706,
+      "tokenCount": 711,
       "translatedCount": 564,
-      "percentage": 80
+      "percentage": 79
     },
     "pt_PT": {
-      "tokenCount": 706,
+      "tokenCount": 711,
       "translatedCount": 602,
       "percentage": 85
     },
     "ru": {
-      "tokenCount": 706,
+      "tokenCount": 711,
       "translatedCount": 591,
-      "percentage": 84
+      "percentage": 83
     },
     "uk": {
-      "tokenCount": 706,
+      "tokenCount": 711,
       "translatedCount": 591,
-      "percentage": 84
+      "percentage": 83
     },
     "zh_Hans": {
-      "tokenCount": 706,
+      "tokenCount": 711,
       "translatedCount": 534,
-      "percentage": 76
+      "percentage": 75
     },
     "zh_Hant": {
-      "tokenCount": 706,
+      "tokenCount": 711,
       "translatedCount": 373,
-      "percentage": 53
+      "percentage": 52
     }
   }
 }

+ 9 - 1
packages/admin-ui/src/lib/catalog/src/catalog.module.ts

@@ -1,7 +1,7 @@
 import { NgModule } from '@angular/core';
 import { RouterModule, ROUTES } from '@angular/router';
-import { BulkActionRegistryService, SharedModule, PageService } from '@vendure/admin-ui/core';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
+import { BulkActionRegistryService, PageService, SharedModule } from '@vendure/admin-ui/core';
 
 import { createRoutes } from './catalog.routes';
 import { ApplyFacetDialogComponent } from './components/apply-facet-dialog/apply-facet-dialog.component';
@@ -36,6 +36,7 @@ import { GenerateProductVariantsComponent } from './components/generate-product-
 import { MoveCollectionsDialogComponent } from './components/move-collections-dialog/move-collections-dialog.component';
 import { OptionValueInputComponent } from './components/option-value-input/option-value-input.component';
 import { ProductDetailComponent } from './components/product-detail/product-detail.component';
+import { ProductDetail2Component } from './components/product-detail2/product-detail.component';
 import {
     assignFacetValuesToProductsBulkAction,
     assignProductsToChannelBulkAction,
@@ -81,6 +82,7 @@ const CATALOG_COMPONENTS = [
     CollectionBreadcrumbPipe,
     MoveCollectionsDialogComponent,
     ProductVariantListComponent,
+    ProductDetail2Component,
 ];
 
 @NgModule({
@@ -121,6 +123,12 @@ export class CatalogModule {
             route: '',
             component: ProductListComponent,
         });
+        pageService.registerPageTab({
+            location: 'product-detail',
+            tab: _('catalog.products'),
+            route: '',
+            component: ProductDetail2Component,
+        });
         pageService.registerPageTab({
             location: 'product-list',
             tab: _('catalog.product-variants'),

+ 3 - 2
packages/admin-ui/src/lib/catalog/src/catalog.routes.ts

@@ -41,10 +41,11 @@ export const createRoutes = (pageService: PageService): Route[] => [
     },
     {
         path: 'products/:id',
-        component: ProductDetailComponent,
+        component: PageComponent,
         resolve: createResolveData(ProductResolver),
-        canDeactivate: [CanDeactivateDetailGuard],
+        // canDeactivate: [CanDeactivateDetailGuard],
         data: {
+            locationId: 'product-detail',
             breadcrumb: productBreadcrumb,
         },
         children: pageService.getPageTabRoutes('product-detail'),

+ 19 - 17
packages/admin-ui/src/lib/catalog/src/components/assets/assets.component.html

@@ -1,35 +1,37 @@
-<div class="card" *ngIf="!compact; else compactView">
-    <div class="card-img">
-        <div class="featured-asset">
-            <img
-                *ngIf="featuredAsset"
-                [src]="featuredAsset | assetPreview:'small'"
-                (click)="previewAsset(featuredAsset)"
-            />
-            <div class="placeholder" *ngIf="!featuredAsset" (click)="selectAssets()">
-                <clr-icon shape="image" size="128"></clr-icon>
-                <div>{{ 'catalog.no-featured-asset' | translate }}</div>
-            </div>
+<div *ngIf="!compact; else compactView" class="standard-view-container">
+    <div class="featured-asset">
+        <img
+            *ngIf="featuredAsset"
+            [src]="featuredAsset | assetPreview : 'small'"
+            (click)="previewAsset(featuredAsset)"
+        />
+        <div class="placeholder" *ngIf="!featuredAsset" (click)="selectAssets()">
+            <clr-icon shape="image" size="128"></clr-icon>
+            <div>{{ 'catalog.no-featured-asset' | translate }}</div>
         </div>
     </div>
-    <div class="card-block"><ng-container *ngTemplateOutlet="assetList"></ng-container></div>
-    <div class="card-footer" *vdrIfPermissions="updatePermissions">
+    <div class="all-assets-container">
+    <ng-container *ngTemplateOutlet="assetList"></ng-container>
+    <div *vdrIfPermissions="updatePermissions">
         <button class="btn" (click)="selectAssets()">
             <clr-icon shape="attachment"></clr-icon>
             {{ 'asset.add-asset' | translate }}
         </button>
     </div>
+    </div>
 </div>
 
 <ng-template #compactView>
     <div class="featured-asset compact">
         <img
             *ngIf="featuredAsset"
-            [src]="featuredAsset | assetPreview:'thumb'"
+            [src]="featuredAsset | assetPreview : 'thumb'"
             (click)="previewAsset(featuredAsset)"
         />
 
-        <div class="placeholder" *ngIf="!featuredAsset" (click)="selectAssets()"><clr-icon shape="image" size="150"></clr-icon></div>
+        <div class="placeholder" *ngIf="!featuredAsset" (click)="selectAssets()">
+            <clr-icon shape="image" size="150"></clr-icon>
+        </div>
     </div>
     <ng-container *ngTemplateOutlet="assetList"></ng-container>
     <button
@@ -62,7 +64,7 @@
                     [title]=""
                     tabindex="0"
                 >
-                    <img [src]="asset | assetPreview:'tiny'" />
+                    <img [src]="asset | assetPreview : 'tiny'" />
                 </div>
                 <vdr-dropdown-menu vdrPosition="bottom-right">
                     <button type="button" vdrDropdownItem (click)="previewAsset(asset)">

+ 12 - 1
packages/admin-ui/src/lib/catalog/src/components/assets/assets.component.scss

@@ -1,12 +1,23 @@
 
 :host {
-    width: 340px;
     display: block;
     &.compact {
         width: 162px;
     }
 }
 
+.standard-view-container {
+    display: flex;
+    gap: calc(var(--space-unit) * 2);
+}
+
+.all-assets-container {
+    display: flex;
+    max-width: 50%;
+    flex-direction: column;
+    justify-content: space-between;
+}
+
 .placeholder {
     text-align: center;
     color: var(--color-grey-300);

+ 0 - 4
packages/admin-ui/src/lib/catalog/src/components/collection-data-table/collection-data-table.component.ts

@@ -91,10 +91,6 @@ export class CollectionDataTableComponent
         return this.subCollections?.filter(c => c.parentId === item.id) ?? [];
     }
 
-    /**
-     * Predicate function that only allows even numbers to be
-     * sorted into even indices and odd numbers at odd indices.
-     */
     sortPredicate = (index: number, item: CdkDrag<{ depth: number; collection: CollectionTableItem }>) => {
         const itemAtIndex = this.dropList.getSortedItems()[index];
         return itemAtIndex?.data.collection.parentId === item.data.collection.parentId;

+ 21 - 6
packages/admin-ui/src/lib/catalog/src/components/collection-list/collection-list.component.html

@@ -1,10 +1,24 @@
 <vdr-page-block>
-    <vdr-language-selector
-        class="mt-2"
-        [availableLanguageCodes]="availableLanguages$ | async"
-        [currentLanguageCode]="contentLanguage$ | async"
-        (languageCodeChange)="setLanguage($event)"
-    ></vdr-language-selector>
+    <vdr-action-bar>
+        <vdr-ab-left>
+            <vdr-language-selector
+                [availableLanguageCodes]="availableLanguages$ | async"
+                [currentLanguageCode]="contentLanguage$ | async"
+                (languageCodeChange)="setLanguage($event)"
+            ></vdr-language-selector>
+        </vdr-ab-left>
+        <vdr-ab-right>
+            <vdr-action-bar-items locationId="collection-list"></vdr-action-bar-items>
+            <a
+                class="btn btn-primary"
+                *vdrIfPermissions="['CreateCatalog', 'CreateCollection']"
+                [routerLink]="['./create']"
+            >
+                <clr-icon shape="plus"></clr-icon>
+                {{ 'catalog.create-new-collection' | translate }}
+            </a>
+        </vdr-ab-right>
+    </vdr-action-bar>
 </vdr-page-block>
 <vdr-split-view [rightPanelOpen]="activeCollectionId$ | async" (closeClicked)="closeContents()">
     <ng-template vdrSplitViewLeft>
@@ -124,6 +138,7 @@
             <vdr-dt2-custom-field-column
                 *ngFor="let customField of customFields"
                 [customField]="customField"
+                [sorts]="sorts"
             />
         </vdr-collection-data-table>
     </ng-template>

+ 0 - 9
packages/admin-ui/src/lib/catalog/src/components/collection-list/collection-list.component.ts

@@ -65,20 +65,11 @@ export class CollectionListComponent
         private modalService: ModalService,
         router: Router,
         route: ActivatedRoute,
-        navBuilderService: NavBuilderService,
         private serverConfigService: ServerConfigService,
         private changeDetectorRef: ChangeDetectorRef,
         private dataTableService: DataTableService,
     ) {
         super(router, route);
-        navBuilderService.addActionBarItem({
-            id: 'create-collection',
-            label: _('catalog.create-new-collection'),
-            locationId: 'collection-list',
-            icon: 'plus',
-            routerLink: ['./create'],
-            requiresPermission: ['CreateCatalog', 'CreateCollection'],
-        });
         super.setQueryFn(
             (...args: any[]) => this.dataService.collection.getCollections().refetchOnChannelChange(),
             data => data.collections,

+ 1 - 1
packages/admin-ui/src/lib/catalog/src/components/facet-list/facet-list.component.html

@@ -96,5 +96,5 @@
             </div>
         </ng-template>
     </vdr-dt2-column>
-    <vdr-dt2-custom-field-column *ngFor="let customField of customFields" [customField]="customField" />
+    <vdr-dt2-custom-field-column *ngFor="let customField of customFields" [customField]="customField" [sorts]="sorts" />
 </vdr-data-table-2>

+ 188 - 0
packages/admin-ui/src/lib/catalog/src/components/product-detail2/product-detail.component.html

@@ -0,0 +1,188 @@
+<vdr-page-block>
+    <vdr-action-bar>
+        <vdr-ab-left>
+            <div class="flex clr-flex-row"></div>
+            <vdr-language-selector
+                [disabled]="isNew$ | async"
+                [availableLanguageCodes]="availableLanguages$ | async"
+                [currentLanguageCode]="languageCode$ | async"
+                (languageCodeChange)="setLanguage($event)"
+            ></vdr-language-selector>
+        </vdr-ab-left>
+
+        <vdr-ab-right>
+            <vdr-action-bar-items locationId="product-detail"></vdr-action-bar-items>
+            <button
+                class="btn btn-primary"
+                *ngIf="isNew$ | async; else updateButton"
+                (click)="create()"
+                [disabled]="detailForm.invalid || detailForm.pristine"
+            >
+                {{ 'common.create' | translate }}
+            </button>
+            <ng-template #updateButton>
+                <button
+                    *vdrIfPermissions="['UpdateCatalog', 'UpdateProduct']"
+                    class="btn btn-primary"
+                    (click)="save()"
+                    [disabled]="(detailForm.invalid || detailForm.pristine) && !assetsChanged()"
+                >
+                    {{ 'common.update' | translate }}
+                </button>
+            </ng-template>
+        </vdr-ab-right>
+    </vdr-action-bar>
+</vdr-page-block>
+
+<form class="form" [formGroup]="detailForm" *ngIf="product$ | async as product">
+    <vdr-page-detail-layout>
+        <vdr-page-detail-sidebar
+            ><vdr-card>
+                <vdr-form-field [label]="'catalog.visibility' | translate" for="visibility">
+                    <clr-toggle-wrapper *vdrIfPermissions="['UpdateCatalog', 'UpdateProduct']">
+                        <input
+                            type="checkbox"
+                            clrToggle
+                            name="enabled"
+                            [formControl]="detailForm.get(['enabled'])"
+                        />
+                        <label>{{ 'common.enabled' | translate }}</label>
+                    </clr-toggle-wrapper>
+                </vdr-form-field>
+            </vdr-card>
+            <ng-container *ngIf="!(isNew$ | async)">
+                <vdr-card *vdrIfMultichannel [title]="'common.channels' | translate">
+                    <vdr-form-item *vdrIfDefaultChannelActive>
+                        <div class="flex channel-assignment">
+                            <ng-container *ngFor="let channel of productChannels$ | async">
+                                <vdr-chip
+                                    *ngIf="!isDefaultChannel(channel.code)"
+                                    icon="times-circle"
+                                    (iconClick)="removeFromChannel(channel.id)"
+                                >
+                                    <vdr-channel-badge [channelCode]="channel.code"></vdr-channel-badge>
+                                    {{ channel.code | channelCodeToLabel }}
+                                </vdr-chip>
+                            </ng-container>
+                            <button class="btn btn-sm" (click)="assignToChannel()">
+                                <clr-icon shape="layers"></clr-icon>
+                                {{ 'catalog.assign-to-channel' | translate }}
+                            </button>
+                        </div>
+                    </vdr-form-item>
+                </vdr-card>
+            </ng-container>
+            <vdr-card [title]="'catalog.facets' | translate">
+                <div class="facets">
+                    <vdr-facet-value-chip
+                        *ngFor="let facetValue of facetValues$ | async"
+                        [facetValue]="facetValue"
+                        [removable]="['UpdateCatalog', 'UpdateProduct'] | hasPermission"
+                        (remove)="removeProductFacetValue(facetValue.id)"
+                    ></vdr-facet-value-chip>
+                    <button
+                        class="btn btn-sm btn-secondary"
+                        *vdrIfPermissions="['UpdateCatalog', 'UpdateProduct']"
+                        (click)="selectProductFacetValue()"
+                    >
+                        <clr-icon shape="plus"></clr-icon>
+                        {{ 'catalog.add-facets' | translate }}
+                    </button>
+                </div>
+            </vdr-card>
+
+            <vdr-card>
+                <vdr-page-entity-info
+                    *ngIf="entity$ | async as entity"
+                    [entity]="entity"
+                ></vdr-page-entity-info>
+            </vdr-card>
+        </vdr-page-detail-sidebar>
+
+        <vdr-page-block>
+            <button type="submit" hidden x-data="prevents enter key from triggering other buttons"></button>
+            <vdr-card>
+                <vdr-form-field [label]="'catalog.product-name' | translate" for="name">
+                    <input
+                        id="name"
+                        type="text"
+                        formControlName="name"
+                        [readonly]="!(['UpdateCatalog', 'UpdateProduct'] | hasPermission)"
+                        (input)="updateSlug($event.target.value)"
+                    />
+                </vdr-form-field>
+                <div *ngIf="(isNew$ | async) === false && detailForm.get(['name'])?.dirty">
+                    <clr-checkbox-wrapper>
+                        <input
+                            clrCheckbox
+                            type="checkbox"
+                            id="auto-update"
+                            formControlName="autoUpdateVariantNames"
+                        />
+                        <label>{{ 'catalog.auto-update-product-variant-name' | translate }}</label>
+                    </clr-checkbox-wrapper>
+                </div>
+                <vdr-form-field
+                    class="mt-2"
+                    [label]="'catalog.slug' | translate"
+                    for="slug"
+                    [errors]="{ pattern: 'catalog.slug-pattern-error' | translate }"
+                >
+                    <input
+                        id="slug"
+                        type="text"
+                        formControlName="slug"
+                        [readonly]="!(['UpdateCatalog', 'UpdateProduct'] | hasPermission)"
+                    />
+                </vdr-form-field>
+                <vdr-form-field
+                    [label]="'common.description' | translate"
+                    for="slug"
+                    [errors]="{ pattern: 'catalog.slug-pattern-error' | translate }"
+                >
+                    <vdr-rich-text-editor
+                        formControlName="description"
+                        [readonly]="!(['UpdateCatalog', 'UpdateProduct'] | hasPermission)"
+                    ></vdr-rich-text-editor>
+                </vdr-form-field>
+            </vdr-card>
+            <vdr-card [title]="'common.custom-fields' | translate" *ngIf="customFields.length">
+                <section formGroupName="customFields">
+                    <vdr-tabbed-custom-fields
+                        entityName="Product"
+                        [customFields]="customFields"
+                        [customFieldsFormGroup]="detailForm.get(['customFields'])"
+                        [readonly]="!(['UpdateCatalog', 'UpdateProduct'] | hasPermission)"
+                    ></vdr-tabbed-custom-fields>
+                </section>
+                <vdr-custom-detail-component-host
+                    locationId="product-detail"
+                    [entity$]="entity$"
+                    [detailForm]="detailForm"
+                ></vdr-custom-detail-component-host>
+            </vdr-card>
+            <vdr-card [title]="'catalog.assets' | translate">
+                <vdr-assets
+                    [assets]="assetChanges.assets || product.assets"
+                    [featuredAsset]="assetChanges.featuredAsset || product.featuredAsset"
+                    [updatePermissions]="updatePermissions"
+                    (change)="assetChanges = $event"
+                ></vdr-assets>
+            </vdr-card>
+
+            <vdr-card [title]="'catalog.product-variants' | translate" [paddingX]="false">
+                <div *ngIf="isNew$ | async; else variantList">
+                    <vdr-generate-product-variants
+                        (variantsChange)="createVariantsConfig = $event"
+                    ></vdr-generate-product-variants>
+                </div>
+                <ng-template #variantList>
+                    <vdr-product-variant-list
+                        [productId]="this.id"
+                        [hideLanguageSelect]="true"
+                    ></vdr-product-variant-list>
+                </ng-template>
+            </vdr-card>
+        </vdr-page-block>
+    </vdr-page-detail-layout>
+</form>

+ 64 - 0
packages/admin-ui/src/lib/catalog/src/components/product-detail2/product-detail.component.scss

@@ -0,0 +1,64 @@
+@import 'variables';
+
+:host {
+    ::ng-deep {
+        trix-toolbar {
+            top: 24px;
+        }
+    }
+}
+
+.facets {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 3px;
+}
+
+vdr-action-bar clr-toggle-wrapper {
+    margin-top: 12px;
+}
+
+.variant-filter {
+    flex: 1;
+    display: flex;
+    input {
+        flex: 1;
+        max-width: initial;
+        border-radius: 3px 0 0 3px !important;
+    }
+    .icon-button {
+        border: 1px solid var(--color-component-border-300);
+        background-color: var(--color-component-bg-100);
+        border-radius: 0 3px 3px 0;
+        border-left: none;
+    }
+}
+
+.group-name {
+    padding-right: 6px;
+}
+
+.view-mode {
+    display: flex;
+    flex-direction: column;
+    justify-content: space-between;
+    @media screen and (min-width: $breakpoint-small) {
+        flex-direction: row;
+    }
+}
+
+.edit-variants-btn {
+    margin-top: 0;
+}
+
+.channel-assignment {
+    flex-wrap: wrap;
+    max-height: 144px;
+    overflow-y: auto;
+}
+
+.pagination-row {
+    display: flex;
+    align-items: baseline;
+    justify-content: space-between;
+}

+ 457 - 0
packages/admin-ui/src/lib/catalog/src/components/product-detail2/product-detail.component.ts

@@ -0,0 +1,457 @@
+import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
+import { FormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
+import {
+    BaseDetailComponent,
+    CreateProductInput,
+    createUpdatedTranslatable,
+    DataService,
+    findTranslation,
+    getChannelCodeFromUserStatus,
+    GetProductWithVariantsQuery,
+    LanguageCode,
+    ModalService,
+    NotificationService,
+    Permission,
+    ProductDetailFragment,
+    ProductVariantFragment,
+    ServerConfigService,
+    unicodePatternValidator,
+    UpdateProductInput,
+    UpdateProductMutation,
+    UpdateProductOptionInput,
+    UpdateProductVariantInput,
+    UpdateProductVariantsMutation,
+} from '@vendure/admin-ui/core';
+import { normalizeString } from '@vendure/common/lib/normalize-string';
+import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants';
+import { unique } from '@vendure/common/lib/unique';
+import { combineLatest, concat, EMPTY, from, Observable } from 'rxjs';
+import {
+    distinctUntilChanged,
+    map,
+    mergeMap,
+    shareReplay,
+    skip,
+    switchMap,
+    switchMapTo,
+    take,
+} from 'rxjs/operators';
+
+import { ProductDetailService } from '../../providers/product-detail/product-detail.service';
+import { ApplyFacetDialogComponent } from '../apply-facet-dialog/apply-facet-dialog.component';
+import { AssignProductsToChannelDialogComponent } from '../assign-products-to-channel-dialog/assign-products-to-channel-dialog.component';
+import { CreateProductVariantsConfig } from '../generate-product-variants/generate-product-variants.component';
+
+import { SelectedAssets } from './product-detail.types';
+
+@Component({
+    selector: 'vdr-product-detail2',
+    templateUrl: './product-detail.component.html',
+    styleUrls: ['./product-detail.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ProductDetail2Component
+    extends BaseDetailComponent<NonNullable<GetProductWithVariantsQuery['product']>>
+    implements OnInit, OnDestroy
+{
+    product$: Observable<NonNullable<GetProductWithVariantsQuery['product']>>;
+    readonly customFields = this.getCustomFieldConfig('Product');
+    detailForm = this.formBuilder.group({
+        enabled: true,
+        name: ['', Validators.required],
+        autoUpdateVariantNames: true,
+        slug: ['', unicodePatternValidator(/^[\p{Letter}0-9_-]+$/)],
+        description: '',
+        facetValueIds: [[] as string[]],
+        customFields: this.formBuilder.group(
+            this.customFields.reduce((hash, field) => ({ ...hash, [field.name]: '' }), {}),
+        ),
+    });
+    assetChanges: SelectedAssets = {};
+    productChannels$: Observable<ProductDetailFragment['channels']>;
+    facetValues$: Observable<ProductDetailFragment['facetValues']>;
+    createVariantsConfig: CreateProductVariantsConfig = { groups: [], variants: [] };
+    public readonly updatePermissions = [Permission.UpdateCatalog, Permission.UpdateProduct];
+
+    constructor(
+        route: ActivatedRoute,
+        router: Router,
+        serverConfigService: ServerConfigService,
+        private productDetailService: ProductDetailService,
+        private formBuilder: FormBuilder,
+        private modalService: ModalService,
+        private notificationService: NotificationService,
+        protected dataService: DataService,
+        private changeDetector: ChangeDetectorRef,
+    ) {
+        super(route, router, serverConfigService, dataService);
+    }
+
+    ngOnInit() {
+        this.init();
+        this.product$ = this.entity$;
+        const productFacetValues$ = this.product$.pipe(map(product => product.facetValues));
+        const productGroup = this.detailForm;
+        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+        const formFacetValueIdChanges$ = productGroup.get('facetValueIds')!.valueChanges.pipe(
+            skip(1),
+            distinctUntilChanged(),
+            switchMap(ids =>
+                this.dataService.facet
+                    .getFacetValues({ filter: { id: { in: ids } } })
+                    .mapSingle(({ facetValues }) => facetValues.items),
+            ),
+            shareReplay(1),
+        );
+        this.facetValues$ = concat(
+            productFacetValues$.pipe(take(1)),
+            productFacetValues$.pipe(switchMapTo(formFacetValueIdChanges$)),
+        );
+        this.productChannels$ = this.product$.pipe(map(p => p.channels));
+    }
+
+    ngOnDestroy() {
+        this.destroy();
+    }
+
+    isDefaultChannel(channelCode: string): boolean {
+        return channelCode === DEFAULT_CHANNEL_CODE;
+    }
+
+    assignToChannel() {
+        this.productChannels$
+            .pipe(
+                take(1),
+                switchMap(channels =>
+                    this.modalService.fromComponent(AssignProductsToChannelDialogComponent, {
+                        size: 'lg',
+                        locals: {
+                            productIds: [this.id],
+                            currentChannelIds: channels.map(c => c.id),
+                        },
+                    }),
+                ),
+            )
+            .subscribe();
+    }
+
+    removeFromChannel(channelId: string) {
+        from(getChannelCodeFromUserStatus(this.dataService, channelId))
+            .pipe(
+                switchMap(({ channelCode }) =>
+                    this.modalService.dialog({
+                        title: _('catalog.remove-product-from-channel'),
+                        buttons: [
+                            { type: 'secondary', label: _('common.cancel') },
+                            {
+                                type: 'danger',
+                                label: _('catalog.remove-from-channel'),
+                                translationVars: { channelCode },
+                                returnValue: true,
+                            },
+                        ],
+                    }),
+                ),
+                switchMap(response =>
+                    response
+                        ? this.dataService.product.removeProductsFromChannel({
+                              channelId,
+                              productIds: [this.id],
+                          })
+                        : EMPTY,
+                ),
+            )
+            .subscribe(
+                () => {
+                    this.notificationService.success(_('catalog.notify-remove-product-from-channel-success'));
+                },
+                err => {
+                    this.notificationService.error(_('catalog.notify-remove-product-from-channel-error'));
+                },
+            );
+    }
+
+    assignVariantToChannel(variant: ProductVariantFragment) {
+        return this.modalService
+            .fromComponent(AssignProductsToChannelDialogComponent, {
+                size: 'lg',
+                locals: {
+                    productIds: [this.id],
+                    productVariantIds: [variant.id],
+                    currentChannelIds: variant.channels.map(c => c.id),
+                },
+            })
+            .subscribe();
+    }
+
+    removeVariantFromChannel({ channelId, variant }: { channelId: string; variant: ProductVariantFragment }) {
+        from(getChannelCodeFromUserStatus(this.dataService, channelId))
+            .pipe(
+                switchMap(({ channelCode }) =>
+                    this.modalService.dialog({
+                        title: _('catalog.remove-product-variant-from-channel'),
+                        buttons: [
+                            { type: 'secondary', label: _('common.cancel') },
+                            {
+                                type: 'danger',
+                                label: _('catalog.remove-from-channel'),
+                                translationVars: { channelCode },
+                                returnValue: true,
+                            },
+                        ],
+                    }),
+                ),
+                switchMap(response =>
+                    response
+                        ? this.dataService.product.removeVariantsFromChannel({
+                              channelId,
+                              productVariantIds: [variant.id],
+                          })
+                        : EMPTY,
+                ),
+            )
+            .subscribe(
+                () => {
+                    this.notificationService.success(_('catalog.notify-remove-variant-from-channel-success'));
+                },
+                err => {
+                    this.notificationService.error(_('catalog.notify-remove-variant-from-channel-error'));
+                },
+            );
+    }
+
+    assetsChanged(): boolean {
+        return !!Object.values(this.assetChanges).length;
+    }
+
+    /**
+     * If creating a new product, automatically generate the slug based on the product name.
+     */
+    updateSlug(nameValue: string) {
+        combineLatest(this.entity$, this.languageCode$)
+            .pipe(take(1))
+            .subscribe(([entity, languageCode]) => {
+                const slugControl = this.detailForm.get(['product', 'slug']);
+                const currentTranslation = findTranslation(entity, languageCode);
+                const currentSlugIsEmpty = !currentTranslation || !currentTranslation.slug;
+                if (slugControl && slugControl.pristine && currentSlugIsEmpty) {
+                    slugControl.setValue(normalizeString(`${nameValue}`, '-'));
+                }
+            });
+    }
+
+    selectProductFacetValue() {
+        this.displayFacetValueModal().subscribe(facetValueIds => {
+            if (facetValueIds) {
+                const productGroup = this.detailForm;
+                const currentFacetValueIds = productGroup.value.facetValueIds ?? [];
+                productGroup.patchValue({
+                    facetValueIds: unique([...currentFacetValueIds, ...facetValueIds]),
+                });
+                productGroup.markAsDirty();
+            }
+        });
+    }
+
+    updateProductOption(input: UpdateProductOptionInput & { autoUpdate: boolean }) {
+        combineLatest(this.product$, this.languageCode$)
+            .pipe(
+                take(1),
+                mergeMap(([product, languageCode]) =>
+                    this.productDetailService.updateProductOption(input, product, languageCode),
+                ),
+            )
+            .subscribe(
+                () => {
+                    this.notificationService.success(_('common.notify-update-success'), {
+                        entity: 'ProductOption',
+                    });
+                },
+                err => {
+                    this.notificationService.error(_('common.notify-update-error'), {
+                        entity: 'ProductOption',
+                    });
+                },
+            );
+    }
+
+    removeProductFacetValue(facetValueId: string) {
+        const productGroup = this.detailForm;
+        const currentFacetValueIds = productGroup.value.facetValueIds ?? [];
+        productGroup.patchValue({
+            facetValueIds: currentFacetValueIds.filter(id => id !== facetValueId),
+        });
+        productGroup.markAsDirty();
+    }
+
+    private displayFacetValueModal(): Observable<string[] | undefined> {
+        return this.modalService
+            .fromComponent(ApplyFacetDialogComponent, {
+                size: 'md',
+                closable: true,
+            })
+            .pipe(map(facetValues => facetValues && facetValues.map(v => v.id)));
+    }
+
+    create() {
+        const productGroup = this.detailForm;
+        if (!productGroup.dirty) {
+            return;
+        }
+        combineLatest(this.product$, this.languageCode$)
+            .pipe(
+                take(1),
+                mergeMap(([product, languageCode]) => {
+                    const newProduct = this.getUpdatedProduct(
+                        product,
+                        productGroup as UntypedFormGroup,
+                        languageCode,
+                    ) as CreateProductInput;
+                    return this.productDetailService.createProductWithVariants(
+                        newProduct,
+                        this.createVariantsConfig,
+                        languageCode,
+                    );
+                }),
+            )
+            .subscribe(
+                ({ createProductVariants, productId }) => {
+                    this.notificationService.success(_('common.notify-create-success'), {
+                        entity: 'Product',
+                    });
+                    this.assetChanges = {};
+                    this.detailForm.markAsPristine();
+                    this.router.navigate(['../', productId], { relativeTo: this.route });
+                },
+                err => {
+                    // eslint-disable-next-line no-console
+                    console.error(err);
+                    this.notificationService.error(_('common.notify-create-error'), {
+                        entity: 'Product',
+                    });
+                },
+            );
+    }
+
+    save() {
+        combineLatest(this.product$, this.languageCode$)
+            .pipe(
+                take(1),
+                mergeMap(([product, languageCode]) => {
+                    const productGroup = this.detailForm;
+                    let productInput: UpdateProductInput | undefined;
+                    let variantsInput: UpdateProductVariantInput[] | undefined;
+
+                    if (productGroup.dirty || this.assetsChanged()) {
+                        productInput = this.getUpdatedProduct(
+                            product,
+                            productGroup as UntypedFormGroup,
+                            languageCode,
+                        ) as UpdateProductInput;
+                    }
+
+                    return this.productDetailService.updateProduct({
+                        product,
+                        languageCode,
+                        autoUpdate:
+                            this.detailForm.get(['product', 'autoUpdateVariantNames'])?.value ?? false,
+                        productInput,
+                        variantsInput,
+                    });
+                }),
+            )
+            .subscribe(
+                result => {
+                    this.updateSlugAfterSave(result);
+                    this.detailForm.markAsPristine();
+                    this.assetChanges = {};
+                    this.notificationService.success(_('common.notify-update-success'), {
+                        entity: 'Product',
+                    });
+                    this.changeDetector.markForCheck();
+                },
+                err => {
+                    this.notificationService.error(_('common.notify-update-error'), {
+                        entity: 'Product',
+                    });
+                },
+            );
+    }
+
+    canDeactivate(): boolean {
+        return super.canDeactivate() && !this.assetChanges.assets && !this.assetChanges.featuredAsset;
+    }
+
+    /**
+     * Sets the values of the form on changes to the product or current language.
+     */
+    protected setFormValues(
+        product: NonNullable<GetProductWithVariantsQuery['product']>,
+        languageCode: LanguageCode,
+    ) {
+        const currentTranslation = findTranslation(product, languageCode);
+        this.detailForm.patchValue({
+            enabled: product.enabled,
+            name: currentTranslation ? currentTranslation.name : '',
+            slug: currentTranslation ? currentTranslation.slug : '',
+            description: currentTranslation ? currentTranslation.description : '',
+            facetValueIds: product.facetValues.map(fv => fv.id),
+        });
+
+        if (this.customFields.length) {
+            this.setCustomFieldFormValues(
+                this.customFields,
+                this.detailForm.get(['customFields']),
+                product,
+                currentTranslation,
+            );
+        }
+    }
+
+    /**
+     * Given a product and the value of the detailForm, this method creates an updated copy of the product which
+     * can then be persisted to the API.
+     */
+    private getUpdatedProduct(
+        product: NonNullable<GetProductWithVariantsQuery['product']>,
+        productFormGroup: UntypedFormGroup,
+        languageCode: LanguageCode,
+    ): UpdateProductInput | CreateProductInput {
+        const updatedProduct = createUpdatedTranslatable({
+            translatable: product,
+            updatedFields: productFormGroup.value,
+            customFieldConfig: this.customFields,
+            languageCode,
+            defaultTranslation: {
+                languageCode,
+                name: product.name || '',
+                slug: product.slug || '',
+                description: product.description || '',
+            },
+        });
+        return {
+            ...updatedProduct,
+            assetIds: this.assetChanges.assets?.map(a => a.id),
+            featuredAssetId: this.assetChanges.featuredAsset?.id,
+            facetValueIds: productFormGroup.value.facetValueIds,
+        } as UpdateProductInput | CreateProductInput;
+    }
+
+    /**
+     * The server may alter the slug value in order to normalize and ensure uniqueness upon saving.
+     */
+    private updateSlugAfterSave(results: Array<UpdateProductMutation | UpdateProductVariantsMutation>) {
+        const firstResult = results[0];
+        const slugControl = this.detailForm.get(['product', 'slug']);
+
+        function isUpdateMutation(input: any): input is UpdateProductMutation {
+            return input.hasOwnProperty('updateProduct');
+        }
+
+        if (slugControl && isUpdateMutation(firstResult)) {
+            slugControl.setValue(firstResult.updateProduct.slug, { emitEvent: false });
+        }
+    }
+}

+ 30 - 0
packages/admin-ui/src/lib/catalog/src/components/product-detail2/product-detail.types.ts

@@ -0,0 +1,30 @@
+import { Asset, GlobalFlag } from '@vendure/admin-ui/core';
+
+export type TabName = 'details' | 'variants';
+
+export interface VariantFormValue {
+    id: string;
+    enabled: boolean;
+    sku: string;
+    name: string;
+    price: number;
+    priceWithTax: number;
+    taxCategoryId: string;
+    stockOnHand: number;
+    useGlobalOutOfStockThreshold: boolean;
+    outOfStockThreshold: number;
+    trackInventory: GlobalFlag;
+    facetValueIds: string[];
+    customFields?: any;
+}
+
+export interface SelectedAssets {
+    assets?: Asset[];
+    featuredAsset?: Asset;
+}
+
+export interface PaginationConfig {
+    totalItems: number;
+    currentPage: number;
+    itemsPerPage: number;
+}

+ 20 - 5
packages/admin-ui/src/lib/catalog/src/components/product-list/product-list.component.html

@@ -1,9 +1,24 @@
 <vdr-page-block>
-    <vdr-language-selector
-        [availableLanguageCodes]="availableLanguages$ | async"
-        [currentLanguageCode]="contentLanguage$ | async"
-        (languageCodeChange)="setLanguage($event)"
-    ></vdr-language-selector>
+    <vdr-action-bar>
+        <vdr-ab-left>
+            <vdr-language-selector
+                [availableLanguageCodes]="availableLanguages$ | async"
+                [currentLanguageCode]="contentLanguage$ | async"
+                (languageCodeChange)="setLanguage($event)"
+            ></vdr-language-selector>
+        </vdr-ab-left>
+        <vdr-ab-right>
+            <vdr-action-bar-items locationId="product-list"></vdr-action-bar-items>
+            <a
+                class="btn btn-primary"
+                [routerLink]="['./create']"
+                *vdrIfPermissions="['CreateCatalog', 'CreateProduct']"
+            >
+                <clr-icon shape="plus"></clr-icon>
+                {{ 'catalog.create-new-product' | translate }}
+            </a>
+        </vdr-ab-right>
+    </vdr-action-bar>
 </vdr-page-block>
 <vdr-data-table-2
     class="mt-2"

+ 0 - 10
packages/admin-ui/src/lib/catalog/src/components/product-list/product-list.component.ts

@@ -13,7 +13,6 @@ import {
     JobState,
     LanguageCode,
     ModalService,
-    NavBuilderService,
     NotificationService,
     ProductFilterParameter,
     ProductSortParameter,
@@ -112,20 +111,11 @@ export class ProductListComponent
         private notificationService: NotificationService,
         private jobQueueService: JobQueueService,
         private dataTableService: DataTableService,
-        private navBuilderService: NavBuilderService,
         private serverConfigService: ServerConfigService,
         router: Router,
         route: ActivatedRoute,
     ) {
         super(router, route);
-        navBuilderService.addActionBarItem({
-            id: 'create-product',
-            label: _('catalog.create-new-product'),
-            locationId: 'product-list',
-            icon: 'plus',
-            routerLink: ['./create'],
-            requiresPermission: ['CreateCatalog', 'CreateProduct'],
-        });
         super.setQueryFn(
             (args: any) => this.dataService.product.getProducts(args).refetchOnChannelChange(),
             data => data.products,

+ 2 - 2
packages/admin-ui/src/lib/catalog/src/components/product-variant-list/product-variant-list.component.html

@@ -1,10 +1,10 @@
-<div class="flex wrap ml-4">
+<vdr-page-block *ngIf="!hideLanguageSelect">
     <vdr-language-selector
         [availableLanguageCodes]="availableLanguages$ | async"
         [currentLanguageCode]="contentLanguage$ | async"
         (languageCodeChange)="setLanguage($event)"
     ></vdr-language-selector>
-</div>
+</vdr-page-block>
 <vdr-data-table-2
     class="mt-2"
     id="product-variant-list"

+ 4 - 1
packages/admin-ui/src/lib/catalog/src/components/product-variant-list/product-variant-list.component.ts

@@ -1,4 +1,4 @@
-import { Component, OnInit } from '@angular/core';
+import { Component, Input, OnInit } from '@angular/core';
 import { ActivatedRoute, Router } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import {
@@ -35,6 +35,8 @@ export class ProductVariantListComponent
     >
     implements OnInit
 {
+    @Input() productId?: string;
+    @Input() hideLanguageSelect = false;
     availableLanguages$: Observable<LanguageCode[]>;
     contentLanguage$: Observable<LanguageCode>;
     readonly filters = this.dataTableService
@@ -109,6 +111,7 @@ export class ProductVariantListComponent
                             contains: this.searchTermControl.value,
                         },
                         ...this.filters.createFilterInput(),
+                        ...(this.productId ? { productId: { eq: this.productId } } : {}),
                     },
                     sort: this.sorts.createSortInput(),
                 },

+ 14 - 24
packages/admin-ui/src/lib/core/src/components/ui-language-switcher-dialog/ui-language-switcher-dialog.component.html

@@ -1,19 +1,12 @@
 <ng-template vdrDialogTitle>{{ 'common.select-display-language' | translate }}</ng-template>
 <div class="clr-row">
     <div class="clr-col-md-6">
-        <clr-select-container>
-            <label>{{ 'common.language' | translate }}</label>
-            <select
-                clrSelect
-                name="options"
-                [(ngModel)]="currentLanguage"
-                (ngModelChange)="updatePreviewLocale()"
-            >
-                <option *ngFor="let code of availableLanguages | sort" [value]="code">
-                    {{ code | uppercase }} ({{ code | localeLanguageName }})
-                </option>
-            </select>
-        </clr-select-container>
+        <label for="options">{{ 'common.language' | translate }}</label>
+        <select name="options" [(ngModel)]="currentLanguage" (ngModelChange)="updatePreviewLocale()">
+            <option *ngFor="let code of availableLanguages | sort" [value]="code">
+                {{ code | uppercase }} ({{ code | localeLanguageName }})
+            </option>
+        </select>
     </div>
     <div class="clr-col-md-6">
         <clr-datalist-container>
@@ -36,28 +29,29 @@
 </div>
 <div class="card">
     <div class="card-header">
-        <span class="p-2">{{ 'common.sample-formatting' | translate }}:</span><strong>{{ previewLocale | localeLanguageName:previewLocale }}</strong>
+        <span class="p-2">{{ 'common.sample-formatting' | translate }}:</span
+        ><strong>{{ previewLocale | localeLanguageName : previewLocale }}</strong>
     </div>
     <div class="card-block">
         <div class="clr-row">
             <div class="clr-col-sm-4">
                 <vdr-labeled-data [label]="'common.medium-date' | translate">
-                    {{ now | localeDate: 'medium':previewLocale }}
+                    {{ now | localeDate : 'medium' : previewLocale }}
                 </vdr-labeled-data>
                 <vdr-labeled-data [label]="'common.short-date' | translate">
-                    {{ now | localeDate: 'short':previewLocale }}
+                    {{ now | localeDate : 'short' : previewLocale }}
                 </vdr-labeled-data>
             </div>
             <div class="clr-col-sm-4">
-                <select clrSelect name="currency" class="currency" [(ngModel)]="selectedCurrencyCode">
+                <select name="currency" class="currency" [(ngModel)]="selectedCurrencyCode">
                     <option *ngFor="let code of availableCurrencyCodes | sort" [value]="code">
-                        {{ code | uppercase }} ({{ code | localeCurrencyName: 'full':previewLocale }})
+                        {{ code | uppercase }} ({{ code | localeCurrencyName : 'full' : previewLocale }})
                     </option>
                 </select>
             </div>
             <div class="clr-col-sm-4">
                 <vdr-labeled-data [label]="'common.price' | translate">
-                    {{ 12345 | localeCurrency: selectedCurrencyCode:previewLocale }}
+                    {{ 12345 | localeCurrency : selectedCurrencyCode : previewLocale }}
                 </vdr-labeled-data>
             </div>
         </div>
@@ -65,11 +59,7 @@
 </div>
 <ng-template vdrDialogButtons>
     <button type="button" class="btn" (click)="cancel()">{{ 'common.cancel' | translate }}</button>
-    <button
-        type="submit"
-        (click)="setLanguage()"
-        class="btn btn-primary"
-    >
+    <button type="submit" (click)="setLanguage()" class="btn btn-primary">
         {{ 'common.set-language' | translate }}
     </button>
 </ng-template>

+ 14 - 5
packages/admin-ui/src/lib/core/src/providers/page/page.service.ts

@@ -1,6 +1,8 @@
 import { Injectable, Type } from '@angular/core';
 import { Route } from '@angular/router';
+import { BaseDetailComponent } from '../../common/base-detail.component';
 import { PageLocationId } from '../../common/component-registry-types';
+import { CanDeactivateDetailGuard } from '../../shared/providers/routing/can-deactivate-detail-guard';
 
 export interface PageTabConfig {
     location: PageLocationId;
@@ -30,11 +32,18 @@ export class PageService {
 
     getPageTabRoutes(location: PageLocationId): Route[] {
         const configs = this.registry.get(location) || [];
-        return configs.map(config => ({
-            path: config.route || '',
-            pathMatch: config.route ? 'prefix' : 'full',
-            component: config.component,
-        }));
+        return configs.map(config => {
+            const guards =
+                typeof config.component.prototype.canDeactivate === 'function'
+                    ? [CanDeactivateDetailGuard]
+                    : [];
+            return {
+                path: config.route || '',
+                pathMatch: config.route ? 'prefix' : 'full',
+                component: config.component,
+                canDeactivate: guards,
+            };
+        });
     }
 
     getPageTabs(location: PageLocationId): PageTabConfig[] {

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/components/card/card.component.html

@@ -1,4 +1,4 @@
-<div class="card-container">
+<div class="card-container" [class.padding-x]="paddingX">
     <div *ngIf="title" class="title">{{ title }}</div>
     <ng-content></ng-content>
 </div>

+ 10 - 1
packages/admin-ui/src/lib/core/src/shared/components/card/card.component.scss

@@ -4,13 +4,22 @@
 .card-container {
     border: 1px solid var(--color-card-border);
     border-radius: var(--border-radius);
-    padding: calc(var(--space-unit) * 2);
+    padding: calc(var(--space-unit) * 2) 0;
     box-shadow: 0px 2px 4px 0px rgba(0,0,0,0.1);
+
+    &.padding-x {
+        padding-left: calc(var(--space-unit) * 2);
+        padding-right: calc(var(--space-unit) * 2);
+    }
 }
 
 .title {
     font-size: var(--font-size-base);
     margin-bottom: calc(var(--space-unit) * 2);
+    padding: 0 calc(var(--space-unit) * 2);
+}
+.padding-x .title {
+    padding: 0;
 }
 
 ::ng-deep vdr-card + vdr-card {

+ 1 - 0
packages/admin-ui/src/lib/core/src/shared/components/card/card.component.ts

@@ -8,4 +8,5 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
 })
 export class CardComponent {
     @Input() title: string;
+    @Input() paddingX = true;
 }

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/components/data-table-2/data-table2.component.scss

@@ -201,7 +201,7 @@ vdr-empty-placeholder {
     justify-content: space-between;
     margin-top: var(--space-unit);
     margin-left: var(--surface-margin-left);
-    margin-right: 3px;
+    margin-right: var(--space-unit);
 }
 .total-items-count {
     font-size: var(--font-size-xs);

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/select-form-input/select-form-input.component.html

@@ -1,4 +1,4 @@
-<select clrSelect [formControl]="formControl" [vdrDisabled]="readonly">
+<select [formControl]="formControl" [vdrDisabled]="readonly">
     <option *ngIf="config.nullable" [ngValue]="null"></option>
     <option *ngFor="let option of options;trackBy:trackByFn" [ngValue]="option.value">
         {{ (option | customFieldLabel:(uiLanguage$ | async)) || option.label || option.value }}

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/textarea-form-input/textarea-form-input.component.scss

@@ -1,7 +1,7 @@
 :host {
     textarea {
         resize: both;
-        height: 6rem;
+        //height: 6rem;
         width: 100%;
     }
 }

+ 19 - 11
packages/admin-ui/src/lib/core/src/shared/shared.module.ts

@@ -70,6 +70,7 @@ import { HistoryEntryDetailComponent } from './components/history-entry-detail/h
 import { ItemsPerPageControlsComponent } from './components/items-per-page-controls/items-per-page-controls.component';
 import { LabeledDataComponent } from './components/labeled-data/labeled-data.component';
 import { LanguageSelectorComponent } from './components/language-selector/language-selector.component';
+import { LocalizedTextComponent } from './components/localized-text/localized-text.component';
 import { ManageTagsDialogComponent } from './components/manage-tags-dialog/manage-tags-dialog.component';
 import { DialogButtonsDirective } from './components/modal-dialog/dialog-buttons.directive';
 import { DialogComponentOutletComponent } from './components/modal-dialog/dialog-component-outlet.component';
@@ -77,11 +78,16 @@ import { DialogTitleDirective } from './components/modal-dialog/dialog-title.dir
 import { ModalDialogComponent } from './components/modal-dialog/modal-dialog.component';
 import { ObjectTreeComponent } from './components/object-tree/object-tree.component';
 import { OrderStateLabelComponent } from './components/order-state-label/order-state-label.component';
+import { PageBlockComponent } from './components/page-block/page-block.component';
 import { PageBodyComponent } from './components/page-body/page-body.component';
+import { PageDetailLayoutComponent } from './components/page-detail-layout/page-detail-layout.component';
+import { PageDetailSidebarComponent } from './components/page-detail-layout/page-detail-sidebar.component';
+import { PageEntityInfoComponent } from './components/page-entity-info/page-entity-info.component';
 import { PageHeaderDescriptionComponent } from './components/page-header-description/page-header-description.component';
 import { PageHeaderTabsComponent } from './components/page-header-tabs/page-header-tabs.component';
 import { PageHeaderComponent } from './components/page-header/page-header.component';
 import { PageTitleComponent } from './components/page-title/page-title.component';
+import { PageComponent } from './components/page/page.component';
 import { PaginationControlsComponent } from './components/pagination-controls/pagination-controls.component';
 import { ProductMultiSelectorDialogComponent } from './components/product-multi-selector-dialog/product-multi-selector-dialog.component';
 import { ProductSearchInputComponent } from './components/product-search-input/product-search-input.component';
@@ -95,11 +101,11 @@ import { RawHtmlDialogComponent } from './components/rich-text-editor/raw-html-d
 import { RichTextEditorComponent } from './components/rich-text-editor/rich-text-editor.component';
 import { SelectToggleComponent } from './components/select-toggle/select-toggle.component';
 import { SimpleDialogComponent } from './components/simple-dialog/simple-dialog.component';
+import { SplitViewComponent } from './components/split-view/split-view.component';
 import {
     SplitViewLeftDirective,
     SplitViewRightDirective,
 } from './components/split-view/split-view.directive';
-import { SplitViewComponent } from './components/split-view/split-view.component';
 import { StatusBadgeComponent } from './components/status-badge/status-badge.component';
 import { TabbedCustomFieldsComponent } from './components/tabbed-custom-fields/tabbed-custom-fields.component';
 import { TableRowActionComponent } from './components/table-row-action/table-row-action.component';
@@ -158,9 +164,7 @@ import { StateI18nTokenPipe } from './pipes/state-i18n-token.pipe';
 import { StringToColorPipe } from './pipes/string-to-color.pipe';
 import { TimeAgoPipe } from './pipes/time-ago.pipe';
 import { CanDeactivateDetailGuard } from './providers/routing/can-deactivate-detail-guard';
-import { PageComponent } from './components/page/page.component';
-import { LocalizedTextComponent } from './components/localized-text/localized-text.component';
-import { PageBlockComponent } from './components/page-block/page-block.component';
+import { CardComponent } from './components/card/card.component';
 
 const IMPORTS = [
     ClarityModule,
@@ -286,6 +290,17 @@ const DECLARATIONS = [
     SplitViewRightDirective,
     PageComponent,
     CustomFilterComponentDirective,
+    PageHeaderComponent,
+    PageTitleComponent,
+    PageHeaderDescriptionComponent,
+    PageHeaderTabsComponent,
+    PageBodyComponent,
+    PageBlockComponent,
+    PageEntityInfoComponent,
+    LocalizedTextComponent,
+    PageDetailLayoutComponent,
+    PageDetailSidebarComponent,
+    CardComponent,
 ];
 
 const DYNAMIC_FORM_INPUTS = [
@@ -313,13 +328,6 @@ const DYNAMIC_FORM_INPUTS = [
     HtmlEditorFormInputComponent,
     ProductMultiSelectorFormInputComponent,
     CombinationModeFormInputComponent,
-    PageHeaderComponent,
-    PageTitleComponent,
-    PageHeaderDescriptionComponent,
-    PageHeaderTabsComponent,
-    PageBodyComponent,
-    PageBlockComponent,
-    LocalizedTextComponent,
 ];
 
 @NgModule({

+ 1 - 1
packages/admin-ui/src/lib/marketing/src/components/promotion-list/promotion-list.component.html

@@ -81,5 +81,5 @@
             {{ promotion.perCustomerUsageLimit }}
         </ng-template>
     </vdr-dt2-column>
-    <vdr-dt2-custom-field-column *ngFor="let customField of customFields" [customField]="customField" />
+    <vdr-dt2-custom-field-column *ngFor="let customField of customFields" [customField]="customField" [sorts]="sorts" />
 </vdr-data-table-2>

+ 1 - 1
packages/admin-ui/src/lib/order/src/components/order-list/order-list.component.html

@@ -69,5 +69,5 @@
             {{ getShippingNames(order) }}
         </ng-template>
     </vdr-dt2-column>
-    <vdr-dt2-custom-field-column *ngFor="let customField of customFields" [customField]="customField" />
+    <vdr-dt2-custom-field-column *ngFor="let customField of customFields" [customField]="customField" [sorts]="sorts" />
 </vdr-data-table-2>

+ 1 - 1
packages/admin-ui/src/lib/settings/src/components/administrator-list/administrator-list.component.html

@@ -54,5 +54,5 @@
             {{ administrator.emailAddress }}
         </ng-template>
     </vdr-dt2-column>
-    <vdr-dt2-custom-field-column *ngFor="let customField of customFields" [customField]="customField" />
+    <vdr-dt2-custom-field-column *ngFor="let customField of customFields" [customField]="customField" [sorts]="sorts" />
 </vdr-data-table-2>

+ 1 - 1
packages/admin-ui/src/lib/settings/src/components/channel-list/channel-list.component.html

@@ -54,5 +54,5 @@
             {{ channel.token }}
         </ng-template>
     </vdr-dt2-column>
-    <vdr-dt2-custom-field-column *ngFor="let customField of customFields" [customField]="customField" />
+    <vdr-dt2-custom-field-column *ngFor="let customField of customFields" [customField]="customField" [sorts]="sorts" />
 </vdr-data-table-2>

+ 1 - 1
packages/admin-ui/src/lib/settings/src/components/country-list/country-list.component.html

@@ -66,5 +66,5 @@
             }}</vdr-chip>
         </ng-template>
     </vdr-dt2-column>
-    <vdr-dt2-custom-field-column *ngFor="let customField of customFields" [customField]="customField" />
+    <vdr-dt2-custom-field-column *ngFor="let customField of customFields" [customField]="customField" [sorts]="sorts" />
 </vdr-data-table-2>

+ 1 - 1
packages/admin-ui/src/lib/settings/src/components/payment-method-list/payment-method-list.component.html

@@ -66,5 +66,5 @@
             }}</vdr-chip>
         </ng-template>
     </vdr-dt2-column>
-    <vdr-dt2-custom-field-column *ngFor="let customField of customFields" [customField]="customField" />
+    <vdr-dt2-custom-field-column *ngFor="let customField of customFields" [customField]="customField" [sorts]="sorts" />
 </vdr-data-table-2>

+ 1 - 1
packages/admin-ui/src/lib/settings/src/components/seller-list/seller-list.component.html

@@ -47,5 +47,5 @@
             </a>
         </ng-template>
     </vdr-dt2-column>
-    <vdr-dt2-custom-field-column *ngFor="let customField of customFields" [customField]="customField" />
+    <vdr-dt2-custom-field-column *ngFor="let customField of customFields" [customField]="customField" [sorts]="sorts" />
 </vdr-data-table-2>

+ 1 - 1
packages/admin-ui/src/lib/settings/src/components/shipping-method-list/shipping-method-list.component.html

@@ -70,5 +70,5 @@
             {{ shippingMethod.description }}
         </ng-template>
     </vdr-dt2-column>
-    <vdr-dt2-custom-field-column *ngFor="let customField of customFields" [customField]="customField" />
+    <vdr-dt2-custom-field-column *ngFor="let customField of customFields" [customField]="customField" [sorts]="sorts" />
 </vdr-data-table-2>

+ 1 - 1
packages/admin-ui/src/lib/settings/src/components/tax-category-list/tax-category-list.component.html

@@ -54,5 +54,5 @@
             <vdr-chip *ngIf="taxCategory.isDefault">{{ 'common.default-tax-category' | translate }}</vdr-chip>
         </ng-template>
     </vdr-dt2-column>
-    <vdr-dt2-custom-field-column *ngFor="let customField of customFields" [customField]="customField" />
+    <vdr-dt2-custom-field-column *ngFor="let customField of customFields" [customField]="customField" [sorts]="sorts" />
 </vdr-data-table-2>

+ 1 - 1
packages/admin-ui/src/lib/settings/src/components/tax-rate-list/tax-rate-list.component.html

@@ -72,5 +72,5 @@
             }}</vdr-chip>
         </ng-template>
     </vdr-dt2-column>
-    <vdr-dt2-custom-field-column *ngFor="let customField of customFields" [customField]="customField" />
+    <vdr-dt2-custom-field-column *ngFor="let customField of customFields" [customField]="customField" [sorts]="sorts" />
 </vdr-data-table-2>

+ 1 - 0
packages/admin-ui/src/lib/settings/src/components/zone-list/zone-list.component.html

@@ -78,6 +78,7 @@
             <vdr-dt2-custom-field-column
                 *ngFor="let customField of customFields"
                 [customField]="customField"
+                [sort]="sorts"
             />
         </vdr-data-table-2>
     </ng-template>

+ 8 - 1
packages/admin-ui/src/lib/static/i18n-messages/cs.json

@@ -58,6 +58,7 @@
     "add-facets": "Přidat atribut",
     "add-option": "Přidat možnost",
     "asset-preview-links": "",
+    "assets": "",
     "assign-collections-to-channel-success": "",
     "assign-facets-to-channel-success": "",
     "assign-product-to-channel-success": "Produkt byl úspěšně přiřazen do \"{ channel }\"",
@@ -69,7 +70,9 @@
     "auto-update-option-variant-name": "Automaticky aktualizovat jména variant pomocí této",
     "auto-update-product-variant-name": "Automaticky aktualizovat jména variant",
     "channel-price-preview": "Náhled ceny v kanálu",
+    "collection": "",
     "collection-contents": "Obsah kolekce",
+    "collections": "",
     "confirm-bulk-delete": "",
     "confirm-bulk-delete-collections": "",
     "confirm-bulk-delete-products": "",
@@ -192,6 +195,7 @@
     "add-new-variants": "Přidat {count, plural, one {variantu} few {{count} varianty} other {{count} variant}}",
     "add-note": "Přidat poznámku",
     "apply": "",
+    "assets": "",
     "available-languages": "Dostupné jazyky",
     "boolean-and": "",
     "boolean-false": "",
@@ -211,6 +215,7 @@
     "confirm": "Potvrdit",
     "confirm-delete-note": "Smazat poznámku?",
     "confirm-navigation": "Potvrdit navigaci",
+    "contents": "",
     "create": "Vytvořit",
     "created-at": "Vytvořeno",
     "custom-fields": "Extra pole",
@@ -236,7 +241,6 @@
     "guest": "Host",
     "id": "",
     "image": "",
-    "info": "",
     "items-per-page-option": "{ count } na stránku",
     "items-selected-count": "",
     "keep-editing": "",
@@ -644,6 +648,9 @@
     "unfulfilled": "Nevyřízeno",
     "unit-price": "Cena za kus"
   },
+  "orders": {
+    "orders": ""
+  },
   "settings": {
     "add-countries-to-zone": "Přidat země do { zoneName }",
     "add-countries-to-zone-success": "Přidáno: { countryCount } {countryCount, plural, one {země} other {země}} do zóny \"{ zoneName }\"",

+ 8 - 1
packages/admin-ui/src/lib/static/i18n-messages/de.json

@@ -58,6 +58,7 @@
     "add-facets": "Facetten hinzufügen",
     "add-option": "Option hinzufügen",
     "asset-preview-links": "",
+    "assets": "",
     "assign-collections-to-channel-success": "",
     "assign-facets-to-channel-success": "",
     "assign-product-to-channel-success": "Produkt erfolgreich an \"{ channel }\" zugewiesen",
@@ -69,7 +70,9 @@
     "auto-update-option-variant-name": "Automatisch Namen der Optionsvariante aktualisieren",
     "auto-update-product-variant-name": "Automatisch Namen der Produktvariante aktualisieren",
     "channel-price-preview": "Kanal-Preisvorschau",
+    "collection": "",
     "collection-contents": "Inhalt der Sammlung",
+    "collections": "",
     "confirm-bulk-delete": "",
     "confirm-bulk-delete-collections": "",
     "confirm-bulk-delete-products": "",
@@ -192,6 +195,7 @@
     "add-new-variants": "{count, plural, one {1 Variante} other {{count} Varianten}} hinzufügen",
     "add-note": "Notiz hinzufügen",
     "apply": "",
+    "assets": "",
     "available-languages": "Verfügbare Sprachen",
     "boolean-and": "",
     "boolean-false": "",
@@ -211,6 +215,7 @@
     "confirm": "Bestätigen",
     "confirm-delete-note": "Notiz löschen?",
     "confirm-navigation": "Navigation bestätigen",
+    "contents": "",
     "create": "Erstellen",
     "created-at": "Erstellt am",
     "custom-fields": "Benutzerdefinierte Felder",
@@ -236,7 +241,6 @@
     "guest": "Gast",
     "id": "",
     "image": "",
-    "info": "",
     "items-per-page-option": "{ count } pro Seite",
     "items-selected-count": "",
     "keep-editing": "",
@@ -644,6 +648,9 @@
     "unfulfilled": "Nicht ausgeführt",
     "unit-price": "Einzelpreis"
   },
+  "orders": {
+    "orders": ""
+  },
   "settings": {
     "add-countries-to-zone": "Länder hinzufügen zu { zoneName }",
     "add-countries-to-zone-success": "{ countryCount } {countryCount, plural, one {Land} other {Länder}} hinzugefügt zu \"{ zoneName }\"",

+ 7 - 0
packages/admin-ui/src/lib/static/i18n-messages/en.json

@@ -58,6 +58,7 @@
     "add-facets": "Add facets",
     "add-option": "Add option",
     "asset-preview-links": "Asset preview links",
+    "assets": "Assets",
     "assign-collections-to-channel-success": "Successfully assigned {count, plural, one {1 collection} other {{count} collections}} to { channelCode }",
     "assign-facets-to-channel-success": "Successfully assigned {count, plural, one {1 facet} other {{count} facets}} to { channelCode }",
     "assign-product-to-channel-success": "Successfully assigned {count, plural, one {1 product} other {{count} products}} to { channel }",
@@ -69,7 +70,9 @@
     "auto-update-option-variant-name": "Automatically update the names of ProductVariants using this option",
     "auto-update-product-variant-name": "Automatically update the names of ProductVariants",
     "channel-price-preview": "Channel price preview",
+    "collection": "Collection",
     "collection-contents": "Collection contents",
+    "collections": "Collections",
     "confirm-bulk-delete": "Delete multiple items?",
     "confirm-bulk-delete-collections": "Delete {count} collections?",
     "confirm-bulk-delete-products": "Delete {count} products?",
@@ -211,6 +214,7 @@
     "confirm": "Confirm",
     "confirm-delete-note": "Delete note?",
     "confirm-navigation": "Confirm navigation",
+    "contents": "Contents",
     "create": "Create",
     "created-at": "Created at",
     "custom-fields": "Custom fields",
@@ -643,6 +647,9 @@
     "unfulfilled": "Unfulfilled",
     "unit-price": "Unit price"
   },
+  "orders": {
+    "orders": ""
+  },
   "settings": {
     "add-countries-to-zone": "Add countries to { zoneName }",
     "add-countries-to-zone-success": "Added { countryCount } {countryCount, plural, one {country} other {countries}} to zone \"{ zoneName }\"",

+ 8 - 1
packages/admin-ui/src/lib/static/i18n-messages/es.json

@@ -58,6 +58,7 @@
     "add-facets": "Añadir facetas",
     "add-option": "Añadir opción",
     "asset-preview-links": "",
+    "assets": "",
     "assign-collections-to-channel-success": "",
     "assign-facets-to-channel-success": "",
     "assign-product-to-channel-success": "Producto asignado a \"{ channel }\" con éxito",
@@ -69,7 +70,9 @@
     "auto-update-option-variant-name": "Actualiza los nombres de las variantes de producto automáticamente usando esta opción",
     "auto-update-product-variant-name": "Actualiza los nombres de las variantes de producto automáticamente",
     "channel-price-preview": "Vista previa de precio para el canal de ventas",
+    "collection": "",
     "collection-contents": "Contenidos de la colección",
+    "collections": "",
     "confirm-bulk-delete": "",
     "confirm-bulk-delete-collections": "",
     "confirm-bulk-delete-products": "",
@@ -192,6 +195,7 @@
     "add-new-variants": "Añadir {count, plural, one {1 variante} other {{count} variantes}}",
     "add-note": "Añadir nota",
     "apply": "",
+    "assets": "",
     "available-languages": "Idiomas disponibles",
     "boolean-and": "",
     "boolean-false": "",
@@ -211,6 +215,7 @@
     "confirm": "Confirmar",
     "confirm-delete-note": "¿Eliminar nota?",
     "confirm-navigation": "Confirmar navegación",
+    "contents": "",
     "create": "Crear",
     "created-at": "Creado el",
     "custom-fields": "Campos personalizados",
@@ -236,7 +241,6 @@
     "guest": "Invitado",
     "id": "",
     "image": "",
-    "info": "",
     "items-per-page-option": "{ count } por página",
     "items-selected-count": "",
     "keep-editing": "",
@@ -644,6 +648,9 @@
     "unfulfilled": "Fulfillment no completado",
     "unit-price": "Precio unitario"
   },
+  "orders": {
+    "orders": ""
+  },
   "settings": {
     "add-countries-to-zone": "Añadir países a zona...",
     "add-countries-to-zone-success": "Añadido { countryCount } {countryCount, plural, one {país} other {países}} a la zona \"{ zoneName }\"",

+ 8 - 1
packages/admin-ui/src/lib/static/i18n-messages/fr.json

@@ -58,6 +58,7 @@
     "add-facets": "Ajout composant",
     "add-option": "Ajout option",
     "asset-preview-links": "",
+    "assets": "",
     "assign-collections-to-channel-success": "",
     "assign-facets-to-channel-success": "",
     "assign-product-to-channel-success": "produit attribué au canal \"{ channel }\"",
@@ -69,7 +70,9 @@
     "auto-update-option-variant-name": "Mettre à jour automatiquement les noms de variations du produit en utilisant cette option",
     "auto-update-product-variant-name": "Mettre à jour automatiquement les noms de variations du produit ",
     "channel-price-preview": "Prévisualisation du prix du canal",
+    "collection": "",
     "collection-contents": "Contenu de la Collection",
+    "collections": "",
     "confirm-bulk-delete": "",
     "confirm-bulk-delete-collections": "",
     "confirm-bulk-delete-products": "",
@@ -192,6 +195,7 @@
     "add-new-variants": "Ajout {count, plural, one {d'une variation} other {de {count} variations}}",
     "add-note": "Ajouter une note",
     "apply": "",
+    "assets": "",
     "available-languages": "Langues disponibles",
     "boolean-and": "",
     "boolean-false": "",
@@ -211,6 +215,7 @@
     "confirm": "Confirmer",
     "confirm-delete-note": "Supprimer la note ?",
     "confirm-navigation": "Confirmer la navigation",
+    "contents": "",
     "create": "Creer",
     "created-at": "Créé à",
     "custom-fields": "Champs personnalisés",
@@ -236,7 +241,6 @@
     "guest": "Invité",
     "id": "",
     "image": "",
-    "info": "",
     "items-per-page-option": "{ count } par page",
     "items-selected-count": "",
     "keep-editing": "",
@@ -644,6 +648,9 @@
     "unfulfilled": "Non préparé",
     "unit-price": "Prix à l'unité"
   },
+  "orders": {
+    "orders": ""
+  },
   "settings": {
     "add-countries-to-zone": "Ajouter des pays à { zoneName }",
     "add-countries-to-zone-success": "{ countryCount } {countryCount, plural, one {pays ajouté} other {pays ajoutés}} à la zone \"{ zoneName }\"",

+ 8 - 1
packages/admin-ui/src/lib/static/i18n-messages/it.json

@@ -58,6 +58,7 @@
     "add-facets": "Aggiungi attributi",
     "add-option": "Aggiungi opzione",
     "asset-preview-links": "",
+    "assets": "",
     "assign-collections-to-channel-success": "",
     "assign-facets-to-channel-success": "",
     "assign-product-to-channel-success": "Prodotto assegnato correttamente a \"{ channel }\"",
@@ -69,7 +70,9 @@
     "auto-update-option-variant-name": "Aggiorna automaticamente i nomi delle Varianti utilizzando questa opzione",
     "auto-update-product-variant-name": "Aggiorna automaticamente i nomi delle Varianti",
     "channel-price-preview": "Anteprima prezzo canale",
+    "collection": "",
     "collection-contents": "Contenuti della Collezione",
+    "collections": "",
     "confirm-bulk-delete": "",
     "confirm-bulk-delete-collections": "",
     "confirm-bulk-delete-products": "",
@@ -192,6 +195,7 @@
     "add-new-variants": "Aggiungi {count, plural, one {1 variante} other {{count} varianti}}",
     "add-note": "Aggiungi nota",
     "apply": "",
+    "assets": "",
     "available-languages": "Lingue disponibili",
     "boolean-and": "",
     "boolean-false": "",
@@ -211,6 +215,7 @@
     "confirm": "Conferma",
     "confirm-delete-note": "Cancellare la nota?",
     "confirm-navigation": "Prosegui con la navigazione",
+    "contents": "",
     "create": "Crea",
     "created-at": "Creato il",
     "custom-fields": "Campi personalizzati",
@@ -236,7 +241,6 @@
     "guest": "Ospite",
     "id": "",
     "image": "",
-    "info": "",
     "items-per-page-option": "{ count } per pagina",
     "items-selected-count": "",
     "keep-editing": "",
@@ -644,6 +648,9 @@
     "unfulfilled": "Non consegnato",
     "unit-price": "Prezzo unitario"
   },
+  "orders": {
+    "orders": ""
+  },
   "settings": {
     "add-countries-to-zone": "Aggiungi nazioni a { zoneName }",
     "add-countries-to-zone-success": "Ho aggiunto { countryCount } {countryCount, plural, one {nazione} other {nazioni}} alla zona \"{ zoneName }\"",

+ 8 - 1
packages/admin-ui/src/lib/static/i18n-messages/pl.json

@@ -58,6 +58,7 @@
     "add-facets": "Dodaj faset",
     "add-option": "Dodaj opcje",
     "asset-preview-links": "",
+    "assets": "",
     "assign-collections-to-channel-success": "",
     "assign-facets-to-channel-success": "",
     "assign-product-to-channel-success": "Pomyślnie przypisano produkt do \"{ channel }\"",
@@ -69,7 +70,9 @@
     "auto-update-option-variant-name": "",
     "auto-update-product-variant-name": "",
     "channel-price-preview": "Podgląd cen kanału",
+    "collection": "",
     "collection-contents": "Zawartość kolekcji",
+    "collections": "",
     "confirm-bulk-delete": "",
     "confirm-bulk-delete-collections": "",
     "confirm-bulk-delete-products": "",
@@ -192,6 +195,7 @@
     "add-new-variants": "Dodaj {count, plural, one {1 wariant} other {{count} wariantów}}",
     "add-note": "",
     "apply": "",
+    "assets": "",
     "available-languages": "Dostępne języki",
     "boolean-and": "",
     "boolean-false": "",
@@ -211,6 +215,7 @@
     "confirm": "",
     "confirm-delete-note": "",
     "confirm-navigation": "Potwierdź nawigacje",
+    "contents": "",
     "create": "Utwórz",
     "created-at": "Utworzone dnia",
     "custom-fields": "Dodatkowe pola",
@@ -236,7 +241,6 @@
     "guest": "Gość",
     "id": "",
     "image": "",
-    "info": "",
     "items-per-page-option": "{ count } na stronę",
     "items-selected-count": "",
     "keep-editing": "",
@@ -644,6 +648,9 @@
     "unfulfilled": "Unfulfilled",
     "unit-price": "Cena jednostkowa"
   },
+  "orders": {
+    "orders": ""
+  },
   "settings": {
     "add-countries-to-zone": "Dodaj kraje do strefy...",
     "add-countries-to-zone-success": "Dodano { countryCount } {countryCount, plural, one {kraj} other {kraje}} do strefy \"{ zoneName }\"",

+ 8 - 1
packages/admin-ui/src/lib/static/i18n-messages/pt_BR.json

@@ -58,6 +58,7 @@
     "add-facets": "Adiciona etiqueta",
     "add-option": "Adiciona opção",
     "asset-preview-links": "",
+    "assets": "",
     "assign-collections-to-channel-success": "",
     "assign-facets-to-channel-success": "",
     "assign-product-to-channel-success": "Produto atribuído com sucesso a \"{ channel }\"",
@@ -69,7 +70,9 @@
     "auto-update-option-variant-name": "Atualizar automaticamente os nomes das variações do produto usando esta opção",
     "auto-update-product-variant-name": "Atualizar automaticamente os nomes das variações do produto",
     "channel-price-preview": "Visualizar preço do canal",
+    "collection": "",
     "collection-contents": "Conteúdo da categoria",
+    "collections": "",
     "confirm-bulk-delete": "",
     "confirm-bulk-delete-collections": "",
     "confirm-bulk-delete-products": "",
@@ -192,6 +195,7 @@
     "add-new-variants": "Adicionar {count, plural, one {1 variant} other {{count} variants}}",
     "add-note": "Adicionar nota",
     "apply": "",
+    "assets": "",
     "available-languages": "Idiomas disponíveis",
     "boolean-and": "",
     "boolean-false": "",
@@ -211,6 +215,7 @@
     "confirm": "Confirme",
     "confirm-delete-note": "Excluir nota?",
     "confirm-navigation": "Confrme navegação",
+    "contents": "",
     "create": "Criar",
     "created-at": "Criado em",
     "custom-fields": "Campos customizados",
@@ -236,7 +241,6 @@
     "guest": "Convidado",
     "id": "",
     "image": "",
-    "info": "",
     "items-per-page-option": "{ count } por página",
     "items-selected-count": "",
     "keep-editing": "",
@@ -644,6 +648,9 @@
     "unfulfilled": "Não realizado",
     "unit-price": "Preço unitário"
   },
+  "orders": {
+    "orders": ""
+  },
   "settings": {
     "add-countries-to-zone": "Adicionar paises para { zoneName }",
     "add-countries-to-zone-success": "Adicionado { countryCount } {countryCount, plural, one {country} other {countries}} para zona \"{ zoneName }\"",

+ 8 - 1
packages/admin-ui/src/lib/static/i18n-messages/pt_PT.json

@@ -58,6 +58,7 @@
     "add-facets": "Adicionar etiqueta",
     "add-option": "Adicionar opção",
     "asset-preview-links": "",
+    "assets": "",
     "assign-collections-to-channel-success": "",
     "assign-facets-to-channel-success": "",
     "assign-product-to-channel-success": "Produto atribuído com sucesso a \"{ channel }\"",
@@ -69,7 +70,9 @@
     "auto-update-option-variant-name": "Utilizar esta opção para actualizar automaticamente os nomes das variantes",
     "auto-update-product-variant-name": "Actualizar automaticamente os nomes das variantes do produto",
     "channel-price-preview": "Visualizar preço do canal",
+    "collection": "",
     "collection-contents": "Conteúdo da categoria",
+    "collections": "",
     "confirm-bulk-delete": "",
     "confirm-bulk-delete-collections": "",
     "confirm-bulk-delete-products": "",
@@ -192,6 +195,7 @@
     "add-new-variants": "Adicionar {count, plural, one {variante} other {{count} variantes}}",
     "add-note": "Adicionar nota",
     "apply": "",
+    "assets": "",
     "available-languages": "Idiomas disponíveis",
     "boolean-and": "",
     "boolean-false": "",
@@ -211,6 +215,7 @@
     "confirm": "Confirmar",
     "confirm-delete-note": "Eliminar nota?",
     "confirm-navigation": "Descartar modificações?",
+    "contents": "",
     "create": "Adicionar",
     "created-at": "Adicionado em",
     "custom-fields": "Campos customizados",
@@ -236,7 +241,6 @@
     "guest": "Convidado",
     "id": "",
     "image": "",
-    "info": "",
     "items-per-page-option": "{ count } por página",
     "items-selected-count": "",
     "keep-editing": "",
@@ -644,6 +648,9 @@
     "unfulfilled": "Por entreguar",
     "unit-price": "Preço unitário"
   },
+  "orders": {
+    "orders": ""
+  },
   "settings": {
     "add-countries-to-zone": "Adicionar paises para { zoneName }",
     "add-countries-to-zone-success": "A adicionar { countryCount } {countryCount, plural, one {país} other {países}} à região \"{ zoneName }\"",

+ 8 - 1
packages/admin-ui/src/lib/static/i18n-messages/ru.json

@@ -58,6 +58,7 @@
     "add-facets": "Добавить тег",
     "add-option": "Добавить опции",
     "asset-preview-links": "",
+    "assets": "",
     "assign-collections-to-channel-success": "",
     "assign-facets-to-channel-success": "",
     "assign-product-to-channel-success": "Товар успешно добавлен в канал \"{ channel }\"",
@@ -69,7 +70,9 @@
     "auto-update-option-variant-name": "Автоматически обновлять названия вариантов товара с помощью этой опции",
     "auto-update-product-variant-name": "Автоматически обновлять названия вариантов товара",
     "channel-price-preview": "Предварительный просмотр цен канала",
+    "collection": "",
     "collection-contents": "Содержание коллекции",
+    "collections": "",
     "confirm-bulk-delete": "",
     "confirm-bulk-delete-collections": "",
     "confirm-bulk-delete-products": "",
@@ -192,6 +195,7 @@
     "add-new-variants": "Добавить {count, plural, one {1 вариант} other {{count} вариантов}}",
     "add-note": "Добавить заметку",
     "apply": "",
+    "assets": "",
     "available-languages": "Доступные языки",
     "boolean-and": "",
     "boolean-false": "",
@@ -211,6 +215,7 @@
     "confirm": "Подтверждать",
     "confirm-delete-note": "Удалить заметку?",
     "confirm-navigation": "Подтвердите навигацию",
+    "contents": "",
     "create": "Создать",
     "created-at": "Создано в",
     "custom-fields": "Настраиваемые поля",
@@ -236,7 +241,6 @@
     "guest": "Гость",
     "id": "",
     "image": "",
-    "info": "",
     "items-per-page-option": "{ count } на странице",
     "items-selected-count": "",
     "keep-editing": "",
@@ -644,6 +648,9 @@
     "unfulfilled": "Невыполненный",
     "unit-price": "Цена за единицу"
   },
+  "orders": {
+    "orders": ""
+  },
   "settings": {
     "add-countries-to-zone": "Добавить страны в { zoneName }",
     "add-countries-to-zone-success": "Добавлено { countryCount } {countryCount, plural, one {страна} other {стран}} в зону \"{ zoneName }\"",

+ 8 - 1
packages/admin-ui/src/lib/static/i18n-messages/uk.json

@@ -58,6 +58,7 @@
     "add-facets": "Додати тег",
     "add-option": "Додати опцію",
     "asset-preview-links": "",
+    "assets": "",
     "assign-collections-to-channel-success": "",
     "assign-facets-to-channel-success": "",
     "assign-product-to-channel-success": "Товар успішно доданий в канал \"{ channel }\"",
@@ -69,7 +70,9 @@
     "auto-update-option-variant-name": "Автоматично оновлювати назви варіантів товару, використовуючи цю опцію",
     "auto-update-product-variant-name": "Автоматично оновлювати назви варіантів товару",
     "channel-price-preview": "Попередній перегляд цін каналу",
+    "collection": "",
     "collection-contents": "Зміст колекції",
+    "collections": "",
     "confirm-bulk-delete": "",
     "confirm-bulk-delete-collections": "",
     "confirm-bulk-delete-products": "",
@@ -192,6 +195,7 @@
     "add-new-variants": "Додати {count, plural, one {1 варіант} other {{count} варіантів}}",
     "add-note": "Додати замітку",
     "apply": "",
+    "assets": "",
     "available-languages": "Доступні мови",
     "boolean-and": "",
     "boolean-false": "",
@@ -211,6 +215,7 @@
     "confirm": "Підтверджувати",
     "confirm-delete-note": "Видалити замітку?",
     "confirm-navigation": "Підтвердіть навігацію",
+    "contents": "",
     "create": "Створити",
     "created-at": "Створено в",
     "custom-fields": "Настроювані поля",
@@ -236,7 +241,6 @@
     "guest": "Гість",
     "id": "",
     "image": "",
-    "info": "",
     "items-per-page-option": "{ count } на сторінці",
     "items-selected-count": "",
     "keep-editing": "",
@@ -644,6 +648,9 @@
     "unfulfilled": "Невиконано",
     "unit-price": "Ціна за одиницю"
   },
+  "orders": {
+    "orders": ""
+  },
   "settings": {
     "add-countries-to-zone": "Додати країни в { zoneName }",
     "add-countries-to-zone-success": "Додано { countryCount } {countryCount, plural, one {країна} other {країн}} в зону \"{ zoneName }\"",

+ 8 - 1
packages/admin-ui/src/lib/static/i18n-messages/zh_Hans.json

@@ -58,6 +58,7 @@
     "add-facets": "添加特征",
     "add-option": "添加规格组",
     "asset-preview-links": "",
+    "assets": "",
     "assign-collections-to-channel-success": "",
     "assign-facets-to-channel-success": "",
     "assign-product-to-channel-success": "成功将产品添加至销售渠道\"{ channel }\"",
@@ -69,7 +70,9 @@
     "auto-update-option-variant-name": "此选项自动更新不同商品变体名称",
     "auto-update-product-variant-name": "自动更新不同商品变体名称",
     "channel-price-preview": "渠道价格预览",
+    "collection": "",
     "collection-contents": "系列产品",
+    "collections": "",
     "confirm-bulk-delete": "",
     "confirm-bulk-delete-collections": "",
     "confirm-bulk-delete-products": "",
@@ -192,6 +195,7 @@
     "add-new-variants": "添加{count}个商品规格",
     "add-note": "添加注释",
     "apply": "",
+    "assets": "",
     "available-languages": "可用语言",
     "boolean-and": "",
     "boolean-false": "",
@@ -211,6 +215,7 @@
     "confirm": "确认",
     "confirm-delete-note": "删除笔记",
     "confirm-navigation": "导航确认",
+    "contents": "",
     "create": "添加",
     "created-at": "创建时间",
     "custom-fields": "客户化字段",
@@ -236,7 +241,6 @@
     "guest": "游客",
     "id": "",
     "image": "",
-    "info": "",
     "items-per-page-option": "每页显示 { count } 条",
     "items-selected-count": "",
     "keep-editing": "",
@@ -644,6 +648,9 @@
     "unfulfilled": "未配货",
     "unit-price": "单价"
   },
+  "orders": {
+    "orders": ""
+  },
   "settings": {
     "add-countries-to-zone": "添加国家到销售区域...",
     "add-countries-to-zone-success": "{ countryCount }个国家已到销售区域 \"{ zoneName }\"",

+ 8 - 1
packages/admin-ui/src/lib/static/i18n-messages/zh_Hant.json

@@ -58,6 +58,7 @@
     "add-facets": "新增特徵",
     "add-option": "新增規格選項",
     "asset-preview-links": "",
+    "assets": "",
     "assign-collections-to-channel-success": "",
     "assign-facets-to-channel-success": "",
     "assign-product-to-channel-success": "成功將產品新增至渠道\"{ channel }\"",
@@ -69,7 +70,9 @@
     "auto-update-option-variant-name": "",
     "auto-update-product-variant-name": "",
     "channel-price-preview": "渠道價格覽",
+    "collection": "",
     "collection-contents": "系列產品",
+    "collections": "",
     "confirm-bulk-delete": "",
     "confirm-bulk-delete-collections": "",
     "confirm-bulk-delete-products": "",
@@ -192,6 +195,7 @@
     "add-new-variants": "新增{count}個商品規格",
     "add-note": "",
     "apply": "",
+    "assets": "",
     "available-languages": "可用語言",
     "boolean-and": "",
     "boolean-false": "",
@@ -211,6 +215,7 @@
     "confirm": "",
     "confirm-delete-note": "",
     "confirm-navigation": "導航確認",
+    "contents": "",
     "create": "新增",
     "created-at": "建立時間",
     "custom-fields": "客戶自訂欄位",
@@ -236,7 +241,6 @@
     "guest": "游客",
     "id": "",
     "image": "",
-    "info": "",
     "items-per-page-option": "每页顯示 { count } 條",
     "items-selected-count": "",
     "keep-editing": "",
@@ -644,6 +648,9 @@
     "unfulfilled": "未配貨",
     "unit-price": "單價"
   },
+  "orders": {
+    "orders": ""
+  },
   "settings": {
     "add-countries-to-zone": "新增國家到銷售區域...",
     "add-countries-to-zone-success": "{ countryCount }個國家已到銷售區域 \"{ zoneName }\"",