Compare commits

..
55 Commits
Author SHA1 Message Date
eneller f708b2e84f feat(client): custom colors 2026-08-02 11:49:06 +02:00
eneller 0aea64383a refactor(client): rename screen 2026-08-02 10:47:31 +02:00
eneller 64fa5eb54d refactor: Modal in own component 2026-08-02 10:37:20 +02:00
eneller b47cc5e90e doc: use cases 2026-07-30 17:31:15 +02:00
eneller 0619428e07 feat: account switcher 2026-07-23 20:19:53 +02:00
eneller ed6c8cc413 feat(client): show real data 2026-07-23 18:49:44 +02:00
eneller e8f8cfbe99 doc: README 2026-07-23 17:33:45 +02:00
eneller cb9728a90b feat: notifications 2026-07-23 16:29:03 +02:00
eneller c282e275ef fix: delayed displaying of messages, transactions using signals 2026-07-23 15:17:29 +02:00
eneller 0f4474d29b feat(server): business owned by user data model 2026-07-23 14:28:20 +02:00
eneller c876a45ef8 feat(client): auth interceptor 2026-07-23 13:02:58 +02:00
eneller 4f734fff30 feat (server): user scopes 2026-07-23 12:11:42 +02:00
eneller a3158894c8 feat: hash passwords 2026-07-23 11:23:53 +02:00
eneller ceab2dee0a refactor: messaging 2026-06-26 13:05:10 +02:00
eneller 37fdc28e1f fix: auth guard 2026-06-26 11:50:15 +02:00
eneller 38dedfc829 fix: nav 2026-04-03 08:47:39 +02:00
eneller 4f8da032b7 feat: frontend send 2026-03-18 15:30:39 +01:00
eneller 7f3326c4a5 fix: send api 2026-03-18 10:27:26 +01:00
eneller 823d696d86 feat: api send 2026-03-18 09:18:27 +01:00
eneller c6629234e1 feat: auth middleware 2026-03-17 00:37:56 +01:00
eneller 8be35e9403 feat: jwt auth 2026-03-16 23:45:09 +01:00
eneller fa2203927a fix: save and send cookie in dev setup 2026-03-12 07:46:25 +01:00
eneller c9a2cd8d66 auth guard 2026-03-08 19:27:18 +01:00
eneller cd568f0a63 begin auth cookie 2026-03-08 19:02:08 +01:00
eneller f5ae9ac9e6 basic login/logout 2026-03-07 14:48:19 +01:00
eneller 111d1a7b48 auth frontend 2026-03-07 13:53:46 +01:00
eneller 3300622191 begin auth 2026-03-07 00:43:40 +01:00
eneller 80a260f515 fix: currency display 2026-03-06 19:53:04 +01:00
eneller 308225f190 fix: transactions display 2026-03-06 19:34:38 +01:00
eneller a618ce5e1e feat: db 2026-03-06 19:22:29 +01:00
eneller 0dabdaa136 database models 2026-03-06 11:16:22 +01:00
eneller d58b0babd2 begin sequelize 2026-03-06 10:24:47 +01:00
eneller fb0fc59240 feat: logging 2026-03-06 01:41:49 +01:00
eneller 599ed86c52 feat: basic db connection 2026-03-06 00:57:19 +01:00
eneller 4f206d9ad9 build: tsconfig shared/ 2026-03-05 23:39:42 +01:00
eneller 0dd4b6590d feat: basic api 2026-03-05 23:31:56 +01:00
eneller cef3474c3d feat: profile screen 2026-03-05 22:34:50 +01:00
eneller 427064a24c feat: receive screen qr code 2026-03-05 22:19:58 +01:00
eneller 9444145bf5 feat: receive screen 2026-03-05 21:23:16 +01:00
eneller 6af1a8a3e9 feat: send screen 2026-03-05 21:08:31 +01:00
eneller 886903e402 feat: working nav 2026-03-05 20:09:15 +01:00
eneller a6b9a5860c fix: ng build 2026-03-05 19:56:30 +01:00
eneller a704c7a4e2 fix: dependencies 2026-02-28 13:30:08 +01:00
eneller 7d73d35aa4 client: begin frontend 2026-02-28 13:20:09 +01:00
eneller 75100e689c fix: server run config 2026-02-28 10:33:20 +01:00
eneller 6b7ef0114f doc: README 2026-02-28 09:41:17 +01:00
eneller 096af359f0 build: fix tsconfig 2026-02-28 02:27:48 +01:00
eneller c4efd793e2 build: fix tsconfig 2026-02-28 01:35:01 +01:00
eneller 05090ebb14 build: fix tsconfig 2026-02-28 01:19:48 +01:00
eneller e64a77a0d7 chore: add sequelize 2026-02-28 00:35:25 +01:00
eneller 9f6c9b569c chore: add ng-bootstrap+icons, qrcode 2026-02-28 00:31:16 +01:00
eneller 40b020455e chore: angular init 2026-02-28 00:26:00 +01:00
eneller 378366e5f5 chore: backend setup 2026-02-28 00:21:44 +01:00
eneller c7bf381ab9 chore: npm workspaces 2026-02-28 00:14:28 +01:00
eneller b7f4df2f5e initial commit 2026-02-28 00:07:10 +01:00
95 changed files with 4462 additions and 717 deletions

No files matched your search

+184 -33
View File
@@ -1,44 +1,195 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. # Created by https://www.toptal.com/developers/gitignore/api/nextjs,node,visualstudiocode
# Edit at https://www.toptal.com/developers/gitignore?templates=nextjs,node,visualstudiocode
# Compiled output ### NextJS ###
/dist # dependencies
/tmp
/out-tsc
/bazel-out
# Node
/node_modules /node_modules
npm-debug.log /.pnp
yarn-error.log .pnp.js
# IDEs and editors # testing
.idea/ /coverage
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code # next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
### Node ###
# Logs
logs
*.log
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
### VisualStudioCode ###
.vscode/* .vscode/*
!.vscode/settings.json !.vscode/settings.json
!.vscode/tasks.json !.vscode/tasks.json
!.vscode/launch.json !.vscode/launch.json
!.vscode/extensions.json !.vscode/extensions.json
!.vscode/mcp.json !.vscode/*.code-snippets
.history/*
# Miscellaneous # Local History for Visual Studio Code
/.angular/cache .history/
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files # Built Visual Studio Code Extensions
.DS_Store *.vsix
Thumbs.db
### VisualStudioCode Patch ###
# Ignore all local history of files
.history
.ionide
# End of https://www.toptal.com/developers/gitignore/api/nextjs,node,visualstudiocode
.docker/
+15 -53
View File
@@ -1,59 +1,21 @@
# Fakemoney # FakeMoney
**A PayPal-like payment processor for virtual money, intended to be used for [simulation games](https://de.wikipedia.org/wiki/Schule_als_Staat).**
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.0. Send and receive money from your account and businesses you own using simple URLs and QR codes.
Install as a [PWA](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps)
from your browser (Firefox/Chrome/Safari) with just 3 taps and bypass tedious app store processes.
## Development server
To start a local development server, run: <img src="docs/img/screenshot-login.png" width="32%" alt="Login view">&nbsp;
<img src="docs/img/screenshot-send.png" width="32%" alt="Send view">&nbsp;
<img src="docs/img/screenshot-receive.png" width="32%" alt="Receive view">&nbsp;
```bash ## Deployment
ng serve Is simplified using Docker and by making a few assumptions, e.g. that API and frontend are available under the same domain.
``` ## Development
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files. ### Frontend
Is written in angular + bootstrap.
## Code scaffolding ### Backend
Uses NextJS + PostgreSQL with JWT as cookies.
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
File renamed without changes.
+44
View File
@@ -0,0 +1,44 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db
View File
File renamed without changes.
File renamed without changes.
File renamed without changes.
View File
File renamed without changes.
View File
File renamed without changes.
+59
View File
@@ -0,0 +1,59 @@
# Client
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.0.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
+13 -6
View File
@@ -1,12 +1,12 @@
{ {
"$schema": "./node_modules/@angular/cli/lib/config/schema.json", "$schema": "../node_modules/@angular/cli/lib/config/schema.json",
"version": 1, "version": 1,
"cli": { "cli": {
"packageManager": "npm" "packageManager": "npm"
}, },
"newProjectRoot": "projects", "newProjectRoot": "projects",
"projects": { "projects": {
"fakemoney": { "client": {
"projectType": "application", "projectType": "application",
"schematics": { "schematics": {
"@schematics/angular:component": { "@schematics/angular:component": {
@@ -20,6 +20,7 @@
"build": { "build": {
"builder": "@angular/build:application", "builder": "@angular/build:application",
"options": { "options": {
"outputPath": "../dist/out-tsc/client",
"browser": "src/main.ts", "browser": "src/main.ts",
"tsConfig": "tsconfig.app.json", "tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "less", "inlineStyleLanguage": "less",
@@ -30,10 +31,13 @@
} }
], ],
"styles": [ "styles": [
"node_modules/bootstrap/dist/css/bootstrap.min.css", "../node_modules/bootstrap/dist/css/bootstrap.min.css",
"node_modules/bootstrap-icons/font/bootstrap-icons.min.css", "../node_modules/bootstrap-icons/font/bootstrap-icons.min.css",
"src/styles.less" "src/styles.less"
], ],
"scripts": [
"../node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"
],
"polyfills": ["@angular/localize/init"] "polyfills": ["@angular/localize/init"]
}, },
"configurations": { "configurations": {
@@ -61,13 +65,16 @@
"defaultConfiguration": "production" "defaultConfiguration": "production"
}, },
"serve": { "serve": {
"options": {
"proxyConfig": "proxy.json"
},
"builder": "@angular/build:dev-server", "builder": "@angular/build:dev-server",
"configurations": { "configurations": {
"production": { "production": {
"buildTarget": "fakemoney:build:production" "buildTarget": "client:build:production"
}, },
"development": { "development": {
"buildTarget": "fakemoney:build:development" "buildTarget": "client:build:development"
} }
}, },
"defaultConfiguration": "development" "defaultConfiguration": "development"
+43
View File
@@ -0,0 +1,43 @@
{
"name": "client",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"serve": "ng serve --host 0.0.0.0",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"packageManager": "npm@11.10.0",
"dependencies": {
"@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0",
"@angular/forms": "^21.2.0",
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"ng-qrcode": "^21.0.0",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^21.2.0",
"@angular/cli": "^21.2.0",
"@angular/compiler-cli": "^21.2.0",
"@angular/localize": "^21.2.0",
"@types/qrcode": "^1.5.6",
"jsdom": "^28.0.0",
"less": "^4.2.0",
"prettier": "^3.8.1",
"typescript": "~5.9.2",
"vitest": "^4.0.8"
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"/api": {
"target": "http://localhost:3000",
"secure": false,
"changeOrigin": true,
"withCredentials": true
}
}
File renamed without changes.
+17
View File
@@ -0,0 +1,17 @@
import { ApplicationConfig, DEFAULT_CURRENCY_CODE, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { DATE_PIPE_DEFAULT_OPTIONS } from '@angular/common';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './services/auth-interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
{provide: DEFAULT_CURRENCY_CODE, useValue: ''},
{provide: DATE_PIPE_DEFAULT_OPTIONS, useValue: {dateFormat: 'shortDate'}},
provideHttpClient(withInterceptors([authInterceptor])),
]
};
+17
View File
@@ -0,0 +1,17 @@
<main class="main">
<app-toast-container></app-toast-container>
<router-outlet></router-outlet>
</main>
<nav>
<ul ngbNav [(activeId)]="active" class="nav-tabs custom-navbar bg-body-secondary">
<li ngbNavItem="/send" class="custom-navitem"><a ngbNavLink routerLink="/send">
<i class="bi bi-cash"></i>
</a></li>
<li ngbNavItem="/receive" class="custom-navitem"><a ngbNavLink routerLink="/receive">
<i class="bi bi-piggy-bank"></i>
</a></li>
<li ngbNavItem="/profile" class="custom-navitem"><a ngbNavLink routerLink="/profile">
<i class="bi bi-person"></i>
</a></li>
</ul>
</nav>
+35
View File
@@ -0,0 +1,35 @@
html, body{
max-width: 100%;
overflow-x: hidden;
}
*{
text-align: center;
}
.custom-table {
//table-layout: fixed;
max-width: 100%;
}
tr{
padding-right: .2em;
}
.wrap-cell{
word-break: break-all;
white-space: normal;
}
.main{
margin-bottom: 5rem;
padding: 1rem;
overflow-y: auto;
}
.custom-navbar{
width: 100vw;
position: fixed;
bottom: 0;
display: flex;
justify-content: space-around; /* Distribute items evenly */
z-index: 1000;
}
.custom-navitem{
text-align: center; /* Center the content of each item */
flex: 1;
}
+33
View File
@@ -0,0 +1,33 @@
import { Routes } from '@angular/router';
import { ScreenSend } from './screens/screen-send/screen-send';
import { ScreenRequest } from './screens/screen-request/screen-request';
import { ScreenProfile } from './screens/screen-profile/screen-profile';
import { ScreenLogin } from './screens/screen-login/screen-login';
import { authGuard } from './services/auth-guard';
export const routes: Routes = [
{
path: '',
pathMatch:'full',
redirectTo: '/send',
},
{
path: 'login',
component: ScreenLogin,
},
{
path:'send',
component: ScreenSend,
canActivate: [authGuard],
},
{
path:'receive',
component: ScreenRequest,
canActivate: [authGuard],
},
{
path:'profile',
component: ScreenProfile,
canActivate: [authGuard],
},
];
@@ -18,6 +18,6 @@ describe('App', () => {
const fixture = TestBed.createComponent(App); const fixture = TestBed.createComponent(App);
await fixture.whenStable(); await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement; const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, fakemoney'); expect(compiled.querySelector('h1')?.textContent).toContain('Hello, client');
}); });
}); });
+39
View File
@@ -0,0 +1,39 @@
import { Component, inject, OnInit, signal } from '@angular/core';
import { RouterOutlet, RouterLinkWithHref, Router, NavigationEnd } from '@angular/router';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import {
NgbNav,
NgbNavItem,
NgbNavItemRole,
NgbNavLinkBase,
} from '@ng-bootstrap/ng-bootstrap/nav';
import { filter } from 'rxjs';
import { APIService } from './services/api';
import { ToastContainer } from "./components/toast-container/toast-container";
@Component({
selector: 'app-root',
imports: [RouterOutlet, NgbModule, NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLinkBase, RouterLinkWithHref, ToastContainer],
templateUrl: './app.html',
styleUrl: './app.less'
})
export class App implements OnInit{
protected readonly title = signal('client');
protected api = inject(APIService);
active = '/';
constructor(
private router: Router
){}
ngOnInit(): void {
this.api.checkAuthStatus().subscribe();
this.router.events
.pipe(filter(event => event instanceof NavigationEnd))
.subscribe(() =>{
this.active = this.router.url;
console.log(this.active);
});
}
}
@@ -0,0 +1,7 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{ title }}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="activeModal.dismiss()"></button>
</div>
<div class="modal-body text-center">
<ng-container *ngTemplateOutlet="body"></ng-container>
</div>
File renamed without changes.
@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Modal } from './modal';
describe('Modal', () => {
let component: Modal;
let fixture: ComponentFixture<Modal>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Modal],
}).compileComponents();
fixture = TestBed.createComponent(Modal);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
+19
View File
@@ -0,0 +1,19 @@
import { NgTemplateOutlet } from '@angular/common';
import { Component, Input, TemplateRef } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
@Component({
selector: 'app-modal',
imports: [NgTemplateOutlet],
templateUrl: './modal.html',
styleUrl: './modal.less',
})
export class Modal {
@Input({required: true}) title!: string;
@Input({required: true}) body!: TemplateRef<any>;
constructor(
public activeModal: NgbActiveModal
){}
}
@@ -0,0 +1,13 @@
<div class="toast-container position-fixed top-0 end-0 p-3">
@for (notification of notifications.notifications(); track notification) {
<ngb-toast
[autohide]="true"
[delay]="notification.delay ?? 3000"
(hidden)="notifications.remove(notification)"
>
<div class="text-{{ notification.type }}">
{{ notification.message }}
</div>
</ngb-toast>
}
</div>
Whitespace-only changes.
@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ToastContainer } from './toast-container';
describe('ToastContainer', () => {
let component: ToastContainer;
let fixture: ComponentFixture<ToastContainer>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ToastContainer],
}).compileComponents();
fixture = TestBed.createComponent(ToastContainer);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,13 @@
import { Component, inject } from '@angular/core';
import { NgbToastModule } from '@ng-bootstrap/ng-bootstrap';
import { NotificationService } from '../../services/notification';
@Component({
selector: 'app-toast-container',
imports: [NgbToastModule],
templateUrl: './toast-container.html',
styleUrl: './toast-container.less',
})
export class ToastContainer {
notifications = inject(NotificationService);
}
@@ -0,0 +1,77 @@
<div class="d-flex align-items-center min-vh-100 bg-light">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-4 col-md-6 col-sm-8">
<div class="card shadow-sm">
<div class="card-body p-4">
<div class="text-center mb-4">
<i class="bi bi-lock-fill fs-1 text-primary"></i>
<h3 class="mt-2">Sign In</h3>
</div>
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()" novalidate>
<!-- Email -->
<div class="mb-3">
<label for="email" class="form-label">Username</label>
<input
type="text"
id="username"
class="form-control"
formControlName="username"
placeholder="Enter your username"
>
</div>
<!-- Password -->
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<div class="input-group">
<input
[type]="showPassword ? 'text' : 'password'"
id="password"
class="form-control"
formControlName="password"
placeholder="Enter your password"
>
<button
class="btn btn-outline-secondary"
type="button"
(click)="showPassword = !showPassword"
>
<i class="bi" [class.bi-eye-fill]="showPassword" [class.bi-eye-slash-fill]="!showPassword"></i>
</button>
</div>
</div>
<!-- Submit Button -->
<button
type="submit"
class="btn btn-primary w-100 mb-3"
>
@if (loading()) {
<span> Signing In... </span>
}@else {
<span>Sign In</span>
}
</button>
<!-- Error Alert -->
@if (error()) {
<ngb-alert
type="danger"
(closed)="error.set(null)"
[dismissible]="true"
>
{{ error() }}
</ngb-alert>
}
</form>
</div>
</div>
</div>
</div>
</div>
</div>
Whitespace-only changes.
@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ScreenLogin } from './screen-login';
describe('ScreenLogin', () => {
let component: ScreenLogin;
let fixture: ComponentFixture<ScreenLogin>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ScreenLogin],
}).compileComponents();
fixture = TestBed.createComponent(ScreenLogin);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,54 @@
import { CommonModule } from '@angular/common';
import { Component, signal } from '@angular/core';
import { Validators, FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Form } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { APIService } from '../../services/api';
import { GenericMessage } from '@message/Message';
import { NotificationService } from '../../services/notification';
@Component({
selector: 'app-screen-login',
imports: [FormsModule, NgbModule, ReactiveFormsModule, CommonModule],
templateUrl: './screen-login.html',
styleUrl: './screen-login.less',
})
export class ScreenLogin {
loginForm: FormGroup;
submitted = false;
loading = signal(false);
showPassword = false;
error = signal<string|null>(null);
constructor(
private api: APIService,
private notify: NotificationService,
private router: Router,
private route: ActivatedRoute,
private fb: FormBuilder,
) {
this.loginForm = this.fb.group({
username: ['', [Validators.required]],
password: ['', [Validators.required]],
});
}
onSubmit() {
this.submitted = true;
this.error.set(null);
this.loading.set(true)
this.api.login(this.loginForm.value.username, this.loginForm.value.password).subscribe({
next: () => {
const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/';
this.router.navigateByUrl(returnUrl);
},
error: (resp) => {
let msg: GenericMessage = resp.error;
this.notify.error(msg.message || 'Login failed. Please try again.');
this.loading.set(false);
}
});
}
}
@@ -0,0 +1,71 @@
<div class="container py-4">
<!-- Profile Header -->
<div class="card mb-4 shadow-sm">
<div class="card-body text-center p-4">
<div class="avatar avatar-xl mb-3">
<i class="bi bi-person-circle fs-1 text-primary"></i>
<b> {{this.api.loggedInUser.displayName}} </b>
</div>
<div ngbDropdown class="d-inline-block">
<button type="button" class="btn btn-outline-primary" id="dropdownBasic1" ngbDropdownToggle>
{{ this.api.currentUser.displayName }}
</button>
<div ngbDropdownMenu aria-labelledby="dropdownBasic1">
@for (acc of this.api.ownedAccounts; track $index) {
<button ngbDropdownItem (click)="this.api.currentUser = acc">{{ acc.displayName }}</button>
}
</div>
</div>
<h3 class="mb-1"></h3>
</div>
<div class="d-flex align-items-center justify-content-center gap-2 mb-1">
<i class="bi bi-wallet2 fs-4"></i>
<h3 class="mb-0">{{ this.api.currentUser.balance | currency}}</h3>
</div>
<button type="button" (click)="logOut()" class="btn btn-outline-secondary">Log out</button>
</div>
</div>
<!-- Recent Transactions -->
<div class="card mb-4 shadow-sm">
<div class="card-header bg-light">
<h5 class="mb-0">Recent Transactions</h5>
</div>
<div class="card-body p-0">
<div class="list-group list-group-flush">
<!-- Transaction Item -->
@for (transaction of transactions(); track $index) {
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<div class="me-3">
<i class="bi bi-person-fill fs-4 text-secondary"></i>
</div>
<div class="text-start">
@if (transaction.receiverID == this.api.currentUser.id) {
<h6 class="mb-0">{{ transaction.senderID }}</h6>
}@else {
<h6 class="mb-0">{{ transaction.receiverID }}</h6>
}
<small class="text-muted">{{ transaction.date | date:'medium' }}</small>
</div>
</div>
<div class="text-end">
@if (transaction.receiverID == this.api.currentUser.id) {
<h6 class="mb-0 text-success">
{{ transaction.amount | currency }}
</h6>
}@else {
<h6 class="mb-0 text-danger">
{{ - transaction.amount | currency }}
</h6>
}
</div>
</div>
</div>
}
@empty {<p class="text-muted">No transactions yet</p>}
</div>
</div>
</div>
Whitespace-only changes.
@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ScreenProfile } from './screen-profile';
describe('ScreenProfile', () => {
let component: ScreenProfile;
let fixture: ComponentFixture<ScreenProfile>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ScreenProfile],
}).compileComponents();
fixture = TestBed.createComponent(ScreenProfile);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,43 @@
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
import { Component, inject, OnInit, signal } from '@angular/core';
import { APIService } from '../../services/api';
import Transaction from '@model/transaction';
import { Router } from '@angular/router';
import { NgbAccordionToggle, NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle } from "@ng-bootstrap/ng-bootstrap";
@Component({
selector: 'app-screen-profile',
imports: [CurrencyPipe, DatePipe, CommonModule, NgbDropdown, NgbDropdownMenu, NgbDropdownToggle, NgbDropdownItem],
templateUrl: './screen-profile.html',
styleUrl: './screen-profile.less',
})
export class ScreenProfile implements OnInit{
transactions = signal<Transaction[]>([])
constructor(
protected api: APIService,
private router: Router,
){}
ngOnInit(): void {
this.api.getTransactions().subscribe({
next: (transactions) => {
this.transactions.set(transactions);
},
error: (err) => {
console.error('Error fetching transactions:', err);
},
})
}
logOut(){
this.api.logout().subscribe({
next: () => {
this.router.navigate(['/'])
},
error: (err) => {
console.error('Error logging out:', err)
}
})
}
}
@@ -0,0 +1,57 @@
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-6 col-md-8">
<div class="card shadow-sm">
<div class="card-header bg-success text-white">
<h3 class="mb-0">Receive Money</h3>
</div>
<div class="card-body p-4 text-center">
<!-- Large Amount Display -->
<div class="mb-4">
<label class="form-label h5">Amount to Receive</label>
<div class="input-group input-group-lg mb-3">
<span class="input-group-text"><i class="bi bi-wallet2"></i></span>
<input
type="number"
class="form-control text-center fs-2"
placeholder="0.00"
[(ngModel)]="amount"
/>
</div>
</div>
<!-- Shareable Link -->
<div class="mb-4">
<p class="text-muted">Share this link to receive money:</p>
<div class="input-group mb-3">
<input
type="text"
class="form-control"
[value]="shareableLink"
readonly
/>
<button class="btn btn-outline-secondary" (click)="copyLink()">
Copy
</button>
</div>
</div>
<!-- Share Button -->
<ng-template #qrTemplate>
<qr-code [value]="shareableLink"
size="300"
errorCorrectionLevel="M" />
</ng-template>
<div class="d-grid gap-2">
<button class="btn btn-primary btn-lg" (click)="openModal()">
<i class="bi bi-qr-code-scan me-2"></i> Show QR Code
</button>
</div>
</div>
</div>
</div>
</div>
</div>
Whitespace-only changes.
@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ScreenRequest } from './screen-request';
describe('ScreenReceive', () => {
let component: ScreenRequest;
let fixture: ComponentFixture<ScreenRequest>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ScreenRequest],
}).compileComponents();
fixture = TestBed.createComponent(ScreenRequest);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,33 @@
import { Component, inject, TemplateRef, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { QrCodeComponent } from 'ng-qrcode';
import { APIService } from '../../services/api';
import { Modal } from '../../components/modal/modal';
@Component({
selector: 'app-screen-request',
imports: [FormsModule, QrCodeComponent],
templateUrl: './screen-request.html',
styleUrl: './screen-request.less',
})
export class ScreenRequest {
private modalService = inject(NgbModal);
api = inject(APIService);
@ViewChild('qrTemplate') qrTemplate !: TemplateRef<any>;
amount: number = 0;
get shareableLink(): string {
const currentDomain = window.location.origin;
return `${currentDomain}/send/${this.api.currentUser.id}?amount=${this.amount}`;
}
copyLink() {
navigator.clipboard.writeText(this.shareableLink);
}
openModal() {
const modalRef = this.modalService.open(Modal);
modalRef.componentInstance.title = `Pay ${this.amount} to ${this.api.currentUser.id}`
modalRef.componentInstance.body = this.qrTemplate;
}
}
@@ -0,0 +1,62 @@
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-6 col-md-8">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h3 class="mb-0">Send Money</h3>
</div>
<div class="card-body p-4">
<!-- Amount Input (Centered Large Field) -->
<div class="text-center mb-4">
<label class="form-label h5">Amount</label>
<div class="input-group input-group-lg mb-3">
<span class="input-group-text"><i class="bi bi-wallet2"></i></span>
<input
type="number"
class="form-control text-center fs-2"
placeholder="0.00"
[(ngModel)]="amount"
/>
</div>
</div>
<!-- Recipient Field -->
<div class="mb-3">
<label class="form-label">To</label>
<div class="input-group">
<span class="input-group-text">@</span>
<input
type="text"
class="form-control"
placeholder="username"
[(ngModel)]="recipient"
/>
<button (click)="openScanner()" class="btn btn-primary"><i class="bi bi-qr-code-scan"></i></button>
</div>
</div>
<!-- Note Field -->
<div class="mb-4">
<label class="form-label">Note (Optional)</label>
<textarea class="form-control" rows="2" [(ngModel)]="reference"></textarea>
</div>
<!-- Action Buttons -->
<div class="d-grid gap-2">
<button class="btn btn-primary btn-lg" (click)="sendMoney()">
Send Money
</button>
<button class="btn btn-outline-secondary btn-lg" (click)="clear()">
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<ng-template #qrScanner>
Abc
</ng-template>
Whitespace-only changes.
@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ScreenSend } from './screen-send';
describe('ScreenSend', () => {
let component: ScreenSend;
let fixture: ComponentFixture<ScreenSend>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ScreenSend],
}).compileComponents();
fixture = TestBed.createComponent(ScreenSend);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,59 @@
import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { APIService } from '../../services/api';
import { NotificationService } from '../../services/notification';
import QrScanner from 'qr-scanner';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { Modal } from '../../components/modal/modal';
@Component({
selector: 'app-screen-send',
imports: [FormsModule],
templateUrl: './screen-send.html',
styleUrl: './screen-send.less',
})
export class ScreenSend {
amount: number = 0;
recipient: string = '';
reference: string = '';
scanner!: QrScanner;
@ViewChild('qrScanner') templScanner!: TemplateRef<any>;
constructor(
private api: APIService,
private notify: NotificationService,
private modalService: NgbModal,
){}
sendMoney() {
this.api.send(this.amount, this.recipient, this.reference).subscribe({
next:()=> {
this.notify.success(`Sent ${this.amount} to ${this.recipient}`);
this.clear()
},
error:(err)=> {
if(err.status == 404){
this.notify.error(`Invalid recipient "${this.recipient}"`);
}
else if(err.status == 402){
this.notify.error(`Insufficient funds.`);
}
else{
this.notify.error(`An error occurred during payment: ${err.status}`);
}
}
});
}
clear() {
this.amount = 0;
this.recipient = '';
this.reference = '';
}
openScanner(){
const modalRef = this.modalService.open(Modal);
modalRef.componentInstance.title = 'Scan a QR Code';
modalRef.componentInstance.body = this.templScanner;
}
closeScanner(){}
}
+16
View File
@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { APIService } from './api';
describe('Api', () => {
let service: APIService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(APIService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});
+66
View File
@@ -0,0 +1,66 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { BehaviorSubject, catchError, map, Observable, of, tap } from 'rxjs';
import Transaction from '@model/transaction'
import { SendRequest, SendResponse } from '@message/Send';
import { LoginResponse } from '@message/Login';
import Account from '@model/user';
@Injectable({
providedIn: 'root',
})
export class APIService {
private apiUrl = '/api';
private isAuthenticatedSubject = new BehaviorSubject<boolean>(false);
isAuthenticated$ = this.isAuthenticatedSubject.asObservable();
// data holding
loggedInUser!: Account;
currentUser!: Account;
ownedAccounts!: Account[];
constructor(private http: HttpClient){}
login(username: string, password: string): Observable<any>{
return this.http.post<LoginResponse>(`${this.apiUrl}/auth/login`,{ 'username': username, 'password': password}).pipe(
tap({
next: (resp) => {
this.isAuthenticatedSubject.next(true);
this.loggedInUser = resp.user;
this.currentUser = this.loggedInUser;
this.ownedAccounts = resp.ownedAccounts;
this.ownedAccounts.push(this.loggedInUser);
},
error: () => this.isAuthenticatedSubject.next(false)
})
);
}
logout(): Observable<any>{
return this.http.post(`${this.apiUrl}/auth/logout`, {}).pipe(
tap({
next: () => this.isAuthenticatedSubject.next(false),
error: () => this.isAuthenticatedSubject.next(false),
})
);
}
clearAuthState() {
this.isAuthenticatedSubject.next(false);
}
checkAuthStatus(): Observable<boolean> {
return this.http.get(`${this.apiUrl}/auth/status`).pipe(
map(() => true),
catchError(() => of(false)),
tap( authenticated => {
this.isAuthenticatedSubject.next(authenticated);
})
);
}
getTransactions(): Observable<Transaction[]>{
return this.http.get<Transaction[]>(`${this.apiUrl}/transactions`);
}
send(amount: number, recipientID: string, reference: string = ""): Observable<SendResponse>{
let request: SendRequest = {senderID: this.currentUser.id, amount, recipientID, reference};
return this.http.post<SendResponse>(`${this.apiUrl}/send`, request);
}
}
@@ -0,0 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { CanActivateFn } from '@angular/router';
import { authGuard } from './auth-guard';
describe('authGuard', () => {
const executeGuard: CanActivateFn = (...guardParameters) =>
TestBed.runInInjectionContext(() => authGuard(...guardParameters));
beforeEach(() => {
TestBed.configureTestingModule({});
});
it('should be created', () => {
expect(executeGuard).toBeTruthy();
});
});
+21
View File
@@ -0,0 +1,21 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { APIService } from './api';
import { map, take } from 'rxjs/operators';
export const authGuard: CanActivateFn = (route, state) => {
const api = inject(APIService);
const router = inject(Router);
return api.isAuthenticated$.pipe(
take(1),
map((isAuthenticated) => {
if (isAuthenticated) {
return true;
} else {
router.navigate(['/login'], { queryParams: { returnUrl: state.url } });
return false;
}
})
);
};
@@ -0,0 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { HttpInterceptorFn } from '@angular/common/http';
import { authInterceptor } from './auth-interceptor';
describe('authInterceptor', () => {
const interceptor: HttpInterceptorFn = (req, next) =>
TestBed.runInInjectionContext(() => authInterceptor(req, next));
beforeEach(() => {
TestBed.configureTestingModule({});
});
it('should be created', () => {
expect(interceptor).toBeTruthy();
});
});
@@ -0,0 +1,19 @@
import { HttpErrorResponse, HttpEventType, HttpInterceptorFn } from '@angular/common/http';
import { catchError, throwError } from 'rxjs';
import { APIService } from './api';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const api = inject(APIService);
const router = inject(Router)
return next(req).pipe(
catchError((err: HttpErrorResponse) => {
if (err.status === 401){
api.clearAuthState();
router.createUrlTree(['/login']);
}
return throwError(() => err);
})
)
};
+16
View File
@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { DataService } from './data';
describe('Data', () => {
let service: DataService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(DataService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});
+6
View File
@@ -0,0 +1,6 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class DataService {}
@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { NotificationService } from './notification';
describe('Notification', () => {
let service: NotificationService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(NotificationService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});
+41
View File
@@ -0,0 +1,41 @@
import { Injectable, signal } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class NotificationService {
notifications = signal<Notification[]>([]);
show(notification: Notification) {
this.notifications.update(items => [
...items,
notification,
]);
}
remove(notification: Notification) {
this.notifications.update(items =>
items.filter(item => item !== notification)
);
}
success(message: string) {
this.show({ type: 'success', message});
}
error(message: string) {
this.show({ type: 'danger', message});
}
warn(message: string) {
this.show({ type: 'warning', message});
}
info(message: string) {
this.show({ type: 'info', message});
}
}
export interface Notification {
type: 'success' | 'warning' | 'info' | 'danger';
message: string;
delay?: number;
}
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>Fakemoney</title> <title>Client</title>
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico"> <link rel="icon" type="image/x-icon" href="favicon.ico">
View File
File renamed without changes.
+67
View File
@@ -0,0 +1,67 @@
:root {
--bs-primary: #2f745f;
--bs-primary-rgb: 47, 116, 95;
--bs-primary-bg-subtle: #9dbbb2;
--bs-primary-border-subtle: #9dbbb2;
--bs-primary-text-emphasis: #114334;
--bs-link-color: #2f745f;
--bs-link-color-rgb: 47, 116, 95;
--bs-link-hover-color: #114334;
--bs-success: #114334;
--bs-success-rgb: 17, 67, 52;
--bs-success-bg-subtle: #9dbbb2;
--bs-success-border-subtle: #9dbbb2;
--bs-success-text-emphasis: #114334;
}
.btn-primary {
--bs-btn-bg: #2f745f;
--bs-btn-border-color: #2f745f;
--bs-btn-hover-bg: #114334;
--bs-btn-hover-border-color: #114334;
--bs-btn-focus-shadow-rgb: 47, 116, 95;
--bs-btn-active-bg: #114334;
--bs-btn-active-border-color: #114334;
--bs-btn-disabled-bg: #2f745f;
--bs-btn-disabled-border-color: #2f745f;
}
.btn-outline-primary {
--bs-btn-color: #2f745f;
--bs-btn-border-color: #2f745f;
--bs-btn-hover-bg: #2f745f;
--bs-btn-hover-border-color: #2f745f;
--bs-btn-focus-shadow-rgb: 47, 116, 95;
--bs-btn-active-bg: #114334;
--bs-btn-active-border-color: #114334;
--bs-btn-disabled-color: #2f745f;
--bs-btn-disabled-bg: transparent;
--bs-btn-disabled-border-color: #2f745f;
}
.btn-success {
--bs-btn-bg: #114334;
--bs-btn-border-color: #114334;
--bs-btn-hover-bg: #0e392c;
--bs-btn-hover-border-color: #0e392c;
--bs-btn-focus-shadow-rgb: 17, 67, 52;
--bs-btn-active-bg: #0e392c;
--bs-btn-active-border-color: #0e392c;
--bs-btn-disabled-bg: #114334;
--bs-btn-disabled-border-color: #114334;
}
.nav-pills .nav-link.active {
--bs-nav-pills-link-active-bg: #2f745f;
}
.dropdown-menu {
--bs-dropdown-link-active-bg: #2f745f;
}
.form-control:focus,
.form-select:focus {
border-color: #9dbbb2;
box-shadow: 0 0 0 0.25rem rgba(47, 116, 95, 0.25);
}
@@ -3,7 +3,7 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./out-tsc/app", "outDir": "../dist/out-tsc/client",
"types": ["@angular/localize"] "types": ["@angular/localize"]
}, },
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
+31
View File
@@ -0,0 +1,31 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "../tsconfig.json",
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"experimentalDecorators": true,
"skipLibCheck": true,
"importHelpers": true,
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
@@ -3,7 +3,7 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./out-tsc/spec", "outDir": "../dist/out-tsc/spec",
"types": ["vitest/globals", "@angular/localize"] "types": ["vitest/globals", "@angular/localize"]
}, },
"include": ["src/**/*.d.ts", "src/**/*.spec.ts"] "include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
+19
View File
@@ -0,0 +1,19 @@
# Use Cases
## Pay someone
The client pays a business or peer by scanning their QR code or clicking their URL.
They are then shown the payment (send) screen that already contains the prefilled recipient and currency amount.
The client then confirms payment with a button and optional confirmation dialog.
## Request payment
The business requests payment from a client by sharing their URL / QR code.
The client proceeds with the `Pay someone` flow.
##
## Visitor
Visitors obtain access by receiving credentials of a predefined visitor account.
Upon login, they participate freely in the payment process as described by `Pay someone` and `Request payment`.
## Onboarding users
An administrator performs onboarding using a batch import feature that makes use of `.csv` or a similarly simple format.
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+2372 -192
View File
File diff suppressed because it is too large. Load diff
+19 -33
View File
@@ -1,40 +1,26 @@
{ {
"name": "fakemoney", "name": "fakemoney",
"version": "0.0.0", "version": "0.0.1",
"description": "Fake Money Payment Processor",
"license": "GPL-3.0",
"author": "",
"type": "commonjs",
"scripts": { "scripts": {
"ng": "ng", "test": "echo \"Error: no test specified\" && exit 1",
"start": "ng serve", "server": "npm run dev --workspace=server",
"build": "ng build", "client": "npm start --prefix client",
"watch": "ng build --watch --configuration development", "dev": "concurrently \"npm run server\" \"npm run client\"",
"test": "ng test" "teardown": "npm run teardown --workspace=server"
},
"private": true,
"packageManager": "npm@11.10.0",
"dependencies": {
"@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0",
"@angular/forms": "^21.2.0",
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
}, },
"workspaces": [
"client",
"server"
],
"devDependencies": { "devDependencies": {
"@angular/build": "^21.2.0", "@types/node": "^25.3.2",
"@angular/cli": "^21.2.0", "concurrently": "^9.2.1",
"@angular/compiler-cli": "^21.2.0", "nodemon": "^3.1.14",
"@angular/localize": "^21.2.0", "ts-node": "^10.9.2",
"@ng-bootstrap/ng-bootstrap": "^20.0.0", "typescript": "^5.9.3"
"bootstrap-icons": "^1.13.1",
"jsdom": "^28.0.0",
"less": "^4.2.0",
"postgres": "^3.4.8",
"prettier": "^3.8.1",
"qrcode": "^1.5.4",
"typescript": "~5.9.2",
"vitest": "^4.0.8"
} }
} }
+7
View File
@@ -0,0 +1,7 @@
FM_PRIVATE_KEY='{"kty":"oct","k":"WoX65SSouJ4c8l_OxwWspo9H5dhPBq9sW0lsOJB6Ygc"}'
NODE_ENV='dev'
FM_DB_HOST
FM_DB_PORT
FM_DB_NAME
FM_DB_USER
FM_DB_PASSWORD
+19
View File
@@ -0,0 +1,19 @@
services:
postgres:
image: postgres
environment:
POSTGRES_PASSWORD: pass
ports:
- "5432:5432"
volumes:
- ./.docker:/var/lib/postgresql
adminer:
image: adminer
restart: always
environment:
ADMINER_DEFAULT_SERVER: postgres
ports:
- "8080:8080"
depends_on:
- postgres
+6
View File
@@ -0,0 +1,6 @@
{
"watch": ["src"],
"ext": "ts,json",
"exec": "ts-node src/index.ts"
}
+52
View File
@@ -0,0 +1,52 @@
{
"name": "server",
"version": "1.0.0",
"description": "",
"license": "GPL-3.0",
"author": "",
"type": "commonjs",
"main": "../dist/out-tsc/server/index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"node": "nodemon",
"start": "ts-node src/index.ts",
"build": "tsc",
"serve": "node ../dist/out-tsc/server/index.js",
"setup": "docker compose up -d",
"teardown": "docker compose down",
"keygen": "ts-node src/scripts/keygen.ts",
"hash": "ts-node src/scripts/hash.ts",
"verify-hash": "ts-node src/scripts/verify-hash.ts",
"create-user": "ts-node src/scripts/create-user.ts",
"dev": "npm run setup && npm run node; npm run teardown"
},
"dependencies": {
"bcrypt": "^6.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"jose": "^6.2.0",
"pg": "^8.20.0",
"pg-hstore": "^2.3.4",
"reflect-metadata": "^0.2.2",
"sequelize": "^6.37.7",
"sequelize-typescript": "^2.1.6",
"winston": "^3.19.0"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/node": "^25.3.5",
"@types/pg": "^8.16.0",
"@types/validator": "^13.15.10",
"concurrently": "^9.2.1",
"eslint": "^10.0.2",
"nodemon": "^3.1.14",
"prettier": "^3.8.1",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
}
+43
View File
@@ -0,0 +1,43 @@
import express, { Express, Request, Response } from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
import transactionsRouter from './routes/transactions';
import authRouter from './routes/auth';
import sendRouter from './routes/send';
import dotenv from 'dotenv';
dotenv.config({quiet: true});
import { db, testConnection } from "./util/db";
import { logger } from "./util/logging";
import { setKeyFromEnv } from "./util/auth";
const app: Express = express();
app.use(cors());
app.use(cookieParser());
app.use(express.json());
app.get("/api/health", (req: Request, res: Response) => {
res.json({ status: "OK" });
});
app.use('/api/auth', authRouter);
app.use('/api/transactions', transactionsRouter);
app.use('/api/send', sendRouter);
const PORT: number = parseInt(process.env.FM_PORT as string) || 3000;
async function startServer() {
await testConnection();
// Sync models (use migrations in production!)
// Use { force: true } to drop and recreate tables (development only!)
await db.sync({ alter: true });
await setKeyFromEnv();
app.listen(PORT, () => {
logger.info(`🚀 Backend Server running on http://localhost:${PORT}`);
});
}
startServer().catch((err) => {
logger.error('Failed to start server:', err);
process.exit(1);
});
+13
View File
@@ -0,0 +1,13 @@
import Account from "../model/user";
export class LoginRequest{
constructor(
public username: string,
public password: string
){}
}
export class LoginResponse{
constructor(
public user: Account,
public ownedAccounts: Account[],
){}
}
+5
View File
@@ -0,0 +1,5 @@
export class GenericMessage{
constructor(
public message: string
){}
}
+14
View File
@@ -0,0 +1,14 @@
export class SendRequest{
constructor(
public senderID: string,
public recipientID: string,
public amount: number,
public reference: string
){}
}
export class SendResponse{
constructor(
public balance: number,
){}
}
+15
View File
@@ -0,0 +1,15 @@
import Transaction from "../model/transaction";
export class TransactionsRequest{
constructor(
public forId: string,
public offset = 0,
public count = 50,
){}
}
export class TransactionsResponse{
constructor(
public transactions: Transaction[],
){}
}
+11
View File
@@ -0,0 +1,11 @@
import Account from "../model/user";
export class UserRequest{
constructor(
){}
}
export class UserResponse{
constructor(
public user: Account
){}
}
+28
View File
@@ -0,0 +1,28 @@
import { Table, Column, Model, CreatedAt, ForeignKey, BelongsTo} from 'sequelize-typescript';
import Account from './user';
@Table
export default class Transaction extends Model{
@Column
declare amount: number;
@Column
declare reference: string;
@Column
@ForeignKey(()=> Account)
declare senderID: string;
@BelongsTo(() => Account, 'senderID')
declare sender: Account;
@Column
@ForeignKey(()=> Account)
declare receiverID: string;
@BelongsTo(() => Account, 'receiverID')
declare receiver: Account;
@CreatedAt
declare date: Date;
}
+65
View File
@@ -0,0 +1,65 @@
import { Table, Column, Model, DataType, Scopes, DefaultScope, DeletedAt, BelongsToMany, ForeignKey} from 'sequelize-typescript';
@Table
@DefaultScope(() => ({
attributes:{ exclude: ['password']}
}))
@Scopes(() => ({
withPassword: {
attributes: {include: ['password']}
}
}))
export default class Account extends Model{
@Column({primaryKey: true, unique: true, allowNull: false})
declare id: string;
@Column
declare isBusiness: boolean;
@Column
declare displayName: string;
@Column(DataType.DECIMAL(20,2))
declare balance: number;
@Column
declare password: string;
@DeletedAt
declare deletedAt: Date | null;
@BelongsToMany(() => Account, () => BusinessOwnership, 'ownerAccountId', 'ownedAccountId')
declare ownedBusinesses: Account[];
@BelongsToMany(() => Account, () => BusinessOwnership, 'ownedAccountId', 'ownerAccountId')
declare owners: Account[];
override toJSON() {
const values = { ...this.get() };
delete values.password;
return values;
}
}
@Table
export class BusinessOwnership extends Model {
@ForeignKey(() => Account)
@Column
declare ownerAccountId: string;
@ForeignKey(() => Account)
@Column
declare ownedAccountId: string;
@DeletedAt
declare deletedAt: Date | null;
}
export async function getOwnedAccounts(user: Account){
let q = await Account.findByPk(user.id, {
include: ['ownedBusinesses'],
});
let ownedAccounts = q?.ownedBusinesses;
return ownedAccounts ?? [];
}
+44
View File
@@ -0,0 +1,44 @@
import { compare } from 'bcrypt';
import express from 'express';
import { logger } from '../util/logging';
import Account, { getOwnedAccounts } from '../model/user';
import { Scope } from '../util/db';
import { getJWT, requireAuth } from '../util/auth';
import { LoginRequest, LoginResponse } from '../messages/Login';
import { GenericMessage as Msg } from '../messages/Message';
const router = express.Router();
router.post('/login', async (req, res) => {
try {
const data : LoginRequest = req.body;
const user = await Account.scope(Scope.withPassword).findOne({where: { id: data.username}});
if (!user) return res.status(401).json(new Msg('Invalid credentials'));
const isMatch = await compare(data.password, user.password);
if (!isMatch) return res.status(401).json(new Msg('Invalid credentials'));
// successfully authenticated
let jwt = await getJWT(user);
res.cookie('jwt', jwt, {
httpOnly: true, // Prevent XSS
secure: process.env.NODE_ENV === 'production', // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 86400000, // 1 day
});
res.json(new LoginResponse(user, await getOwnedAccounts(user)));
}catch (err) {
logger.error('Failed to authenticate:', err);
res.status(500).json(new Msg('Failed to authenticate'));
}
});
router.post('/logout', (req, res) => {
res.clearCookie('jwt');
res.json({ message: 'Logged out successfully' });
});
router.get('/status',requireAuth , async (req, res) => {
return res.status(200).json({authenticated: true});
})
export default router;
+56
View File
@@ -0,0 +1,56 @@
import express from 'express';
import { logger } from '../util/logging';
import { requireAuth } from '../util/auth';
import Account, { BusinessOwnership, getOwnedAccounts } from '../model/user';
import { db } from '../util/db';
import Transaction from '../model/transaction';
import { SendRequest, SendResponse} from '../messages/Send';
import { GenericMessage as Err} from '../messages/Message';
const router = express.Router();
router.post('/', requireAuth, async (req, res) => {
try {
const user = res.locals.user as Account;
const data : SendRequest = req.body;
if ( Number(data.amount) <= 0) {
return res.status(400).json(new Err('Invalid transfer amount'));
}
const recipient = await Account.findOne({where: {id: data.recipientID}})
if (!recipient) {
return res.status(404).json(new Err('Recipient not found' ));
}
const sender = await Account.findOne({where: {id: data.senderID}})
if (!sender) {
return res.status(404).json(new Err('Sender not found' ));
}
let ownsAccount = (await getOwnedAccounts(user))?.some(
business => business.id == data.senderID
);
if(!(user.equals(sender) || ownsAccount )){
return res.status(403).json(new Err('Unauthorized sender'));
}
if (Number(sender.balance) < Number(data.amount)){
logger.debug(`Insufficient balance: ${sender.balance} < ${data.amount}`)
return res.status(402).json(new Err('Insufficient balance'))
}
await db.transaction(async (t) =>{
await sender.decrement({balance: data.amount});
await recipient.increment({balance: data.amount});
await Transaction.create({
amount: data.amount,
senderID: sender.id,
receiverID: recipient.id,
reference: data.reference
});
})
return res.status(200).json(new SendResponse(sender.balance))
} catch (err) {
logger.error('Failed to commit transaction:', err);
return res.status(500).json(new Err('Failed to commit transaction' ));
}
});
export default router;
+21
View File
@@ -0,0 +1,21 @@
import express from 'express';
import { logger } from '../util/logging';
import Transaction from '../model/transaction';
import { requireAuth } from '../util/auth';
const router = express.Router();
router.get('/', requireAuth, async (req, res) => {
try {
const transactions = await Transaction.findAll({
limit: 100,
order: [['date', 'DESC']]
});
res.json(transactions);
} catch (err) {
logger.error('Failed to fetch transactions:', err);
res.status(500).json({ error: 'Failed to fetch transactions' });
}
});
export default router;
+12
View File
@@ -0,0 +1,12 @@
import { db, testConnection } from "../util/db";
import { hash } from "bcrypt";
import Account from '../model/user';
(async () =>{
await testConnection();
await Account.create({
id: process.argv[2],
displayName: process.argv[3],
balance: process.argv[4],
password: await hash(process.argv[5], 10),
})
})();
+5
View File
@@ -0,0 +1,5 @@
import {hash} from 'bcrypt';
let pass = process.argv[2];
hash(pass, 10, function(err, hash) {
console.log(hash);
});
+11
View File
@@ -0,0 +1,11 @@
import { exportJWK, generateSecret } from "jose"
async function printSecret(secret){
const jwk = await exportJWK(secret);
console.log('🔑Generated Secret:');
console.log(JSON.stringify(jwk));
}
(async () => {
const secret = await generateSecret('HS256', {extractable: true});
await printSecret(secret);
})();
+6
View File
@@ -0,0 +1,6 @@
import {compare} from 'bcrypt';
let pass = process.argv[2];
let hash = process.argv[3];
compare(pass,hash, function(err, result){
console.log(result);
});
+53
View File
@@ -0,0 +1,53 @@
import { NextFunction, Request, Response } from "express";
import Account from "../model/user"
import { importJWK, SignJWT, jwtVerify } from "jose";
let key;
async function setKeyFromEnv() {
key = await importJWK(JSON.parse(process.env.FM_PRIVATE_KEY));
}
async function getJWT(user: Account){
let jwt = await new SignJWT()
.setSubject(user.id)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.sign(key);
return jwt
}
async function requireAuth(req: Request, res: Response, next: NextFunction) {
try {
const token = req.cookies.jwt; // Or req.headers.authorization
if (!token) {
return res.status(401).json({ error: 'Unauthorized: No token provided' });
}
const jwt= await jwtVerify(token, key);
const user = await Account.findOne({where: { id: jwt.payload.sub}});
if (!user) {
return res.status(401).json({ error: 'Unauthorized: User not found' });
}
//TODO extend req instead of using res.locals
res.locals.user = user;
next();
} catch (err) {
return res.status(401).json({ error: 'Unauthorized: Invalid token' });
}
}
function requireRole(role: string) {
return async (req: Request, res: Response, next: NextFunction) => {
// First, run requireAuth to ensure the user is authenticated
await requireAuth(req, res, () => {
if (res.locals.role !== role) {
return res.status(403).json({ error: 'Forbidden: Insufficient permissions' });
}
next(); // User is authenticated and has the required role
});
};
}
export {getJWT, setKeyFromEnv, requireAuth}
+36
View File
@@ -0,0 +1,36 @@
import { Sequelize } from 'sequelize-typescript';
import { logger } from './logging';
import Account, { BusinessOwnership } from '../model/user';
import Transaction from '../model/transaction';
enum Scope{
// for User
withPassword = 'withPassword',
}
// Initialize Sequelize
const db = new Sequelize({
dialect: 'postgres',
host: process.env.FM_DB_HOST || 'localhost',
port: parseInt(process.env.FM_DB_PORT || '5432'),
database: process.env.FM_DB_NAME || 'postgres',
username: process.env.FM_DB_USER || 'postgres',
password: process.env.FM_DB_PASSWORD || 'pass',
logging: logger.debug.bind(logger),
});
db.addModels([Account, BusinessOwnership, Transaction ])
// Test the connection
async function testConnection() {
try {
await db.authenticate();
logger.info('✅ Database connection established.');
} catch (err) {
logger.error('❌ Unable to connect to the database:', err);
process.exit(1); // Exit if DB connection fails
}
}
// Export Sequelize instance and models
export { logger, db, testConnection, Scope };
+17
View File
@@ -0,0 +1,17 @@
import winston, { format } from "winston";
const logger = winston.createLogger({
level:'info',
})
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: format.combine(
format.colorize({all: true}),
format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
format.printf((info) => `${info.timestamp} [${info.level}]: ${info.message}`),
)
}));
}
// Export Sequelize instance and models
export { logger };
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../dist/out-tsc/server",
"esModuleInterop": true,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"module": "nodenext",
"moduleResolution": "nodenext"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
-11
View File
@@ -1,11 +0,0 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes)
]
};
-342
View File
@@ -1,342 +0,0 @@
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
<!-- * * * * * * * to get started with your project! * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<style>
:host {
--bright-blue: oklch(51.01% 0.274 263.83);
--electric-violet: oklch(53.18% 0.28 296.97);
--french-violet: oklch(47.66% 0.246 305.88);
--vivid-pink: oklch(69.02% 0.277 332.77);
--hot-red: oklch(61.42% 0.238 15.34);
--orange-red: oklch(63.32% 0.24 31.68);
--gray-900: oklch(19.37% 0.006 300.98);
--gray-700: oklch(36.98% 0.014 302.71);
--gray-400: oklch(70.9% 0.015 304.04);
--red-to-pink-to-purple-vertical-gradient: linear-gradient(
180deg,
var(--orange-red) 0%,
var(--vivid-pink) 50%,
var(--electric-violet) 100%
);
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
90deg,
var(--orange-red) 0%,
var(--vivid-pink) 50%,
var(--electric-violet) 100%
);
--pill-accent: var(--bright-blue);
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol";
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1 {
font-size: 3.125rem;
color: var(--gray-900);
font-weight: 500;
line-height: 100%;
letter-spacing: -0.125rem;
margin: 0;
font-family: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol";
}
p {
margin: 0;
color: var(--gray-700);
}
main {
width: 100%;
min-height: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: 1rem;
box-sizing: inherit;
position: relative;
}
.angular-logo {
max-width: 9.2rem;
}
.content {
display: flex;
justify-content: space-around;
width: 100%;
max-width: 700px;
margin-bottom: 3rem;
}
.content h1 {
margin-top: 1.75rem;
}
.content p {
margin-top: 1.5rem;
}
.divider {
width: 1px;
background: var(--red-to-pink-to-purple-vertical-gradient);
margin-inline: 0.5rem;
}
.pill-group {
display: flex;
flex-direction: column;
align-items: start;
flex-wrap: wrap;
gap: 1.25rem;
}
.pill {
display: flex;
align-items: center;
--pill-accent: var(--bright-blue);
background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
color: var(--pill-accent);
padding-inline: 0.75rem;
padding-block: 0.375rem;
border-radius: 2.75rem;
border: 0;
transition: background 0.3s ease;
font-family: var(--inter-font);
font-size: 0.875rem;
font-style: normal;
font-weight: 500;
line-height: 1.4rem;
letter-spacing: -0.00875rem;
text-decoration: none;
white-space: nowrap;
}
.pill:hover {
background: color-mix(in srgb, var(--pill-accent) 15%, transparent);
}
.pill-group .pill:nth-child(6n + 1) {
--pill-accent: var(--bright-blue);
}
.pill-group .pill:nth-child(6n + 2) {
--pill-accent: var(--electric-violet);
}
.pill-group .pill:nth-child(6n + 3) {
--pill-accent: var(--french-violet);
}
.pill-group .pill:nth-child(6n + 4),
.pill-group .pill:nth-child(6n + 5),
.pill-group .pill:nth-child(6n + 6) {
--pill-accent: var(--hot-red);
}
.pill-group svg {
margin-inline-start: 0.25rem;
}
.social-links {
display: flex;
align-items: center;
gap: 0.73rem;
margin-top: 1.5rem;
}
.social-links path {
transition: fill 0.3s ease;
fill: var(--gray-400);
}
.social-links a:hover svg path {
fill: var(--gray-900);
}
@media screen and (max-width: 650px) {
.content {
flex-direction: column;
width: max-content;
}
.divider {
height: 1px;
width: 100%;
background: var(--red-to-pink-to-purple-horizontal-gradient);
margin-block: 1.5rem;
}
}
</style>
<main class="main">
<div class="content">
<div class="left-side">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 982 239"
fill="none"
class="angular-logo"
>
<g clip-path="url(#a)">
<path
fill="url(#b)"
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
/>
<path
fill="url(#c)"
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
/>
</g>
<defs>
<radialGradient
id="c"
cx="0"
cy="0"
r="1"
gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#FF41F8" />
<stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" />
<stop offset="1" stop-color="#FF41F8" stop-opacity="0" />
</radialGradient>
<linearGradient
id="b"
x1="0"
x2="982"
y1="192"
y2="192"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#F0060B" />
<stop offset="0" stop-color="#F0070C" />
<stop offset=".526" stop-color="#CC26D5" />
<stop offset="1" stop-color="#7702FF" />
</linearGradient>
<clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath>
</defs>
</svg>
<h1>Hello, {{ title() }}</h1>
<p>Congratulations! Your app is running. 🎉</p>
</div>
<div class="divider" role="separator" aria-label="Divider"></div>
<div class="right-side">
<div class="pill-group">
@for (item of [
{ title: 'Explore the Docs', link: 'https://angular.dev' },
{ title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' },
{ title: 'Prompt and best practices for AI', link: 'https://angular.dev/ai/develop-with-ai'},
{ title: 'CLI Docs', link: 'https://angular.dev/tools/cli' },
{ title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' },
{ title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' },
]; track item.title) {
<a
class="pill"
[href]="item.link"
target="_blank"
rel="noopener"
>
<span>{{ item.title }}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
height="14"
viewBox="0 -960 960 960"
width="14"
fill="currentColor"
>
<path
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
/>
</svg>
</a>
}
</div>
<div class="social-links">
<a
href="https://github.com/angular/angular"
aria-label="Github"
target="_blank"
rel="noopener"
>
<svg
width="25"
height="24"
viewBox="0 0 25 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Github"
>
<path
d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z"
/>
</svg>
</a>
<a
href="https://x.com/angular"
aria-label="X"
target="_blank"
rel="noopener"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="X"
>
<path
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
/>
</svg>
</a>
<a
href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw"
aria-label="Youtube"
target="_blank"
rel="noopener"
>
<svg
width="29"
height="20"
viewBox="0 0 29 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Youtube"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z"
/>
</svg>
</a>
</div>
</div>
</div>
</main>
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * -->
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * -->
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<router-outlet />
-3
View File
@@ -1,3 +0,0 @@
import { Routes } from '@angular/router';
export const routes: Routes = [];
-12
View File
@@ -1,12 +0,0 @@
import { Component, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
templateUrl: './app.html',
styleUrl: './app.less'
})
export class App {
protected readonly title = signal('fakemoney');
}
-1
View File
@@ -1 +0,0 @@
/* You can add global styles to this file, and also import other style files */
+12 -27
View File
@@ -1,33 +1,18 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{ {
"compileOnSave": false, "compileOnSave": false,
"compilerOptions": { "compilerOptions": {
"strict": true, "baseUrl": "./",
"noImplicitOverride": true, "outDir": "./dist/out-tsc",
"noPropertyAccessFromIndexSignature": true, "sourceMap": true,
"noImplicitReturns": true, "declaration": false,
"noFallthroughCasesInSwitch": true, "moduleResolution": "bundler",
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022", "target": "ES2022",
"module": "preserve" "module": "es2022",
}, "lib": ["es2022", "dom"],
"angularCompilerOptions": { "paths": {
"enableI18nLegacyMessageIdFormat": false, "@model/*": ["server/src/model/*"],
"strictInjectionParameters": true, "@message/*": ["server/src/messages/*"],
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
} }
] },
"exclude": ["node_modules"]
} }