@masterteam/system-settings 0.0.22 → 0.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,10 @@
1
- import { CommonModule } from '@angular/common';
2
- import { HttpClient, HttpErrorResponse } from '@angular/common/http';
1
+ import { CommonModule, NgTemplateOutlet } from '@angular/common';
2
+ import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http';
3
3
  import * as i0 from '@angular/core';
4
- import { input, ChangeDetectionStrategy, Component, inject, computed, effect, output, signal, InjectionToken, makeEnvironmentProviders, Injectable, DestroyRef } from '@angular/core';
4
+ import { input, ChangeDetectionStrategy, Component, inject, computed, effect, output, signal, InjectionToken, makeEnvironmentProviders, Injectable, DestroyRef, viewChild, untracked, forwardRef } from '@angular/core';
5
5
  import { toSignal, takeUntilDestroyed } from '@angular/core/rxjs-interop';
6
6
  import * as i1 from '@angular/forms';
7
- import { ReactiveFormsModule, FormGroup, FormControl, Validators } from '@angular/forms';
7
+ import { ReactiveFormsModule, FormGroup, FormControl, Validators, NG_VALUE_ACCESSOR } from '@angular/forms';
8
8
  import { Router } from '@angular/router';
9
9
  import { TranslocoDirective, TranslocoService } from '@jsverse/transloco';
10
10
  import { Breadcrumb } from '@masterteam/components/breadcrumb';
@@ -24,9 +24,13 @@ import { TextField } from '@masterteam/components/text-field';
24
24
  import { Table } from '@masterteam/components/table';
25
25
  import { MultiSelectField } from '@masterteam/components/multi-select-field';
26
26
  import { NumberField } from '@masterteam/components/number-field';
27
- import { of, map, tap, catchError, throwError, finalize, shareReplay, mergeMap, switchMap, forkJoin, timer } from 'rxjs';
27
+ import { of, map, tap, catchError, throwError, finalize, shareReplay, mergeMap, switchMap, forkJoin, timer, Observable } from 'rxjs';
28
28
  import { TextareaField } from '@masterteam/components/textarea-field';
29
29
  import { EntityStatus } from '@masterteam/components/entities';
30
+ import { ConfirmationService } from '@masterteam/components/confirmation';
31
+ import { Tabs } from '@masterteam/components/tabs';
32
+ import { Tooltip } from '@masterteam/components/tooltip';
33
+ import { Icon } from '@masterteam/icons';
30
34
 
31
35
  /**
32
36
  * "Experience & Appearance" — product-wide application behaviour that is not
@@ -192,7 +196,7 @@ class SystemSettingsHolidaysSection {
192
196
  ], ...(ngDevMode ? [{ debugName: "rowActions" }] : /* istanbul ignore next */ []));
193
197
  hasHolidays = computed(() => this.holidays().length > 0, ...(ngDevMode ? [{ debugName: "hasHolidays" }] : /* istanbul ignore next */ []));
194
198
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: SystemSettingsHolidaysSection, deps: [], target: i0.ɵɵFactoryTarget.Component });
195
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.8", type: SystemSettingsHolidaysSection, isStandalone: true, selector: "mt-system-settings-holidays-section", inputs: { holidays: { classPropertyName: "holidays", publicName: "holidays", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { addHoliday: "addHoliday", editHoliday: "editHoliday", deleteHoliday: "deleteHoliday" }, ngImport: i0, template: "<ng-container *transloco=\"let t; prefix: 'system-settings'\">\n <mt-card class=\"block\">\n <div class=\"space-y-6\">\n <div\n class=\"flex flex-col gap-4 md:flex-row md:items-start md:justify-between\"\n >\n <div class=\"space-y-1\">\n <h4 class=\"text-base font-semibold text-neutral-900\">\n {{ t(\"holidays-title\") }}\n </h4>\n <p class=\"text-sm text-neutral-600\">\n {{ t(\"holidays-description\") }}\n </p>\n </div>\n\n <mt-button\n [label]=\"t('add-holiday')\"\n icon=\"general.plus\"\n (onClick)=\"addHoliday.emit()\"\n />\n </div>\n\n @if (hasHolidays()) {\n <mt-table\n noCard\n [data]=\"holidays()\"\n [columns]=\"columns()\"\n [rowActions]=\"rowActions()\"\n [generalSearch]=\"false\"\n [showFilters]=\"false\"\n [dataKey]=\"'rowKey'\"\n storageKey=\"admin-system-settings-holidays-table\"\n >\n </mt-table>\n } @else {\n <div\n class=\"rounded-xl border border-dashed border-neutral-300 bg-neutral-50 px-6 py-10 text-center\"\n >\n <h5 class=\"text-base font-semibold text-neutral-900\">\n {{ t(\"no-holidays-title\") }}\n </h5>\n <p class=\"mt-2 text-sm text-neutral-600\">\n {{ t(\"no-holidays-description\") }}\n </p>\n </div>\n }\n </div>\n </mt-card>\n</ng-container>\n", styles: [":host{display:block}\n"], dependencies: [{ kind: "directive", type: TranslocoDirective, selector: "[transloco]", inputs: ["transloco", "translocoParams", "translocoScope", "translocoRead", "translocoPrefix", "translocoLang", "translocoLoadingTpl"] }, { kind: "component", type: Button, selector: "mt-button", inputs: ["icon", "label", "tooltip", "class", "type", "styleClass", "severity", "badge", "variant", "badgeSeverity", "size", "iconPos", "autofocus", "fluid", "raised", "rounded", "text", "plain", "outlined", "link", "disabled", "loading", "pInputs", "ariaLabel"], outputs: ["onClick", "onFocus", "onBlur"] }, { kind: "component", type: Card, selector: "mt-card", inputs: ["class", "title", "paddingless"] }, { kind: "component", type: Table, selector: "mt-table", inputs: ["filters", "data", "columns", "rowActions", "size", "showGridlines", "stripedRows", "selectableRows", "clickableRows", "generalSearch", "lazyLocalSearch", "showFilters", "filterMode", "loading", "updating", "lazy", "lazyLocalSort", "lazyTotalRecords", "reorderableColumns", "reorderableRows", "dataKey", "storageKey", "storageMode", "persistStateExclude", "exportable", "printable", "groupable", "groupCountMap", "cellClickFilter", "freezeActions", "virtualScroll", "virtualScrollItemSize", "scrollHeight", "printTitle", "exportFilename", "actionShape", "rowActionsLoadingFn", "tableLayout", "noCard", "tabs", "tabsOptionLabel", "tabsOptionValue", "activeTab", "actions", "captionStartTemplate", "captionEndTemplate", "emptyTitle", "emptyDescription", "emptyActionLabel", "emptyActionIcon", "paginatorPosition", "alwaysShowPaginator", "rowsPerPageOptions", "pageSize", "currentPage", "first", "filterTerm", "groupBy", "sortField", "sortDirection"], outputs: ["selectionChange", "cellChange", "lazyLoad", "columnReorder", "rowReorder", "rowClick", "emptyAction", "rowActionsRequested", "filtersChange", "activeTabChange", "onTabChange", "pageSizeChange", "currentPageChange", "firstChange", "filterTermChange", "groupByChange", "sortFieldChange", "sortDirectionChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
199
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.8", type: SystemSettingsHolidaysSection, isStandalone: true, selector: "mt-system-settings-holidays-section", inputs: { holidays: { classPropertyName: "holidays", publicName: "holidays", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { addHoliday: "addHoliday", editHoliday: "editHoliday", deleteHoliday: "deleteHoliday" }, ngImport: i0, template: "<ng-container *transloco=\"let t; prefix: 'system-settings'\">\n <mt-card class=\"block\">\n <div class=\"space-y-6\">\n <div\n class=\"flex flex-col gap-4 md:flex-row md:items-start md:justify-between\"\n >\n <div class=\"space-y-1\">\n <h4 class=\"text-base font-semibold text-neutral-900\">\n {{ t(\"holidays-title\") }}\n </h4>\n <p class=\"text-sm text-neutral-600\">\n {{ t(\"holidays-description\") }}\n </p>\n </div>\n\n <mt-button\n [label]=\"t('add-holiday')\"\n icon=\"general.plus\"\n (onClick)=\"addHoliday.emit()\"\n />\n </div>\n\n @if (hasHolidays()) {\n <mt-table\n noCard\n [data]=\"holidays()\"\n [columns]=\"columns()\"\n [rowActions]=\"rowActions()\"\n [generalSearch]=\"false\"\n [showFilters]=\"false\"\n [dataKey]=\"'rowKey'\"\n storageKey=\"admin-system-settings-holidays-table\"\n >\n </mt-table>\n } @else {\n <div\n class=\"rounded-xl border border-dashed border-neutral-300 bg-neutral-50 px-6 py-10 text-center\"\n >\n <h5 class=\"text-base font-semibold text-neutral-900\">\n {{ t(\"no-holidays-title\") }}\n </h5>\n <p class=\"mt-2 text-sm text-neutral-600\">\n {{ t(\"no-holidays-description\") }}\n </p>\n </div>\n }\n </div>\n </mt-card>\n</ng-container>\n", styles: [":host{display:block}\n"], dependencies: [{ kind: "directive", type: TranslocoDirective, selector: "[transloco]", inputs: ["transloco", "translocoParams", "translocoScope", "translocoRead", "translocoPrefix", "translocoLang", "translocoLoadingTpl"] }, { kind: "component", type: Button, selector: "mt-button", inputs: ["icon", "label", "tooltip", "class", "type", "styleClass", "severity", "badge", "variant", "badgeSeverity", "size", "iconPos", "autofocus", "fluid", "raised", "rounded", "text", "plain", "outlined", "link", "disabled", "loading", "pInputs", "ariaLabel"], outputs: ["onClick", "onFocus", "onBlur"] }, { kind: "component", type: Card, selector: "mt-card", inputs: ["class", "title", "paddingless"] }, { kind: "component", type: Table, selector: "mt-table", inputs: ["filters", "data", "columns", "rowActions", "size", "showGridlines", "stripedRows", "selectableRows", "clickableRows", "generalSearch", "lazyLocalSearch", "showFilters", "filterMode", "filterCloseOnOutsideClick", "loading", "updating", "lazy", "lazyLocalSort", "lazyTotalRecords", "reorderableColumns", "reorderableRows", "dataKey", "storageKey", "storageMode", "persistStateExclude", "exportable", "printable", "groupable", "groupCountMap", "cellClickFilter", "freezeActions", "virtualScroll", "virtualScrollItemSize", "scrollHeight", "printTitle", "exportFilename", "actionShape", "rowActionsLoadingFn", "tableLayout", "noCard", "tabs", "tabsOptionLabel", "tabsOptionValue", "activeTab", "actions", "captionStartTemplate", "captionEndTemplate", "emptyTitle", "emptyDescription", "emptyActionLabel", "emptyActionIcon", "externalFiltersActive", "paginatorPosition", "alwaysShowPaginator", "rowsPerPageOptions", "pageSize", "currentPage", "first", "filterTerm", "groupBy", "sortField", "sortDirection"], outputs: ["selectionChange", "cellChange", "lazyLoad", "columnReorder", "rowReorder", "rowClick", "emptyAction", "filtersCleared", "rowActionsRequested", "filtersChange", "activeTabChange", "onTabChange", "pageSizeChange", "currentPageChange", "firstChange", "filterTermChange", "groupByChange", "sortFieldChange", "sortDirectionChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
196
200
  }
197
201
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: SystemSettingsHolidaysSection, decorators: [{
198
202
  type: Component,
@@ -2679,6 +2683,12 @@ class EmailUserTestDialog {
2679
2683
  sourceLabel = input('', ...(ngDevMode ? [{ debugName: "sourceLabel" }] : /* istanbul ignore next */ []));
2680
2684
  placeholderKeys = input([], ...(ngDevMode ? [{ debugName: "placeholderKeys" }] : /* istanbul ignore next */ []));
2681
2685
  placeholderFields = input([], ...(ngDevMode ? [{ debugName: "placeholderFields" }] : /* istanbul ignore next */ []));
2686
+ /**
2687
+ * When given, these values fill the placeholders and the per-placeholder
2688
+ * fields are not shown (the notification editor sends the same readable
2689
+ * samples as its preview).
2690
+ */
2691
+ sampleValues = input(null, ...(ngDevMode ? [{ debugName: "sampleValues" }] : /* istanbul ignore next */ []));
2682
2692
  modal = inject(ModalService);
2683
2693
  ref = inject(ModalRef);
2684
2694
  destroyRef = inject(DestroyRef);
@@ -2755,7 +2765,11 @@ class EmailUserTestDialog {
2755
2765
  const request = {
2756
2766
  testRequestId: this.testRequestId(),
2757
2767
  emails: parseEmailTestRecipients(this.recipients.value),
2758
- metadata: Object.fromEntries(this.metadataControls().map(({ key, control }) => [key, control.value])),
2768
+ metadata: this.sampleValues() ??
2769
+ Object.fromEntries(this.metadataControls().map(({ key, control }) => [
2770
+ key,
2771
+ control.value,
2772
+ ])),
2759
2773
  };
2760
2774
  const operation = this.operation(request);
2761
2775
  if (!operation)
@@ -2858,7 +2872,7 @@ class EmailUserTestDialog {
2858
2872
  return this.transloco.translate(`email-delivery.user-test.${key}`);
2859
2873
  }
2860
2874
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: EmailUserTestDialog, deps: [], target: i0.ɵɵFactoryTarget.Component });
2861
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.8", type: EmailUserTestDialog, isStandalone: true, selector: "mt-email-user-test-dialog", inputs: { kind: { classPropertyName: "kind", publicName: "kind", isSignal: true, isRequired: true, transformFunction: null }, eventId: { classPropertyName: "eventId", publicName: "eventId", isSignal: true, isRequired: false, transformFunction: null }, templateId: { classPropertyName: "templateId", publicName: "templateId", isSignal: true, isRequired: false, transformFunction: null }, alertId: { classPropertyName: "alertId", publicName: "alertId", isSignal: true, isRequired: false, transformFunction: null }, sourceLabel: { classPropertyName: "sourceLabel", publicName: "sourceLabel", isSignal: true, isRequired: false, transformFunction: null }, placeholderKeys: { classPropertyName: "placeholderKeys", publicName: "placeholderKeys", isSignal: true, isRequired: false, transformFunction: null }, placeholderFields: { classPropertyName: "placeholderFields", publicName: "placeholderFields", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "<ng-container *transloco=\"let t; prefix: 'email-delivery.user-test'\">\r\n <div [class]=\"modal.contentClass + ' p-4 sm:p-5'\">\r\n <div\r\n class=\"mb-4 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950\"\r\n role=\"note\"\r\n >\r\n <strong>{{ t(\"real-email-warning-title\") }}</strong>\r\n {{ t(\"real-email-warning\") }}\r\n </div>\r\n\r\n @if (sourceLabel()) {\r\n <p class=\"mb-2 text-sm font-medium\">{{ sourceLabel() }}</p>\r\n }\r\n <p class=\"mb-4 text-xs text-muted-color\">\r\n {{ t(\"test-request-id\") }}:\r\n <span class=\"break-all font-mono\">{{ testRequestId() }}</span>\r\n </p>\r\n\r\n @if (!result()) {\r\n <div class=\"grid gap-4\">\r\n <mt-textarea-field\r\n [formControl]=\"recipients\"\r\n [label]=\"t('recipients')\"\r\n [placeholder]=\"t('recipients-placeholder')\"\r\n [hint]=\"t('recipients-hint')\"\r\n rows=\"3\"\r\n [required]=\"true\"\r\n />\r\n\r\n @if (metadataControls().length > 0) {\r\n <div class=\"rounded-md border border-surface-200 p-4\">\r\n <p class=\"m-0 text-sm font-semibold\">\r\n {{ t(\"placeholder-values\") }}\r\n </p>\r\n <p class=\"mt-1 mb-4 text-xs text-muted-color\">\r\n {{ t(\"placeholder-values-hint\") }}\r\n </p>\r\n <div class=\"grid gap-4 md:grid-cols-2\">\r\n @for (item of metadataControls(); track item.key) {\r\n <mt-text-field\r\n [formControl]=\"item.control\"\r\n [label]=\"item.label\"\r\n [placeholder]=\"t('placeholder-value')\"\r\n />\r\n }\r\n </div>\r\n </div>\r\n } @else {\r\n <p class=\"m-0 text-sm text-muted-color\">\r\n {{ t(\"no-placeholders\") }}\r\n </p>\r\n }\r\n </div>\r\n }\r\n\r\n @if (polling() && !result()) {\r\n <div\r\n class=\"mt-4 rounded-md border border-surface-200 bg-surface-50 px-4 py-3\"\r\n aria-live=\"polite\"\r\n >\r\n <p class=\"m-0 font-semibold\">\r\n {{ t(\"pending-title\") }}\r\n </p>\r\n <p class=\"mt-1 mb-0 text-sm text-muted-color\">\r\n {{ t(\"pending-description\") }}\r\n </p>\r\n </div>\r\n } @else if (timedOut() && !result()) {\r\n <div\r\n class=\"mt-4 rounded-md border border-orange-200 bg-orange-50 px-4 py-3\"\r\n role=\"alert\"\r\n >\r\n <p class=\"m-0 font-semibold text-orange-950\">\r\n {{ t(\"timeout-title\") }}\r\n </p>\r\n <p class=\"mt-1 mb-0 text-sm text-orange-900\">\r\n {{ t(\"timeout-description\") }}\r\n </p>\r\n </div>\r\n }\r\n\r\n @if (result(); as dispatch) {\r\n <div\r\n class=\"rounded-md border border-surface-200 bg-surface-50 px-4 py-4\"\r\n aria-live=\"polite\"\r\n >\r\n <p class=\"m-0 font-semibold\">\r\n {{ dispatch.allAccepted ? t(\"accepted\") : t(\"not-accepted\") }}\r\n </p>\r\n <p class=\"mt-1 mb-0 text-sm text-muted-color\">\r\n {{ t(\"request-state\") }}: {{ dispatch.requestState }}\r\n </p>\r\n @if (dispatch.expectedRecipientCount !== null) {\r\n <p class=\"mt-1 mb-0 text-sm text-muted-color\">\r\n {{ t(\"recipient-count\") }}: {{ dispatch.expectedRecipientCount }}\r\n </p>\r\n }\r\n @if (dispatch.allAccepted) {\r\n <p class=\"mt-3 mb-0 text-sm font-medium text-amber-800\">\r\n {{ t(\"accepted-unconfirmed\") }}\r\n </p>\r\n }\r\n\r\n @for (item of dispatch.results; track $index) {\r\n <div\r\n class=\"mt-3 rounded-md border border-surface-200 bg-surface-0 p-3\"\r\n >\r\n <mt-email-delivery-status kind=\"attempt\" [value]=\"item.outcome\" />\r\n <p class=\"mt-1 mb-0 text-xs text-muted-color\">\r\n {{ facade.describeCode(item.code) }}\r\n </p>\r\n </div>\r\n }\r\n </div>\r\n }\r\n </div>\r\n\r\n <div [class]=\"modal.footerClass\">\r\n <mt-button\r\n severity=\"secondary\"\r\n variant=\"outlined\"\r\n [label]=\"t('close')\"\r\n styleClass=\"w-full sm:w-auto\"\r\n (onClick)=\"close()\"\r\n />\r\n @if ((polling() || timedOut()) && !result()) {\r\n <mt-button\r\n [label]=\"t('retrieve')\"\r\n icon=\"arrow.refresh-cw-01\"\r\n [disabled]=\"polling()\"\r\n [loading]=\"\r\n polling() || facade.mutationLoading() === 'retrieve-user-test'\r\n \"\r\n styleClass=\"w-full sm:w-auto\"\r\n (onClick)=\"retrieve()\"\r\n />\r\n } @else if (!result()) {\r\n <mt-button\r\n [label]=\"t('send')\"\r\n icon=\"communication.send-01\"\r\n [disabled]=\"!canSubmit()\"\r\n [loading]=\"\r\n facade.mutationLoading() === 'template-test' ||\r\n facade.mutationLoading() === 'alert-test'\r\n \"\r\n styleClass=\"w-full sm:w-auto\"\r\n (onClick)=\"submit()\"\r\n />\r\n }\r\n </div>\r\n</ng-container>\r\n", dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.RequiredValidator, selector: ":not([type=checkbox])[required][formControlName],:not([type=checkbox])[required][formControl],:not([type=checkbox])[required][ngModel]", inputs: ["required"] }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "directive", type: TranslocoDirective, selector: "[transloco]", inputs: ["transloco", "translocoParams", "translocoScope", "translocoRead", "translocoPrefix", "translocoLang", "translocoLoadingTpl"] }, { kind: "component", type: Button, selector: "mt-button", inputs: ["icon", "label", "tooltip", "class", "type", "styleClass", "severity", "badge", "variant", "badgeSeverity", "size", "iconPos", "autofocus", "fluid", "raised", "rounded", "text", "plain", "outlined", "link", "disabled", "loading", "pInputs", "ariaLabel"], outputs: ["onClick", "onFocus", "onBlur"] }, { kind: "component", type: TextareaField, selector: "mt-textarea-field", inputs: ["field", "hint", "label", "placeholder", "class", "readonly", "noErrorStyle", "pInputs", "rows", "required", "maxLength"] }, { kind: "component", type: TextField, selector: "mt-text-field", inputs: ["field", "hint", "label", "placeholder", "class", "type", "readonly", "pInputs", "required", "maxLength", "icon", "iconPosition"] }, { kind: "component", type: EmailDeliveryStatus, selector: "mt-email-delivery-status", inputs: ["value", "kind"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2875
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.8", type: EmailUserTestDialog, isStandalone: true, selector: "mt-email-user-test-dialog", inputs: { kind: { classPropertyName: "kind", publicName: "kind", isSignal: true, isRequired: true, transformFunction: null }, eventId: { classPropertyName: "eventId", publicName: "eventId", isSignal: true, isRequired: false, transformFunction: null }, templateId: { classPropertyName: "templateId", publicName: "templateId", isSignal: true, isRequired: false, transformFunction: null }, alertId: { classPropertyName: "alertId", publicName: "alertId", isSignal: true, isRequired: false, transformFunction: null }, sourceLabel: { classPropertyName: "sourceLabel", publicName: "sourceLabel", isSignal: true, isRequired: false, transformFunction: null }, placeholderKeys: { classPropertyName: "placeholderKeys", publicName: "placeholderKeys", isSignal: true, isRequired: false, transformFunction: null }, placeholderFields: { classPropertyName: "placeholderFields", publicName: "placeholderFields", isSignal: true, isRequired: false, transformFunction: null }, sampleValues: { classPropertyName: "sampleValues", publicName: "sampleValues", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "<ng-container *transloco=\"let t; prefix: 'email-delivery.user-test'\">\r\n <div [class]=\"modal.contentClass + ' p-4 sm:p-5'\">\r\n <div\r\n class=\"mb-4 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950\"\r\n role=\"note\"\r\n >\r\n <strong>{{ t(\"real-email-warning-title\") }}</strong>\r\n {{\r\n t(sampleValues() ? \"real-email-warning-samples\" : \"real-email-warning\")\r\n }}\r\n </div>\r\n\r\n <!-- The test request ID is still generated and sent (it makes a retry\r\n safe); it is not something the user acts on, so it is not shown. -->\r\n @if (sourceLabel()) {\r\n <p class=\"mb-4 text-sm font-medium\">{{ sourceLabel() }}</p>\r\n }\r\n\r\n @if (!result()) {\r\n <div class=\"grid gap-4\">\r\n <mt-textarea-field\r\n [formControl]=\"recipients\"\r\n [label]=\"t('recipients')\"\r\n [placeholder]=\"t('recipients-placeholder')\"\r\n [hint]=\"t('recipients-hint')\"\r\n rows=\"3\"\r\n [required]=\"true\"\r\n />\r\n\r\n @if (sampleValues()) {\r\n <!-- The host fills the placeholders with sample values. -->\r\n } @else if (metadataControls().length > 0) {\r\n <div class=\"rounded-md border border-surface-200 p-4\">\r\n <p class=\"m-0 text-sm font-semibold\">\r\n {{ t(\"placeholder-values\") }}\r\n </p>\r\n <p class=\"mt-1 mb-4 text-xs text-muted-color\">\r\n {{ t(\"placeholder-values-hint\") }}\r\n </p>\r\n <div class=\"grid gap-4 md:grid-cols-2\">\r\n @for (item of metadataControls(); track item.key) {\r\n <mt-text-field\r\n [formControl]=\"item.control\"\r\n [label]=\"item.label\"\r\n [placeholder]=\"t('placeholder-value')\"\r\n />\r\n }\r\n </div>\r\n </div>\r\n } @else {\r\n <p class=\"m-0 text-sm text-muted-color\">\r\n {{ t(\"no-placeholders\") }}\r\n </p>\r\n }\r\n </div>\r\n }\r\n\r\n @if (polling() && !result()) {\r\n <div\r\n class=\"mt-4 rounded-md border border-surface-200 bg-surface-50 px-4 py-3\"\r\n aria-live=\"polite\"\r\n >\r\n <p class=\"m-0 font-semibold\">\r\n {{ t(\"pending-title\") }}\r\n </p>\r\n <p class=\"mt-1 mb-0 text-sm text-muted-color\">\r\n {{ t(\"pending-description\") }}\r\n </p>\r\n </div>\r\n } @else if (timedOut() && !result()) {\r\n <div\r\n class=\"mt-4 rounded-md border border-orange-200 bg-orange-50 px-4 py-3\"\r\n role=\"alert\"\r\n >\r\n <p class=\"m-0 font-semibold text-orange-950\">\r\n {{ t(\"timeout-title\") }}\r\n </p>\r\n <p class=\"mt-1 mb-0 text-sm text-orange-900\">\r\n {{ t(\"timeout-description\") }}\r\n </p>\r\n </div>\r\n }\r\n\r\n @if (result(); as dispatch) {\r\n <div\r\n class=\"rounded-md border border-surface-200 bg-surface-50 px-4 py-4\"\r\n aria-live=\"polite\"\r\n >\r\n <p class=\"m-0 font-semibold\">\r\n {{ dispatch.allAccepted ? t(\"accepted\") : t(\"not-accepted\") }}\r\n </p>\r\n @if (dispatch.expectedRecipientCount !== null) {\r\n <p class=\"mt-1 mb-0 text-sm text-muted-color\">\r\n {{ t(\"recipient-count\") }}: {{ dispatch.expectedRecipientCount }}\r\n </p>\r\n }\r\n @if (dispatch.allAccepted) {\r\n <p class=\"mt-3 mb-0 text-sm font-medium text-amber-800\">\r\n {{ t(\"accepted-unconfirmed\") }}\r\n </p>\r\n }\r\n\r\n @for (item of dispatch.results; track $index) {\r\n <div\r\n class=\"mt-3 rounded-md border border-surface-200 bg-surface-0 p-3\"\r\n >\r\n <mt-email-delivery-status kind=\"attempt\" [value]=\"item.outcome\" />\r\n <p class=\"mt-1 mb-0 text-xs text-muted-color\">\r\n {{ facade.describeCode(item.code) }}\r\n </p>\r\n </div>\r\n }\r\n </div>\r\n }\r\n </div>\r\n\r\n <div [class]=\"modal.footerClass\">\r\n <mt-button\r\n severity=\"secondary\"\r\n variant=\"outlined\"\r\n [label]=\"t('close')\"\r\n styleClass=\"w-full sm:w-auto\"\r\n (onClick)=\"close()\"\r\n />\r\n @if ((polling() || timedOut()) && !result()) {\r\n <mt-button\r\n [label]=\"t('retrieve')\"\r\n icon=\"arrow.refresh-cw-01\"\r\n [disabled]=\"polling()\"\r\n [loading]=\"\r\n polling() || facade.mutationLoading() === 'retrieve-user-test'\r\n \"\r\n styleClass=\"w-full sm:w-auto\"\r\n (onClick)=\"retrieve()\"\r\n />\r\n } @else if (!result()) {\r\n <mt-button\r\n [label]=\"t('send')\"\r\n icon=\"communication.send-01\"\r\n [disabled]=\"!canSubmit()\"\r\n [loading]=\"\r\n facade.mutationLoading() === 'template-test' ||\r\n facade.mutationLoading() === 'alert-test'\r\n \"\r\n styleClass=\"w-full sm:w-auto\"\r\n (onClick)=\"submit()\"\r\n />\r\n }\r\n </div>\r\n</ng-container>\r\n", dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.RequiredValidator, selector: ":not([type=checkbox])[required][formControlName],:not([type=checkbox])[required][formControl],:not([type=checkbox])[required][ngModel]", inputs: ["required"] }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "directive", type: TranslocoDirective, selector: "[transloco]", inputs: ["transloco", "translocoParams", "translocoScope", "translocoRead", "translocoPrefix", "translocoLang", "translocoLoadingTpl"] }, { kind: "component", type: Button, selector: "mt-button", inputs: ["icon", "label", "tooltip", "class", "type", "styleClass", "severity", "badge", "variant", "badgeSeverity", "size", "iconPos", "autofocus", "fluid", "raised", "rounded", "text", "plain", "outlined", "link", "disabled", "loading", "pInputs", "ariaLabel"], outputs: ["onClick", "onFocus", "onBlur"] }, { kind: "component", type: TextareaField, selector: "mt-textarea-field", inputs: ["field", "hint", "label", "placeholder", "class", "readonly", "noErrorStyle", "pInputs", "rows", "required", "maxLength"] }, { kind: "component", type: TextField, selector: "mt-text-field", inputs: ["field", "hint", "label", "placeholder", "class", "type", "readonly", "pInputs", "required", "maxLength", "icon", "iconPosition"] }, { kind: "component", type: EmailDeliveryStatus, selector: "mt-email-delivery-status", inputs: ["value", "kind"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2862
2876
  }
2863
2877
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: EmailUserTestDialog, decorators: [{
2864
2878
  type: Component,
@@ -2869,8 +2883,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImpor
2869
2883
  TextareaField,
2870
2884
  TextField,
2871
2885
  EmailDeliveryStatus,
2872
- ], changeDetection: ChangeDetectionStrategy.OnPush, template: "<ng-container *transloco=\"let t; prefix: 'email-delivery.user-test'\">\r\n <div [class]=\"modal.contentClass + ' p-4 sm:p-5'\">\r\n <div\r\n class=\"mb-4 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950\"\r\n role=\"note\"\r\n >\r\n <strong>{{ t(\"real-email-warning-title\") }}</strong>\r\n {{ t(\"real-email-warning\") }}\r\n </div>\r\n\r\n @if (sourceLabel()) {\r\n <p class=\"mb-2 text-sm font-medium\">{{ sourceLabel() }}</p>\r\n }\r\n <p class=\"mb-4 text-xs text-muted-color\">\r\n {{ t(\"test-request-id\") }}:\r\n <span class=\"break-all font-mono\">{{ testRequestId() }}</span>\r\n </p>\r\n\r\n @if (!result()) {\r\n <div class=\"grid gap-4\">\r\n <mt-textarea-field\r\n [formControl]=\"recipients\"\r\n [label]=\"t('recipients')\"\r\n [placeholder]=\"t('recipients-placeholder')\"\r\n [hint]=\"t('recipients-hint')\"\r\n rows=\"3\"\r\n [required]=\"true\"\r\n />\r\n\r\n @if (metadataControls().length > 0) {\r\n <div class=\"rounded-md border border-surface-200 p-4\">\r\n <p class=\"m-0 text-sm font-semibold\">\r\n {{ t(\"placeholder-values\") }}\r\n </p>\r\n <p class=\"mt-1 mb-4 text-xs text-muted-color\">\r\n {{ t(\"placeholder-values-hint\") }}\r\n </p>\r\n <div class=\"grid gap-4 md:grid-cols-2\">\r\n @for (item of metadataControls(); track item.key) {\r\n <mt-text-field\r\n [formControl]=\"item.control\"\r\n [label]=\"item.label\"\r\n [placeholder]=\"t('placeholder-value')\"\r\n />\r\n }\r\n </div>\r\n </div>\r\n } @else {\r\n <p class=\"m-0 text-sm text-muted-color\">\r\n {{ t(\"no-placeholders\") }}\r\n </p>\r\n }\r\n </div>\r\n }\r\n\r\n @if (polling() && !result()) {\r\n <div\r\n class=\"mt-4 rounded-md border border-surface-200 bg-surface-50 px-4 py-3\"\r\n aria-live=\"polite\"\r\n >\r\n <p class=\"m-0 font-semibold\">\r\n {{ t(\"pending-title\") }}\r\n </p>\r\n <p class=\"mt-1 mb-0 text-sm text-muted-color\">\r\n {{ t(\"pending-description\") }}\r\n </p>\r\n </div>\r\n } @else if (timedOut() && !result()) {\r\n <div\r\n class=\"mt-4 rounded-md border border-orange-200 bg-orange-50 px-4 py-3\"\r\n role=\"alert\"\r\n >\r\n <p class=\"m-0 font-semibold text-orange-950\">\r\n {{ t(\"timeout-title\") }}\r\n </p>\r\n <p class=\"mt-1 mb-0 text-sm text-orange-900\">\r\n {{ t(\"timeout-description\") }}\r\n </p>\r\n </div>\r\n }\r\n\r\n @if (result(); as dispatch) {\r\n <div\r\n class=\"rounded-md border border-surface-200 bg-surface-50 px-4 py-4\"\r\n aria-live=\"polite\"\r\n >\r\n <p class=\"m-0 font-semibold\">\r\n {{ dispatch.allAccepted ? t(\"accepted\") : t(\"not-accepted\") }}\r\n </p>\r\n <p class=\"mt-1 mb-0 text-sm text-muted-color\">\r\n {{ t(\"request-state\") }}: {{ dispatch.requestState }}\r\n </p>\r\n @if (dispatch.expectedRecipientCount !== null) {\r\n <p class=\"mt-1 mb-0 text-sm text-muted-color\">\r\n {{ t(\"recipient-count\") }}: {{ dispatch.expectedRecipientCount }}\r\n </p>\r\n }\r\n @if (dispatch.allAccepted) {\r\n <p class=\"mt-3 mb-0 text-sm font-medium text-amber-800\">\r\n {{ t(\"accepted-unconfirmed\") }}\r\n </p>\r\n }\r\n\r\n @for (item of dispatch.results; track $index) {\r\n <div\r\n class=\"mt-3 rounded-md border border-surface-200 bg-surface-0 p-3\"\r\n >\r\n <mt-email-delivery-status kind=\"attempt\" [value]=\"item.outcome\" />\r\n <p class=\"mt-1 mb-0 text-xs text-muted-color\">\r\n {{ facade.describeCode(item.code) }}\r\n </p>\r\n </div>\r\n }\r\n </div>\r\n }\r\n </div>\r\n\r\n <div [class]=\"modal.footerClass\">\r\n <mt-button\r\n severity=\"secondary\"\r\n variant=\"outlined\"\r\n [label]=\"t('close')\"\r\n styleClass=\"w-full sm:w-auto\"\r\n (onClick)=\"close()\"\r\n />\r\n @if ((polling() || timedOut()) && !result()) {\r\n <mt-button\r\n [label]=\"t('retrieve')\"\r\n icon=\"arrow.refresh-cw-01\"\r\n [disabled]=\"polling()\"\r\n [loading]=\"\r\n polling() || facade.mutationLoading() === 'retrieve-user-test'\r\n \"\r\n styleClass=\"w-full sm:w-auto\"\r\n (onClick)=\"retrieve()\"\r\n />\r\n } @else if (!result()) {\r\n <mt-button\r\n [label]=\"t('send')\"\r\n icon=\"communication.send-01\"\r\n [disabled]=\"!canSubmit()\"\r\n [loading]=\"\r\n facade.mutationLoading() === 'template-test' ||\r\n facade.mutationLoading() === 'alert-test'\r\n \"\r\n styleClass=\"w-full sm:w-auto\"\r\n (onClick)=\"submit()\"\r\n />\r\n }\r\n </div>\r\n</ng-container>\r\n" }]
2873
- }], ctorParameters: () => [], propDecorators: { kind: [{ type: i0.Input, args: [{ isSignal: true, alias: "kind", required: true }] }], eventId: [{ type: i0.Input, args: [{ isSignal: true, alias: "eventId", required: false }] }], templateId: [{ type: i0.Input, args: [{ isSignal: true, alias: "templateId", required: false }] }], alertId: [{ type: i0.Input, args: [{ isSignal: true, alias: "alertId", required: false }] }], sourceLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "sourceLabel", required: false }] }], placeholderKeys: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholderKeys", required: false }] }], placeholderFields: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholderFields", required: false }] }] } });
2886
+ ], changeDetection: ChangeDetectionStrategy.OnPush, template: "<ng-container *transloco=\"let t; prefix: 'email-delivery.user-test'\">\r\n <div [class]=\"modal.contentClass + ' p-4 sm:p-5'\">\r\n <div\r\n class=\"mb-4 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950\"\r\n role=\"note\"\r\n >\r\n <strong>{{ t(\"real-email-warning-title\") }}</strong>\r\n {{\r\n t(sampleValues() ? \"real-email-warning-samples\" : \"real-email-warning\")\r\n }}\r\n </div>\r\n\r\n <!-- The test request ID is still generated and sent (it makes a retry\r\n safe); it is not something the user acts on, so it is not shown. -->\r\n @if (sourceLabel()) {\r\n <p class=\"mb-4 text-sm font-medium\">{{ sourceLabel() }}</p>\r\n }\r\n\r\n @if (!result()) {\r\n <div class=\"grid gap-4\">\r\n <mt-textarea-field\r\n [formControl]=\"recipients\"\r\n [label]=\"t('recipients')\"\r\n [placeholder]=\"t('recipients-placeholder')\"\r\n [hint]=\"t('recipients-hint')\"\r\n rows=\"3\"\r\n [required]=\"true\"\r\n />\r\n\r\n @if (sampleValues()) {\r\n <!-- The host fills the placeholders with sample values. -->\r\n } @else if (metadataControls().length > 0) {\r\n <div class=\"rounded-md border border-surface-200 p-4\">\r\n <p class=\"m-0 text-sm font-semibold\">\r\n {{ t(\"placeholder-values\") }}\r\n </p>\r\n <p class=\"mt-1 mb-4 text-xs text-muted-color\">\r\n {{ t(\"placeholder-values-hint\") }}\r\n </p>\r\n <div class=\"grid gap-4 md:grid-cols-2\">\r\n @for (item of metadataControls(); track item.key) {\r\n <mt-text-field\r\n [formControl]=\"item.control\"\r\n [label]=\"item.label\"\r\n [placeholder]=\"t('placeholder-value')\"\r\n />\r\n }\r\n </div>\r\n </div>\r\n } @else {\r\n <p class=\"m-0 text-sm text-muted-color\">\r\n {{ t(\"no-placeholders\") }}\r\n </p>\r\n }\r\n </div>\r\n }\r\n\r\n @if (polling() && !result()) {\r\n <div\r\n class=\"mt-4 rounded-md border border-surface-200 bg-surface-50 px-4 py-3\"\r\n aria-live=\"polite\"\r\n >\r\n <p class=\"m-0 font-semibold\">\r\n {{ t(\"pending-title\") }}\r\n </p>\r\n <p class=\"mt-1 mb-0 text-sm text-muted-color\">\r\n {{ t(\"pending-description\") }}\r\n </p>\r\n </div>\r\n } @else if (timedOut() && !result()) {\r\n <div\r\n class=\"mt-4 rounded-md border border-orange-200 bg-orange-50 px-4 py-3\"\r\n role=\"alert\"\r\n >\r\n <p class=\"m-0 font-semibold text-orange-950\">\r\n {{ t(\"timeout-title\") }}\r\n </p>\r\n <p class=\"mt-1 mb-0 text-sm text-orange-900\">\r\n {{ t(\"timeout-description\") }}\r\n </p>\r\n </div>\r\n }\r\n\r\n @if (result(); as dispatch) {\r\n <div\r\n class=\"rounded-md border border-surface-200 bg-surface-50 px-4 py-4\"\r\n aria-live=\"polite\"\r\n >\r\n <p class=\"m-0 font-semibold\">\r\n {{ dispatch.allAccepted ? t(\"accepted\") : t(\"not-accepted\") }}\r\n </p>\r\n @if (dispatch.expectedRecipientCount !== null) {\r\n <p class=\"mt-1 mb-0 text-sm text-muted-color\">\r\n {{ t(\"recipient-count\") }}: {{ dispatch.expectedRecipientCount }}\r\n </p>\r\n }\r\n @if (dispatch.allAccepted) {\r\n <p class=\"mt-3 mb-0 text-sm font-medium text-amber-800\">\r\n {{ t(\"accepted-unconfirmed\") }}\r\n </p>\r\n }\r\n\r\n @for (item of dispatch.results; track $index) {\r\n <div\r\n class=\"mt-3 rounded-md border border-surface-200 bg-surface-0 p-3\"\r\n >\r\n <mt-email-delivery-status kind=\"attempt\" [value]=\"item.outcome\" />\r\n <p class=\"mt-1 mb-0 text-xs text-muted-color\">\r\n {{ facade.describeCode(item.code) }}\r\n </p>\r\n </div>\r\n }\r\n </div>\r\n }\r\n </div>\r\n\r\n <div [class]=\"modal.footerClass\">\r\n <mt-button\r\n severity=\"secondary\"\r\n variant=\"outlined\"\r\n [label]=\"t('close')\"\r\n styleClass=\"w-full sm:w-auto\"\r\n (onClick)=\"close()\"\r\n />\r\n @if ((polling() || timedOut()) && !result()) {\r\n <mt-button\r\n [label]=\"t('retrieve')\"\r\n icon=\"arrow.refresh-cw-01\"\r\n [disabled]=\"polling()\"\r\n [loading]=\"\r\n polling() || facade.mutationLoading() === 'retrieve-user-test'\r\n \"\r\n styleClass=\"w-full sm:w-auto\"\r\n (onClick)=\"retrieve()\"\r\n />\r\n } @else if (!result()) {\r\n <mt-button\r\n [label]=\"t('send')\"\r\n icon=\"communication.send-01\"\r\n [disabled]=\"!canSubmit()\"\r\n [loading]=\"\r\n facade.mutationLoading() === 'template-test' ||\r\n facade.mutationLoading() === 'alert-test'\r\n \"\r\n styleClass=\"w-full sm:w-auto\"\r\n (onClick)=\"submit()\"\r\n />\r\n }\r\n </div>\r\n</ng-container>\r\n" }]
2887
+ }], ctorParameters: () => [], propDecorators: { kind: [{ type: i0.Input, args: [{ isSignal: true, alias: "kind", required: true }] }], eventId: [{ type: i0.Input, args: [{ isSignal: true, alias: "eventId", required: false }] }], templateId: [{ type: i0.Input, args: [{ isSignal: true, alias: "templateId", required: false }] }], alertId: [{ type: i0.Input, args: [{ isSignal: true, alias: "alertId", required: false }] }], sourceLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "sourceLabel", required: false }] }], placeholderKeys: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholderKeys", required: false }] }], placeholderFields: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholderFields", required: false }] }], sampleValues: [{ type: i0.Input, args: [{ isSignal: true, alias: "sampleValues", required: false }] }] } });
2874
2888
  const POLL_DELAYS_MS = [1_000, 2_000, 4_000, 5_000, 5_000, 5_000];
2875
2889
  const TEST_REQUEST_CONFLICT_CODE = 'email.delivery_test_request_conflict';
2876
2890
  function isAmbiguousTimeout(error) {
@@ -2886,17 +2900,1241 @@ function isTestRequestConflict(error) {
2886
2900
  value.errors?.code === TEST_REQUEST_CONFLICT_CODE);
2887
2901
  }
2888
2902
 
2903
+ /**
2904
+ * State for the Email design tab. The working draft lives here rather than in
2905
+ * the tab so switching between Connections / Activity / Email design keeps
2906
+ * unsaved edits; leaving the page asks first (`emailDesignLeaveGuard`).
2907
+ *
2908
+ * There is no server-side draft: the base is only ever previewed (render-only)
2909
+ * or published with the revision it was read at.
2910
+ */
2911
+ class EmailLayoutFacade {
2912
+ http = inject(HttpClient);
2913
+ options = inject(EMAIL_DELIVERY_OPTIONS);
2914
+ transloco = inject(TranslocoService);
2915
+ // Failures arrive as a bare stable code; the tab translates it itself.
2916
+ headers = new HttpHeaders({ noMessage: 'true' });
2917
+ snapshot = signal(null, ...(ngDevMode ? [{ debugName: "snapshot" }] : /* istanbul ignore next */ []));
2918
+ draft = signal('', ...(ngDevMode ? [{ debugName: "draft" }] : /* istanbul ignore next */ []));
2919
+ loading = signal(false, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
2920
+ loadErrorCode = signal(null, ...(ngDevMode ? [{ debugName: "loadErrorCode" }] : /* istanbul ignore next */ []));
2921
+ loadFailed = signal(false, ...(ngDevMode ? [{ debugName: "loadFailed" }] : /* istanbul ignore next */ []));
2922
+ saving = signal(null, ...(ngDevMode ? [{ debugName: "saving" }] : /* istanbul ignore next */ []));
2923
+ /** Someone else published since this draft's baseline was read. */
2924
+ conflict = signal(false, ...(ngDevMode ? [{ debugName: "conflict" }] : /* istanbul ignore next */ []));
2925
+ dirty = computed(() => {
2926
+ const snapshot = this.snapshot();
2927
+ return snapshot !== null && this.draft() !== snapshot.body;
2928
+ }, ...(ngDevMode ? [{ debugName: "dirty" }] : /* istanbul ignore next */ []));
2929
+ load() {
2930
+ this.loading.set(true);
2931
+ this.loadFailed.set(false);
2932
+ this.loadErrorCode.set(null);
2933
+ return this.http
2934
+ .get(this.url(), { headers: this.headers })
2935
+ .pipe(map((response) => response.data), tap((snapshot) => {
2936
+ // Never overwrite edits the user has not saved yet.
2937
+ if (!this.dirty())
2938
+ this.draft.set(snapshot.body);
2939
+ this.snapshot.set(snapshot);
2940
+ }), catchError((error) => {
2941
+ this.loadFailed.set(true);
2942
+ this.loadErrorCode.set(readLayoutErrorCode(error));
2943
+ return throwError(() => error);
2944
+ }), finalize(() => this.loading.set(false)));
2945
+ }
2946
+ /** Render-only: the server supplies a fixed bilingual sample; nothing is saved or sent. */
2947
+ preview(body) {
2948
+ return this.http
2949
+ .post(this.url('preview'), { body }, { headers: this.headers })
2950
+ .pipe(map((response) => response.data));
2951
+ }
2952
+ publish() {
2953
+ const snapshot = this.snapshot();
2954
+ if (!snapshot)
2955
+ return throwError(() => new Error('Layout not loaded'));
2956
+ this.saving.set('publish');
2957
+ return this.http
2958
+ .put(this.url(), { body: this.draft(), expectedRevision: snapshot.revision }, { headers: this.headers })
2959
+ .pipe(map((response) => response.data), tap((published) => this.adopt(published)), catchError((error) => this.onWriteError(error)), finalize(() => this.saving.set(null)));
2960
+ }
2961
+ restorePrevious() {
2962
+ const snapshot = this.snapshot();
2963
+ if (!snapshot)
2964
+ return throwError(() => new Error('Layout not loaded'));
2965
+ this.saving.set('restore');
2966
+ return this.http
2967
+ .post(this.url('restore'), { expectedRevision: snapshot.revision }, { headers: this.headers })
2968
+ .pipe(map((response) => response.data), tap((restored) => this.adopt(restored)), catchError((error) => this.onWriteError(error)), finalize(() => this.saving.set(null)));
2969
+ }
2970
+ /**
2971
+ * Re-reads the live design after a conflict. `keepDraft` keeps the user's
2972
+ * edits on top of the newer baseline, so the next publish applies them
2973
+ * deliberately; otherwise the draft is replaced by what is live.
2974
+ */
2975
+ reloadLatest(keepDraft) {
2976
+ const draft = this.draft();
2977
+ return this.load().pipe(tap((snapshot) => {
2978
+ this.draft.set(keepDraft ? draft : snapshot.body);
2979
+ this.conflict.set(false);
2980
+ }));
2981
+ }
2982
+ discard() {
2983
+ const snapshot = this.snapshot();
2984
+ if (snapshot)
2985
+ this.draft.set(snapshot.body);
2986
+ }
2987
+ describeCode(code) {
2988
+ if (code) {
2989
+ const key = `email-delivery.design.codes.${code}`;
2990
+ const translated = this.transloco.translate(key);
2991
+ if (translated !== key)
2992
+ return translated;
2993
+ }
2994
+ return this.transloco.translate('email-delivery.design.codes.generic', {
2995
+ code: code ?? '—',
2996
+ });
2997
+ }
2998
+ describeDiagnostic(diagnostic) {
2999
+ const key = `email-delivery.design.codes.${diagnostic.code}`;
3000
+ const translated = this.transloco.translate(key);
3001
+ const message = translated === key ? diagnostic.code : translated;
3002
+ return diagnostic.path ? `${message} (${diagnostic.path})` : message;
3003
+ }
3004
+ readErrorCode(error) {
3005
+ return readLayoutErrorCode(error);
3006
+ }
3007
+ adopt(snapshot) {
3008
+ // The server returns the sanitized body it stored; show exactly that.
3009
+ this.snapshot.set(snapshot);
3010
+ this.draft.set(snapshot.body);
3011
+ this.conflict.set(false);
3012
+ }
3013
+ onWriteError(error) {
3014
+ if ((error instanceof HttpErrorResponse && error.status === 409) ||
3015
+ readLayoutErrorCode(error) === 'EMAIL_REVISION_CONFLICT') {
3016
+ this.conflict.set(true);
3017
+ }
3018
+ return throwError(() => error);
3019
+ }
3020
+ url(path = '') {
3021
+ const base = this.options.notificationApiBasePath.replace(/\/$/, '');
3022
+ return path ? `${base}/email-layout/${path}` : `${base}/email-layout`;
3023
+ }
3024
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: EmailLayoutFacade, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
3025
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: EmailLayoutFacade, providedIn: 'root' });
3026
+ }
3027
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: EmailLayoutFacade, decorators: [{
3028
+ type: Injectable,
3029
+ args: [{ providedIn: 'root' }]
3030
+ }] });
3031
+ function readLayoutErrorCode(error) {
3032
+ const body = error instanceof HttpErrorResponse ? error.error : error;
3033
+ const code = body?.errors?.code;
3034
+ return typeof code === 'string' ? code : null;
3035
+ }
3036
+ function readLayoutErrorDiagnostics(error) {
3037
+ const body = error instanceof HttpErrorResponse ? error.error : error;
3038
+ const details = body?.errors
3039
+ ?.details;
3040
+ return Array.isArray(details)
3041
+ ? details.filter((item) => typeof item === 'object' &&
3042
+ item !== null &&
3043
+ typeof item.code === 'string')
3044
+ : [];
3045
+ }
3046
+
3047
+ /**
3048
+ * Leaving the Email page with an unpublished design draft asks once. Switching
3049
+ * tabs inside the page is not a navigation away — the draft lives in
3050
+ * `EmailLayoutFacade` and is still there when the tab is reopened.
3051
+ */
3052
+ const emailDesignLeaveGuard = () => {
3053
+ const facade = inject(EmailLayoutFacade);
3054
+ if (facade.saving())
3055
+ return false;
3056
+ if (!facade.dirty())
3057
+ return true;
3058
+ const confirmation = inject(ConfirmationService);
3059
+ const transloco = inject(TranslocoService);
3060
+ const t = (key) => transloco.translate(`email-delivery.design.leave.${key}`);
3061
+ return new Observable((subscriber) => {
3062
+ confirmation.confirm({
3063
+ header: t('header'),
3064
+ message: t('message'),
3065
+ icon: 'alert.alert-triangle',
3066
+ acceptLabel: t('discard'),
3067
+ rejectLabel: t('stay'),
3068
+ acceptButton: { severity: 'danger' },
3069
+ accept: () => {
3070
+ facade.discard();
3071
+ subscriber.next(true);
3072
+ subscriber.complete();
3073
+ },
3074
+ reject: () => {
3075
+ subscriber.next(false);
3076
+ subscriber.complete();
3077
+ },
3078
+ });
3079
+ });
3080
+ };
3081
+
2889
3082
  const EMAIL_DELIVERY_ROUTES = [
2890
3083
  {
2891
3084
  path: '',
2892
- loadComponent: () => import('./masterteam-system-settings-email-delivery-shell-oTWOzKWj.mjs').then((module) => module.EmailDeliveryShell),
3085
+ canDeactivate: [emailDesignLeaveGuard],
3086
+ loadComponent: () => import('./masterteam-system-settings-email-delivery-shell-D9NShoTc.mjs').then((module) => module.EmailDeliveryShell),
2893
3087
  },
2894
3088
  { path: '**', redirectTo: '' },
2895
3089
  ];
2896
3090
 
3091
+ /** The one literal slot every base must contain exactly once. */
3092
+ const EMAIL_LAYOUT_CONTENT_SLOT = '{content}';
3093
+ /** UTF-8 byte limit for the base HTML. */
3094
+ const EMAIL_LAYOUT_MAX_BYTES = 128 * 1024;
3095
+
3096
+ /**
3097
+ * Renders server-composed notification HTML without letting it touch the app.
3098
+ *
3099
+ * The frame is an opaque origin (`sandbox=""`: no scripts, forms, popups,
3100
+ * same-origin or top navigation), sends no referrer, and the document carries
3101
+ * its own CSP: nothing loads except inline styles, and images only from the
3102
+ * approved origins once the viewer opts in. Links are stripped from this
3103
+ * preview-only copy so a click cannot navigate the frame; scrolling and
3104
+ * keyboard focus still work. The source is never injected into the app DOM.
3105
+ */
3106
+ class EmailHtmlPreview {
3107
+ html = input('', ...(ngDevMode ? [{ debugName: "html" }] : /* istanbul ignore next */ []));
3108
+ showImages = input(false, ...(ngDevMode ? [{ debugName: "showImages" }] : /* istanbul ignore next */ []));
3109
+ approvedImageOrigins = input([], ...(ngDevMode ? [{ debugName: "approvedImageOrigins" }] : /* istanbul ignore next */ []));
3110
+ fragment = input(false, ...(ngDevMode ? [{ debugName: "fragment" }] : /* istanbul ignore next */ []));
3111
+ direction = input(null, ...(ngDevMode ? [{ debugName: "direction" }] : /* istanbul ignore next */ []));
3112
+ title = input('', ...(ngDevMode ? [{ debugName: "title" }] : /* istanbul ignore next */ []));
3113
+ height = input('28rem', ...(ngDevMode ? [{ debugName: "height" }] : /* istanbul ignore next */ []));
3114
+ frame = viewChild.required('frame');
3115
+ constructor() {
3116
+ effect(() => {
3117
+ this.frame().nativeElement.srcdoc = buildEmailPreviewDocument(this.html(), {
3118
+ showImages: this.showImages(),
3119
+ approvedImageOrigins: this.approvedImageOrigins(),
3120
+ fragment: this.fragment(),
3121
+ direction: this.direction(),
3122
+ });
3123
+ });
3124
+ }
3125
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: EmailHtmlPreview, deps: [], target: i0.ɵɵFactoryTarget.Component });
3126
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.2.8", type: EmailHtmlPreview, isStandalone: true, selector: "mt-email-html-preview", inputs: { html: { classPropertyName: "html", publicName: "html", isSignal: true, isRequired: false, transformFunction: null }, showImages: { classPropertyName: "showImages", publicName: "showImages", isSignal: true, isRequired: false, transformFunction: null }, approvedImageOrigins: { classPropertyName: "approvedImageOrigins", publicName: "approvedImageOrigins", isSignal: true, isRequired: false, transformFunction: null }, fragment: { classPropertyName: "fragment", publicName: "fragment", isSignal: true, isRequired: false, transformFunction: null }, direction: { classPropertyName: "direction", publicName: "direction", isSignal: true, isRequired: false, transformFunction: null }, title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, height: { classPropertyName: "height", publicName: "height", isSignal: true, isRequired: false, transformFunction: null } }, host: { classAttribute: "block min-w-0" }, viewQueries: [{ propertyName: "frame", first: true, predicate: ["frame"], descendants: true, isSignal: true }], ngImport: i0, template: `<iframe
3127
+ #frame
3128
+ sandbox=""
3129
+ referrerpolicy="no-referrer"
3130
+ [attr.title]="title()"
3131
+ class="block w-full border-0 bg-white"
3132
+ [style.height]="height()"
3133
+ ></iframe>`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
3134
+ }
3135
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: EmailHtmlPreview, decorators: [{
3136
+ type: Component,
3137
+ args: [{
3138
+ selector: 'mt-email-html-preview',
3139
+ template: `<iframe
3140
+ #frame
3141
+ sandbox=""
3142
+ referrerpolicy="no-referrer"
3143
+ [attr.title]="title()"
3144
+ class="block w-full border-0 bg-white"
3145
+ [style.height]="height()"
3146
+ ></iframe>`,
3147
+ host: { class: 'block min-w-0' },
3148
+ changeDetection: ChangeDetectionStrategy.OnPush,
3149
+ }]
3150
+ }], ctorParameters: () => [], propDecorators: { html: [{ type: i0.Input, args: [{ isSignal: true, alias: "html", required: false }] }], showImages: [{ type: i0.Input, args: [{ isSignal: true, alias: "showImages", required: false }] }], approvedImageOrigins: [{ type: i0.Input, args: [{ isSignal: true, alias: "approvedImageOrigins", required: false }] }], fragment: [{ type: i0.Input, args: [{ isSignal: true, alias: "fragment", required: false }] }], direction: [{ type: i0.Input, args: [{ isSignal: true, alias: "direction", required: false }] }], title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], height: [{ type: i0.Input, args: [{ isSignal: true, alias: "height", required: false }] }], frame: [{ type: i0.ViewChild, args: ['frame', { isSignal: true }] }] } });
3151
+ const BLOCKED_ELEMENTS = 'script, iframe, frame, object, embed, form, link, base, meta';
3152
+ const ORIGIN_PATTERN = /^https:\/\/[a-z0-9.-]+(:\d{1,5})?$/i;
3153
+ function buildEmailPreviewDocument(html, options) {
3154
+ const source = options.fragment
3155
+ ? `<!doctype html><html><head></head><body>${html}</body></html>`
3156
+ : html;
3157
+ const doc = new DOMParser().parseFromString(source, 'text/html');
3158
+ doc.querySelectorAll(BLOCKED_ELEMENTS).forEach((node) => node.remove());
3159
+ doc.querySelectorAll('*').forEach((element) => {
3160
+ for (const attribute of Array.from(element.attributes)) {
3161
+ if (attribute.name.toLowerCase().startsWith('on')) {
3162
+ element.removeAttribute(attribute.name);
3163
+ }
3164
+ }
3165
+ });
3166
+ doc.querySelectorAll('a').forEach((anchor) => {
3167
+ anchor.removeAttribute('href');
3168
+ anchor.removeAttribute('target');
3169
+ anchor.setAttribute('aria-disabled', 'true');
3170
+ });
3171
+ const origins = options.approvedImageOrigins.filter((origin) => ORIGIN_PATTERN.test(origin));
3172
+ const imageSources = options.showImages && origins.length > 0 ? origins.join(' ') : "'none'";
3173
+ const csp = doc.createElement('meta');
3174
+ csp.setAttribute('http-equiv', 'Content-Security-Policy');
3175
+ csp.setAttribute('content', `default-src 'none'; style-src 'unsafe-inline'; img-src ${imageSources}; font-src 'none'; form-action 'none'`);
3176
+ const referrer = doc.createElement('meta');
3177
+ referrer.setAttribute('name', 'referrer');
3178
+ referrer.setAttribute('content', 'no-referrer');
3179
+ doc.head.prepend(csp, referrer);
3180
+ if (options.fragment) {
3181
+ const style = doc.createElement('style');
3182
+ style.textContent =
3183
+ 'body{margin:16px;font-family:Tahoma,Arial,sans-serif;font-size:14px;line-height:1.6;color:#1f2937;background:#fff}';
3184
+ doc.head.append(style);
3185
+ if (options.direction) {
3186
+ doc.body.setAttribute('dir', options.direction);
3187
+ }
3188
+ }
3189
+ return `<!doctype html>${doc.documentElement.outerHTML}`;
3190
+ }
3191
+
3192
+ /**
3193
+ * Pure helpers for `mt-email-visual-editor`: building the editing document and
3194
+ * turning it back into the exact source format the backend expects.
3195
+ *
3196
+ * In the canvas, `{{token}}` placeholders and the layout's `{content}` slot are
3197
+ * shown as non-editable chips; serialization turns every chip back into its
3198
+ * literal text, so the stored HTML never contains editor markup.
3199
+ */
3200
+ const TOKEN_PATTERN = /\{\{\s*([A-Za-z0-9_.]+)\s*\}\}/g;
3201
+ /** Canvas-only highlight of the image being edited; never serialized. */
3202
+ const SELECTED_CLASS = 'mt-selected';
3203
+ const TOKEN_ATTRIBUTE = 'data-mt-token';
3204
+ const SLOT_ATTRIBUTE = 'data-mt-slot';
3205
+ const STRIPPED_ELEMENTS = 'script, iframe, frame, object, embed, form, link, base, meta, style';
3206
+ /** A full HTML document cannot round-trip through a body-only canvas. */
3207
+ function isFullDocument(source) {
3208
+ return /<\s*(html|head|body)\b/i.test(source);
3209
+ }
3210
+ /**
3211
+ * Images load in the canvas only from the origins the server approves (the
3212
+ * same ones an email may use); anything else stays a sized placeholder box.
3213
+ */
3214
+ function buildEditingDocument(source, editable, direction = null, imageOrigins = []) {
3215
+ const dir = direction ? ` dir="${direction}"` : '';
3216
+ const origins = canvasImageOrigins(imageOrigins);
3217
+ const imgSrc = origins.length > 0 ? origins.join(' ') : "'none'";
3218
+ const loaded = origins.map((origin) => `img[src^="${origin}/"]`).join(',');
3219
+ const style = loaded
3220
+ ? `${CANVAS_STYLE}${loaded}{background:none;outline:none}${CANVAS_IMAGE_STATE_STYLE}`
3221
+ : `${CANVAS_STYLE}${CANVAS_IMAGE_STATE_STYLE}`;
3222
+ return `<!doctype html><html${dir}><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src ${imgSrc}; font-src 'none'; form-action 'none'"><style>${style}</style></head><body contenteditable="${editable}" spellcheck="true">${source}</body></html>`;
3223
+ }
3224
+ /** Only well-formed HTTPS origins reach the canvas CSP and style. */
3225
+ function canvasImageOrigins(origins) {
3226
+ const result = [];
3227
+ for (const origin of origins) {
3228
+ try {
3229
+ const url = new URL(origin.trim());
3230
+ if (url.protocol === 'https:' && !result.includes(url.origin)) {
3231
+ result.push(url.origin);
3232
+ }
3233
+ }
3234
+ catch {
3235
+ // Not an origin; leave its images blocked.
3236
+ }
3237
+ }
3238
+ return result;
3239
+ }
3240
+ /** Strips anything active and turns tokens / the slot into chips. */
3241
+ function decorateCanvas(doc, placeholders, slotLabel) {
3242
+ doc.body.querySelectorAll(STRIPPED_ELEMENTS).forEach((node) => node.remove());
3243
+ doc.body.querySelectorAll('*').forEach((element) => {
3244
+ for (const attribute of Array.from(element.attributes)) {
3245
+ if (attribute.name.toLowerCase().startsWith('on')) {
3246
+ element.removeAttribute(attribute.name);
3247
+ }
3248
+ }
3249
+ });
3250
+ const labels = new Map(placeholders.map((item) => [item.key, item.label]));
3251
+ const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
3252
+ const textNodes = [];
3253
+ while (walker.nextNode())
3254
+ textNodes.push(walker.currentNode);
3255
+ for (const node of textNodes) {
3256
+ const text = node.data;
3257
+ if (!text.includes('{'))
3258
+ continue;
3259
+ const pattern = new RegExp(`${TOKEN_PATTERN.source}|${escapeRegExp(EMAIL_LAYOUT_CONTENT_SLOT)}`, 'g');
3260
+ if (!pattern.test(text))
3261
+ continue;
3262
+ pattern.lastIndex = 0;
3263
+ const fragment = doc.createDocumentFragment();
3264
+ let last = 0;
3265
+ for (const match of text.matchAll(pattern)) {
3266
+ const index = match.index ?? 0;
3267
+ if (index > last)
3268
+ fragment.append(text.slice(last, index));
3269
+ fragment.append(match[0] === EMAIL_LAYOUT_CONTENT_SLOT
3270
+ ? createSlotChip(doc, slotLabel)
3271
+ : createTokenChip(doc, match[1], labels.get(match[1]) ?? match[1]));
3272
+ last = index + match[0].length;
3273
+ }
3274
+ if (last < text.length)
3275
+ fragment.append(text.slice(last));
3276
+ node.replaceWith(fragment);
3277
+ }
3278
+ }
3279
+ function createTokenChip(doc, key, label) {
3280
+ const chip = doc.createElement('span');
3281
+ chip.setAttribute(TOKEN_ATTRIBUTE, key);
3282
+ chip.setAttribute('contenteditable', 'false');
3283
+ chip.className = 'mt-token';
3284
+ chip.textContent = label;
3285
+ return chip;
3286
+ }
3287
+ function createSlotChip(doc, label) {
3288
+ const chip = doc.createElement('span');
3289
+ chip.setAttribute(SLOT_ATTRIBUTE, '');
3290
+ chip.setAttribute('contenteditable', 'false');
3291
+ chip.className = 'mt-slot';
3292
+ chip.textContent = label;
3293
+ return chip;
3294
+ }
3295
+ /** The canvas body back to source: chips become their literal text again. */
3296
+ function serializeCanvas(body) {
3297
+ const clone = body.cloneNode(true);
3298
+ const doc = body.ownerDocument;
3299
+ clone.querySelectorAll(`[${TOKEN_ATTRIBUTE}]`).forEach((chip) => {
3300
+ chip.replaceWith(doc.createTextNode(`{{${chip.getAttribute(TOKEN_ATTRIBUTE)}}}`));
3301
+ });
3302
+ clone.querySelectorAll(`[${SLOT_ATTRIBUTE}]`).forEach((chip) => {
3303
+ chip.replaceWith(doc.createTextNode(EMAIL_LAYOUT_CONTENT_SLOT));
3304
+ });
3305
+ clone.querySelectorAll(`.${SELECTED_CLASS}`).forEach((element) => {
3306
+ element.classList.remove(SELECTED_CLASS);
3307
+ if (element.classList.length === 0)
3308
+ element.removeAttribute('class');
3309
+ });
3310
+ return clone.innerHTML;
3311
+ }
3312
+ /** Static links must be HTTPS; the only dynamic link is the whole `{{webUrl}}`. */
3313
+ function isAllowedLink(value) {
3314
+ const trimmed = value.trim();
3315
+ if (/^\{\{\s*webUrl\s*\}\}$/i.test(trimmed))
3316
+ return true;
3317
+ try {
3318
+ return new URL(trimmed).protocol === 'https:';
3319
+ }
3320
+ catch {
3321
+ return false;
3322
+ }
3323
+ }
3324
+ const EMAIL_IMAGE_MIN_SIZE = 16;
3325
+ const EMAIL_IMAGE_MAX_SIZE = 1200;
3326
+ /**
3327
+ * The backend image policy, checked before insertion so the user is told why
3328
+ * instead of seeing the image silently removed on save: an approved HTTPS
3329
+ * origin, no query or fragment, descriptive alt text, and explicit width and
3330
+ * height between 16 and 1200. Inline image styles are never written.
3331
+ */
3332
+ function validateEmailImage(image, approvedOrigins) {
3333
+ if (approvedOrigins.length === 0)
3334
+ return 'no-origins';
3335
+ let url;
3336
+ try {
3337
+ url = new URL(image.src.trim());
3338
+ }
3339
+ catch {
3340
+ return 'url';
3341
+ }
3342
+ if (url.protocol !== 'https:' || url.search || url.hash)
3343
+ return 'url';
3344
+ const approved = approvedOrigins.some((origin) => origin.replace(/\/$/, '').toLowerCase() === url.origin.toLowerCase());
3345
+ if (!approved)
3346
+ return 'origin';
3347
+ if (!image.alt.trim())
3348
+ return 'alt';
3349
+ const inRange = (value) => value !== null &&
3350
+ Number.isInteger(value) &&
3351
+ value >= EMAIL_IMAGE_MIN_SIZE &&
3352
+ value <= EMAIL_IMAGE_MAX_SIZE;
3353
+ if (!inRange(image.width) || !inRange(image.height))
3354
+ return 'size';
3355
+ return null;
3356
+ }
3357
+ function buildEmailImage(doc, image) {
3358
+ const element = doc.createElement('img');
3359
+ applyEmailImage(element, image);
3360
+ return element;
3361
+ }
3362
+ function applyEmailImage(element, image) {
3363
+ element.setAttribute('src', image.src.trim());
3364
+ element.setAttribute('alt', image.alt.trim());
3365
+ element.setAttribute('width', String(image.width));
3366
+ element.setAttribute('height', String(image.height));
3367
+ element.removeAttribute('style');
3368
+ }
3369
+ const TEXT_COLORS = [
3370
+ { key: 'black', value: '#111827' },
3371
+ { key: 'gray', value: '#6b7280' },
3372
+ { key: 'red', value: '#b91c1c' },
3373
+ { key: 'orange', value: '#c2410c' },
3374
+ { key: 'amber', value: '#a16207' },
3375
+ { key: 'green', value: '#15803d' },
3376
+ { key: 'teal', value: '#0f766e' },
3377
+ { key: 'blue', value: '#1d4ed8' },
3378
+ { key: 'purple', value: '#6d28d9' },
3379
+ { key: 'pink', value: '#be185d' },
3380
+ ];
3381
+ const HIGHLIGHT_COLORS = [
3382
+ { key: 'yellow', value: '#fef08a' },
3383
+ { key: 'green', value: '#bbf7d0' },
3384
+ { key: 'teal', value: '#99f6e4' },
3385
+ { key: 'blue', value: '#bfdbfe' },
3386
+ { key: 'purple', value: '#e9d5ff' },
3387
+ { key: 'pink', value: '#fbcfe8' },
3388
+ { key: 'red', value: '#fecaca' },
3389
+ { key: 'orange', value: '#fed7aa' },
3390
+ { key: 'gray', value: '#e5e7eb' },
3391
+ ];
3392
+ /** `normal` clears the size, so the text follows the shared design. */
3393
+ const TEXT_SIZES = [
3394
+ { key: 'small', px: 13 },
3395
+ { key: 'normal', px: null },
3396
+ { key: 'large', px: 20 },
3397
+ { key: 'huge', px: 26 },
3398
+ ];
3399
+ /**
3400
+ * `execCommand('fontSize')` can only write `<font size>`, which the policy
3401
+ * strips; the editor applies it with a marker size and swaps each marker for a
3402
+ * span with a pixel size.
3403
+ */
3404
+ const FONT_SIZE_MARKER = '7';
3405
+ function replaceFontSizeMarkers(doc, px) {
3406
+ doc.body
3407
+ .querySelectorAll(`font[size="${FONT_SIZE_MARKER}"]`)
3408
+ .forEach((font) => {
3409
+ const span = doc.createElement('span');
3410
+ span.style.fontSize = `${px}px`;
3411
+ while (font.firstChild)
3412
+ span.append(font.firstChild);
3413
+ // The new size replaces any size already applied inside the selection.
3414
+ span.querySelectorAll('span').forEach((inner) => {
3415
+ const element = inner;
3416
+ element.style.removeProperty('font-size');
3417
+ unwrapIfBare(element);
3418
+ });
3419
+ font.replaceWith(span);
3420
+ });
3421
+ }
3422
+ /**
3423
+ * The browser writes strikethrough as `<strike>` (or, with CSS, as
3424
+ * `text-decoration-line`); the policy keeps neither, but keeps `<s>`.
3425
+ */
3426
+ function replaceStrikeTags(doc) {
3427
+ doc.body.querySelectorAll('strike').forEach((strike) => {
3428
+ const s = doc.createElement('s');
3429
+ while (strike.firstChild)
3430
+ s.append(strike.firstChild);
3431
+ strike.replaceWith(s);
3432
+ });
3433
+ }
3434
+ /**
3435
+ * Removes one inline style (`color`, `background-color`, `font-size`) from the
3436
+ * spans the range touches; a span left with no attributes is unwrapped.
3437
+ */
3438
+ function clearInlineStyle(doc, range, property) {
3439
+ const touched = Array.from(doc.body.querySelectorAll('span, font')).filter((element) => range.intersectsNode(element));
3440
+ for (const element of touched) {
3441
+ const html = element;
3442
+ html.style.removeProperty(property);
3443
+ if (property === 'color' && element.tagName === 'FONT') {
3444
+ element.removeAttribute('color');
3445
+ }
3446
+ unwrapIfBare(html);
3447
+ }
3448
+ }
3449
+ function unwrapIfBare(element) {
3450
+ // Removing the last style property leaves `style=""` behind.
3451
+ if (element.getAttribute('style')?.trim() === '') {
3452
+ element.removeAttribute('style');
3453
+ }
3454
+ if (element.attributes.length > 0 || !element.parentNode)
3455
+ return;
3456
+ if (element.tagName !== 'SPAN' && element.tagName !== 'FONT')
3457
+ return;
3458
+ element.replaceWith(...Array.from(element.childNodes));
3459
+ }
3460
+ const BILINGUAL_STARTER = '<div lang="en" dir="ltr"><p><br></p></div><div lang="ar" dir="rtl"><p><br></p></div>';
3461
+ function escapeRegExp(value) {
3462
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3463
+ }
3464
+ const CANVAS_STYLE = [
3465
+ 'html{background:#fff}',
3466
+ 'body{margin:0;padding:16px 20px;min-height:calc(100vh - 32px);font-family:Tahoma,Arial,sans-serif;font-size:15px;line-height:1.7;color:#1f2937;outline:none;overflow-wrap:anywhere}',
3467
+ 'h1{font-size:24px;margin:0 0 12px}h2{font-size:20px;margin:0 0 12px}h3{font-size:17px;margin:0 0 10px}',
3468
+ 'p{margin:0 0 12px}ul,ol{margin:0 0 12px;padding-inline-start:24px}',
3469
+ 'a{color:#1d4ed8;text-decoration:underline;cursor:text}',
3470
+ // Cell outlines only on hover: always-on outlines made layouts look broken.
3471
+ 'table{border-collapse:collapse}td:hover,th:hover{outline:1px dashed #cbd5e1;outline-offset:-1px}',
3472
+ // An image from an origin that is not approved never loads (CSP); it shows
3473
+ // as a sized box with its description.
3474
+ 'img{display:inline-block;max-width:100%;background:#f1f5f9;outline:1px dashed #94a3b8;outline-offset:-1px;color:#475569;font:12px/1.4 system-ui,sans-serif;cursor:pointer}',
3475
+ '.mt-token{display:inline-block;margin:0 1px;padding:0 6px;border:1px solid #c7d2fe;border-radius:4px;background:#eef2ff;color:#3730a3;font:12px/1.7 system-ui,sans-serif;white-space:nowrap;vertical-align:baseline;user-select:all}',
3476
+ '.mt-slot{display:block;margin:4px 0;padding:14px;border:1px dashed #94a3b8;border-radius:6px;background:#f8fafc;color:#475569;font:13px/1.5 system-ui,sans-serif;text-align:center;user-select:none}',
3477
+ ].join('');
3478
+ /** After the per-origin rules, so hover and selection always show. */
3479
+ const CANVAS_IMAGE_STATE_STYLE = 'img:hover{outline:1px dashed #94a3b8}img.mt-selected{outline:2px solid #2563eb}';
3480
+
3481
+ /**
3482
+ * Visual editor for notification email HTML (event content and the shared
3483
+ * base layout), with an HTML tab for the exact source.
3484
+ *
3485
+ * The canvas is a sandboxed, script-free iframe (`sandbox="allow-same-origin"`
3486
+ * so the host can edit it; nothing inside it can run) with its own CSP —
3487
+ * images and network stay blocked, as the contract asks. The browser's editing
3488
+ * DOM keeps tables, inline styles and `lang` / `dir`; `{{tokens}}` and the
3489
+ * layout's `{content}` slot are shown as chips and written back as literal
3490
+ * text. Opening content, or switching between Visual and HTML, never emits a
3491
+ * value: only a real edit does, so nothing is rewritten or marked unsaved just
3492
+ * by looking at it.
3493
+ */
3494
+ class EmailVisualEditor {
3495
+ transloco = inject(TranslocoService);
3496
+ destroyRef = inject(DestroyRef);
3497
+ label = input(...(ngDevMode ? [undefined, { debugName: "label" }] : /* istanbul ignore next */ []));
3498
+ hint = input(...(ngDevMode ? [undefined, { debugName: "hint" }] : /* istanbul ignore next */ []));
3499
+ /** Placeholders the content may use; their labels name the chips. */
3500
+ placeholders = input([], ...(ngDevMode ? [{ debugName: "placeholders" }] : /* istanbul ignore next */ []));
3501
+ /** Editing the shared base layout: `{content}` is shown as a protected slot. */
3502
+ layout = input(false, ...(ngDevMode ? [{ debugName: "layout" }] : /* istanbul ignore next */ []));
3503
+ height = input('24rem', ...(ngDevMode ? [{ debugName: "height" }] : /* istanbul ignore next */ []));
3504
+ /** Reading direction of the whole canvas (a single-language field). */
3505
+ direction = input(null, ...(ngDevMode ? [{ debugName: "direction" }] : /* istanbul ignore next */ []));
3506
+ /** Offer the English + Arabic starter on an empty email. */
3507
+ starter = input(true, ...(ngDevMode ? [{ debugName: "starter" }] : /* istanbul ignore next */ []));
3508
+ /** The canvas or the HTML source received focus. */
3509
+ focused = output();
3510
+ /**
3511
+ * HTTPS origins the server accepts images from (`approvedImageOrigins` on the
3512
+ * layout read / preview). Empty means images are not allowed at all.
3513
+ */
3514
+ approvedImageOrigins = input([], ...(ngDevMode ? [{ debugName: "approvedImageOrigins" }] : /* istanbul ignore next */ []));
3515
+ imageOpen = signal(false, ...(ngDevMode ? [{ debugName: "imageOpen" }] : /* istanbul ignore next */ []));
3516
+ imageError = signal(null, ...(ngDevMode ? [{ debugName: "imageError" }] : /* istanbul ignore next */ []));
3517
+ editingImage = signal(null, ...(ngDevMode ? [{ debugName: "editingImage" }] : /* istanbul ignore next */ []));
3518
+ imageForm = {
3519
+ src: new FormControl('', { nonNullable: true }),
3520
+ alt: new FormControl('', { nonNullable: true }),
3521
+ width: new FormControl(null),
3522
+ height: new FormControl(null),
3523
+ };
3524
+ imageMin = EMAIL_IMAGE_MIN_SIZE;
3525
+ imageMax = EMAIL_IMAGE_MAX_SIZE;
3526
+ imageOriginsLabel = computed(() => this.approvedImageOrigins().join(', '), ...(ngDevMode ? [{ debugName: "imageOriginsLabel" }] : /* istanbul ignore next */ []));
3527
+ imageErrorText = computed(() => {
3528
+ const error = this.imageError();
3529
+ if (!error)
3530
+ return '';
3531
+ this.lang();
3532
+ return this.transloco.translate(`email-delivery.editor.image.errors.${error}`, {
3533
+ origins: this.imageOriginsLabel(),
3534
+ min: EMAIL_IMAGE_MIN_SIZE,
3535
+ max: EMAIL_IMAGE_MAX_SIZE,
3536
+ });
3537
+ }, ...(ngDevMode ? [{ debugName: "imageErrorText" }] : /* istanbul ignore next */ []));
3538
+ mode = signal('design', ...(ngDevMode ? [{ debugName: "mode" }] : /* istanbul ignore next */ []));
3539
+ value = signal('', ...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
3540
+ disabled = signal(false, ...(ngDevMode ? [{ debugName: "disabled" }] : /* istanbul ignore next */ []));
3541
+ linkOpen = signal(false, ...(ngDevMode ? [{ debugName: "linkOpen" }] : /* istanbul ignore next */ []));
3542
+ /** The colour / size row under the toolbar. */
3543
+ stylePanel = signal(null, ...(ngDevMode ? [{ debugName: "stylePanel" }] : /* istanbul ignore next */ []));
3544
+ textColors = TEXT_COLORS;
3545
+ highlightColors = HIGHLIGHT_COLORS;
3546
+ textSizes = TEXT_SIZES;
3547
+ linkError = signal(false, ...(ngDevMode ? [{ debugName: "linkError" }] : /* istanbul ignore next */ []));
3548
+ linkControl = new FormControl('', { nonNullable: true });
3549
+ sourceControl = new FormControl('', { nonNullable: true });
3550
+ designBlocked = computed(() => isFullDocument(this.value()), ...(ngDevMode ? [{ debugName: "designBlocked" }] : /* istanbul ignore next */ []));
3551
+ effectiveMode = computed(() => this.designBlocked() ? 'html' : this.mode(), ...(ngDevMode ? [{ debugName: "effectiveMode" }] : /* istanbul ignore next */ []));
3552
+ isEmpty = computed(() => this.value().replace(/<[^>]*>|&nbsp;|\s/g, '') === '', ...(ngDevMode ? [{ debugName: "isEmpty" }] : /* istanbul ignore next */ []));
3553
+ modeOptions = computed(() => {
3554
+ this.lang();
3555
+ return [
3556
+ { label: this.t('design'), value: 'design', icon: 'editor.pencil-01' },
3557
+ { label: this.t('html'), value: 'html', icon: 'dev.code-02' },
3558
+ ];
3559
+ }, ...(ngDevMode ? [{ debugName: "modeOptions" }] : /* istanbul ignore next */ []));
3560
+ blockActions = [
3561
+ {
3562
+ key: 'paragraph',
3563
+ icon: 'editor.pilcrow-01',
3564
+ run: () => this.formatBlock('p'),
3565
+ },
3566
+ { key: 'heading', text: 'H2', run: () => this.formatBlock('h2') },
3567
+ { key: 'subheading', text: 'H3', run: () => this.formatBlock('h3') },
3568
+ { key: 'quote', text: '“', run: () => this.formatBlock('blockquote') },
3569
+ ];
3570
+ inlineActions = [
3571
+ { key: 'bold', icon: 'editor.bold-01', run: () => this.exec('bold') },
3572
+ { key: 'italic', icon: 'editor.italic-01', run: () => this.exec('italic') },
3573
+ {
3574
+ key: 'underline',
3575
+ icon: 'editor.underline-01',
3576
+ run: () => this.exec('underline'),
3577
+ },
3578
+ {
3579
+ key: 'strike',
3580
+ icon: 'editor.strikethrough-01',
3581
+ run: () => this.strike(),
3582
+ },
3583
+ ];
3584
+ styleActions = [
3585
+ {
3586
+ key: 'text-color',
3587
+ icon: 'editor.palette',
3588
+ run: () => this.toggleStylePanel('color'),
3589
+ },
3590
+ {
3591
+ key: 'highlight',
3592
+ icon: 'editor.roller-brush',
3593
+ run: () => this.toggleStylePanel('highlight'),
3594
+ },
3595
+ {
3596
+ key: 'size',
3597
+ icon: 'editor.type-01',
3598
+ run: () => this.toggleStylePanel('size'),
3599
+ },
3600
+ ];
3601
+ alignActions = [
3602
+ {
3603
+ key: 'align-left',
3604
+ icon: 'editor.align-left',
3605
+ run: () => this.execWithCss('justifyLeft'),
3606
+ },
3607
+ {
3608
+ key: 'align-center',
3609
+ icon: 'editor.align-center',
3610
+ run: () => this.execWithCss('justifyCenter'),
3611
+ },
3612
+ {
3613
+ key: 'align-right',
3614
+ icon: 'editor.align-right',
3615
+ run: () => this.execWithCss('justifyRight'),
3616
+ },
3617
+ ];
3618
+ listActions = [
3619
+ {
3620
+ key: 'bullets',
3621
+ icon: 'editor.dotpoints-01',
3622
+ run: () => this.exec('insertUnorderedList'),
3623
+ },
3624
+ {
3625
+ key: 'numbers',
3626
+ icon: 'layout.list',
3627
+ run: () => this.exec('insertOrderedList'),
3628
+ },
3629
+ {
3630
+ key: 'divider',
3631
+ icon: 'general.minus',
3632
+ run: () => this.exec('insertHorizontalRule'),
3633
+ },
3634
+ ];
3635
+ directionActions = [
3636
+ { key: 'ltr', text: 'EN', run: () => this.setDirection('ltr') },
3637
+ { key: 'rtl', text: 'ع', run: () => this.setDirection('rtl') },
3638
+ ];
3639
+ historyActions = [
3640
+ {
3641
+ key: 'clear',
3642
+ icon: 'editor.eraser',
3643
+ run: () => this.exec('removeFormat'),
3644
+ },
3645
+ { key: 'undo', icon: 'arrow.flip-backward', run: () => this.exec('undo') },
3646
+ { key: 'redo', icon: 'arrow.flip-forward', run: () => this.exec('redo') },
3647
+ ];
3648
+ frame = viewChild('frame', ...(ngDevMode ? [{ debugName: "frame" }] : /* istanbul ignore next */ []));
3649
+ sourceHost = viewChild('sourceHost', ...(ngDevMode ? [{ debugName: "sourceHost" }] : /* istanbul ignore next */ []));
3650
+ lang = signal(this.transloco.getActiveLang(), ...(ngDevMode ? [{ debugName: "lang" }] : /* istanbul ignore next */ []));
3651
+ /** The last value this editor emitted — writes of it are not re-rendered. */
3652
+ lastEmitted = null;
3653
+ renderedFrame = null;
3654
+ renderedKey = '';
3655
+ savedRange = null;
3656
+ /** Set while a multi-step command runs, so it emits once at the end. */
3657
+ batching = false;
3658
+ onChange = () => undefined;
3659
+ onTouched = () => undefined;
3660
+ detachCanvas = null;
3661
+ constructor() {
3662
+ const langSub = this.transloco.langChanges$.subscribe((lang) => this.lang.set(lang));
3663
+ this.destroyRef.onDestroy(() => {
3664
+ langSub.unsubscribe();
3665
+ this.detachCanvas?.();
3666
+ });
3667
+ // (Re)build the canvas for a new frame, when the chip labels change (the
3668
+ // field catalog arrives after the content, or the language switches) and
3669
+ // when the approved image origins arrive.
3670
+ effect(() => {
3671
+ const frame = this.frame()?.nativeElement ?? null;
3672
+ if (!frame || this.effectiveMode() !== 'design')
3673
+ return;
3674
+ const renderKey = `${this.lang()}|${this.placeholders()
3675
+ .map((item) => `${item.key}=${item.label}`)
3676
+ .join(',')}|${this.approvedImageOrigins().join(',')}`;
3677
+ untracked(() => {
3678
+ if (frame !== this.renderedFrame || renderKey !== this.renderedKey) {
3679
+ this.renderedKey = renderKey;
3680
+ this.renderCanvas(frame);
3681
+ }
3682
+ });
3683
+ });
3684
+ this.sourceControl.valueChanges
3685
+ .pipe(takeUntilDestroyed())
3686
+ .subscribe((value) => this.emit(value));
3687
+ }
3688
+ // ==========================================================================
3689
+ // ControlValueAccessor
3690
+ // ==========================================================================
3691
+ writeValue(value) {
3692
+ const next = value ?? '';
3693
+ this.value.set(next);
3694
+ if (this.sourceControl.value !== next) {
3695
+ this.sourceControl.setValue(next, { emitEvent: false });
3696
+ }
3697
+ if (next === this.lastEmitted)
3698
+ return;
3699
+ this.lastEmitted = null;
3700
+ const frame = this.frame()?.nativeElement;
3701
+ if (frame && this.effectiveMode() === 'design')
3702
+ this.renderCanvas(frame);
3703
+ }
3704
+ registerOnChange(fn) {
3705
+ this.onChange = fn;
3706
+ }
3707
+ registerOnTouched(fn) {
3708
+ this.onTouched = fn;
3709
+ }
3710
+ setDisabledState(disabled) {
3711
+ this.disabled.set(disabled);
3712
+ const body = this.renderedFrame?.contentDocument?.body;
3713
+ if (body)
3714
+ body.contentEditable = String(!disabled);
3715
+ if (disabled) {
3716
+ this.sourceControl.disable({ emitEvent: false });
3717
+ }
3718
+ else {
3719
+ this.sourceControl.enable({ emitEvent: false });
3720
+ }
3721
+ }
3722
+ // ==========================================================================
3723
+ // Public API
3724
+ // ==========================================================================
3725
+ /** Inserts `{{key}}` where the cursor is (as a chip in Visual, as text in HTML). */
3726
+ insertToken(key, label) {
3727
+ if (this.disabled())
3728
+ return;
3729
+ const text = `{{${key}}}`;
3730
+ if (this.effectiveMode() === 'html') {
3731
+ const textarea = this.sourceHost()?.nativeElement.querySelector('textarea');
3732
+ const current = this.sourceControl.value;
3733
+ const start = textarea?.selectionStart ?? current.length;
3734
+ const end = textarea?.selectionEnd ?? start;
3735
+ this.sourceControl.setValue(current.slice(0, start) + text + current.slice(end));
3736
+ setTimeout(() => {
3737
+ textarea?.focus();
3738
+ textarea?.setSelectionRange(start + text.length, start + text.length);
3739
+ });
3740
+ return;
3741
+ }
3742
+ const doc = this.canvasDocument();
3743
+ if (!doc)
3744
+ return;
3745
+ const chip = createTokenChip(doc, key, label ??
3746
+ this.placeholders().find((item) => item.key === key)?.label ??
3747
+ key);
3748
+ this.restoreSelection();
3749
+ doc.execCommand('insertHTML', false, `${chip.outerHTML}&nbsp;`);
3750
+ }
3751
+ // ==========================================================================
3752
+ // Toolbar
3753
+ // ==========================================================================
3754
+ onModeChange(mode) {
3755
+ if (mode !== 'design' && mode !== 'html')
3756
+ return;
3757
+ if (mode === this.mode())
3758
+ return;
3759
+ this.mode.set(mode);
3760
+ // A fresh frame is created for Visual; the effect renders it.
3761
+ this.renderedFrame = null;
3762
+ }
3763
+ startBilingual() {
3764
+ const doc = this.canvasDocument();
3765
+ if (!doc)
3766
+ return;
3767
+ doc.body.innerHTML = BILINGUAL_STARTER;
3768
+ this.placeCaret(doc, doc.body.querySelector('p'));
3769
+ this.emitFromCanvas();
3770
+ }
3771
+ toggleStylePanel(panel) {
3772
+ this.linkOpen.set(false);
3773
+ this.imageOpen.set(false);
3774
+ this.stylePanel.update((open) => (open === panel ? null : panel));
3775
+ }
3776
+ applyColor(swatch) {
3777
+ const panel = this.stylePanel();
3778
+ this.execWithCss(panel === 'highlight' ? 'hiliteColor' : 'foreColor', swatch.value);
3779
+ this.stylePanel.set(null);
3780
+ }
3781
+ removeColor() {
3782
+ const property = this.stylePanel() === 'highlight' ? 'background-color' : 'color';
3783
+ this.clearStyle(property);
3784
+ this.stylePanel.set(null);
3785
+ }
3786
+ applySize(size) {
3787
+ const px = this.textSizes.find((item) => item.key === size)?.px ?? null;
3788
+ if (px === null) {
3789
+ this.clearStyle('font-size');
3790
+ }
3791
+ else {
3792
+ const doc = this.canvasDocument();
3793
+ if (!doc || this.disabled())
3794
+ return;
3795
+ this.restoreSelection();
3796
+ this.batching = true;
3797
+ try {
3798
+ doc.execCommand('styleWithCSS', false, 'false');
3799
+ doc.execCommand('fontSize', false, FONT_SIZE_MARKER);
3800
+ replaceFontSizeMarkers(doc, px);
3801
+ }
3802
+ finally {
3803
+ this.batching = false;
3804
+ }
3805
+ this.emitFromCanvas();
3806
+ }
3807
+ this.stylePanel.set(null);
3808
+ }
3809
+ swatchLabel(swatch) {
3810
+ return this.t(`style.colors.${swatch.key}`);
3811
+ }
3812
+ toggleLink() {
3813
+ this.stylePanel.set(null);
3814
+ this.imageOpen.set(false);
3815
+ this.linkError.set(false);
3816
+ this.linkControl.setValue('');
3817
+ this.linkOpen.update((open) => !open);
3818
+ }
3819
+ useRecordLink() {
3820
+ this.linkControl.setValue('{{webUrl}}');
3821
+ this.applyLink();
3822
+ }
3823
+ applyLink() {
3824
+ const url = this.linkControl.value.trim();
3825
+ if (!isAllowedLink(url)) {
3826
+ this.linkError.set(true);
3827
+ return;
3828
+ }
3829
+ const canonical = /^\{\{\s*webUrl\s*\}\}$/i.test(url) ? '{{webUrl}}' : url;
3830
+ this.exec('createLink', canonical);
3831
+ this.linkOpen.set(false);
3832
+ }
3833
+ removeLink() {
3834
+ this.exec('unlink');
3835
+ this.linkOpen.set(false);
3836
+ }
3837
+ /**
3838
+ * Opens the image row: for the image selected in the canvas (to change it),
3839
+ * otherwise for a new image at the cursor.
3840
+ */
3841
+ toggleImage() {
3842
+ if (this.imageOpen()) {
3843
+ this.imageOpen.set(false);
3844
+ return;
3845
+ }
3846
+ this.linkOpen.set(false);
3847
+ this.stylePanel.set(null);
3848
+ this.imageError.set(this.approvedImageOrigins().length === 0 ? 'no-origins' : null);
3849
+ const image = this.editingImage();
3850
+ this.imageForm.src.setValue(image?.getAttribute('src') ?? '');
3851
+ this.imageForm.alt.setValue(image?.getAttribute('alt') ?? '');
3852
+ this.imageForm.width.setValue(numberAttribute(image, 'width'));
3853
+ this.imageForm.height.setValue(numberAttribute(image, 'height'));
3854
+ this.imageOpen.set(true);
3855
+ }
3856
+ applyImage() {
3857
+ const draft = {
3858
+ src: this.imageForm.src.value,
3859
+ alt: this.imageForm.alt.value,
3860
+ width: this.imageForm.width.value,
3861
+ height: this.imageForm.height.value,
3862
+ };
3863
+ const error = validateEmailImage(draft, this.approvedImageOrigins());
3864
+ this.imageError.set(error);
3865
+ if (error)
3866
+ return;
3867
+ const doc = this.canvasDocument();
3868
+ if (!doc)
3869
+ return;
3870
+ const editing = this.editingImage();
3871
+ if (editing && doc.body.contains(editing)) {
3872
+ applyEmailImage(editing, draft);
3873
+ this.emitFromCanvas();
3874
+ }
3875
+ else {
3876
+ this.restoreSelection();
3877
+ doc.execCommand('insertHTML', false, buildEmailImage(doc, draft).outerHTML);
3878
+ }
3879
+ this.closeImage();
3880
+ }
3881
+ removeImage() {
3882
+ const editing = this.editingImage();
3883
+ if (editing) {
3884
+ editing.remove();
3885
+ this.emitFromCanvas();
3886
+ }
3887
+ this.closeImage();
3888
+ }
3889
+ closeImage() {
3890
+ this.imageOpen.set(false);
3891
+ this.selectImage(null);
3892
+ }
3893
+ actionLabel(key) {
3894
+ return this.t(`actions.${key}`);
3895
+ }
3896
+ // ==========================================================================
3897
+ // Canvas
3898
+ // ==========================================================================
3899
+ renderCanvas(frame) {
3900
+ this.detachCanvas?.();
3901
+ this.detachCanvas = null;
3902
+ this.renderedFrame = frame;
3903
+ this.savedRange = null;
3904
+ // Elements of the previous canvas document are gone.
3905
+ this.editingImage.set(null);
3906
+ this.imageOpen.set(false);
3907
+ this.stylePanel.set(null);
3908
+ const onLoad = () => this.attachCanvas(frame);
3909
+ frame.addEventListener('load', onLoad, { once: true });
3910
+ frame.srcdoc = buildEditingDocument(this.value(), !this.disabled(), this.direction(), this.approvedImageOrigins());
3911
+ }
3912
+ attachCanvas(frame) {
3913
+ const doc = frame.contentDocument;
3914
+ if (!doc || frame !== this.renderedFrame)
3915
+ return;
3916
+ decorateCanvas(doc, this.placeholders(), this.t('slot'));
3917
+ doc.execCommand('defaultParagraphSeparator', false, 'p');
3918
+ const onInput = () => {
3919
+ if (!this.batching)
3920
+ this.emitFromCanvas();
3921
+ };
3922
+ const onSelection = () => this.saveSelection();
3923
+ const onBlur = () => this.onTouched();
3924
+ const onFocus = () => this.focused.emit();
3925
+ const onClick = (event) => {
3926
+ const target = event.target;
3927
+ // A link in the canvas is text being edited, never a navigation.
3928
+ if (target?.closest('a'))
3929
+ event.preventDefault();
3930
+ // Clicking an image selects it, so the Image button edits it. Canvas
3931
+ // nodes live in the iframe's realm, so `instanceof` would never match.
3932
+ this.selectImage(target?.tagName === 'IMG' ? target : null);
3933
+ };
3934
+ const onPaste = (event) => {
3935
+ // Pasted documents bring foreign styles; keep plain text only.
3936
+ event.preventDefault();
3937
+ const text = event.clipboardData?.getData('text/plain') ?? '';
3938
+ doc.execCommand('insertText', false, text);
3939
+ };
3940
+ doc.addEventListener('input', onInput);
3941
+ doc.addEventListener('selectionchange', onSelection);
3942
+ doc.addEventListener('click', onClick);
3943
+ doc.addEventListener('paste', onPaste);
3944
+ doc.body.addEventListener('blur', onBlur);
3945
+ doc.addEventListener('focusin', onFocus);
3946
+ this.detachCanvas = () => {
3947
+ doc.removeEventListener('input', onInput);
3948
+ doc.removeEventListener('selectionchange', onSelection);
3949
+ doc.removeEventListener('click', onClick);
3950
+ doc.removeEventListener('paste', onPaste);
3951
+ doc.body.removeEventListener('blur', onBlur);
3952
+ doc.removeEventListener('focusin', onFocus);
3953
+ };
3954
+ }
3955
+ emitFromCanvas() {
3956
+ const doc = this.canvasDocument();
3957
+ if (!doc)
3958
+ return;
3959
+ const html = serializeCanvas(doc.body);
3960
+ this.sourceControl.setValue(html, { emitEvent: false });
3961
+ this.emit(html);
3962
+ }
3963
+ emit(value) {
3964
+ if (value === this.value())
3965
+ return;
3966
+ this.lastEmitted = value;
3967
+ this.value.set(value);
3968
+ this.onChange(value);
3969
+ }
3970
+ exec(command, argument) {
3971
+ const doc = this.canvasDocument();
3972
+ if (!doc || this.disabled())
3973
+ return;
3974
+ this.restoreSelection();
3975
+ doc.execCommand(command, false, argument);
3976
+ }
3977
+ /** Commands whose CSS form survives the email policy (spans with styles). */
3978
+ execWithCss(command, argument) {
3979
+ const doc = this.canvasDocument();
3980
+ if (!doc || this.disabled())
3981
+ return;
3982
+ this.restoreSelection();
3983
+ doc.execCommand('styleWithCSS', false, 'true');
3984
+ try {
3985
+ doc.execCommand(command, false, argument);
3986
+ }
3987
+ finally {
3988
+ doc.execCommand('styleWithCSS', false, 'false');
3989
+ }
3990
+ }
3991
+ /** Written as `<s>`: the policy strips `<strike>` and `text-decoration-line`. */
3992
+ strike() {
3993
+ const doc = this.canvasDocument();
3994
+ if (!doc || this.disabled())
3995
+ return;
3996
+ this.restoreSelection();
3997
+ this.batching = true;
3998
+ try {
3999
+ doc.execCommand('strikeThrough');
4000
+ replaceStrikeTags(doc);
4001
+ }
4002
+ finally {
4003
+ this.batching = false;
4004
+ }
4005
+ this.emitFromCanvas();
4006
+ }
4007
+ clearStyle(property) {
4008
+ const doc = this.canvasDocument();
4009
+ if (!doc || this.disabled())
4010
+ return;
4011
+ this.restoreSelection();
4012
+ const selection = doc.getSelection();
4013
+ if (!selection || selection.rangeCount === 0)
4014
+ return;
4015
+ clearInlineStyle(doc, selection.getRangeAt(0), property);
4016
+ this.emitFromCanvas();
4017
+ }
4018
+ formatBlock(tag) {
4019
+ this.exec('formatBlock', tag);
4020
+ }
4021
+ /** Sets the reading direction (and language) of the block under the cursor. */
4022
+ setDirection(direction) {
4023
+ const doc = this.canvasDocument();
4024
+ if (!doc || this.disabled())
4025
+ return;
4026
+ this.restoreSelection();
4027
+ const anchor = doc.getSelection()?.anchorNode ?? null;
4028
+ // nodeType, not `instanceof`: canvas nodes belong to the iframe's realm.
4029
+ const start = anchor?.nodeType === Node.ELEMENT_NODE
4030
+ ? anchor
4031
+ : (anchor?.parentElement ?? null);
4032
+ const block = start?.closest('p, h1, h2, h3, li, td, th, div');
4033
+ if (!block || block === doc.body || !doc.body.contains(block))
4034
+ return;
4035
+ block.setAttribute('dir', direction);
4036
+ block.setAttribute('lang', direction === 'rtl' ? 'ar' : 'en');
4037
+ this.emitFromCanvas();
4038
+ }
4039
+ /** Highlights (canvas-only class, never serialized) and selects one image. */
4040
+ selectImage(image) {
4041
+ const previous = this.editingImage();
4042
+ if (previous && previous !== image) {
4043
+ previous.classList.remove(SELECTED_CLASS);
4044
+ if (previous.classList.length === 0)
4045
+ previous.removeAttribute('class');
4046
+ }
4047
+ this.editingImage.set(image);
4048
+ if (!image)
4049
+ return;
4050
+ image.classList.add(SELECTED_CLASS);
4051
+ const doc = image.ownerDocument;
4052
+ const range = doc.createRange();
4053
+ range.selectNode(image);
4054
+ const selection = doc.getSelection();
4055
+ selection?.removeAllRanges();
4056
+ selection?.addRange(range);
4057
+ this.savedRange = range.cloneRange();
4058
+ }
4059
+ saveSelection() {
4060
+ const doc = this.canvasDocument();
4061
+ const selection = doc?.getSelection();
4062
+ if (!doc || !selection || selection.rangeCount === 0)
4063
+ return;
4064
+ const range = selection.getRangeAt(0);
4065
+ if (doc.body.contains(range.commonAncestorContainer)) {
4066
+ this.savedRange = range.cloneRange();
4067
+ }
4068
+ }
4069
+ restoreSelection() {
4070
+ const frame = this.renderedFrame;
4071
+ const doc = this.canvasDocument();
4072
+ if (!frame || !doc)
4073
+ return;
4074
+ frame.contentWindow?.focus();
4075
+ doc.body.focus();
4076
+ if (!this.savedRange)
4077
+ return;
4078
+ const selection = doc.getSelection();
4079
+ selection?.removeAllRanges();
4080
+ selection?.addRange(this.savedRange);
4081
+ }
4082
+ placeCaret(doc, element) {
4083
+ if (!element)
4084
+ return;
4085
+ const range = doc.createRange();
4086
+ range.selectNodeContents(element);
4087
+ range.collapse(true);
4088
+ const selection = doc.getSelection();
4089
+ selection?.removeAllRanges();
4090
+ selection?.addRange(range);
4091
+ this.savedRange = range.cloneRange();
4092
+ }
4093
+ canvasDocument() {
4094
+ return this.effectiveMode() === 'design'
4095
+ ? (this.renderedFrame?.contentDocument ?? null)
4096
+ : null;
4097
+ }
4098
+ t(key) {
4099
+ return this.transloco.translate(`email-delivery.editor.${key}`);
4100
+ }
4101
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: EmailVisualEditor, deps: [], target: i0.ɵɵFactoryTarget.Component });
4102
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.8", type: EmailVisualEditor, isStandalone: true, selector: "mt-email-visual-editor", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, hint: { classPropertyName: "hint", publicName: "hint", isSignal: true, isRequired: false, transformFunction: null }, placeholders: { classPropertyName: "placeholders", publicName: "placeholders", isSignal: true, isRequired: false, transformFunction: null }, layout: { classPropertyName: "layout", publicName: "layout", isSignal: true, isRequired: false, transformFunction: null }, height: { classPropertyName: "height", publicName: "height", isSignal: true, isRequired: false, transformFunction: null }, direction: { classPropertyName: "direction", publicName: "direction", isSignal: true, isRequired: false, transformFunction: null }, starter: { classPropertyName: "starter", publicName: "starter", isSignal: true, isRequired: false, transformFunction: null }, approvedImageOrigins: { classPropertyName: "approvedImageOrigins", publicName: "approvedImageOrigins", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { focused: "focused" }, host: { classAttribute: "block min-w-0" }, providers: [
4103
+ {
4104
+ provide: NG_VALUE_ACCESSOR,
4105
+ useExisting: forwardRef(() => EmailVisualEditor),
4106
+ multi: true,
4107
+ },
4108
+ ], viewQueries: [{ propertyName: "frame", first: true, predicate: ["frame"], descendants: true, isSignal: true }, { propertyName: "sourceHost", first: true, predicate: ["sourceHost"], descendants: true, isSignal: true }], ngImport: i0, template: "<ng-container *transloco=\"let t; prefix: 'email-delivery.editor'\">\n <div class=\"flex min-w-0 flex-col gap-1\">\n @if (label() || hint()) {\n <div class=\"flex items-center gap-1 text-sm\">\n @if (label()) {\n <span class=\"font-medium\">{{ label() }}</span>\n }\n @if (hint()) {\n <span class=\"inline-flex text-surface-400\" [mtTooltip]=\"hint()\">\n <mt-icon icon=\"general.help-circle\" />\n </span>\n }\n </div>\n }\n\n <div\n class=\"flex min-w-0 flex-col overflow-hidden rounded-md border border-surface bg-content\"\n >\n <!-- Toolbar -->\n <div\n class=\"flex flex-wrap items-center justify-between gap-2 border-b border-surface bg-surface-50 px-2 py-1.5\"\n >\n <div\n class=\"flex min-w-0 flex-wrap items-center gap-0.5\"\n role=\"toolbar\"\n [attr.aria-label]=\"t('toolbar')\"\n >\n @if (effectiveMode() === \"design\") {\n @for (action of blockActions; track action.key) {\n <ng-container\n [ngTemplateOutlet]=\"toolbarButton\"\n [ngTemplateOutletContext]=\"{ $implicit: action }\"\n />\n }\n <span\n class=\"mx-1 h-5 w-px bg-surface-200\"\n aria-hidden=\"true\"\n ></span>\n @for (action of inlineActions; track action.key) {\n <ng-container\n [ngTemplateOutlet]=\"toolbarButton\"\n [ngTemplateOutletContext]=\"{ $implicit: action }\"\n />\n }\n <span\n class=\"mx-1 h-5 w-px bg-surface-200\"\n aria-hidden=\"true\"\n ></span>\n @for (action of styleActions; track action.key) {\n <ng-container\n [ngTemplateOutlet]=\"toolbarButton\"\n [ngTemplateOutletContext]=\"{ $implicit: action }\"\n />\n }\n <span\n class=\"mx-1 h-5 w-px bg-surface-200\"\n aria-hidden=\"true\"\n ></span>\n @for (action of alignActions; track action.key) {\n <ng-container\n [ngTemplateOutlet]=\"toolbarButton\"\n [ngTemplateOutletContext]=\"{ $implicit: action }\"\n />\n }\n <span\n class=\"mx-1 h-5 w-px bg-surface-200\"\n aria-hidden=\"true\"\n ></span>\n @for (action of listActions; track action.key) {\n <ng-container\n [ngTemplateOutlet]=\"toolbarButton\"\n [ngTemplateOutletContext]=\"{ $implicit: action }\"\n />\n }\n <mt-button\n icon=\"general.link-01\"\n severity=\"secondary\"\n variant=\"text\"\n size=\"small\"\n [tooltip]=\"t('actions.link')\"\n [ariaLabel]=\"t('actions.link')\"\n [disabled]=\"disabled()\"\n (onClick)=\"toggleLink()\"\n />\n <mt-button\n icon=\"image.image-01\"\n severity=\"secondary\"\n variant=\"text\"\n size=\"small\"\n [tooltip]=\"\n editingImage() ? t('actions.image-edit') : t('actions.image')\n \"\n [ariaLabel]=\"t('actions.image')\"\n [disabled]=\"disabled()\"\n (onClick)=\"toggleImage()\"\n />\n <span\n class=\"mx-1 h-5 w-px bg-surface-200\"\n aria-hidden=\"true\"\n ></span>\n @for (action of directionActions; track action.key) {\n <ng-container\n [ngTemplateOutlet]=\"toolbarButton\"\n [ngTemplateOutletContext]=\"{ $implicit: action }\"\n />\n }\n <span\n class=\"mx-1 h-5 w-px bg-surface-200\"\n aria-hidden=\"true\"\n ></span>\n @for (action of historyActions; track action.key) {\n <ng-container\n [ngTemplateOutlet]=\"toolbarButton\"\n [ngTemplateOutletContext]=\"{ $implicit: action }\"\n />\n }\n }\n </div>\n @if (!designBlocked()) {\n <!-- ms-auto keeps the switch at the end when the toolbar wraps. -->\n <mt-tabs\n class=\"ms-auto\"\n size=\"small\"\n [options]=\"modeOptions()\"\n [active]=\"mode()\"\n (activeChange)=\"onModeChange($event)\"\n />\n }\n </div>\n\n @if (stylePanel() && effectiveMode() === \"design\") {\n <div\n class=\"flex flex-wrap items-center gap-2 border-b border-surface px-3 py-2\"\n role=\"group\"\n [attr.aria-label]=\"\n t(\n 'style.' +\n (stylePanel() === 'color' ? 'text-color' : stylePanel())\n )\n \"\n >\n <span class=\"text-xs font-medium text-muted-color\">{{\n t(\n \"style.\" +\n (stylePanel() === \"color\" ? \"text-color\" : stylePanel())\n )\n }}</span>\n @if (stylePanel() === \"size\") {\n @for (size of textSizes; track size.key) {\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"outlined\"\n [label]=\"t('style.sizes.' + size.key)\"\n (onClick)=\"applySize(size.key)\"\n />\n }\n } @else {\n <div class=\"flex flex-wrap items-center gap-1\">\n @for (\n swatch of stylePanel() === \"highlight\"\n ? highlightColors\n : textColors;\n track swatch.key\n ) {\n <!-- The swatch colour is data, not a theme token. -->\n <button\n type=\"button\"\n class=\"size-6 rounded-md border border-surface outline-offset-2 hover:scale-110 focus-visible:outline-2 focus-visible:outline-primary\"\n [style.background-color]=\"swatch.value\"\n [attr.aria-label]=\"swatchLabel(swatch)\"\n [mtTooltip]=\"swatchLabel(swatch)\"\n (click)=\"applyColor(swatch)\"\n ></button>\n }\n </div>\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"text\"\n icon=\"general.x-close\"\n [label]=\"t('style.remove-color')\"\n (onClick)=\"removeColor()\"\n />\n }\n </div>\n }\n\n @if (imageOpen() && effectiveMode() === \"design\") {\n <div\n class=\"flex flex-col gap-2 border-b border-surface px-3 py-2\"\n [attr.aria-label]=\"t('actions.image')\"\n >\n <div class=\"grid grid-cols-1 gap-2 md:grid-cols-2\">\n <mt-text-field\n [formControl]=\"imageForm.src\"\n [label]=\"t('image.src')\"\n placeholder=\"https://\"\n dir=\"ltr\"\n />\n <mt-text-field\n [formControl]=\"imageForm.alt\"\n [label]=\"t('image.alt')\"\n [hint]=\"t('image.alt-hint')\"\n />\n <mt-number-field\n [formControl]=\"imageForm.width\"\n [label]=\"t('image.width')\"\n [min]=\"imageMin\"\n [max]=\"imageMax\"\n [maxFractionDigits]=\"0\"\n />\n <mt-number-field\n [formControl]=\"imageForm.height\"\n [label]=\"t('image.height')\"\n [min]=\"imageMin\"\n [max]=\"imageMax\"\n [maxFractionDigits]=\"0\"\n />\n </div>\n @if (imageError(); as error) {\n <p\n class=\"rounded-md border px-3 py-2 text-xs\"\n [class]=\"\n error === 'no-origins'\n ? 'border-amber-200 bg-amber-50 text-amber-700'\n : 'border-red-200 bg-red-50 text-red-700'\n \"\n role=\"alert\"\n >\n {{ imageErrorText() }}\n </p>\n } @else {\n <p class=\"text-xs text-muted-color\">\n {{ t(\"image.hint\", { origins: imageOriginsLabel() }) }}\n </p>\n }\n <div class=\"flex flex-wrap gap-2\">\n <mt-button\n size=\"small\"\n [label]=\"editingImage() ? t('image.update') : t('image.insert')\"\n [disabled]=\"imageError() === 'no-origins'\"\n (onClick)=\"applyImage()\"\n />\n @if (editingImage()) {\n <mt-button\n size=\"small\"\n severity=\"danger\"\n variant=\"text\"\n [label]=\"t('image.remove')\"\n (onClick)=\"removeImage()\"\n />\n }\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"text\"\n [label]=\"t('image.cancel')\"\n (onClick)=\"closeImage()\"\n />\n </div>\n </div>\n }\n\n @if (linkOpen() && effectiveMode() === \"design\") {\n <div\n class=\"flex flex-wrap items-start gap-2 border-b border-surface px-3 py-2\"\n >\n <mt-text-field\n class=\"min-w-0 flex-1\"\n [formControl]=\"linkControl\"\n [placeholder]=\"t('link.placeholder')\"\n dir=\"ltr\"\n />\n <mt-button\n size=\"small\"\n [label]=\"t('link.apply')\"\n (onClick)=\"applyLink()\"\n />\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"outlined\"\n [label]=\"t('link.record')\"\n (onClick)=\"useRecordLink()\"\n />\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"text\"\n [label]=\"t('link.remove')\"\n (onClick)=\"removeLink()\"\n />\n @if (linkError()) {\n <p class=\"w-full text-xs text-red-600\" role=\"alert\">\n {{ t(\"link.invalid\") }}\n </p>\n }\n </div>\n }\n\n @if (designBlocked()) {\n <p\n class=\"border-b border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700\"\n >\n {{ t(\"design-blocked\") }}\n </p>\n }\n\n @if (effectiveMode() === \"design\") {\n @if (starter() && isEmpty() && !disabled()) {\n <div\n class=\"flex items-center justify-between gap-2 border-b border-surface px-3 py-2 text-xs text-muted-color\"\n >\n <span>{{ t(\"empty\") }}</span>\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"outlined\"\n [label]=\"t('bilingual')\"\n (onClick)=\"startBilingual()\"\n />\n </div>\n }\n <iframe\n #frame\n sandbox=\"allow-same-origin\"\n referrerpolicy=\"no-referrer\"\n [attr.title]=\"label() || t('canvas')\"\n class=\"block w-full border-0 bg-white\"\n [style.height]=\"height()\"\n ></iframe>\n } @else {\n <div #sourceHost class=\"p-2\" dir=\"ltr\">\n <textarea\n class=\"block w-full resize-y rounded-md border border-surface bg-content p-2 font-mono text-xs leading-5 outline-none focus:border-primary\"\n (focus)=\"focused.emit()\"\n [style.height]=\"height()\"\n [formControl]=\"sourceControl\"\n [attr.aria-label]=\"label() || t('html')\"\n spellcheck=\"false\"\n ></textarea>\n </div>\n }\n </div>\n </div>\n\n <ng-template #toolbarButton let-action>\n <mt-button\n [icon]=\"action.icon\"\n [label]=\"action.text\"\n severity=\"secondary\"\n variant=\"text\"\n size=\"small\"\n [tooltip]=\"actionLabel(action.key)\"\n [ariaLabel]=\"actionLabel(action.key)\"\n [disabled]=\"disabled()\"\n (onClick)=\"action.run()\"\n />\n </ng-template>\n</ng-container>\n", dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: TranslocoDirective, selector: "[transloco]", inputs: ["transloco", "translocoParams", "translocoScope", "translocoRead", "translocoPrefix", "translocoLang", "translocoLoadingTpl"] }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "component", type: Button, selector: "mt-button", inputs: ["icon", "label", "tooltip", "class", "type", "styleClass", "severity", "badge", "variant", "badgeSeverity", "size", "iconPos", "autofocus", "fluid", "raised", "rounded", "text", "plain", "outlined", "link", "disabled", "loading", "pInputs", "ariaLabel"], outputs: ["onClick", "onFocus", "onBlur"] }, { kind: "component", type: Tabs, selector: "mt-tabs", inputs: ["options", "optionLabel", "optionValue", "active", "mode", "moreLabel", "defaultIcon", "size", "fluid", "disabled", "searchThreshold"], outputs: ["activeChange", "onChange"] }, { kind: "component", type: TextField, selector: "mt-text-field", inputs: ["field", "hint", "label", "placeholder", "class", "type", "readonly", "pInputs", "required", "maxLength", "icon", "iconPosition"] }, { kind: "component", type: NumberField, selector: "mt-number-field", inputs: ["field", "hint", "label", "placeholder", "class", "readonly", "pInputs", "format", "useGrouping", "maxFractionDigits", "min", "max", "required"] }, { kind: "directive", type: Tooltip, selector: "[mtTooltip]" }, { kind: "component", type: Icon, selector: "mt-icon", inputs: ["icon"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
4109
+ }
4110
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: EmailVisualEditor, decorators: [{
4111
+ type: Component,
4112
+ args: [{ selector: 'mt-email-visual-editor', imports: [
4113
+ NgTemplateOutlet,
4114
+ TranslocoDirective,
4115
+ ReactiveFormsModule,
4116
+ Button,
4117
+ Tabs,
4118
+ TextField,
4119
+ NumberField,
4120
+ Tooltip,
4121
+ Icon,
4122
+ ], host: { class: 'block min-w-0' }, providers: [
4123
+ {
4124
+ provide: NG_VALUE_ACCESSOR,
4125
+ useExisting: forwardRef(() => EmailVisualEditor),
4126
+ multi: true,
4127
+ },
4128
+ ], changeDetection: ChangeDetectionStrategy.OnPush, template: "<ng-container *transloco=\"let t; prefix: 'email-delivery.editor'\">\n <div class=\"flex min-w-0 flex-col gap-1\">\n @if (label() || hint()) {\n <div class=\"flex items-center gap-1 text-sm\">\n @if (label()) {\n <span class=\"font-medium\">{{ label() }}</span>\n }\n @if (hint()) {\n <span class=\"inline-flex text-surface-400\" [mtTooltip]=\"hint()\">\n <mt-icon icon=\"general.help-circle\" />\n </span>\n }\n </div>\n }\n\n <div\n class=\"flex min-w-0 flex-col overflow-hidden rounded-md border border-surface bg-content\"\n >\n <!-- Toolbar -->\n <div\n class=\"flex flex-wrap items-center justify-between gap-2 border-b border-surface bg-surface-50 px-2 py-1.5\"\n >\n <div\n class=\"flex min-w-0 flex-wrap items-center gap-0.5\"\n role=\"toolbar\"\n [attr.aria-label]=\"t('toolbar')\"\n >\n @if (effectiveMode() === \"design\") {\n @for (action of blockActions; track action.key) {\n <ng-container\n [ngTemplateOutlet]=\"toolbarButton\"\n [ngTemplateOutletContext]=\"{ $implicit: action }\"\n />\n }\n <span\n class=\"mx-1 h-5 w-px bg-surface-200\"\n aria-hidden=\"true\"\n ></span>\n @for (action of inlineActions; track action.key) {\n <ng-container\n [ngTemplateOutlet]=\"toolbarButton\"\n [ngTemplateOutletContext]=\"{ $implicit: action }\"\n />\n }\n <span\n class=\"mx-1 h-5 w-px bg-surface-200\"\n aria-hidden=\"true\"\n ></span>\n @for (action of styleActions; track action.key) {\n <ng-container\n [ngTemplateOutlet]=\"toolbarButton\"\n [ngTemplateOutletContext]=\"{ $implicit: action }\"\n />\n }\n <span\n class=\"mx-1 h-5 w-px bg-surface-200\"\n aria-hidden=\"true\"\n ></span>\n @for (action of alignActions; track action.key) {\n <ng-container\n [ngTemplateOutlet]=\"toolbarButton\"\n [ngTemplateOutletContext]=\"{ $implicit: action }\"\n />\n }\n <span\n class=\"mx-1 h-5 w-px bg-surface-200\"\n aria-hidden=\"true\"\n ></span>\n @for (action of listActions; track action.key) {\n <ng-container\n [ngTemplateOutlet]=\"toolbarButton\"\n [ngTemplateOutletContext]=\"{ $implicit: action }\"\n />\n }\n <mt-button\n icon=\"general.link-01\"\n severity=\"secondary\"\n variant=\"text\"\n size=\"small\"\n [tooltip]=\"t('actions.link')\"\n [ariaLabel]=\"t('actions.link')\"\n [disabled]=\"disabled()\"\n (onClick)=\"toggleLink()\"\n />\n <mt-button\n icon=\"image.image-01\"\n severity=\"secondary\"\n variant=\"text\"\n size=\"small\"\n [tooltip]=\"\n editingImage() ? t('actions.image-edit') : t('actions.image')\n \"\n [ariaLabel]=\"t('actions.image')\"\n [disabled]=\"disabled()\"\n (onClick)=\"toggleImage()\"\n />\n <span\n class=\"mx-1 h-5 w-px bg-surface-200\"\n aria-hidden=\"true\"\n ></span>\n @for (action of directionActions; track action.key) {\n <ng-container\n [ngTemplateOutlet]=\"toolbarButton\"\n [ngTemplateOutletContext]=\"{ $implicit: action }\"\n />\n }\n <span\n class=\"mx-1 h-5 w-px bg-surface-200\"\n aria-hidden=\"true\"\n ></span>\n @for (action of historyActions; track action.key) {\n <ng-container\n [ngTemplateOutlet]=\"toolbarButton\"\n [ngTemplateOutletContext]=\"{ $implicit: action }\"\n />\n }\n }\n </div>\n @if (!designBlocked()) {\n <!-- ms-auto keeps the switch at the end when the toolbar wraps. -->\n <mt-tabs\n class=\"ms-auto\"\n size=\"small\"\n [options]=\"modeOptions()\"\n [active]=\"mode()\"\n (activeChange)=\"onModeChange($event)\"\n />\n }\n </div>\n\n @if (stylePanel() && effectiveMode() === \"design\") {\n <div\n class=\"flex flex-wrap items-center gap-2 border-b border-surface px-3 py-2\"\n role=\"group\"\n [attr.aria-label]=\"\n t(\n 'style.' +\n (stylePanel() === 'color' ? 'text-color' : stylePanel())\n )\n \"\n >\n <span class=\"text-xs font-medium text-muted-color\">{{\n t(\n \"style.\" +\n (stylePanel() === \"color\" ? \"text-color\" : stylePanel())\n )\n }}</span>\n @if (stylePanel() === \"size\") {\n @for (size of textSizes; track size.key) {\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"outlined\"\n [label]=\"t('style.sizes.' + size.key)\"\n (onClick)=\"applySize(size.key)\"\n />\n }\n } @else {\n <div class=\"flex flex-wrap items-center gap-1\">\n @for (\n swatch of stylePanel() === \"highlight\"\n ? highlightColors\n : textColors;\n track swatch.key\n ) {\n <!-- The swatch colour is data, not a theme token. -->\n <button\n type=\"button\"\n class=\"size-6 rounded-md border border-surface outline-offset-2 hover:scale-110 focus-visible:outline-2 focus-visible:outline-primary\"\n [style.background-color]=\"swatch.value\"\n [attr.aria-label]=\"swatchLabel(swatch)\"\n [mtTooltip]=\"swatchLabel(swatch)\"\n (click)=\"applyColor(swatch)\"\n ></button>\n }\n </div>\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"text\"\n icon=\"general.x-close\"\n [label]=\"t('style.remove-color')\"\n (onClick)=\"removeColor()\"\n />\n }\n </div>\n }\n\n @if (imageOpen() && effectiveMode() === \"design\") {\n <div\n class=\"flex flex-col gap-2 border-b border-surface px-3 py-2\"\n [attr.aria-label]=\"t('actions.image')\"\n >\n <div class=\"grid grid-cols-1 gap-2 md:grid-cols-2\">\n <mt-text-field\n [formControl]=\"imageForm.src\"\n [label]=\"t('image.src')\"\n placeholder=\"https://\"\n dir=\"ltr\"\n />\n <mt-text-field\n [formControl]=\"imageForm.alt\"\n [label]=\"t('image.alt')\"\n [hint]=\"t('image.alt-hint')\"\n />\n <mt-number-field\n [formControl]=\"imageForm.width\"\n [label]=\"t('image.width')\"\n [min]=\"imageMin\"\n [max]=\"imageMax\"\n [maxFractionDigits]=\"0\"\n />\n <mt-number-field\n [formControl]=\"imageForm.height\"\n [label]=\"t('image.height')\"\n [min]=\"imageMin\"\n [max]=\"imageMax\"\n [maxFractionDigits]=\"0\"\n />\n </div>\n @if (imageError(); as error) {\n <p\n class=\"rounded-md border px-3 py-2 text-xs\"\n [class]=\"\n error === 'no-origins'\n ? 'border-amber-200 bg-amber-50 text-amber-700'\n : 'border-red-200 bg-red-50 text-red-700'\n \"\n role=\"alert\"\n >\n {{ imageErrorText() }}\n </p>\n } @else {\n <p class=\"text-xs text-muted-color\">\n {{ t(\"image.hint\", { origins: imageOriginsLabel() }) }}\n </p>\n }\n <div class=\"flex flex-wrap gap-2\">\n <mt-button\n size=\"small\"\n [label]=\"editingImage() ? t('image.update') : t('image.insert')\"\n [disabled]=\"imageError() === 'no-origins'\"\n (onClick)=\"applyImage()\"\n />\n @if (editingImage()) {\n <mt-button\n size=\"small\"\n severity=\"danger\"\n variant=\"text\"\n [label]=\"t('image.remove')\"\n (onClick)=\"removeImage()\"\n />\n }\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"text\"\n [label]=\"t('image.cancel')\"\n (onClick)=\"closeImage()\"\n />\n </div>\n </div>\n }\n\n @if (linkOpen() && effectiveMode() === \"design\") {\n <div\n class=\"flex flex-wrap items-start gap-2 border-b border-surface px-3 py-2\"\n >\n <mt-text-field\n class=\"min-w-0 flex-1\"\n [formControl]=\"linkControl\"\n [placeholder]=\"t('link.placeholder')\"\n dir=\"ltr\"\n />\n <mt-button\n size=\"small\"\n [label]=\"t('link.apply')\"\n (onClick)=\"applyLink()\"\n />\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"outlined\"\n [label]=\"t('link.record')\"\n (onClick)=\"useRecordLink()\"\n />\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"text\"\n [label]=\"t('link.remove')\"\n (onClick)=\"removeLink()\"\n />\n @if (linkError()) {\n <p class=\"w-full text-xs text-red-600\" role=\"alert\">\n {{ t(\"link.invalid\") }}\n </p>\n }\n </div>\n }\n\n @if (designBlocked()) {\n <p\n class=\"border-b border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700\"\n >\n {{ t(\"design-blocked\") }}\n </p>\n }\n\n @if (effectiveMode() === \"design\") {\n @if (starter() && isEmpty() && !disabled()) {\n <div\n class=\"flex items-center justify-between gap-2 border-b border-surface px-3 py-2 text-xs text-muted-color\"\n >\n <span>{{ t(\"empty\") }}</span>\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"outlined\"\n [label]=\"t('bilingual')\"\n (onClick)=\"startBilingual()\"\n />\n </div>\n }\n <iframe\n #frame\n sandbox=\"allow-same-origin\"\n referrerpolicy=\"no-referrer\"\n [attr.title]=\"label() || t('canvas')\"\n class=\"block w-full border-0 bg-white\"\n [style.height]=\"height()\"\n ></iframe>\n } @else {\n <div #sourceHost class=\"p-2\" dir=\"ltr\">\n <textarea\n class=\"block w-full resize-y rounded-md border border-surface bg-content p-2 font-mono text-xs leading-5 outline-none focus:border-primary\"\n (focus)=\"focused.emit()\"\n [style.height]=\"height()\"\n [formControl]=\"sourceControl\"\n [attr.aria-label]=\"label() || t('html')\"\n spellcheck=\"false\"\n ></textarea>\n </div>\n }\n </div>\n </div>\n\n <ng-template #toolbarButton let-action>\n <mt-button\n [icon]=\"action.icon\"\n [label]=\"action.text\"\n severity=\"secondary\"\n variant=\"text\"\n size=\"small\"\n [tooltip]=\"actionLabel(action.key)\"\n [ariaLabel]=\"actionLabel(action.key)\"\n [disabled]=\"disabled()\"\n (onClick)=\"action.run()\"\n />\n </ng-template>\n</ng-container>\n" }]
4129
+ }], ctorParameters: () => [], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], hint: [{ type: i0.Input, args: [{ isSignal: true, alias: "hint", required: false }] }], placeholders: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholders", required: false }] }], layout: [{ type: i0.Input, args: [{ isSignal: true, alias: "layout", required: false }] }], height: [{ type: i0.Input, args: [{ isSignal: true, alias: "height", required: false }] }], direction: [{ type: i0.Input, args: [{ isSignal: true, alias: "direction", required: false }] }], starter: [{ type: i0.Input, args: [{ isSignal: true, alias: "starter", required: false }] }], focused: [{ type: i0.Output, args: ["focused"] }], approvedImageOrigins: [{ type: i0.Input, args: [{ isSignal: true, alias: "approvedImageOrigins", required: false }] }], frame: [{ type: i0.ViewChild, args: ['frame', { isSignal: true }] }], sourceHost: [{ type: i0.ViewChild, args: ['sourceHost', { isSignal: true }] }] } });
4130
+ function numberAttribute(element, name) {
4131
+ const value = Number(element?.getAttribute(name));
4132
+ return Number.isFinite(value) && value > 0 ? value : null;
4133
+ }
4134
+
2897
4135
  /**
2898
4136
  * Generated bundle index. Do not edit.
2899
4137
  */
2900
4138
 
2901
- export { DEFAULT_GENERAL_CONFIGURATION as D, EmailDeliveryFacade as E, FIXED_CURRENCIES as F, GENERAL_CONFIGURATION_SETTING_KEY as G, RATE_SOURCE_OPTIONS as R, SystemSettings as S, TIMESHEET_FREQUENCY_OPTIONS as T, WEEKEND_DAY_DEFINITIONS as W, EmailDeliveryStatus as a, EMAIL_DELIVERY_ATTEMPT_STATUSES as b, createEmailTestRequestId as c, areEmailTestRecipientsValid as d, isEmailTestRequestPending as e, EMAIL_DELIVERY_PERMISSIONS as f, getEmailDeliveryStatusMap as g, EXPLORE_DEFAULT_VIEW_OPTIONS as h, isConnectionTestAttempt as i, DEFAULT_DATE_FORMAT_OPTIONS as j, DEFAULT_TIME_FORMAT_OPTIONS as k, DOTNET_DAY_DEFINITIONS as l, SCHEDULE_ITEM_TYPE_OPTIONS as m, SystemSettingsHolidayEditor as n, DEFAULT_EMAIL_DELIVERY_OPTIONS as o, parseEmailTestRecipients as p, EMAIL_DELIVERY_OPTIONS as q, provideEmailDelivery as r, EmailDeliveryAccessService as s, collectEmailTemplatePlaceholders as t, buildEmailTemplatePlaceholderFields as u, EmailUserTestDialog as v, EMAIL_USER_INITIATED_TEST_OPERATION_KIND as w, EMAIL_DELIVERY_ROUTES as x };
2902
- //# sourceMappingURL=masterteam-system-settings-masterteam-system-settings-7I4TfgwP.mjs.map
4139
+ export { buildEmailTemplatePlaceholderFields as A, EmailUserTestDialog as B, EMAIL_USER_INITIATED_TEST_OPERATION_KIND as C, DEFAULT_GENERAL_CONFIGURATION as D, EmailDeliveryFacade as E, FIXED_CURRENCIES as F, GENERAL_CONFIGURATION_SETTING_KEY as G, EMAIL_DELIVERY_ROUTES as H, emailDesignLeaveGuard as I, buildEmailPreviewDocument as J, RATE_SOURCE_OPTIONS as R, SystemSettings as S, TIMESHEET_FREQUENCY_OPTIONS as T, WEEKEND_DAY_DEFINITIONS as W, EmailDeliveryStatus as a, EMAIL_DELIVERY_ATTEMPT_STATUSES as b, createEmailTestRequestId as c, areEmailTestRecipientsValid as d, isEmailTestRequestPending as e, EMAIL_DELIVERY_PERMISSIONS as f, getEmailDeliveryStatusMap as g, EmailLayoutFacade as h, isConnectionTestAttempt as i, EMAIL_LAYOUT_CONTENT_SLOT as j, EMAIL_LAYOUT_MAX_BYTES as k, EmailVisualEditor as l, EmailHtmlPreview as m, EXPLORE_DEFAULT_VIEW_OPTIONS as n, DEFAULT_DATE_FORMAT_OPTIONS as o, parseEmailTestRecipients as p, DEFAULT_TIME_FORMAT_OPTIONS as q, readLayoutErrorDiagnostics as r, DOTNET_DAY_DEFINITIONS as s, SCHEDULE_ITEM_TYPE_OPTIONS as t, SystemSettingsHolidayEditor as u, DEFAULT_EMAIL_DELIVERY_OPTIONS as v, EMAIL_DELIVERY_OPTIONS as w, provideEmailDelivery as x, EmailDeliveryAccessService as y, collectEmailTemplatePlaceholders as z };
4140
+ //# sourceMappingURL=masterteam-system-settings-masterteam-system-settings-C51hZw-Q.mjs.map