parent
606350b2ff
commit
78e0fdb0ca
@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## 1.88.0 - 09.12.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a coupon system
|
||||||
|
|
||||||
## 1.87.0 - 07.12.2021
|
## 1.87.0 - 07.12.2021
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
@ -31,7 +31,6 @@ export class AdminController {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly adminService: AdminService,
|
private readonly adminService: AdminService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly propertyService: PropertyService,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@ -729,8 +729,8 @@ export class PortfolioService {
|
|||||||
currentNetPerformance,
|
currentNetPerformance,
|
||||||
currentNetPerformancePercent,
|
currentNetPerformancePercent,
|
||||||
currentValue,
|
currentValue,
|
||||||
isAllTimeHigh: true, // TODO
|
isAllTimeHigh: true,
|
||||||
isAllTimeLow: false // TODO
|
isAllTimeLow: false
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
|
import { PROPERTY_COUPONS } from '@ghostfolio/common/config';
|
||||||
|
import { Coupon } from '@ghostfolio/common/interfaces';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
@ -14,6 +17,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { Response } from 'express';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { SubscriptionService } from './subscription.service';
|
import { SubscriptionService } from './subscription.service';
|
||||||
@ -22,13 +26,63 @@ import { SubscriptionService } from './subscription.service';
|
|||||||
export class SubscriptionController {
|
export class SubscriptionController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly propertyService: PropertyService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
private readonly subscriptionService: SubscriptionService
|
private readonly subscriptionService: SubscriptionService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@Post('redeem-coupon')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async redeemCoupon(
|
||||||
|
@Body() { couponCode }: { couponCode: string },
|
||||||
|
@Res() res: Response
|
||||||
|
) {
|
||||||
|
if (!this.request.user) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let coupons =
|
||||||
|
((await this.propertyService.getByKey(PROPERTY_COUPONS)) as Coupon[]) ??
|
||||||
|
[];
|
||||||
|
|
||||||
|
const isValid = coupons.some((coupon) => {
|
||||||
|
return coupon.code === couponCode;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
|
StatusCodes.BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.subscriptionService.createSubscription(this.request.user.id);
|
||||||
|
|
||||||
|
// Destroy coupon
|
||||||
|
coupons = coupons.filter((coupon) => {
|
||||||
|
return coupon.code !== couponCode;
|
||||||
|
});
|
||||||
|
await this.propertyService.put({
|
||||||
|
key: PROPERTY_COUPONS,
|
||||||
|
value: JSON.stringify(coupons)
|
||||||
|
});
|
||||||
|
|
||||||
|
Logger.log(`Coupon with code '${couponCode}' has been redeemed`);
|
||||||
|
|
||||||
|
res.status(StatusCodes.OK);
|
||||||
|
|
||||||
|
return <any>res.json({
|
||||||
|
message: getReasonPhrase(StatusCodes.OK),
|
||||||
|
statusCode: StatusCodes.OK
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Get('stripe/callback')
|
@Get('stripe/callback')
|
||||||
public async stripeCallback(@Req() req, @Res() res) {
|
public async stripeCallback(@Req() req, @Res() res) {
|
||||||
await this.subscriptionService.createSubscription(
|
await this.subscriptionService.createSubscriptionViaStripe(
|
||||||
req.query.checkoutSessionId
|
req.query.checkoutSessionId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { SubscriptionController } from './subscription.controller';
|
import { SubscriptionController } from './subscription.controller';
|
||||||
import { SubscriptionService } from './subscription.service';
|
import { SubscriptionService } from './subscription.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
imports: [PropertyModule],
|
||||||
controllers: [SubscriptionController],
|
controllers: [SubscriptionController],
|
||||||
providers: [ConfigurationService, PrismaService, SubscriptionService],
|
providers: [ConfigurationService, PrismaService, SubscriptionService],
|
||||||
exports: [SubscriptionService]
|
exports: [SubscriptionService]
|
||||||
|
@ -2,7 +2,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Subscription } from '@prisma/client';
|
import { Subscription, User } from '@prisma/client';
|
||||||
import { addDays, isBefore } from 'date-fns';
|
import { addDays, isBefore } from 'date-fns';
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
@ -64,22 +64,28 @@ export class SubscriptionService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createSubscription(aCheckoutSessionId: string) {
|
public async createSubscription(aUserId: string) {
|
||||||
|
await this.prismaService.subscription.create({
|
||||||
|
data: {
|
||||||
|
expiresAt: addDays(new Date(), 365),
|
||||||
|
User: {
|
||||||
|
connect: {
|
||||||
|
id: aUserId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Logger.log(`Subscription for user '${aUserId}' has been created`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createSubscriptionViaStripe(aCheckoutSessionId: string) {
|
||||||
try {
|
try {
|
||||||
const session = await this.stripe.checkout.sessions.retrieve(
|
const session = await this.stripe.checkout.sessions.retrieve(
|
||||||
aCheckoutSessionId
|
aCheckoutSessionId
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.prismaService.subscription.create({
|
await this.createSubscription(session.client_reference_id);
|
||||||
data: {
|
|
||||||
expiresAt: addDays(new Date(), 365),
|
|
||||||
User: {
|
|
||||||
connect: {
|
|
||||||
id: session.client_reference_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.stripe.customers.update(session.customer as string, {
|
await this.stripe.customers.update(session.customer as string, {
|
||||||
description: session.client_reference_id
|
description: session.client_reference_id
|
||||||
|
@ -6,11 +6,12 @@ import { DataService } from '@ghostfolio/client/services/data.service';
|
|||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import {
|
import {
|
||||||
DEFAULT_DATE_FORMAT,
|
DEFAULT_DATE_FORMAT,
|
||||||
|
PROPERTY_COUPONS,
|
||||||
PROPERTY_CURRENCIES,
|
PROPERTY_CURRENCIES,
|
||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
PROPERTY_SYSTEM_MESSAGE
|
PROPERTY_SYSTEM_MESSAGE
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
import { Coupon, InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import {
|
import {
|
||||||
differenceInSeconds,
|
differenceInSeconds,
|
||||||
@ -28,11 +29,13 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
templateUrl: './admin-overview.html'
|
templateUrl: './admin-overview.html'
|
||||||
})
|
})
|
||||||
export class AdminOverviewComponent implements OnDestroy, OnInit {
|
export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||||
|
public coupons: Coupon[];
|
||||||
public customCurrencies: string[];
|
public customCurrencies: string[];
|
||||||
public dataGatheringInProgress: boolean;
|
public dataGatheringInProgress: boolean;
|
||||||
public dataGatheringProgress: number;
|
public dataGatheringProgress: number;
|
||||||
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
||||||
public exchangeRates: { label1: string; label2: string; value: number }[];
|
public exchangeRates: { label1: string; label2: string; value: number }[];
|
||||||
|
public hasPermissionForSubscription: boolean;
|
||||||
public hasPermissionForSystemMessage: boolean;
|
public hasPermissionForSystemMessage: boolean;
|
||||||
public hasPermissionToToggleReadOnlyMode: boolean;
|
public hasPermissionToToggleReadOnlyMode: boolean;
|
||||||
public info: InfoItem;
|
public info: InfoItem;
|
||||||
@ -61,6 +64,11 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
if (state?.user) {
|
if (state?.user) {
|
||||||
this.user = state.user;
|
this.user = state.user;
|
||||||
|
|
||||||
|
this.hasPermissionForSubscription = hasPermission(
|
||||||
|
this.info.globalPermissions,
|
||||||
|
permissions.enableSubscription
|
||||||
|
);
|
||||||
|
|
||||||
this.hasPermissionForSystemMessage = hasPermission(
|
this.hasPermissionForSystemMessage = hasPermission(
|
||||||
this.info.globalPermissions,
|
this.info.globalPermissions,
|
||||||
permissions.enableSystemMessage
|
permissions.enableSystemMessage
|
||||||
@ -96,6 +104,11 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onAddCoupon() {
|
||||||
|
const coupons = [...this.coupons, { code: this.generateCouponCode(16) }];
|
||||||
|
this.putCoupons(coupons);
|
||||||
|
}
|
||||||
|
|
||||||
public onAddCurrency() {
|
public onAddCurrency() {
|
||||||
const currency = prompt('Please add a currency:');
|
const currency = prompt('Please add a currency:');
|
||||||
|
|
||||||
@ -105,6 +118,17 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onDeleteCoupon(aCouponCode: string) {
|
||||||
|
const confirmation = confirm('Do you really want to delete this coupon?');
|
||||||
|
|
||||||
|
if (confirmation) {
|
||||||
|
const coupons = this.coupons.filter((coupon) => {
|
||||||
|
return coupon.code !== aCouponCode;
|
||||||
|
});
|
||||||
|
this.putCoupons(coupons);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public onDeleteCurrency(aCurrency: string) {
|
public onDeleteCurrency(aCurrency: string) {
|
||||||
const confirmation = confirm('Do you really want to delete this currency?');
|
const confirmation = confirm('Do you really want to delete this currency?');
|
||||||
|
|
||||||
@ -185,6 +209,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
transactionCount,
|
transactionCount,
|
||||||
userCount
|
userCount
|
||||||
}) => {
|
}) => {
|
||||||
|
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
|
||||||
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
|
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
|
||||||
this.dataGatheringProgress = dataGatheringProgress;
|
this.dataGatheringProgress = dataGatheringProgress;
|
||||||
this.exchangeRates = exchangeRates;
|
this.exchangeRates = exchangeRates;
|
||||||
@ -210,6 +235,32 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private generateCouponCode(aLength: number) {
|
||||||
|
const characters = 'ABCDEFGHJKLMNPQRSTUVWXYZ123456789';
|
||||||
|
let couponCode = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < aLength; i++) {
|
||||||
|
couponCode += characters.charAt(
|
||||||
|
Math.floor(Math.random() * characters.length)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return couponCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private putCoupons(aCoupons: Coupon[]) {
|
||||||
|
this.dataService
|
||||||
|
.putAdminSetting(PROPERTY_COUPONS, {
|
||||||
|
value: JSON.stringify(aCoupons)
|
||||||
|
})
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private putCurrencies(aCurrencies: string[]) {
|
private putCurrencies(aCurrencies: string[]) {
|
||||||
this.dataService
|
this.dataService
|
||||||
.putAdminSetting(PROPERTY_CURRENCIES, {
|
.putAdminSetting(PROPERTY_CURRENCIES, {
|
||||||
|
@ -142,7 +142,7 @@
|
|||||||
class="mr-1"
|
class="mr-1"
|
||||||
name="information-circle-outline"
|
name="information-circle-outline"
|
||||||
></ion-icon>
|
></ion-icon>
|
||||||
<span i18n>Set System Message</span>
|
<span i18n>Set Message</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -156,6 +156,27 @@
|
|||||||
></mat-slide-toggle>
|
></mat-slide-toggle>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div *ngIf="hasPermissionForSubscription" class="d-flex my-3">
|
||||||
|
<div class="w-50" i18n>Coupons</div>
|
||||||
|
<div class="w-50">
|
||||||
|
<div *ngFor="let coupon of coupons">
|
||||||
|
<span>{{ coupon.code }}</span>
|
||||||
|
<button
|
||||||
|
class="mini-icon mx-1 no-min-width px-2"
|
||||||
|
mat-button
|
||||||
|
(click)="onDeleteCoupon(coupon.code)"
|
||||||
|
>
|
||||||
|
<ion-icon name="trash-outline"></ion-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<button color="primary" mat-flat-button (click)="onAddCoupon()">
|
||||||
|
<ion-icon class="mr-1" name="add-outline"></ion-icon>
|
||||||
|
<span i18n>Add Coupon</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
</div>
|
</div>
|
||||||
|
@ -14,15 +14,14 @@ import {
|
|||||||
TextOnlySnackBar
|
TextOnlySnackBar
|
||||||
} from '@angular/material/snack-bar';
|
} from '@angular/material/snack-bar';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
|
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||||
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
|
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
|
||||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||||
import { StatusCodes } from 'http-status-codes';
|
import { StatusCodes } from 'http-status-codes';
|
||||||
import { Observable, throwError } from 'rxjs';
|
import { Observable, throwError } from 'rxjs';
|
||||||
import { catchError, tap } from 'rxjs/operators';
|
import { catchError, tap } from 'rxjs/operators';
|
||||||
|
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
|
||||||
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class HttpResponseInterceptor implements HttpInterceptor {
|
export class HttpResponseInterceptor implements HttpInterceptor {
|
||||||
public info: InfoItem;
|
public info: InfoItem;
|
||||||
|
@ -10,6 +10,11 @@ import {
|
|||||||
MatSlideToggle,
|
MatSlideToggle,
|
||||||
MatSlideToggleChange
|
MatSlideToggleChange
|
||||||
} from '@angular/material/slide-toggle';
|
} from '@angular/material/slide-toggle';
|
||||||
|
import {
|
||||||
|
MatSnackBar,
|
||||||
|
MatSnackBarRef,
|
||||||
|
TextOnlySnackBar
|
||||||
|
} from '@angular/material/snack-bar';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
|
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
@ -49,6 +54,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
public hasPermissionToUpdateUserSettings: boolean;
|
public hasPermissionToUpdateUserSettings: boolean;
|
||||||
public price: number;
|
public price: number;
|
||||||
public priceId: string;
|
public priceId: string;
|
||||||
|
public snackBarRef: MatSnackBarRef<TextOnlySnackBar>;
|
||||||
public user: User;
|
public user: User;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
@ -61,6 +67,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
private deviceService: DeviceDetectorService,
|
private deviceService: DeviceDetectorService,
|
||||||
private dialog: MatDialog,
|
private dialog: MatDialog,
|
||||||
|
private snackBar: MatSnackBar,
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private stripeService: StripeService,
|
private stripeService: StripeService,
|
||||||
@ -185,6 +192,49 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onRedeemCoupon() {
|
||||||
|
let couponCode = prompt('Please enter your coupon code:');
|
||||||
|
couponCode = couponCode?.trim();
|
||||||
|
|
||||||
|
if (couponCode) {
|
||||||
|
this.dataService
|
||||||
|
.redeemCoupon(couponCode)
|
||||||
|
.pipe(
|
||||||
|
takeUntil(this.unsubscribeSubject),
|
||||||
|
catchError(() => {
|
||||||
|
this.snackBar.open('😞 Could not redeem coupon code', undefined, {
|
||||||
|
duration: 3000
|
||||||
|
});
|
||||||
|
|
||||||
|
return EMPTY;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe(() => {
|
||||||
|
this.snackBarRef = this.snackBar.open(
|
||||||
|
'✅ Coupon code has been redeemed',
|
||||||
|
'Reload',
|
||||||
|
{
|
||||||
|
duration: 3000
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.snackBarRef
|
||||||
|
.afterDismissed()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.snackBarRef
|
||||||
|
.onAction()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public onRestrictedViewChange(aEvent: MatSlideToggleChange) {
|
public onRestrictedViewChange(aEvent: MatSlideToggleChange) {
|
||||||
this.dataService
|
this.dataService
|
||||||
.putUserSetting({ isRestrictedView: aEvent.checked })
|
.putUserSetting({ isRestrictedView: aEvent.checked })
|
||||||
|
@ -47,6 +47,13 @@
|
|||||||
<ng-container *ngIf="!coupon">{{ price }}</ng-container>
|
<ng-container *ngIf="!coupon">{{ price }}</ng-container>
|
||||||
<span i18n> per year</span>
|
<span i18n> per year</span>
|
||||||
</div>
|
</div>
|
||||||
|
<a
|
||||||
|
class="cursor-pointer d-block mt-2"
|
||||||
|
i18n
|
||||||
|
[routerLink]=""
|
||||||
|
(click)="onRedeemCoupon()"
|
||||||
|
>Redeem Coupon</a
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -8,6 +8,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
|
|||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module';
|
import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module';
|
||||||
|
|
||||||
import { AccountPageRoutingModule } from './account-page-routing.module';
|
import { AccountPageRoutingModule } from './account-page-routing.module';
|
||||||
@ -30,7 +31,8 @@ import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-di
|
|||||||
MatInputModule,
|
MatInputModule,
|
||||||
MatSelectModule,
|
MatSelectModule,
|
||||||
MatSlideToggleModule,
|
MatSlideToggleModule,
|
||||||
ReactiveFormsModule
|
ReactiveFormsModule,
|
||||||
|
RouterModule
|
||||||
],
|
],
|
||||||
providers: []
|
providers: []
|
||||||
})
|
})
|
||||||
|
@ -2,6 +2,15 @@
|
|||||||
color: rgb(var(--dark-primary-text));
|
color: rgb(var(--dark-primary-text));
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgba(var(--palette-primary-500), 1);
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: rgba(var(--palette-primary-300), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
gf-access-table {
|
gf-access-table {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
|
||||||
|
@ -253,4 +253,10 @@ export class DataService {
|
|||||||
public putUserSettings(aData: UpdateUserSettingsDto) {
|
public putUserSettings(aData: UpdateUserSettingsDto) {
|
||||||
return this.http.put<User>(`/api/user/settings`, aData);
|
return this.http.put<User>(`/api/user/settings`, aData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public redeemCoupon(couponCode: string) {
|
||||||
|
return this.http.post('/api/subscription/redeem-coupon', {
|
||||||
|
couponCode
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -30,6 +30,7 @@ export const warnColorRgb = {
|
|||||||
export const DEFAULT_DATE_FORMAT = 'dd.MM.yyyy';
|
export const DEFAULT_DATE_FORMAT = 'dd.MM.yyyy';
|
||||||
export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy';
|
export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy';
|
||||||
|
|
||||||
|
export const PROPERTY_COUPONS = 'COUPONS';
|
||||||
export const PROPERTY_CURRENCIES = 'CURRENCIES';
|
export const PROPERTY_CURRENCIES = 'CURRENCIES';
|
||||||
export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE';
|
export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE';
|
||||||
export const PROPERTY_LAST_DATA_GATHERING = 'LAST_DATA_GATHERING';
|
export const PROPERTY_LAST_DATA_GATHERING = 'LAST_DATA_GATHERING';
|
||||||
|
3
libs/common/src/lib/interfaces/coupon.interface.ts
Normal file
3
libs/common/src/lib/interfaces/coupon.interface.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export interface Coupon {
|
||||||
|
code: string;
|
||||||
|
}
|
@ -3,6 +3,7 @@ import { Accounts } from './accounts.interface';
|
|||||||
import { AdminData } from './admin-data.interface';
|
import { AdminData } from './admin-data.interface';
|
||||||
import { AdminMarketDataDetails } from './admin-market-data-details.interface';
|
import { AdminMarketDataDetails } from './admin-market-data-details.interface';
|
||||||
import { AdminMarketData } from './admin-market-data.interface';
|
import { AdminMarketData } from './admin-market-data.interface';
|
||||||
|
import { Coupon } from './coupon.interface';
|
||||||
import { Export } from './export.interface';
|
import { Export } from './export.interface';
|
||||||
import { InfoItem } from './info-item.interface';
|
import { InfoItem } from './info-item.interface';
|
||||||
import { PortfolioChart } from './portfolio-chart.interface';
|
import { PortfolioChart } from './portfolio-chart.interface';
|
||||||
@ -27,6 +28,7 @@ export {
|
|||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
|
Coupon,
|
||||||
Export,
|
Export,
|
||||||
InfoItem,
|
InfoItem,
|
||||||
PortfolioChart,
|
PortfolioChart,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ghostfolio",
|
"name": "ghostfolio",
|
||||||
"version": "1.87.0",
|
"version": "1.88.0",
|
||||||
"homepage": "https://ghostfol.io",
|
"homepage": "https://ghostfol.io",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user