diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml
index b5de8d81..4295c18d 100644
--- a/.github/workflows/prerelease.yml
+++ b/.github/workflows/prerelease.yml
@@ -40,7 +40,6 @@ jobs:
LOCALAZY_WRITE_KEY: ${{ secrets.LOCALAZY_WRITE_KEY }}
run: |
yarn translations:pull
- yarn translations:generate-index
- name: Install src deps
working-directory: src
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 73a42cad..2729f403 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -37,9 +37,6 @@ jobs:
LOCALAZY_READ_KEY: a8269809765126758267-f01743c76d6e9e434d7b4c6322938eefce8d82a559b57efc2acfcf4531d46089
run: yarn translations:pull
- - name: Generate translations
- run: yarn translations:generate-index
-
- name: Install src deps
working-directory: src
run: yarn --frozen-lockfile
diff --git a/README.md b/README.md
index 2b0490f7..f61d2c42 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
[![License: CC BY-NC-SA 4.0](https://img.shields.io/badge/License-CC_BY--NC--SA_4.0-lightgrey.svg)](https://creativecommons.org/licenses/by-nc-sa/4.0/)
-![Discord](https://img.shields.io/discord/791854454760013827?label=Our%20Discord)
+[![Discord](https://img.shields.io/discord/791854454760013827?label=Our%20Discord)](https://discord.gg/HYwBjTbAY5)
diff --git a/config.json b/config.json
index fd502769..def3219b 100644
--- a/config.json
+++ b/config.json
@@ -7,7 +7,8 @@
"frameworkIntegration": {
"enabled": false,
"resource": "your-resource",
- "syncInitialBankBalance": true
+ "syncInitialBankBalance": true,
+ "isCardsEnabled": false
},
"database": {
"profileQueries": true
@@ -24,6 +25,10 @@
"clearingNumber": 920,
"maximumNumberOfAccounts": 3
},
+ "cards": {
+ "cost": 4500,
+ "maxCardsPerAccount": 2
+ },
"atms": {
"distance": 5.0,
"props": [-870868698, -1126237515, 506770882, -1364697528],
diff --git a/package.json b/package.json
index 9a346dc7..bf905681 100644
--- a/package.json
+++ b/package.json
@@ -11,13 +11,13 @@
"postinstall": "husky install && yarn setup",
"translations:generate": "yarn i18next",
"translations:generate-index": "node ./scripts/generateLocales.js",
- "translations:pull": "localazy download",
+ "translations:pull": "localazy download && node ./scripts/generateLocales.js",
"translations:push": "localazy upload -w $LOCALAZY_WRITE_KEY -r $LOCALAZY_READ_KEY",
"setup": "yarn nx run-many --target=setup --all && yarn translations:pull && yarn translations:generate-index",
"build": "yarn nx run-many --target=build --all",
"lint": "yarn nx run-many --target=lint --all",
- "dev": "yarn nx run-many --target=dev --all",
"tsc": "yarn nx run-many --target=tsc --all",
+ "dev": "yarn nx run-many --target=dev --all",
"dev:ingame": "yarn nx run-many --target=dev:ingame --all",
"dev:mobile": "yarn nx run-many --target=dev:mobile --all",
"pre-release": "yarn build && sh ./scripts/prerelease.sh",
@@ -39,4 +39,4 @@
"dependencies": {
"i18next-parser": "^6.0.0"
}
-}
\ No newline at end of file
+}
diff --git a/shared/constants.ts b/shared/constants.ts
new file mode 100644
index 00000000..d21fe449
--- /dev/null
+++ b/shared/constants.ts
@@ -0,0 +1,2 @@
+export const PIN_CODE_LENGTH = 4;
+export const CHECK_PLAYER_LOADED_INTERVAL = 15; // ms;
diff --git a/src/client/cl_api.ts b/src/client/cl_api.ts
index cc59d204..13928acc 100644
--- a/src/client/cl_api.ts
+++ b/src/client/cl_api.ts
@@ -3,6 +3,7 @@ import { ATMInput } from '@typings/Account';
import { AccountEvents, CashEvents, InvoiceEvents } from '@typings/Events';
import { ServerPromiseResp } from '@typings/http';
import { Invoice, InvoiceOnlineInput } from '@typings/Invoice';
+import { translations } from 'i18n';
export class Api {
utils: ClientUtils;
@@ -83,7 +84,9 @@ export class Api {
try {
const payload: ATMInput = {
amount,
- message: 'Deposition',
+ message: translations.t('Successfully deposited {{amount}} into selected account.', {
+ amount,
+ }),
};
const response = await this.utils.emitNetPromise(AccountEvents.DepositMoney, payload);
console.log({ response });
@@ -97,7 +100,9 @@ export class Api {
try {
const payload: ATMInput = {
amount,
- message: 'Withdrawal',
+ message: translations.t('Withdrew {{amount}} from an ATM.', {
+ amount,
+ }),
};
const response = await this.utils.emitNetPromise(AccountEvents.WithdrawMoney, payload);
diff --git a/src/client/cl_events.ts b/src/client/cl_events.ts
index fdf8d714..1ee0dcd1 100644
--- a/src/client/cl_events.ts
+++ b/src/client/cl_events.ts
@@ -11,9 +11,11 @@ import {
Broadcasts,
NUIEvents,
CashEvents,
+ CardEvents,
} from '@typings/Events';
import { Invoice } from '@typings/Invoice';
import { Transaction } from '@typings/Transaction';
+import { OnlineUser } from '@typings/user';
import { RegisterNuiProxy } from 'cl_utils';
import API from './cl_api';
import config from './cl_config';
@@ -23,6 +25,8 @@ const npwdExports = global.exports['npwd'];
const useFrameworkIntegration = config.frameworkIntegration?.enabled;
let hasNUILoaded = false;
+emitNet(UserEvents.LoadClient);
+
RegisterNuiCB(NUIEvents.Loaded, () => {
console.debug('NUI has loaded.');
hasNUILoaded = true;
@@ -84,7 +88,7 @@ onNet(Broadcasts.RemovedSharedUser, () => {
SendBankUIMessage('PEFCL', Broadcasts.RemovedSharedUser, {});
});
-onNet(UserEvents.Loaded, async () => {
+onNet(UserEvents.Loaded, async (user: OnlineUser) => {
console.debug('Waiting for NUI to load ..');
await waitForNUILoaded();
console.debug('Loaded. Emitting data to NUI.');
@@ -124,10 +128,20 @@ RegisterNuiProxy(SharedAccountEvents.GetUsers);
RegisterNuiProxy(ExternalAccountEvents.Add);
RegisterNuiProxy(ExternalAccountEvents.Get);
+RegisterNuiProxy(AccountEvents.GetAtmAccount);
RegisterNuiProxy(AccountEvents.WithdrawMoney);
RegisterNuiProxy(AccountEvents.DepositMoney);
RegisterNuiProxy(CashEvents.GetMyCash);
+// Cards
+RegisterNuiProxy(CardEvents.Get);
+RegisterNuiProxy(CardEvents.Block);
+RegisterNuiProxy(CardEvents.Delete);
+RegisterNuiProxy(CardEvents.OrderPersonal);
+RegisterNuiProxy(CardEvents.OrderShared);
+RegisterNuiProxy(CardEvents.UpdatePin);
+RegisterNuiProxy(CardEvents.GetInventoryCards);
+
RegisterCommand(
'bank-force-load',
async () => {
diff --git a/src/client/cl_exports.ts b/src/client/cl_exports.ts
index a5655341..123b8d77 100644
--- a/src/client/cl_exports.ts
+++ b/src/client/cl_exports.ts
@@ -1,10 +1,12 @@
+import { NUIEvents } from '@typings/Events';
import { setBankIsOpen, setAtmIsOpen } from 'client';
import { createInvoice, depositMoney, giveCash, withdrawMoney } from 'functions';
const exp = global.exports;
-exp('openBank', async () => {
+exp('openBank', async (accountId: number) => {
setBankIsOpen(true);
+ SendNUIMessage({ type: NUIEvents.SetCardId, payload: accountId });
});
exp('closeBank', async () => {
diff --git a/src/server/globals.server.ts b/src/server/globals.server.ts
index 76793829..f2fe2e65 100644
--- a/src/server/globals.server.ts
+++ b/src/server/globals.server.ts
@@ -7,7 +7,7 @@ export const mockedResourceName = 'pefcl';
// TODO: Move this into package
const convars = {
- mysql_connection_string: 'mysql://root:bruv@localhost/dev',
+ mysql_connection_string: 'mysql://root:root@127.0.0.1/QBCoreFramework_E05901?charset=utf8mb4',
};
const players: any = {
@@ -26,6 +26,10 @@ if (isMocking) {
const ServerEmitter = new EventEmitter().setMaxListeners(25);
const NetEmitter = new EventEmitter().setMaxListeners(25);
+ global.RegisterCommand = (cmd: string) => {
+ console.log('Registered command', cmd);
+ };
+
global.LoadResourceFile = (_resourceName: string, fileName: string) => {
const file = readFileSync(`${baseDir}/${fileName}`, 'utf-8');
return file;
@@ -64,7 +68,7 @@ if (isMocking) {
'your-resource': {
addCash: () => {
console.log('global.server.ts: Adding cash ..');
- throw new Error('no funds');
+ throw new Error('adding cash');
},
getCash: () => {
console.log('global.server.ts: Getting cash ..');
@@ -72,7 +76,15 @@ if (isMocking) {
},
removeCash: () => {
console.log('global.server.ts: Removing cash ..');
- throw new Error('no funds');
+ throw new Error('could not remove cash');
+ },
+ giveCard: () => {
+ console.log('global.server.ts: Giving card ..');
+ throw new Error('giving card');
+ },
+ getCards: () => {
+ console.log('global.server.ts: Getting cards ..');
+ return [];
},
},
});
diff --git a/src/server/server.ts b/src/server/server.ts
index e49cfbb0..1b38b5b5 100644
--- a/src/server/server.ts
+++ b/src/server/server.ts
@@ -2,6 +2,7 @@ import './globals.server';
import { ServerPromiseResp } from '@project-error/pe-utils';
import {
AccountEvents,
+ CardEvents,
CashEvents,
ExternalAccountEvents,
GeneralEvents,
@@ -28,6 +29,7 @@ import { mockedResourceName } from './globals.server';
import { config } from './utils/server-config';
import { UserService } from './services/user/user.service';
import { container } from 'tsyringe';
+import { CardService } from './services/card/card.service';
const hotReloadConfig = {
resourceName: GetCurrentResourceName(),
@@ -76,6 +78,7 @@ if (isMocking) {
app.post(...createEndpoint(UserEvents.GetUsers));
app.post(...createEndpoint(AccountEvents.GetAccounts));
+ app.post(...createEndpoint(AccountEvents.GetAtmAccount));
app.post(...createEndpoint(AccountEvents.DeleteAccount));
app.post(...createEndpoint(AccountEvents.SetDefaultAccount));
app.post(...createEndpoint(AccountEvents.CreateAccount));
@@ -96,6 +99,14 @@ if (isMocking) {
app.post(...createEndpoint(ExternalAccountEvents.Get));
app.post(...createEndpoint(CashEvents.GetMyCash));
+ // Cards
+ app.post(...createEndpoint(CardEvents.Get));
+ app.post(...createEndpoint(CardEvents.OrderPersonal));
+ app.post(...createEndpoint(CardEvents.UpdatePin));
+ app.post(...createEndpoint(CardEvents.Block));
+ app.post(...createEndpoint(CardEvents.Delete));
+ app.post(...createEndpoint(CardEvents.GetInventoryCards));
+
app.listen(port, async () => {
mainLogger.child({ module: 'server' }).debug(`[MOCKSERVER]: listening on port: ${port}`);
@@ -186,6 +197,33 @@ const debug = async () => {
// },
// source: 0,
// });
+
+ RegisterCommand(
+ 'card',
+ async (src: number) => {
+ const exps = exports;
+ const QBCore = await exps['qb-core']?.GetCoreObject();
+
+ await exps['qb-core'].RemoveItem('bank_card');
+
+ const item = {
+ name: 'bank_card',
+ label: 'Bank card',
+ weight: 1,
+ type: 'item',
+ image: 'visacard.png',
+ };
+
+ await exps['qb-core'].AddItem('bank_card', item);
+
+ const cardService = container.resolve(CardService);
+
+ const res = await cardService.giveCard(src, QBCore);
+
+ console.log(res);
+ },
+ false,
+ );
};
on(GeneralEvents.ResourceStarted, debug);
diff --git a/src/server/services/account/account.controller.ts b/src/server/services/account/account.controller.ts
index d75b89e7..201ddd4c 100644
--- a/src/server/services/account/account.controller.ts
+++ b/src/server/services/account/account.controller.ts
@@ -125,11 +125,7 @@ export class AccountController {
@Export(ServerExports.WithdrawCash)
@NetPromise(AccountEvents.WithdrawMoney)
async withdrawMoney(req: Request, res: Response) {
- const accountId = req.data.accountId;
-
try {
- accountId &&
- (await this._auth.isAuthorizedAccount(accountId, req.source, [AccountRole.Admin]));
await this._accountService.handleWithdrawMoney(req);
res({ status: 'ok', data: {} });
} catch (err) {
diff --git a/src/server/services/account/account.model.ts b/src/server/services/account/account.model.ts
index 271e9843..10d03da9 100644
--- a/src/server/services/account/account.model.ts
+++ b/src/server/services/account/account.model.ts
@@ -3,7 +3,7 @@ import { DataTypes, Model, Optional } from 'sequelize';
import { config } from '@utils/server-config';
import { Account, AccountRole, AccountType } from '@typings/Account';
import { sequelize } from '@utils/pool';
-import { generateAccountNumber } from '@utils/misc';
+import { generateClearingNumber } from '@utils/misc';
import { timestamps } from '../timestamps.model';
import { AccountEvents } from '@server/../../typings/Events';
@@ -22,7 +22,7 @@ AccountModel.init(
number: {
type: DataTypes.STRING,
unique: true,
- defaultValue: generateAccountNumber,
+ defaultValue: generateClearingNumber,
},
accountName: {
type: DataTypes.STRING,
diff --git a/src/server/services/account/account.service.ts b/src/server/services/account/account.service.ts
index 815dafea..f19a07f4 100644
--- a/src/server/services/account/account.service.ts
+++ b/src/server/services/account/account.service.ts
@@ -31,6 +31,7 @@ import {
AccountErrors,
AuthorizationErrors,
BalanceErrors,
+ CardErrors,
GenericErrors,
UserErrors,
} from '@typings/Errors';
@@ -38,14 +39,20 @@ import { SharedAccountDB } from '@services/accountShared/sharedAccount.db';
import { AccountEvents, Broadcasts } from '@server/../../typings/Events';
import { getFrameworkExports } from '@server/utils/frameworkIntegration';
import { Transaction } from 'sequelize/types';
+import { CardDB } from '../card/card.db';
const logger = mainLogger.child({ module: 'accounts' });
-const { enabled = false, syncInitialBankBalance = false } = config.frameworkIntegration ?? {};
+const {
+ enabled = false,
+ syncInitialBankBalance = false,
+ isCardsEnabled = false,
+} = config.frameworkIntegration ?? {};
const { firstAccountStartBalance } = config.accounts ?? {};
const isFrameworkIntegrationEnabled = enabled;
@singleton()
export class AccountService {
+ _cardDB: CardDB;
_accountDB: AccountDB;
_sharedAccountDB: SharedAccountDB;
_cashService: CashService;
@@ -58,7 +65,9 @@ export class AccountService {
userService: UserService,
cashService: CashService,
transactionService: TransactionService,
+ cardDB: CardDB,
) {
+ this._cardDB = cardDB;
this._accountDB = accountDB;
this._sharedAccountDB = sharedAccountDB;
this._cashService = cashService;
@@ -83,7 +92,7 @@ export class AccountService {
/* Override role by the shared one. */
return {
- ...acc.toJSON(),
+ ...acc?.toJSON(),
role: sharedAcc.role,
};
});
@@ -460,25 +469,39 @@ export class AccountService {
}
async handleWithdrawMoney(req: Request) {
- logger.silly(`"${req.source}" withdrawing "${req.data.amount}".`);
- const amount = req.data.amount;
+ const { accountId, amount, cardId, cardPin } = req.data;
+ logger.silly(`"${req.source}" withdrawing "${amount}".`);
if (amount <= 0) {
throw new ServerError(GenericErrors.BadInput);
}
- /* Only run the export when account is the default(?). Not sure about this. */
const t = await sequelize.transaction();
try {
- const targetAccount = req.data.accountId
- ? await this._accountDB.getAccountById(req.data.accountId)
+ /* If framework is enabled, do a card check, otherwise continue. */
+ if (isFrameworkIntegrationEnabled && isCardsEnabled && cardId) {
+ const exports = getFrameworkExports();
+ const cards = exports.getCards(req.source);
+ const selectedCard = cards?.find((card) => card.id === cardId);
+
+ if (!selectedCard) {
+ throw new Error('User does not have selected card in inventory.');
+ }
+
+ const card = await this._cardDB.getById(selectedCard.id);
+ if (card?.getDataValue('pin') !== cardPin) {
+ throw new Error(CardErrors.InvalidPin);
+ }
+ }
+
+ const targetAccount = accountId
+ ? await this._accountDB.getAccountById(accountId)
: await this.getDefaultAccountBySource(req.source);
if (!targetAccount) {
throw new ServerError(GenericErrors.NotFound);
}
- const accountId = targetAccount.getDataValue('id') ?? 0;
const currentAccountBalance = targetAccount.getDataValue('balance');
if (currentAccountBalance < amount) {
@@ -859,7 +882,6 @@ export class AccountService {
type,
accountName: name,
ownerIdentifier: identifier,
- isDefault: true,
});
const json = account.toJSON();
diff --git a/src/server/services/associations.ts b/src/server/services/associations.ts
index faeb29b2..20285b20 100644
--- a/src/server/services/associations.ts
+++ b/src/server/services/associations.ts
@@ -4,6 +4,7 @@ import { AccountModel } from './account/account.model';
import { TransactionModel } from './transaction/transaction.model';
import './invoice/invoice.model';
import { SharedAccountModel } from './accountShared/sharedAccount.model';
+import { CardModel } from './card/card.model';
/* This is so annoying. Next time choose something with TS support. */
declare module './accountShared/sharedAccount.model' {
@@ -19,6 +20,12 @@ declare module './transaction/transaction.model' {
}
}
+declare module './card/card.model' {
+ interface CardModel {
+ setAccount(id?: number): Promise;
+ }
+}
+
TransactionModel.belongsTo(AccountModel, {
as: 'toAccount',
});
@@ -31,6 +38,10 @@ SharedAccountModel.belongsTo(AccountModel, {
as: 'account',
});
+CardModel.belongsTo(AccountModel, {
+ as: 'account',
+});
+
if (config?.database?.shouldSync) {
sequelize.sync();
}
diff --git a/src/server/services/boot/boot.service.ts b/src/server/services/boot/boot.service.ts
index ca911a33..b4aaf896 100644
--- a/src/server/services/boot/boot.service.ts
+++ b/src/server/services/boot/boot.service.ts
@@ -43,14 +43,27 @@ export class BootService {
logger.error(error);
if (error instanceof Error && error.message.includes('No such export')) {
- logger.error(
- 'Check your starting order. The framework integration library needs to be started before PEFCL!',
- );
+ if (config.frameworkIntegration.isCardsEnabled && error.message.includes('Card')) {
+ logger.error(' ');
+ logger.error(
+ 'This framework does not seem to support cards. Make sure your resource is exporting the required exports!',
+ );
+ logger.error(
+ 'Check the documentation for correct setup: https://projecterror.dev/docs/pefcl/developers/framework_integration',
+ );
+ logger.error(' ');
+ } else {
+ logger.error(
+ 'Check your starting order. The framework integration library needs to be started before PEFCL!',
+ );
+ }
}
this.handleResourceStop();
return;
}
+
+ logger.info('Successfully verified exports.');
}
logger.info(`Starting ${resourceName}.`);
@@ -61,5 +74,8 @@ export class BootService {
logger.info(`Stopping ${resourceName}.`);
emit(GeneralEvents.ResourceStopped);
StopResource(resourceName);
+ StopResource(resourceName);
+ StopResource(resourceName);
+ StopResource(resourceName);
}
}
diff --git a/src/server/services/broadcast/broadcast.controller.ts b/src/server/services/broadcast/broadcast.controller.ts
index e34981b0..c99ad577 100644
--- a/src/server/services/broadcast/broadcast.controller.ts
+++ b/src/server/services/broadcast/broadcast.controller.ts
@@ -1,11 +1,18 @@
import { Account } from '@server/../../typings/Account';
+import { Card } from '@server/../../typings/BankCard';
import { Cash } from '@server/../../typings/Cash';
-import { AccountEvents, CashEvents, TransactionEvents } from '@server/../../typings/Events';
import { Transaction } from '@server/../../typings/Transaction';
import { Controller } from '@server/decorators/Controller';
import { Event, EventListener } from '@server/decorators/Event';
import { BroadcastService } from './broadcast.service';
+import {
+ AccountEvents,
+ CardEvents,
+ CashEvents,
+ TransactionEvents,
+} from '@server/../../typings/Events';
+
@Controller('Broadcast')
@EventListener()
export class BroadcastController {
@@ -43,4 +50,9 @@ export class BroadcastController {
async onNewTransaction(transaction: Transaction) {
this.broadcastService.broadcastTransaction(transaction);
}
+
+ @Event(CardEvents.NewCard)
+ async onNewCard(card: Card) {
+ this.broadcastService.broadcastNewCard(card);
+ }
}
diff --git a/src/server/services/broadcast/broadcast.service.ts b/src/server/services/broadcast/broadcast.service.ts
index 3da23e94..cca8d3b1 100644
--- a/src/server/services/broadcast/broadcast.service.ts
+++ b/src/server/services/broadcast/broadcast.service.ts
@@ -7,6 +7,7 @@ import { TransactionDB } from '../transaction/transaction.db';
import { Account, AccountType } from '@server/../../typings/Account';
import { Cash } from '@server/../../typings/Cash';
import { AccountService } from '../account/account.service';
+import { Card } from '@server/../../typings/BankCard';
const logger = mainLogger.child({ module: 'broadcastService' });
@@ -36,6 +37,16 @@ export class BroadcastService {
emitNet(Broadcasts.UpdatedAccount, user?.getSource(), account);
}
+ async broadcastNewCard(card: Card) {
+ logger.silly(`Broadcasted new card:`);
+ logger.silly(JSON.stringify(card));
+
+ const user = this._userService.getUserByIdentifier(card.holderCitizenId);
+ if (!user) return;
+
+ emitNet(Broadcasts.NewCard, user?.getSource(), card);
+ }
+
async broadcastTransaction(transaction: Transaction) {
logger.silly(`Broadcasted transaction:`);
logger.silly(JSON.stringify(transaction));
diff --git a/src/server/services/card/card.controller.ts b/src/server/services/card/card.controller.ts
new file mode 100644
index 00000000..ed13a187
--- /dev/null
+++ b/src/server/services/card/card.controller.ts
@@ -0,0 +1,97 @@
+import { NetPromise, PromiseEventListener } from '@decorators/NetPromise';
+import { GetATMAccountInput, GetATMAccountResponse } from '@server/../../typings/Account';
+import {
+ BlockCardInput,
+ Card,
+ CreateCardInput,
+ DeleteCardInput,
+ GetCardInput,
+ InventoryCard,
+ UpdateCardPinInput,
+} from '@server/../../typings/BankCard';
+import { AccountEvents, CardEvents } from '@typings/Events';
+import { Request, Response } from '@typings/http';
+import { Controller } from '../../decorators/Controller';
+import { EventListener } from '../../decorators/Event';
+import { CardService } from './card.service';
+
+@Controller('Card')
+@EventListener()
+@PromiseEventListener()
+export class CardController {
+ cardService: CardService;
+ constructor(cardService: CardService) {
+ this.cardService = cardService;
+ }
+
+ @NetPromise(AccountEvents.GetAtmAccount)
+ async getAtmAccount(req: Request, res: Response) {
+ try {
+ const result = await this.cardService.getAccountByCard(req);
+ res({ status: 'ok', data: result });
+ } catch (error) {
+ res({ status: 'error', errorMsg: error.message });
+ }
+ }
+
+ @NetPromise(CardEvents.OrderPersonal)
+ async orderPersonalAccount(req: Request, res: Response) {
+ try {
+ const result = await this.cardService.orderPersonalCard(req);
+ res({ status: 'ok', data: result });
+ } catch (error) {
+ res({ status: 'error', errorMsg: error.message });
+ }
+ }
+
+ @NetPromise(CardEvents.Block)
+ async blockCard(req: Request, res: Response) {
+ try {
+ const isUpdated = await this.cardService.blockCard(req);
+ res({ status: 'ok', data: isUpdated });
+ } catch (error) {
+ res({ status: 'error', errorMsg: error.message });
+ }
+ }
+
+ @NetPromise(CardEvents.Delete)
+ async deleteCard(req: Request, res: Response) {
+ try {
+ const isDeleted = await this.cardService.blockCard(req);
+ res({ status: 'ok', data: isDeleted });
+ } catch (error) {
+ res({ status: 'error', errorMsg: error.message });
+ }
+ }
+
+ @NetPromise(CardEvents.UpdatePin)
+ async updatePin(req: Request, res: Response) {
+ try {
+ const isUpdated = await this.cardService.updateCardPin(req);
+ res({ status: 'ok', data: isUpdated });
+ } catch (error) {
+ res({ status: 'error', errorMsg: error.message });
+ }
+ }
+
+ @NetPromise(CardEvents.Get)
+ async getCards(req: Request, res: Response) {
+ try {
+ const result = await this.cardService.getCards(req);
+ res({ status: 'ok', data: result });
+ } catch (error) {
+ res({ status: 'error', errorMsg: error.message });
+ }
+ }
+
+ /* Return cards from player inventory to be selected at ATM */
+ @NetPromise(CardEvents.GetInventoryCards)
+ async getInventoryCards(req: Request, res: Response) {
+ try {
+ const result = await this.cardService.getInventoryCards(req);
+ res({ status: 'ok', data: result });
+ } catch (error) {
+ res({ status: 'error', errorMsg: error.message });
+ }
+ }
+}
diff --git a/src/server/services/card/card.db.ts b/src/server/services/card/card.db.ts
new file mode 100644
index 00000000..a0767df2
--- /dev/null
+++ b/src/server/services/card/card.db.ts
@@ -0,0 +1,22 @@
+import { Transaction } from 'sequelize/types';
+import { singleton } from 'tsyringe';
+import { CardModel, CardModelCreate } from './card.model';
+
+@singleton()
+export class CardDB {
+ async getAll(): Promise {
+ return await CardModel.findAll();
+ }
+
+ async getById(cardId: number, transaction?: Transaction): Promise {
+ return await CardModel.findOne({ where: { id: cardId }, transaction });
+ }
+
+ async getByAccountId(accountId: number): Promise {
+ return await CardModel.findAll({ where: { accountId: accountId } });
+ }
+
+ async create(data: CardModelCreate, transaction: Transaction): Promise {
+ return await CardModel.create(data, { transaction });
+ }
+}
diff --git a/src/server/services/card/card.model.ts b/src/server/services/card/card.model.ts
new file mode 100644
index 00000000..c4675bcc
--- /dev/null
+++ b/src/server/services/card/card.model.ts
@@ -0,0 +1,44 @@
+import { Card } from '@server/../../typings/BankCard';
+import { generateCardNumber } from '@server/utils/misc';
+import { DATABASE_PREFIX } from '@utils/constants';
+import { DataTypes, Model, Optional } from 'sequelize';
+import { singleton } from 'tsyringe';
+import { sequelize } from '../../utils/pool';
+import { timestamps } from '../timestamps.model';
+
+export type CardModelCreate = Optional<
+ Card,
+ 'id' | 'number' | 'pin' | 'isBlocked' | 'createdAt' | 'updatedAt'
+>;
+
+@singleton()
+export class CardModel extends Model {}
+CardModel.init(
+ {
+ id: {
+ type: DataTypes.INTEGER,
+ autoIncrement: true,
+ primaryKey: true,
+ },
+ holder: {
+ type: DataTypes.STRING,
+ },
+ holderCitizenId: {
+ type: DataTypes.STRING,
+ },
+ isBlocked: {
+ type: DataTypes.BOOLEAN,
+ defaultValue: false,
+ },
+ number: {
+ type: DataTypes.STRING,
+ defaultValue: generateCardNumber,
+ },
+ pin: {
+ type: DataTypes.INTEGER,
+ defaultValue: 1234,
+ },
+ ...timestamps,
+ },
+ { sequelize: sequelize, tableName: DATABASE_PREFIX + 'cards' },
+);
diff --git a/src/server/services/card/card.service.ts b/src/server/services/card/card.service.ts
new file mode 100644
index 00000000..3284ffec
--- /dev/null
+++ b/src/server/services/card/card.service.ts
@@ -0,0 +1,286 @@
+import { singleton } from 'tsyringe';
+import { config } from '@utils/server-config';
+import { mainLogger } from '../../sv_logger';
+import { UserService } from '../user/user.service';
+import { Request } from '@typings/http';
+import { CardDB } from './card.db';
+import { sequelize } from '@server/utils/pool';
+import { AccountService } from '../account/account.service';
+import {
+ AuthorizationErrors,
+ BalanceErrors,
+ CardErrors,
+ GenericErrors,
+ UserErrors,
+} from '@server/../../typings/Errors';
+import i18next from '@utils/i18n';
+import {
+ BlockCardInput,
+ Card,
+ CreateCardInput,
+ InventoryCard,
+ UpdateCardPinInput,
+} from '@server/../../typings/BankCard';
+import { AccountDB } from '../account/account.db';
+import { PIN_CODE_LENGTH } from '@shared/constants';
+import { GetATMAccountResponse, GetATMAccountInput } from '@server/../../typings/Account';
+import { CardEvents } from '@server/../../typings/Events';
+import { getFrameworkExports } from '@server/utils/frameworkIntegration';
+
+const logger = mainLogger.child({ module: 'card' });
+const isFrameworkIntegrationEnabled = config?.frameworkIntegration?.enabled;
+
+@singleton()
+export class CardService {
+ cardDB: CardDB;
+ accountDB: AccountDB;
+ userService: UserService;
+ accountService: AccountService;
+
+ constructor(
+ CardDB: CardDB,
+ userService: UserService,
+ accountService: AccountService,
+ accountDB: AccountDB,
+ ) {
+ this.cardDB = CardDB;
+ this.accountDB = accountDB;
+ this.userService = userService;
+ this.accountService = accountService;
+ }
+
+ validateCardsConfig() {
+ if (!isFrameworkIntegrationEnabled) {
+ logger.error('Could not give card to player.');
+ throw new Error('Phsyical cards are not available without FrameworkIntegration enabled.');
+ }
+
+ if (!config.frameworkIntegration?.isCardsEnabled) {
+ logger.error('Cards are not enabled in the config.');
+ throw new Error('Cards are not enabled in the config.');
+ }
+ }
+
+ async getCards(req: Request<{ accountId: number }>) {
+ const cards = await this.cardDB.getByAccountId(req.data.accountId);
+ return cards.map((card) => card.toJSON());
+ }
+
+ async getInventoryCards(req: Request): Promise {
+ this.validateCardsConfig();
+
+ const user = this.userService.getUser(req.source);
+
+ if (!user) {
+ throw new Error(UserErrors.NotFound);
+ }
+
+ const exports = getFrameworkExports();
+ return exports.getCards(user.getSource());
+ }
+
+ async getAccountByCard(req: Request): Promise {
+ this.validateCardsConfig();
+
+ logger.silly('Getting account by card.');
+
+ const { cardId, pin } = req.data;
+ const card = await this.cardDB.getById(cardId);
+
+ if (!card) {
+ logger.error('Card not found');
+ throw new Error(GenericErrors.NotFound);
+ }
+
+ if (card.getDataValue('isBlocked')) {
+ logger.error('The card is blocked');
+ throw new Error(CardErrors.Blocked);
+ }
+
+ if (pin !== card.getDataValue('pin')) {
+ logger.error('Invalid pin');
+ throw new Error(CardErrors.InvalidPin);
+ }
+
+ const account = await this.accountDB.getAccountById(card.getDataValue('accountId') ?? -1);
+
+ if (!account) {
+ logger.error('Card is not bound to any account');
+ throw new Error(GenericErrors.NotFound);
+ }
+
+ logger.error('Returning account!');
+ return { account: account?.toJSON(), card: card.toJSON() };
+ }
+
+ async giveCard(src: number, card: Card) {
+ this.validateCardsConfig();
+
+ const exports = getFrameworkExports();
+ return exports.giveCard(src, card);
+ }
+
+ async blockCard(req: Request) {
+ this.validateCardsConfig();
+ logger.silly('Blocking card ..');
+ logger.silly(req.data);
+
+ const user = this.userService.getUser(req.source);
+ const { cardId } = req.data;
+
+ const t = await sequelize.transaction();
+ const card = await this.cardDB.getById(cardId, t);
+
+ if (card?.getDataValue('holderCitizenId') !== user.getIdentifier()) {
+ throw new Error(AuthorizationErrors.Forbidden);
+ }
+
+ if (!card) {
+ throw new Error(GenericErrors.NotFound);
+ }
+
+ try {
+ await card.update({ isBlocked: true });
+ t.commit();
+ logger.silly('Blocked card.');
+ return true;
+ } catch (error: unknown) {
+ t.rollback();
+ logger.error(error);
+ return false;
+ }
+ }
+
+ async deleteCard(req: Request) {
+ this.validateCardsConfig();
+ logger.silly('Deleting card ..');
+ logger.silly(req.data);
+ const user = this.userService.getUser(req.source);
+ const { cardId } = req.data;
+
+ const t = await sequelize.transaction();
+ const card = await this.cardDB.getById(cardId, t);
+
+ if (card?.getDataValue('holderCitizenId') !== user.getIdentifier()) {
+ throw new Error(AuthorizationErrors.Forbidden);
+ }
+
+ if (!card) {
+ throw new Error(GenericErrors.NotFound);
+ }
+
+ try {
+ await card.destroy();
+ t.commit();
+ logger.silly('Deleted card.');
+ return true;
+ } catch (error: unknown) {
+ t.rollback();
+ logger.error(error);
+ return false;
+ }
+ }
+
+ async updateCardPin(req: Request): Promise {
+ this.validateCardsConfig();
+ logger.silly('Updating pin for card ..');
+ logger.silly(req.data);
+
+ const user = this.userService.getUser(req.source);
+ const { cardId, newPin } = req.data;
+
+ const t = await sequelize.transaction();
+ const card = await this.cardDB.getById(cardId, t);
+
+ if (card?.getDataValue('holderCitizenId') !== user.getIdentifier()) {
+ throw new Error(AuthorizationErrors.Forbidden);
+ }
+
+ if (!card) {
+ throw new Error(GenericErrors.NotFound);
+ }
+
+ try {
+ await card.update({ pin: newPin }, { transaction: t });
+ t.commit();
+ logger.silly('Updated pin.');
+ return true;
+ } catch (error) {
+ logger.error(error);
+ t.rollback();
+ return false;
+ }
+ }
+
+ async orderPersonalCard(req: Request): Promise {
+ this.validateCardsConfig();
+ logger.silly('Ordering new card ..');
+ logger.silly(req.data);
+
+ const user = this.userService.getUser(req.source);
+ const { accountId, paymentAccountId, pin } = req.data;
+
+ const newCardCost = config.cards?.cost;
+
+ if (!newCardCost) {
+ logger.error('Missing "cards.cost" in config.json');
+ throw new Error(CardErrors.MissingConfigCost);
+ }
+
+ if (pin.toString().length !== PIN_CODE_LENGTH) {
+ logger.error('Pin is wrong length, should be: ' + PIN_CODE_LENGTH);
+ throw new Error(GenericErrors.BadInput);
+ }
+
+ const t = await sequelize.transaction();
+ try {
+ const account = await this.accountService.getAuthorizedAccount(req.source, accountId);
+ const paymentAccount = await this.accountService.getAuthorizedAccount(
+ req.source,
+ paymentAccountId,
+ );
+
+ if (!account || !paymentAccount) {
+ throw new Error(GenericErrors.NotFound);
+ }
+
+ const card = await this.cardDB.create(
+ {
+ pin: pin,
+ holder: user.name,
+ holderCitizenId: user.getIdentifier(),
+ accountId: account.getDataValue('id'),
+ },
+ t,
+ );
+
+ if (paymentAccount.getDataValue('balance') < newCardCost) {
+ throw new Error(BalanceErrors.InsufficentFunds);
+ }
+
+ this.accountService.removeMoneyByAccountNumber({
+ ...req,
+ data: {
+ amount: newCardCost,
+ message: i18next.t('Ordered new card'),
+ accountNumber: paymentAccount.getDataValue('number'),
+ },
+ });
+
+ t.afterCommit(() => {
+ logger.silly(`Emitting ${CardEvents.NewCard}`);
+ emit(CardEvents.NewCard, { ...card.toJSON() });
+ });
+
+ this.giveCard(req.source, card.toJSON());
+
+ t.commit();
+ logger.silly('Ordered new card.');
+ return card.toJSON();
+ } catch (error: unknown) {
+ logger.error(error);
+ t.rollback();
+ throw new Error(i18next.t('Failed to create new account'));
+ }
+ }
+}
diff --git a/src/server/services/controllers.ts b/src/server/services/controllers.ts
index 70a2c640..c5484128 100644
--- a/src/server/services/controllers.ts
+++ b/src/server/services/controllers.ts
@@ -5,3 +5,4 @@ import './invoice/invoice.controller';
import './account/account.controller';
import './transaction/transaction.controller';
import './broadcast/broadcast.controller';
+import './card/card.controller';
diff --git a/src/server/services/transaction/transaction.service.ts b/src/server/services/transaction/transaction.service.ts
index 0e99e236..0f3b9a9d 100644
--- a/src/server/services/transaction/transaction.service.ts
+++ b/src/server/services/transaction/transaction.service.ts
@@ -54,12 +54,18 @@ export class TransactionService {
const user = this._userService.getUser(req.source);
const accounts = await this._accountDB.getAccountsByIdentifier(user.getIdentifier());
+ const sharedAccounts = await this._sharedAccountDB.getSharedAccountsByIdentifier(
+ user.getIdentifier(),
+ );
+ const sharedAccountIds = sharedAccounts.map(
+ (account) => account.getDataValue('accountId') ?? 0,
+ );
const accountIds = accounts.map((account) => account.getDataValue('id') ?? 0);
const transactions = await this._transactionDB.getTransactionFromAccounts({
...req.data,
- accountIds,
+ accountIds: [...sharedAccountIds, ...accountIds],
});
const total = await this._transactionDB.getTotalTransactionsFromAccounts(accountIds);
diff --git a/src/server/services/user/user.controller.ts b/src/server/services/user/user.controller.ts
index bb3a0668..cfd05f48 100644
--- a/src/server/services/user/user.controller.ts
+++ b/src/server/services/user/user.controller.ts
@@ -1,5 +1,5 @@
import { Controller } from '@decorators/Controller';
-import { EventListener, Event } from '@decorators/Event';
+import { EventListener, Event, NetEvent } from '@decorators/Event';
import { NetPromise, PromiseEventListener } from '@decorators/NetPromise';
import { ServerExports } from '@server/../../typings/exports/server';
import { Export, ExportListener } from '@server/decorators/Export';
@@ -48,6 +48,12 @@ export class UserController {
res({ status: 'ok', data: list });
}
+ @NetEvent(UserEvents.LoadClient)
+ async loadClient() {
+ const src = source;
+ this._userService.loadClient(src);
+ }
+
@Event('playerJoining')
playerJoining() {
if (config.frameworkIntegration?.enabled) return;
diff --git a/src/server/services/user/user.module.ts b/src/server/services/user/user.module.ts
index 4165dddd..2dc6df30 100644
--- a/src/server/services/user/user.module.ts
+++ b/src/server/services/user/user.module.ts
@@ -4,6 +4,7 @@ export class UserModule {
private readonly _source: number;
private readonly _identifier: string;
public readonly name: string;
+ public isClientLoaded: boolean;
constructor(user: OnlineUser) {
this._source = user.source;
@@ -18,4 +19,8 @@ export class UserModule {
getIdentifier() {
return this._identifier;
}
+
+ loadClient() {
+ this.isClientLoaded = true;
+ }
}
diff --git a/src/server/services/user/user.service.ts b/src/server/services/user/user.service.ts
index 56969f95..c52513da 100644
--- a/src/server/services/user/user.service.ts
+++ b/src/server/services/user/user.service.ts
@@ -6,16 +6,25 @@ import { OnlineUser, UserDTO } from '../../../../typings/user';
import { getPlayerIdentifier, getPlayerName } from '../../utils/misc';
import { UserModule } from './user.module';
import { UserEvents } from '@server/../../typings/Events';
+import { CHECK_PLAYER_LOADED_INTERVAL } from '@shared/constants';
const logger = mainLogger.child({ module: 'user' });
@singleton()
export class UserService {
private readonly usersBySource: Map; // Player class
+ private loadedSources: number[];
+
constructor() {
+ this.loadedSources = [];
this.usersBySource = new Map();
}
+ loadClient(source: number) {
+ logger.debug('Loaded client for source: ' + source);
+ this.loadedSources.push(source);
+ }
+
getAllUsers() {
return this.usersBySource;
}
@@ -55,12 +64,8 @@ export class UserService {
const user = new UserModule(data);
this.usersBySource.set(user.getSource(), user);
- logger.debug(`Player loaded. Emitting: ${UserEvents.Loaded}`);
-
- setImmediate(() => {
- emit(UserEvents.Loaded, data);
- emitNet(UserEvents.Loaded, data.source, data);
- });
+ logger.debug('Player loaded on server.');
+ this.emitLoadedPlayer(user, data);
}
async unloadPlayer(source: number) {
@@ -86,4 +91,16 @@ export class UserService {
return this.loadPlayer(user);
}
+
+ emitLoadedPlayer(user: UserModule, data: OnlineUser) {
+ const interval = setInterval(() => {
+ const isLoaded = this.loadedSources.includes(user.getSource());
+ if (isLoaded) {
+ logger.debug(`Player loaded on client. Emitting: ${UserEvents.Loaded}`);
+ emit(UserEvents.Loaded, data);
+ emitNet(UserEvents.Loaded, data.source, data);
+ clearInterval(interval);
+ }
+ }, CHECK_PLAYER_LOADED_INTERVAL);
+ }
}
diff --git a/src/server/utils/__tests__/misc.test.ts b/src/server/utils/__tests__/misc.test.ts
index 7df4f059..04c96513 100644
--- a/src/server/utils/__tests__/misc.test.ts
+++ b/src/server/utils/__tests__/misc.test.ts
@@ -1,5 +1,5 @@
import { DEFAULT_CLEARING_NUMBER } from '@utils/constants';
-import { generateAccountNumber, getClearingNumber } from '@utils/misc';
+import { generateClearingNumber, getClearingNumber } from '@utils/misc';
import { createMockedConfig } from '@utils/test';
import { regexExternalNumber } from '@shared/utils/regexes';
@@ -49,7 +49,7 @@ describe('Helper: getClearingNumber', () => {
describe('Helper: generateAccountNumber', () => {
test('should pass regex test', () => {
for (let i = 0; i < 100; i++) {
- const accountNumber = generateAccountNumber();
+ const accountNumber = generateClearingNumber();
expect(regexExternalNumber.test(accountNumber)).toBe(true);
}
});
diff --git a/src/server/utils/frameworkIntegration.ts b/src/server/utils/frameworkIntegration.ts
index f72406d2..6135954f 100644
--- a/src/server/utils/frameworkIntegration.ts
+++ b/src/server/utils/frameworkIntegration.ts
@@ -6,7 +6,7 @@ import { mainLogger } from '@server/sv_logger';
import { getExports } from './misc';
import { config } from './server-config';
-const log = mainLogger.child({ module: 'frameworkIntegration' });
+const logger = mainLogger.child({ module: 'frameworkIntegration' });
const frameworkIntegrationKeys: FrameworkIntegrationFunction[] = [
'addCash',
@@ -15,17 +15,23 @@ const frameworkIntegrationKeys: FrameworkIntegrationFunction[] = [
'getBank',
];
+if (config?.frameworkIntegration?.isCardsEnabled) {
+ frameworkIntegrationKeys.push('giveCard');
+ frameworkIntegrationKeys.push('getCards');
+}
+
export const validateResourceExports = (resourceExports: FrameworkIntegrationExports): boolean => {
let isValid = true;
frameworkIntegrationKeys.forEach((key: FrameworkIntegrationFunction) => {
+ logger.silly(`Verifying export: ${key}`);
if (typeof resourceExports[key] === 'undefined') {
- log.error(`Framework integration export ${key} is missing.`);
+ logger.error(`Framework integration export ${key} is missing.`);
isValid = false;
return;
}
if (typeof resourceExports[key] !== 'function') {
- log.error(`Framework integration export ${key} is not a function.`);
+ logger.error(`Framework integration export ${key} is not a function.`);
isValid = false;
}
});
@@ -38,15 +44,15 @@ export const getFrameworkExports = (): FrameworkIntegrationExports => {
const resourceName = config?.frameworkIntegration?.resource;
const resourceExports: FrameworkIntegrationExports = exps[resourceName ?? ''];
- log.debug(`Checking exports from resource: ${resourceName}`);
+ logger.debug(`Checking exports from resource: ${resourceName}`);
if (!resourceName) {
- log.error(`Missing resourceName in the config for framework integration`);
+ logger.error(`Missing resourceName in the config for framework integration`);
throw new Error('Framework integration failed');
}
if (!resourceExports) {
- log.error(
+ logger.error(
`No resource found with name: ${resourceName}. Make sure you have the correct resource name in the config.`,
);
throw new Error('Framework integration failed');
diff --git a/src/server/utils/misc.ts b/src/server/utils/misc.ts
index ce55c696..c39c5f7c 100644
--- a/src/server/utils/misc.ts
+++ b/src/server/utils/misc.ts
@@ -45,7 +45,7 @@ export const getClearingNumber = (initialConfig = config): string => {
return confValue;
};
-export const generateAccountNumber = (clearingNumber = getClearingNumber()): string => {
+export const generateClearingNumber = (clearingNumber = getClearingNumber()): string => {
const initialNumber = clearingNumber;
let uuid = `${initialNumber},`;
@@ -67,6 +67,26 @@ export const generateAccountNumber = (clearingNumber = getClearingNumber()): str
return uuid;
};
+export const generateCardNumber = (): string => {
+ let uuid = `5160 `;
+ for (let i = 0; i < 12; i++) {
+ switch (i) {
+ case 8:
+ uuid += ' ';
+ uuid += ((Math.random() * 4) | 0).toString();
+ break;
+ case 4:
+ uuid += ' ';
+ uuid += ((Math.random() * 4) | 0).toString();
+ break;
+ default:
+ uuid += ((Math.random() * 9) | 0).toString(10);
+ }
+ }
+
+ return uuid;
+};
+
// Credits to d0p3t
// https://github.com/d0p3t/fivem-js/blob/master/src/utils/UUIDV4.ts
export const uuidv4 = (): string => {
diff --git a/typings/Account.ts b/typings/Account.ts
index 9ebc3382..8b48bbeb 100644
--- a/typings/Account.ts
+++ b/typings/Account.ts
@@ -1,3 +1,5 @@
+import { Card } from './BankCard';
+
export enum AccountType {
Personal = 'personal',
Shared = 'shared',
@@ -40,6 +42,15 @@ export interface Account {
createdAt?: string;
}
+export interface GetATMAccountInput {
+ pin: number;
+ cardId: number;
+}
+
+export interface GetATMAccountResponse {
+ card: Card;
+ account: Account;
+}
export interface CreateAccountInput {
accountName: string;
ownerIdentifier: string;
@@ -96,6 +107,8 @@ export interface ATMInput {
amount: number;
message: string;
accountId?: number;
+ cardId?: number;
+ cardPin?: number;
}
export interface ExternalAccount {
diff --git a/typings/BankCard.ts b/typings/BankCard.ts
new file mode 100644
index 00000000..f6c32de4
--- /dev/null
+++ b/typings/BankCard.ts
@@ -0,0 +1,42 @@
+import { Account } from './Account';
+
+export interface Card {
+ // Dynamic
+ id: number;
+ account?: Account;
+ accountId?: number;
+
+ pin: number;
+ isBlocked: boolean;
+
+ // Static
+ holder: string;
+ holderCitizenId: string;
+ number: string;
+
+ // Timestamps
+ updatedAt?: string | number | Date;
+ createdAt?: string | number | Date;
+}
+
+export type InventoryCard = Pick;
+
+export interface GetCardInput {
+ accountId: number;
+}
+export interface CreateCardInput {
+ pin: number;
+ accountId: number;
+ paymentAccountId: number;
+}
+
+export interface BlockCardInput {
+ cardId: number;
+}
+
+export type DeleteCardInput = BlockCardInput;
+
+export interface UpdateCardPinInput {
+ cardId: number;
+ newPin: number;
+}
diff --git a/typings/Errors.ts b/typings/Errors.ts
index f9a9314c..9ef61c3e 100644
--- a/typings/Errors.ts
+++ b/typings/Errors.ts
@@ -16,6 +16,13 @@ export enum AccountErrors {
SameAccount = 'SameAccount',
}
+export enum CardErrors {
+ MissingConfigCost = 'MissingConfigCost',
+ FailedToCreate = 'FailedToCreate',
+ InvalidPin = 'InvalidPin',
+ Blocked = 'Blocked',
+}
+
export enum UserErrors {
NotFound = 'UserNotFound',
}
diff --git a/typings/Events.ts b/typings/Events.ts
index 4893f011..52dbb9ea 100644
--- a/typings/Events.ts
+++ b/typings/Events.ts
@@ -8,14 +8,18 @@ export enum UserEvents {
GetUsers = 'pefcl:userEventsGetUsers',
Loaded = 'pefcl:userLoaded',
Unloaded = 'pefcl:userUnloaded',
+ LoadClient = 'pefcl:loadClient',
}
export enum NUIEvents {
Loaded = 'pefcl:nuiHasLoaded',
Unloaded = 'pefcl:nuiHasUnloaded',
+ SetCardId = 'pefcl:nuiSetCardId',
+ SetCards = 'pefcl:nuiSetCards',
}
export enum AccountEvents {
+ GetAtmAccount = 'pefcl:getAtmAccount',
GetAccounts = 'pefcl:getAccounts',
CreateAccount = 'pefcl:createAccount',
RenameAccount = 'pefcl:renameAccount',
@@ -51,6 +55,7 @@ export enum Broadcasts {
NewAccountBalance = 'pefcl:newAccountBalanceBroadcast',
NewDefaultAccountBalance = 'pefcl:newDefaultAccountBalance',
NewCashAmount = 'pefcl:newCashAmount',
+ NewCard = 'pefcl:newCardBroadcast',
}
export enum TransactionEvents {
@@ -77,3 +82,14 @@ export enum CashEvents {
export enum BalanceEvents {
UpdateCashBalance = 'pefcl:updateCashBalance',
}
+
+export enum CardEvents {
+ Get = 'pefcl:getCards',
+ OrderShared = 'pefcl:orderSharedCard',
+ OrderPersonal = 'pefcl:orderPersonalCard',
+ Block = 'pefcl:blockCard',
+ Delete = 'pefcl:deleteCard',
+ UpdatePin = 'pefcl:updatePin',
+ NewCard = 'pefcl:newCard',
+ GetInventoryCards = 'pefcl:getInventoryCards',
+}
diff --git a/typings/config.ts b/typings/config.ts
index e6f8300e..6b1d87ad 100644
--- a/typings/config.ts
+++ b/typings/config.ts
@@ -34,6 +34,7 @@ export interface ResourceConfig {
enabled: boolean;
resource: string;
syncInitialBankBalance: boolean;
+ isCardsEnabled: boolean;
};
database: {
profileQueries: boolean;
@@ -48,6 +49,10 @@ export interface ResourceConfig {
clearingNumber: string | number;
maximumNumberOfAccounts: number;
};
+ cards: {
+ cost: number;
+ maxCardsPerAccount: number;
+ };
cash: {
startAmount: number;
};
diff --git a/typings/exports.ts b/typings/exports.ts
index 7004521a..5c236917 100644
--- a/typings/exports.ts
+++ b/typings/exports.ts
@@ -1,6 +1,6 @@
/* Exports used with framework integrations */
-import { OnlineUser } from './user';
+import { Card, InventoryCard } from './BankCard';
export interface FrameworkIntegrationExports {
/* Cash exports */
@@ -15,6 +15,8 @@ export interface FrameworkIntegrationExports {
* This export should probably remove old bank balance as well.
*/
getBank: (source: number) => number;
+ giveCard: (source: number, card: Card) => void;
+ getCards: (source: number) => InventoryCard[];
}
export type FrameworkIntegrationFunction = keyof FrameworkIntegrationExports;
diff --git a/web/src/App.tsx b/web/src/App.tsx
index b0981f74..7f07b006 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -1,27 +1,31 @@
+import Devbar from '@components/DebugBar';
+import { accountsAtom, rawAccountAtom } from '@data/accounts';
+import { transactionBaseAtom, transactionInitialState } from '@data/transactions';
import styled from '@emotion/styled';
+import { BroadcastsWrapper } from '@hooks/useBroadcasts';
import { useExitListener } from '@hooks/useExitListener';
+import { useNuiEvent } from '@hooks/useNuiEvent';
+import { GeneralEvents, NUIEvents, UserEvents } from '@typings/Events';
+import { fetchNui } from '@utils/fetchNui';
import dayjs from 'dayjs';
-import updateLocale from 'dayjs/plugin/updateLocale';
import 'dayjs/locale/sv';
+import updateLocale from 'dayjs/plugin/updateLocale';
+import { useSetAtom } from 'jotai';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Route } from 'react-router-dom';
import './App.css';
import { useConfig } from './hooks/useConfig';
import theme from './utils/theme';
-import Accounts from './views/accounts/Accounts';
-import Dashboard from './views/dashboard/Dashboard';
-import Invoices from './views/Invoices/Invoices';
import ATM from './views/ATM/ATM';
-import { BroadcastsWrapper } from '@hooks/useBroadcasts';
-import Transfer from './views/transfer/Transfer';
-import Transactions from './views/transactions/Transactions';
-import Devbar from '@components/DebugBar';
-import { NUIEvents, UserEvents } from '@typings/Events';
import Deposit from './views/Deposit/Deposit';
-import { fetchNui } from '@utils/fetchNui';
+import Invoices from './views/Invoices/Invoices';
import Withdraw from './views/Withdraw/Withdraw';
-import { useNuiEvent } from '@hooks/useNuiEvent';
+import Accounts from './views/accounts/Accounts';
+import Dashboard from './views/dashboard/Dashboard';
+import Transactions from './views/transactions/Transactions';
+import Transfer from './views/transfer/Transfer';
+import CardsView from './views/Cards/CardsView';
dayjs.extend(updateLocale);
@@ -46,12 +50,22 @@ const Content = styled.div`
const App: React.FC = () => {
const config = useConfig();
+ const setRawAccounts = useSetAtom(rawAccountAtom);
+ const setAccounts = useSetAtom(accountsAtom);
+ const setTransactions = useSetAtom(transactionBaseAtom);
const [hasLoaded, setHasLoaded] = useState(process.env.NODE_ENV === 'development');
const [isAtmVisible, setIsAtmVisible] = useState(false);
const [isVisible, setIsVisible] = useState(false);
useNuiEvent('PEFCL', UserEvents.Loaded, () => setHasLoaded(true));
- useNuiEvent('PEFCL', UserEvents.Unloaded, () => setHasLoaded(false));
+ useNuiEvent('PEFCL', UserEvents.Unloaded, () => {
+ setHasLoaded(false);
+ setAccounts([]);
+ setRawAccounts([]);
+ setTransactions();
+ fetchNui(GeneralEvents.CloseUI);
+ setTransactions(transactionInitialState);
+ });
useEffect(() => {
fetchNui(NUIEvents.Loaded);
@@ -64,7 +78,7 @@ const App: React.FC = () => {
useNuiEvent('PEFCL', 'setVisibleATM', (data) => setIsAtmVisible(data));
const { i18n } = useTranslation();
- useExitListener();
+ useExitListener(isVisible);
useEffect(() => {
i18n.changeLanguage(config?.general?.language).catch((e) => console.error(e));
@@ -93,6 +107,7 @@ const App: React.FC = () => {
+
)}
diff --git a/web/src/components/Card.tsx b/web/src/components/AccountCard.tsx
similarity index 80%
rename from web/src/components/Card.tsx
rename to web/src/components/AccountCard.tsx
index d91efafc..18f95c0c 100644
--- a/web/src/components/Card.tsx
+++ b/web/src/components/AccountCard.tsx
@@ -12,7 +12,12 @@ import { IconButton, Skeleton, Stack } from '@mui/material';
import { ContentCopyRounded } from '@mui/icons-material';
import copy from 'copy-to-clipboard';
-const Container = styled.div<{ accountType: AccountType; selected: boolean }>`
+interface ContainerProps {
+ isDisabled: boolean;
+ accountType: AccountType;
+ selected: boolean;
+}
+const Container = styled.div`
user-select: none;
width: 100%;
padding: 1rem;
@@ -40,8 +45,14 @@ const Container = styled.div<{ accountType: AccountType; selected: boolean }>`
${({ selected }) =>
selected &&
`
- border: 2px solid ${theme.palette.background.light8};
+ border: 2px solid ${theme.palette.primary.light};
`};
+
+ ${({ isDisabled }) =>
+ isDisabled &&
+ `
+ opacity: 0.5;
+ `}
`;
const Row = styled.div`
@@ -77,15 +88,23 @@ const DefaultText = styled(Heading6)`
type AccountCardProps = {
account: Account;
selected?: boolean;
+ withCopy?: boolean;
+ isDisabled?: boolean;
};
-export const AccountCard = ({ account, selected = false, ...props }: AccountCardProps) => {
+export const AccountCard = ({
+ account,
+ selected = false,
+ withCopy = false,
+ isDisabled = false,
+ ...props
+}: AccountCardProps) => {
const { type, id, balance, isDefault, accountName, number } = account;
const { t } = useTranslation();
const config = useConfig();
return (
-
+
{formatMoney(balance, config.general)}
@@ -96,14 +115,16 @@ export const AccountCard = ({ account, selected = false, ...props }: AccountCard
{number}
- copy(number)}
- size="small"
- color="inherit"
- style={{ opacity: '0.45', marginTop: 0, marginLeft: '0.25rem' }}
- >
-
-
+ {withCopy && (
+ copy(number)}
+ size="small"
+ color="inherit"
+ style={{ opacity: '0.45', marginTop: 0, marginLeft: '0.25rem' }}
+ >
+
+
+ )}
@@ -111,8 +132,6 @@ export const AccountCard = ({ account, selected = false, ...props }: AccountCard
{t('Account name')}
{accountName}
-
-
);
@@ -120,7 +139,7 @@ export const AccountCard = ({ account, selected = false, ...props }: AccountCard
export const LoadingAccountCard = () => {
return (
-
+
diff --git a/web/src/components/BankCard.tsx b/web/src/components/BankCard.tsx
new file mode 100644
index 00000000..a4cc97ad
--- /dev/null
+++ b/web/src/components/BankCard.tsx
@@ -0,0 +1,68 @@
+import { Stack } from '@mui/material';
+import { Card, InventoryCard } from '@typings/BankCard';
+import theme from '@utils/theme';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { MasterCardIcon } from 'src/icons/MasterCardIcon';
+import styled from 'styled-components';
+import { BodyText } from './ui/Typography/BodyText';
+import { Heading4, Heading6 } from './ui/Typography/Headings';
+
+const Container = styled.div<{ selected: boolean; blocked: boolean }>`
+ user-select: none;
+ width: 100%;
+ padding: 1rem;
+ background: ${({ blocked }) =>
+ blocked
+ ? 'linear-gradient(90deg, #bcbcbc 0%, #b0b0b0 100%)'
+ : 'linear-gradient(90deg, #fc5f02 0%, #f43200 100%)'};
+
+ min-height: 7rem;
+ width: auto;
+ border-radius: ${theme.spacing(1)};
+
+ cursor: pointer;
+ transition: 250ms;
+ box-shadow: ${theme.shadows[4]};
+
+ :hover {
+ box-shadow: ${theme.shadows[6]};
+ }
+
+ transition: 200ms ease-in-out;
+ border: 2px solid transparent;
+
+ ${({ selected }) => selected && `border: 2px solid ${theme.palette.text.primary}`}
+`;
+
+const StyledIcon = styled(MasterCardIcon)`
+ color: rgba(255, 255, 255, 0.54);
+ align-self: flex-end;
+`;
+
+interface BankCardProps {
+ card: Card | InventoryCard;
+ isBlocked?: boolean;
+ selected?: boolean;
+}
+const BankCard = ({ card, selected = false, isBlocked = false }: BankCardProps) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {card.number}
+
+
+ {t('Card holder')}
+ {card.holder}
+
+
+
+
+
+
+ );
+};
+
+export default BankCard;
diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx
index 624fa809..c4db56a0 100644
--- a/web/src/components/Layout.tsx
+++ b/web/src/components/Layout.tsx
@@ -14,6 +14,7 @@ const Container = styled.div`
`;
const Content = styled(motion.div)`
+ position: relative;
padding: 2rem;
flex: 1;
height: 100%;
diff --git a/web/src/components/Modals/BaseDialog.tsx b/web/src/components/Modals/BaseDialog.tsx
index c48cf590..ada35818 100644
--- a/web/src/components/Modals/BaseDialog.tsx
+++ b/web/src/components/Modals/BaseDialog.tsx
@@ -1,5 +1,5 @@
import { useGlobalSettings } from '@hooks/useGlobalSettings';
-import { Dialog, DialogProps } from '@mui/material';
+import { Backdrop, Dialog, DialogProps } from '@mui/material';
import React, { ReactNode } from 'react';
interface BaseDialogProps extends DialogProps {
diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx
index 3f4c5c94..ec8937c0 100644
--- a/web/src/components/Sidebar.tsx
+++ b/web/src/components/Sidebar.tsx
@@ -1,5 +1,6 @@
import React, { ReactNode } from 'react';
import {
+ AccountBalanceRounded,
Add,
CreditCardRounded,
DashboardRounded,
@@ -16,6 +17,7 @@ import { useTranslation } from 'react-i18next';
import { Atom } from 'jotai';
import { totalUnpaidInvoicesAtom } from '@data/invoices';
import BadgeAtom from './ui/BadgeAtom';
+import { useConfig } from '@hooks/useConfig';
const List = styled.ul`
margin: 0;
@@ -96,11 +98,12 @@ const ListItem = ({ to, icon, label, amount, countAtom }: ListItemProps) => {
const Sidebar = () => {
const { t } = useTranslation();
+ const config = useConfig();
return (
} label={t('Dashboard')} />
- } label={t('Accounts')} />
+ } label={t('Accounts')} />
} label={t('Transfer')} />
} label={t('Transactions')} />
{
/>
} label={t('Deposit')} />
} label={t('Withdraw')} />
+
+ {config.frameworkIntegration.isCardsEnabled && (
+ } label={t('Cards')} />
+ )}
);
};
diff --git a/web/src/components/ui/Count.tsx b/web/src/components/ui/Count.tsx
index 02f4ed8d..f6d85106 100644
--- a/web/src/components/ui/Count.tsx
+++ b/web/src/components/ui/Count.tsx
@@ -2,7 +2,7 @@ import styled from '@emotion/styled';
import theme from '@utils/theme';
import React from 'react';
-const Total = styled.div`
+const Total = styled.div<{ focus: boolean }>`
display: flex;
justify-content: center;
align-items: center;
@@ -14,15 +14,22 @@ const Total = styled.div`
border-radius: ${theme.spacing(1)};
font-weight: ${theme.typography.fontWeightBold};
background-color: ${theme.palette.background.light4};
+
+ ${({ focus }) =>
+ focus &&
+ `
+ background-color: ${theme.palette.background.light8};
+ `}
`;
interface CountProps extends React.HTMLAttributes {
amount: string | number;
+ focus?: boolean;
}
-const Count = ({ amount, ...props }: CountProps) => {
+const Count = ({ amount, focus = false, ...props }: CountProps) => {
return (
- {amount}
+ {amount}
);
};
diff --git a/web/src/components/ui/Fields/PinField.tsx b/web/src/components/ui/Fields/PinField.tsx
new file mode 100644
index 00000000..b16ca2b9
--- /dev/null
+++ b/web/src/components/ui/Fields/PinField.tsx
@@ -0,0 +1,71 @@
+import styled from '@emotion/styled';
+import { InputBaseProps, Stack, Typography } from '@mui/material';
+import { PIN_CODE_LENGTH } from '@shared/constants';
+import React, { ChangeEvent, useState } from 'react';
+import Count from '../Count';
+
+const Container = styled.div`
+ position: relative;
+ display: grid;
+ grid-template-columns: ${`repeat(${PIN_CODE_LENGTH}, 1fr)`};
+ grid-column-gap: 0.5rem;
+ width: ${`calc(${PIN_CODE_LENGTH} * 3rem)`};
+`;
+
+const InputField = styled.input`
+ position: absolute;
+ opacity: 0;
+ width: 100%;
+ height: 100%;
+`;
+
+interface PinFieldProps {
+ label?: string;
+ value: string;
+ onChange: InputBaseProps['onChange'];
+}
+
+const PinField = ({ onChange, value, label }: PinFieldProps) => {
+ const [hasFocus, setHasFocus] = useState(false);
+
+ const handleChange = (event: ChangeEvent) => {
+ const newValue = event.target.value;
+ const newLength = newValue.length;
+
+ if (newValue && isNaN(parseInt(newValue, 10))) {
+ return;
+ }
+
+ if (newLength > PIN_CODE_LENGTH && value.length < newLength) {
+ return;
+ }
+
+ onChange?.(event);
+ };
+
+ const codeLen = new Array(PIN_CODE_LENGTH).fill('');
+
+ return (
+
+ {label && (
+
+ {label}
+
+ )}
+
+ setHasFocus(false)}
+ onFocus={() => setHasFocus(true)}
+ />
+
+ {codeLen.map((_val, index) => (
+
+ ))}
+
+
+ );
+};
+
+export default PinField;
diff --git a/web/src/data/cards.ts b/web/src/data/cards.ts
new file mode 100644
index 00000000..d8abd71b
--- /dev/null
+++ b/web/src/data/cards.ts
@@ -0,0 +1,80 @@
+import { Card, GetCardInput } from '@typings/BankCard';
+import { CardEvents } from '@typings/Events';
+import { mockedAccounts } from '@utils/constants';
+import { fetchNui } from '@utils/fetchNui';
+import { isEnvBrowser } from '@utils/misc';
+import { atom } from 'jotai';
+import { atomWithStorage } from 'jotai/utils';
+
+const mockedCards: Card[] = [
+ {
+ id: 1,
+ account: mockedAccounts[0],
+ isBlocked: false,
+ number: '4242 4220 1234 9000',
+ holder: 'Charles Carlsberg',
+ pin: 1234,
+ holderCitizenId: '1',
+ },
+ {
+ id: 2,
+ account: mockedAccounts[0],
+ isBlocked: false,
+ number: '4242 4220 1234 9002',
+ holder: 'Charles Carlsberg',
+ pin: 1234,
+ holderCitizenId: '2',
+ },
+ {
+ id: 3,
+ account: mockedAccounts[0],
+ isBlocked: false,
+ number: '4242 4220 1234 9003',
+ holder: 'Charles Carlsberg',
+ pin: 1234,
+ holderCitizenId: '3',
+ },
+];
+
+const getCards = async (accountId: number): Promise => {
+ try {
+ const res = await fetchNui(CardEvents.Get, { accountId });
+ return res ?? [];
+ } catch (e) {
+ if (isEnvBrowser()) {
+ return mockedCards;
+ }
+
+ console.error(e);
+ return [];
+ }
+};
+
+export const selectedAccountIdAtom = atom(0);
+
+export const rawCardAtom = atomWithStorage>('rawCards', {});
+export const cardsAtom = atom(
+ async (get) => {
+ const selectedCardId = get(selectedAccountIdAtom);
+ const state = get(rawCardAtom);
+
+ return state[selectedCardId] ?? [];
+ },
+ async (get, set, by: Card | number) => {
+ const selectedCardId = get(selectedAccountIdAtom);
+ const state = get(rawCardAtom);
+
+ if (typeof by === 'number') {
+ const cards = await getCards(by);
+ return set(rawCardAtom, { ...state, [selectedCardId]: cards });
+ }
+
+ if (!by) {
+ const cards = await getCards(selectedCardId);
+ return set(rawCardAtom, { ...state, [selectedCardId]: cards });
+ }
+
+ const cards = state[selectedCardId];
+ return set(rawCardAtom, { ...state, [selectedCardId]: [...cards, by] });
+ },
+);
diff --git a/web/src/data/transactions.ts b/web/src/data/transactions.ts
index f312d670..6f22f8bd 100644
--- a/web/src/data/transactions.ts
+++ b/web/src/data/transactions.ts
@@ -5,7 +5,7 @@ import { mockedTransactions } from '../utils/constants';
import { fetchNui } from '../utils/fetchNui';
import { isEnvBrowser } from '../utils/misc';
-const initialState: GetTransactionsResponse = {
+export const transactionInitialState: GetTransactionsResponse = {
total: 0,
offset: 0,
limit: 10,
@@ -15,26 +15,31 @@ const initialState: GetTransactionsResponse = {
const getTransactions = async (input: GetTransactionsInput): Promise => {
try {
const res = await fetchNui(TransactionEvents.Get, input);
- return res ?? initialState;
+ return res ?? transactionInitialState;
} catch (e) {
if (isEnvBrowser()) {
return mockedTransactions;
}
console.error(e);
- return initialState;
+ return transactionInitialState;
}
};
-export const rawTransactionsAtom = atom(initialState);
+export const rawTransactionsAtom = atom(transactionInitialState);
-export const transactionBaseAtom = atom(
+export const transactionBaseAtom = atom<
+ Promise,
+ GetTransactionsResponse | undefined
+>(
async (get) => {
const hasTransactions = get(rawTransactionsAtom).transactions.length > 0;
- return hasTransactions ? get(rawTransactionsAtom) : await getTransactions({ ...initialState });
+ return hasTransactions
+ ? get(rawTransactionsAtom)
+ : await getTransactions({ ...transactionInitialState });
},
- async (get, set, by: Partial | undefined) => {
+ async (get, set, by?) => {
const currentSettings = get(rawTransactionsAtom);
- return set(rawTransactionsAtom, await getTransactions({ ...currentSettings, ...by }));
+ return set(rawTransactionsAtom, by ?? (await getTransactions({ ...currentSettings })));
},
);
diff --git a/web/src/hooks/useBroadcasts.ts b/web/src/hooks/useBroadcasts.ts
index f77e3238..3cbf828c 100644
--- a/web/src/hooks/useBroadcasts.ts
+++ b/web/src/hooks/useBroadcasts.ts
@@ -1,4 +1,4 @@
-import { accountsAtom } from '@data/accounts';
+import { accountsAtom, rawAccountAtom } from '@data/accounts';
import { invoicesAtom } from '@data/invoices';
import { transactionBaseAtom } from '@data/transactions';
import { Account } from '@typings/Account';
@@ -10,6 +10,7 @@ import { useNuiEvent } from '@hooks/useNuiEvent';
export const useBroadcasts = () => {
const updateInvoices = useSetAtom(invoicesAtom);
const updateTransactions = useSetAtom(transactionBaseAtom);
+ const setRawAccounts = useSetAtom(rawAccountAtom);
const [accounts, updateAccounts] = useAtom(accountsAtom);
useNuiEvent('PEFCL', Broadcasts.NewTransaction, () => {
@@ -17,7 +18,7 @@ export const useBroadcasts = () => {
});
useNuiEvent('PEFCL', Broadcasts.NewAccount, (account: Account) => {
- updateAccounts([...accounts, account]);
+ setRawAccounts([...accounts, account])
});
useNuiEvent('PEFCL', Broadcasts.UpdatedAccount, () => {
@@ -25,7 +26,7 @@ export const useBroadcasts = () => {
});
useNuiEvent('PEFCL', Broadcasts.NewAccountBalance, (account: Account) => {
- updateAccounts(updateAccount(accounts, account));
+ setRawAccounts(updateAccount(accounts, account));
});
useNuiEvent('PEFCL', Broadcasts.NewInvoice, () => {
diff --git a/web/src/hooks/useExitListener.ts b/web/src/hooks/useExitListener.ts
index afe423e7..f3149282 100644
--- a/web/src/hooks/useExitListener.ts
+++ b/web/src/hooks/useExitListener.ts
@@ -5,10 +5,10 @@ import { GeneralEvents } from '@typings/Events';
const LISTENED_KEYS = ['Escape'];
-export const useExitListener = () => {
+export const useExitListener = (enabled: boolean) => {
useEffect(() => {
const keyHandler = (e: KeyboardEvent) => {
- if (LISTENED_KEYS.includes(e.code) && !isEnvBrowser()) {
+ if (LISTENED_KEYS.includes(e.code) && !isEnvBrowser() && enabled) {
fetchNui(GeneralEvents.CloseUI);
}
};
@@ -16,5 +16,5 @@ export const useExitListener = () => {
window.addEventListener('keydown', keyHandler);
return () => window.removeEventListener('keydown', keyHandler);
- }, []);
+ }, [enabled]);
};
diff --git a/web/src/hooks/useKeyPress.ts b/web/src/hooks/useKeyPress.ts
new file mode 100644
index 00000000..acd5254c
--- /dev/null
+++ b/web/src/hooks/useKeyPress.ts
@@ -0,0 +1,15 @@
+import { useEffect } from 'react';
+
+export const useKeyDown = (keys: string[], callback: () => void) => {
+ useEffect(() => {
+ const keyHandler = (e: KeyboardEvent) => {
+ if (keys.includes(e.code)) {
+ callback();
+ }
+ };
+
+ window.addEventListener('keydown', keyHandler);
+
+ return () => window.removeEventListener('keydown', keyHandler);
+ }, [keys, callback]);
+};
diff --git a/web/src/utils/constants.ts b/web/src/utils/constants.ts
index 5d7e4e34..be428c24 100644
--- a/web/src/utils/constants.ts
+++ b/web/src/utils/constants.ts
@@ -1,7 +1,7 @@
import { Account, AccountRole, AccountType } from '@typings/Account';
-import { GetTransactionsResponse, TransactionType } from '../../../typings/Transaction';
-import { Invoice, InvoiceStatus } from '../../../typings/Invoice';
import dayjs from 'dayjs';
+import { Invoice, InvoiceStatus } from '@typings/Invoice';
+import { GetTransactionsResponse, TransactionType } from '@typings/Transaction';
export const resourceDefaultName = 'pefcl';
diff --git a/web/src/views/ATM/ATM.tsx b/web/src/views/ATM/ATM.tsx
index 6ff706a9..3c94a800 100644
--- a/web/src/views/ATM/ATM.tsx
+++ b/web/src/views/ATM/ATM.tsx
@@ -1,21 +1,29 @@
import Button from '@components/ui/Button';
-import { Heading2, Heading6 } from '@components/ui/Typography/Headings';
-import { accountsAtom, defaultAccountBalance } from '@data/accounts';
+import { Heading2, Heading4, Heading6 } from '@components/ui/Typography/Headings';
import styled from '@emotion/styled';
import { useConfig } from '@hooks/useConfig';
-import { Paper, Stack } from '@mui/material';
-import { ATMInput } from '@typings/Account';
-import { AccountEvents } from '@typings/Events';
+import { Alert, Paper, Stack } from '@mui/material';
+import { Account, ATMInput, GetATMAccountInput } from '@typings/Account';
+import { AccountEvents, CardEvents } from '@typings/Events';
import { defaultWithdrawOptions } from '@utils/constants';
import { formatMoney } from '@utils/currency';
import { fetchNui } from '@utils/fetchNui';
import theme from '@utils/theme';
import { AnimatePresence } from 'framer-motion';
-import { useAtom } from 'jotai';
-import React, { useState } from 'react';
+import React, { FormEvent, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { useNuiEvent } from '@hooks/useNuiEvent';
+import { Card, InventoryCard } from '@typings/BankCard';
+import { useKeyDown } from '@hooks/useKeyPress';
+import { CardErrors } from '@typings/Errors';
+import { PIN_CODE_LENGTH } from '@shared/constants';
+import BankCard from '@components/BankCard';
+import { ErrorRounded } from '@mui/icons-material';
+import PinField from '@components/ui/Fields/PinField';
+import { useExitListener } from '@hooks/useExitListener';
+import { useAtomValue } from 'jotai';
+import { defaultAccountAtom } from '@data/accounts';
const AnimationContainer = styled.div`
position: absolute;
@@ -36,7 +44,7 @@ const AccountBalance = styled(Heading6)`
`;
const Header = styled(Stack)`
- margin-bottom: ${theme.spacing(7)};
+ margin-bottom: ${theme.spacing(5)};
`;
const WithdrawText = styled(Heading6)`
@@ -51,60 +59,321 @@ const WithdrawContainer = styled.div`
grid-column-gap: ${theme.spacing(1.5)};
`;
+const CardWrapper = styled.div`
+ min-width: 15rem;
+`;
+
+type BankState = 'select-card' | 'enter-pin' | 'withdraw';
+
const ATM = () => {
const { t } = useTranslation();
const config = useConfig();
- const withdrawOptions = config?.atms?.withdrawOptions ?? defaultWithdrawOptions;
+ const { isCardsEnabled } = config.frameworkIntegration;
+ const defaultAccount = useAtomValue(defaultAccountAtom);
+ const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
- const [accountBalance] = useAtom(defaultAccountBalance);
- const [, updateAccounts] = useAtom(accountsAtom);
-
+ const [account, setAccount] = useState();
const [isOpen, setIsOpen] = useState(false);
useNuiEvent('PEFCL', 'setVisibleATM', (data) => setIsOpen(data));
+ const initialStatus: BankState = isCardsEnabled ? 'select-card' : 'withdraw';
+
+ const [selectedCard, setSelectedCard] = useState();
+ const [cards, setCards] = useState([]);
+ const [state, setState] = useState(initialStatus);
+ const [pin, setPin] = useState('');
+
+ useExitListener(state === 'withdraw' || state === initialStatus);
+
+ const withdrawOptions = config?.atms?.withdrawOptions ?? defaultWithdrawOptions;
+
+ const handleClose = () => {
+ setError('');
+ setPin('');
+ setAccount(undefined);
+ setState(initialStatus);
+ };
+
+ const handleBack = () => {
+ setError('');
+ setPin('');
+ if (state === 'enter-pin') {
+ setState('select-card');
+ }
+ };
- const handleWithdraw = (amount: number) => {
- const payload: ATMInput = {
- amount,
- message: 'Withdrew ' + amount + ' from an ATM.',
+ useKeyDown(['Escape'], handleBack);
+
+ const handleVisibility = (isOpen: boolean) => {
+ setIsOpen(isOpen);
+
+ if (!isOpen) {
+ handleClose();
+ }
+ };
+
+ useEffect(() => {
+ const updateCards = async () => {
+ try {
+ const cards = await fetchNui(CardEvents.GetInventoryCards);
+ if (!cards) {
+ throw new Error('No cards available');
+ }
+ setCards(cards);
+ } catch (error) {
+ if (error instanceof Error) {
+ setError(error.message);
+ } else {
+ setError(t('Something went wrong, please try again later.'));
+ }
+ }
};
+ isCardsEnabled && isOpen && updateCards();
+ }, [t, isCardsEnabled, isOpen]);
+
+ const input = {
+ cardId: selectedCard?.id ?? 0,
+ pin: parseInt(pin, 10),
+ };
+
+ const handleUpdateBalance = async () => {
+ setError('');
+ const response = await fetchNui<{ account: Account; card: Card }, GetATMAccountInput>(
+ AccountEvents.GetAtmAccount,
+ input,
+ );
+
+ if (!response) {
+ return;
+ }
+
+ const { card, account } = response;
+ setSelectedCard(card);
+ setAccount(account);
+ };
+
+ const handleWithdraw = async (amount: number) => {
+ const withdrawAccount = isCardsEnabled ? account : defaultAccount;
+ if (!withdrawAccount) {
+ return;
+ }
+
+ const accountId = withdrawAccount.id;
+
+ const payload: ATMInput = isCardsEnabled
+ ? {
+ amount,
+ cardId: selectedCard?.id,
+ cardPin: parseInt(pin, 10),
+ accountId,
+ message: t('Withdrew {{amount}} from an ATM with card {{cardNumber}}.', {
+ amount,
+ cardNumber: selectedCard?.number ?? 'unknown',
+ }),
+ }
+ : {
+ amount,
+ accountId,
+ message: t('Withdrew {{amount}} from an ATM.', {
+ amount,
+ }),
+ };
+
setIsLoading(true);
- // TODO: Update this with cards implementation
- fetchNui(AccountEvents.WithdrawMoney, payload)
- .then(() => updateAccounts())
- .finally(() => setIsLoading(false));
+
+ try {
+ setError('');
+ await fetchNui(AccountEvents.WithdrawMoney, payload);
+ await handleUpdateBalance();
+ } catch (error) {
+ if (error instanceof Error) {
+ if (error.message === CardErrors.InvalidPin) {
+ setError(t('Invalid pin'));
+ return;
+ }
+
+ if (error.message === CardErrors.Blocked) {
+ setError(t('The card is blocked'));
+ return;
+ }
+
+ setError(error.message);
+ } else {
+ setError(t('Something went wrong, please try again later.'));
+ }
+ }
+
+ setIsLoading(false);
+ };
+
+ const handleSubmit = async (event: FormEvent) => {
+ event.preventDefault();
+
+ if (!isCardsEnabled) {
+ return;
+ }
+
+ if (pin.length === PIN_CODE_LENGTH && selectedCard?.id) {
+ try {
+ setError('');
+ const response = await fetchNui<{ account: Account; card: Card }, GetATMAccountInput>(
+ AccountEvents.GetAtmAccount,
+ input,
+ );
+
+ if (!response) {
+ return;
+ }
+
+ const { card, account } = response;
+ setSelectedCard(card);
+ setAccount(account);
+ setState('withdraw');
+ } catch (error) {
+ if (error instanceof Error) {
+ if (error.message === CardErrors.InvalidPin) {
+ setError(t('Invalid pin'));
+ return;
+ }
+
+ if (error.message === CardErrors.Blocked) {
+ setError(t('The card is blocked'));
+ return;
+ }
+
+ setError(error.message);
+ } else {
+ setError(t('Something went wrong, please try again later.'));
+ }
+ }
+ }
};
+ const handleSelectCard = (card: InventoryCard) => {
+ setSelectedCard(card);
+ setState('enter-pin');
+ };
+
+ const accountBalance = isCardsEnabled ? account?.balance ?? 0 : defaultAccount?.balance ?? 0;
return (
-
- {isOpen && (
-
-
-
-
- {t('Account balance')}
- {formatMoney(accountBalance ?? 0, config.general)}
-
-
- {t('Quick withdraw')}
-
- {withdrawOptions.map((value) => (
-
- ))}
-
-
-
-
- )}
-
+ {error}
+
+ )}
+
+
+
+ )}
+
+
+
+ {isOpen && state === 'withdraw' && (
+
+
+
+
+ {t('Account balance')}
+ {formatMoney(accountBalance, config.general)}
+
+
+ {t('Quick withdraw')}
+
+ {withdrawOptions.map((value) => (
+
+ ))}
+
+
+ {error && (
+ } color="error" sx={{ marginTop: '1rem' }}>
+ {error}
+
+ )}
+
+
+
+ )}
+
+ >
);
};
diff --git a/web/src/views/Cards/CardsView.tsx b/web/src/views/Cards/CardsView.tsx
new file mode 100644
index 00000000..b1959848
--- /dev/null
+++ b/web/src/views/Cards/CardsView.tsx
@@ -0,0 +1,102 @@
+import { AccountCard } from '@components/AccountCard';
+import Layout from '@components/Layout';
+import { PreHeading } from '@components/ui/Typography/BodyText';
+import { Heading1 } from '@components/ui/Typography/Headings';
+import { accountsAtom } from '@data/accounts';
+import { Backdrop, Stack } from '@mui/material';
+import { useAtom } from 'jotai';
+import React, { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import styled from 'styled-components';
+import { AnimatePresence, motion } from 'framer-motion';
+import theme from '@utils/theme';
+import BankCards from './components/BankCards';
+import { selectedAccountIdAtom } from '@data/cards';
+import { AccountType } from '@typings/Account';
+
+const Container = styled.div`
+ overflow: auto;
+ height: 100%;
+`;
+
+const CardContainer = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 1rem;
+ margin-left: -0.5rem;
+
+ & > div {
+ width: calc(33% - 0.5rem);
+ margin-left: 0.5rem;
+ margin-top: 0.5rem;
+ }
+`;
+
+const Modal = styled(motion.div)`
+ z-index: 2;
+ padding: 2rem 3rem;
+ position: absolute;
+ width: calc(100% - 5rem);
+ height: 100%;
+ top: 0;
+ left: 5rem;
+ background-color: ${theme.palette.background.paper};
+`;
+
+const CardsView = () => {
+ const [selectedCardId, setSelectedCardId] = useState(0);
+ const [selectedAccountId, setSelectedAccountId] = useAtom(selectedAccountIdAtom);
+ const [accounts] = useAtom(accountsAtom);
+ const { t } = useTranslation();
+
+ return (
+
+
+
+ {t('Accounts')}
+ {t('Handle cards for your accounts')}
+
+
+
+ {accounts.map((account) => (
+
+ account.type !== AccountType.Shared && setSelectedAccountId(account.id)
+ }
+ >
+
+
+ ))}
+
+
+ {
+ setSelectedAccountId(0);
+ setSelectedCardId(0);
+ }}
+ sx={{ position: 'absolute' }}
+ />
+
+
+ {Boolean(selectedAccountId) && (
+
+
+
+ )}
+
+
+
+ );
+};
+
+export default CardsView;
diff --git a/web/src/views/Cards/components/BankCards.tsx b/web/src/views/Cards/components/BankCards.tsx
new file mode 100644
index 00000000..97351f57
--- /dev/null
+++ b/web/src/views/Cards/components/BankCards.tsx
@@ -0,0 +1,253 @@
+import { PreHeading } from '@components/ui/Typography/BodyText';
+import { Heading1 } from '@components/ui/Typography/Headings';
+import React, { useEffect, useState } from 'react';
+import BankCard from '@components/BankCard';
+import { AddRounded, ErrorRounded, InfoRounded } from '@mui/icons-material';
+import { Alert, Backdrop, DialogActions, DialogContent, DialogTitle, Stack } from '@mui/material';
+import { Card, CreateCardInput } from '@typings/BankCard';
+import theme from '@utils/theme';
+import styled from 'styled-components';
+import { useTranslation } from 'react-i18next';
+import CardActions from './CardActions';
+import { useConfig } from '@hooks/useConfig';
+import BaseDialog from '@components/Modals/BaseDialog';
+import { fetchNui } from '@utils/fetchNui';
+import { CardEvents } from '@typings/Events';
+import { useAtom } from 'jotai';
+import { cardsAtom } from '@data/cards';
+import { AnimatePresence, motion } from 'framer-motion';
+import Button from '@components/ui/Button';
+import AccountSelect from '@components/AccountSelect';
+import Summary from '@components/Summary';
+import { accountsAtom } from '@data/accounts';
+import PinField from '@components/ui/Fields/PinField';
+
+const CreateCard = styled.div`
+ cursor: pointer;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border-radius: ${theme.spacing(1)};
+ border: 1px dashed ${theme.palette.grey[500]};
+ font-size: 1.75rem;
+ transition: 300ms;
+
+ min-height: 7rem;
+ width: auto;
+
+ :hover {
+ color: ${theme.palette.primary.main};
+ border: 1px dashed ${theme.palette.primary.main};
+ }
+
+ svg {
+ font-size: 2.5rem;
+ }
+`;
+
+const CardContainer = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+ height: 100%;
+ overflow: auto;
+ margin-top: 1rem;
+ margin-left: -0.5rem;
+
+ & > div {
+ width: calc(33% - 0.5rem);
+ margin-left: 0.5rem;
+ margin-top: 0.5rem;
+ }
+`;
+
+const Modal = styled(motion.div)`
+ z-index: 2;
+ padding: 2rem 3rem;
+ position: absolute;
+ width: calc(100% - 5rem);
+ height: 100%;
+ top: 0;
+ left: 5rem;
+ background-color: ${theme.palette.background.paper};
+`;
+
+interface BankCardsProps {
+ accountId: number;
+ selectedCardId: number;
+ onSelectCardId(id: number): void;
+}
+
+const BankCards = ({ onSelectCardId, selectedCardId, accountId }: BankCardsProps) => {
+ const { t } = useTranslation();
+ const [accounts] = useAtom(accountsAtom);
+ const defaultAccount = accounts.find((account) => Boolean(account.isDefault));
+ const initialAccountId = defaultAccount?.id ?? -1;
+ const [cards, updateCards] = useAtom(cardsAtom);
+ const [error, setError] = useState('');
+ const [pin, setPin] = useState('');
+ const [confirmPin, setConfirmPin] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const [isOrderingCard, setIsOrderingCard] = useState(false);
+ const [selectedAccountId, setSelectedAccountId] = useState(initialAccountId);
+ const {
+ cards: { cost, maxCardsPerAccount },
+ } = useConfig();
+
+ useEffect(() => {
+ updateCards(accountId);
+ }, [accountId, updateCards]);
+
+ const handleClose = () => {
+ setError('');
+ setIsLoading(false);
+ setIsOrderingCard(false);
+ setPin('');
+ setConfirmPin('');
+ };
+
+ const handleOrderCard = async () => {
+ if (confirmPin !== pin) {
+ setError(t('Pins do not match'));
+ return;
+ }
+
+ setError('');
+ setIsLoading(true);
+ try {
+ const newCard = await fetchNui(CardEvents.OrderPersonal, {
+ accountId,
+ paymentAccountId: selectedAccountId,
+ pin: parseInt(pin, 10),
+ });
+
+ if (!newCard) {
+ return;
+ }
+
+ updateCards(newCard);
+ handleClose();
+ } catch (error: unknown) {
+ if (error instanceof Error) {
+ setError(error.message);
+ }
+ }
+
+ setIsLoading(false);
+ };
+
+ const selectedAccount = accounts.find((acc) => acc.id === selectedAccountId);
+ const selectedCard = cards.find((card) => card.id === selectedCardId);
+ const isAffordable = (selectedAccount?.balance ?? 0) > cost;
+
+ return (
+
+
+
+ {t('Cards')}
+ {t('Select a card to handle, or order a new one.')}
+
+
+
+ {cards.map((card) => (
+ {
+ !card.isBlocked && onSelectCardId(card.id);
+ }}
+ >
+
+
+ ))}
+
+ {cards.length < maxCardsPerAccount && (
+ setIsOrderingCard(true)}>
+
+
+
+
+ )}
+
+
+
+ {
+ onSelectCardId(0);
+ }}
+ sx={{ position: 'absolute', left: '-2rem' }}
+ />
+
+
+ {Boolean(selectedCardId) && (
+
+ {
+ updateCards(accountId);
+ onSelectCardId(0);
+ }}
+ onDelete={() => {
+ updateCards(accountId);
+ onSelectCardId(0);
+ }}
+ />
+
+ )}
+
+
+
+ {t('Order a new card')}
+
+
+
+ setPin(event.target.value)}
+ />
+
+ setConfirmPin(event.target.value)}
+ />
+
+
+
+
+
+
+
+
+
+ {error && (
+ : }
+ color={isLoading ? 'info' : 'error'}
+ >
+ {error}
+
+ )}
+
+
+
+
+
+
+
+
+ );
+};
+
+export default BankCards;
diff --git a/web/src/views/Cards/components/CardActions.tsx b/web/src/views/Cards/components/CardActions.tsx
new file mode 100644
index 00000000..4ac9904b
--- /dev/null
+++ b/web/src/views/Cards/components/CardActions.tsx
@@ -0,0 +1,251 @@
+import React, { useState } from 'react';
+import Button from '@components/ui/Button';
+import {
+ Alert,
+ DialogActions,
+ DialogContent,
+ DialogContentText,
+ DialogTitle,
+ Stack,
+} from '@mui/material';
+import { Heading1 } from '@components/ui/Typography/Headings';
+import { PreHeading } from '@components/ui/Typography/BodyText';
+import { useTranslation } from 'react-i18next';
+import BaseDialog from '@components/Modals/BaseDialog';
+import { fetchNui } from '@utils/fetchNui';
+import { CardEvents } from '@typings/Events';
+import { CheckRounded, ErrorRounded, InfoRounded } from '@mui/icons-material';
+import { BlockCardInput, DeleteCardInput, UpdateCardPinInput } from '@typings/BankCard';
+import PinField from '@components/ui/Fields/PinField';
+
+interface CardActionsProps {
+ cardId: number;
+ isBlocked?: boolean;
+ onBlock?(): void;
+ onDelete?(): void;
+}
+
+const CardActions = ({ cardId, onBlock, onDelete, isBlocked }: CardActionsProps) => {
+ const [error, setError] = useState('');
+ const [success, setSuccess] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const [dialog, setDialog] = useState<'none' | 'block' | 'update' | 'delete'>('none');
+ const [newPin, setNewPin] = useState('');
+ const [confirmNewPin, setConfirmNewPin] = useState('');
+ const { t } = useTranslation();
+
+ const handleClose = () => {
+ setIsLoading(false);
+ setDialog('none');
+ setError('');
+ setNewPin('');
+ setConfirmNewPin('');
+
+ setTimeout(() => {
+ setSuccess('');
+ }, 2000);
+ };
+
+ const handleBlockCard = async () => {
+ try {
+ setSuccess('');
+ setError('');
+ setIsLoading(true);
+
+ await fetchNui(CardEvents.Block, {
+ cardId,
+ });
+ setSuccess(t('Successfully blocked the card.'));
+ handleClose();
+ onBlock?.();
+ } catch (err: unknown | Error) {
+ if (err instanceof Error) {
+ setError(err.message);
+ }
+ }
+
+ setIsLoading(false);
+ };
+
+ const handleDeleteCard = async () => {
+ try {
+ setSuccess('');
+ setError('');
+ setIsLoading(true);
+
+ await fetchNui(CardEvents.Delete, {
+ cardId,
+ });
+ setSuccess(t('Successfully deleted the card.'));
+ handleClose();
+ onDelete?.();
+ } catch (err: unknown | Error) {
+ if (err instanceof Error) {
+ setError(err.message);
+ }
+ }
+
+ setIsLoading(false);
+ };
+
+ const handleUpdatePin = async () => {
+ try {
+ setError('');
+ setSuccess('');
+
+ if (confirmNewPin !== newPin) {
+ setError(t('Pins do not match'));
+ return;
+ }
+
+ setIsLoading(true);
+ const data = { cardId, newPin: parseInt(newPin, 10) };
+ await fetchNui(CardEvents.UpdatePin, data);
+
+ setSuccess(t('Successfully updated pin.'));
+ handleClose();
+ } catch (err: unknown | Error) {
+ if (err instanceof Error) {
+ setError(err.message);
+ }
+ }
+
+ setIsLoading(false);
+ };
+
+ return (
+ <>
+
+
+
+ {t('Actions')}
+ {t('Block, update pin and more.')}
+
+
+
+
+
+
+
+
+
+ {success && (
+ } color="success">
+ {success}
+
+ )}
+
+
+
+
+ {t('Update pin')}
+
+
+
+ setNewPin(event.target.value)}
+ />
+ setConfirmNewPin(event.target.value)}
+ />
+
+
+ {(Boolean(error) || isLoading) && (
+ : }
+ color={isLoading ? 'info' : 'error'}
+ >
+ {error}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {t('Blocking card')}
+
+
+
+
+ {t('Are you sure you want to block this card? This action cannot be undone.')}
+
+
+
+ {error && (
+ : }
+ color={isLoading ? 'info' : 'error'}
+ >
+ {error}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {t('Deleting card')}
+
+
+
+
+ {t('Are you sure you want to delete this card? This action cannot be undone.')}
+
+
+
+ {error && (
+ : }
+ color={isLoading ? 'info' : 'error'}
+ >
+ {error}
+
+ )}
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default CardActions;
diff --git a/web/src/views/Mobile/views/Accounts/MobileAccountsView.tsx b/web/src/views/Mobile/views/Accounts/MobileAccountsView.tsx
index 5d59bc9c..5d2b3951 100644
--- a/web/src/views/Mobile/views/Accounts/MobileAccountsView.tsx
+++ b/web/src/views/Mobile/views/Accounts/MobileAccountsView.tsx
@@ -1,11 +1,11 @@
-import { AccountCard } from '@components/Card';
+import React from 'react';
+import { AccountCard } from '@components/AccountCard';
import TotalBalance from '@components/TotalBalance';
import { Heading5 } from '@components/ui/Typography/Headings';
import { accountsAtom } from '@data/accounts';
import { Stack } from '@mui/material';
import { Box } from '@mui/system';
import { useAtom } from 'jotai';
-import React from 'react';
import { useTranslation } from 'react-i18next';
const MobileAccountsView = () => {
diff --git a/web/src/views/Mobile/views/Dashboard/MobileDashboardView.tsx b/web/src/views/Mobile/views/Dashboard/MobileDashboardView.tsx
index 6d2fbaea..029b6c3c 100644
--- a/web/src/views/Mobile/views/Dashboard/MobileDashboardView.tsx
+++ b/web/src/views/Mobile/views/Dashboard/MobileDashboardView.tsx
@@ -1,4 +1,4 @@
-import { AccountCard } from '@components/Card';
+import { AccountCard } from '@components/AccountCard';
import InvoiceItem from '@components/InvoiceItem';
import TotalBalance from '@components/TotalBalance';
import TransactionItem from '@components/TransactionItem';
diff --git a/web/src/views/Withdraw/Withdraw.tsx b/web/src/views/Withdraw/Withdraw.tsx
index 3edd9498..eb33585b 100644
--- a/web/src/views/Withdraw/Withdraw.tsx
+++ b/web/src/views/Withdraw/Withdraw.tsx
@@ -42,6 +42,10 @@ const Withdraw = () => {
}, [success]);
const handleWithdrawal = () => {
+ if (!selectedAccountId) {
+ return;
+ }
+
const payload: ATMInput = {
amount: value,
message: t('Withdrew {{amount}} from account.', { amount: formatMoney(value, general) }),
diff --git a/web/src/views/dashboard/components/AccountCards.tsx b/web/src/views/dashboard/components/AccountCards.tsx
index 53cf9bb9..31e73825 100644
--- a/web/src/views/dashboard/components/AccountCards.tsx
+++ b/web/src/views/dashboard/components/AccountCards.tsx
@@ -1,4 +1,4 @@
-import { AccountCard, LoadingAccountCard } from '@components/Card';
+import { AccountCard, LoadingAccountCard } from '@components/AccountCard';
import CreateAccountModal from '@components/Modals/CreateAccount';
import { orderedAccountsAtom } from '@data/accounts';
import styled from '@emotion/styled';
@@ -89,7 +89,7 @@ const AccountCards = ({ onSelectAccount, selectedAccountId }: AccountCardsProps)
{orderedAccounts.map((account) => (
onSelectAccount?.(account.id)}>
-
+
))}
diff --git a/web/tsconfig.json b/web/tsconfig.json
index bcd21407..e58dd45e 100644
--- a/web/tsconfig.json
+++ b/web/tsconfig.json
@@ -1,6 +1,7 @@
{
"compilerOptions": {
"target": "es5",
+ "module": "esnext",
"lib": [
"dom",
"dom.iterable",
@@ -13,7 +14,6 @@
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
- "module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"strictNullChecks": true,
@@ -34,6 +34,7 @@
}
},
"include": [
- "src/**/*"
+ "src/**/*",
+ "../shared/**/*"
]
}
\ No newline at end of file
diff --git a/web/webpack.config.js b/web/webpack.config.js
index e748f230..0d2bdc78 100644
--- a/web/webpack.config.js
+++ b/web/webpack.config.js
@@ -11,6 +11,7 @@ const deps = require('./package.json').dependencies;
delete deps['@emotion/react'];
delete deps['@emotion/styled'];
delete deps['@mui/material'];
+delete deps['@mui/icons-material'];
delete deps['@mui/styles'];
module.exports = (env, options) => ({
@@ -18,6 +19,7 @@ module.exports = (env, options) => ({
main: './src/bootstrapApp.ts',
},
mode: 'development',
+ // devtool: 'none',
output: {
publicPath: 'auto',
filename: '[name].js',
@@ -31,7 +33,6 @@ module.exports = (env, options) => ({
'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization',
},
},
- devtool: 'eval-source-map',
module: {
rules: [
{