diff --git a/frontend/src/app/app-management/app-management-routing.module.ts b/frontend/src/app/app-management/app-management-routing.module.ts index 96c7c0800..1ce90301d 100644 --- a/frontend/src/app/app-management/app-management-routing.module.ts +++ b/frontend/src/app/app-management/app-management-routing.module.ts @@ -16,6 +16,7 @@ import {MenuComponent} from './menu/menu.component'; import {RolloverConfigComponent} from './rollover-config/rollover-config.component'; import {UtmApiDocComponent} from './utm-api-doc/utm-api-doc.component'; import {UtmNotificationViewComponent} from './utm-notification/components/notifications-view/utm-notification-view.component'; +import { SubscriptionComponent } from './subscription/subscription.component'; import {ApiKeysComponent} from "./api-keys/api-keys.component"; import {IdentityProviderComponent} from "./identity-provider/identity-provider.component"; @@ -54,6 +55,12 @@ const routes: Routes = [ canActivate: [UserRouteAccessService], data: {authorities: [ADMIN_ROLE]} }, + { + path: 'subscription', + component: SubscriptionComponent, + canActivate: [UserRouteAccessService], + data: {authorities: [ADMIN_ROLE]} + }, { path: 'index-pattern', component: IndexPatternListComponent, diff --git a/frontend/src/app/app-management/app-management.module.ts b/frontend/src/app/app-management/app-management.module.ts index d56cca75b..f69baaa73 100644 --- a/frontend/src/app/app-management/app-management.module.ts +++ b/frontend/src/app/app-management/app-management.module.ts @@ -51,6 +51,10 @@ import { UtmNotificationViewComponent } from './utm-notification/components/notifications-view/utm-notification-view.component'; import { IdentityProviderModalComponent } from './identity-provider/shared/components/identity-provider-modal/identity-provider-modal.component'; +import { SubscriptionComponent } from './subscription/subscription.component'; +import { PlanCardsComponent } from './subscription/components/plans/plans.component'; +import { SubscriptionAllowedDirective } from './subscription/directives/allowed-in-subscription'; +import { SubscriptionLimitModalComponent } from './subscription/components/subscription-limit-modal/subscription-limit-modal.component'; @NgModule({ declarations: [ @@ -89,7 +93,11 @@ import { IdentityProviderModalComponent } from './identity-provider/shared/compo IdentityProviderComponent, ProviderComponent, ProviderFormComponent, - IdentityProviderModalComponent + IdentityProviderModalComponent, + SubscriptionComponent, + PlanCardsComponent, + SubscriptionAllowedDirective, + SubscriptionLimitModalComponent, ], entryComponents: [ IndexPatternHelpComponent, @@ -99,7 +107,8 @@ import { IdentityProviderModalComponent } from './identity-provider/shared/compo TokenActivateComponent, ApiKeyModalComponent, IndexDeleteComponent, - IdentityProviderModalComponent + IdentityProviderModalComponent, + SubscriptionLimitModalComponent ], imports: [ CommonModule, diff --git a/frontend/src/app/app-management/connection-key/connection-key.component.html b/frontend/src/app/app-management/connection-key/connection-key.component.html index aa95f0ae7..9a200eb75 100644 --- a/frontend/src/app/app-management/connection-key/connection-key.component.html +++ b/frontend/src/app/app-management/connection-key/connection-key.component.html @@ -49,7 +49,6 @@
Connection Key
[allowCopy]="true" [secret]="token"> -
diff --git a/frontend/src/app/app-management/layout/app-management-sidebar/app-management-sidebar.component.html b/frontend/src/app/app-management/layout/app-management-sidebar/app-management-sidebar.component.html index 1ca77a565..c3b1f5b10 100644 --- a/frontend/src/app/app-management/layout/app-management-sidebar/app-management-sidebar.component.html +++ b/frontend/src/app/app-management/layout/app-management-sidebar/app-management-sidebar.component.html @@ -13,6 +13,19 @@
+ + +
+ +   + Subscription + +
+
+ +
+ lkjkjlkjlk + +

+ {{ mode === 'profile' ? 'Available Plans' : 'Choose a Plan to Start' }} +

+
+ + + + +
+ +
+ + +
+
+ + + Current Plan + + + +
+

{{ plan.name }}

+ + +
+ + + +
+ + + +
+ + ${{ selectedPrice.price }} + {{ selectedPrice.interval === 'month' ? '/mo' : '/year' }} + + + Free + +
+
+ + +
    +
  • + + {{ plan.allowed_users }} user{{ plan.allowed_users > 1 ? 's' : '' }} +
  • + +
  • + + {{ plan.max_resources }} resources +
  • +
  • + + {{ plan.ai_requests }} AI requests / week +
  • + +
  • + + {{ plan.custom_domain ? '✔' : '✖' }} + + + Custom domain + +
  • + +
  • + + + {{plan.active_projects}} Active Projects + +
  • + + +
  • + + {{ plan.visual_editor ? '✔' : '✖' }} + + + Visual editor + +
  • + + +
  • + + {{ plan.support ? '✔' : '✖' }} + + + Customer Service chat + +
  • + + +
  • + + {{ plan.project_level_roles ? '✔' : '✖' }} + + + Project-level roles + +
  • +
+
+ + + +
+
+
+
+
+
+ + + + + diff --git a/frontend/src/app/app-management/subscription/components/plans/plans.component.scss b/frontend/src/app/app-management/subscription/components/plans/plans.component.scss new file mode 100644 index 000000000..5394b0784 --- /dev/null +++ b/frontend/src/app/app-management/subscription/components/plans/plans.component.scss @@ -0,0 +1,3 @@ +.text-body-emphasis{ + color:black; +} diff --git a/frontend/src/app/app-management/subscription/components/plans/plans.component.ts b/frontend/src/app/app-management/subscription/components/plans/plans.component.ts new file mode 100644 index 000000000..2af7a9869 --- /dev/null +++ b/frontend/src/app/app-management/subscription/components/plans/plans.component.ts @@ -0,0 +1,205 @@ +import { + Component, + Input, + OnInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + OnDestroy, +} from '@angular/core'; +import { + combineLatest, + Observable, + Subject, +} from 'rxjs'; +import { + takeUntil, + map, + finalize, +} from 'rxjs/operators' +import { Router } from '@angular/router'; +import { + PlanModel, + PlanPrice, + StripeUrlModel, + SubscriptionModel, +} from '../../models/plan.model'; +import { SubscriptionService } from '../../services/subscription.service'; + +type Status = 'new' | 'trialing' | 'active' | 'from_code'; + +@Component({ + selector: 'app-plan-cards', + templateUrl: './plans.component.html', + styleUrls: ['./plans.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PlanCardsComponent implements OnInit, OnDestroy { + @Input() mode: 'profile' | 'setup' = 'profile'; + + plans$!: Observable; + subscription$!: Observable; + + codeSubscriptionLoading: boolean = false; + codeSubscriptionError: string = ''; + + subStatus: Status = 'new'; + currentPriceId: string | null = null; + currentPlanPosition: number | null = null; + + readonly customPlanUrl = 'https://example.com'; + private destroy$ = new Subject(); + + constructor( + private subscriptionService: SubscriptionService, + private cdr: ChangeDetectorRef, + private router: Router + ) {} + + ngOnInit(): void { + this.plans$ = this.subscriptionService.plansObservable.pipe( + map((plans) => plans.filter((p) => p.position >= 0)) + ); + this.subscription$ = this.subscriptionService.subscriptionObservable; + + combineLatest([this.plans$, this.subscription$]) + .pipe(takeUntil(this.destroy$)) + .subscribe(([plans, sub]:[PlanModel[],SubscriptionModel]) => { + if (!sub || sub.status === 'new') { + this.subStatus = 'new'; + this.currentPriceId = null; + this.currentPlanPosition = null; + } else { + this.subStatus = sub.status as Status; + this.currentPriceId = sub.price_id; + } + + if (this.currentPriceId != null) { + const currentPlan = plans.filter((plan) => + plan.prices.find((p) => p.id == this.currentPriceId) + )[0]; + if (currentPlan) { + this.currentPlanPosition = currentPlan.position; + const currentPrice = currentPlan.prices.find( + (p) => p.id == this.currentPriceId + ); + if (currentPrice.interval && currentPrice.interval == 'year') { + this.isAnnualSelected[currentPlan.id] = true; + } + } + } + + this.cdr.markForCheck(); + }); + } + + isAnnualSelected: { [planId: string]: boolean } = {}; + + setBillingPeriod(planId: string, isAnnual: boolean) { + this.isAnnualSelected[planId] = isAnnual; + } + + getButtonText(plan: PlanModel): string { + if (this.subStatus === 'new' || this.subStatus === 'from_code') { + return 'Select Plan'; + } + + const selectedPrice = this.getSelectedPrice(plan); + if (selectedPrice && selectedPrice.id === this.currentPriceId) { + return 'Manage Plan'; + } + + if (this.currentPlanPosition !== null) { + return plan.position > this.currentPlanPosition ? 'Upgrade' : 'Downgrade'; + } + + return 'Select Plan'; + } + + getSelectedPrice(plan: PlanModel): PlanPrice | undefined { + // If plan has only one price (like Free plan with one_time), return that price + if (plan.prices.length === 1) { + return plan.prices[0]; + } + + // Free plans always show yearly price + if (plan.name.toLowerCase().includes('free')) { + return plan.prices.find((p) => p.interval === 'year') || plan.prices[0]; + } + + // For plans with multiple prices, use the toggle selection logic + return plan.prices.find((p) => + this.isAnnualSelected[plan.id] + ? p.interval === 'year' + : p.interval === 'month' + ); + } + + onPlanAction(plan: PlanModel): void { + const selectedPrice = this.getSelectedPrice(plan); + if (!selectedPrice) { + console.error('No selected price found for plan:', plan); + return; + } + + // Si es el plan actual, ir al portal para gestionar + if (selectedPrice.id === this.currentPriceId) { + this.subscriptionService + .getPortalSession() + .subscribe((r: StripeUrlModel) => (window.location.href = r.url)); + } else { + // Para cualquier otro plan (nuevo usuario o upgrade/downgrade), usar checkout + this.subscriptionService + .getCheckoutSession(selectedPrice.id) + .subscribe((r: StripeUrlModel) => (window.location.href = r.url)); + } + } + + contactSupport(): void { + window.open(this.customPlanUrl, '_blank'); + } + + isPromoCodeModalOpen = false; + promoCode = ''; + + openPromoCodeModal() { + this.codeSubscriptionError = ''; + this.isPromoCodeModalOpen = true; + } + + closePromoCodeModal() { + this.isPromoCodeModalOpen = false; + this.promoCode = ''; + this.codeSubscriptionError = ''; + } + + applyPromoCode() { + if (this.promoCode) { + this.codeSubscriptionLoading = true; + this.subscriptionService + .subscribeWithPromoCode(this.promoCode) + .pipe(finalize(() => (this.codeSubscriptionLoading = false))) + .subscribe({ + next: () => { + this.closePromoCodeModal(); + if (this.subStatus == 'new') { + this.router.navigate(['/onboarding']); + } else { + this.router.navigate(['/']); + } + }, + error: (error) => { + console.error('Failed to apply promo code', error); + this.codeSubscriptionError = 'Invalid Code'; + this.codeSubscriptionLoading = false; + this.cdr.markForCheck(); + }, + }); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} + diff --git a/frontend/src/app/app-management/subscription/components/subscription-limit-modal/subscription-limit-modal.component.html b/frontend/src/app/app-management/subscription/components/subscription-limit-modal/subscription-limit-modal.component.html new file mode 100644 index 000000000..c749fe682 --- /dev/null +++ b/frontend/src/app/app-management/subscription/components/subscription-limit-modal/subscription-limit-modal.component.html @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/app/app-management/subscription/components/subscription-limit-modal/subscription-limit-modal.component.scss b/frontend/src/app/app-management/subscription/components/subscription-limit-modal/subscription-limit-modal.component.scss new file mode 100644 index 000000000..00dfa5a87 --- /dev/null +++ b/frontend/src/app/app-management/subscription/components/subscription-limit-modal/subscription-limit-modal.component.scss @@ -0,0 +1 @@ +/* No custom styles needed for now. */ diff --git a/frontend/src/app/app-management/subscription/components/subscription-limit-modal/subscription-limit-modal.component.ts b/frontend/src/app/app-management/subscription/components/subscription-limit-modal/subscription-limit-modal.component.ts new file mode 100644 index 000000000..d35ab7506 --- /dev/null +++ b/frontend/src/app/app-management/subscription/components/subscription-limit-modal/subscription-limit-modal.component.ts @@ -0,0 +1,29 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'app-subscription-limit-modal', + templateUrl: './subscription-limit-modal.component.html', + styleUrls: ['./subscription-limit-modal.component.scss'] +}) +export class SubscriptionLimitModalComponent implements OnInit { + + @Input() title: string; + @Input() message: string; + @Input() showUpgradeButton: boolean; + @Input() icon: 'warning' | 'error' | 'info' = 'warning'; + + constructor( + public activeModal: NgbActiveModal, + private router: Router + ) { } + + ngOnInit(): void { + } + + upgrade(): void { + this.activeModal.dismiss('upgrade'); + this.router.navigate(['/profile/plans']); + } +} diff --git a/frontend/src/app/app-management/subscription/directives/allowed-in-subscription.ts b/frontend/src/app/app-management/subscription/directives/allowed-in-subscription.ts new file mode 100644 index 000000000..abd833040 --- /dev/null +++ b/frontend/src/app/app-management/subscription/directives/allowed-in-subscription.ts @@ -0,0 +1,113 @@ +import { + Directive, + Input, + OnDestroy, + Renderer2, + HostListener, + ElementRef, + Output, + EventEmitter +} from '@angular/core'; +import { Subject, of } from 'rxjs'; +import { switchMap, takeUntil } from 'rxjs/operators'; +import { SubscriptionService } from '../services/subscription.service'; +import { SubscriptionLimitModalService } from '../services/subscription-limit-modal.service'; + +interface CheckConfig { + entity: string; + projectId?: string; + versionId?: string; +} + +@Directive({ + selector: '[allowedInSubscription]', +}) +export class SubscriptionAllowedDirective implements OnDestroy { + private config?: CheckConfig; + private isFeatureAllowed: boolean = true; + private destroy$ = new Subject(); + + @Output() allowedClick = new EventEmitter(); + + constructor( + private elementRef: ElementRef, + private renderer: Renderer2, + private subscriptionService: SubscriptionService, + private modalService: SubscriptionLimitModalService + ) {} + + @Input() + set allowedInSubscription(config: CheckConfig) { + if (!config || !config.entity) return; + + // Check if config actually changed to avoid unnecessary re-checks + const configChanged = !this.config || + this.config.entity !== config.entity || + this.config.projectId !== config.projectId || + this.config.versionId !== config.versionId; + + this.config = config; + + // Only check if config changed + if (configChanged) { + this.checkFeatureAccess(); + } + } + + @HostListener('click', ['$event']) + onClick(event: MouseEvent): void { + if (this.isFeatureAllowed) { + this.allowedClick.emit(event); + } else { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + this.modalService.showFeatureLimitModal(this.config!.entity.replace(/_/g, ' ')); + } + } + + private checkFeatureAccess(): void { + this.subscriptionService.subscriptionObservable + .pipe( + switchMap(() => { + const { entity, projectId, versionId } = this.config!; + + const featuresNeedingProjectId = ['ai_requests', 'max_resources']; + + if (featuresNeedingProjectId.includes(entity)) { + // If projectId is required but not available, skip the validation + if (!projectId) { + console.warn(`Feature ${entity} requires projectId but it's not provided`); + return of(true); // Allow by default until projectId is available + } + return this.subscriptionService.validateFeature(entity, projectId, versionId); + } + + return this.subscriptionService.validateFeature(entity); + }), + takeUntil(this.destroy$) + ) + .subscribe(isAllowed => { + this.isFeatureAllowed = isAllowed; + this.updateElementStyle(isAllowed); + }); + } + + private updateElementStyle(isAllowed: boolean): void { + const element = this.elementRef.nativeElement; + if (!element) return; + + if (!isAllowed) { + this.renderer.setStyle(element, 'opacity', '0.5'); + this.renderer.setStyle(element, 'cursor', 'not-allowed'); + } else { + this.renderer.removeStyle(element, 'opacity'); + this.renderer.removeStyle(element, 'cursor'); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/frontend/src/app/app-management/subscription/models/feature.validation.ts b/frontend/src/app/app-management/subscription/models/feature.validation.ts new file mode 100644 index 000000000..0257f0a0b --- /dev/null +++ b/frontend/src/app/app-management/subscription/models/feature.validation.ts @@ -0,0 +1,5 @@ + +export interface FeatureValidation{ + valid:boolean; + message:string; +} diff --git a/frontend/src/app/app-management/subscription/models/plan.model.ts b/frontend/src/app/app-management/subscription/models/plan.model.ts new file mode 100644 index 000000000..f91078883 --- /dev/null +++ b/frontend/src/app/app-management/subscription/models/plan.model.ts @@ -0,0 +1,53 @@ + +export class PlanPrice { + plan_id: string; + id: string; + price: number; + currency: string; + interval: 'day' | 'week' | 'month' | 'year'; +} + + + +export class PlanModel { + id: string; + name: string; + description: string; + default_price_id: string; + active_projects: number; + ai_requests: number; + allowed_users: number; + custom_domain: boolean; + position: number; + max_resources: number; + project_level_roles: boolean; + support: boolean; + visual_editor: boolean + prices: PlanPrice[]; +} + + + +export class SubscriptionModel { + price_id: string|undefined; + name: string|undefined; + active_projects: number|undefined; + ai_requests: number|undefined; + allowed_users: number|undefined; + custom_domain: boolean|undefined; + position: number|undefined; + max_resources: number|undefined; + project_level_roles: boolean|undefined; + quantity: number|undefined; + status: string|undefined; + current_period_start: string|undefined; + current_period_end: string|undefined; + cancel_at_period_end: boolean|undefined; + support: boolean|undefined; + visual_editor: boolean + from_promotion_code?:boolean +} + +export class StripeUrlModel { + url: string; +} diff --git a/frontend/src/app/app-management/subscription/services/plan-http/plan-http.service.ts b/frontend/src/app/app-management/subscription/services/plan-http/plan-http.service.ts new file mode 100644 index 000000000..3e0675e26 --- /dev/null +++ b/frontend/src/app/app-management/subscription/services/plan-http/plan-http.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from 'src/environments/environment'; + +@Injectable({ providedIn: 'root' }) +export class PlanHttpService { + public resourceUrl = environment.CUSTOMMER_MANAGER_API_URL + 'api/v1/subscription'; + + constructor(protected http: HttpClient) {} + + getPlans(): Observable { + return this.http.get(`${this.resourceUrl}/plans`); + } + + getSubscription(): Observable { + return this.http.get(this.resourceUrl); + } + + getCheckoutSession(price_id:string): Observable { + return this.http.get(`${this.resourceUrl}/checkout?price_id=${price_id}`); + } + + getCustomerPortal(): Observable { + return this.http.get(`${this.resourceUrl}/customer-portal`); + } + + stripeWebhook(payload: any): Observable { + return this.http.post(`${this.resourceUrl}/webhook`, payload); + } + + updateBillingEmail(email: string): Observable { + return this.http.put(`${this.resourceUrl}/billing-email`, { email }); + } + + validateFeature(): Observable { + return this.http.get(`${this.resourceUrl}/validate-feature`); + } +} diff --git a/frontend/src/app/app-management/subscription/services/subscription-limit-modal.service.ts b/frontend/src/app/app-management/subscription/services/subscription-limit-modal.service.ts new file mode 100644 index 000000000..654339909 --- /dev/null +++ b/frontend/src/app/app-management/subscription/services/subscription-limit-modal.service.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@angular/core'; +import { SubscriptionLimitModalComponent } from '../components/subscription-limit-modal/subscription-limit-modal.component'; +import { ModalService } from 'src/app/core/modal/modal.service'; + +export interface SubscriptionLimitModalOptions { + title?: string; + message: string; + icon?: 'warning' | 'error' | 'info'; + showUpgradeButton?: boolean; +} + +@Injectable({ + providedIn: 'root' +}) +export class SubscriptionLimitModalService { + + constructor(private modalService: ModalService) { } + + showFeatureLimitModal(entity: string): void { + const numericLimits = ['active projects', 'ai generations per month', 'allowed users', 'max resources']; + const featureName = entity.replace(/_/g, ' '); + + if (numericLimits.includes(entity)) { + let title = `${featureName.charAt(0).toUpperCase() + featureName.slice(1)} Limit Reached`; + + if (entity === 'allowed_users') { + title = 'User Limit Reached'; + } else if (entity === 'active_projects') { + title = 'Project Limit Reached'; + } + + this.showLimitModal({ + title: title, + message: `You've reached your ${featureName} limit. Upgrade your plan to increase it.`, + showUpgradeButton: true, + icon: 'warning' + }); + } else { + this.showLimitModal({ + title: 'Feature Not Available', + message: `The ${featureName} feature is not available in your current plan. Upgrade to access this feature.`, + icon: 'info', + showUpgradeButton: true + }); + } + } + + private showLimitModal(options: SubscriptionLimitModalOptions): void { + const modalRef = this.modalService.open(SubscriptionLimitModalComponent, { + centered: true + }); + modalRef.componentInstance.title = options.title || 'Subscription Limit Reached'; + modalRef.componentInstance.message = options.message; + modalRef.componentInstance.icon = options.icon || 'warning'; + modalRef.componentInstance.showUpgradeButton = options.showUpgradeButton || false; + } +} diff --git a/frontend/src/app/app-management/subscription/services/subscription.service.ts b/frontend/src/app/app-management/subscription/services/subscription.service.ts new file mode 100644 index 000000000..aac6eba12 --- /dev/null +++ b/frontend/src/app/app-management/subscription/services/subscription.service.ts @@ -0,0 +1,136 @@ +import { Injectable, OnDestroy } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { BehaviorSubject, Observable, Subscription, throwError, of } from 'rxjs'; +import { catchError, finalize, map, tap } from 'rxjs/operators'; + +import { PlanModel, SubscriptionModel, StripeUrlModel } from '../models/plan.model'; +import { FeatureValidation } from '../models/feature.validation'; +import { environment } from 'src/environments/environment'; + +const API_SUBSCRIPTION_URL = `${environment.CUSTOMMER_MANAGER_API_URL}/subscription`; + +@Injectable({ + providedIn: 'root', +}) +export class SubscriptionService implements OnDestroy { + private plans$ = new BehaviorSubject([]); + private subscription$ = new BehaviorSubject(undefined); + private isLoading$ = new BehaviorSubject(false); + private subs: Subscription[] = []; + + constructor(private http: HttpClient) { + this.fetchPlans().subscribe(); + this.fetchSubscription().subscribe(); + } + + // Public Observables + get plansObservable(): Observable { + return this.plans$.asObservable(); + } + + get subscriptionObservable(): Observable { + return this.subscription$.asObservable(); + } + + get loading$(): Observable { + return this.isLoading$.asObservable(); + } + + // Data Accessors + get currentPlans(): PlanModel[] { + return this.plans$.value; + } + + get currentSubscription(): SubscriptionModel | undefined { + return this.subscription$.value; + } + + // API Calls + fetchPlans(): Observable { + this.isLoading$.next(true); + return this.http.get(`${API_SUBSCRIPTION_URL}/plans`).pipe( + tap((plans)=>{console.log(plans)}), + tap((plans)=>{alert()}), + tap((plans) => this.plans$.next(plans.sort((a, b) => a.position - b.position))), + finalize(() => this.isLoading$.next(false)), + catchError((err) => { + console.error('Failed to fetch plans', err); + return throwError(() => new Error('Failed to fetch plans')); + }) + ); + } + + fetchSubscription(): Observable { + this.isLoading$.next(true); + return this.http.get(API_SUBSCRIPTION_URL).pipe( + tap((sub) => { + this.subscription$.next(sub); + }), + finalize(() => this.isLoading$.next(false)), + catchError((err) => { + console.error('Failed to fetch subscription', err); + if (err.status === 404) { + this.subscription$.next({ status: 'new' } as SubscriptionModel); + return of({ status: 'new' } as SubscriptionModel); + } + return throwError(() => new Error('Failed to fetch subscription')); + }) + ); + } + + getCheckoutSession(priceId: string): Observable { + return this.http.get(`${API_SUBSCRIPTION_URL}/checkout?price_id=${priceId}`).pipe( + catchError((err) => { + console.error('Failed to create checkout session', err); + return throwError(() => new Error('Checkout session failed')); + }) + ); + } + + getPortalSession(): Observable { + return this.http.get(`${API_SUBSCRIPTION_URL}/customer-portal`).pipe( + catchError((err) => { + console.error('Failed to create portal session', err); + return throwError(() => new Error('Portal session failed')); + }) + ); + } + + subscribeWithPromoCode(code: string): Observable { + this.isLoading$.next(true); + return this.http.post(`${API_SUBSCRIPTION_URL}/promo-code?promo-code=${code}`, {}).pipe( + finalize(() => this.isLoading$.next(false)), + tap(() => this.fetchSubscription().subscribe()), + catchError((err) => { + console.error('Failed to subscribe with promo code', err); + return throwError(err); + }) + ); + } + + setBillingEmail(email: string) { + this.isLoading$.next(true); + return this.http.put(`${API_SUBSCRIPTION_URL}/billing-email?email=${email}`, {}).pipe( + finalize(() => this.isLoading$.next(false)) + ); + } + + validateFeature(feature: string, projectId?: string, versionId?: string): Observable { + let url = `${API_SUBSCRIPTION_URL}/validate-feature?feature=${feature}`; + if (projectId) { + url += `&project-id=${projectId}`; + } + if (versionId) { + url += `&version-id=${versionId}`; + } + return this.http.get(url).pipe(map(fv => fv.valid)); + } + + refreshSubscription() { + this.subscription$.next(this.subscription$.value); + } + + ngOnDestroy() { + this.subs.forEach((sb) => sb.unsubscribe()); + } +} diff --git a/frontend/src/app/app-management/subscription/subscription.component.html b/frontend/src/app/app-management/subscription/subscription.component.html new file mode 100644 index 000000000..5022ccf87 --- /dev/null +++ b/frontend/src/app/app-management/subscription/subscription.component.html @@ -0,0 +1,7 @@ +
+
+
+ +
+
+
diff --git a/frontend/src/app/app-management/subscription/subscription.component.scss b/frontend/src/app/app-management/subscription/subscription.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/app/app-management/subscription/subscription.component.ts b/frontend/src/app/app-management/subscription/subscription.component.ts new file mode 100644 index 000000000..7b460b920 --- /dev/null +++ b/frontend/src/app/app-management/subscription/subscription.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-subscription', + templateUrl: './subscription.component.html', + styleUrls: ['./subscription.component.scss'] +}) +export class SubscriptionComponent { + +} diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts index c14489291..ec084d7c1 100644 --- a/frontend/src/environments/environment.ts +++ b/frontend/src/environments/environment.ts @@ -5,6 +5,7 @@ export const environment = { production: false, SERVER_API_URL: 'https://192.168.1.18/', + CUSTOMMER_MANAGER_API_URL: 'http://localhost:8080/api/v1', //SERVER_API_URL: 'http://localhost:8080/', SERVER_API_CONTEXT: '', SESSION_AUTH_TOKEN: window.location.host.split(':')[0].toLocaleUpperCase(), diff --git a/installer/install.go b/installer/install.go index 0cd337b1a..fd301e895 100644 --- a/installer/install.go +++ b/installer/install.go @@ -193,8 +193,8 @@ func Install(specificVersion string) error { } fmt.Println(" [OK]") - fmt.Printf("Installing UTMStack version %s-%s. This may take a while.\n", version.Version, version.Edition) - err = docker.StackUP(version.Version + "-" + version.Edition) + fmt.Printf("Installing UTMStack version %s. This may take a while.\n", version.Version) + err = docker.StackUP(version.Version) if err != nil { return err }