Add cart view

This commit is contained in:
2021-01-02 22:13:10 +01:00
parent e21531dec0
commit 32340eb4cc
15 changed files with 311 additions and 37 deletions

View File

@@ -12,6 +12,7 @@ import { SocketIoConfig, SocketIoModule } from 'ngx-socket-io';
import { HttpClientModule } from '@angular/common/http';
import { AppRoutingModule } from 'src/routes';
import { NotFoundComponent } from './not-found/not-found.component';
import { CartComponent } from './cart/cart.component';
const config: SocketIoConfig = { url: 'http://localhost:2009', options: {} };
@@ -22,7 +23,8 @@ const config: SocketIoConfig = { url: 'http://localhost:2009', options: {} };
PaymentComponent,
PayComponent,
HelloComponent,
NotFoundComponent
NotFoundComponent,
CartComponent
],
imports: [
BrowserModule,

View File

@@ -1,3 +1,4 @@
import { getCurrencySymbol } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Socket } from 'ngx-socket-io';
@@ -26,8 +27,9 @@ export interface ICart {
}
export interface IPaymentMethod {
method: any;
method: CryptoUnits;
amount: number;
exRate: number;
}
export enum PaymentStatus {
@@ -43,19 +45,20 @@ export enum PaymentStatus {
export interface IInvoice {
selector: string;
paymentMethods: IPaymentMethod[];
receiveAddress: string;
paidWith?: CryptoUnits;
paid?: number;
paymentMethod?: CryptoUnits;
receiveAddress?: string;
transcationHash?: string;
confirmation?: number;
cart?: ICart[];
totalPrice?: number;
currency: string;
dueBy: number;
dueBy: Date;
status?: PaymentStatus;
email?: string;
successUrl: string;
cancelUrl: string;
createdAt?: number;
}
@Injectable({
@@ -70,9 +73,8 @@ export class BackendService {
selector: '',
paymentMethods: [],
receiveAddress: '',
paid: 0,
currency: 'USD',
dueBy: Date.now(),
dueBy: new Date(),
successUrl: '',
cancelUrl: ''
};
@@ -102,6 +104,9 @@ export class BackendService {
return this.socket;
}
/**
* Subscribe to the real-time status of the selected invoice.
*/
subscribeTo(selector: string): void {
this.socket.on('subscribe', (status: boolean) => {
if (status) {
@@ -118,12 +123,18 @@ export class BackendService {
this.socket.emit('subscribe', { selector });
}
/**
* This will update the current invoice
*/
updateInvoice(): void {
if (this.invoice !== undefined || this.invoice !== null) {
this.setInvoice(this.invoice.selector);
}
}
/**
* This will set the current selected invoice by the `selector`.
*/
setInvoice(selector: string): Promise<IInvoice> {
return new Promise(async (resolve, reject) => {
if (selector === undefined || selector === 'undefined' || selector === '') {
@@ -144,6 +155,9 @@ export class BackendService {
});
}
/**
* This will notify the backend that the user just cancelled the payment.
*/
cancelInvoice(): Promise<void> {
return new Promise(async (resolve, reject) => {
if (this.invoice.selector === '') {
@@ -162,6 +176,9 @@ export class BackendService {
});
}
/**
* This will set the payment method of the selected invoice.
*/
setPaymentMethod(method: CryptoUnits): Promise<void> {
return new Promise(async (resolve, reject) => {
if (this.invoice === null) { reject('Invoice is not set!'); return; }
@@ -170,12 +187,20 @@ export class BackendService {
responseType: 'json'
}).toPromise().then(() => {
this.setInvoice(this.invoice.selector);
resolve();
}).catch(err => {
reject(err);
});
});
}
currencyPrefix(): string {
return getCurrencySymbol(this.invoice.currency, 'narrow');
}
/**
* Can be used if the socket connection is broken or as initial call.
*/
getConfirmation(): Promise<number> {
return new Promise(async (resolve, reject) => {
if (this.invoice === null || this.invoice.status !== PaymentStatus.UNCONFIRMED) {
@@ -197,7 +222,7 @@ export class BackendService {
}
/**
* @returns Path to icon
* @returns Path to icon in assets folder
*/
getIcon(unit: CryptoUnits): string {
switch (unit) {
@@ -226,12 +251,30 @@ export class BackendService {
return null;
}
/**
* @returns The price to pay by cryptocurrency;
*/
getAmount(): string | undefined {
return this.invoice?.paymentMethods.find(item => {
return item.method === CryptoUnits.BITCOIN;
return item.method === this.invoice.paymentMethod;
})?.amount.toFixed(8);
}
/**
* Calculate the price in crypto of a specifc product.
* @param prodcut Index of product in cart
*/
calculateCryptoPrice(productNr: number): number {
if (this.invoice.cart === undefined) return 0;
if (this.invoice.paymentMethod === undefined) return 0;
const product = this.invoice.cart[productNr];
const exRate = this.invoice.paymentMethods.find(method => { return method.method === this.invoice.paymentMethod })?.exRate;
if (exRate === undefined) return 0;
return product.quantity * product.price / exRate;
}
getStatus(): string {
switch (this.invoice?.status) {
case PaymentStatus.PENDING:

View File

@@ -0,0 +1,50 @@
.cart {
margin: 0;
padding: 0;
width: 100%;
height: 400px;
background-color: #1c1c1c;
border-radius: 8px;
transform: translateY(-8px);
color: #fff;
}
.cart ul {
list-style: none;
margin: 0;
padding: 20px;
overflow-y: auto;
height: 359px;
}
.cart ul li {
display: grid;
padding: 1rem;
grid-template-columns: 64px 1fr auto;
grid-column-gap: 1rem;
border-radius: 12px;
}
.cart ul li:nth-child(even) {
background-color: #292929;
}
.image {
height: 64px;
width: 64px;
padding: 0;
margin: 0;
border-radius: 8px;
}
.name {
font-size: 11pt;
width: 80%;
margin: auto 0;
line-break: loose;
}
.price {
text-align: right;
margin: auto;
}

View File

@@ -0,0 +1,10 @@
<div class="cart" xyz="stagger-0.5 fade-100% down-1 ease-ease" >
<ul>
<li *ngFor="let item of this.backend.invoice.cart;let indexOfelement=index;" [ngClass]="{'xyz-in': this.state.showCart.value, 'xyz-out': !this.state.showCart.value}">
<img [src]="item.image" class="image">
<h5 class="name">{{ item.name }}</h5>
<span class="price"><b>{{ item.price.toFixed(2) }} {{ this.backend.currencyPrefix() }}</b><br>
{{ this.backend.calculateCryptoPrice(indexOfelement).toFixed(8) }} {{ this.backend.invoice.paymentMethod }}</span>
</li>
</ul>
</div>

View File

@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CartComponent } from './cart.component';
describe('CartComponent', () => {
let component: CartComponent;
let fixture: ComponentFixture<CartComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ CartComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(CartComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,20 @@
import { Component, OnInit } from '@angular/core';
import { BackendService } from '../backend.service';
import { StateService } from '../state.service';
@Component({
selector: 'app-cart',
templateUrl: './cart.component.html',
styleUrls: ['./cart.component.css']
})
export class CartComponent implements OnInit {
constructor(
public backend: BackendService,
public state: StateService
) { }
ngOnInit(): void {
}
}

View File

@@ -1,6 +1,7 @@
.header {
display: grid;
grid-template-columns: 1fr auto;
grid-template-columns: auto 1fr auto 1fr auto;
grid-template-rows: 1fr;
margin: 0;
padding: 0;
background-color: #fff;
@@ -12,23 +13,43 @@
border-bottom-right-radius: 0;
}
.header img {
height: 3rem;
.header .logo {
margin-top: 0.5rem;
margin-bottom: 1rem;
grid-column: 1;
grid-column: 3;
}
.header a {
.header .logo img {
height: 3rem;
}
/* Cancel button */
.cancel {
color: red;
border: 1px solid red;
border-block-end-width: 1px;
border-radius: 5px;
padding: 0.8rem;
margin: auto 0;
height: fit-content;
float: right;
margin: auto;
cursor: pointer;
grid-column: 2;
width: fit-content !important;
grid-column: 5;
transition: .2s ease;
}
#cart {
display: flex;
grid-column: 1;
vertical-align: middle;
margin: auto auto;
padding-left: 1rem;
cursor: pointer;
border-radius: 8px;
border: 1px solid #000;
padding: 0.8rem;
}
#cart span {
margin: auto auto;
padding-left: .5rem;
font-weight: bold;
}

View File

@@ -1,4 +1,24 @@
<div class="header">
<img src="assets/logo.svg">
<a *ngIf="this.backend.isInvoicePending()" (click)="cancel()" [style]="cancelProgressStyle">{{ startCancelling ? 'Are you sure?' : 'Cancel payment'}}</a>
<div id="cart" *ngIf="this.backend!.invoice!.status! > 0" (click)="this.state.toggleCart()">
<svg
xmlns="http://www.w3.org/2000/svg" *ngIf="!this.state.showCart.value" class="icon icon-tabler icon-tabler-shopping-cart"
width="32" height="32" viewBox="0 0 24 24" stroke-width="1.5" stroke="#2c3e50" title="Shopping cart"
fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<circle cx="9" cy="19" r="2" />
<circle cx="17" cy="19" r="2" />
<path d="M3 3h2l2 12a3 3 0 0 0 3 2h7a3 3 0 0 0 3 -2l1 -7h-15.2" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" *ngIf="this.state.showCart.value" class="icon icon-tabler icon-tabler-x"
width="32" height="32" viewBox="0 0 24 24" stroke-width="1.5" stroke="#2c3e50" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
<span>Cart</span>
</div>
<a href="https://librepay.me" target="_blank" class="logo">
<img src="assets/logo.svg">
</a>
<a class="cancel" *ngIf="this.backend.isInvoicePending()" (click)="cancel()" [style]="cancelProgressStyle">{{ startCancelling ? 'Are you sure?' : 'Cancel payment'}}</a>
</div>

View File

@@ -1,5 +1,6 @@
import { Component, OnInit } from '@angular/core';
import { BackendService } from '../backend.service';
import { StateService } from '../state.service';
@Component({
selector: 'app-header',
@@ -12,7 +13,10 @@ export class HeaderComponent implements OnInit {
cancelProgress = 0;
cancelProgressStyle = ""; // This is the style of the cancel button
constructor(public backend: BackendService) { }
constructor(
public backend: BackendService,
public state: StateService
) { }
ngOnInit(): void {
}
@@ -21,9 +25,10 @@ export class HeaderComponent implements OnInit {
if (!this.startCancelling) {
this.startCancelling = true;
const animation = setInterval(() => {
this.cancelProgress += 0.3;
this.cancelProgress += 0.5;
this.cancelProgressStyle = `
background-image: linear-gradient(90deg, rgba(255,120,120,0.3) ${this.cancelProgress.toFixed(1)}%, rgba(255,255,255,0) ${this.cancelProgress.toFixed(1)}%);
margin-left: 16px;
transform: scale(1.1);
`;

View File

@@ -1,5 +1,6 @@
import { Component, OnInit } from '@angular/core';
import { BackendService } from '../backend.service';
import { StateService } from '../state.service';
@Component({
selector: 'app-pay',
@@ -8,7 +9,10 @@ import { BackendService } from '../backend.service';
})
export class PayComponent implements OnInit {
constructor(public backend: BackendService) { }
constructor(
public backend: BackendService,
public state: StateService
) { }
ngOnInit(): void {
}

View File

@@ -3,7 +3,7 @@
-->
<div class="payment request" xyz="stagger-2 fade-100% down-1" *ngIf="this.backend.isInvoiceRequested()">
<h3 id="title">Choose your<br>payment method</h3>
<p id="price">{{ this.backend.invoice.totalPrice!.toFixed(2) }} {{ currencyPrefix() }}</p>
<p id="price">{{ this.backend.invoice.totalPrice!.toFixed(2) }} {{ this.backend.currencyPrefix() }}</p>
<ul id="list">
<li class="xyz-in" *ngFor="let coin of this.backend.invoice!!.paymentMethods" (click)="chooseMethod(coin.method)">
@@ -19,10 +19,11 @@
<!--
Main view
-->
<div class="payment main" xyz="stagger-2 fade-100% down-1 ease-ease" *ngIf="!this.backend.isInvoiceRequested() && ready"
<div class="payment main" xyz="stagger-0.5 fade-100% down-1 ease-ease"
*ngIf="!this.backend.isInvoiceRequested() && ready && !hideMain"
[ngClass]="{invalid: status === 'Expired' || status === 'Paid too little' || status === 'Cancelled by user'}">
<div class="qrWrapper xyz-in">
<div class="qrWrapper" [ngClass]="{'xyz-in': !this.state.showCart.value, 'xyz-out': this.state.showCart.value}">
<div class="qr">
<img src="assets/Bitcoin.svg">
<qrcode
@@ -37,13 +38,13 @@
<div class="data">
<!-- Payment data -->
<span id="target" class="xyz-in">Send to
<span id="target" [ngClass]="{'xyz-in': !this.state.showCart.value, 'xyz-out': this.state.showCart.value}">Send to
<h3>{{ this.backend.invoice?.receiveAddress }}</h3>
</span>
<span id="amount" class="xyz-in">Amount
<h3>{{ this.backend.getAmount() }} BTC <span class="price"> | {{ this.backend.invoice.totalPrice!.toFixed(2) }} {{ currencyPrefix() }}</span></h3>
<span id="amount" [ngClass]="{'xyz-in': !this.state.showCart.value, 'xyz-out': this.state.showCart.value}">Amount
<h3>{{ this.backend.getAmount() }} BTC <span class="price"> | {{ this.backend.invoice.totalPrice!.toFixed(2) }} {{ this.backend.currencyPrefix() }}</span></h3>
</span>
<span id="status" class="xyz-in">Status
<span id="status" [ngClass]="{'xyz-in': !this.state.showCart.value, 'xyz-out': this.state.showCart.value}">Status
<h3>
{{ status }}
<div class="loader" *ngIf="this.status === 'Unconfirmed'">
@@ -66,6 +67,11 @@
</div>
</div>
<!--
Cart view
-->
<app-cart id="cart" [ngStyle]="{'display': !hideMain ? 'none' : 'block'}"></app-cart>
<!--
Alerts
-->

View File

@@ -1,8 +1,8 @@
import { getCurrencySymbol } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { BackendService, CryptoUnits } from '../backend.service';
import { StateService } from '../state.service';
@Component({
selector: 'app-payment',
@@ -13,15 +13,21 @@ export class PaymentComponent implements OnInit {
paymentSelector = '';
confirmations = 0;
choosenPaymentMethod = CryptoUnits.BITCOIN;
status: string;
ready = false;
// XYZ class (will be xyz-out if cart is shown for example)
xyzClass: string;
hideMain: boolean;
constructor(
public backend: BackendService,
public state: StateService,
private route: ActivatedRoute
) {
this.status = this.backend.getStatus();
this.hideMain = false;
this.xyzClass = 'xyz-in';
}
ngOnInit(): void {
@@ -31,6 +37,20 @@ export class PaymentComponent implements OnInit {
this.get();
});
this.state.showCart.subscribe(cartStatus => {
if (cartStatus) {
this.xyzClass = 'xyz-out';
setTimeout(() => {
this.hideMain = true;
}, 700);
} else {
setTimeout(() => {
this.hideMain = false;
this.xyzClass = 'xyz-in';
}, 600);
}
})
this.backend.invoiceUpdate.subscribe(newInvoice => {
this.status = this.backend.getStatus();
});
@@ -40,10 +60,6 @@ export class PaymentComponent implements OnInit {
this.backend.setPaymentMethod(coin);
}
currencyPrefix(): string {
return getCurrencySymbol(this.backend.invoice.currency, 'narrow');
}
async get(): Promise<void> {
await this.backend.setInvoice(this.paymentSelector);
this.backend.getConfirmation().catch();

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { StateService } from './state.service';
describe('StateService', () => {
let service: StateService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(StateService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

26
src/app/state.service.ts Normal file
View File

@@ -0,0 +1,26 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { BackendService, PaymentStatus } from './backend.service';
@Injectable({
providedIn: 'root'
})
/**
* The state service is responsible to exchange data between components.
*/
export class StateService {
showCart: BehaviorSubject<boolean>;
constructor(private backend: BackendService) {
this.showCart = new BehaviorSubject<boolean>(false);
this.backend.invoiceUpdate.subscribe(invoice => {
this.showCart.next(false); // Hide cart if status changes
});
}
toggleCart() {
this.showCart.next(!this.showCart.value);
}
}

View File

@@ -11,4 +11,14 @@ body, html {
:host{
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
::-webkit-scrollbar {
background-color: transparent;
width: 8px;
}
::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255);
border-radius: 8px;
}