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$Subjectthat emits whenpayis called.Create a
card$observable like:const card$ = combineCard(this.cardNumber$, this.expiry$, this.cvc$);cardshould publish objects with thecardNumber,expiry, andcvc:{ cardNumber, expiry, cvc, };Create a
payments$observable like:const payments$ = paymentPromises(this.paySubmitted$, card$);paymentspublishes promises whenthis.paySubmittedemits. Those promises resolve when the payment is complete.The
paymentsobservable 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>