Request payment page
The problem
In this section, we will:
- Simulate making a 2 second AJAX request to create a payment when someone submits the form.
- Log
console.log("Asking for token with", card)
when the request is made. - Log
console.log("payment complete!");
when the payment is complete.
How to solve the problem
Create a
appComponent.pay(event)
method that is called when the form is submitted.Create a
appComponent.paySubmitted$
Subject
that emits whenpay
is called.Create a
card$
observable like:const card$ = combineCard(this.cardNumber$, this.expiry$, this.cvc$);
card
should publish objects with thecardNumber
,expiry
, andcvc
:{ cardNumber, expiry, cvc, };
Create a
payments$
observable like:const payments$ = paymentPromises(this.paySubmitted$, card$);
payments
publishes promises whenthis.paySubmitted
emits. Those promises resolve when the payment is complete.The
payments
observable will not be used in the template so we will need to subscribe to it as follows:payments$.subscribe((paymentPromise) => { paymentPromise.then(() => { console.log('payment complete!'); }); });
Log
console.log("Asking for token with", card)
when the request is made.
What you need to know
Use
(event)="method($event)"
to listen to an event in the template and call a method with the event.<form (submit)="pay($event)"></form>
Methods are on your component look like:
class AppComponent { pay() { } }
Call event.preventDefault() to prevent an submit event from posting to a url.
withLatestFrom works like combineLatest, but it only publishes when the source observable emits a value. It publishes an array with the last source value and sibling values.
source.pipe(withLatestFrom(siblingObservable1, siblingObservable2, ...));
The following will log the latest random number whenever the page is clicked:
<div id="clickMe">Click Me</div> <script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/7.4.0/rxjs.umd.min.js"></script> <script type="typescript"> const { fromEvent, interval } = rxjs; const { map, withLatestFrom } = rxjs.operators; const randomNumbers = interval(1000).pipe(map(() => Math.random())); const clicks = fromEvent(clickMe, "click"); clicks .pipe(withLatestFrom(randomNumbers)) .subscribe(([clickEvent, number]) => { console.log(number); }); </script>
- Use the following to create a Promise that takes 2 seconds to resolve:
new Promise((resolve) => { setTimeout(() => { resolve(1000); }, 2000); });
The solution
Click to see the solution
<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/7.4.0/rxjs.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/core-js/3.19.0/core.js"></script>
<script src="https://unpkg.com/@angular/core@12.2.16/bundles/core.umd.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/zone.js/0.11.4/zone.min.js"></script>
<script src="https://unpkg.com/@angular/common@12.2.16/bundles/common.umd.js"></script>
<script src="https://unpkg.com/@angular/compiler@12.2.16/bundles/compiler.umd.js"></script>
<script src="https://unpkg.com/@angular/platform-browser@12.2.16/bundles/platform-browser.umd.js"></script>
<script src="https://unpkg.com/@angular/platform-browser-dynamic@12.2.16/bundles/platform-browser-dynamic.umd.js"></script>
<my-app></my-app>
<script type="typescript">
// app.js
const { Component, VERSION } = ng.core;
const { BehaviorSubject, Subject, merge, combineLatest } = rxjs;
const { map, tap, scan, withLatestFrom } = rxjs.operators;
const cleanCardNumber = map((card) => {
if (card) {
return card.replace(/[\s-]/g, "");
}
});
const validateCard = map((card) => {
if (!card) {
return "There is no card";
}
if (card.length !== 16) {
return "There should be 16 characters in a card";
}
});
const log = (name) => {
return tap((value) => console.log(name, value));
};
function showOnlyWhenBlurredOnce(error$, blurred$) {
const errorEvents$ = error$.pipe(
map((error) => {
return { type: error ? "invalid" : "valid" };
})
);
const focusEvents$ = blurred$.pipe(
map((isBlurred) => {
return { type: isBlurred ? "blurred" : "focused" };
})
);
const events$ = merge(errorEvents$, focusEvents$);
const eventsToState = scan(
(previous, event) => {
switch (event.type) {
case "valid":
return { ...previous, isValid: true, showCardError: false };
case "invalid":
return {
...previous,
isValid: false,
showCardError: previous.hasBeenBlurred,
};
case "blurred":
return {
...previous,
hasBeenBlurred: true,
showCardError: !previous.isValid,
};
default:
return previous;
}
},
{
hasBeenBlurred: false,
showCardError: false,
isValid: false,
}
);
const state$ = events$.pipe(eventsToState);
return state$.pipe(map((state) => state.showCardError));
}
const expiryParts = map((expiry) => {
if (expiry) {
return expiry.split("-");
}
});
const validateExpiry = map((expiry) => {
if (!expiry) {
return "There is no expiry. Format MM-YY";
}
if (
expiry.length !== 2 ||
expiry[0].length !== 2 ||
expiry[1].length !== 2
) {
return "Expiry must be formatted like MM-YY";
}
});
const validateCVC = map((cvc) => {
if (!cvc) {
return "There is no CVC code";
}
if (cvc.length !== 3) {
return "The CVC must be at least 3 numbers";
}
if (isNaN(parseInt(cvc))) {
return "The CVC must be numbers";
}
});
function isCardInvalid(cardError$, expiryError$, cvcError$) {
return combineLatest([cardError$, expiryError$, cvcError$]).pipe(
map(([cardError, expiryError, cvcError]) => {
return !!(cardError || expiryError || cvcError);
})
);
}
function combineCard(cardNumber$, expiry$, cvc$) {
return combineLatest([cardNumber$, expiry$, cvc$]).pipe(
map(([cardNumber, expiry, cvc]) => {
return {
cardNumber,
expiry,
cvc,
};
})
);
}
function paymentPromises(paySubmitted$, card$) {
return paySubmitted$.pipe(withLatestFrom(card$)).pipe(
map(([paySubmitted, card]) => {
console.log("Asking for token with", card);
return new Promise((resolve) => {
setTimeout(() => {
resolve(1000);
}, 2000);
});
})
);
}
@Component({
selector: 'my-app',
template: `
<form (submit)="pay($event)">
<div class="message" *ngIf="showCardError$ | async">{{ cardError$ | async }}</div>
<div class="message" *ngIf="showExpiryError$ | async">{{ expiryError$ | async }}</div>
<div class="message" *ngIf="showCVCError$ | async">{{ cvcError$ | async }}</div>
<input
type="text"
name="cardNumber"
placeholder="Card Number"
(input)="userCardNumber$.next($event.target.value)"
(blur)="userCardNumberBlurred$.next(true)"
[class.is-error]="showCardError$ | async"
/>
<input
type="text"
name="expiry"
placeholder="MM-YY"
(input)="userExpiry$.next($event.target.value)"
(blur)="userExpiryBlurred$.next(true)"
[class.is-error]="showExpiryError$ | async"
/>
<input
type="text"
name="cvc"
placeholder="CVC"
(input)="userCVC$.next($event.target.value)"
(blur)="userCVCBlurred$.next(true)"
[class.is-error]="showCVCError$ | async"
/>
<button [disabled]="isCardInvalid$ | async">
PAY
</button>
</form>
UserCardNumber: {{ userCardNumber$ | async }} <br />
CardNumber: {{ cardNumber$ | async }} <br />
`
})
class AppComponent {
userCardNumber$ = new BehaviorSubject<string>();
userCardNumberBlurred$ = new Subject<boolean>();
userExpiry$ = new BehaviorSubject<[]>();
userExpiryBlurred$ = new Subject<boolean>();
userCVC$ = new BehaviorSubject<string>();
userCVCBlurred$ = new Subject<boolean>();
paySubmitted$ = new Subject<void>();
constructor() {
this.cardNumber$ = this.userCardNumber$
.pipe(cleanCardNumber)
.pipe(log("cardNumber"));
this.cardError$ = this.cardNumber$.pipe(validateCard);
this.showCardError$ = showOnlyWhenBlurredOnce(this.cardError$, this.userCardNumberBlurred$);
this.expiry$ = this.userExpiry$.pipe(expiryParts);
this.expiryError$ = this.expiry$.pipe(validateExpiry);
this.showExpiryError$ = showOnlyWhenBlurredOnce(this.expiryError$, this.userExpiryBlurred$);
this.cvc$ = this.userCVC$;
this.cvcError$ = this.cvc$.pipe(validateCVC);
this.showCVCError$ = showOnlyWhenBlurredOnce(this.cvcError$, this.userCVCBlurred$);
this.isCardInvalid$ = isCardInvalid(this.cardError$, this.expiryError$, this.cvcError$);
const card$ = combineCard(this.cardNumber$, this.expiry$, this.cvc$);
const payments$ = paymentPromises(this.paySubmitted$, card$);
payments$.subscribe((paymentPromise) => {
paymentPromise.then(() => {
console.log("payment complete!");
});
});
}
pay(event) {
event.preventDefault();
this.paySubmitted$.next();
}
}
// main.js
const { BrowserModule } = ng.platformBrowser;
const { NgModule } = ng.core;
const { CommonModule } = ng.common;
@NgModule({
imports: [
BrowserModule,
CommonModule,
],
declarations: [AppComponent],
bootstrap: [AppComponent],
providers: []
})
class AppModule {}
const { platformBrowserDynamic } = ng.platformBrowserDynamic;
platformBrowserDynamic()
.bootstrapModule(AppModule)
.catch(err => console.error(err));
</script>
<style>
@import url('https://fonts.googleapis.com/css?family=Raleway:400,500');
body {
background-color: rgba(8, 211, 67, 0.3);
padding: 2%;
font-family: 'Raleway', sans-serif;
font-size: 1em;
}
input {
display: block;
width: 100%;
box-sizing: border-box;
font-size: 1em;
font-family: 'Raleway', sans-serif;
font-weight: 500;
padding: 12px;
border: 1px solid #ccc;
outline-color: white;
transition: background-color 0.5s ease;
transition: outline-color 0.5s ease;
}
input[name='cardNumber'] {
border-bottom: 0;
}
input[name='expiry'],
input[name='cvc'] {
width: 50%;
}
input[name='expiry'] {
float: left;
border-right: 0;
}
input::placeholder {
color: #999;
font-weight: 400;
}
input:focus {
background-color: rgba(130, 245, 249, 0.1);
outline-color: #82f5f9;
}
input.is-error {
background-color: rgba(250, 55, 55, 0.1);
}
input.is-error:focus {
outline-color: #ffbdbd;
}
button {
font-size: 1em;
font-family: 'Raleway', sans-serif;
background-color: #08d343;
border: 0;
box-shadow: 0px 1px 3px 1px rgba(51, 51, 51, 0.16);
color: white;
font-weight: 500;
letter-spacing: 1px;
margin-top: 30px;
padding: 12px;
text-transform: uppercase;
width: 100%;
}
button:disabled {
opacity: 0.4;
background-color: #999999;
}
form {
background-color: white;
box-shadow: 0px 17px 22px 1px rgba(51, 51, 51, 0.16);
padding: 40px;
margin: 0 auto;
max-width: 500px;
}
.message {
margin-bottom: 20px;
color: #fa3737;
}
</style>