Compare commits
26 Commits
Author | SHA1 | Date | |
---|---|---|---|
beb7e6ec34 | |||
2eafc042ad | |||
74954bc51d | |||
6a03120225 | |||
21504573b4 | |||
fabd912fba | |||
00b42855b6 | |||
ef272360fb | |||
026a5011d4 | |||
aa4206af0e | |||
7788465272 | |||
3066dfd805 | |||
34303163bc | |||
e7fbcd4fa0 | |||
7c22969de1 | |||
6623bc0113 | |||
146b5201b5 | |||
b021fbde59 | |||
ec046b81a7 | |||
aea497154a | |||
dc736d53b4 | |||
5957b33779 | |||
bafdce56ad | |||
42a2d404e4 | |||
11b2379d98 | |||
c0657a2e9e |
@ -8,3 +8,4 @@ before_script:
|
||||
script:
|
||||
- yarn format:check
|
||||
- yarn test
|
||||
- yarn build:all
|
||||
|
54
CHANGELOG.md
54
CHANGELOG.md
@ -5,6 +5,60 @@ 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).
|
||||
|
||||
## 1.13.0 - 08.06.2021
|
||||
|
||||
- Added a global heat map to visualize investments by country
|
||||
|
||||
## 1.12.0 - 06.06.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a symbol profile model with additional data
|
||||
- Added new pie charts: Investments by continent and country
|
||||
|
||||
## 1.11.0 - 05.06.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a dedicated page for the account registration
|
||||
- Rendered the average buy prices in the position detail chart (useful for recurring transactions)
|
||||
- Introduced the initial prisma migration
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the buttons to links (`<a>`) on the tools page
|
||||
- Upgraded `prisma` from version `2.20.1` to `2.24.1`
|
||||
|
||||
## 1.10.1 - 02.06.2021
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an optional type in the user interface
|
||||
|
||||
## 1.10.0 - 02.06.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved the tools to a sub path (`/tools`)
|
||||
- Extended the pricing page and aligned with the subscription model
|
||||
|
||||
## 1.9.0 - 01.06.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the year labels to the investment chart on the x-axis
|
||||
|
||||
### Changed
|
||||
|
||||
- Respected the data source attribute of the transactions model in the data management for historical data
|
||||
- Prettified the generic scraper symbols in the transaction filtering component
|
||||
- Changed to the strict mode of distance formatting between two given dates
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the sorting in various tables
|
||||
- Made the order of the rules in the _X-ray_ section consistent
|
||||
|
||||
## 1.8.0 - 24.05.2021
|
||||
|
||||
### Added
|
||||
|
16
README.md
16
README.md
@ -7,16 +7,15 @@
|
||||
<a href="https://ghostfol.io"><strong>Live Demo</strong></a>
|
||||
</p>
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/>
|
||||
<a href="https://travis-ci.org/github/ghostfolio/ghostfolio" rel="nofollow">
|
||||
<img src="https://travis-ci.org/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/>
|
||||
</a>
|
||||
<img src="https://travis-ci.org/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/></a>
|
||||
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
|
||||
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/>
|
||||
</a>
|
||||
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
**Ghostfolio** is an open source portfolio tracker. The software empowers busy folks to have a sharp look of their financial assets and to make solid, data-driven investment decisions by evaluating automated static portfolio analysis rules.
|
||||
**Ghostfolio** is an open source portfolio tracker based on web technology. The software empowers busy folks to have a sharp look of their financial assets and to make solid, data-driven investment decisions by evaluating automated static portfolio analysis rules.
|
||||
|
||||
## Why Ghostfolio?
|
||||
|
||||
@ -43,10 +42,13 @@ Ghostfolio is for you if you are...
|
||||
## Features
|
||||
|
||||
- ✅ Create, update and delete transactions
|
||||
- ✅ Multi account management
|
||||
- ✅ Portfolio performance (`Today`, `YTD`, `1Y`, `5Y`, `Max`)
|
||||
- ✅ Various charts
|
||||
- ✅ Static analysis to identify potential risks in your portfolio
|
||||
- ✅ Dark Mode
|
||||
- ✅ Zen Mode
|
||||
- ✅ Mobile-first design
|
||||
|
||||
## Technology Stack
|
||||
|
||||
@ -54,11 +56,11 @@ Ghostfolio is a modern web application written in [TypeScript](https://www.types
|
||||
|
||||
### Backend
|
||||
|
||||
The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://www.postgresql.org) as a database and [Redis](https://redis.io) for caching.
|
||||
The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://www.postgresql.org) as a database together with [Prisma](https://www.prisma.io) and [Redis](https://redis.io) for caching.
|
||||
|
||||
### Frontend
|
||||
|
||||
The frontend is built with [Angular](https://angular.io).
|
||||
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
|
||||
|
||||
## Getting Started
|
||||
|
||||
|
@ -37,7 +37,9 @@ export class ExperimentalController {
|
||||
);
|
||||
}
|
||||
|
||||
return benchmarks;
|
||||
return benchmarks.map(({ symbol }) => {
|
||||
return symbol;
|
||||
});
|
||||
}
|
||||
|
||||
@Get('benchmarks/:symbol')
|
||||
|
@ -44,6 +44,7 @@ export class ExperimentalService {
|
||||
fee: 0,
|
||||
id: undefined,
|
||||
platformId: undefined,
|
||||
symbolProfileId: undefined,
|
||||
type: Type.BUY,
|
||||
updatedAt: undefined,
|
||||
userId: undefined
|
||||
|
@ -2,7 +2,7 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Order, Prisma } from '@prisma/client';
|
||||
import { DataSource, Order, Prisma } from '@prisma/client';
|
||||
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import { RedisCacheService } from '../redis-cache/redis-cache.service';
|
||||
@ -53,6 +53,7 @@ export class OrderService {
|
||||
// Gather symbol data of order in the background
|
||||
this.dataGatheringService.gatherSymbols([
|
||||
{
|
||||
dataSource: data.dataSource,
|
||||
date: <Date>data.date,
|
||||
symbol: data.symbol
|
||||
}
|
||||
@ -90,6 +91,7 @@ export class OrderService {
|
||||
// Gather symbol data of order in the background
|
||||
this.dataGatheringService.gatherSymbols([
|
||||
{
|
||||
dataSource: <DataSource>data.dataSource,
|
||||
date: <Date>data.date,
|
||||
symbol: <string>data.symbol
|
||||
}
|
||||
|
@ -315,18 +315,6 @@ export class PortfolioController {
|
||||
impersonationUserId || this.request.user.id
|
||||
);
|
||||
|
||||
let report = await portfolio.getReport();
|
||||
|
||||
if (
|
||||
impersonationId &&
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.readForeignPortfolio
|
||||
)
|
||||
) {
|
||||
// TODO: Filter out absolute numbers
|
||||
}
|
||||
|
||||
return report;
|
||||
return await portfolio.getReport();
|
||||
}
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ import {
|
||||
import { DateRange, RequestWithUser } from '@ghostfolio/common/types';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import {
|
||||
add,
|
||||
format,
|
||||
@ -19,6 +20,7 @@ import {
|
||||
getYear,
|
||||
isAfter,
|
||||
isSameDay,
|
||||
parse,
|
||||
parseISO,
|
||||
setDate,
|
||||
setMonth,
|
||||
@ -74,7 +76,8 @@ export class PortfolioService {
|
||||
// Get portfolio from database
|
||||
const orders = await this.orderService.orders({
|
||||
include: {
|
||||
Account: true
|
||||
Account: true,
|
||||
SymbolProfile: true
|
||||
},
|
||||
orderBy: { date: 'asc' },
|
||||
where: { userId: aUserId }
|
||||
@ -214,6 +217,8 @@ export class PortfolioService {
|
||||
transactionCount
|
||||
} = portfolio.getPositions(new Date())[aSymbol];
|
||||
|
||||
const orders = portfolio.getOrders(aSymbol);
|
||||
|
||||
const historicalData = await this.dataProviderService.getHistorical(
|
||||
[aSymbol],
|
||||
'day',
|
||||
@ -226,6 +231,7 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
const historicalDataArray: HistoricalDataItem[] = [];
|
||||
let currentAveragePrice: number;
|
||||
let maxPrice = marketPrice;
|
||||
let minPrice = marketPrice;
|
||||
|
||||
@ -233,9 +239,24 @@ export class PortfolioService {
|
||||
for (const [date, { marketPrice }] of Object.entries(
|
||||
historicalData[aSymbol]
|
||||
)) {
|
||||
const currentDate = parse(date, 'yyyy-MM-dd', new Date());
|
||||
if (
|
||||
isSameDay(currentDate, parseISO(orders[0]?.getDate())) ||
|
||||
isAfter(currentDate, parseISO(orders[0]?.getDate()))
|
||||
) {
|
||||
// Get snapshot of first day of month
|
||||
const snapshot = portfolio.get(setDate(currentDate, 1))[0]
|
||||
.positions[aSymbol];
|
||||
orders.shift();
|
||||
|
||||
if (snapshot?.averagePrice) {
|
||||
currentAveragePrice = snapshot?.averagePrice;
|
||||
}
|
||||
}
|
||||
|
||||
historicalDataArray.push({
|
||||
averagePrice,
|
||||
date,
|
||||
averagePrice: currentAveragePrice,
|
||||
value: marketPrice
|
||||
});
|
||||
|
||||
@ -289,7 +310,7 @@ export class PortfolioService {
|
||||
|
||||
if (isEmpty(historicalData)) {
|
||||
historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||
[aSymbol],
|
||||
[{ dataSource: DataSource.YAHOO, symbol: aSymbol }],
|
||||
portfolio.getMinDate(),
|
||||
new Date()
|
||||
);
|
||||
|
@ -4,9 +4,10 @@ import { locale } from '@ghostfolio/common/config';
|
||||
import { resetHours } from '@ghostfolio/common/helper';
|
||||
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
|
||||
import { getPermissions, permissions } from '@ghostfolio/common/permissions';
|
||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client';
|
||||
import { add } from 'date-fns';
|
||||
import { add, isBefore } from 'date-fns';
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
@ -24,7 +25,8 @@ export class UserService {
|
||||
alias,
|
||||
id,
|
||||
role,
|
||||
Settings
|
||||
Settings,
|
||||
subscription
|
||||
}: UserWithSettings): Promise<IUser> {
|
||||
const access = await this.prisma.access.findMany({
|
||||
include: {
|
||||
@ -43,6 +45,7 @@ export class UserService {
|
||||
return {
|
||||
alias,
|
||||
id,
|
||||
subscription,
|
||||
access: access.map((accessItem) => {
|
||||
return {
|
||||
alias: accessItem.User.alias,
|
||||
@ -54,11 +57,7 @@ export class UserService {
|
||||
settings: {
|
||||
locale,
|
||||
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
|
||||
viewMode: Settings.viewMode ?? ViewMode.DEFAULT
|
||||
},
|
||||
subscription: {
|
||||
expiresAt: resetHours(add(new Date(), { days: 7 })),
|
||||
type: 'Trial'
|
||||
viewMode: Settings?.viewMode ?? ViewMode.DEFAULT
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -66,26 +65,49 @@ export class UserService {
|
||||
public async user(
|
||||
userWhereUniqueInput: Prisma.UserWhereUniqueInput
|
||||
): Promise<UserWithSettings | null> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
include: { Account: true, Settings: true },
|
||||
const userFromDatabase = await this.prisma.user.findUnique({
|
||||
include: { Account: true, Settings: true, Subscription: true },
|
||||
where: userWhereUniqueInput
|
||||
});
|
||||
|
||||
if (user?.Settings) {
|
||||
if (!user.Settings.currency) {
|
||||
const user: UserWithSettings = userFromDatabase;
|
||||
|
||||
if (userFromDatabase?.Settings) {
|
||||
if (!userFromDatabase.Settings.currency) {
|
||||
// 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
|
||||
user.Settings = {
|
||||
userFromDatabase.Settings = {
|
||||
currency: UserService.DEFAULT_CURRENCY,
|
||||
updatedAt: new Date(),
|
||||
userId: user?.id,
|
||||
userId: userFromDatabase?.id,
|
||||
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;
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Account, Currency, Platform } from '@prisma/client';
|
||||
import { Account, Currency, Platform, SymbolProfile } from '@prisma/client';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { IOrder } from '../services/interfaces/interfaces';
|
||||
@ -12,6 +12,7 @@ export class Order {
|
||||
private id: string;
|
||||
private quantity: number;
|
||||
private symbol: string;
|
||||
private symbolProfile: SymbolProfile;
|
||||
private total: number;
|
||||
private type: OrderType;
|
||||
private unitPrice: number;
|
||||
@ -24,6 +25,7 @@ export class Order {
|
||||
this.id = data.id || uuidv4();
|
||||
this.quantity = data.quantity;
|
||||
this.symbol = data.symbol;
|
||||
this.symbolProfile = data.symbolProfile;
|
||||
this.type = data.type;
|
||||
this.unitPrice = data.unitPrice;
|
||||
|
||||
@ -58,6 +60,10 @@ export class Order {
|
||||
return this.symbol;
|
||||
}
|
||||
|
||||
getSymbolProfile() {
|
||||
return this.symbolProfile;
|
||||
}
|
||||
|
||||
public getTotal() {
|
||||
return this.total;
|
||||
}
|
||||
|
@ -189,6 +189,7 @@ describe('Portfolio', () => {
|
||||
id: '8d999347-dee2-46ee-88e1-26b344e71fcc',
|
||||
quantity: 1,
|
||||
symbol: 'BTCUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 49631.24,
|
||||
updatedAt: null,
|
||||
@ -223,6 +224,7 @@ describe('Portfolio', () => {
|
||||
},
|
||||
allocationCurrent: 1,
|
||||
allocationInvestment: 1,
|
||||
countries: [],
|
||||
currency: Currency.USD,
|
||||
exchange: UNKNOWN_KEY,
|
||||
grossPerformance: 0,
|
||||
@ -290,6 +292,7 @@ describe('Portfolio', () => {
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
||||
quantity: 0.2,
|
||||
symbol: 'ETHUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 991.49,
|
||||
updatedAt: null,
|
||||
@ -324,6 +327,7 @@ describe('Portfolio', () => {
|
||||
},
|
||||
// allocationCurrent: 1,
|
||||
allocationInvestment: 1,
|
||||
countries: [],
|
||||
currency: Currency.USD,
|
||||
exchange: UNKNOWN_KEY,
|
||||
// grossPerformance: 0,
|
||||
@ -385,6 +389,7 @@ describe('Portfolio', () => {
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
||||
quantity: 0.2,
|
||||
symbol: 'ETHUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 991.49,
|
||||
updatedAt: null,
|
||||
@ -401,6 +406,7 @@ describe('Portfolio', () => {
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
||||
quantity: 0.3,
|
||||
symbol: 'ETHUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 1050,
|
||||
updatedAt: null,
|
||||
@ -461,6 +467,7 @@ describe('Portfolio', () => {
|
||||
id: 'd96795b2-6ae6-420e-aa21-fabe5e45d475',
|
||||
quantity: 0.05614682,
|
||||
symbol: 'BTCUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 3562.089535970158,
|
||||
updatedAt: null,
|
||||
@ -477,6 +484,7 @@ describe('Portfolio', () => {
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
||||
quantity: 0.2,
|
||||
symbol: 'ETHUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 991.49,
|
||||
updatedAt: null,
|
||||
@ -550,6 +558,7 @@ describe('Portfolio', () => {
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
||||
quantity: 0.2,
|
||||
symbol: 'ETHUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 991.49,
|
||||
updatedAt: null,
|
||||
@ -566,6 +575,7 @@ describe('Portfolio', () => {
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
||||
quantity: 0.1,
|
||||
symbol: 'ETHUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.SELL,
|
||||
unitPrice: 1050,
|
||||
updatedAt: null,
|
||||
@ -582,6 +592,7 @@ describe('Portfolio', () => {
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
||||
quantity: 0.2,
|
||||
symbol: 'ETHUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 1050,
|
||||
updatedAt: null,
|
||||
|
@ -8,7 +8,10 @@ import {
|
||||
Position,
|
||||
UserWithSettings
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||
import { DateRange, OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { continents, countries } from 'countries-list';
|
||||
import {
|
||||
add,
|
||||
format,
|
||||
@ -127,6 +130,7 @@ export class Portfolio implements PortfolioInterface {
|
||||
id,
|
||||
quantity,
|
||||
symbol,
|
||||
symbolProfile,
|
||||
type,
|
||||
unitPrice
|
||||
}) => {
|
||||
@ -139,6 +143,7 @@ export class Portfolio implements PortfolioInterface {
|
||||
id,
|
||||
quantity,
|
||||
symbol,
|
||||
symbolProfile,
|
||||
type,
|
||||
unitPrice
|
||||
})
|
||||
@ -204,6 +209,7 @@ export class Portfolio implements PortfolioInterface {
|
||||
|
||||
symbols.forEach((symbol) => {
|
||||
const accounts: PortfolioPosition['accounts'] = {};
|
||||
let countriesOfSymbol: Country[];
|
||||
const [portfolioItem] = portfolioItems;
|
||||
|
||||
const ordersBySymbol = this.getOrders().filter((order) => {
|
||||
@ -243,6 +249,21 @@ export class Portfolio implements PortfolioInterface {
|
||||
original: originalValueOfSymbol
|
||||
};
|
||||
}
|
||||
|
||||
countriesOfSymbol = (
|
||||
(orderOfSymbol.getSymbolProfile()?.countries as Prisma.JsonArray) ??
|
||||
[]
|
||||
).map((country) => {
|
||||
const { code, weight } = country as Prisma.JsonObject;
|
||||
|
||||
return {
|
||||
code: code as string,
|
||||
continent:
|
||||
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
|
||||
name: countries[code as string]?.name ?? UNKNOWN_KEY,
|
||||
weight: weight as number
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
let now = portfolioItemsNow.positions[symbol].marketPrice;
|
||||
@ -289,6 +310,7 @@ export class Portfolio implements PortfolioInterface {
|
||||
) / value,
|
||||
allocationInvestment:
|
||||
portfolioItem.positions[symbol].investment / investment,
|
||||
countries: countriesOfSymbol,
|
||||
grossPerformance: roundTo(
|
||||
portfolioItemsNow.positions[symbol].quantity * (now - before),
|
||||
2
|
||||
@ -296,7 +318,12 @@ export class Portfolio implements PortfolioInterface {
|
||||
grossPerformancePercent: roundTo((now - before) / before, 4),
|
||||
investment: portfolioItem.positions[symbol].investment,
|
||||
quantity: portfolioItem.positions[symbol].quantity,
|
||||
transactionCount: portfolioItem.positions[symbol].transactionCount
|
||||
transactionCount: portfolioItem.positions[symbol].transactionCount,
|
||||
value: this.exchangeRateDataService.toCurrency(
|
||||
portfolioItem.positions[symbol].quantity * now,
|
||||
data[symbol]?.currency,
|
||||
this.user.Settings.currency
|
||||
)
|
||||
};
|
||||
});
|
||||
|
||||
@ -402,10 +429,10 @@ export class Portfolio implements PortfolioInterface {
|
||||
accountClusterRisk: await this.rulesService.evaluate(
|
||||
this,
|
||||
[
|
||||
new AccountClusterRiskCurrentInvestment(
|
||||
new AccountClusterRiskInitialInvestment(
|
||||
this.exchangeRateDataService
|
||||
),
|
||||
new AccountClusterRiskInitialInvestment(
|
||||
new AccountClusterRiskCurrentInvestment(
|
||||
this.exchangeRateDataService
|
||||
),
|
||||
new AccountClusterRiskSingleAccount(this.exchangeRateDataService)
|
||||
@ -486,7 +513,13 @@ export class Portfolio implements PortfolioInterface {
|
||||
.reduce((previous, current) => previous + current, 0);
|
||||
}
|
||||
|
||||
public getOrders() {
|
||||
public getOrders(aSymbol?: string) {
|
||||
if (aSymbol) {
|
||||
return this.orders.filter((order) => {
|
||||
return order.getSymbol() === aSymbol;
|
||||
});
|
||||
}
|
||||
|
||||
return this.orders;
|
||||
}
|
||||
|
||||
@ -538,6 +571,7 @@ export class Portfolio implements PortfolioInterface {
|
||||
fee: order.fee,
|
||||
quantity: order.quantity,
|
||||
symbol: order.symbol,
|
||||
symbolProfile: order.SymbolProfile,
|
||||
type: <OrderType>order.type,
|
||||
unitPrice: order.unitPrice
|
||||
})
|
||||
|
@ -5,6 +5,7 @@ import {
|
||||
resetHours
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import {
|
||||
differenceInHours,
|
||||
format,
|
||||
@ -18,6 +19,7 @@ import {
|
||||
import { ConfigurationService } from './configuration.service';
|
||||
import { DataProviderService } from './data-provider.service';
|
||||
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { IDataGatheringItem } from './interfaces/interfaces';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Injectable()
|
||||
@ -115,15 +117,13 @@ export class DataGatheringService {
|
||||
}
|
||||
}
|
||||
|
||||
public async gatherSymbols(
|
||||
aSymbolsWithStartDate: { date: Date; symbol: string }[]
|
||||
) {
|
||||
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
|
||||
let hasError = false;
|
||||
|
||||
for (const { date, symbol } of aSymbolsWithStartDate) {
|
||||
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
|
||||
try {
|
||||
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||
[symbol],
|
||||
[{ dataSource, symbol }],
|
||||
date,
|
||||
new Date()
|
||||
);
|
||||
@ -184,20 +184,24 @@ export class DataGatheringService {
|
||||
}
|
||||
}
|
||||
|
||||
public async getCustomSymbolsToGather(startDate?: Date) {
|
||||
public async getCustomSymbolsToGather(
|
||||
startDate?: Date
|
||||
): Promise<IDataGatheringItem[]> {
|
||||
const scraperConfigurations = await this.ghostfolioScraperApi.getScraperConfigurations();
|
||||
|
||||
return scraperConfigurations.map((scraperConfiguration) => {
|
||||
return {
|
||||
dataSource: DataSource.GHOSTFOLIO,
|
||||
date: startDate,
|
||||
symbol: scraperConfiguration.symbol
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getBenchmarksToGather(startDate: Date) {
|
||||
const benchmarksToGather = benchmarks.map((symbol) => {
|
||||
private getBenchmarksToGather(startDate: Date): IDataGatheringItem[] {
|
||||
const benchmarksToGather = benchmarks.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: startDate
|
||||
};
|
||||
@ -205,6 +209,7 @@ export class DataGatheringService {
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||
benchmarksToGather.push({
|
||||
dataSource: DataSource.RAKUTEN,
|
||||
date: startDate,
|
||||
symbol: 'GF.FEAR_AND_GREED_INDEX'
|
||||
});
|
||||
@ -213,16 +218,16 @@ export class DataGatheringService {
|
||||
return benchmarksToGather;
|
||||
}
|
||||
|
||||
private async getSymbols7D(): Promise<{ date: Date; symbol: string }[]> {
|
||||
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
|
||||
const startDate = subDays(resetHours(new Date()), 7);
|
||||
|
||||
const distinctOrders = await this.prisma.order.findMany({
|
||||
distinct: ['symbol'],
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: { symbol: true }
|
||||
select: { dataSource: true, symbol: true }
|
||||
});
|
||||
|
||||
const distinctOrdersWithDate = distinctOrders
|
||||
const distinctOrdersWithDate: IDataGatheringItem[] = distinctOrders
|
||||
.filter((distinctOrder) => {
|
||||
return !isGhostfolioScraperApiSymbol(distinctOrder.symbol);
|
||||
})
|
||||
@ -233,12 +238,15 @@ export class DataGatheringService {
|
||||
};
|
||||
});
|
||||
|
||||
const currencyPairsToGather = currencyPairs.map((symbol) => {
|
||||
return {
|
||||
symbol,
|
||||
date: startDate
|
||||
};
|
||||
});
|
||||
const currencyPairsToGather = currencyPairs.map(
|
||||
({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: startDate
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const customSymbolsToGather = await this.getCustomSymbolsToGather(
|
||||
startDate
|
||||
@ -252,24 +260,27 @@ export class DataGatheringService {
|
||||
];
|
||||
}
|
||||
|
||||
private async getSymbolsMax() {
|
||||
private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
|
||||
const startDate = new Date(getUtc('2015-01-01'));
|
||||
|
||||
const customSymbolsToGather = await this.getCustomSymbolsToGather(
|
||||
startDate
|
||||
);
|
||||
|
||||
const currencyPairsToGather = currencyPairs.map((symbol) => {
|
||||
return {
|
||||
symbol,
|
||||
date: startDate
|
||||
};
|
||||
});
|
||||
const currencyPairsToGather = currencyPairs.map(
|
||||
({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: startDate
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const distinctOrders = await this.prisma.order.findMany({
|
||||
distinct: ['symbol'],
|
||||
orderBy: [{ date: 'asc' }],
|
||||
select: { date: true, symbol: true }
|
||||
select: { dataSource: true, date: true, symbol: true }
|
||||
});
|
||||
|
||||
return [
|
||||
|
@ -1,6 +1,4 @@
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import {
|
||||
isCrypto,
|
||||
isGhostfolioScraperApiSymbol,
|
||||
isRakutenRapidApiSymbol
|
||||
} from '@ghostfolio/common/helper';
|
||||
@ -14,15 +12,15 @@ import { AlphaVantageService } from './data-provider/alpha-vantage/alpha-vantage
|
||||
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { RakutenRapidApiService } from './data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||
import { YahooFinanceService } from './data-provider/yahoo-finance/yahoo-finance.service';
|
||||
import { DataProviderInterface } from './interfaces/data-provider.interface';
|
||||
import {
|
||||
IDataGatheringItem,
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from './interfaces/interfaces';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class DataProviderService implements DataProviderInterface {
|
||||
export class DataProviderService {
|
||||
public constructor(
|
||||
private readonly alphaVantageService: AlphaVantageService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
@ -121,79 +119,53 @@ export class DataProviderService implements DataProviderInterface {
|
||||
}
|
||||
|
||||
public async getHistoricalRaw(
|
||||
aSymbols: string[],
|
||||
aDataGatheringItems: IDataGatheringItem[],
|
||||
from: Date,
|
||||
to: Date
|
||||
): Promise<{
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
}> {
|
||||
const filteredSymbols = aSymbols.filter((symbol) => {
|
||||
return !isGhostfolioScraperApiSymbol(symbol);
|
||||
});
|
||||
const result: {
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
} = {};
|
||||
|
||||
const dataOfYahoo = await this.yahooFinanceService.getHistorical(
|
||||
filteredSymbols,
|
||||
undefined,
|
||||
from,
|
||||
to
|
||||
);
|
||||
|
||||
if (aSymbols.length === 1) {
|
||||
const symbol = aSymbols[0];
|
||||
|
||||
if (
|
||||
isCrypto(symbol) &&
|
||||
this.configurationService.get('ALPHA_VANTAGE_API_KEY')
|
||||
) {
|
||||
// Merge data from Yahoo with data from Alpha Vantage
|
||||
const dataOfAlphaVantage = await this.alphaVantageService.getHistorical(
|
||||
[symbol],
|
||||
undefined,
|
||||
from,
|
||||
to
|
||||
const promises: Promise<{
|
||||
data: { [date: string]: IDataProviderHistoricalResponse };
|
||||
symbol: string;
|
||||
}>[] = [];
|
||||
for (const { dataSource, symbol } of aDataGatheringItems) {
|
||||
const dataProvider = this.getDataProvider(dataSource);
|
||||
if (dataProvider.canHandle(symbol)) {
|
||||
promises.push(
|
||||
dataProvider
|
||||
.getHistorical([symbol], undefined, from, to)
|
||||
.then((data) => ({ data: data?.[symbol], symbol }))
|
||||
);
|
||||
|
||||
return {
|
||||
[symbol]: {
|
||||
...dataOfYahoo[symbol],
|
||||
...dataOfAlphaVantage[symbol]
|
||||
}
|
||||
};
|
||||
} else if (isGhostfolioScraperApiSymbol(symbol)) {
|
||||
const dataOfGhostfolioScraperApi = await this.ghostfolioScraperApiService.getHistorical(
|
||||
[symbol],
|
||||
undefined,
|
||||
from,
|
||||
to
|
||||
);
|
||||
|
||||
return dataOfGhostfolioScraperApi;
|
||||
} else if (
|
||||
isRakutenRapidApiSymbol(symbol) &&
|
||||
this.configurationService.get('RAKUTEN_RAPID_API_KEY')
|
||||
) {
|
||||
const dataOfRakutenRapidApi = await this.rakutenRapidApiService.getHistorical(
|
||||
[symbol],
|
||||
undefined,
|
||||
from,
|
||||
to
|
||||
);
|
||||
|
||||
return dataOfRakutenRapidApi;
|
||||
}
|
||||
}
|
||||
|
||||
return dataOfYahoo;
|
||||
const allData = await Promise.all(promises);
|
||||
for (const { data, symbol } of allData) {
|
||||
result[symbol] = data;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async search(aSymbol: string) {
|
||||
return this.getDataProvider().search(aSymbol);
|
||||
return this.getDataProvider(
|
||||
<DataSource>this.configurationService.get('DATA_SOURCES')[0]
|
||||
).search(aSymbol);
|
||||
}
|
||||
|
||||
private getDataProvider() {
|
||||
switch (this.configurationService.get('DATA_SOURCES')[0]) {
|
||||
private getDataProvider(providerName: DataSource) {
|
||||
switch (providerName) {
|
||||
case DataSource.ALPHA_VANTAGE:
|
||||
return this.alphaVantageService;
|
||||
case DataSource.GHOSTFOLIO:
|
||||
return this.ghostfolioScraperApiService;
|
||||
case DataSource.RAKUTEN:
|
||||
return this.rakutenRapidApiService;
|
||||
case DataSource.YAHOO:
|
||||
return this.yahooFinanceService;
|
||||
default:
|
||||
|
@ -24,6 +24,10 @@ export class AlphaVantageService implements DataProviderInterface {
|
||||
});
|
||||
}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
return !!this.configurationService.get('ALPHA_VANTAGE_API_KEY');
|
||||
}
|
||||
|
||||
public async get(
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
|
@ -1,4 +1,7 @@
|
||||
import { getYesterday } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
getYesterday,
|
||||
isGhostfolioScraperApiSymbol
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
@ -21,6 +24,10 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
|
||||
public constructor(private prisma: PrismaService) {}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
return isGhostfolioScraperApiSymbol(symbol);
|
||||
}
|
||||
|
||||
public async get(
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
|
@ -1,4 +1,8 @@
|
||||
import { getToday, getYesterday } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
getToday,
|
||||
getYesterday,
|
||||
isRakutenRapidApiSymbol
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
@ -24,6 +28,13 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
return (
|
||||
isRakutenRapidApiSymbol(symbol) &&
|
||||
!!this.configurationService.get('RAKUTEN_RAPID_API_KEY')
|
||||
);
|
||||
}
|
||||
|
||||
public async get(
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
|
@ -28,6 +28,10 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
|
||||
public constructor() {}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
return true;
|
||||
}
|
||||
|
||||
public async get(
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
|
@ -7,6 +7,8 @@ import {
|
||||
} from './interfaces';
|
||||
|
||||
export interface DataProviderInterface {
|
||||
canHandle(symbol: string): boolean;
|
||||
|
||||
get(aSymbols: string[]): Promise<{ [symbol: string]: IDataProviderResponse }>;
|
||||
|
||||
getHistorical(
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { Account, Currency, DataSource } from '@prisma/client';
|
||||
import { Account, Currency, DataSource, SymbolProfile } from '@prisma/client';
|
||||
|
||||
import { OrderType } from '../../models/order-type';
|
||||
|
||||
@ -41,6 +41,7 @@ export interface IOrder {
|
||||
id?: string;
|
||||
quantity: number;
|
||||
symbol: string;
|
||||
symbolProfile: SymbolProfile;
|
||||
type: OrderType;
|
||||
unitPrice: number;
|
||||
}
|
||||
@ -65,6 +66,12 @@ export interface IDataProviderResponse {
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface IDataGatheringItem {
|
||||
dataSource: DataSource;
|
||||
date?: Date;
|
||||
symbol: string;
|
||||
}
|
||||
|
||||
export type Industry = typeof Industry[keyof typeof Industry];
|
||||
|
||||
export type MarketState = typeof MarketState[keyof typeof MarketState];
|
||||
|
@ -28,13 +28,6 @@ const routes: Routes = [
|
||||
loadChildren: () =>
|
||||
import('./pages/admin/admin-page.module').then((m) => m.AdminPageModule)
|
||||
},
|
||||
{
|
||||
path: 'analysis',
|
||||
loadChildren: () =>
|
||||
import('./pages/analysis/analysis-page.module').then(
|
||||
(m) => m.AnalysisPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'auth',
|
||||
loadChildren: () =>
|
||||
@ -53,10 +46,10 @@ const routes: Routes = [
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'report',
|
||||
path: 'register',
|
||||
loadChildren: () =>
|
||||
import('./pages/report/report-page.module').then(
|
||||
(m) => m.ReportPageModule
|
||||
import('./pages/register/register-page.module').then(
|
||||
(m) => m.RegisterPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
@ -69,13 +62,29 @@ const routes: Routes = [
|
||||
{
|
||||
path: 'start',
|
||||
loadChildren: () =>
|
||||
import('./pages/login/login-page.module').then((m) => m.LoginPageModule)
|
||||
import('./pages/landing/landing-page.module').then(
|
||||
(m) => m.LandingPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'tools',
|
||||
loadChildren: () =>
|
||||
import('./pages/tools/tools-page.module').then((m) => m.ToolsPageModule)
|
||||
},
|
||||
{
|
||||
path: 'tools/analysis',
|
||||
loadChildren: () =>
|
||||
import('./pages/tools/analysis/analysis-page.module').then(
|
||||
(m) => m.AnalysisPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'tools/report',
|
||||
loadChildren: () =>
|
||||
import('./pages/tools/report/report-page.module').then(
|
||||
(m) => m.ReportPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'transactions',
|
||||
loadChildren: () =>
|
||||
|
@ -12,13 +12,15 @@
|
||||
<div *ngIf="canCreateAccount" class="container create-account-container">
|
||||
<div class="row mb-5">
|
||||
<div class="col-md-6 offset-md-3">
|
||||
<div
|
||||
class="create-account-box p-2 text-center"
|
||||
(click)="onCreateAccount()"
|
||||
<a [routerLink]="['/']">
|
||||
<mat-card
|
||||
class="create-account-box p-2 text-center"
|
||||
(click)="onCreateAccount()"
|
||||
>
|
||||
<div class="mt-1" i18n>You are using the Live Demo.</div>
|
||||
<button mat-button color="primary" i18n>Create Account</button>
|
||||
</mat-card></a
|
||||
>
|
||||
<div class="mt-1" i18n>You are using the Live Demo.</div>
|
||||
<button mat-button color="primary" i18n>Create Account</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -5,8 +5,6 @@
|
||||
padding: 5rem 0;
|
||||
|
||||
.create-account-box {
|
||||
border: 1px solid rgba(var(--palette-primary-500), 1);
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 90%;
|
||||
|
||||
|
@ -68,17 +68,12 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
this.userService.stateChanged
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((state) => {
|
||||
if (state?.user) {
|
||||
this.user = state.user;
|
||||
this.user = state.user;
|
||||
|
||||
this.canCreateAccount = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.createUserAccount
|
||||
);
|
||||
} else if (!this.tokenStorageService.getToken()) {
|
||||
// User has not been logged in
|
||||
this.user = null;
|
||||
}
|
||||
this.canCreateAccount = hasPermission(
|
||||
this.user?.permissions,
|
||||
permissions.createUserAccount
|
||||
);
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
@ -86,7 +81,6 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
|
||||
public onCreateAccount() {
|
||||
this.tokenStorageService.signOut();
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
public onSignOut() {
|
||||
|
@ -2,6 +2,7 @@ import { Platform } from '@angular/cdk/platform';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import {
|
||||
DateAdapter,
|
||||
MAT_DATE_FORMATS,
|
||||
@ -34,6 +35,7 @@ import { LanguageService } from './core/language.service';
|
||||
HttpClientModule,
|
||||
MarkdownModule.forRoot(),
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
MaterialCssVarsModule.forRoot({
|
||||
darkThemeClass: 'is-dark-theme',
|
||||
isAutoContrast: true,
|
||||
|
@ -1,24 +1,13 @@
|
||||
<table
|
||||
class="gf-table w-100"
|
||||
matSort
|
||||
matSortActive="account"
|
||||
matSortDirection="desc"
|
||||
mat-table
|
||||
[dataSource]="dataSource"
|
||||
>
|
||||
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
|
||||
<ng-container matColumnDef="account">
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
|
||||
Name
|
||||
</th>
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Name</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
{{ element.name }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="platform">
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
|
||||
Platform
|
||||
</th>
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Platform</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<div class="d-flex">
|
||||
<gf-symbol-icon
|
||||
@ -60,7 +49,7 @@
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="transactions">
|
||||
<th *matHeaderCellDef i18n mat-header-cell mat-sort-header>Transactions</th>
|
||||
<th *matHeaderCellDef i18n mat-header-cell>Transactions</th>
|
||||
<td *matCellDef="let element" mat-cell>
|
||||
{{ element.Order?.length }}
|
||||
</td>
|
||||
|
@ -6,13 +6,9 @@ import {
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
ViewChild
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSort } from '@angular/material/sort';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Account as AccountModel } from '@prisma/client';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
|
||||
@ -32,8 +28,6 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
@Output() accountDeleted = new EventEmitter<string>();
|
||||
@Output() accountToUpdate = new EventEmitter<AccountModel>();
|
||||
|
||||
@ViewChild(MatSort) sort: MatSort;
|
||||
|
||||
public dataSource: MatTableDataSource<AccountModel> = new MatTableDataSource();
|
||||
public displayedColumns = [];
|
||||
public isLoading = true;
|
||||
@ -41,11 +35,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
private dialog: MatDialog,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router
|
||||
) {}
|
||||
public constructor() {}
|
||||
|
||||
public ngOnInit() {}
|
||||
|
||||
@ -60,7 +50,6 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
|
||||
if (this.accounts) {
|
||||
this.dataSource = new MatTableDataSource(this.accounts);
|
||||
this.dataSource.sort = this.sort;
|
||||
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
@ -3,7 +3,6 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
@ -22,7 +21,6 @@ import { AccountsTableComponent } from './accounts-table.component';
|
||||
MatButtonModule,
|
||||
MatInputModule,
|
||||
MatMenuModule,
|
||||
MatSortModule,
|
||||
MatTableModule,
|
||||
NgxSkeletonLoaderModule,
|
||||
RouterModule
|
||||
|
@ -8,9 +8,11 @@
|
||||
class="d-none d-sm-block"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[color]="
|
||||
currentRoute === 'home' || currentRoute === 'zen' ? 'primary' : null
|
||||
"
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'home' || currentRoute === 'zen',
|
||||
'text-decoration-underline':
|
||||
currentRoute === 'home' || currentRoute === 'zen'
|
||||
}"
|
||||
[routerLink]="['/']"
|
||||
>Overview</a
|
||||
>
|
||||
@ -19,13 +21,16 @@
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[color]="
|
||||
currentRoute === 'analysis' ||
|
||||
currentRoute === 'report' ||
|
||||
currentRoute === 'tools'
|
||||
? 'primary'
|
||||
: null
|
||||
"
|
||||
[ngClass]="{
|
||||
'font-weight-bold':
|
||||
currentRoute === 'analysis' ||
|
||||
currentRoute === 'report' ||
|
||||
currentRoute === 'tools',
|
||||
'text-decoration-underline':
|
||||
currentRoute === 'analysis' ||
|
||||
currentRoute === 'report' ||
|
||||
currentRoute === 'tools'
|
||||
}"
|
||||
[routerLink]="['/tools']"
|
||||
>Tools</a
|
||||
>
|
||||
@ -33,7 +38,10 @@
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[color]="currentRoute === 'transactions' ? 'primary' : null"
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'transactions',
|
||||
'text-decoration-underline': currentRoute === 'transactions'
|
||||
}"
|
||||
[routerLink]="['/transactions']"
|
||||
>Transactions</a
|
||||
>
|
||||
@ -41,7 +49,10 @@
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[color]="currentRoute === 'accounts' ? 'primary' : null"
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'accounts',
|
||||
'text-decoration-underline': currentRoute === 'accounts'
|
||||
}"
|
||||
[routerLink]="['/accounts']"
|
||||
>Accounts</a
|
||||
>
|
||||
@ -50,7 +61,10 @@
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[color]="currentRoute === 'admin' ? 'primary' : null"
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'admin',
|
||||
'text-decoration-underline': currentRoute === 'admin'
|
||||
}"
|
||||
[routerLink]="['/admin']"
|
||||
>Admin Control</a
|
||||
>
|
||||
@ -58,7 +72,10 @@
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[color]="currentRoute === 'resources' ? 'primary' : null"
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'resources',
|
||||
'text-decoration-underline': currentRoute === 'resources'
|
||||
}"
|
||||
[routerLink]="['/resources']"
|
||||
>Resources</a
|
||||
>
|
||||
@ -67,7 +84,10 @@
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[color]="currentRoute === 'pricing' ? 'primary' : null"
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'pricing',
|
||||
'text-decoration-underline': currentRoute === 'pricing'
|
||||
}"
|
||||
[routerLink]="['/pricing']"
|
||||
>Pricing</a
|
||||
>
|
||||
@ -75,7 +95,10 @@
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[color]="currentRoute === 'about' ? 'primary' : null"
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'about',
|
||||
'text-decoration-underline': currentRoute === 'about'
|
||||
}"
|
||||
[routerLink]="['/about']"
|
||||
>About</a
|
||||
>
|
||||
@ -136,6 +159,7 @@
|
||||
<hr class="m-0" />
|
||||
</ng-container>
|
||||
<a
|
||||
*ngIf="user?.settings?.viewMode === 'DEFAULT'"
|
||||
class="d-block d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
@ -225,28 +249,44 @@
|
||||
<gf-logo></gf-logo>
|
||||
</a>
|
||||
<span class="spacer"></span>
|
||||
<a
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[color]="currentRoute === 'pricing' ? 'primary' : null"
|
||||
[routerLink]="['/pricing']"
|
||||
>Pricing</a
|
||||
>
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[color]="currentRoute === 'about' ? 'primary' : null"
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'about',
|
||||
'text-decoration-underline': currentRoute === 'about'
|
||||
}"
|
||||
[routerLink]="['/about']"
|
||||
>About</a
|
||||
>
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'pricing',
|
||||
'text-decoration-underline': currentRoute === 'pricing'
|
||||
}"
|
||||
[routerLink]="['/pricing']"
|
||||
>Pricing</a
|
||||
>
|
||||
<a
|
||||
class="d-none d-sm-block mx-1 no-min-width px-1"
|
||||
href="https://github.com/ghostfolio/ghostfolio"
|
||||
mat-flat-button
|
||||
>GitHub</a
|
||||
>
|
||||
<button i18n mat-flat-button (click)="openLoginDialog()">Sign in</button>
|
||||
><ion-icon name="logo-github"></ion-icon
|
||||
></a>
|
||||
<button class="mx-1" i18n mat-flat-button (click)="openLoginDialog()">
|
||||
Sign In
|
||||
</button>
|
||||
<a
|
||||
class="d-none d-sm-block"
|
||||
color="primary"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[routerLink]="['/register']"
|
||||
>Get Started
|
||||
</a>
|
||||
</ng-container>
|
||||
</mat-toolbar>
|
||||
|
@ -8,7 +8,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { Router } from '@angular/router';
|
||||
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/pages/login/login-with-access-token-dialog/login-with-access-token-dialog.component';
|
||||
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||
|
@ -4,7 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/pages/login/login-with-access-token-dialog/login-with-access-token-dialog.module';
|
||||
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';
|
||||
|
||||
import { GfLogoModule } from '../logo/logo.module';
|
||||
import { HeaderComponent } from './header.component';
|
||||
|
@ -36,10 +36,10 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
|
||||
|
||||
public constructor() {
|
||||
Chart.register(
|
||||
LinearScale,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
LinearScale,
|
||||
TimeScale
|
||||
);
|
||||
}
|
||||
@ -95,7 +95,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
display: false,
|
||||
display: true,
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
|
@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
|
||||
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
|
||||
@Component({
|
||||
selector: 'login-with-access-token-dialog',
|
||||
selector: 'gf-login-with-access-token-dialog',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styleUrls: ['./login-with-access-token-dialog.scss'],
|
||||
templateUrl: 'login-with-access-token-dialog.html'
|
@ -8,7 +8,7 @@
|
||||
[removable]="true"
|
||||
(removed)="removeKeyword(searchKeyword)"
|
||||
>
|
||||
{{ searchKeyword }}
|
||||
{{ searchKeyword | gfSymbol }}
|
||||
<ion-icon class="ml-2" matPrefix name="close-outline"></ion-icon>
|
||||
</mat-chip>
|
||||
<input
|
||||
@ -26,11 +26,8 @@
|
||||
#autocomplete="matAutocomplete"
|
||||
(optionSelected)="keywordSelected($event)"
|
||||
>
|
||||
<mat-option
|
||||
*ngFor="let transaction of filteredTransactions | async"
|
||||
[value]="transaction"
|
||||
>
|
||||
{{ transaction }}
|
||||
<mat-option *ngFor="let filter of filters | async" [value]="filter">
|
||||
{{ filter | gfSymbol }}
|
||||
</mat-option>
|
||||
</mat-autocomplete>
|
||||
</mat-form-field>
|
||||
@ -178,9 +175,7 @@
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="account">
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
|
||||
Account
|
||||
</th>
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Account</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<div class="d-flex">
|
||||
<gf-symbol-icon
|
||||
|
@ -57,10 +57,8 @@ export class TransactionsTableComponent
|
||||
public dataSource: MatTableDataSource<OrderWithAccount> = new MatTableDataSource();
|
||||
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
||||
public displayedColumns = [];
|
||||
public filteredTransactions$: Subject<string[]> = new BehaviorSubject([]);
|
||||
public filteredTransactions: Observable<
|
||||
string[]
|
||||
> = this.filteredTransactions$.asObservable();
|
||||
public filters$: Subject<string[]> = new BehaviorSubject([]);
|
||||
public filters: Observable<string[]> = this.filters$.asObservable();
|
||||
public isLoading = true;
|
||||
public placeholder = '';
|
||||
public routeQueryParams: Subscription;
|
||||
@ -68,7 +66,7 @@ export class TransactionsTableComponent
|
||||
public searchKeywords: string[] = [];
|
||||
public separatorKeysCodes: number[] = [ENTER, COMMA];
|
||||
|
||||
private allFilteredTransactions: string[];
|
||||
private allFilters: string[];
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
@ -90,13 +88,13 @@ export class TransactionsTableComponent
|
||||
this.searchControl.valueChanges.subscribe((keyword) => {
|
||||
if (keyword) {
|
||||
const filterValue = keyword.toLowerCase();
|
||||
this.filteredTransactions$.next(
|
||||
this.allFilteredTransactions.filter(
|
||||
this.filters$.next(
|
||||
this.allFilters.filter(
|
||||
(filter) => filter.toLowerCase().indexOf(filterValue) === 0
|
||||
)
|
||||
);
|
||||
} else {
|
||||
this.filteredTransactions$.next(this.allFilteredTransactions);
|
||||
this.filters$.next(this.allFilters);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -239,13 +237,13 @@ export class TransactionsTableComponent
|
||||
this.placeholder =
|
||||
lowercaseSearchKeywords.length <= 0 ? SEARCH_PLACEHOLDER : '';
|
||||
|
||||
this.allFilteredTransactions = this.getSearchableFieldValues(
|
||||
this.transactions
|
||||
).filter((item) => {
|
||||
return !lowercaseSearchKeywords.includes(item.trim().toLowerCase());
|
||||
});
|
||||
this.allFilters = this.getSearchableFieldValues(this.transactions).filter(
|
||||
(item) => {
|
||||
return !lowercaseSearchKeywords.includes(item.trim().toLowerCase());
|
||||
}
|
||||
);
|
||||
|
||||
this.filteredTransactions$.next(this.allFilteredTransactions);
|
||||
this.filters$.next(this.allFilters);
|
||||
}
|
||||
|
||||
private getSearchableFieldValues(transactions: OrderWithAccount[]): string[] {
|
||||
|
@ -0,0 +1,10 @@
|
||||
<ngx-skeleton-loader
|
||||
*ngIf="isLoading"
|
||||
animation="pulse"
|
||||
class="h-100"
|
||||
[theme]="{
|
||||
width: '100%'
|
||||
}"
|
||||
></ngx-skeleton-loader>
|
||||
|
||||
<div class="align-items-center d-flex h-100 w-100" id="svgMap"></div>
|
@ -0,0 +1,24 @@
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
|
||||
::ng-deep {
|
||||
.loader {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.svgMap-map-wrapper {
|
||||
background: transparent;
|
||||
|
||||
.svgMap-map-controls-wrapper {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.is-dark-theme) {
|
||||
.svgMap-tooltip {
|
||||
background: var(--dark-background);
|
||||
}
|
||||
}
|
@ -0,0 +1,79 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { primaryColorHex } from '@ghostfolio/common/config';
|
||||
import { getCssVariable, getTextColor } from '@ghostfolio/common/helper';
|
||||
import { Currency } from '@prisma/client';
|
||||
import svgMap from 'svgmap';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-world-map-chart',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './world-map-chart.component.html',
|
||||
styleUrls: ['./world-map-chart.component.scss']
|
||||
})
|
||||
export class WorldMapChartComponent implements OnChanges, OnDestroy, OnInit {
|
||||
@Input() baseCurrency: Currency;
|
||||
@Input() countries: { [code: string]: { name: string; value: number } };
|
||||
|
||||
public isLoading = true;
|
||||
public svgMapElement;
|
||||
|
||||
public constructor(private changeDetectorRef: ChangeDetectorRef) {}
|
||||
|
||||
public ngOnInit() {}
|
||||
|
||||
public ngOnChanges() {
|
||||
if (this.countries) {
|
||||
this.destroySvgMap();
|
||||
|
||||
this.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.destroySvgMap();
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
this.svgMapElement = new svgMap({
|
||||
colorMax: primaryColorHex,
|
||||
colorMin: '#d3f4f3',
|
||||
colorNoData: `rgba(${getTextColor()}, ${getCssVariable(
|
||||
'--palette-foreground-divider-alpha'
|
||||
)})`,
|
||||
data: {
|
||||
applyData: 'value',
|
||||
data: {
|
||||
value: {
|
||||
format: `{0} ${this.baseCurrency}`
|
||||
}
|
||||
},
|
||||
values: this.countries
|
||||
},
|
||||
hideFlag: true,
|
||||
minZoom: 1.06,
|
||||
maxZoom: 1.06,
|
||||
targetElementID: 'svgMap'
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
this.isLoading = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
private destroySvgMap() {
|
||||
this.svgMapElement?.mapWrapper?.remove();
|
||||
this.svgMapElement?.tooltip?.remove();
|
||||
|
||||
this.svgMapElement = null;
|
||||
}
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
import { WorldMapChartComponent } from './world-map-chart.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [WorldMapChartComponent],
|
||||
exports: [WorldMapChartComponent],
|
||||
imports: [CommonModule, NgxSkeletonLoaderModule],
|
||||
providers: []
|
||||
})
|
||||
export class GfWorldMapChartModule {}
|
@ -14,7 +14,12 @@ import { UserService } from '../services/user/user.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthGuard implements CanActivate {
|
||||
private static PUBLIC_PAGE_ROUTES = ['/about', '/pricing', '/resources'];
|
||||
private static PUBLIC_PAGE_ROUTES = [
|
||||
'/about',
|
||||
'/pricing',
|
||||
'/register',
|
||||
'/resources'
|
||||
];
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
|
@ -18,7 +18,6 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
||||
public baseCurrency: Currency;
|
||||
public currencies: Currency[] = [];
|
||||
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
||||
public hasPermissionForSubscription: boolean;
|
||||
public hasPermissionToUpdateUserSettings: boolean;
|
||||
public user: User;
|
||||
|
||||
@ -35,13 +34,8 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
||||
this.dataService
|
||||
.fetchInfo()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ currencies, globalPermissions }) => {
|
||||
.subscribe(({ currencies }) => {
|
||||
this.currencies = currencies;
|
||||
|
||||
this.hasPermissionForSubscription = hasPermission(
|
||||
globalPermissions,
|
||||
permissions.enableSubscription
|
||||
);
|
||||
});
|
||||
|
||||
this.userService.stateChanged
|
||||
|
@ -15,16 +15,21 @@
|
||||
<div class="w-50" i18n>Alias</div>
|
||||
<div class="w-50">{{ user.alias }}</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">
|
||||
<div class="align-items-center d-flex mb-1">
|
||||
{{ user?.subscription?.type }}
|
||||
{{ user.subscription.type }}
|
||||
</div>
|
||||
<div>
|
||||
<div *ngIf="user.subscription.expiresAt">
|
||||
Valid until {{ user.subscription.expiresAt | date:
|
||||
defaultDateFormat }}
|
||||
</div>
|
||||
<div *ngIf="!user.subscription.expiresAt">
|
||||
<button color="primary" disabled i18n mat-flat-button>
|
||||
Upgrade
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex mt-4 py-1">
|
||||
|
@ -1,6 +1,7 @@
|
||||
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 { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
@ -17,6 +18,7 @@ import { AccountPageComponent } from './account-page.component';
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
GfPortfolioAccessTableModule,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
MatFormFieldModule,
|
||||
MatSelectModule,
|
||||
|
@ -5,7 +5,7 @@ import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
|
||||
import { AdminData, User } from '@ghostfolio/common/interfaces';
|
||||
import { formatDistanceToNow, isValid, parseISO, sub } from 'date-fns';
|
||||
import { formatDistanceToNowStrict, isValid, parseISO } from 'date-fns';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@ -76,14 +76,12 @@ export class AdminPageComponent implements OnInit {
|
||||
|
||||
public formatDistanceToNow(aDateString: string) {
|
||||
if (aDateString) {
|
||||
const distanceString = formatDistanceToNow(
|
||||
sub(parseISO(aDateString), { seconds: 10 }),
|
||||
{
|
||||
addSuffix: true
|
||||
}
|
||||
);
|
||||
const distanceString = formatDistanceToNowStrict(parseISO(aDateString), {
|
||||
addSuffix: true
|
||||
});
|
||||
|
||||
return distanceString === 'less than a minute ago'
|
||||
return distanceString === 'in 0 seconds' ||
|
||||
distanceString === '0 seconds ago'
|
||||
? 'just now'
|
||||
: distanceString;
|
||||
}
|
||||
@ -124,7 +122,7 @@ export class AdminPageComponent implements OnInit {
|
||||
this.users = users;
|
||||
|
||||
if (isValid(parseISO(lastDataGathering?.toString()))) {
|
||||
this.lastDataGathering = formatDistanceToNow(
|
||||
this.lastDataGathering = formatDistanceToNowStrict(
|
||||
new Date(lastDataGathering),
|
||||
{
|
||||
addSuffix: true
|
||||
|
@ -2,14 +2,14 @@ import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||
|
||||
import { LoginPageComponent } from './login-page.component';
|
||||
import { LandingPageComponent } from './landing-page.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{ path: '', component: LoginPageComponent, canActivate: [AuthGuard] }
|
||||
{ path: '', component: LandingPageComponent, canActivate: [AuthGuard] }
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class LoginPageRoutingModule {}
|
||||
export class LandingPageRoutingModule {}
|
@ -1,21 +1,17 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { Router } from '@angular/router';
|
||||
import { LineChartItem } from '@ghostfolio/client/components/line-chart/interfaces/line-chart.interface';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||
import { format } from 'date-fns';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { ShowAccessTokenDialog } from './show-access-token-dialog/show-access-token-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-login-page',
|
||||
templateUrl: './login-page.html',
|
||||
styleUrls: ['./login-page.scss']
|
||||
selector: 'gf-landing-page',
|
||||
templateUrl: './landing-page.html',
|
||||
styleUrls: ['./landing-page.scss']
|
||||
})
|
||||
export class LoginPageComponent implements OnDestroy, OnInit {
|
||||
export class LandingPageComponent implements OnDestroy, OnInit {
|
||||
public currentYear = format(new Date(), 'yyyy');
|
||||
public demoAuthToken: string;
|
||||
public historicalDataItems: LineChartItem[];
|
||||
@ -28,7 +24,6 @@ export class LoginPageComponent implements OnDestroy, OnInit {
|
||||
public constructor(
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private dialog: MatDialog,
|
||||
private router: Router,
|
||||
private tokenStorageService: TokenStorageService
|
||||
) {}
|
||||
@ -46,15 +41,6 @@ export class LoginPageComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public async createAccount() {
|
||||
this.dataService
|
||||
.postUser()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ accessToken, authToken }) => {
|
||||
this.openShowAccessTokenDialog(accessToken, authToken);
|
||||
});
|
||||
}
|
||||
|
||||
public initializeLineChart() {
|
||||
this.historicalDataItems = [
|
||||
{
|
||||
@ -268,28 +254,6 @@ export class LoginPageComponent implements OnDestroy, OnInit {
|
||||
];
|
||||
}
|
||||
|
||||
public openShowAccessTokenDialog(
|
||||
accessToken: string,
|
||||
authToken: string
|
||||
): void {
|
||||
const dialogRef = this.dialog.open(ShowAccessTokenDialog, {
|
||||
data: {
|
||||
accessToken,
|
||||
authToken
|
||||
},
|
||||
disableClose: true,
|
||||
width: '30rem'
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((data) => {
|
||||
if (data?.authToken) {
|
||||
this.tokenStorageService.saveToken(authToken);
|
||||
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public setToken(aToken: string) {
|
||||
this.tokenStorageService.saveToken(aToken);
|
||||
|
@ -13,16 +13,16 @@
|
||||
class="align-items-center col d-flex justify-content-center position-relative"
|
||||
>
|
||||
<div class="py-5 text-center">
|
||||
<button
|
||||
<a
|
||||
class="d-inline-block"
|
||||
color="primary"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[disabled]="!demoAuthToken"
|
||||
(click)="createAccount()"
|
||||
[routerLink]="['/register']"
|
||||
>
|
||||
Create Account
|
||||
</button>
|
||||
Get Started
|
||||
</a>
|
||||
<div class="d-inline-block mx-3 text-muted" i18n>or</div>
|
||||
<button
|
||||
class="d-inline-block"
|
||||
@ -135,15 +135,15 @@
|
||||
Join now or check out the example account
|
||||
</p>
|
||||
<div class="py-2 text-center">
|
||||
<button
|
||||
<a
|
||||
color="primary"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[disabled]="!demoAuthToken"
|
||||
(click)="createAccount()"
|
||||
[routerLink]="['/register']"
|
||||
>
|
||||
Create Account
|
||||
</button>
|
||||
Get Started
|
||||
</a>
|
||||
<div class="d-inline-block mx-3 text-muted" i18n>or</div>
|
||||
<button
|
||||
class="d-inline-block"
|
25
apps/client/src/app/pages/landing/landing-page.module.ts
Normal file
25
apps/client/src/app/pages/landing/landing-page.module.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module';
|
||||
import { GfLogoModule } from '@ghostfolio/client/components/logo/logo.module';
|
||||
|
||||
import { LandingPageRoutingModule } from './landing-page-routing.module';
|
||||
import { LandingPageComponent } from './landing-page.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [LandingPageComponent],
|
||||
exports: [],
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfLineChartModule,
|
||||
GfLogoModule,
|
||||
LandingPageRoutingModule,
|
||||
MatButtonModule,
|
||||
RouterModule
|
||||
],
|
||||
providers: [],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class LandingPageModule {}
|
@ -1,96 +1,185 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<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 text-center" i18n>
|
||||
Pricing Plans
|
||||
</h3>
|
||||
<mat-card class="mb-4">
|
||||
<mat-card-content>
|
||||
<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>
|
||||
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>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-md-6">
|
||||
<mat-card class="mb-3">
|
||||
<h4 i18n>Open Source</h4>
|
||||
<p>Host your <strong>Ghostfolio</strong> instance by yourself.</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>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>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>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>Advanced Insights</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="h5 text-right">
|
||||
<span>Free</span>
|
||||
</p>
|
||||
<div class="col-xs-12 col-md-4 mb-3">
|
||||
<mat-card class="d-flex flex-column h-100">
|
||||
<div class="flex-grow-1">
|
||||
<h4 i18n>Open Source</h4>
|
||||
<p>
|
||||
For tech-savvy investors who prefer to run
|
||||
<strong>Ghostfolio</strong> on their own infrastructure.
|
||||
</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>Self-hosted.</p>
|
||||
<p class="h5 text-right">Free</p>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-6">
|
||||
<div class="col-xs-12 col-md-4 mb-3">
|
||||
<mat-card
|
||||
class="mb-3"
|
||||
[ngClass]="{ 'active': user?.subscription?.type === 'Trial' }"
|
||||
class="d-flex flex-column h-100"
|
||||
[ngClass]="{ 'active': user?.subscription?.type === 'Basic' }"
|
||||
>
|
||||
<h4 class="align-items-center d-flex" i18n>
|
||||
Diamond
|
||||
<ion-icon
|
||||
class="ml-1 text-muted"
|
||||
name="diamond-outline"
|
||||
></ion-icon>
|
||||
</h4>
|
||||
<p>
|
||||
Get a fully managed <strong>Ghostfolio</strong> cloud offering.
|
||||
</p>
|
||||
<ul class="list-unstyled mb-3">
|
||||
<li class="align-items-center d-flex mb-1">
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="align-items-center d-flex" i18n>Basic</h4>
|
||||
<p>
|
||||
For new investors who are just getting started with trading.
|
||||
</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>
|
||||
<ion-icon
|
||||
class="invisible"
|
||||
name="checkmark-circle-outline"
|
||||
></ion-icon>
|
||||
</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
|
||||
class="mr-1 text-muted"
|
||||
name="checkmark-circle-outline"
|
||||
class="ml-1 text-muted"
|
||||
name="diamond-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>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>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>Advanced Insights</span>
|
||||
</li>
|
||||
</ul>
|
||||
</h4>
|
||||
<p>
|
||||
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">
|
||||
<span class="font-weight-normal"
|
||||
>{{ user?.settings.baseCurrency || baseCurrency }}
|
||||
<strong>2.99</strong>
|
||||
<strong>0.00</strong>
|
||||
<del class="ml-1 text-muted">3.99</del> / Month</span
|
||||
>
|
||||
</p>
|
||||
@ -99,4 +188,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="!user" class="row">
|
||||
<div class="col mt-3 text-center">
|
||||
<a color="primary" i18n mat-flat-button [routerLink]="['/register']">
|
||||
Get Started
|
||||
</a>
|
||||
<p class="text-muted"><small>It's free</small></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { PricingPageRoutingModule } from './pricing-page-routing.module';
|
||||
import { PricingPageComponent } from './pricing-page.component';
|
||||
@ -8,7 +10,13 @@ import { PricingPageComponent } from './pricing-page.component';
|
||||
@NgModule({
|
||||
declarations: [PricingPageComponent],
|
||||
exports: [],
|
||||
imports: [CommonModule, MatCardModule, PricingPageRoutingModule],
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
PricingPageRoutingModule,
|
||||
RouterModule
|
||||
],
|
||||
providers: [],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
|
@ -2,6 +2,15 @@
|
||||
color: rgb(var(--dark-primary-text));
|
||||
display: block;
|
||||
|
||||
a {
|
||||
color: rgba(var(--palette-primary-500), 1);
|
||||
font-weight: bold;
|
||||
|
||||
&:hover {
|
||||
color: rgba(var(--palette-primary-300), 1);
|
||||
}
|
||||
}
|
||||
|
||||
.mat-card {
|
||||
&.active {
|
||||
border-color: rgba(var(--palette-primary-500), 1);
|
||||
@ -11,4 +20,8 @@
|
||||
|
||||
:host-context(.is-dark-theme) {
|
||||
color: rgb(var(--light-primary-text));
|
||||
|
||||
a {
|
||||
color: rgb(var(--light-primary-text));
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,15 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||
|
||||
import { RegisterPageComponent } from './register-page.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{ path: '', component: RegisterPageComponent, canActivate: [AuthGuard] }
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class RegisterPageRoutingModule {}
|
@ -0,0 +1,98 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { Router } from '@angular/router';
|
||||
import { LineChartItem } from '@ghostfolio/client/components/line-chart/interfaces/line-chart.interface';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { format } from 'date-fns';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { ShowAccessTokenDialog } from './show-access-token-dialog/show-access-token-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-register-page',
|
||||
templateUrl: './register-page.html',
|
||||
styleUrls: ['./register-page.scss']
|
||||
})
|
||||
export class RegisterPageComponent implements OnDestroy, OnInit {
|
||||
public currentYear = format(new Date(), 'yyyy');
|
||||
public demoAuthToken: string;
|
||||
public hasPermissionForSocialLogin: boolean;
|
||||
public historicalDataItems: LineChartItem[];
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
*/
|
||||
public constructor(
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private dialog: MatDialog,
|
||||
private router: Router,
|
||||
private tokenStorageService: TokenStorageService
|
||||
) {
|
||||
this.tokenStorageService.signOut();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the controller
|
||||
*/
|
||||
public ngOnInit() {
|
||||
this.dataService
|
||||
.fetchInfo()
|
||||
.subscribe(({ demoAuthToken, globalPermissions }) => {
|
||||
this.demoAuthToken = demoAuthToken;
|
||||
this.hasPermissionForSocialLogin = hasPermission(
|
||||
globalPermissions,
|
||||
permissions.enableSocialLogin
|
||||
);
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
public async createAccount() {
|
||||
this.dataService
|
||||
.postUser()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ accessToken, authToken }) => {
|
||||
this.openShowAccessTokenDialog(accessToken, authToken);
|
||||
});
|
||||
}
|
||||
|
||||
public openShowAccessTokenDialog(
|
||||
accessToken: string,
|
||||
authToken: string
|
||||
): void {
|
||||
const dialogRef = this.dialog.open(ShowAccessTokenDialog, {
|
||||
data: {
|
||||
accessToken,
|
||||
authToken
|
||||
},
|
||||
disableClose: true,
|
||||
width: '30rem'
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((data) => {
|
||||
if (data?.authToken) {
|
||||
this.tokenStorageService.saveToken(authToken);
|
||||
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public setToken(aToken: string) {
|
||||
this.tokenStorageService.saveToken(aToken);
|
||||
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
}
|
30
apps/client/src/app/pages/register/register-page.html
Normal file
30
apps/client/src/app/pages/register/register-page.html
Normal file
@ -0,0 +1,30 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h3 class="d-flex justify-content-center mb-3 text-center" i18n>
|
||||
Create your Ghostfolio account
|
||||
</h3>
|
||||
<mat-card class="mb-4">
|
||||
<mat-card-content class="text-center">
|
||||
<button
|
||||
class="d-inline-block"
|
||||
color="primary"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[disabled]="!demoAuthToken"
|
||||
(click)="createAccount()"
|
||||
>
|
||||
Create Account
|
||||
</button>
|
||||
<ng-container *ngIf="hasPermissionForSocialLogin">
|
||||
<div class="my-3 text-muted" i18n>or</div>
|
||||
<a color="accent" href="/api/auth/google" mat-flat-button
|
||||
><ion-icon class="mr-1" name="logo-google"></ion-icon
|
||||
><span i18n>Continue with Google</span></a
|
||||
>
|
||||
</ng-container>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -1,27 +1,27 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module';
|
||||
import { GfLogoModule } from '@ghostfolio/client/components/logo/logo.module';
|
||||
|
||||
import { LoginPageRoutingModule } from './login-page-routing.module';
|
||||
import { LoginPageComponent } from './login-page.component';
|
||||
import { RegisterPageRoutingModule } from './register-page-routing.module';
|
||||
import { RegisterPageComponent } from './register-page.component';
|
||||
import { ShowAccessTokenDialogModule } from './show-access-token-dialog/show-access-token-dialog.module';
|
||||
|
||||
@NgModule({
|
||||
declarations: [LoginPageComponent],
|
||||
declarations: [RegisterPageComponent],
|
||||
exports: [],
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfLineChartModule,
|
||||
GfLogoModule,
|
||||
LoginPageRoutingModule,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
RegisterPageRoutingModule,
|
||||
RouterModule,
|
||||
ShowAccessTokenDialogModule
|
||||
],
|
||||
providers: [],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class LoginPageModule {}
|
||||
export class RegisterPageModule {}
|
3
apps/client/src/app/pages/register/register-page.scss
Normal file
3
apps/client/src/app/pages/register/register-page.scss
Normal file
@ -0,0 +1,3 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
@ -7,7 +7,7 @@ import {
|
||||
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
|
||||
@Component({
|
||||
selector: 'show-access-token-dialog',
|
||||
selector: 'gf-show-access-token-dialog',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styleUrls: ['./show-access-token-dialog.scss'],
|
||||
templateUrl: 'show-access-token-dialog.html'
|
@ -1,5 +1,5 @@
|
||||
import { ClipboardModule } from '@angular/cdk/clipboard';
|
||||
import { CdkTextareaAutosize, TextFieldModule } from '@angular/cdk/text-field';
|
||||
import { TextFieldModule } from '@angular/cdk/text-field';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
@ -3,6 +3,7 @@ import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/to
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import {
|
||||
PortfolioItem,
|
||||
PortfolioPosition,
|
||||
@ -21,6 +22,12 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
public accounts: {
|
||||
[symbol: string]: Pick<PortfolioPosition, 'name'> & { value: number };
|
||||
};
|
||||
public continents: {
|
||||
[code: string]: { name: string; value: number };
|
||||
};
|
||||
public countries: {
|
||||
[code: string]: { name: string; value: number };
|
||||
};
|
||||
public deviceType: string;
|
||||
public period = 'current';
|
||||
public periodOptions: ToggleOption[] = [
|
||||
@ -97,6 +104,18 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
aPeriod: string
|
||||
) {
|
||||
this.accounts = {};
|
||||
this.continents = {
|
||||
[UNKNOWN_KEY]: {
|
||||
name: UNKNOWN_KEY,
|
||||
value: 0
|
||||
}
|
||||
};
|
||||
this.countries = {
|
||||
[UNKNOWN_KEY]: {
|
||||
name: UNKNOWN_KEY,
|
||||
value: 0
|
||||
}
|
||||
};
|
||||
this.positions = {};
|
||||
this.positionsArray = [];
|
||||
|
||||
@ -122,11 +141,53 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
aPeriod === 'original' ? original : current;
|
||||
} else {
|
||||
this.accounts[account] = {
|
||||
value: aPeriod === 'original' ? original : current,
|
||||
name: account
|
||||
name: account,
|
||||
value: aPeriod === 'original' ? original : current
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (position.countries.length > 0) {
|
||||
for (const country of position.countries) {
|
||||
const { code, continent, name, weight } = country;
|
||||
|
||||
if (this.continents[continent]?.value) {
|
||||
this.continents[continent].value += weight * position.value;
|
||||
} else {
|
||||
this.continents[continent] = {
|
||||
name: continent,
|
||||
value:
|
||||
weight *
|
||||
(aPeriod === 'original'
|
||||
? this.portfolioPositions[symbol].investment
|
||||
: this.portfolioPositions[symbol].value)
|
||||
};
|
||||
}
|
||||
|
||||
if (this.countries[code]?.value) {
|
||||
this.countries[code].value += weight * position.value;
|
||||
} else {
|
||||
this.countries[code] = {
|
||||
name,
|
||||
value:
|
||||
weight *
|
||||
(aPeriod === 'original'
|
||||
? this.portfolioPositions[symbol].investment
|
||||
: this.portfolioPositions[symbol].value)
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.continents[UNKNOWN_KEY].value +=
|
||||
aPeriod === 'original'
|
||||
? this.portfolioPositions[symbol].investment
|
||||
: this.portfolioPositions[symbol].value;
|
||||
|
||||
this.countries[UNKNOWN_KEY].value +=
|
||||
aPeriod === 'original'
|
||||
? this.portfolioPositions[symbol].investment
|
||||
: this.portfolioPositions[symbol].value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -146,6 +146,50 @@
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-header class="w-100">
|
||||
<mat-card-title i18n>By Continent</mat-card-title>
|
||||
<gf-toggle
|
||||
[defaultValue]="period"
|
||||
[isLoading]="false"
|
||||
[options]="periodOptions"
|
||||
(change)="onChangePeriod($event.value)"
|
||||
></gf-toggle>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<gf-portfolio-proportion-chart
|
||||
key="name"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[isInPercent]="false"
|
||||
[locale]="user?.settings?.locale"
|
||||
[positions]="continents"
|
||||
></gf-portfolio-proportion-chart>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-header class="w-100">
|
||||
<mat-card-title i18n>By Country</mat-card-title>
|
||||
<gf-toggle
|
||||
[defaultValue]="period"
|
||||
[isLoading]="false"
|
||||
[options]="periodOptions"
|
||||
(change)="onChangePeriod($event.value)"
|
||||
></gf-toggle>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<gf-portfolio-proportion-chart
|
||||
key="name"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[isInPercent]="false"
|
||||
[locale]="user?.settings?.locale"
|
||||
[positions]="countries"
|
||||
></gf-portfolio-proportion-chart>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-block d-sm-none row">
|
||||
<div class="col-lg">
|
||||
@ -167,7 +211,28 @@
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="!hasImpersonationId" class="d-none d-sm-block row">
|
||||
<div class="row world-map-chart">
|
||||
<div class="col-lg">
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-header class="w-100">
|
||||
<mat-card-title i18n>Global Heat Map</mat-card-title>
|
||||
<gf-toggle
|
||||
[defaultValue]="period"
|
||||
[isLoading]="false"
|
||||
[options]="periodOptions"
|
||||
(change)="onChangePeriod($event.value)"
|
||||
></gf-toggle>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<gf-world-map-chart
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[countries]="countries"
|
||||
></gf-world-map-chart>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg">
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-header>
|
@ -6,6 +6,7 @@ import { PortfolioPositionsChartModule } from '@ghostfolio/client/components/por
|
||||
import { PortfolioProportionChartModule } from '@ghostfolio/client/components/portfolio-proportion-chart/portfolio-proportion-chart.module';
|
||||
import { GfPositionsTableModule } from '@ghostfolio/client/components/positions-table/positions-table.module';
|
||||
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
|
||||
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
|
||||
|
||||
import { AnalysisPageRoutingModule } from './analysis-page-routing.module';
|
||||
import { AnalysisPageComponent } from './analysis-page.component';
|
||||
@ -19,6 +20,7 @@ import { AnalysisPageComponent } from './analysis-page.component';
|
||||
GfInvestmentChartModule,
|
||||
GfPositionsTableModule,
|
||||
GfToggleModule,
|
||||
GfWorldMapChartModule,
|
||||
MatCardModule,
|
||||
PortfolioPositionsChartModule,
|
||||
PortfolioProportionChartModule
|
@ -7,6 +7,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.world-map-chart {
|
||||
.mat-card {
|
||||
.mat-card-content {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mat-card {
|
||||
.mat-card-header {
|
||||
::ng-deep {
|
@ -9,9 +9,14 @@
|
||||
portfolio.
|
||||
</p>
|
||||
<p class="text-right">
|
||||
<button color="primary" i18n mat-button [routerLink]="['/analysis']">
|
||||
<a
|
||||
color="primary"
|
||||
i18n
|
||||
mat-button
|
||||
[routerLink]="['/tools', 'analysis']"
|
||||
>
|
||||
Open Analysis →
|
||||
</button>
|
||||
</a>
|
||||
</p>
|
||||
</mat-card>
|
||||
</div>
|
||||
@ -23,9 +28,14 @@
|
||||
risks in your portfolio.
|
||||
</p>
|
||||
<p class="text-right">
|
||||
<button color="primary" i18n mat-button [routerLink]="['/report']">
|
||||
<a
|
||||
color="primary"
|
||||
i18n
|
||||
mat-button
|
||||
[routerLink]="['/tools', 'report']"
|
||||
>
|
||||
Open X-ray →
|
||||
</button>
|
||||
</a>
|
||||
</p>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
@ -1,12 +1,14 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { UserService } from './user/user.service';
|
||||
|
||||
const TOKEN_KEY = 'auth-token';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class TokenStorageService {
|
||||
public constructor() {}
|
||||
public constructor(private userService: UserService) {}
|
||||
|
||||
public getToken(): string {
|
||||
return window.localStorage.getItem(TOKEN_KEY);
|
||||
@ -22,6 +24,8 @@ export class TokenStorageService {
|
||||
|
||||
window.localStorage.clear();
|
||||
|
||||
this.userService.remove();
|
||||
|
||||
if (utmSource) {
|
||||
window.localStorage.setItem('utm_source', utmSource);
|
||||
}
|
||||
|
@ -6,18 +6,22 @@
|
||||
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
||||
<url>
|
||||
<loc>https://ghostfol.io</loc>
|
||||
<lastmod>2021-05-14T20:24:46+00:00</lastmod>
|
||||
<lastmod>2021-06-03T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/about</loc>
|
||||
<lastmod>2021-05-14T20:24:46+00:00</lastmod>
|
||||
<lastmod>2021-06-03T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pricing</loc>
|
||||
<lastmod>2021-05-14T20:24:46+00:00</lastmod>
|
||||
<lastmod>2021-06-03T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/register</loc>
|
||||
<lastmod>2021-06-03T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/resources</loc>
|
||||
<lastmod>2021-05-14T20:24:46+00:00</lastmod>
|
||||
<lastmod>2021-06-03T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
</urlset>
|
||||
|
@ -4,6 +4,8 @@
|
||||
|
||||
@import '~angular-material-css-vars/main';
|
||||
|
||||
@import '~svgmap/dist/svgMap';
|
||||
|
||||
$mat-css-dark-theme-selector: '.is-dark-theme';
|
||||
$mat-css-light-theme-selector: '.is-light-theme';
|
||||
|
||||
@ -126,14 +128,16 @@ ngx-skeleton-loader {
|
||||
}
|
||||
}
|
||||
|
||||
.mat-card-header-text {
|
||||
margin: 0 !important;
|
||||
.mat-card {
|
||||
&:not([class*='mat-elevation-z']) {
|
||||
border: 1px solid
|
||||
rgba(var(--dark-primary-text), var(--palette-foreground-divider-alpha));
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-card:not([class*='mat-elevation-z']) {
|
||||
border: 1px solid
|
||||
rgba(var(--dark-primary-text), var(--palette-foreground-divider-alpha));
|
||||
box-shadow: none;
|
||||
.mat-card-header-text {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.mat-row {
|
||||
@ -151,3 +155,7 @@ ngx-skeleton-loader {
|
||||
.no-min-width {
|
||||
min-width: unset !important;
|
||||
}
|
||||
|
||||
.text-decoration-underline {
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
@ -1,13 +1,17 @@
|
||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
export const baseCurrency = Currency.CHF;
|
||||
|
||||
export const benchmarks = ['VOO'];
|
||||
export const benchmarks: Partial<IDataGatheringItem>[] = [
|
||||
{ dataSource: DataSource.YAHOO, symbol: 'VOO' }
|
||||
];
|
||||
|
||||
export const currencyPairs = [
|
||||
`${Currency.USD}${Currency.EUR}`,
|
||||
`${Currency.USD}${Currency.GBP}`,
|
||||
`${Currency.USD}${Currency.CHF}`
|
||||
export const currencyPairs: Partial<IDataGatheringItem>[] = [
|
||||
{ dataSource: DataSource.YAHOO, symbol: `${Currency.USD}${Currency.EUR}` },
|
||||
{ dataSource: DataSource.YAHOO, symbol: `${Currency.USD}${Currency.GBP}` },
|
||||
{ dataSource: DataSource.YAHOO, symbol: `${Currency.USD}${Currency.CHF}` }
|
||||
];
|
||||
|
||||
export const ghostfolioScraperApiSymbolPrefix = '_GF_';
|
||||
|
6
libs/common/src/lib/interfaces/country.interface.ts
Normal file
6
libs/common/src/lib/interfaces/country.interface.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export interface Country {
|
||||
code: string;
|
||||
continent: string;
|
||||
name: string;
|
||||
weight: number;
|
||||
}
|
@ -1,12 +1,15 @@
|
||||
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
import { Country } from './country.interface';
|
||||
|
||||
export interface PortfolioPosition {
|
||||
accounts: {
|
||||
[name: string]: { current: number; original: number };
|
||||
};
|
||||
allocationCurrent: number;
|
||||
allocationInvestment: number;
|
||||
countries: Country[];
|
||||
currency: Currency;
|
||||
exchange?: string;
|
||||
grossPerformance: number;
|
||||
@ -24,4 +27,5 @@ export interface PortfolioPosition {
|
||||
symbol: string;
|
||||
type?: string;
|
||||
url?: string;
|
||||
value: number;
|
||||
}
|
||||
|
@ -1,6 +1,11 @@
|
||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||
import { Account, Settings, User } from '@prisma/client';
|
||||
|
||||
export type UserWithSettings = User & {
|
||||
Account: Account[];
|
||||
Settings: Settings;
|
||||
subscription?: {
|
||||
expiresAt?: Date;
|
||||
type: SubscriptionType;
|
||||
};
|
||||
};
|
||||
|
@ -11,7 +11,7 @@ export interface User {
|
||||
permissions: string[];
|
||||
settings: UserSettings;
|
||||
subscription: {
|
||||
expiresAt: Date;
|
||||
type: 'Trial';
|
||||
expiresAt?: Date;
|
||||
type: 'Basic' | 'Premium';
|
||||
};
|
||||
}
|
||||
|
@ -1,5 +1,8 @@
|
||||
import { Account, Order, Platform } from '@prisma/client';
|
||||
import { Account, Order, Platform, SymbolProfile } from '@prisma/client';
|
||||
|
||||
type AccountWithPlatform = Account & { Platform?: Platform };
|
||||
|
||||
export type OrderWithAccount = Order & { Account?: AccountWithPlatform };
|
||||
export type OrderWithAccount = Order & {
|
||||
Account?: AccountWithPlatform;
|
||||
SymbolProfile?: SymbolProfile;
|
||||
};
|
||||
|
4
libs/common/src/lib/types/subscription.type.ts
Normal file
4
libs/common/src/lib/types/subscription.type.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export enum SubscriptionType {
|
||||
Basic = 'Basic',
|
||||
Premium = 'Premium'
|
||||
}
|
10
package.json
10
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ghostfolio",
|
||||
"version": "1.8.0",
|
||||
"version": "1.13.0",
|
||||
"homepage": "https://ghostfol.io",
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@ -18,7 +18,7 @@
|
||||
"database:format-schema": "prisma format",
|
||||
"database:generate-typings": "prisma generate",
|
||||
"database:gui": "prisma studio",
|
||||
"database:push": "prisma db push --preview-feature",
|
||||
"database:push": "prisma db push",
|
||||
"database:seed": "prisma db seed --preview-feature",
|
||||
"dep-graph": "nx dep-graph",
|
||||
"e2e": "ng e2e",
|
||||
@ -65,7 +65,7 @@
|
||||
"@nestjs/schedule": "0.4.1",
|
||||
"@nestjs/serve-static": "2.1.4",
|
||||
"@nrwl/angular": "12.0.0",
|
||||
"@prisma/client": "2.20.1",
|
||||
"@prisma/client": "2.24.1",
|
||||
"@types/lodash": "4.14.168",
|
||||
"alphavantage": "2.2.0",
|
||||
"angular-material-css-vars": "1.1.2",
|
||||
@ -79,6 +79,7 @@
|
||||
"cheerio": "1.0.0-rc.6",
|
||||
"class-transformer": "0.3.2",
|
||||
"class-validator": "0.13.1",
|
||||
"countries-list": "2.6.1",
|
||||
"countup.js": "2.0.7",
|
||||
"cryptocurrencies": "7.0.0",
|
||||
"date-fns": "2.19.0",
|
||||
@ -92,10 +93,11 @@
|
||||
"passport": "0.4.1",
|
||||
"passport-google-oauth20": "2.0.0",
|
||||
"passport-jwt": "4.0.0",
|
||||
"prisma": "2.20.1",
|
||||
"prisma": "2.24.1",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"round-to": "5.0.0",
|
||||
"rxjs": "6.6.7",
|
||||
"svgmap": "2.1.1",
|
||||
"uuid": "8.3.2",
|
||||
"yahoo-finance": "0.3.6",
|
||||
"zone.js": "0.11.4"
|
||||
|
171
prisma/migrations/20210604190809_initial_migration/migration.sql
Normal file
171
prisma/migrations/20210604190809_initial_migration/migration.sql
Normal file
@ -0,0 +1,171 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AccountType" AS ENUM ('SECURITIES');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Currency" AS ENUM ('CHF', 'EUR', 'USD', 'GBP');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DataSource" AS ENUM ('GHOSTFOLIO', 'RAKUTEN', 'YAHOO', 'ALPHA_VANTAGE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ViewMode" AS ENUM ('DEFAULT', 'ZEN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Provider" AS ENUM ('GOOGLE', 'ANONYMOUS');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN', 'DEMO');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Type" AS ENUM ('BUY', 'SELL');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Access" (
|
||||
"granteeUserId" TEXT NOT NULL,
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
PRIMARY KEY ("id","userId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Account" (
|
||||
"accountType" "AccountType" NOT NULL DEFAULT E'SECURITIES',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"id" TEXT NOT NULL,
|
||||
"isDefault" BOOLEAN NOT NULL DEFAULT false,
|
||||
"name" TEXT,
|
||||
"platformId" TEXT,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY ("id","userId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Analytics" (
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"activityCount" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
PRIMARY KEY ("userId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MarketData" (
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"date" TIMESTAMP(3) NOT NULL,
|
||||
"id" TEXT NOT NULL,
|
||||
"symbol" TEXT NOT NULL,
|
||||
"marketPrice" DOUBLE PRECISION NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Order" (
|
||||
"currency" "Currency" NOT NULL,
|
||||
"date" TIMESTAMP(3) NOT NULL,
|
||||
"fee" DOUBLE PRECISION NOT NULL,
|
||||
"id" TEXT NOT NULL,
|
||||
"quantity" DOUBLE PRECISION NOT NULL,
|
||||
"symbol" TEXT NOT NULL,
|
||||
"type" "Type" NOT NULL,
|
||||
"unitPrice" DOUBLE PRECISION NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"accountId" TEXT,
|
||||
"accountUserId" TEXT,
|
||||
"dataSource" "DataSource" NOT NULL DEFAULT E'YAHOO',
|
||||
|
||||
PRIMARY KEY ("id","userId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Platform" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"url" TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Property" (
|
||||
"key" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY ("key")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Settings" (
|
||||
"currency" "Currency",
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"viewMode" "ViewMode",
|
||||
|
||||
PRIMARY KEY ("userId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Subscription" (
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"id" TEXT NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY ("id","userId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"provider" "Provider",
|
||||
"thirdPartyId" TEXT,
|
||||
"accessToken" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"role" "Role" NOT NULL DEFAULT E'USER',
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"alias" TEXT,
|
||||
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "MarketData.date_symbol_unique" ON "MarketData"("date", "symbol");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MarketData.symbol_index" ON "MarketData"("symbol");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Platform.url_unique" ON "Platform"("url");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Access" ADD FOREIGN KEY ("granteeUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Access" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Account" ADD FOREIGN KEY ("platformId") REFERENCES "Platform"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Account" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Analytics" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Order" ADD FOREIGN KEY ("accountId", "accountUserId") REFERENCES "Account"("id", "userId") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Order" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Settings" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Subscription" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
@ -0,0 +1,21 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Order" ADD COLUMN "symbolProfileId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SymbolProfile" (
|
||||
"countries" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"dataSource" "DataSource" NOT NULL,
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"symbol" TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SymbolProfile.dataSource_symbol_unique" ON "SymbolProfile"("dataSource", "symbol");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Order" ADD FOREIGN KEY ("symbolProfileId") REFERENCES "SymbolProfile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
@ -59,22 +59,24 @@ model MarketData {
|
||||
}
|
||||
|
||||
model Order {
|
||||
Account Account? @relation(fields: [accountId, accountUserId], references: [id, userId])
|
||||
accountId String?
|
||||
accountUserId String?
|
||||
createdAt DateTime @default(now())
|
||||
currency Currency
|
||||
dataSource DataSource @default(YAHOO)
|
||||
date DateTime
|
||||
fee Float
|
||||
id String @default(uuid())
|
||||
quantity Float
|
||||
symbol String
|
||||
type Type
|
||||
unitPrice Float
|
||||
updatedAt DateTime @updatedAt
|
||||
User User @relation(fields: [userId], references: [id])
|
||||
userId String
|
||||
Account Account? @relation(fields: [accountId, accountUserId], references: [id, userId])
|
||||
accountId String?
|
||||
accountUserId String?
|
||||
createdAt DateTime @default(now())
|
||||
currency Currency
|
||||
dataSource DataSource @default(YAHOO)
|
||||
date DateTime
|
||||
fee Float
|
||||
id String @default(uuid())
|
||||
quantity Float
|
||||
symbol String
|
||||
SymbolProfile SymbolProfile? @relation(fields: [symbolProfileId], references: [id])
|
||||
symbolProfileId String?
|
||||
type Type
|
||||
unitPrice Float
|
||||
updatedAt DateTime @updatedAt
|
||||
User User @relation(fields: [userId], references: [id])
|
||||
userId String
|
||||
|
||||
@@id([id, userId])
|
||||
}
|
||||
@ -99,21 +101,46 @@ model Settings {
|
||||
userId String @id
|
||||
}
|
||||
|
||||
model SymbolProfile {
|
||||
countries Json?
|
||||
createdAt DateTime @default(now())
|
||||
dataSource DataSource
|
||||
id String @id @default(uuid())
|
||||
name String?
|
||||
Order Order[]
|
||||
updatedAt DateTime @updatedAt
|
||||
symbol String
|
||||
|
||||
@@unique([dataSource, symbol])
|
||||
}
|
||||
|
||||
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 {
|
||||
Access Access[] @relation("accessGet")
|
||||
AccessGive Access[] @relation(name: "accessGive")
|
||||
Access Access[] @relation("accessGet")
|
||||
AccessGive Access[] @relation(name: "accessGive")
|
||||
accessToken String?
|
||||
Account Account[]
|
||||
alias String?
|
||||
Analytics Analytics?
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(uuid())
|
||||
Order Order[]
|
||||
provider Provider?
|
||||
role Role @default(USER)
|
||||
role Role @default(USER)
|
||||
Settings Settings?
|
||||
Subscription Subscription[]
|
||||
thirdPartyId String?
|
||||
updatedAt DateTime @updatedAt
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
enum AccountType {
|
||||
|
@ -1,6 +1,7 @@
|
||||
import {
|
||||
AccountType,
|
||||
Currency,
|
||||
DataSource,
|
||||
PrismaClient,
|
||||
Role,
|
||||
Type
|
||||
@ -135,17 +136,47 @@ async function main() {
|
||||
where: { id: '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f' }
|
||||
});
|
||||
|
||||
await prisma.symbolProfile.createMany({
|
||||
data: [
|
||||
{
|
||||
countries: [{ code: 'US', weight: 1 }],
|
||||
dataSource: DataSource.YAHOO,
|
||||
id: '2bd26362-136e-411c-b578-334084b4cdcc',
|
||||
symbol: 'AMZN'
|
||||
},
|
||||
{
|
||||
countries: [{ code: 'US', weight: 1 }],
|
||||
dataSource: DataSource.YAHOO,
|
||||
id: 'd1ee9681-fb21-4f99-a3b7-afd4fc04df2e',
|
||||
symbol: 'TSLA'
|
||||
},
|
||||
{
|
||||
countries: [
|
||||
{ code: 'US', weight: 0.9886789999999981 },
|
||||
{ code: 'NL', weight: 0.000203 },
|
||||
{ code: 'CA', weight: 0.000362 }
|
||||
],
|
||||
dataSource: DataSource.YAHOO,
|
||||
id: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
|
||||
symbol: 'VTI'
|
||||
}
|
||||
],
|
||||
skipDuplicates: true
|
||||
});
|
||||
|
||||
await prisma.order.createMany({
|
||||
data: [
|
||||
{
|
||||
accountId: '65cfb79d-b6c7-4591-9d46-73426bc62094',
|
||||
accountUserId: userDemo.id,
|
||||
currency: Currency.USD,
|
||||
dataSource: DataSource.YAHOO,
|
||||
date: new Date(Date.UTC(2017, 0, 3, 0, 0, 0)),
|
||||
fee: 30,
|
||||
id: 'cf7c0418-8535-4089-ae3d-5dbfa0aec2e1',
|
||||
quantity: 50,
|
||||
symbol: 'TSLA',
|
||||
symbolProfileId: 'd1ee9681-fb21-4f99-a3b7-afd4fc04df2e',
|
||||
type: Type.BUY,
|
||||
unitPrice: 42.97,
|
||||
userId: userDemo.id
|
||||
@ -154,6 +185,7 @@ async function main() {
|
||||
accountId: 'd804de69-0429-42dc-b6ca-b308fd7dd926',
|
||||
accountUserId: userDemo.id,
|
||||
currency: Currency.USD,
|
||||
dataSource: DataSource.YAHOO,
|
||||
date: new Date(Date.UTC(2017, 7, 16, 0, 0, 0)),
|
||||
fee: 29.9,
|
||||
id: 'a1c5d73a-8631-44e5-ac44-356827a5212c',
|
||||
@ -167,11 +199,13 @@ async function main() {
|
||||
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
|
||||
accountUserId: userDemo.id,
|
||||
currency: Currency.USD,
|
||||
dataSource: DataSource.YAHOO,
|
||||
date: new Date(Date.UTC(2018, 9, 1, 0, 0, 0)),
|
||||
fee: 80.79,
|
||||
id: '71c08e2a-4a86-44ae-a890-c337de5d5f9b',
|
||||
quantity: 5,
|
||||
symbol: 'AMZN',
|
||||
symbolProfileId: '2bd26362-136e-411c-b578-334084b4cdcc',
|
||||
type: Type.BUY,
|
||||
unitPrice: 2021.99,
|
||||
userId: userDemo.id
|
||||
@ -180,11 +214,13 @@ async function main() {
|
||||
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
|
||||
accountUserId: userDemo.id,
|
||||
currency: Currency.USD,
|
||||
dataSource: DataSource.YAHOO,
|
||||
date: new Date(Date.UTC(2019, 2, 1, 0, 0, 0)),
|
||||
fee: 19.9,
|
||||
id: '385f2c2c-d53e-4937-b0e5-e92ef6020d4e',
|
||||
quantity: 10,
|
||||
symbol: 'VTI',
|
||||
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
|
||||
type: Type.BUY,
|
||||
unitPrice: 144.38,
|
||||
userId: userDemo.id
|
||||
@ -193,11 +229,13 @@ async function main() {
|
||||
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
|
||||
accountUserId: userDemo.id,
|
||||
currency: Currency.USD,
|
||||
dataSource: DataSource.YAHOO,
|
||||
date: new Date(Date.UTC(2019, 8, 3, 0, 0, 0)),
|
||||
fee: 19.9,
|
||||
id: '185f2c2c-d53e-4937-b0e5-a93ef6020d4e',
|
||||
quantity: 10,
|
||||
symbol: 'VTI',
|
||||
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
|
||||
type: Type.BUY,
|
||||
unitPrice: 147.99,
|
||||
userId: userDemo.id
|
||||
@ -206,11 +244,13 @@ async function main() {
|
||||
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
|
||||
accountUserId: userDemo.id,
|
||||
currency: Currency.USD,
|
||||
dataSource: DataSource.YAHOO,
|
||||
date: new Date(Date.UTC(2020, 2, 2, 0, 0, 0)),
|
||||
fee: 19.9,
|
||||
id: '347b0430-a84f-4031-a0f9-390399066ad6',
|
||||
quantity: 10,
|
||||
symbol: 'VTI',
|
||||
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
|
||||
type: Type.BUY,
|
||||
unitPrice: 151.41,
|
||||
userId: userDemo.id
|
||||
@ -219,11 +259,13 @@ async function main() {
|
||||
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
|
||||
accountUserId: userDemo.id,
|
||||
currency: Currency.USD,
|
||||
dataSource: DataSource.YAHOO,
|
||||
date: new Date(Date.UTC(2020, 8, 1, 0, 0, 0)),
|
||||
fee: 19.9,
|
||||
id: '67ec3f47-3189-4b63-ba05-60d3a06b302f',
|
||||
quantity: 10,
|
||||
symbol: 'VTI',
|
||||
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
|
||||
type: Type.BUY,
|
||||
unitPrice: 177.69,
|
||||
userId: userDemo.id
|
||||
@ -232,11 +274,13 @@ async function main() {
|
||||
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
|
||||
accountUserId: userDemo.id,
|
||||
currency: Currency.USD,
|
||||
dataSource: DataSource.YAHOO,
|
||||
date: new Date(Date.UTC(2020, 2, 1, 0, 0, 0)),
|
||||
fee: 19.9,
|
||||
id: 'd01c6fbc-fa8d-47e6-8e80-66f882d2bfd2',
|
||||
quantity: 10,
|
||||
symbol: 'VTI',
|
||||
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
|
||||
type: Type.BUY,
|
||||
unitPrice: 203.15,
|
||||
userId: userDemo.id
|
||||
|
53
yarn.lock
53
yarn.lock
@ -2081,22 +2081,22 @@
|
||||
consola "^2.15.0"
|
||||
node-fetch "^2.6.1"
|
||||
|
||||
"@prisma/client@2.20.1":
|
||||
version "2.20.1"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-2.20.1.tgz#45e7bade3a1b58972fc35e511578e313ead18770"
|
||||
integrity sha512-/IYPubBi55rNMHfE0wwglA6eTWEZD77oz+x+3Mm9ji2lDKdS1lnYKZ0wZX0E3AB8gTNL/zsGtfzmfjgn3ePyIw==
|
||||
"@prisma/client@2.24.1":
|
||||
version "2.24.1"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-2.24.1.tgz#c4f26fb4d768dd52dd20a17e626f10e69cc0b85c"
|
||||
integrity sha512-vllhf36g3oI98GF1Q5IPmnR5MYzBPeCcl/Xiz6EAi4DMOxE069o9ka5BAqYbUG2USx8JuKw09QdMnDrp3Kyn8g==
|
||||
dependencies:
|
||||
"@prisma/engines-version" "2.20.0-26.60ba6551f29b17d7d6ce479e5733c70d9c00860e"
|
||||
"@prisma/engines-version" "2.24.1-2.18095475d5ee64536e2f93995e48ad800737a9e4"
|
||||
|
||||
"@prisma/engines-version@2.20.0-26.60ba6551f29b17d7d6ce479e5733c70d9c00860e":
|
||||
version "2.20.0-26.60ba6551f29b17d7d6ce479e5733c70d9c00860e"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-2.20.0-26.60ba6551f29b17d7d6ce479e5733c70d9c00860e.tgz#1189f0a7e682f500015446cfe2b34d2753452190"
|
||||
integrity sha512-fJhbGZXm2SPs/RsI79Ew4SFe+6QmChNdgU2I/SIjmU18bUgK8f1TBEWnVtFdBqEDHYPGxbpaianF7lp04KN7EA==
|
||||
"@prisma/engines-version@2.24.1-2.18095475d5ee64536e2f93995e48ad800737a9e4":
|
||||
version "2.24.1-2.18095475d5ee64536e2f93995e48ad800737a9e4"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-2.24.1-2.18095475d5ee64536e2f93995e48ad800737a9e4.tgz#2c5813ef98bcbe659b18b521f002f5c8aabbaae2"
|
||||
integrity sha512-60Do+ByVfHnhJ2id5h/lXOZnDQNIf5pz3enkKWOmyr744Z2IxkBu65jRckFfMN5cPtmXDre/Ay/GKm0aoeLwrw==
|
||||
|
||||
"@prisma/engines@2.20.0-26.60ba6551f29b17d7d6ce479e5733c70d9c00860e":
|
||||
version "2.20.0-26.60ba6551f29b17d7d6ce479e5733c70d9c00860e"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-2.20.0-26.60ba6551f29b17d7d6ce479e5733c70d9c00860e.tgz#18f23c4a8a335a93fd338f88c310f5cf120f5591"
|
||||
integrity sha512-zOWETm7DTRvlwf/CekPNSeJe6EC5bn2IFexd74wM9zgBXCZo+1sMDuNGtCqIt4Rzv8CcimEgyzrEFVq0LPV8qg==
|
||||
"@prisma/engines@2.24.1-2.18095475d5ee64536e2f93995e48ad800737a9e4":
|
||||
version "2.24.1-2.18095475d5ee64536e2f93995e48ad800737a9e4"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-2.24.1-2.18095475d5ee64536e2f93995e48ad800737a9e4.tgz#7e542d510f0c03f41b73edbb17254f5a0b272a4d"
|
||||
integrity sha512-29/xO9kqeQka+wN5Ev10l5L4XQXNVXdPToJs1M29VZ2imQsNsL4rtz26m3qGM54IoGWwwfTVdvuVRxKnDl2rig==
|
||||
|
||||
"@samverschueren/stream-to-observable@^0.3.0":
|
||||
version "0.3.1"
|
||||
@ -4698,6 +4698,11 @@ cosmiconfig@^7.0.0:
|
||||
path-type "^4.0.0"
|
||||
yaml "^1.10.0"
|
||||
|
||||
countries-list@2.6.1:
|
||||
version "2.6.1"
|
||||
resolved "https://registry.yarnpkg.com/countries-list/-/countries-list-2.6.1.tgz#d479757ac873b1e596ccea0a925962d20396c0cb"
|
||||
integrity sha512-jXM1Nv3U56dPQ1DsUSsEaGmLHburo4fnB7m+1yhWDUVvx5gXCd1ok/y3gXCjXzhqyawG+igcPYcAl4qjkvopaQ==
|
||||
|
||||
countup.js@2.0.7:
|
||||
version "2.0.7"
|
||||
resolved "https://registry.yarnpkg.com/countup.js/-/countup.js-2.0.7.tgz#56b72a87fc0ee3cadb38356c246ccac88fb0a8cc"
|
||||
@ -10702,12 +10707,12 @@ pretty-format@26.x, pretty-format@^26.0.0, pretty-format@^26.6.2:
|
||||
ansi-styles "^4.0.0"
|
||||
react-is "^17.0.1"
|
||||
|
||||
prisma@2.20.1:
|
||||
version "2.20.1"
|
||||
resolved "https://registry.yarnpkg.com/prisma/-/prisma-2.20.1.tgz#28c52135523e0853258cd4ca7e883e9e4b5a9d40"
|
||||
integrity sha512-zyPvJSUfJrmciP2D/4aUrsyIefiH8AIJUeuq1a0X1df1AFw9QQ+ata/7VQdoP+RIQHnCb6Kln9kqfUw/fieljw==
|
||||
prisma@2.24.1:
|
||||
version "2.24.1"
|
||||
resolved "https://registry.yarnpkg.com/prisma/-/prisma-2.24.1.tgz#f8f4cb8baf407a71800256160277f69603bd43a3"
|
||||
integrity sha512-L+ykMpttbWzpTNsy+PPynnEX/mS1s5zs49euXBrMjxXh1M6/f9MYlTNAj+iP90O9ZSaURSpNa+1jzatPghqUcQ==
|
||||
dependencies:
|
||||
"@prisma/engines" "2.20.0-26.60ba6551f29b17d7d6ce479e5733c70d9c00860e"
|
||||
"@prisma/engines" "2.24.1-2.18095475d5ee64536e2f93995e48ad800737a9e4"
|
||||
|
||||
prismjs@^1.23.0:
|
||||
version "1.23.0"
|
||||
@ -12377,6 +12382,18 @@ supports-hyperlinks@^2.0.0:
|
||||
has-flag "^4.0.0"
|
||||
supports-color "^7.0.0"
|
||||
|
||||
svg-pan-zoom@^3.6.1:
|
||||
version "3.6.1"
|
||||
resolved "https://registry.yarnpkg.com/svg-pan-zoom/-/svg-pan-zoom-3.6.1.tgz#f880a1bb32d18e9c625d7715350bebc269b450cf"
|
||||
integrity sha512-JaKkGHHfGvRrcMPdJWkssLBeWqM+Isg/a09H7kgNNajT1cX5AztDTNs+C8UzpCxjCTRrG34WbquwaovZbmSk9g==
|
||||
|
||||
svgmap@2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/svgmap/-/svgmap-2.1.1.tgz#355c259cf4e04b20d2d39bab05d0e718ade942ff"
|
||||
integrity sha512-1blZYMYDXq8H3xykzgBJRh5q+XPd5JLOJ8K7UuZI6ab2D3hngiVcr+Z1olfy7DH9Xf9AOCTpt4Id7iVD8cKD0A==
|
||||
dependencies:
|
||||
svg-pan-zoom "^3.6.1"
|
||||
|
||||
svgo@^1.0.0:
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/svgo/-/svgo-1.3.2.tgz#b6dc511c063346c9e415b81e43401145b96d4167"
|
||||
|
Reference in New Issue
Block a user