diff --git a/apps/proxy/src/app/features/create-feature/create-feature.component.html b/apps/proxy/src/app/features/create-feature/create-feature.component.html index 1d818523..d4199616 100644 --- a/apps/proxy/src/app/features/create-feature/create-feature.component.html +++ b/apps/proxy/src/app/features/create-feature/create-feature.component.html @@ -49,7 +49,7 @@

Add New Blocks

(selectionChange)="stepChange($event)" *ngIf="!isEditMode" > - Add New Blocks - + --> Block Name -
- -
- + +
+
+
+ + + Authorization Setup +
+
+
+
+ + + Branding +
+ +
+ + + - -
-
-
Design & code
@@ -296,7 +320,7 @@

Add New Blocks

- +
@@ -316,6 +340,16 @@

Add New Blocks

+ +
+ +
+ +
+
+
@@ -421,7 +455,7 @@

Add New Blocks

- +

Give a name of your Block

@@ -433,15 +467,33 @@

Add New Blocks

fieldConfig: { label: 'Name', is_required: true, - patternErrorText: 'Name must contain only alphanumeric and underscore' + patternErrorText: 'Name must contain only alphanumeric and underscore', + info: 'Internal name to identify this authentication block.' } } " > + + +
-
+
@@ -566,144 +618,247 @@

Custom Mapping

-
-
- -
- -

Credentials

- -
-
- -
+ +
+ +
+
+ + + +
+
+ + + +

Credentials

+ +
+
+
- +
+
+ +

Configurations

-

Configurations

- -
-
- -
+
+
+
- +
- - Callback URL - - - - - Enable Service - - - - -
-
-
- + + + Callback URL + + + + + Enable Service + + + +
- + +
+

{{ getSelectedServiceName() }}

+ +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + +
Method{{ row.name }}Toggle + + + (default) + Configure + +
+
No methods available
+
+

+ Note: By default, 36Blocks credentials will be used, and the consent screen or Sender ID + will display the 36Blocks name. You can update these with your own credentials and branding anytime after + the block is created. +

+
+
+ +
-
-

Sign in to your account

-
+ +
+ +

+ {{ featureForm.get('brandingDetails.title')?.value }} +

+
+
- - - + + + - + - + - + - - + +

- Are you a new user? Create an account + Are you a new user? + {{ featureForm.get('brandingDetails.sign_up_button_text')?.value }}

- +
@@ -719,8 +874,9 @@

Sign in to your account

@@ -728,11 +884,11 @@

Sign in to your account

- +
Sign in to your account >
Or continue with
@@ -774,7 +932,7 @@

Sign in to your account

-
@@ -1217,6 +1282,24 @@

Sign in to your account

readonly /> + + + + info + + Sign in to your account
- {{ fieldConfig?.hint }} + {{ fieldConfig?.hint }} + + + info + + +
Taxes
+ + +
+ +
+

Customize your login UI

+ +
+ + + Enter URL + + Upload file + info + + +
+ + Logo URL + + +
+ + + + {{ logoFileInput.files?.[0]?.name || 'Logo selected' }} + + +
+ + + + + + + + + + + + + + + + +
+
+ Show the social login as a icon: + + +
+ +
+ Show Create Account For New User: + + +
+ +
+ + + {{ + featureForm.get('brandingDetails.light_theme_primary_color')?.value || '#000000' + }} + (Light theme) +
+ +
+ + + {{ + featureForm.get('brandingDetails.dark_theme_primary_color')?.value || '#ffffff' + }} + (Dark theme) +
+ +
+ + + {{ + featureForm.get('brandingDetails.button_color')?.value || '#19E6CE' + }} +
+ +
+ + + {{ + featureForm.get('brandingDetails.button_hover_color')?.value || '#19E6CE' + }} +
+ +
+ + + {{ + featureForm.get('brandingDetails.button_text_color')?.value || '#000000' + }} +
+
+
+ +
+
+
+ + +
+
+ +
+
+
diff --git a/apps/proxy/src/app/features/create-feature/create-feature.component.scss b/apps/proxy/src/app/features/create-feature/create-feature.component.scss index 80f39a9c..1afd4dda 100644 --- a/apps/proxy/src/app/features/create-feature/create-feature.component.scss +++ b/apps/proxy/src/app/features/create-feature/create-feature.component.scss @@ -21,6 +21,28 @@ width: 250px; } +.configure-method-dialog-content { + max-height: 65vh; + overflow-y: auto; +} + +// Show Configure column edit button only on row hover +.configure-methods-table { + tr.mat-row, + tr.mat-mdc-row { + .mat-column-configure button { + opacity: 0; + transition: opacity 0.15s ease; + } + &:hover .mat-column-configure button { + opacity: 1; + } + } + th { + font-size: var(--font-size-common-14) !important; + } +} + // Fix table borders .default-table { .mat-mdc-row { @@ -409,7 +431,6 @@ .info-icon { color: var(--color-common-primary, #1976d2); font-size: 17px; - cursor: help; opacity: 0.7; transition: opacity 0.2s ease-in-out; position: absolute; @@ -575,7 +596,6 @@ color: #ffffff !important; } .mat-form-field-outline-thick { - background-color: #fffcfc !important; color: #ffffff !important; } .mat-form-field-outline-start, @@ -893,3 +913,69 @@ } } } + +// Branding step: apply border radius to all preview elements (variable --branding-border-radius set on .auth-credentials) +.auth-credentials { + border-radius: var(--branding-border-radius, 8px) !important; + // mat-form-field and other elements use --border-common-radius-4; override with branding radius + --border-common-radius-4: var(--branding-border-radius, 8px); +} +.auth-credentials .branding-preview-btn { + background-color: var(--branding-button-color, #19e6ce) !important; + color: var(--branding-button-text-color, #000000) !important; + border-radius: var(--branding-border-radius, 8px) !important; +} +.auth-credentials .branding-preview-btn:hover { + background-color: var(--branding-button-hover-color, #19e6ce) !important; +} +.auth-credentials .auth-option-btn { + border-radius: var(--branding-border-radius, 8px) !important; +} +.auth-credentials .social-login-icon-box { + border-radius: var(--branding-border-radius, 8px) !important; +} + +// Email or Phone & Password fields in preview: apply user's border radius to outline +::ng-deep .auth-credentials .branding-preview-input .mdc-text-field--outlined { + --mdc-outlined-text-field-container-shape: var(--branding-border-radius, 8px) !important; +} +::ng-deep .auth-credentials .branding-preview-input .mdc-notched-outline .mdc-notched-outline__leading { + border-top-left-radius: var(--branding-border-radius, 8px) !important; + border-bottom-left-radius: var(--branding-border-radius, 8px) !important; +} +::ng-deep .auth-credentials .branding-preview-input .mdc-notched-outline .mdc-notched-outline__trailing { + border-top-right-radius: var(--branding-border-radius, 8px) !important; + border-bottom-right-radius: var(--branding-border-radius, 8px) !important; +} +::ng-deep .auth-credentials .branding-preview-input .mdc-notched-outline .mdc-notched-outline__notch { + border-top-left-radius: var(--branding-border-radius, 8px) !important; + border-top-right-radius: var(--branding-border-radius, 8px) !important; +} + +.branding-preview-logo { + max-height: 48px; + max-width: 160px; + object-fit: contain; +} + +.branding-step-content { + min-height: 400px; +} + +// Use app green theme for logo radio buttons (instead of default blue) +::ng-deep .branding-logo-radio { + .mat-mdc-radio-button.mat-mdc-radio-checked .mdc-radio__outer-circle, + .mat-mdc-radio-button .mdc-radio__outer-circle { + border-color: var(--color-dark-accent) !important; + } + .mat-mdc-radio-button.mat-mdc-radio-checked .mdc-radio__inner-circle { + border-color: var(--color-dark-accent) !important; + background-color: var(--color-dark-accent) !important; + } + .mat-mdc-radio-button .mdc-radio__background::before { + background-color: var(--color-dark-accent) !important; + } +} +.mat-tab-body-wrapper { + height: 100%; +} diff --git a/apps/proxy/src/app/features/create-feature/create-feature.component.ts b/apps/proxy/src/app/features/create-feature/create-feature.component.ts index 95bb7b8f..21480d06 100644 --- a/apps/proxy/src/app/features/create-feature/create-feature.component.ts +++ b/apps/proxy/src/app/features/create-feature/create-feature.component.ts @@ -1,11 +1,24 @@ import { ActivatedRoute } from '@angular/router'; import { cloneDeep, isEqual } from 'lodash-es'; -import { Component, OnDestroy, OnInit, NgZone, ViewChildren, QueryList, ChangeDetectorRef } from '@angular/core'; +import { + Component, + OnDestroy, + OnInit, + NgZone, + ViewChildren, + QueryList, + ChangeDetectorRef, + ViewChild, + ElementRef, + AfterViewInit, + TemplateRef, +} from '@angular/core'; import { BaseComponent } from '@proxy/ui/base-component'; import { BehaviorSubject, Observable, distinctUntilChanged, filter, of, take, takeUntil } from 'rxjs'; import { CreateFeatureComponentStore } from './create-feature.store'; import { FeatureFieldType, + FeatureServiceIds, IFeature, IFeatureDetails, IFeatureType, @@ -15,7 +28,7 @@ import { ProxyAuthScript, ProxyAuthScriptUrl, } from '@proxy/models/features-model'; -import { FormArray, FormControl, FormGroup, Validators, ValidatorFn } from '@angular/forms'; +import { AbstractControl, FormArray, FormControl, FormGroup, Validators, ValidatorFn } from '@angular/forms'; import { CAMPAIGN_NAME_REGEX, ONLY_INTEGER_REGEX, URL_REGEX } from '@proxy/regex'; import { CustomValidators } from '@proxy/custom-validator'; import { COMMA, ENTER } from '@angular/cdk/keycodes'; @@ -74,8 +87,12 @@ export interface PeriodicElement { styleUrls: ['./create-feature.component.scss'], providers: [CreateFeatureComponentStore], }) -export class CreateFeatureComponent extends BaseComponent implements OnDestroy, OnInit { +export class CreateFeatureComponent extends BaseComponent implements OnDestroy, OnInit, AfterViewInit { @ViewChildren('stepper') stepper: QueryList; + @ViewChild('blockNameStepContent', { read: ElementRef }) blockNameStepContent: ElementRef; + @ViewChild('authorizationStepContent', { read: ElementRef }) authorizationStepContent: ElementRef; + @ViewChild('configureMethodDialogTemplate', { read: TemplateRef }) + configureMethodDialogTemplateRef: TemplateRef; public taxes: any[] = []; public createPlanForm: any; public taxConfigData: any; @@ -132,11 +149,15 @@ export class CreateFeatureComponent extends BaseComponent implements OnDestroy, public paymentDetailsById$: Observable = this.componentStore.paymentDetailsById$; public updatePaymentDetails$: Observable = this.componentStore.updatePaymentDetails$; public webhookEvents$: Observable = this.componentStore.webhookEvents$; + public uploadLogo$: Observable = this.componentStore.uploadLogo$; public isEditMode = false; public previewInputPosition: 'top' | 'bottom' = 'top'; public selectedServiceIndex = 0; public selectedSubscriptionServiceIndex = -2; + /** Duplicate of service form used inside configure method dialog; patched back to serviceForm on close */ + public configureMethodDialogForm: ServiceFormGroup | null = null; + public selectedMethod = new BehaviorSubject(null); public featureId: number = null; public nameFieldEditMode = false; @@ -150,6 +171,12 @@ export class CreateFeatureComponent extends BaseComponent implements OnDestroy, public featureFieldType = FeatureFieldType; public proxyAuthScript = ProxyAuthScript(environment.proxyServer); + public configureMethodsTableColumns: string[] = ['method', 'toggle', 'configure']; + + /** Table data for Configure Method step (create block). Set when method loads so dataSource is stable and toggle binding works. */ + public configureMethodsTableData: { name: string; index: number }[] = []; + + public configureMethodDialog: MatDialogRef; // Chip list public chipListSeparatorKeysCodes: number[] = [ENTER, COMMA]; public chipListValues: { [key: string]: Set } = {}; @@ -158,8 +185,14 @@ export class CreateFeatureComponent extends BaseComponent implements OnDestroy, // File public fileValues: { [key: string]: FileList } = {}; + /** Logo input: 'url' = enter URL, 'file' = upload file */ + public logoInputMode: 'url' | 'file' = 'url'; + + public isLogoUploading = false; + // Options cache for select fields private optionsCache: { [key: string]: any[] } = {}; + public logoUrl: string = null; public featureForm = new FormGroup({ primaryDetails: new FormGroup({ @@ -170,8 +203,9 @@ export class CreateFeatureComponent extends BaseComponent implements OnDestroy, CustomValidators.noStartEndSpaces, Validators.maxLength(60), ]), - feature_id: new FormControl(null, [Validators.required]), + feature_id: new FormControl(1, [Validators.required]), method_id: new FormControl(null, [Validators.required]), + redirect_url: new FormControl(null, [Validators.required, Validators.pattern(URL_REGEX)]), }), serviceDetails: new FormArray([]), planDetails: new FormArray([]), @@ -181,22 +215,17 @@ export class CreateFeatureComponent extends BaseComponent implements OnDestroy, planSelected: new FormControl(null, [Validators.required]), }), authorizationDetails: new FormGroup({ - session_time: new FormControl(null, [ + session_time: new FormControl(86400, [ Validators.required, Validators.pattern(ONLY_INTEGER_REGEX), Validators.min(60), Validators.max(999999999), ]), - authorizationKey: new FormControl(null, [ + authorizationKey: new FormControl('Authorization', [ Validators.required, CustomValidators.minLengthThreeWithoutSpace, ]), - theme: new FormControl('system', []), - version: new FormControl('v1', []), - allowNewUserRegistration: new FormControl(false, []), encryptionKey: new FormControl(null, []), - redirect_url: new FormControl(null, [Validators.required, Validators.pattern(URL_REGEX)]), - showSocialLoginIcons: new FormControl(false, []), blockNewUserSignUps: new FormControl(false, []), }), webhookDetails: new FormGroup({ @@ -204,6 +233,21 @@ export class CreateFeatureComponent extends BaseComponent implements OnDestroy, method: new FormControl('POST', [Validators.required]), triggerEvents: new FormControl([], []), }), + brandingDetails: new FormGroup({ + icons: new FormControl(false, []), + logo_url: new FormControl(null, []), + light_theme_primary_color: new FormControl('#000000', []), + dark_theme_primary_color: new FormControl('#ffffff', []), + button_color: new FormControl('#19E6CE', []), + button_hover_color: new FormControl('#19E6CE', []), + button_text_color: new FormControl('#000000', []), + border_radius: new FormControl('small', []), + title: new FormControl('Sign in to your account', []), + sign_up_button_text: new FormControl('Create an account', [Validators.required]), + create_account_link: new FormControl(true, []), + theme: new FormControl('system', []), + version: new FormControl('v2', []), + }), // New form controls for conditional steps }); public demoDiv$: Observable = of(null); @@ -229,6 +273,8 @@ export class CreateFeatureComponent extends BaseComponent implements OnDestroy, this.getFeatureDetalis(); } else { this.getFeatureType(); + this.featureForm.get('brandingDetails.light_theme_primary_color')?.setValue('#000000'); + this.featureForm.get('brandingDetails.dark_theme_primary_color')?.setValue('#ffffff'); } }); @@ -283,10 +329,6 @@ export class CreateFeatureComponent extends BaseComponent implements OnDestroy, authorizationDetails: { session_time: feature.session_time, authorizationKey: feature.authorization_format.key, - theme: feature.ui_preferences?.theme || 'system', - version: feature.ui_preferences?.version || 'v1', - showSocialLoginIcons: feature.ui_preferences?.icons || false, - allowNewUserRegistration: feature.ui_preferences?.create_account_link || false, encryptionKey: feature.encryption_key, blockNewUserSignUps: feature.block_registration || false, }, @@ -295,10 +337,26 @@ export class CreateFeatureComponent extends BaseComponent implements OnDestroy, method: feature.webhook?.method, triggerEvents: feature.trigger_events || feature.webhook_events || [], }, + brandingDetails: { + logo_url: feature.ui_preferences?.logo_url, + light_theme_primary_color: feature.ui_preferences?.light_theme_primary_color, + dark_theme_primary_color: feature.ui_preferences?.dark_theme_primary_color, + button_color: feature.ui_preferences?.button_color, + button_hover_color: feature.ui_preferences?.button_hover_color, + button_text_color: + feature.ui_preferences?.button_text_color ?? feature.ui_preferences?.text_color, + border_radius: feature.ui_preferences?.border_radius, + title: feature.ui_preferences?.title, + sign_up_button_text: feature.ui_preferences?.sign_up_button_text ?? 'Create an account', + icons: feature.ui_preferences?.icons ?? false, + create_account_link: feature.ui_preferences?.create_account_link ?? true, + theme: feature.ui_preferences?.theme || 'system', + version: feature.ui_preferences?.version || 'v1', + }, }); // Clear redirect_url validators in edit mode since the field is hidden - this.featureForm.get('authorizationDetails.redirect_url')?.clearValidators(); - this.featureForm.get('authorizationDetails.redirect_url')?.updateValueAndValidity(); + this.featureForm.get('primaryDetails.redirect_url')?.clearValidators(); + this.featureForm.get('primaryDetails.redirect_url')?.updateValueAndValidity(); this.previewInputPosition = feature.ui_preferences?.input_fields || 'top'; }); }); @@ -326,7 +384,12 @@ export class CreateFeatureComponent extends BaseComponent implements OnDestroy, configurations: new FormGroup({}), createPlanForm: new FormGroup({}), chargesForm: new FormGroup({}), - is_enable: new FormControl(this.isEditMode ? serviceValues?.is_enable : true), + is_enable: new FormControl( + this.isEditMode + ? serviceValues?.is_enable + : FeatureServiceIds.GoogleAuthentication === service.service_id || + FeatureServiceIds.Msg91OtpService === service.service_id + ), }); if (service.requirements) { Object.entries(service.requirements).forEach(([key, config]) => { @@ -352,6 +415,15 @@ export class CreateFeatureComponent extends BaseComponent implements OnDestroy, } this.featureForm.controls.serviceDetails.push(serviceFormGroup); }); + this.configureMethodsTableData = method.method_services.map((s, i) => ({ name: s.name, index: i })); + // After building service details for authorization block, sync redirect_url into all service redirect_uri fields + if ( + !this.isEditMode && + this.featureForm.get('primaryDetails.feature_id')?.value === 1 && + this.featureForm.get('primaryDetails.redirect_url')?.value + ) { + this.setRedirectUrlInServiceDetails(); + } }); this.createUpdateObject$.pipe(filter(Boolean), takeUntil(this.destroy$)).subscribe((obj) => { this.proxyAuthScript = ProxyAuthScript( @@ -376,6 +448,8 @@ export class CreateFeatureComponent extends BaseComponent implements OnDestroy, this.stepper?.first?.next(); }, 10); } + this.featureId = obj.id; + this.demoDiv$ = of(`
`); }); this.createBillableMetric$.pipe(filter(Boolean), takeUntil(this.destroy$)).subscribe((metric) => { @@ -502,14 +576,110 @@ export class CreateFeatureComponent extends BaseComponent implements OnDestroy, } } }); + this.uploadLogo$.pipe(filter(Boolean), takeUntil(this.destroy$)).subscribe((data) => { + const url = data?.logo_url ?? data?.url; + if (url) { + // this.featureForm.get('brandingDetails.logo_url')?.setValue(url); + this.logoUrl = url; + } + this.isLogoUploading = false; + this.cdr.markForCheck(); + }); + } + + public validateFirstStepAndNext(nameControl: AbstractControl): void { + nameControl.markAllAsTouched(); + const featureId = this.featureForm.get('primaryDetails.feature_id')?.value; + if (featureId === 1) { + this.featureForm.get('primaryDetails.redirect_url')?.markAllAsTouched(); + } + if (nameControl.invalid) { + return; + } + if (featureId === 1) { + const redirectUrlControl = this.featureForm.get('primaryDetails.redirect_url'); + if (redirectUrlControl?.invalid) { + return; + } + // Sync redirect_url into all service redirect_uri fields before showing Configure Method step + this.setRedirectUrlInServiceDetails(); + } + this.stepper?.first?.next(); + } + + public get validateFirstStep(): boolean { + const nameControl = this.featureForm.get('primaryDetails.name'); + const featureId = this.featureForm.get('primaryDetails.feature_id')?.value; + const redirectUrlControl = this.featureForm.get('primaryDetails.redirect_url'); + if (nameControl.invalid || redirectUrlControl?.invalid) { + return false; + } + if (featureId === 1) { + const redirectUrlControl = this.featureForm.get('primaryDetails.redirect_url'); + if (redirectUrlControl?.invalid) { + return false; + } + // Sync redirect_url into all service redirect_uri fields before showing Configure Method step + this.setRedirectUrlInServiceDetails(); + } + return true; + } + + /** Default primary color: black in light theme, white in dark theme */ + public getDefaultPrimaryColor(): string { + return typeof localStorage !== 'undefined' && localStorage.getItem('selected-theme') === 'dark-theme' + ? '#ffffff' + : '#000000'; + } + + /** Returns the effective primary color based on the selected branding theme */ + public getEffectivePrimaryColor(): string { + const theme = this.featureForm.get('brandingDetails.theme')?.value; + const isDark = + theme === 'dark' || + (theme === 'system' && + typeof localStorage !== 'undefined' && + localStorage.getItem('selected-theme') === 'dark-theme'); + if (isDark) { + return this.featureForm.get('brandingDetails.dark_theme_primary_color')?.value || '#ffffff'; + } + return this.featureForm.get('brandingDetails.light_theme_primary_color')?.value || '#000000'; + } + + public getBrandingBorderRadiusValue(): string { + const v = this.featureForm.get('brandingDetails.border_radius')?.value; + switch (v) { + case 'none': + return '0'; + case 'small': + return '4px'; + case 'medium': + return '8px'; + case 'large': + return '12px'; + default: + return '8px'; + } + } + + public onBrandingLogoFileSelected(event: Event): void { + const input = event.target as HTMLInputElement; + const file = input?.files?.[0]; + if (!file || !this.featureId) return; + input.value = ''; + const formData = new FormData(); + formData.append('logo', file); + this.isLogoUploading = true; + this.cdr.markForCheck(); + this.componentStore.uploadLogo({ id: this.featureId, formData }); } public setRedirectUrlInServiceDetails(): void { const serviceDetailsForm = this.featureForm.controls.serviceDetails; - const redirectUrl = this.featureForm.controls.authorizationDetails.value.redirect_url; + const redirectUrl = this.featureForm.controls.primaryDetails.value.redirect_url; serviceDetailsForm.controls.forEach((formGroup) => { const redirectUrlControl = formGroup.controls.configurations.controls['redirect_uri'] as FormControl; - if (redirectUrlControl) { + if (redirectUrlControl && !redirectUrlControl.value) { redirectUrlControl.setValue(redirectUrl); formGroup.markAsDirty(); } @@ -531,10 +701,13 @@ export class CreateFeatureComponent extends BaseComponent implements OnDestroy, }, session_time: featureFormData.authorizationDetails.session_time, extra_configurations: { - theme: featureFormData.authorizationDetails.theme || 'system', - allowNewUserRegistration: featureFormData.authorizationDetails.allowNewUserRegistration || false, + theme: featureFormData.brandingDetails.theme, + create_account_link: featureFormData.brandingDetails.create_account_link || false, }, services: this.getServicePayload(selectedMethod), + ui_preferences: { + ...featureFormData.brandingDetails, + }, }; // Added setTimeout because payload creation might contain promises setTimeout(() => { @@ -543,10 +716,12 @@ export class CreateFeatureComponent extends BaseComponent implements OnDestroy, } } - public updateFeature(type: 'name' | 'service' | 'authorization' | 'webhook') { + public updateFeature(type: 'name' | 'service' | 'authorization' | 'branding' | 'webhook') { let payload; const selectedMethod = cloneDeep(this.selectedMethod.getValue()); const featureDetails: IFeatureDetails = this.getValueFromObservable(this.featureDetails$); + const brandingDetailsForm = this.featureForm.controls.brandingDetails; + switch (type) { case 'name': const primaryDetailsForm = this.featureForm.controls.primaryDetails; @@ -564,8 +739,8 @@ export class CreateFeatureComponent extends BaseComponent implements OnDestroy, if (authorizationDetailsForm.valid) { payload = { extra_configurations: { - theme: authorizationDetailsForm.value.theme, - create_account_link: authorizationDetailsForm.value.allowNewUserRegistration || false, + theme: brandingDetailsForm.controls.theme.value, + create_account_link: brandingDetailsForm.controls.create_account_link.value || false, default_role: { name: 'Owner', value: 1, @@ -577,13 +752,13 @@ export class CreateFeatureComponent extends BaseComponent implements OnDestroy, ...featureDetails.authorization_format, key: authorizationDetailsForm.value.authorizationKey, }, - ui_preferences: { - theme: authorizationDetailsForm.value.theme, - create_account_link: authorizationDetailsForm.value.allowNewUserRegistration || false, - icons: authorizationDetailsForm.value.showSocialLoginIcons || false, - version: authorizationDetailsForm.value.version, - input_fields: this.previewInputPosition, - }, + // ui_preferences: { + // theme: brandingDetailsForm.controls.theme.value || 'system', + // create_account_link: brandingDetailsForm.controls.create_account_link.value || false, + // icons: brandingDetailsForm.controls.icons.value || false, + // version: brandingDetailsForm.controls.version.value || 'v1', + // input_fields: this.previewInputPosition, + // }, session_time: authorizationDetailsForm.value.session_time, }; } else { @@ -601,6 +776,25 @@ export class CreateFeatureComponent extends BaseComponent implements OnDestroy, return; } break; + case 'branding': + if (brandingDetailsForm.valid) { + const existingUiPrefs = (featureDetails as unknown as Record)?.ui_preferences as + | Record + | undefined; + const { primary_color: _removed, ...cleanedUiPrefs } = existingUiPrefs || ({} as any); + payload = { + ui_preferences: { + ...cleanedUiPrefs, + ...brandingDetailsForm.value, + input_fields: this.previewInputPosition, + logo_url: this.logoInputMode === 'file' ? this.logoUrl : brandingDetailsForm.value.logo_url, + }, + }; + } else { + brandingDetailsForm.markAllAsTouched(); + return; + } + break; case 'webhook': const webhookDetailsForm = this.featureForm.controls.webhookDetails; @@ -663,6 +857,51 @@ export class CreateFeatureComponent extends BaseComponent implements OnDestroy, if (!this.isEditMode && event?.previouslySelectedIndex === 0) { this.getServiceMethods(this.featureForm.value.primaryDetails.feature_id); } + // When entering Block Name step (index 0), focus the name input if empty + if (!this.isEditMode && event?.selectedIndex === 0) { + setTimeout(() => this.focusBlockNameInputIfEmpty(), 0); + } + // When entering Authorization Setup step (index 3), focus session time input if empty + if ( + !this.isEditMode && + this.featureForm.get('primaryDetails.feature_id')?.value === 1 && + event?.selectedIndex === 3 + ) { + setTimeout(() => this.focusSessionTimeInputIfEmpty(), 0); + } + // When entering Configure Method (step 1) for authorization block, ensure redirect_url is synced to all service redirect_uri fields + if ( + !this.isEditMode && + this.featureForm.get('primaryDetails.feature_id')?.value === 1 && + event?.selectedIndex === 1 + ) { + this.setRedirectUrlInServiceDetails(); + } + } + + public ngAfterViewInit(): void { + if (!this.isEditMode && this.stepper?.first?.selectedIndex === 0) { + setTimeout(() => this.focusBlockNameInputIfEmpty(), 100); + } + } + + private focusBlockNameInputIfEmpty(): void { + const nameValue = this.featureForm.get('primaryDetails.name')?.value; + if (nameValue != null && String(nameValue).trim() !== '') { + return; + } + this.blockNameStepContent?.nativeElement?.querySelector('input')?.focus(); + } + + private focusSessionTimeInputIfEmpty(): void { + const sessionTimeValue = this.featureForm.get('authorizationDetails.session_time')?.value; + const hasValue = + sessionTimeValue != null && + (typeof sessionTimeValue === 'string' ? String(sessionTimeValue).trim() !== '' : true); + if (hasValue) { + return; + } + this.authorizationStepContent?.nativeElement?.querySelector('input')?.focus(); } public ngOnDestroy(): void { @@ -801,8 +1040,13 @@ export class CreateFeatureComponent extends BaseComponent implements OnDestroy, this.featureForm.controls.planDetails.push(planFormGroup); } - public resetFormGroup(formGroup: FormGroup, index: number): void { + public resetFormGroup(formGroup: FormGroup | null | undefined, index: number): void { + if (!formGroup) return; + const isEnableValue = formGroup.get('is_enable')?.value; formGroup.reset(); + if (formGroup.get('is_enable') && isEnableValue !== undefined) { + formGroup.get('is_enable')?.setValue(isEnableValue); + } Object.keys(this.chipListValues) .filter((key) => +key.split('_')[1] === index) .forEach((key) => (this.chipListValues[key] = new Set(this.chipListReadOnlyValues[key]))); @@ -811,6 +1055,36 @@ export class CreateFeatureComponent extends BaseComponent implements OnDestroy, .forEach((key) => (this.fileValues[key] = null)); } + /** Deep-clone a FormGroup (and nested FormGroups/FormArrays/FormControls) for use in dialog. Copies values and validators. */ + private cloneFormGroup(source: FormGroup): FormGroup { + const clone = new FormGroup({}); + Object.keys(source.controls).forEach((key) => { + const control = source.get(key); + if (control instanceof FormGroup) { + clone.addControl(key, this.cloneFormGroup(control)); + } else if (control instanceof FormArray) { + clone.addControl( + key, + new FormArray( + control.controls.map((c) => + c instanceof FormGroup ? this.cloneFormGroup(c) : this.cloneFormControl(c as FormControl) + ) + ) + ); + } else { + clone.addControl(key, this.cloneFormControl(control as FormControl)); + } + }); + return clone; + } + + private cloneFormControl(source: FormControl): FormControl { + return new FormControl(source.value, { + validators: source.validator, + asyncValidators: source.asyncValidator, + }); + } + public updateChipListValues( operation: 'add' | 'delete', chipListKey: string, @@ -1733,4 +2007,50 @@ export class CreateFeatureComponent extends BaseComponent implements OnDestroy, this.http.post(url, sampleResponse).pipe(takeUntil(this.destroy$)).subscribe(); this.toast.success('Sample response sent successfully'); } + + /** Reset only the configure dialog form (duplicate), preserving is_enable. Does not touch chipListValues/fileValues. */ + public resetConfigureDialogForm(): void { + if (!this.configureMethodDialogForm) return; + const isEnableValue = this.configureMethodDialogForm.get('is_enable')?.value; + this.configureMethodDialogForm.reset(); + if (this.configureMethodDialogForm.get('is_enable') && isEnableValue !== undefined) { + this.configureMethodDialogForm.get('is_enable')?.setValue(isEnableValue); + } + } + + public editService(index: number): void { + this.selectedServiceIndex = index; + const serviceDetailsArray = this.featureForm.get('serviceDetails') as FormArray; + const sourceForm = serviceDetailsArray?.at(index) as ServiceFormGroup | null; + if (!sourceForm) return; + this.configureMethodDialogForm = this.cloneFormGroup(sourceForm) as ServiceFormGroup; + this.configureMethodDialog = this.dialog.open(this.configureMethodDialogTemplateRef); + this.configureMethodDialog + .afterClosed() + .pipe(takeUntil(this.destroy$)) + .subscribe((res) => { + if (res) { + if (this.configureMethodDialogForm) { + const targetForm = serviceDetailsArray?.at(this.selectedServiceIndex) as FormGroup | null; + if (targetForm) { + targetForm.patchValue(this.configureMethodDialogForm.getRawValue()); + } + this.configureMethodDialogForm = null; + } + } + }); + } + + public getSelectedServiceName(): string { + const method = this.selectedMethod.getValue(); + return method?.method_services?.[this.selectedServiceIndex]?.name ?? 'Configure Service'; + } + + public closeDialog(): void { + if (this.configureMethodDialogForm?.valid) { + this.configureMethodDialog.close('true'); + } else { + this.configureMethodDialogForm.markAllAsTouched(); + } + } } diff --git a/apps/proxy/src/app/features/create-feature/create-feature.module.ts b/apps/proxy/src/app/features/create-feature/create-feature.module.ts index f3d1b44b..554ff593 100644 --- a/apps/proxy/src/app/features/create-feature/create-feature.module.ts +++ b/apps/proxy/src/app/features/create-feature/create-feature.module.ts @@ -2,7 +2,7 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterModule, Routes } from '@angular/router'; import { MatCardModule } from '@angular/material/card'; -import { ReactiveFormsModule } from '@angular/forms'; +import { ReactiveFormsModule, FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { UiLoaderModule } from '@proxy/ui/loader'; import { MatIconModule } from '@angular/material/icon'; @@ -26,6 +26,7 @@ import { CreatePlanDialogComponent } from './create-plan-dialog/create-plan-dial import { CreateTaxDialogComponent } from './create-tax-dialog/create-tax-dialog.component'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatExpansionModule } from '@angular/material/expansion'; +import { MatRadioModule } from '@angular/material/radio'; const routes: Routes = [ { @@ -41,6 +42,7 @@ const routes: Routes = [ CommonModule, RouterModule.forChild(routes), ReactiveFormsModule, + FormsModule, MatInputModule, MatFormFieldModule, MatIconModule, @@ -61,6 +63,7 @@ const routes: Routes = [ HttpClientModule, MatTooltipModule, MatExpansionModule, + MatRadioModule, ], exports: [RouterModule], }) diff --git a/apps/proxy/src/app/features/create-feature/create-feature.store.ts b/apps/proxy/src/app/features/create-feature/create-feature.store.ts index 635292be..c0afe600 100644 --- a/apps/proxy/src/app/features/create-feature/create-feature.store.ts +++ b/apps/proxy/src/app/features/create-feature/create-feature.store.ts @@ -29,6 +29,7 @@ export interface ICreateFeatureInitialState { paymentDetailsById: any; updatePaymentDetails: any; webhookEvents: any; + uploadLogo: any; } @Injectable() @@ -58,6 +59,7 @@ export class CreateFeatureComponentStore extends ComponentStore = this.select((state) => state.updatePaymentDetails); /** Selector for webhook events data */ readonly webhookEvents$: Observable = this.select((state) => state.webhookEvents); + /** Selector for upload logo data */ + readonly uploadLogo$: Observable = this.select((state) => state.uploadLogo); /** Get feature type data */ readonly getFeatureType = this.effect((data) => { return data.pipe( @@ -690,6 +694,31 @@ export class CreateFeatureComponentStore extends ComponentStore) => { + return data.pipe( + switchMap((req) => { + this.patchState({ isLoading: true, uploadLogo: null }); + return this.service.uploadLogo(req.id, req.formData).pipe( + tapResponse( + (res: BaseResponse) => { + if (res?.hasError) { + this.showError(res.errors); + return this.patchState({ isLoading: false }); + } + this.toast.success('Logo uploaded successfully'); + return this.patchState({ isLoading: false, uploadLogo: res.data }); + }, + (error: any) => { + this.showError(error.errors); + this.patchState({ isLoading: false }); + } + ), + catchError((err) => EMPTY) + ); + }) + ); + }); + private showError(error): void { const errorMessage = errorResolver(error); errorMessage.forEach((error) => { diff --git a/apps/proxy/src/assets/scss/component/_buttons.scss b/apps/proxy/src/assets/scss/component/_buttons.scss index a11162b5..71c8f767 100644 --- a/apps/proxy/src/assets/scss/component/_buttons.scss +++ b/apps/proxy/src/assets/scss/component/_buttons.scss @@ -297,4 +297,30 @@ button.mat-btn-md.mat-btn-wran:hover[disabled] { background-color: var(--color-common-slate) !important; } } +} +.radio-button-custom-style { + .mat-radio-button { + .mat-radio-outer-circle { + border-color: var(--color-dark-accent) !important; + } + + .mat-radio-inner-circle { + background-color: var(--color-dark-accent) !important; + } + } +} +.accent-color { + .material-icons { + &.mat-icon { + &.mat-icon-no-color { + color: var(--color-dark-accent) !important; + &:hover { + color: var(--color-dark-accent) !important; + } + } + } + } +} +.accent-bg-color { + background-color: var(--color-dark-accent) !important; } \ No newline at end of file diff --git a/apps/proxy/src/styles.scss b/apps/proxy/src/styles.scss index 060a77ff..2cf1da14 100644 --- a/apps/proxy/src/styles.scss +++ b/apps/proxy/src/styles.scss @@ -105,3 +105,9 @@ canvas { width: 100%; } } + +// .block-feature-tabs { +// .mat-tab-body-wrapper { +// height: 100%; +// } +// } diff --git a/libs/models/features-model/src/index.ts b/libs/models/features-model/src/index.ts index 6003e565..46c8c956 100644 --- a/libs/models/features-model/src/index.ts +++ b/libs/models/features-model/src/index.ts @@ -98,6 +98,7 @@ export enum FeatureFieldType { ReadFile = 'readFile', Select = 'select', TextArea = 'textarea', + Password = 'password', } export const ProxyAuthScript = ( @@ -132,6 +133,7 @@ export enum FeatureServiceIds { Msg91OtpService = 6, GoogleAuthentication = 7, PasswordAuthentication = 9, + AppleAuthentication = 8, } export const ProxyUserManagementScript = ( baseUrl: string, diff --git a/libs/services/proxy/features/src/lib/services-proxy-features.module.ts b/libs/services/proxy/features/src/lib/services-proxy-features.module.ts index 3dcc3e01..f165e2b0 100644 --- a/libs/services/proxy/features/src/lib/services-proxy-features.module.ts +++ b/libs/services/proxy/features/src/lib/services-proxy-features.module.ts @@ -122,4 +122,10 @@ export class FeaturesService { public getWebhookEvents(): Observable> { return this.http.get>(FeaturesUrls.getWebhookEvents(this.baseURL)); } + + public uploadLogo(id: string | number, formData: FormData): Observable> { + return this.http.post>(FeaturesUrls.uploadLogo(this.baseURL, id), formData, { + headers: {}, + }); + } } diff --git a/libs/urls/features-url/src/index.ts b/libs/urls/features-url/src/index.ts index 373f99ee..c1fde911 100644 --- a/libs/urls/features-url/src/index.ts +++ b/libs/urls/features-url/src/index.ts @@ -20,4 +20,5 @@ export const FeaturesUrls = { getPaymentDetailsFormById: (baseUrl, refid) => createUrl(baseUrl, `${refid}/paymentForm`), updatePaymentDetails: (baseUrl, refid) => createUrl(baseUrl, `subscription/${refid}/updateCredentials`), getWebhookEvents: (baseUrl) => createUrl(baseUrl, `getWebhookTriggerEvents`), + uploadLogo: (baseUrl, id) => createUrl(baseUrl, `features/${id}/upload-logo`), };