diff --git a/CHANGELOG.md b/CHANGELOG.md index b18b2f4c9..692208064 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,16 @@ 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/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## 2.7.0 - 2023-09-30 + +### Added + +- Added a new static portfolio analysis rule: Emergency fund setup +- Added tabs to the user account page ### Changed +- Set up the _Inter_ font family - Upgraded `yahoo-finance2` from version `2.7.0` to `2.8.0` ### Fixed diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index e9d0b2792..b9d6cef1b 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -10,6 +10,7 @@ import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rule import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account'; import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment'; import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment'; +import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup'; import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; @@ -1214,12 +1215,6 @@ export class PortfolioService { userId }); - if (isEmpty(orders)) { - return { - rules: {} - }; - } - const portfolioCalculator = new PortfolioCalculator({ currency: userCurrency, currentRateService: this.currentRateService, @@ -1228,7 +1223,9 @@ export class PortfolioService { portfolioCalculator.setTransactionPoints(transactionPoints); - const portfolioStart = parseDate(transactionPoints[0].date); + const portfolioStart = parseDate( + transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT) + ); const currentPositions = await portfolioCalculator.getCurrentPositions(portfolioStart); @@ -1249,33 +1246,48 @@ export class PortfolioService { userId }); + const userSettings = this.request.user.Settings.settings; + return { rules: { - accountClusterRisk: await this.rulesService.evaluate( - [ - new AccountClusterRiskCurrentInvestment( - this.exchangeRateDataService, - accounts + accountClusterRisk: isEmpty(orders) + ? undefined + : await this.rulesService.evaluate( + [ + new AccountClusterRiskCurrentInvestment( + this.exchangeRateDataService, + accounts + ), + new AccountClusterRiskSingleAccount( + this.exchangeRateDataService, + accounts + ) + ], + userSettings ), - new AccountClusterRiskSingleAccount( - this.exchangeRateDataService, - accounts - ) - ], - this.request.user.Settings.settings - ), - currencyClusterRisk: await this.rulesService.evaluate( - [ - new CurrencyClusterRiskBaseCurrencyCurrentInvestment( - this.exchangeRateDataService, - positions + currencyClusterRisk: isEmpty(orders) + ? undefined + : await this.rulesService.evaluate( + [ + new CurrencyClusterRiskBaseCurrencyCurrentInvestment( + this.exchangeRateDataService, + positions + ), + new CurrencyClusterRiskCurrentInvestment( + this.exchangeRateDataService, + positions + ) + ], + userSettings ), - new CurrencyClusterRiskCurrentInvestment( + emergencyFund: await this.rulesService.evaluate( + [ + new EmergencyFundSetup( this.exchangeRateDataService, - positions + userSettings.emergencyFund ) ], - this.request.user.Settings.settings + userSettings ), fees: await this.rulesService.evaluate( [ @@ -1285,7 +1297,7 @@ export class PortfolioService { this.getFees({ userCurrency, activities: orders }).toNumber() ) ], - this.request.user.Settings.settings + userSettings ) } }; diff --git a/apps/api/src/assets/sitemap.xml b/apps/api/src/assets/sitemap.xml index d0d04e72f..2a3650752 100644 --- a/apps/api/src/assets/sitemap.xml +++ b/apps/api/src/assets/sitemap.xml @@ -78,6 +78,10 @@ https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-exirio ${currentDate}T00:00:00+00:00 + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finary + ${currentDate}T00:00:00+00:00 + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-folishare ${currentDate}T00:00:00+00:00 @@ -146,6 +150,10 @@ https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-snowball-analytics ${currentDate}T00:00:00+00:00 + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-stockle + ${currentDate}T00:00:00+00:00 + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-stockmarketeye ${currentDate}T00:00:00+00:00 @@ -324,6 +332,10 @@ https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio ${currentDate}T00:00:00+00:00 + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finary + ${currentDate}T00:00:00+00:00 + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare ${currentDate}T00:00:00+00:00 @@ -392,6 +404,10 @@ https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-snowball-analytics ${currentDate}T00:00:00+00:00 + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stockle + ${currentDate}T00:00:00+00:00 + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stockmarketeye ${currentDate}T00:00:00+00:00 @@ -598,6 +614,10 @@ https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-exirio ${currentDate}T00:00:00+00:00 + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finary + ${currentDate}T00:00:00+00:00 + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-folishare ${currentDate}T00:00:00+00:00 @@ -666,6 +686,10 @@ https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-snowball-analytics ${currentDate}T00:00:00+00:00 + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-stockle + ${currentDate}T00:00:00+00:00 + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-stockmarketeye ${currentDate}T00:00:00+00:00 @@ -718,6 +742,10 @@ https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-exirio ${currentDate}T00:00:00+00:00 + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finary + ${currentDate}T00:00:00+00:00 + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-folishare ${currentDate}T00:00:00+00:00 @@ -786,6 +814,10 @@ https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-snowball-analytics ${currentDate}T00:00:00+00:00 + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-stockle + ${currentDate}T00:00:00+00:00 + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-stockmarketeye ${currentDate}T00:00:00+00:00 diff --git a/apps/api/src/models/rules/account-cluster-risk/current-investment.ts b/apps/api/src/models/rules/account-cluster-risk/current-investment.ts index d0cdbb58c..23d3307de 100644 --- a/apps/api/src/models/rules/account-cluster-risk/current-investment.ts +++ b/apps/api/src/models/rules/account-cluster-risk/current-investment.ts @@ -1,4 +1,5 @@ import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; +import { Rule } from '@ghostfolio/api/models/rule'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PortfolioDetails, @@ -6,16 +7,18 @@ import { UserSettings } from '@ghostfolio/common/interfaces'; -import { Rule } from '../../rule'; - export class AccountClusterRiskCurrentInvestment extends Rule { + private accounts: PortfolioDetails['accounts']; + public constructor( protected exchangeRateDataService: ExchangeRateDataService, - private accounts: PortfolioDetails['accounts'] + accounts: PortfolioDetails['accounts'] ) { super(exchangeRateDataService, { name: 'Investment' }); + + this.accounts = accounts; } public evaluate(ruleSettings: Settings) { diff --git a/apps/api/src/models/rules/account-cluster-risk/single-account.ts b/apps/api/src/models/rules/account-cluster-risk/single-account.ts index 3be323d7c..b5028228a 100644 --- a/apps/api/src/models/rules/account-cluster-risk/single-account.ts +++ b/apps/api/src/models/rules/account-cluster-risk/single-account.ts @@ -1,17 +1,20 @@ import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; +import { Rule } from '@ghostfolio/api/models/rule'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces'; -import { Rule } from '../../rule'; - export class AccountClusterRiskSingleAccount extends Rule { + private accounts: PortfolioDetails['accounts']; + public constructor( protected exchangeRateDataService: ExchangeRateDataService, - private accounts: PortfolioDetails['accounts'] + accounts: PortfolioDetails['accounts'] ) { super(exchangeRateDataService, { name: 'Single Account' }); + + this.accounts = accounts; } public evaluate() { diff --git a/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts b/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts index 2facb8803..a23a208c3 100644 --- a/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts +++ b/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts @@ -1,17 +1,20 @@ import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; +import { Rule } from '@ghostfolio/api/models/rule'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces'; -import { Rule } from '../../rule'; - export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule { + private positions: TimelinePosition[]; + public constructor( protected exchangeRateDataService: ExchangeRateDataService, - private positions: TimelinePosition[] + positions: TimelinePosition[] ) { super(exchangeRateDataService, { name: 'Investment: Base Currency' }); + + this.positions = positions; } public evaluate(ruleSettings: Settings) { diff --git a/apps/api/src/models/rules/currency-cluster-risk/current-investment.ts b/apps/api/src/models/rules/currency-cluster-risk/current-investment.ts index 2d69865f5..bd6e060ef 100644 --- a/apps/api/src/models/rules/currency-cluster-risk/current-investment.ts +++ b/apps/api/src/models/rules/currency-cluster-risk/current-investment.ts @@ -1,17 +1,20 @@ import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; +import { Rule } from '@ghostfolio/api/models/rule'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces'; -import { Rule } from '../../rule'; - export class CurrencyClusterRiskCurrentInvestment extends Rule { + private positions: TimelinePosition[]; + public constructor( protected exchangeRateDataService: ExchangeRateDataService, - private positions: TimelinePosition[] + positions: TimelinePosition[] ) { super(exchangeRateDataService, { name: 'Investment' }); + + this.positions = positions; } public evaluate(ruleSettings: Settings) { diff --git a/apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts b/apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts new file mode 100644 index 000000000..b6248ab51 --- /dev/null +++ b/apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts @@ -0,0 +1,46 @@ +import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; +import { Rule } from '@ghostfolio/api/models/rule'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { UserSettings } from '@ghostfolio/common/interfaces'; + +export class EmergencyFundSetup extends Rule { + private emergencyFund: number; + + public constructor( + protected exchangeRateDataService: ExchangeRateDataService, + emergencyFund: number + ) { + super(exchangeRateDataService, { + name: 'Emergency Fund: Set up' + }); + + this.emergencyFund = emergencyFund; + } + + public evaluate(ruleSettings: Settings) { + if (this.emergencyFund > ruleSettings.threshold) { + return { + evaluation: 'An emergency fund has been set up', + value: true + }; + } + + return { + evaluation: 'No emergency fund has been set up', + value: false + }; + } + + public getSettings(aUserSettings: UserSettings): Settings { + return { + baseCurrency: aUserSettings.baseCurrency, + isActive: true, + threshold: 0 + }; + } +} + +interface Settings extends RuleSettings { + baseCurrency: string; + threshold: number; +} diff --git a/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts b/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts index dfe375c43..0ba70d23c 100644 --- a/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts +++ b/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts @@ -1,22 +1,29 @@ import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; +import { Rule } from '@ghostfolio/api/models/rule'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { UserSettings } from '@ghostfolio/common/interfaces'; -import { Rule } from '../../rule'; - export class FeeRatioInitialInvestment extends Rule { + private fees: number; + private totalInvestment: number; + public constructor( protected exchangeRateDataService: ExchangeRateDataService, - private totalInvestment: number, - private fees: number + totalInvestment: number, + fees: number ) { super(exchangeRateDataService, { - name: 'Investment' + name: 'Fee Ratio' }); + + this.fees = fees; + this.totalInvestment = totalInvestment; } public evaluate(ruleSettings: Settings) { - const feeRatio = this.fees / this.totalInvestment; + const feeRatio = this.totalInvestment + ? this.fees / this.totalInvestment + : 0; if (feeRatio > ruleSettings.threshold) { return { diff --git a/apps/client/project.json b/apps/client/project.json index 03cbde62d..9da89ea9e 100644 --- a/apps/client/project.json +++ b/apps/client/project.json @@ -21,6 +21,7 @@ "tsConfig": "apps/client/tsconfig.app.json", "assets": [], "styles": [ + "apps/client/src/assets/fonts/inter.css", "apps/client/src/styles/theme.scss", "apps/client/src/styles.scss" ], diff --git a/apps/client/src/app/pages/user-account/create-or-update-access-dialog/create-or-update-access-dialog.component.ts b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts similarity index 100% rename from apps/client/src/app/pages/user-account/create-or-update-access-dialog/create-or-update-access-dialog.component.ts rename to apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts diff --git a/apps/client/src/app/pages/user-account/create-or-update-access-dialog/create-or-update-access-dialog.html b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html similarity index 100% rename from apps/client/src/app/pages/user-account/create-or-update-access-dialog/create-or-update-access-dialog.html rename to apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html diff --git a/apps/client/src/app/pages/user-account/create-or-update-access-dialog/create-or-update-access-dialog.module.ts b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.module.ts similarity index 100% rename from apps/client/src/app/pages/user-account/create-or-update-access-dialog/create-or-update-access-dialog.module.ts rename to apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.module.ts diff --git a/apps/client/src/app/pages/user-account/create-or-update-access-dialog/create-or-update-access-dialog.scss b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.scss similarity index 100% rename from apps/client/src/app/pages/user-account/create-or-update-access-dialog/create-or-update-access-dialog.scss rename to apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.scss diff --git a/apps/client/src/app/pages/user-account/create-or-update-access-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts similarity index 100% rename from apps/client/src/app/pages/user-account/create-or-update-access-dialog/interfaces/interfaces.ts rename to apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts diff --git a/apps/client/src/app/components/user-account-access/user-account-access.component.ts b/apps/client/src/app/components/user-account-access/user-account-access.component.ts new file mode 100644 index 000000000..1bd1d85d6 --- /dev/null +++ b/apps/client/src/app/components/user-account-access/user-account-access.component.ts @@ -0,0 +1,146 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + OnDestroy, + OnInit +} from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { ActivatedRoute, Router } from '@angular/router'; +import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto'; +import { DataService } from '@ghostfolio/client/services/data.service'; +import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { Access, User } from '@ghostfolio/common/interfaces'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { DeviceDetectorService } from 'ngx-device-detector'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog/create-or-update-access-dialog.component'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'gf-user-account-access', + styleUrls: ['./user-account-access.scss'], + templateUrl: './user-account-access.html' +}) +export class UserAccountAccessComponent implements OnDestroy, OnInit { + public accesses: Access[]; + public deviceType: string; + public hasPermissionToCreateAccess: boolean; + public hasPermissionToDeleteAccess: boolean; + public user: User; + + private unsubscribeSubject = new Subject(); + + public constructor( + private changeDetectorRef: ChangeDetectorRef, + private dataService: DataService, + private deviceService: DeviceDetectorService, + private dialog: MatDialog, + private route: ActivatedRoute, + private router: Router, + private userService: UserService + ) { + const { globalPermissions } = this.dataService.fetchInfo(); + + this.hasPermissionToDeleteAccess = hasPermission( + globalPermissions, + permissions.deleteAccess + ); + + this.userService.stateChanged + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((state) => { + if (state?.user) { + this.user = state.user; + + this.hasPermissionToCreateAccess = hasPermission( + this.user.permissions, + permissions.createAccess + ); + + this.hasPermissionToDeleteAccess = hasPermission( + this.user.permissions, + permissions.deleteAccess + ); + + this.changeDetectorRef.markForCheck(); + } + }); + + this.route.queryParams + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((params) => { + if (params['createDialog']) { + this.openCreateAccessDialog(); + } + }); + } + + public ngOnInit() { + this.deviceType = this.deviceService.getDeviceInfo().deviceType; + + this.update(); + } + + public onDeleteAccess(aId: string) { + this.dataService + .deleteAccess(aId) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe({ + next: () => { + this.update(); + } + }); + } + + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } + + private openCreateAccessDialog(): void { + const dialogRef = this.dialog.open(CreateOrUpdateAccessDialog, { + data: { + access: { + alias: '', + type: 'PUBLIC' + } + }, + height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', + width: this.deviceType === 'mobile' ? '100vw' : '50rem' + }); + + dialogRef + .afterClosed() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((data: any) => { + const access: CreateAccessDto = data?.access; + + if (access) { + this.dataService + .postAccess({ alias: access.alias }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe({ + next: () => { + this.update(); + } + }); + } + + this.router.navigate(['.'], { relativeTo: this.route }); + }); + } + + private update() { + this.dataService + .fetchAccesses() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((accesses) => { + this.accesses = accesses; + + this.changeDetectorRef.markForCheck(); + }); + } +} diff --git a/apps/client/src/app/components/user-account-access/user-account-access.html b/apps/client/src/app/components/user-account-access/user-account-access.html new file mode 100644 index 000000000..c3aa485cd --- /dev/null +++ b/apps/client/src/app/components/user-account-access/user-account-access.html @@ -0,0 +1,17 @@ +
+

+ Granted Access + +

+ +
diff --git a/apps/client/src/app/components/user-account-access/user-account-access.module.ts b/apps/client/src/app/components/user-account-access/user-account-access.module.ts new file mode 100644 index 000000000..76495db63 --- /dev/null +++ b/apps/client/src/app/components/user-account-access/user-account-access.module.ts @@ -0,0 +1,23 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { MatDialogModule } from '@angular/material/dialog'; +import { RouterModule } from '@angular/router'; +import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module'; +import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator'; + +import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-dialog/create-or-update-access-dialog.module'; +import { UserAccountAccessComponent } from './user-account-access.component'; + +@NgModule({ + declarations: [UserAccountAccessComponent], + exports: [UserAccountAccessComponent], + imports: [ + CommonModule, + GfCreateOrUpdateAccessDialogModule, + GfPortfolioAccessTableModule, + GfPremiumIndicatorModule, + MatDialogModule, + RouterModule + ] +}) +export class GfUserAccountAccessModule {} diff --git a/apps/client/src/app/components/user-account-access/user-account-access.scss b/apps/client/src/app/components/user-account-access/user-account-access.scss new file mode 100644 index 000000000..695f786f2 --- /dev/null +++ b/apps/client/src/app/components/user-account-access/user-account-access.scss @@ -0,0 +1,12 @@ +:host { + color: rgb(var(--dark-primary-text)); + display: block; + + gf-access-table { + overflow-x: auto; + } +} + +:host-context(.is-dark-theme) { + color: rgb(var(--light-primary-text)); +} diff --git a/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts b/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts new file mode 100644 index 000000000..13d7495a9 --- /dev/null +++ b/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts @@ -0,0 +1,160 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + OnDestroy, + OnInit +} from '@angular/core'; +import { + MatSnackBar, + MatSnackBarRef, + TextOnlySnackBar +} from '@angular/material/snack-bar'; +import { DataService } from '@ghostfolio/client/services/data.service'; +import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { getDateFormatString } from '@ghostfolio/common/helper'; +import { User } from '@ghostfolio/common/interfaces'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { StripeService } from 'ngx-stripe'; +import { EMPTY, Subject } from 'rxjs'; +import { catchError, switchMap, takeUntil } from 'rxjs/operators'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'gf-user-account-membership', + styleUrls: ['./user-account-membership.scss'], + templateUrl: './user-account-membership.html' +}) +export class UserAccountMembershipComponent implements OnDestroy, OnInit { + public baseCurrency: string; + public coupon: number; + public couponId: string; + public defaultDateFormat: string; + public hasPermissionForSubscription: boolean; + public hasPermissionToUpdateUserSettings: boolean; + public price: number; + public priceId: string; + public routerLinkPricing = ['/' + $localize`pricing`]; + public snackBarRef: MatSnackBarRef; + public trySubscriptionMail = + 'mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Trial&body=Hello%0D%0DI am interested in Ghostfolio Premium. Can you please send me a coupon code to try it for some time?%0D%0DKind regards'; + public user: User; + + private unsubscribeSubject = new Subject(); + + public constructor( + private changeDetectorRef: ChangeDetectorRef, + private dataService: DataService, + private snackBar: MatSnackBar, + private stripeService: StripeService, + private userService: UserService + ) { + const { baseCurrency, globalPermissions, subscriptions } = + this.dataService.fetchInfo(); + + this.baseCurrency = baseCurrency; + + this.hasPermissionForSubscription = hasPermission( + globalPermissions, + permissions.enableSubscription + ); + + this.userService.stateChanged + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((state) => { + if (state?.user) { + this.user = state.user; + + this.defaultDateFormat = getDateFormatString( + this.user.settings.locale + ); + + this.hasPermissionToUpdateUserSettings = hasPermission( + this.user.permissions, + permissions.updateUserSettings + ); + + this.coupon = subscriptions?.[this.user.subscription.offer]?.coupon; + this.couponId = + subscriptions?.[this.user.subscription.offer]?.couponId; + this.price = subscriptions?.[this.user.subscription.offer]?.price; + this.priceId = subscriptions?.[this.user.subscription.offer]?.priceId; + + this.changeDetectorRef.markForCheck(); + } + }); + } + + public ngOnInit() {} + + public onCheckout() { + this.dataService + .createCheckoutSession({ couponId: this.couponId, priceId: this.priceId }) + .pipe( + switchMap(({ sessionId }: { sessionId: string }) => { + return this.stripeService.redirectToCheckout({ sessionId }); + }), + catchError((error) => { + alert(error.message); + throw error; + }) + ) + .subscribe((result) => { + if (result.error) { + alert(result.error.message); + } + }); + } + + public onRedeemCoupon() { + let couponCode = prompt($localize`Please enter your coupon code:`); + couponCode = couponCode?.trim(); + + if (couponCode) { + this.dataService + .redeemCoupon(couponCode) + .pipe( + takeUntil(this.unsubscribeSubject), + catchError(() => { + this.snackBar.open( + '😞 ' + $localize`Could not redeem coupon code`, + undefined, + { + duration: 3000 + } + ); + + return EMPTY; + }) + ) + .subscribe(() => { + this.snackBarRef = this.snackBar.open( + '✅ ' + $localize`Coupon code has been redeemed`, + $localize`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 ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } +} diff --git a/apps/client/src/app/components/user-account-membership/user-account-membership.html b/apps/client/src/app/components/user-account-membership/user-account-membership.html new file mode 100644 index 000000000..1681e3e16 --- /dev/null +++ b/apps/client/src/app/components/user-account-membership/user-account-membership.html @@ -0,0 +1,69 @@ +
+

Membership

+
+
+
+
+ +
+ Valid until {{ + user?.subscription?.expiresAt | date: defaultDateFormat }} +
+
+ + +
+ {{ baseCurrency }} {{ price }} {{ baseCurrency }} {{ price - coupon + }} + {{ baseCurrency }} {{ price }} per year +
+
+ Try Premium + + Redeem Coupon +
+
+
+
+
+
diff --git a/apps/client/src/app/components/user-account-membership/user-account-membership.module.ts b/apps/client/src/app/components/user-account-membership/user-account-membership.module.ts new file mode 100644 index 000000000..bef027c62 --- /dev/null +++ b/apps/client/src/app/components/user-account-membership/user-account-membership.module.ts @@ -0,0 +1,23 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { RouterModule } from '@angular/router'; +import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator'; +import { GfValueModule } from '@ghostfolio/ui/value'; + +import { UserAccountMembershipComponent } from './user-account-membership.component'; + +@NgModule({ + declarations: [UserAccountMembershipComponent], + exports: [UserAccountMembershipComponent], + imports: [ + CommonModule, + GfPremiumIndicatorModule, + GfValueModule, + MatButtonModule, + MatCardModule, + RouterModule + ] +}) +export class GfUserAccountMembershipModule {} diff --git a/apps/client/src/app/components/user-account-membership/user-account-membership.scss b/apps/client/src/app/components/user-account-membership/user-account-membership.scss new file mode 100644 index 000000000..39eb6792e --- /dev/null +++ b/apps/client/src/app/components/user-account-membership/user-account-membership.scss @@ -0,0 +1,8 @@ +:host { + color: rgb(var(--dark-primary-text)); + display: block; +} + +:host-context(.is-dark-theme) { + color: rgb(var(--light-primary-text)); +} diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts b/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts new file mode 100644 index 000000000..a52812ed3 --- /dev/null +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts @@ -0,0 +1,258 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + OnDestroy, + OnInit, + ViewChild +} from '@angular/core'; +import { MatCheckbox, MatCheckboxChange } from '@angular/material/checkbox'; +import { DataService } from '@ghostfolio/client/services/data.service'; +import { + STAY_SIGNED_IN, + SettingsStorageService +} from '@ghostfolio/client/services/settings-storage.service'; +import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; +import { downloadAsFile } from '@ghostfolio/common/helper'; +import { User } from '@ghostfolio/common/interfaces'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { format, parseISO } from 'date-fns'; +import { uniq } from 'lodash'; +import { EMPTY, Subject } from 'rxjs'; +import { catchError, takeUntil } from 'rxjs/operators'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'gf-user-account-settings', + styleUrls: ['./user-account-settings.scss'], + templateUrl: './user-account-settings.html' +}) +export class UserAccountSettingsComponent implements OnDestroy, OnInit { + @ViewChild('toggleSignInWithFingerprintEnabledElement') + signInWithFingerprintElement: MatCheckbox; + + public appearancePlaceholder = $localize`Auto`; + public baseCurrency: string; + public currencies: string[] = []; + public hasPermissionToUpdateViewMode: boolean; + public hasPermissionToUpdateUserSettings: boolean; + public language = document.documentElement.lang; + public locales = [ + 'de', + 'de-CH', + 'en-GB', + 'en-US', + 'es', + 'fr', + 'it', + 'nl', + 'pt', + 'tr' + ]; + public user: User; + + private unsubscribeSubject = new Subject(); + + public constructor( + private changeDetectorRef: ChangeDetectorRef, + private dataService: DataService, + private settingsStorageService: SettingsStorageService, + private userService: UserService, + public webAuthnService: WebAuthnService + ) { + const { baseCurrency, currencies } = this.dataService.fetchInfo(); + + this.baseCurrency = baseCurrency; + this.currencies = currencies; + + this.userService.stateChanged + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((state) => { + if (state?.user) { + this.user = state.user; + + this.hasPermissionToUpdateUserSettings = hasPermission( + this.user.permissions, + permissions.updateUserSettings + ); + + this.hasPermissionToUpdateViewMode = hasPermission( + this.user.permissions, + permissions.updateViewMode + ); + + this.locales.push(this.user.settings.locale); + this.locales = uniq(this.locales.sort()); + + this.changeDetectorRef.markForCheck(); + } + }); + } + + public ngOnInit() { + this.update(); + } + + public onChangeUserSetting(aKey: string, aValue: string) { + this.dataService + .putUserSetting({ [aKey]: aValue }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.userService.remove(); + + this.userService + .get() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((user) => { + this.user = user; + + this.changeDetectorRef.markForCheck(); + + if (aKey === 'language') { + if (aValue) { + window.location.href = `../${aValue}/account`; + } else { + window.location.href = `../`; + } + } + }); + }); + } + + public onExperimentalFeaturesChange(aEvent: MatCheckboxChange) { + this.dataService + .putUserSetting({ isExperimentalFeatures: aEvent.checked }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.userService.remove(); + + this.userService + .get() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((user) => { + this.user = user; + + this.changeDetectorRef.markForCheck(); + }); + }); + } + + public onExport() { + this.dataService + .fetchExport() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((data) => { + for (const activity of data.activities) { + delete activity.id; + } + + downloadAsFile({ + content: data, + fileName: `ghostfolio-export-${format( + parseISO(data.meta.date), + 'yyyyMMddHHmm' + )}.json`, + format: 'json' + }); + }); + } + + public onRestrictedViewChange(aEvent: MatCheckboxChange) { + this.dataService + .putUserSetting({ isRestrictedView: aEvent.checked }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.userService.remove(); + + this.userService + .get() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((user) => { + this.user = user; + + this.changeDetectorRef.markForCheck(); + }); + }); + } + + public onSignInWithFingerprintChange(aEvent: MatCheckboxChange) { + if (aEvent.checked) { + this.registerDevice(); + } else { + const confirmation = confirm( + $localize`Do you really want to remove this sign in method?` + ); + + if (confirmation) { + this.deregisterDevice(); + } else { + this.update(); + } + } + } + + public onViewModeChange(aEvent: MatCheckboxChange) { + this.dataService + .putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.userService.remove(); + + this.userService + .get() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((user) => { + this.user = user; + + this.changeDetectorRef.markForCheck(); + }); + }); + } + + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } + + private deregisterDevice() { + this.webAuthnService + .deregister() + .pipe( + takeUntil(this.unsubscribeSubject), + catchError(() => { + this.update(); + + return EMPTY; + }) + ) + .subscribe(() => { + this.update(); + }); + } + + private registerDevice() { + this.webAuthnService + .register() + .pipe( + takeUntil(this.unsubscribeSubject), + catchError(() => { + this.update(); + + return EMPTY; + }) + ) + .subscribe(() => { + this.settingsStorageService.removeSetting(STAY_SIGNED_IN); + + this.update(); + }); + } + + private update() { + if (this.signInWithFingerprintElement) { + this.signInWithFingerprintElement.checked = + this.webAuthnService.isEnabled() ?? false; + } + } +} diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.html b/apps/client/src/app/components/user-account-settings/user-account-settings.html new file mode 100644 index 000000000..12f3da458 --- /dev/null +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.html @@ -0,0 +1,197 @@ +
+

Settings

+
+
+
+
+
Presenter View
+
+ Protection for sensitive information like absolute performances and + quantity values +
+
+
+ +
+
+
+
+
+
+ Base Currency +
+
+ + + {{ currency }} + + +
+
+
+
+
Language
+
+
+ + + + Deutsch + English + Español (Community) + Français (Community) + Italiano (Community) + Nederlands (Community) + Português (Community) + Türkçe (Community) + + +
+
+
+
+
Locale
+
+ Date and number format +
+
+
+ + + + {{ locale }} + + +
+
+
+
+ Appearance +
+
+ + + Auto + Light + Dark + + +
+
+
+
+
+
+
Zen Mode
+
+ Distraction-free experience for turbulent times +
+
+
+ +
+
+
+
+
Biometric Authentication
+
Sign in with fingerprint
+
+
+ +
+
+
+
+
Experimental Features
+
+ Sneak peek at upcoming functionality +
+
+
+ +
+
+
+
User ID
+
{{ user?.id }}
+
+
+
+
+ +
+
+
+
+
diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.module.ts b/apps/client/src/app/components/user-account-settings/user-account-settings.module.ts new file mode 100644 index 000000000..24e57ff20 --- /dev/null +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.module.ts @@ -0,0 +1,30 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { RouterModule } from '@angular/router'; +import { GfValueModule } from '@ghostfolio/ui/value'; + +import { UserAccountSettingsComponent } from './user-account-settings.component'; + +@NgModule({ + declarations: [UserAccountSettingsComponent], + exports: [UserAccountSettingsComponent], + imports: [ + CommonModule, + FormsModule, + GfValueModule, + MatButtonModule, + MatCardModule, + MatCheckboxModule, + MatFormFieldModule, + MatSelectModule, + ReactiveFormsModule, + RouterModule + ] +}) +export class GfUserAccountSettingsModule {} diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.scss b/apps/client/src/app/components/user-account-settings/user-account-settings.scss new file mode 100644 index 000000000..1bcd1c65a --- /dev/null +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.scss @@ -0,0 +1,13 @@ +:host { + color: rgb(var(--dark-primary-text)); + display: block; + + .hint-text { + font-size: 90%; + line-height: 1.2; + } +} + +:host-context(.is-dark-theme) { + color: rgb(var(--light-primary-text)); +} diff --git a/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts b/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts index 58d0b3702..909ca774a 100644 --- a/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts +++ b/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts @@ -18,6 +18,7 @@ export class FirePageComponent implements OnDestroy, OnInit { public accountClusterRiskRules: PortfolioReportRule[]; public currencyClusterRiskRules: PortfolioReportRule[]; public deviceType: string; + public emergencyFundRules: PortfolioReportRule[]; public feeRules: PortfolioReportRule[]; public fireWealth: Big; public hasImpersonationId: boolean; @@ -67,6 +68,8 @@ export class FirePageComponent implements OnDestroy, OnInit { portfolioReport.rules['accountClusterRisk'] || null; this.currencyClusterRiskRules = portfolioReport.rules['currencyClusterRisk'] || null; + this.emergencyFundRules = + portfolioReport.rules['emergencyFund'] || null; this.feeRules = portfolioReport.rules['fees'] || null; this.changeDetectorRef.markForCheck(); diff --git a/apps/client/src/app/pages/portfolio/fire/fire-page.html b/apps/client/src/app/pages/portfolio/fire/fire-page.html index 793eadc77..2e8522570 100644 --- a/apps/client/src/app/pages/portfolio/fire/fire-page.html +++ b/apps/client/src/app/pages/portfolio/fire/fire-page.html @@ -96,8 +96,10 @@

X-ray

- Ghostfolio X-ray uses static analysis to identify potential issues and - risks in your portfolio. + Ghostfolio X-ray uses static analysis to identify potential issues + and risks in your portfolio. It will be highly configurable in the future: activate / deactivate rules and customize the thresholds to match your personal investment @@ -106,7 +108,20 @@

- Currency Cluster RisksEmergency Fund +

+ +
+
+

+ Currency Cluster Risks

- Account Cluster RisksAccount Cluster Risks

- FeesFees { + return key === 'ghostfolio'; + }); + + public product2 = products.find(({ key }) => { + return key === 'finary'; + }); + + public routerLinkAbout = ['/' + $localize`about`]; + public routerLinkFeatures = ['/' + $localize`features`]; + public routerLinkResourcesPersonalFinanceTools = [ + '/' + $localize`resources`, + 'personal-finance-tools' + ]; +} diff --git a/apps/client/src/app/pages/resources/personal-finance-tools/products/stockle-page.component.ts b/apps/client/src/app/pages/resources/personal-finance-tools/products/stockle-page.component.ts new file mode 100644 index 000000000..eb1b41a9c --- /dev/null +++ b/apps/client/src/app/pages/resources/personal-finance-tools/products/stockle-page.component.ts @@ -0,0 +1,31 @@ +import { CommonModule } from '@angular/common'; +import { Component } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { RouterModule } from '@angular/router'; + +import { products } from '../products'; + +@Component({ + host: { class: 'page' }, + imports: [CommonModule, MatButtonModule, RouterModule], + selector: 'gf-stockle-page', + standalone: true, + styleUrls: ['../product-page-template.scss'], + templateUrl: '../product-page-template.html' +}) +export class StocklePageComponent { + public product1 = products.find(({ key }) => { + return key === 'ghostfolio'; + }); + + public product2 = products.find(({ key }) => { + return key === 'stockle'; + }); + + public routerLinkAbout = ['/' + $localize`about`]; + public routerLinkFeatures = ['/' + $localize`features`]; + public routerLinkResourcesPersonalFinanceTools = [ + '/' + $localize`resources`, + 'personal-finance-tools' + ]; +} diff --git a/apps/client/src/app/pages/user-account/user-account-page-routing.module.ts b/apps/client/src/app/pages/user-account/user-account-page-routing.module.ts index f52591d21..568095009 100644 --- a/apps/client/src/app/pages/user-account/user-account-page-routing.module.ts +++ b/apps/client/src/app/pages/user-account/user-account-page-routing.module.ts @@ -1,5 +1,8 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; +import { UserAccountAccessComponent } from '@ghostfolio/client/components/user-account-access/user-account-access.component'; +import { UserAccountMembershipComponent } from '@ghostfolio/client/components/user-account-membership/user-account-membership.component'; +import { UserAccountSettingsComponent } from '@ghostfolio/client/components/user-account-settings/user-account-settings.component'; import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; import { UserAccountPageComponent } from './user-account-page.component'; @@ -7,6 +10,23 @@ import { UserAccountPageComponent } from './user-account-page.component'; const routes: Routes = [ { canActivate: [AuthGuard], + children: [ + { + path: '', + component: UserAccountSettingsComponent, + title: $localize`Settings` + }, + { + path: 'membership', + component: UserAccountMembershipComponent, + title: $localize`Membership` + }, + { + path: 'access', + component: UserAccountAccessComponent, + title: $localize`Access` + } + ], component: UserAccountPageComponent, path: '', title: $localize`My Ghostfolio` diff --git a/apps/client/src/app/pages/user-account/user-account-page.component.ts b/apps/client/src/app/pages/user-account/user-account-page.component.ts index c02c8bdf1..970dadd6a 100644 --- a/apps/client/src/app/pages/user-account/user-account-page.component.ts +++ b/apps/client/src/app/pages/user-account/user-account-page.component.ts @@ -1,448 +1,63 @@ -import { - ChangeDetectorRef, - Component, - OnDestroy, - OnInit, - ViewChild -} from '@angular/core'; -import { MatCheckbox, MatCheckboxChange } from '@angular/material/checkbox'; -import { MatDialog } from '@angular/material/dialog'; -import { - MatSnackBar, - MatSnackBarRef, - TextOnlySnackBar -} from '@angular/material/snack-bar'; -import { ActivatedRoute, Router } from '@angular/router'; -import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto'; -import { DataService } from '@ghostfolio/client/services/data.service'; -import { - STAY_SIGNED_IN, - SettingsStorageService -} from '@ghostfolio/client/services/settings-storage.service'; +import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; -import { downloadAsFile, getDateFormatString } from '@ghostfolio/common/helper'; -import { Access, User } from '@ghostfolio/common/interfaces'; -import { hasPermission, permissions } from '@ghostfolio/common/permissions'; -import { format, parseISO } from 'date-fns'; -import { uniq } from 'lodash'; +import { TabConfiguration, User } from '@ghostfolio/common/interfaces'; import { DeviceDetectorService } from 'ngx-device-detector'; -import { StripeService } from 'ngx-stripe'; -import { EMPTY, Subject } from 'rxjs'; -import { catchError, switchMap, takeUntil } from 'rxjs/operators'; - -import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog/create-or-update-access-dialog.component'; +import { Subject, takeUntil } from 'rxjs'; @Component({ - host: { class: 'page' }, + host: { class: 'page has-tabs' }, selector: 'gf-user-account-page', styleUrls: ['./user-account-page.scss'], templateUrl: './user-account-page.html' }) export class UserAccountPageComponent implements OnDestroy, OnInit { - @ViewChild('toggleSignInWithFingerprintEnabledElement') - signInWithFingerprintElement: MatCheckbox; - - public accesses: Access[]; - public appearancePlaceholder = $localize`Auto`; - public baseCurrency: string; - public coupon: number; - public couponId: string; - public currencies: string[] = []; - public defaultDateFormat: string; public deviceType: string; - public hasPermissionForSubscription: boolean; - public hasPermissionToCreateAccess: boolean; - public hasPermissionToDeleteAccess: boolean; - public hasPermissionToUpdateViewMode: boolean; - public hasPermissionToUpdateUserSettings: boolean; - public language = document.documentElement.lang; - public locales = [ - 'de', - 'de-CH', - 'en-GB', - 'en-US', - 'es', - 'fr', - 'it', - 'nl', - 'pt', - 'tr' - ]; - public price: number; - public priceId: string; - public snackBarRef: MatSnackBarRef; - public trySubscriptionMail = - 'mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Trial&body=Hello%0D%0DI am interested in Ghostfolio Premium. Can you please send me a coupon code to try it for some time?%0D%0DKind regards'; + public tabs: TabConfiguration[] = []; public user: User; private unsubscribeSubject = new Subject(); public constructor( private changeDetectorRef: ChangeDetectorRef, - private dataService: DataService, private deviceService: DeviceDetectorService, - private dialog: MatDialog, - private snackBar: MatSnackBar, - private route: ActivatedRoute, - private router: Router, - private settingsStorageService: SettingsStorageService, - private stripeService: StripeService, - private userService: UserService, - public webAuthnService: WebAuthnService + private userService: UserService ) { - const { baseCurrency, currencies, globalPermissions, subscriptions } = - this.dataService.fetchInfo(); - - this.baseCurrency = baseCurrency; - this.currencies = currencies; - - this.hasPermissionForSubscription = hasPermission( - globalPermissions, - permissions.enableSubscription - ); - - this.hasPermissionToDeleteAccess = hasPermission( - globalPermissions, - permissions.deleteAccess - ); - this.userService.stateChanged .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((state) => { if (state?.user) { this.user = state.user; - this.defaultDateFormat = getDateFormatString( - this.user.settings.locale - ); - - this.hasPermissionToCreateAccess = hasPermission( - this.user.permissions, - permissions.createAccess - ); - - this.hasPermissionToDeleteAccess = hasPermission( - this.user.permissions, - permissions.deleteAccess - ); - - this.hasPermissionToUpdateUserSettings = hasPermission( - this.user.permissions, - permissions.updateUserSettings - ); - - this.hasPermissionToUpdateViewMode = hasPermission( - this.user.permissions, - permissions.updateViewMode - ); - - this.locales.push(this.user.settings.locale); - this.locales = uniq(this.locales.sort()); - - this.coupon = subscriptions?.[this.user.subscription.offer]?.coupon; - this.couponId = - subscriptions?.[this.user.subscription.offer]?.couponId; - this.price = subscriptions?.[this.user.subscription.offer]?.price; - this.priceId = subscriptions?.[this.user.subscription.offer]?.priceId; + this.tabs = [ + { + iconName: 'cog-outline', + label: $localize`Settings`, + path: ['/account'] + }, + { + iconName: 'diamond-outline', + label: $localize`Membership`, + path: ['/account/membership'], + showCondition: !!this.user?.subscription + }, + { + iconName: 'share-social-outline', + label: $localize`Access`, + path: ['/account', 'access'] + } + ]; this.changeDetectorRef.markForCheck(); } }); - - this.route.queryParams - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((params) => { - if (params['createDialog']) { - this.openCreateAccessDialog(); - } - }); } public ngOnInit() { this.deviceType = this.deviceService.getDeviceInfo().deviceType; - - this.update(); - } - - public onChangeUserSetting(aKey: string, aValue: string) { - this.dataService - .putUserSetting({ [aKey]: aValue }) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(() => { - this.userService.remove(); - - this.userService - .get() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((user) => { - this.user = user; - - this.changeDetectorRef.markForCheck(); - - if (aKey === 'language') { - if (aValue) { - window.location.href = `../${aValue}/account`; - } else { - window.location.href = `../`; - } - } - }); - }); - } - - public onCheckout() { - this.dataService - .createCheckoutSession({ couponId: this.couponId, priceId: this.priceId }) - .pipe( - switchMap(({ sessionId }: { sessionId: string }) => { - return this.stripeService.redirectToCheckout({ sessionId }); - }), - catchError((error) => { - alert(error.message); - throw error; - }) - ) - .subscribe((result) => { - if (result.error) { - alert(result.error.message); - } - }); - } - - public onDeleteAccess(aId: string) { - this.dataService - .deleteAccess(aId) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe({ - next: () => { - this.update(); - } - }); - } - - public onExperimentalFeaturesChange(aEvent: MatCheckboxChange) { - this.dataService - .putUserSetting({ isExperimentalFeatures: aEvent.checked }) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(() => { - this.userService.remove(); - - this.userService - .get() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((user) => { - this.user = user; - - this.changeDetectorRef.markForCheck(); - }); - }); - } - - public onExport() { - this.dataService - .fetchExport() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((data) => { - for (const activity of data.activities) { - delete activity.id; - } - - downloadAsFile({ - content: data, - fileName: `ghostfolio-export-${format( - parseISO(data.meta.date), - 'yyyyMMddHHmm' - )}.json`, - format: 'json' - }); - }); - } - - public onRedeemCoupon() { - let couponCode = prompt($localize`Please enter your coupon code:`); - couponCode = couponCode?.trim(); - - if (couponCode) { - this.dataService - .redeemCoupon(couponCode) - .pipe( - takeUntil(this.unsubscribeSubject), - catchError(() => { - this.snackBar.open( - '😞 ' + $localize`Could not redeem coupon code`, - undefined, - { - duration: 3000 - } - ); - - return EMPTY; - }) - ) - .subscribe(() => { - this.snackBarRef = this.snackBar.open( - '✅ ' + $localize`Coupon code has been redeemed`, - $localize`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: MatCheckboxChange) { - this.dataService - .putUserSetting({ isRestrictedView: aEvent.checked }) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(() => { - this.userService.remove(); - - this.userService - .get() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((user) => { - this.user = user; - - this.changeDetectorRef.markForCheck(); - }); - }); - } - - public onSignInWithFingerprintChange(aEvent: MatCheckboxChange) { - if (aEvent.checked) { - this.registerDevice(); - } else { - const confirmation = confirm( - $localize`Do you really want to remove this sign in method?` - ); - - if (confirmation) { - this.deregisterDevice(); - } else { - this.update(); - } - } - } - - public onViewModeChange(aEvent: MatCheckboxChange) { - this.dataService - .putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' }) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(() => { - this.userService.remove(); - - this.userService - .get() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((user) => { - this.user = user; - - this.changeDetectorRef.markForCheck(); - }); - }); } public ngOnDestroy() { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); } - - private openCreateAccessDialog(): void { - const dialogRef = this.dialog.open(CreateOrUpdateAccessDialog, { - data: { - access: { - alias: '', - type: 'PUBLIC' - } - }, - height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', - width: this.deviceType === 'mobile' ? '100vw' : '50rem' - }); - - dialogRef - .afterClosed() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((data: any) => { - const access: CreateAccessDto = data?.access; - - if (access) { - this.dataService - .postAccess({ alias: access.alias }) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe({ - next: () => { - this.update(); - } - }); - } - - this.router.navigate(['.'], { relativeTo: this.route }); - }); - } - - private deregisterDevice() { - this.webAuthnService - .deregister() - .pipe( - takeUntil(this.unsubscribeSubject), - catchError(() => { - this.update(); - - return EMPTY; - }) - ) - .subscribe(() => { - this.update(); - }); - } - - private registerDevice() { - this.webAuthnService - .register() - .pipe( - takeUntil(this.unsubscribeSubject), - catchError(() => { - this.update(); - - return EMPTY; - }) - ) - .subscribe(() => { - this.settingsStorageService.removeSetting(STAY_SIGNED_IN); - - this.update(); - }); - } - - private update() { - this.dataService - .fetchAccesses() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((response) => { - this.accesses = response; - - if (this.signInWithFingerprintElement) { - this.signInWithFingerprintElement.checked = - this.webAuthnService.isEnabled() ?? false; - } - - this.changeDetectorRef.markForCheck(); - }); - } } diff --git a/apps/client/src/app/pages/user-account/user-account-page.html b/apps/client/src/app/pages/user-account/user-account-page.html index debd190c1..d3fbca534 100644 --- a/apps/client/src/app/pages/user-account/user-account-page.html +++ b/apps/client/src/app/pages/user-account/user-account-page.html @@ -1,309 +1,29 @@ -
-
-
-

Account

-
-
-
-
- - -
-
Membership
-
- -
- Valid until {{ - user?.subscription?.expiresAt | date: defaultDateFormat }} -
-
- - -
- {{ baseCurrency }} {{ price }} {{ baseCurrency }} {{ price - coupon - }} - {{ baseCurrency }} {{ price }} per year -
-
- Try Premium - - Redeem Coupon -
-
-
-
-
-
Presenter View
-
- Protection for sensitive information like absolute performances - and quantity values -
-
-
- -
-
-
-
-
-
- Base Currency -
-
- - - {{ currency }} - - -
-
-
-
-
Language
-
-
- - - - Deutsch - English - Español (Community) - Français (Community) - Italiano (Community) - Nederlands (Community) - Português (Community) - Türkçe (Community) - - -
-
-
-
-
Locale
-
- Date and number format -
-
-
- - - - {{ locale }} - - -
-
-
-
- Appearance -
-
- - - Auto - Light - Dark - - -
-
-
-
-
-
-
Zen Mode
-
- Distraction-free experience for turbulent times -
-
-
- -
-
-
-
-
Biometric Authentication
-
- Sign in with fingerprint -
-
-
- -
-
-
-
-
Experimental Features
-
- Sneak peek at upcoming functionality -
-
-
- -
-
-
-
User ID
-
{{ user?.id }}
-
-
-
-
- -
-
-
-
-
-
-
-
-

- Granted Access - -

- -
-
-
+ + + + + diff --git a/apps/client/src/app/pages/user-account/user-account-page.module.ts b/apps/client/src/app/pages/user-account/user-account-page.module.ts index 240441ada..3c6670af4 100644 --- a/apps/client/src/app/pages/user-account/user-account-page.module.ts +++ b/apps/client/src/app/pages/user-account/user-account-page.module.ts @@ -1,18 +1,10 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatCardModule } from '@angular/material/card'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatDialogModule } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatSelectModule } from '@angular/material/select'; -import { RouterModule } from '@angular/router'; -import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module'; -import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator'; -import { GfValueModule } from '@ghostfolio/ui/value'; +import { MatTabsModule } from '@angular/material/tabs'; +import { GfUserAccountAccessModule } from '@ghostfolio/client/components/user-account-access/user-account-access.module'; +import { GfUserAccountMembershipModule } from '@ghostfolio/client/components/user-account-membership/user-account-membership.module'; +import { GfUserAccountSettingsModule } from '@ghostfolio/client/components/user-account-settings/user-account-settings.module'; -import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-dialog/create-or-update-access-dialog.module'; import { UserAccountPageRoutingModule } from './user-account-page-routing.module'; import { UserAccountPageComponent } from './user-account-page.component'; @@ -20,19 +12,10 @@ import { UserAccountPageComponent } from './user-account-page.component'; declarations: [UserAccountPageComponent], imports: [ CommonModule, - FormsModule, - GfCreateOrUpdateAccessDialogModule, - GfPortfolioAccessTableModule, - GfPremiumIndicatorModule, - GfValueModule, - MatButtonModule, - MatCardModule, - MatCheckboxModule, - MatDialogModule, - MatFormFieldModule, - MatSelectModule, - ReactiveFormsModule, - RouterModule, + GfUserAccountAccessModule, + GfUserAccountMembershipModule, + GfUserAccountSettingsModule, + MatTabsModule, UserAccountPageRoutingModule ] }) diff --git a/apps/client/src/app/pages/user-account/user-account-page.scss b/apps/client/src/app/pages/user-account/user-account-page.scss index 6dddf0e35..6a0b74854 100644 --- a/apps/client/src/app/pages/user-account/user-account-page.scss +++ b/apps/client/src/app/pages/user-account/user-account-page.scss @@ -1,15 +1,7 @@ +@import 'apps/client/src/styles/ghostfolio-style'; + :host { color: rgb(var(--dark-primary-text)); - display: block; - - gf-access-table { - overflow-x: auto; - } - - .hint-text { - font-size: 90%; - line-height: 1.2; - } } :host-context(.is-dark-theme) { diff --git a/apps/client/src/assets/fonts/Inter-Black.woff b/apps/client/src/assets/fonts/Inter-Black.woff new file mode 100644 index 000000000..a18593a09 Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-Black.woff differ diff --git a/apps/client/src/assets/fonts/Inter-Black.woff2 b/apps/client/src/assets/fonts/Inter-Black.woff2 new file mode 100644 index 000000000..68f64c9ed Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-Black.woff2 differ diff --git a/apps/client/src/assets/fonts/Inter-BlackItalic.woff b/apps/client/src/assets/fonts/Inter-BlackItalic.woff new file mode 100644 index 000000000..b6b01943d Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-BlackItalic.woff differ diff --git a/apps/client/src/assets/fonts/Inter-BlackItalic.woff2 b/apps/client/src/assets/fonts/Inter-BlackItalic.woff2 new file mode 100644 index 000000000..1c9c7ca8b Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-BlackItalic.woff2 differ diff --git a/apps/client/src/assets/fonts/Inter-Bold.woff b/apps/client/src/assets/fonts/Inter-Bold.woff new file mode 100644 index 000000000..eaf3d4bfd Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-Bold.woff differ diff --git a/apps/client/src/assets/fonts/Inter-Bold.woff2 b/apps/client/src/assets/fonts/Inter-Bold.woff2 new file mode 100644 index 000000000..2846f29cc Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-Bold.woff2 differ diff --git a/apps/client/src/assets/fonts/Inter-BoldItalic.woff b/apps/client/src/assets/fonts/Inter-BoldItalic.woff new file mode 100644 index 000000000..327507616 Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-BoldItalic.woff differ diff --git a/apps/client/src/assets/fonts/Inter-BoldItalic.woff2 b/apps/client/src/assets/fonts/Inter-BoldItalic.woff2 new file mode 100644 index 000000000..0b1fe8e12 Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-BoldItalic.woff2 differ diff --git a/apps/client/src/assets/fonts/Inter-ExtraBold.woff b/apps/client/src/assets/fonts/Inter-ExtraBold.woff new file mode 100644 index 000000000..c2c17edea Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-ExtraBold.woff differ diff --git a/apps/client/src/assets/fonts/Inter-ExtraBold.woff2 b/apps/client/src/assets/fonts/Inter-ExtraBold.woff2 new file mode 100644 index 000000000..c24c2bdc2 Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-ExtraBold.woff2 differ diff --git a/apps/client/src/assets/fonts/Inter-ExtraBoldItalic.woff b/apps/client/src/assets/fonts/Inter-ExtraBoldItalic.woff new file mode 100644 index 000000000..c42f70526 Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-ExtraBoldItalic.woff differ diff --git a/apps/client/src/assets/fonts/Inter-ExtraBoldItalic.woff2 b/apps/client/src/assets/fonts/Inter-ExtraBoldItalic.woff2 new file mode 100644 index 000000000..4a81dc798 Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-ExtraBoldItalic.woff2 differ diff --git a/apps/client/src/assets/fonts/Inter-ExtraLight.woff b/apps/client/src/assets/fonts/Inter-ExtraLight.woff new file mode 100644 index 000000000..d0de5f397 Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-ExtraLight.woff differ diff --git a/apps/client/src/assets/fonts/Inter-ExtraLight.woff2 b/apps/client/src/assets/fonts/Inter-ExtraLight.woff2 new file mode 100644 index 000000000..f2ea706fa Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-ExtraLight.woff2 differ diff --git a/apps/client/src/assets/fonts/Inter-ExtraLightItalic.woff b/apps/client/src/assets/fonts/Inter-ExtraLightItalic.woff new file mode 100644 index 000000000..81f1a28ef Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-ExtraLightItalic.woff differ diff --git a/apps/client/src/assets/fonts/Inter-ExtraLightItalic.woff2 b/apps/client/src/assets/fonts/Inter-ExtraLightItalic.woff2 new file mode 100644 index 000000000..9af717ba9 Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-ExtraLightItalic.woff2 differ diff --git a/apps/client/src/assets/fonts/Inter-Italic.woff b/apps/client/src/assets/fonts/Inter-Italic.woff new file mode 100644 index 000000000..a806b3820 Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-Italic.woff differ diff --git a/apps/client/src/assets/fonts/Inter-Italic.woff2 b/apps/client/src/assets/fonts/Inter-Italic.woff2 new file mode 100644 index 000000000..a619fc548 Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-Italic.woff2 differ diff --git a/apps/client/src/assets/fonts/Inter-Light.woff b/apps/client/src/assets/fonts/Inter-Light.woff new file mode 100644 index 000000000..c496464d0 Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-Light.woff differ diff --git a/apps/client/src/assets/fonts/Inter-Light.woff2 b/apps/client/src/assets/fonts/Inter-Light.woff2 new file mode 100644 index 000000000..bc4be6658 Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-Light.woff2 differ diff --git a/apps/client/src/assets/fonts/Inter-LightItalic.woff b/apps/client/src/assets/fonts/Inter-LightItalic.woff new file mode 100644 index 000000000..f84a9de35 Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-LightItalic.woff differ diff --git a/apps/client/src/assets/fonts/Inter-LightItalic.woff2 b/apps/client/src/assets/fonts/Inter-LightItalic.woff2 new file mode 100644 index 000000000..842b2dfcb Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-LightItalic.woff2 differ diff --git a/apps/client/src/assets/fonts/Inter-Medium.woff b/apps/client/src/assets/fonts/Inter-Medium.woff new file mode 100644 index 000000000..d546843f2 Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-Medium.woff differ diff --git a/apps/client/src/assets/fonts/Inter-Medium.woff2 b/apps/client/src/assets/fonts/Inter-Medium.woff2 new file mode 100644 index 000000000..f92498a2e Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-Medium.woff2 differ diff --git a/apps/client/src/assets/fonts/Inter-MediumItalic.woff b/apps/client/src/assets/fonts/Inter-MediumItalic.woff new file mode 100644 index 000000000..459a65688 Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-MediumItalic.woff differ diff --git a/apps/client/src/assets/fonts/Inter-MediumItalic.woff2 b/apps/client/src/assets/fonts/Inter-MediumItalic.woff2 new file mode 100644 index 000000000..0e3019f4a Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-MediumItalic.woff2 differ diff --git a/apps/client/src/assets/fonts/Inter-Regular.woff b/apps/client/src/assets/fonts/Inter-Regular.woff new file mode 100644 index 000000000..62d3a6187 Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-Regular.woff differ diff --git a/apps/client/src/assets/fonts/Inter-Regular.woff2 b/apps/client/src/assets/fonts/Inter-Regular.woff2 new file mode 100644 index 000000000..6c2b6893d Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-Regular.woff2 differ diff --git a/apps/client/src/assets/fonts/Inter-SemiBold.woff b/apps/client/src/assets/fonts/Inter-SemiBold.woff new file mode 100644 index 000000000..a815f43a9 Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-SemiBold.woff differ diff --git a/apps/client/src/assets/fonts/Inter-SemiBold.woff2 b/apps/client/src/assets/fonts/Inter-SemiBold.woff2 new file mode 100644 index 000000000..611e90c95 Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-SemiBold.woff2 differ diff --git a/apps/client/src/assets/fonts/Inter-SemiBoldItalic.woff b/apps/client/src/assets/fonts/Inter-SemiBoldItalic.woff new file mode 100644 index 000000000..909e43a97 Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-SemiBoldItalic.woff differ diff --git a/apps/client/src/assets/fonts/Inter-SemiBoldItalic.woff2 b/apps/client/src/assets/fonts/Inter-SemiBoldItalic.woff2 new file mode 100644 index 000000000..545685bd2 Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-SemiBoldItalic.woff2 differ diff --git a/apps/client/src/assets/fonts/Inter-Thin.woff b/apps/client/src/assets/fonts/Inter-Thin.woff new file mode 100644 index 000000000..62bc58cd1 Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-Thin.woff differ diff --git a/apps/client/src/assets/fonts/Inter-Thin.woff2 b/apps/client/src/assets/fonts/Inter-Thin.woff2 new file mode 100644 index 000000000..abbc3a5c9 Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-Thin.woff2 differ diff --git a/apps/client/src/assets/fonts/Inter-ThinItalic.woff b/apps/client/src/assets/fonts/Inter-ThinItalic.woff new file mode 100644 index 000000000..700a7f069 Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-ThinItalic.woff differ diff --git a/apps/client/src/assets/fonts/Inter-ThinItalic.woff2 b/apps/client/src/assets/fonts/Inter-ThinItalic.woff2 new file mode 100644 index 000000000..ab0b2002a Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-ThinItalic.woff2 differ diff --git a/apps/client/src/assets/fonts/Inter-italic.var.woff2 b/apps/client/src/assets/fonts/Inter-italic.var.woff2 new file mode 100644 index 000000000..b826d5af8 Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-italic.var.woff2 differ diff --git a/apps/client/src/assets/fonts/Inter-roman.var.woff2 b/apps/client/src/assets/fonts/Inter-roman.var.woff2 new file mode 100644 index 000000000..6a256a068 Binary files /dev/null and b/apps/client/src/assets/fonts/Inter-roman.var.woff2 differ diff --git a/apps/client/src/assets/fonts/Inter.var.woff2 b/apps/client/src/assets/fonts/Inter.var.woff2 new file mode 100644 index 000000000..365eedc50 Binary files /dev/null and b/apps/client/src/assets/fonts/Inter.var.woff2 differ diff --git a/apps/client/src/assets/fonts/inter.css b/apps/client/src/assets/fonts/inter.css new file mode 100644 index 000000000..71afda98a --- /dev/null +++ b/apps/client/src/assets/fonts/inter.css @@ -0,0 +1,226 @@ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100; + font-display: swap; + src: + url('Inter-Thin.woff2?v=3.19') format('woff2'), + url('Inter-Thin.woff?v=3.19') format('woff'); +} + +@font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 100; + font-display: swap; + src: + url('Inter-ThinItalic.woff2?v=3.19') format('woff2'), + url('Inter-ThinItalic.woff?v=3.19') format('woff'); +} + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 200; + font-display: swap; + src: + url('Inter-ExtraLight.woff2?v=3.19') format('woff2'), + url('Inter-ExtraLight.woff?v=3.19') format('woff'); +} + +@font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 200; + font-display: swap; + src: + url('Inter-ExtraLightItalic.woff2?v=3.19') format('woff2'), + url('Inter-ExtraLightItalic.woff?v=3.19') format('woff'); +} + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: + url('Inter-Light.woff2?v=3.19') format('woff2'), + url('Inter-Light.woff?v=3.19') format('woff'); +} + +@font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 300; + font-display: swap; + src: + url('Inter-LightItalic.woff2?v=3.19') format('woff2'), + url('Inter-LightItalic.woff?v=3.19') format('woff'); +} + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: + url('Inter-Regular.woff2?v=3.19') format('woff2'), + url('Inter-Regular.woff?v=3.19') format('woff'); +} + +@font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: + url('Inter-Italic.woff2?v=3.19') format('woff2'), + url('Inter-Italic.woff?v=3.19') format('woff'); +} + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: + url('Inter-Medium.woff2?v=3.19') format('woff2'), + url('Inter-Medium.woff?v=3.19') format('woff'); +} + +@font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 500; + font-display: swap; + src: + url('Inter-MediumItalic.woff2?v=3.19') format('woff2'), + url('Inter-MediumItalic.woff?v=3.19') format('woff'); +} + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: + url('Inter-SemiBold.woff2?v=3.19') format('woff2'), + url('Inter-SemiBold.woff?v=3.19') format('woff'); +} + +@font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 600; + font-display: swap; + src: + url('Inter-SemiBoldItalic.woff2?v=3.19') format('woff2'), + url('Inter-SemiBoldItalic.woff?v=3.19') format('woff'); +} + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: + url('Inter-Bold.woff2?v=3.19') format('woff2'), + url('Inter-Bold.woff?v=3.19') format('woff'); +} + +@font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 700; + font-display: swap; + src: + url('Inter-BoldItalic.woff2?v=3.19') format('woff2'), + url('Inter-BoldItalic.woff?v=3.19') format('woff'); +} + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 800; + font-display: swap; + src: + url('Inter-ExtraBold.woff2?v=3.19') format('woff2'), + url('Inter-ExtraBold.woff?v=3.19') format('woff'); +} + +@font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 800; + font-display: swap; + src: + url('Inter-ExtraBoldItalic.woff2?v=3.19') format('woff2'), + url('Inter-ExtraBoldItalic.woff?v=3.19') format('woff'); +} + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 900; + font-display: swap; + src: + url('Inter-Black.woff2?v=3.19') format('woff2'), + url('Inter-Black.woff?v=3.19') format('woff'); +} + +@font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 900; + font-display: swap; + src: + url('Inter-BlackItalic.woff2?v=3.19') format('woff2'), + url('Inter-BlackItalic.woff?v=3.19') format('woff'); +} + +/* ------------------------------------------------------- +Variable font. +Usage: + + html { font-family: 'Inter', sans-serif; } + @supports (font-variation-settings: normal) { + html { font-family: 'Inter var', sans-serif; } + } +*/ +@font-face { + font-family: 'Inter var'; + font-weight: 100 900; + font-display: swap; + font-style: normal; + font-named-instance: 'Regular'; + src: url('Inter-roman.var.woff2?v=3.19') format('woff2'); +} +@font-face { + font-family: 'Inter var'; + font-weight: 100 900; + font-display: swap; + font-style: italic; + font-named-instance: 'Italic'; + src: url('Inter-italic.var.woff2?v=3.19') format('woff2'); +} + +/* -------------------------------------------------------------------------- +[EXPERIMENTAL] Multi-axis, single variable font. + +Slant axis is not yet widely supported (as of February 2019) and thus this +multi-axis single variable font is opt-in rather than the default. + +When using this, you will probably need to set font-variation-settings +explicitly, e.g. + + * { font-variation-settings: "slnt" 0deg } + .italic { font-variation-settings: "slnt" 10deg } + +*/ +@font-face { + font-family: 'Inter var experimental'; + font-weight: 100 900; + font-display: swap; + font-style: oblique 0deg 10deg; + src: url('Inter.var.woff2?v=3.19') format('woff2'); +} diff --git a/apps/client/src/locales/messages.de.xlf b/apps/client/src/locales/messages.de.xlf index 6fcaa9d47..4ca6c031e 100644 --- a/apps/client/src/locales/messages.de.xlf +++ b/apps/client/src/locales/messages.de.xlf @@ -1274,6 +1274,10 @@ apps/client/src/app/pages/features/features-page.html 89 + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 150 + Buying Power @@ -1590,6 +1594,10 @@ apps/client/src/app/pages/blog/2023/09/ghostfolio-2/ghostfolio-2-page.html 273 + + apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.html + 181 + apps/client/src/app/pages/blog/blog-page.html 5 @@ -3734,6 +3742,10 @@ apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html 148 + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 137 + Portfolio Allocations @@ -8130,6 +8142,10 @@ apps/client/src/app/pages/blog/2023/09/ghostfolio-2/ghostfolio-2-page.component.ts 14 + + apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.component.ts + 13 + apps/client/src/app/pages/landing/landing-page.component.ts 25 @@ -9927,6 +9943,30 @@ 312 + + Ghostfolio X-ray uses static analysis to identify potential issues and risks in your portfolio. + Ghostfolio X-ray nutzt statische Analysen, um potenzielle Probleme und Risiken in deinem Portfolio zu identifizieren. + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 100,101 + + + + Currency Cluster Risks + Währungsklumpenrisiken + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 111 + + + + Account Cluster Risks + Kontoklumpenrisiken + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 124 + + diff --git a/apps/client/src/locales/messages.es.xlf b/apps/client/src/locales/messages.es.xlf index 0cc0dcf6d..bbb2d41a5 100644 --- a/apps/client/src/locales/messages.es.xlf +++ b/apps/client/src/locales/messages.es.xlf @@ -1272,6 +1272,10 @@ apps/client/src/app/pages/features/features-page.html 89 + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 150 + Buying Power @@ -1588,6 +1592,10 @@ apps/client/src/app/pages/blog/2023/09/ghostfolio-2/ghostfolio-2-page.html 273 + + apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.html + 181 + apps/client/src/app/pages/blog/blog-page.html 5 @@ -3732,6 +3740,10 @@ apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html 148 + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 137 + Portfolio Allocations @@ -8128,6 +8140,10 @@ apps/client/src/app/pages/blog/2023/09/ghostfolio-2/ghostfolio-2-page.component.ts 14 + + apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.component.ts + 13 + apps/client/src/app/pages/landing/landing-page.component.ts 25 @@ -9925,6 +9941,30 @@ 312 + + Ghostfolio X-ray uses static analysis to identify potential issues and risks in your portfolio. + Ghostfolio X-ray uses static analysis to identify potential issues and risks in your portfolio. + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 100,101 + + + + Currency Cluster Risks + Currency Cluster Risks + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 111 + + + + Account Cluster Risks + Account Cluster Risks + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 124 + + diff --git a/apps/client/src/locales/messages.fr.xlf b/apps/client/src/locales/messages.fr.xlf index cf651180a..bdba67fad 100644 --- a/apps/client/src/locales/messages.fr.xlf +++ b/apps/client/src/locales/messages.fr.xlf @@ -1603,6 +1603,10 @@ apps/client/src/app/pages/features/features-page.html 89 + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 150 + Buying Power @@ -2339,6 +2343,10 @@ apps/client/src/app/pages/blog/2023/09/ghostfolio-2/ghostfolio-2-page.html 273 + + apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.html + 181 + apps/client/src/app/pages/blog/blog-page.html 5 @@ -3731,6 +3739,10 @@ apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html 148 + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 137 + Portfolio Allocations @@ -8127,6 +8139,10 @@ apps/client/src/app/pages/blog/2023/09/ghostfolio-2/ghostfolio-2-page.component.ts 14 + + apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.component.ts + 13 + apps/client/src/app/pages/landing/landing-page.component.ts 25 @@ -9924,6 +9940,30 @@ 312 + + Ghostfolio X-ray uses static analysis to identify potential issues and risks in your portfolio. + Ghostfolio X-ray uses static analysis to identify potential issues and risks in your portfolio. + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 100,101 + + + + Currency Cluster Risks + Currency Cluster Risks + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 111 + + + + Account Cluster Risks + Account Cluster Risks + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 124 + + diff --git a/apps/client/src/locales/messages.it.xlf b/apps/client/src/locales/messages.it.xlf index 9123d8385..ae8834f0c 100644 --- a/apps/client/src/locales/messages.it.xlf +++ b/apps/client/src/locales/messages.it.xlf @@ -1272,6 +1272,10 @@ apps/client/src/app/pages/features/features-page.html 89 + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 150 + Buying Power @@ -1588,6 +1592,10 @@ apps/client/src/app/pages/blog/2023/09/ghostfolio-2/ghostfolio-2-page.html 273 + + apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.html + 181 + apps/client/src/app/pages/blog/blog-page.html 5 @@ -3732,6 +3740,10 @@ apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html 148 + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 137 + Portfolio Allocations @@ -8128,6 +8140,10 @@ apps/client/src/app/pages/blog/2023/09/ghostfolio-2/ghostfolio-2-page.component.ts 14 + + apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.component.ts + 13 + apps/client/src/app/pages/landing/landing-page.component.ts 25 @@ -9925,6 +9941,30 @@ 312 + + Ghostfolio X-ray uses static analysis to identify potential issues and risks in your portfolio. + Ghostfolio X-ray uses static analysis to identify potential issues and risks in your portfolio. + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 100,101 + + + + Currency Cluster Risks + Currency Cluster Risks + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 111 + + + + Account Cluster Risks + Account Cluster Risks + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 124 + + diff --git a/apps/client/src/locales/messages.nl.xlf b/apps/client/src/locales/messages.nl.xlf index 5a4e2549e..f69216f63 100644 --- a/apps/client/src/locales/messages.nl.xlf +++ b/apps/client/src/locales/messages.nl.xlf @@ -1271,6 +1271,10 @@ apps/client/src/app/pages/features/features-page.html 89 + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 150 + Buying Power @@ -1587,6 +1591,10 @@ apps/client/src/app/pages/blog/2023/09/ghostfolio-2/ghostfolio-2-page.html 273 + + apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.html + 181 + apps/client/src/app/pages/blog/blog-page.html 5 @@ -3731,6 +3739,10 @@ apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html 148 + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 137 + Portfolio Allocations @@ -8127,6 +8139,10 @@ apps/client/src/app/pages/blog/2023/09/ghostfolio-2/ghostfolio-2-page.component.ts 14 + + apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.component.ts + 13 + apps/client/src/app/pages/landing/landing-page.component.ts 25 @@ -9924,6 +9940,30 @@ 312 + + Ghostfolio X-ray uses static analysis to identify potential issues and risks in your portfolio. + Ghostfolio X-ray uses static analysis to identify potential issues and risks in your portfolio. + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 100,101 + + + + Currency Cluster Risks + Currency Cluster Risks + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 111 + + + + Account Cluster Risks + Account Cluster Risks + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 124 + + diff --git a/apps/client/src/locales/messages.pt.xlf b/apps/client/src/locales/messages.pt.xlf index e8d3b4ccd..19a8df179 100644 --- a/apps/client/src/locales/messages.pt.xlf +++ b/apps/client/src/locales/messages.pt.xlf @@ -1499,6 +1499,10 @@ apps/client/src/app/pages/features/features-page.html 89 + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 150 + Buying Power @@ -2263,6 +2267,10 @@ apps/client/src/app/pages/blog/2023/09/ghostfolio-2/ghostfolio-2-page.html 273 + + apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.html + 181 + apps/client/src/app/pages/blog/blog-page.html 5 @@ -3731,6 +3739,10 @@ apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html 148 + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 137 + Portfolio Allocations @@ -8127,6 +8139,10 @@ apps/client/src/app/pages/blog/2023/09/ghostfolio-2/ghostfolio-2-page.component.ts 14 + + apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.component.ts + 13 + apps/client/src/app/pages/landing/landing-page.component.ts 25 @@ -9924,6 +9940,30 @@ 312 + + Ghostfolio X-ray uses static analysis to identify potential issues and risks in your portfolio. + Ghostfolio X-ray uses static analysis to identify potential issues and risks in your portfolio. + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 100,101 + + + + Currency Cluster Risks + Currency Cluster Risks + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 111 + + + + Account Cluster Risks + Account Cluster Risks + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 124 + + diff --git a/apps/client/src/locales/messages.tr.xlf b/apps/client/src/locales/messages.tr.xlf index fafe42821..61cdc0972 100644 --- a/apps/client/src/locales/messages.tr.xlf +++ b/apps/client/src/locales/messages.tr.xlf @@ -64,6 +64,10 @@ apps/client/src/app/pages/blog/2023/09/ghostfolio-2/ghostfolio-2-page.component.ts 14 + + apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.component.ts + 13 + apps/client/src/app/pages/landing/landing-page.component.ts 25 @@ -804,6 +808,10 @@ apps/client/src/app/pages/blog/2023/09/ghostfolio-2/ghostfolio-2-page.html 273 + + apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.html + 181 + apps/client/src/app/pages/blog/blog-page.html 5 @@ -2671,6 +2679,10 @@ apps/client/src/app/pages/features/features-page.html 89 + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 150 + Cash @@ -2823,6 +2835,10 @@ apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html 148 + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 137 + First Buy Date @@ -9924,6 +9940,30 @@ 312 + + Ghostfolio X-ray uses static analysis to identify potential issues and risks in your portfolio. + Ghostfolio X-ray uses static analysis to identify potential issues and risks in your portfolio. + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 100,101 + + + + Currency Cluster Risks + Currency Cluster Risks + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 111 + + + + Account Cluster Risks + Account Cluster Risks + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 124 + + diff --git a/apps/client/src/locales/messages.xlf b/apps/client/src/locales/messages.xlf index 383ee964b..956a2ad85 100644 --- a/apps/client/src/locales/messages.xlf +++ b/apps/client/src/locales/messages.xlf @@ -64,6 +64,10 @@ apps/client/src/app/pages/blog/2023/09/ghostfolio-2/ghostfolio-2-page.component.ts 14 + + apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.component.ts + 13 + apps/client/src/app/pages/landing/landing-page.component.ts 25 @@ -791,6 +795,10 @@ apps/client/src/app/pages/blog/2023/09/ghostfolio-2/ghostfolio-2-page.html 273 + + apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.html + 181 + apps/client/src/app/pages/blog/blog-page.html 5 @@ -2504,6 +2512,10 @@ apps/client/src/app/pages/features/features-page.html 89 + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 150 + Cash @@ -2641,6 +2653,10 @@ apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html 148 + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 137 + First Buy Date @@ -9370,6 +9386,27 @@ 11,13 + + Ghostfolio X-ray uses static analysis to identify potential issues and risks in your portfolio. + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 100,101 + + + + Currency Cluster Risks + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 111 + + + + Account Cluster Risks + + apps/client/src/app/pages/portfolio/fire/fire-page.html + 124 + + diff --git a/apps/client/src/styles.scss b/apps/client/src/styles.scss index 5920df530..c5f8ffd0b 100644 --- a/apps/client/src/styles.scss +++ b/apps/client/src/styles.scss @@ -5,7 +5,7 @@ :root { --dark-background: rgb(25, 25, 25); - --font-family-sans-serif: Roboto, 'Helvetica Neue', sans-serif; + --font-family-sans-serif: 'Inter', Roboto, 'Helvetica Neue', sans-serif; --light-background: rgb(255, 255, 255); --dark-primary-text: 0, 0, 0, 0.87; diff --git a/package.json b/package.json index 01bedb555..0cc54aaf4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "2.6.0", + "version": "2.7.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio",