Browse Source

Feature/extend pricing page (#130)

* Extend pricing page

* Feature/align pricing page with subscription model (#135)

* Align pricing page with subscription model

* Update changelog
pull/137/head
Thomas 3 years ago
committed by GitHub
parent
commit
e7fbcd4fa0
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      CHANGELOG.md
  2. 52
      apps/api/src/app/user/user.service.ts
  3. 8
      apps/client/src/app/pages/account/account-page.component.ts
  4. 6
      apps/client/src/app/pages/account/account-page.html
  5. 249
      apps/client/src/app/pages/pricing/pricing-page.html
  6. 10
      apps/client/src/app/pages/pricing/pricing-page.module.ts
  7. 13
      apps/client/src/app/pages/pricing/pricing-page.scss
  8. 5
      libs/common/src/lib/interfaces/user-with-settings.ts
  9. 2
      libs/common/src/lib/interfaces/user.interface.ts
  10. 4
      libs/common/src/lib/types/subscription.type.ts
  11. 24
      prisma/schema.prisma

1
CHANGELOG.md

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Moved the tools to a sub path (`/tools`) - Moved the tools to a sub path (`/tools`)
- Extended the pricing page and aligned with the subscription model
## 1.9.0 - 01.06.2021 ## 1.9.0 - 01.06.2021

52
apps/api/src/app/user/user.service.ts

@ -4,9 +4,10 @@ import { locale } from '@ghostfolio/common/config';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces'; import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
import { getPermissions, permissions } from '@ghostfolio/common/permissions'; import { getPermissions, permissions } from '@ghostfolio/common/permissions';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client'; import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client';
import { add } from 'date-fns'; import { add, isBefore } from 'date-fns';
const crypto = require('crypto'); const crypto = require('crypto');
@ -24,7 +25,8 @@ export class UserService {
alias, alias,
id, id,
role, role,
Settings Settings,
subscription
}: UserWithSettings): Promise<IUser> { }: UserWithSettings): Promise<IUser> {
const access = await this.prisma.access.findMany({ const access = await this.prisma.access.findMany({
include: { include: {
@ -43,6 +45,7 @@ export class UserService {
return { return {
alias, alias,
id, id,
subscription,
access: access.map((accessItem) => { access: access.map((accessItem) => {
return { return {
alias: accessItem.User.alias, alias: accessItem.User.alias,
@ -54,11 +57,7 @@ export class UserService {
settings: { settings: {
locale, locale,
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY, baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
viewMode: Settings.viewMode ?? ViewMode.DEFAULT viewMode: Settings?.viewMode ?? ViewMode.DEFAULT
},
subscription: {
expiresAt: resetHours(add(new Date(), { days: 7 })),
type: 'Trial'
} }
}; };
} }
@ -66,26 +65,49 @@ export class UserService {
public async user( public async user(
userWhereUniqueInput: Prisma.UserWhereUniqueInput userWhereUniqueInput: Prisma.UserWhereUniqueInput
): Promise<UserWithSettings | null> { ): Promise<UserWithSettings | null> {
const user = await this.prisma.user.findUnique({ const userFromDatabase = await this.prisma.user.findUnique({
include: { Account: true, Settings: true }, include: { Account: true, Settings: true, Subscription: true },
where: userWhereUniqueInput where: userWhereUniqueInput
}); });
if (user?.Settings) { const user: UserWithSettings = userFromDatabase;
if (!user.Settings.currency) {
if (userFromDatabase?.Settings) {
if (!userFromDatabase.Settings.currency) {
// Set default currency if needed // Set default currency if needed
user.Settings.currency = UserService.DEFAULT_CURRENCY; userFromDatabase.Settings.currency = UserService.DEFAULT_CURRENCY;
} }
} else if (user) { } else if (userFromDatabase) {
// Set default settings if needed // Set default settings if needed
user.Settings = { userFromDatabase.Settings = {
currency: UserService.DEFAULT_CURRENCY, currency: UserService.DEFAULT_CURRENCY,
updatedAt: new Date(), updatedAt: new Date(),
userId: user?.id, userId: userFromDatabase?.id,
viewMode: ViewMode.DEFAULT viewMode: ViewMode.DEFAULT
}; };
} }
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (userFromDatabase?.Subscription?.length > 0) {
const latestSubscription = userFromDatabase.Subscription.reduce(
(a, b) => {
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
}
);
user.subscription = {
expiresAt: latestSubscription.expiresAt,
type: isBefore(new Date(), latestSubscription.expiresAt)
? SubscriptionType.Premium
: SubscriptionType.Basic
};
} else {
user.subscription = {
type: SubscriptionType.Basic
};
}
}
return user; return user;
} }

8
apps/client/src/app/pages/account/account-page.component.ts

@ -18,7 +18,6 @@ export class AccountPageComponent implements OnDestroy, OnInit {
public baseCurrency: Currency; public baseCurrency: Currency;
public currencies: Currency[] = []; public currencies: Currency[] = [];
public defaultDateFormat = DEFAULT_DATE_FORMAT; public defaultDateFormat = DEFAULT_DATE_FORMAT;
public hasPermissionForSubscription: boolean;
public hasPermissionToUpdateUserSettings: boolean; public hasPermissionToUpdateUserSettings: boolean;
public user: User; public user: User;
@ -35,13 +34,8 @@ export class AccountPageComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.fetchInfo() .fetchInfo()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ currencies, globalPermissions }) => { .subscribe(({ currencies }) => {
this.currencies = currencies; this.currencies = currencies;
this.hasPermissionForSubscription = hasPermission(
globalPermissions,
permissions.enableSubscription
);
}); });
this.userService.stateChanged this.userService.stateChanged

6
apps/client/src/app/pages/account/account-page.html

@ -15,13 +15,13 @@
<div class="w-50" i18n>Alias</div> <div class="w-50" i18n>Alias</div>
<div class="w-50">{{ user.alias }}</div> <div class="w-50">{{ user.alias }}</div>
</div> </div>
<div *ngIf="hasPermissionForSubscription" class="d-flex py-1"> <div *ngIf="user?.subscription" class="d-flex py-1">
<div class="w-50" i18n>Membership</div> <div class="w-50" i18n>Membership</div>
<div class="w-50"> <div class="w-50">
<div class="align-items-center d-flex mb-1"> <div class="align-items-center d-flex mb-1">
{{ user?.subscription?.type }} {{ user.subscription.type }}
</div> </div>
<div> <div *ngIf="user.subscription.expiresAt">
Valid until {{ user.subscription.expiresAt | date: Valid until {{ user.subscription.expiresAt | date:
defaultDateFormat }} defaultDateFormat }}
</div> </div>

249
apps/client/src/app/pages/pricing/pricing-page.html

@ -2,95 +2,178 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>Pricing Plans</h3> <h3 class="d-flex justify-content-center mb-3" i18n>Pricing Plans</h3>
<p>
Our official
<strong>Ghostfolio</strong> cloud offering is the easiest way to get
started. Due to the time it saves, this will be the best option for most
people. The revenue is used for covering the hosting costs.
</p>
<p class="mb-5">
If you prefer to run <strong>Ghostfolio</strong> on your own
infrastructure, please find the source code and further instructions on
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>.
</p>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-6"> <div class="col-xs-12 col-md-4 mb-3">
<mat-card class="mb-3"> <mat-card class="d-flex flex-column h-100">
<h4 i18n>Open Source</h4> <div class="flex-grow-1">
<p>Host your <strong>Ghostfolio</strong> instance by yourself.</p> <h4 i18n>Open Source</h4>
<ul class="list-unstyled mb-3"> <p>
<li class="align-items-center d-flex mb-1"> For tech-savvy investors who prefer to run
<ion-icon <strong>Ghostfolio</strong> on their own infrastructure.
class="mr-1 text-muted" </p>
name="checkmark-circle-outline" <ul class="list-unstyled mb-3">
></ion-icon> <li class="align-items-center d-flex mb-1">
<span>Portfolio Performance</span> <ion-icon
</li> class="mr-1 text-muted"
<li class="align-items-center d-flex mb-1"> name="checkmark-circle-outline"
<ion-icon ></ion-icon>
class="mr-1 text-muted" <span>Unlimited Transactions</span>
name="checkmark-circle-outline" </li>
></ion-icon> <li class="align-items-center d-flex mb-1">
<span>Portfolio Summary</span> <ion-icon
</li> class="mr-1 text-muted"
<li class="align-items-center d-flex mb-1"> name="checkmark-circle-outline"
<ion-icon ></ion-icon>
class="mr-1 text-muted" <span>Portfolio Performance</span>
name="checkmark-circle-outline" </li>
></ion-icon> <li class="align-items-center d-flex mb-1">
<span>Unlimited Transactions</span> <ion-icon
</li> class="mr-1 text-muted"
<li class="align-items-center d-flex mb-1"> name="checkmark-circle-outline"
<ion-icon ></ion-icon>
class="mr-1 text-muted" <span>Zen Mode</span>
name="checkmark-circle-outline" </li>
></ion-icon> <li class="align-items-center d-flex mb-1">
<span>Advanced Insights</span> <ion-icon
</li> class="mr-1 text-muted"
</ul> name="checkmark-circle-outline"
<p class="h5 text-right"> ></ion-icon>
<span>Free</span> <span>Portfolio Summary</span>
</p> </li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Advanced Insights</span>
</li>
</ul>
</div>
<p>Self-hosted.</p>
<p class="h5 text-right">Free</p>
</mat-card> </mat-card>
</div> </div>
<div class="col-xs-12 col-md-6"> <div class="col-xs-12 col-md-4 mb-3">
<mat-card <mat-card
class="mb-3" class="d-flex flex-column h-100"
[ngClass]="{ 'active': user?.subscription?.type === 'Trial' }" [ngClass]="{ 'active': user?.subscription?.type === 'Basic' }"
> >
<h4 class="align-items-center d-flex" i18n> <div class="flex-grow-1">
Diamond <h4 class="align-items-center d-flex" i18n>Basic</h4>
<ion-icon <p>
class="ml-1 text-muted" For new investors who are just getting started with trading.
name="diamond-outline" </p>
></ion-icon> <ul class="list-unstyled mb-3">
</h4> <li class="align-items-center d-flex mb-1">
<p> <ion-icon
Get a fully managed <strong>Ghostfolio</strong> cloud offering. class="mr-1 text-muted"
</p> name="checkmark-circle-outline"
<ul class="list-unstyled mb-3"> ></ion-icon>
<li class="align-items-center d-flex mb-1"> <span>Unlimited Transactions</span>
<ion-icon </li>
class="mr-1 text-muted" <li class="align-items-center d-flex mb-1">
name="checkmark-circle-outline" <ion-icon
></ion-icon> class="mr-1 text-muted"
<span>Portfolio Performance</span> name="checkmark-circle-outline"
</li> ></ion-icon>
<li class="align-items-center d-flex mb-1"> <span>Portfolio Performance</span>
<ion-icon </li>
class="mr-1 text-muted" <li class="align-items-center d-flex mb-1">
name="checkmark-circle-outline" <ion-icon
></ion-icon> class="mr-1 text-muted"
<span>Portfolio Summary</span> name="checkmark-circle-outline"
</li> ></ion-icon>
<li class="align-items-center d-flex mb-1"> <span>Zen Mode</span>
<ion-icon </li>
class="mr-1 text-muted" <li>
name="checkmark-circle-outline" <ion-icon
></ion-icon> class="invisible"
<span>Unlimited Transactions</span> name="checkmark-circle-outline"
</li> ></ion-icon>
<li class="align-items-center d-flex mb-1"> </li>
<li>
<ion-icon
class="invisible"
name="checkmark-circle-outline"
></ion-icon>
</li>
</ul>
</div>
<p>Fully managed <strong>Ghostfolio</strong> cloud offering.</p>
<p class="h5 text-right">Free</p>
</mat-card>
</div>
<div class="col-xs-12 col-md-4 mb-3">
<mat-card
class="d-flex flex-column h-100"
[ngClass]="{ 'active': user?.subscription?.type === 'Premium' }"
>
<div class="flex-grow-1">
<h4 class="align-items-center d-flex" i18n>
Premium
<ion-icon <ion-icon
class="mr-1 text-muted" class="ml-1 text-muted"
name="checkmark-circle-outline" name="diamond-outline"
></ion-icon> ></ion-icon>
<span>Advanced Insights</span> </h4>
</li> <p>
</ul> For ambitious investors who need the full picture of their
financial assets.
</p>
<ul class="list-unstyled mb-3">
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Unlimited Transactions</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Portfolio Performance</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Zen Mode</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Portfolio Summary</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Advanced Insights</span>
</li>
</ul>
</div>
<p>Fully managed <strong>Ghostfolio</strong> cloud offering.</p>
<p class="h5 text-right"> <p class="h5 text-right">
<span class="font-weight-normal" <span class="font-weight-normal"
>{{ user?.settings.baseCurrency || baseCurrency }} >{{ user?.settings.baseCurrency || baseCurrency }}
<strong>2.99</strong> <strong>0.00</strong>
<del class="ml-1 text-muted">3.99</del> / Month</span <del class="ml-1 text-muted">3.99</del> / Month</span
> >
</p> </p>
@ -99,4 +182,12 @@
</div> </div>
</div> </div>
</div> </div>
<div *ngIf="!user" class="row">
<div class="col mt-3 text-center">
<a color="primary" i18n mat-flat-button [routerLink]="['/start']">
Create Account
</a>
<p class="text-muted"><small>It's free</small></p>
</div>
</div>
</div> </div>

10
apps/client/src/app/pages/pricing/pricing-page.module.ts

@ -1,6 +1,8 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router';
import { PricingPageRoutingModule } from './pricing-page-routing.module'; import { PricingPageRoutingModule } from './pricing-page-routing.module';
import { PricingPageComponent } from './pricing-page.component'; import { PricingPageComponent } from './pricing-page.component';
@ -8,7 +10,13 @@ import { PricingPageComponent } from './pricing-page.component';
@NgModule({ @NgModule({
declarations: [PricingPageComponent], declarations: [PricingPageComponent],
exports: [], exports: [],
imports: [CommonModule, MatCardModule, PricingPageRoutingModule], imports: [
CommonModule,
MatButtonModule,
MatCardModule,
PricingPageRoutingModule,
RouterModule
],
providers: [], providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })

13
apps/client/src/app/pages/pricing/pricing-page.scss

@ -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: bold;
&:hover {
color: rgba(var(--palette-primary-300), 1);
}
}
.mat-card { .mat-card {
&.active { &.active {
border-color: rgba(var(--palette-primary-500), 1); border-color: rgba(var(--palette-primary-500), 1);
@ -11,4 +20,8 @@
:host-context(.is-dark-theme) { :host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text)); color: rgb(var(--light-primary-text));
a {
color: rgb(var(--light-primary-text));
}
} }

5
libs/common/src/lib/interfaces/user-with-settings.ts

@ -1,6 +1,11 @@
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Account, Settings, User } from '@prisma/client'; import { Account, Settings, User } from '@prisma/client';
export type UserWithSettings = User & { export type UserWithSettings = User & {
Account: Account[]; Account: Account[];
Settings: Settings; Settings: Settings;
subscription?: {
expiresAt?: Date;
type: SubscriptionType;
};
}; };

2
libs/common/src/lib/interfaces/user.interface.ts

@ -12,6 +12,6 @@ export interface User {
settings: UserSettings; settings: UserSettings;
subscription: { subscription: {
expiresAt: Date; expiresAt: Date;
type: 'Trial'; type: 'Basic' | 'Premium';
}; };
} }

4
libs/common/src/lib/types/subscription.type.ts

@ -0,0 +1,4 @@
export enum SubscriptionType {
Basic = 'Basic',
Premium = 'Premium'
}

24
prisma/schema.prisma

@ -99,21 +99,33 @@ model Settings {
userId String @id userId String @id
} }
model Subscription {
createdAt DateTime @default(now())
expiresAt DateTime
id String @default(uuid())
updatedAt DateTime @updatedAt
User User @relation(fields: [userId], references: [id])
userId String
@@id([id, userId])
}
model User { model User {
Access Access[] @relation("accessGet") Access Access[] @relation("accessGet")
AccessGive Access[] @relation(name: "accessGive") AccessGive Access[] @relation(name: "accessGive")
accessToken String? accessToken String?
Account Account[] Account Account[]
alias String? alias String?
Analytics Analytics? Analytics Analytics?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
id String @id @default(uuid()) id String @id @default(uuid())
Order Order[] Order Order[]
provider Provider? provider Provider?
role Role @default(USER) role Role @default(USER)
Settings Settings? Settings Settings?
Subscription Subscription[]
thirdPartyId String? thirdPartyId String?
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
enum AccountType { enum AccountType {

Loading…
Cancel
Save