Credit Card
This advanced guide walks through building a simple credit card payment form with validations. It doesn’t use
can-define. Instead it uses Kefir.js
streams to make a ViewModel.
can-kefir is used to make the Kefir streams observable to can-stache.
In this guide, you will learn how to:
- Use Kefir streams.
- Use the event-reducer pattern.
- Handle promises (and side-effects) with streams.
The final widget looks like:
See the Pen Credit Card Guide (Advanced) by Bitovi (@bitovi) on CodePen.
To use the widget:
- Enter a Card Number, Expiration Date, and CVC.
- Click on the form so those inputs lose focus. The Pay button should become enabled.
- Click the Pay button to see the Pay button disabled for 2 seconds.
- Change the inputs to invalid values. An error message should appear, the invalid inputs should be highlighted red, and the Pay button should become disabled.
START THIS TUTORIAL BY CLICKING THE “EDIT ON CODEPEN” BUTTON IN THE TOP RIGHT CORNER OF THE FOLLOWING EMBED::
See the Pen Credit Card Guide (Starter) by Bitovi (@bitovi) on CodePen.
This CodePen has initial prototype HTML and CSS which is useful for getting the application to look right.
The following sections are broken down into:
- The problem — A description of what the section is trying to accomplish.
- What you need to know — Information about CanJS that is useful for solving the problem.
- The solution — The solution to the problem.
The following video walks through the entire guide; it was recorded for CanJS 3, but most of the same basic info applies:
Setup
The problem
We are going to try an alternate form of the basic CanJS setup. We
will have a component with cc-payment
as a custom tag.
The component ViewModel
should be a plain JavaScript object
whose properties are all Kefir.js
streams.
We will render the static content in the component view, but use a
constant stream to hold the amount
value.
What you need to know
Kefir.js allows you to create streams of events and transform those streams into other streams. For example, the following
numbers
stream produces three numbers with interval of 100 milliseconds:const numbers = Kefir.sequentially(100, [1, 2, 3]);
Now let’s create another stream based on the first one. As you might guess, it will produce 2, 4, and 6.
const numbers2 = numbers.map(x => x * 2);
Kefir supports both streams and properties. It’s worth reading Kefir’s documentation on the difference between streams and properties. In short:
- Properties retain their value
- Streams do not
Kefir.constant creates a property with the specified value:
const property = Kefir.constant(1);
can-kefir integrates streams into CanJS, including can-stache templates. Output the value of a stream like:
{{ stream.value }}
Or the error like:
{{ stream.error }}
The solution
Update the HTML tab to:
<cc-payment></cc-payment>
Update the JavaScript tab to:
import { Component, kefir as Kefir } from "//unpkg.com/can@5/ecosystem.mjs";
Component.extend({
tag: "cc-payment",
view: `
<form>
<input type="text" name="number" placeholder="Card Number"/>
<input type="text" name="expiry" placeholder="MM-YY"/>
<input type="text" name="cvc" placeholder="CVC"/>
<button>Pay \${{ this.amount.value }}</button>
</form>
`,
ViewModel: {
amount: {
default: () => Kefir.constant(1000)
}
}
});
Read the card number
The problem
Users will be able to enter a card number like 1234-1234-1234-1234
.
Let’s read the card number entered by the user, print it back, and also print back the cleaned card number (the entered number with no dashes).
What you need to know
can-kefir adds an emitterProperty method that returns a Kefir property, but also adds an
emitter
object with with.value()
and.error()
methods. The end result is a single object that has methods of a stream and property access to its emitter methods.import { kefir as Kefir } from "//unpkg.com/can@5/ecosystem.mjs"; const age = Kefir.emitterProperty(); age.onValue(function(age) { console.log(age) }); age.emitter.value(20) //-> logs 20 age.emitter.value(30) //-> logs 30
emitterProperty
property streams are useful data sinks when getting user data.Kefir streams and properties have a map method that maps values on one stream to values in a new stream:
const source = Kefir.sequentially(100, [1, 2, 3]); const result = source.map(x => x + 1); // source: ---1---2---3X // result: ---2---3---4X
<input on:input:value:to="KEY"/>
Listens to theinput
events produced by the<input>
element and writes the<input>
’s value toKEY
.can-kefir allows you to write to a
emitterProperty
’s with:<input value:to="emitterProperty.value"/>
The solution
Update the JavaScript tab to:
import { Component, kefir as Kefir } from "//unpkg.com/can@5/ecosystem.mjs";
Component.extend({
tag: "cc-payment",
view: `
<form>
User Entered: {{ this.userCardNumber.value }},
Card Number: {{ this.cardNumber.value }}
<input type="text" name="number" placeholder="Card Number"
on:input:value:to="this.userCardNumber.value"/>
<input type="text" name="expiry" placeholder="MM-YY"/>
<input type="text" name="cvc" placeholder="CVC"/>
<button>Pay \${{ this.amount.value }}</button>
</form>
`,
ViewModel: {
amount: {
default: () => Kefir.constant(1000)
},
userCardNumber: {
default: () => Kefir.emitterProperty()
},
get cardNumber() {
return this.userCardNumber.map((card) => {
if (card) {
return card.replace(/[\s-]/g, "");
}
});
}
}
});
Output the card error
The problem
As someone types a card number, let’s show the user a warning message about what they need to enter for the card number. It should go away if the card number is 16 characters.
What you need to know
Add the
cardError
message above the input like:<div class="message">{{cardError.value}}</div>
Validate a card with:
function validateCard(card) { if (!card) { return "There is no card" } if (card.length !== 16) { return "There should be 16 characters in a card"; } }
The solution
Update the JavaScript tab to:
import { Component, kefir as Kefir } from "//unpkg.com/can@5/ecosystem.mjs";
Component.extend({
tag: "cc-payment",
view: `
<form>
<div class="message">{{ this.cardError.value }}</div>
<input type="text" name="number" placeholder="Card Number"
on:input:value:to="this.userCardNumber.value"/>
<input type="text" name="expiry" placeholder="MM-YY"/>
<input type="text" name="cvc" placeholder="CVC"/>
<button>Pay \${{ this.amount.value }}</button>
</form>
`,
ViewModel: {
amount: {
default: () => Kefir.constant(1000)
},
userCardNumber: {
default: () => Kefir.emitterProperty()
},
get cardNumber() {
return this.userCardNumber.map((card) => {
if (card) {
return card.replace(/[\s-]/g, "");
}
});
},
get cardError() {
return this.cardNumber.map(this.validateCard);
},
// HELPER FUNCTIONS
validateCard(card) {
if (!card) {
return "There is no card"
}
if (card.length !== 16) {
return "There should be 16 characters in a card";
}
}
}
});
Only show the card error when blurred
The problem
Let’s only show the cardNumber error if the user blurs the card number input. Once the user blurs, we will update the card number error, if there is one, on every keystroke.
We should also add class="is-error"
to the input when it has an error.
For this to work, we will need to track if the user has blurred
the input in a userCardNumberBlurred
emitterProperty
.
What you need to know
We can call an
emitterProperty
’s value in the template when something happens like:<div on:click="emitterProperty.emitter.value(true)">
One of the most useful patterns in constructing streams is the event-reducer pattern. On a high-level it involves making streams events, and using those events to update a stateful object.
For example, we might have a
first
and alast
stream:const first = Kefir.sequentially(100, ["Justin", "Ramiya"]) const last = Kefir.sequentially(100, ["Shah", "Meyer"]).delay(50); // first: ---Justin---RamiyaX // last: ------Shah__---Meyer_X
We can promote these to event-like objects with
.map
:const firstEvents = first.map( (first) => { return {type: "first", value: first} }) const lastEvents = first.map( (last) => { return {type: "last", value: last} }) // firstEvents: ---{t:"f"}---{t:"f"}X // lastEvents: ------{t:"l"}---{t:"l"}X
Next, we can merge these into a single stream:
const merged = Kefir.merge([firstEvents,lastEvents]) // merged: ---{t:"f"}-{t:"l"}-{t:"f"}-{t:"l"}X
We can "reduce" (or
.scan
) these events based on a previous state. The following copies the old state and updates it using the event data:const state = merged.scan((previous, event) => { const copy = Object.assign({}, previous); copy[event.type] = event.value; return copy; }, {first: "", last: ""}); // state: ---{first:"Justin", last:""} // -{first:"Justin", last:"Shah"} // -{first:"Ramiya", last:"Shah"} // -{first:"Ramiya", last:"Meyer"}X
The following is a more common structure for the reducer pattern:
const state = merged.scan((previous, event) => { switch( event.type ) { case "first": return Object.assign({}, previous,{ first: event.value }); case "last": return Object.assign({}, previous,{ last: event.value }); default: return previous; } }, {first: "", last: ""})
Finally, we can map this state to another value:
const fullName = state.map( (state) => state.first +" "+ state.last ); // fullName: ---Justin // -Justin Shah // -Ramiya Shah // -Ramiya MeyerX
Note:
fullName
can be derived more simply fromKefir.combine
. The reducer pattern is used here for illustrative purposes. It is able to support a larger set of stream transformations thanKefir.combine
.On any stream, you can call
stream.toProperty()
to return a property that will retain its values. This can be useful if you want a stream’s immediate value.
The solution
Update the JavaScript tab to:
import { Component, kefir as Kefir } from "//unpkg.com/can@5/ecosystem.mjs";
Component.extend({
tag: "cc-payment",
view: `
<form>
{{# if(this.showCardError.value) }}
<div class="message">{{ this.cardError.value }}</div>
{{/ if }}
<input type="text" name="number" placeholder="Card Number"
on:input:value:to="this.userCardNumber.value"
on:blur="this.userCardNumberBlurred.emitter.value(true)"
{{# if(this.showCardError.value) }}class="is-error"{{/ if }}/>
<input type="text" name="expiry" placeholder="MM-YY"/>
<input type="text" name="cvc" placeholder="CVC"/>
<button>Pay \${{ this.amount.value }}</button>
</form>
`,
ViewModel: {
amount: {
default: () => Kefir.constant(1000)
},
userCardNumber: {
default: () => Kefir.emitterProperty()
},
userCardNumberBlurred: {
default: () => Kefir.emitterProperty()
},
get cardNumber() {
return this.userCardNumber.map((card) => {
if (card) {
return card.replace(/[\s-]/g, "");
}
});
},
get cardError() {
return this.cardNumber.map(this.validateCard);
},
get showCardError() {
return this.showOnlyWhenBlurredOnce(this.cardError, this.userCardNumberBlurred);
},
// HELPER FUNCTIONS
validateCard(card) {
if (!card) {
return "There is no card"
}
if (card.length !== 16) {
return "There should be 16 characters in a card";
}
},
showOnlyWhenBlurredOnce(errorStream, blurredStream) {
const errorEvent = errorStream.map((error) => {
if (!error) {
return {
type: "valid"
}
} else {
return {
type: "invalid",
message: error
}
}
});
const focusEvents = blurredStream.map((isBlurred) => {
if (isBlurred === undefined) {
return {};
}
return isBlurred ? {
type: "blurred"
} : {
type: "focused"
};
});
return Kefir.merge([errorEvent, focusEvents])
.scan((previous, event) => {
switch (event.type) {
case "valid":
return Object.assign({}, previous, {
isValid: true,
showCardError: false
});
case "invalid":
return Object.assign({}, previous, {
isValid: false,
showCardError: previous.hasBeenBlurred
});
case "blurred":
return Object.assign({}, previous, {
hasBeenBlurred: true,
showCardError: !previous.isValid
});
default:
return previous;
}
}, {
hasBeenBlurred: false,
showCardError: false,
isValid: false
}).map((state) => {
return state.showCardError
});
}
}
});
Read, validate, and show the error of the expiry
The problem
Let’s make the expiry
input element just like the cardNumber
element. The expiry should be entered like 12-17
and be stored as an
array like ["12","16"]
. Make sure to:
- validate the expiry
- show a warning validation message in a
<div class="message">
element - add
class="is-error"
to the element if we should show theexpiry
error.
What you need to know
- Use
expiry.split("-")
to convert what a user typed into an array of numbers. - To validate the expiry use:
function validateExpiry(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"; } }
The solution
Update the JavaScript tab to:
import { Component, kefir as Kefir } from "//unpkg.com/can@5/ecosystem.mjs";
Component.extend({
tag: "cc-payment",
view: `
<form>
{{# if(this.showCardError.value) }}
<div class="message">{{ this.cardError.value }}</div>
{{/ if }}
{{# if(this.showExpiryError.value) }}
<div class="message">{{ this.expiryError.value }}</div>
{{/ if }}
<input type="text" name="number" placeholder="Card Number"
on:input:value:to="this.userCardNumber.value"
on:blur="this.userCardNumberBlurred.emitter.value(true)"
{{# if(this.showCardError.value) }}class="is-error"{{/ if }}/>
<input type="text" name="expiry" placeholder="MM-YY"
on:input:value:to="this.userExpiry.value"
on:blur="this.userExpiryBlurred.emitter.value(true)"
{{# if(this.showExpiryError.value) }}class="is-error"{{/ if }}/>
<input type="text" name="cvc" placeholder="CVC"/>
<button>Pay \${{ this.amount.value }}</button>
</form>
`,
ViewModel: {
amount: {
default: () => Kefir.constant(1000)
},
userCardNumber: {
default: () => Kefir.emitterProperty()
},
userCardNumberBlurred: {
default: () => Kefir.emitterProperty()
},
userExpiry: {
default: () => Kefir.emitterProperty()
},
userExpiryBlurred: {
default: () => Kefir.emitterProperty()
},
get cardNumber() {
return this.userCardNumber.map((card) => {
if (card) {
return card.replace(/[\s-]/g, "");
}
});
},
get cardError() {
return this.cardNumber.map(this.validateCard);
},
get showCardError() {
return this.showOnlyWhenBlurredOnce(this.cardError, this.userCardNumberBlurred);
},
// EXPIRY
get expiry() {
return this.userExpiry.map((expiry) => {
if (expiry) {
return expiry.split("-")
}
});
},
get expiryError() {
return this.expiry.map(this.validateExpiry).toProperty();
},
get showExpiryError() {
return this.showOnlyWhenBlurredOnce(this.expiryError, this.userExpiryBlurred);
},
// HELPER FUNCTIONS
validateCard(card) {
if (!card) {
return "There is no card"
}
if (card.length !== 16) {
return "There should be 16 characters in a card";
}
},
validateExpiry(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";
}
},
showOnlyWhenBlurredOnce(errorStream, blurredStream) {
const errorEvent = errorStream.map((error) => {
if (!error) {
return {
type: "valid"
}
} else {
return {
type: "invalid",
message: error
}
}
});
const focusEvents = blurredStream.map((isBlurred) => {
if (isBlurred === undefined) {
return {};
}
return isBlurred ? {
type: "blurred"
} : {
type: "focused"
};
});
return Kefir.merge([errorEvent, focusEvents])
.scan((previous, event) => {
switch (event.type) {
case "valid":
return Object.assign({}, previous, {
isValid: true,
showCardError: false
});
case "invalid":
return Object.assign({}, previous, {
isValid: false,
showCardError: previous.hasBeenBlurred
});
case "blurred":
return Object.assign({}, previous, {
hasBeenBlurred: true,
showCardError: !previous.isValid
});
default:
return previous;
}
}, {
hasBeenBlurred: false,
showCardError: false,
isValid: false
}).map((state) => {
return state.showCardError
});
}
}
});
Read, validate, and show the error of the CVC
The problem
Let’s make the CVC
input element just like the cardNumber
and expiry
element. Make sure to:
- validate the cvc
- show a warning validation message in a
<div class="message">
element - add
class="is-error"
to the element if we should show theCVC
error.
What you need to know
- The
cvc
can be saved as whatever the user entered. No special processing necessary. - To validate CVC:
function validateCVC(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"; } }
The solution
Update the JavaScript tab to:
import { Component, kefir as Kefir } from "//unpkg.com/can@5/ecosystem.mjs";
Component.extend({
tag: "cc-payment",
view: `
<form>
{{# if(this.showCardError.value) }}
<div class="message">{{ this.cardError.value }}</div>
{{/ if }}
{{# if(this.showExpiryError.value) }}
<div class="message">{{ this.expiryError.value }}</div>
{{/ if }}
{{# if(this.showCVCError.value) }}
<div class="message">{{ this.cvcError.value }}</div>
{{/ if }}
<input type="text" name="number" placeholder="Card Number"
on:input:value:to="this.userCardNumber.value"
on:blur="this.userCardNumberBlurred.emitter.value(true)"
{{# if(this.showCardError.value) }}class="is-error"{{/ if }}/>
<input type="text" name="expiry" placeholder="MM-YY"
on:input:value:to="this.userExpiry.value"
on:blur="this.userExpiryBlurred.emitter.value(true)"
{{# if(this.showExpiryError.value) }}class="is-error"{{/ if }}/>
<input type="text" name="cvc" placeholder="CVC"
on:input:value:to="this.userCVC.value"
on:blur="this.userCVCBlurred.emitter.value(true)"
{{# if(this.showCVCError.value) }}class="is-error"{{/ if }}/>
<button>Pay \${{ this.amount.value }}</button>
</form>
`,
ViewModel: {
amount: {
default: () => Kefir.constant(1000)
},
userCardNumber: {
default: () => Kefir.emitterProperty()
},
userCardNumberBlurred: {
default: () => Kefir.emitterProperty()
},
userExpiry: {
default: () => Kefir.emitterProperty()
},
userExpiryBlurred: {
default: () => Kefir.emitterProperty()
},
userCVC: {
default: () => Kefir.emitterProperty(),
},
userCVCBlurred: {
default: () => Kefir.emitterProperty()
},
get cardNumber() {
return this.userCardNumber.map((card) => {
if (card) {
return card.replace(/[\s-]/g, "");
}
});
},
get cardError() {
return this.cardNumber.map(this.validateCard);
},
get showCardError() {
return this.showOnlyWhenBlurredOnce(this.cardError, this.userCardNumberBlurred);
},
// EXPIRY
get expiry() {
return this.userExpiry.map((expiry) => {
if (expiry) {
return expiry.split("-")
}
});
},
get expiryError() {
return this.expiry.map(this.validateExpiry).toProperty();
},
get showExpiryError() {
return this.showOnlyWhenBlurredOnce(this.expiryError, this.userExpiryBlurred);
},
// CVC
get cvc () {
return this.userCVC;
},
get cvcError() {
return this.cvc.map(this.validateCVC).toProperty();
},
get showCVCError() {
return this.showOnlyWhenBlurredOnce(this.cvcError, this.userCVCBlurred);
},
// HELPER FUNCTIONS
validateCard(card) {
if (!card) {
return "There is no card"
}
if (card.length !== 16) {
return "There should be 16 characters in a card";
}
},
validateExpiry(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";
}
},
validateCVC(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";
}
},
showOnlyWhenBlurredOnce(errorStream, blurredStream) {
const errorEvent = errorStream.map((error) => {
if (!error) {
return {
type: "valid"
}
} else {
return {
type: "invalid",
message: error
}
}
});
const focusEvents = blurredStream.map((isBlurred) => {
if (isBlurred === undefined) {
return {};
}
return isBlurred ? {
type: "blurred"
} : {
type: "focused"
};
});
return Kefir.merge([errorEvent, focusEvents])
.scan((previous, event) => {
switch (event.type) {
case "valid":
return Object.assign({}, previous, {
isValid: true,
showCardError: false
});
case "invalid":
return Object.assign({}, previous, {
isValid: false,
showCardError: previous.hasBeenBlurred
});
case "blurred":
return Object.assign({}, previous, {
hasBeenBlurred: true,
showCardError: !previous.isValid
});
default:
return previous;
}
}, {
hasBeenBlurred: false,
showCardError: false,
isValid: false
}).map((state) => {
return state.showCardError
});
}
}
});
Disable the pay button if any part of the card has an error
The problem
Let’s disable the Pay button until the card, expiry, and cvc are valid.
What you need to know
Kefir.combine
can combine several values into a single value:const first = Kefir.sequentially(100, ["Justin", "Ramiya"]) const last = Kefir.sequentially(100, ["Shah", "Meyer"]).delay(50); // first: ---Justin---RamiyaX // last: ------Shah__---Meyer_X const fullName = Kefir.combine([first, last], (first, last) => { return first +" "+ last; }) // fullName: ---Justin Shah // -Ramiya Shah // -Ramiya MeyerX
- childProp:from can set a property from another value:
<input checked:from="someKey"/>
The solution
Update the JavaScript tab to:
import { Component, kefir as Kefir } from "//unpkg.com/can@5/ecosystem.mjs";
Component.extend({
tag: "cc-payment",
view: `
<form>
{{# if(this.showCardError.value) }}
<div class="message">{{ this.cardError.value }}</div>
{{/ if }}
{{# if(this.showExpiryError.value) }}
<div class="message">{{ this.expiryError.value }}</div>
{{/ if }}
{{# if(this.showCVCError.value) }}
<div class="message">{{ this.cvcError.value }}</div>
{{/ if }}
<input type="text" name="number" placeholder="Card Number"
on:input:value:to="this.userCardNumber.value"
on:blur="this.userCardNumberBlurred.emitter.value(true)"
{{# if(this.showCardError.value) }}class="is-error"{{/ if }}/>
<input type="text" name="expiry" placeholder="MM-YY"
on:input:value:to="this.userExpiry.value"
on:blur="this.userExpiryBlurred.emitter.value(true)"
{{# if(this.showExpiryError.value) }}class="is-error"{{/ if }}/>
<input type="text" name="cvc" placeholder="CVC"
on:input:value:to="this.userCVC.value"
on:blur="this.userCVCBlurred.emitter.value(true)"
{{# if(this.showCVCError.value) }}class="is-error"{{/ if }}/>
<button disabled:from="this.isCardInvalid.value">
Pay \${{ this.amount.value }}
</button>
</form>
`,
ViewModel: {
amount: {
default: () => Kefir.constant(1000)
},
userCardNumber: {
default: () => Kefir.emitterProperty()
},
userCardNumberBlurred: {
default: () => Kefir.emitterProperty()
},
userExpiry: {
default: () => Kefir.emitterProperty()
},
userExpiryBlurred: {
default: () => Kefir.emitterProperty()
},
userCVC: {
default: () => Kefir.emitterProperty(),
},
userCVCBlurred: {
default: () => Kefir.emitterProperty()
},
get cardNumber() {
return this.userCardNumber.map((card) => {
if (card) {
return card.replace(/[\s-]/g, "");
}
});
},
get cardError() {
return this.cardNumber.map(this.validateCard);
},
get showCardError() {
return this.showOnlyWhenBlurredOnce(this.cardError, this.userCardNumberBlurred);
},
// EXPIRY
get expiry() {
return this.userExpiry.map((expiry) => {
if (expiry) {
return expiry.split("-")
}
});
},
get expiryError() {
return this.expiry.map(this.validateExpiry).toProperty();
},
get showExpiryError() {
return this.showOnlyWhenBlurredOnce(this.expiryError, this.userExpiryBlurred);
},
// CVC
get cvc () {
return this.userCVC;
},
get cvcError() {
return this.cvc.map(this.validateCVC).toProperty();
},
get showCVCError() {
return this.showOnlyWhenBlurredOnce(this.cvcError, this.userCVCBlurred);
},
get isCardInvalid(){
return Kefir.combine([this.cardError, this.expiryError, this.cvcError],
(cardError, expiryError, cvcError) => {
return !!(cardError || expiryError || cvcError)
});
},
// HELPER FUNCTIONS
validateCard(card) {
if (!card) {
return "There is no card"
}
if (card.length !== 16) {
return "There should be 16 characters in a card";
}
},
validateExpiry(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";
}
},
validateCVC(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";
}
},
showOnlyWhenBlurredOnce(errorStream, blurredStream) {
const errorEvent = errorStream.map((error) => {
if (!error) {
return {
type: "valid"
}
} else {
return {
type: "invalid",
message: error
}
}
});
const focusEvents = blurredStream.map((isBlurred) => {
if (isBlurred === undefined) {
return {};
}
return isBlurred ? {
type: "blurred"
} : {
type: "focused"
};
});
return Kefir.merge([errorEvent, focusEvents])
.scan((previous, event) => {
switch (event.type) {
case "valid":
return Object.assign({}, previous, {
isValid: true,
showCardError: false
});
case "invalid":
return Object.assign({}, previous, {
isValid: false,
showCardError: previous.hasBeenBlurred
});
case "blurred":
return Object.assign({}, previous, {
hasBeenBlurred: true,
showCardError: !previous.isValid
});
default:
return previous;
}
}, {
hasBeenBlurred: false,
showCardError: false,
isValid: false
}).map((state) => {
return state.showCardError
});
}
}
});
Implement the payment button
The problem
When the user submits the form, let’s simulate making a 2 second AJAX request to create a payment. While the request is being made, we will change the Pay button to say Paying.
What you need to know
Use the following to create a Promise that takes 2 seconds to resolve:
new Promise(function(resolve) { setTimeout(function() { resolve(1000); }, 2000); });
Use on:event to listen to an event on an element and call a method in can-stache. For example, the following calls
doSomething()
when the<div>
is clicked:<div on:click="doSomething(scope.event)"> ... </div>
Notice that it also passed the event object with scope.event.
To prevent a form from submitting, call event.preventDefault().
Kefir.fromPromise returns a stream from the resolved value of a promise.
Kefir.combine takes a list of passive streams where the combinator will not be called when the passive streams emit a value.
Kefir.concat concatenates streams so events are produced in order.
const a = Kefir.sequentially(100, [0, 1, 2]); const b = Kefir.sequentially(100, [3, 4, 5]); const abc = Kefir.concat([a, b]); //a: ---0---1---2X //b: ---3---4---5X //abc: ---0---1---2---3---4---5X
Kefir.flatMap flattens a stream of streams to a single stream of values.
const count = Kefir.sequentially(100, [1, 2, 3]); const streamOfStreams = count.map( (count) => { return Kefir.interval(40, count).take(4) }); const result = streamOfStreams.flatMap(); // source: ----------1---------2---------3X // // spawned 1: ---1---1---1---1X // spawned 2: ---2---2---2---2X // spawned 3: ---3---3---3---3X // result: -------------1---1---1-2-1-2---2-3-2-3---3---3X
I think of this like promises’ ability to resolve when an “inner” promise resolves. For example,
resultPromise
below resolves with theinnerPromise
:const outerPromise = new Promise((resolve) => { setTimeout(() => { resolve("outer") }, 100); }); return innerPromise = new Promise((resolve) => { setTimeout(() => { resolve("inner") }, 200); }); const resultPromise = outerPromise.then(function(value) { // value -> "outer" return innerPromise; }); resultPromise.then(function(value) { // value -> "inner" })
In some ways,
outerPromise
is a promise of promises. Promises flatten by default. With Kefir, you callflatMap
to flatten streams.
The solution
Update the JavaScript tab to:
import { Component, kefir as Kefir } from "//unpkg.com/can@5/ecosystem.mjs";
Component.extend({
tag: "cc-payment",
view: `
<form on:submit="this.pay(scope.event)">
{{# if(this.showCardError.value) }}
<div class="message">{{ this.cardError.value }}</div>
{{/ if }}
{{# if(this.showExpiryError.value) }}
<div class="message">{{ this.expiryError.value }}</div>
{{/ if }}
{{# if(this.showCVCError.value) }}
<div class="message">{{ this.cvcError.value }}</div>
{{/ if }}
<input type="text" name="number" placeholder="Card Number"
on:input:value:to="this.userCardNumber.value"
on:blur="this.userCardNumberBlurred.emitter.value(true)"
{{# if(this.showCardError.value) }}class="is-error"{{/ if }}/>
<input type="text" name="expiry" placeholder="MM-YY"
on:input:value:to="this.userExpiry.value"
on:blur="this.userExpiryBlurred.emitter.value(true)"
{{# if(this.showExpiryError.value) }}class="is-error"{{/ if }}/>
<input type="text" name="cvc" placeholder="CVC"
on:input:value:to="this.userCVC.value"
on:blur="this.userCVCBlurred.emitter.value(true)"
{{# if(this.showCVCError.value) }}class="is-error"{{/ if }}/>
<button disabled:from="this.isCardInvalid.value">
{{# eq(this.paymentStatus.value.status, "pending") }}Paying{{ else }}Pay{{/ eq }} \${{ this.amount.value }}
</button>
</form>
`,
ViewModel: {
amount: {
default: () => Kefir.constant(1000)
},
userCardNumber: {
default: () => Kefir.emitterProperty()
},
userCardNumberBlurred: {
default: () => Kefir.emitterProperty()
},
userExpiry: {
default: () => Kefir.emitterProperty()
},
userExpiryBlurred: {
default: () => Kefir.emitterProperty()
},
userCVC: {
default: () => Kefir.emitterProperty(),
},
userCVCBlurred: {
default: () => Kefir.emitterProperty()
},
payClicked: {
default: () => Kefir.emitterProperty()
},
get cardNumber() {
return this.userCardNumber.map((card) => {
if (card) {
return card.replace(/[\s-]/g, "");
}
});
},
get cardError() {
return this.cardNumber.map(this.validateCard);
},
get showCardError() {
return this.showOnlyWhenBlurredOnce(this.cardError, this.userCardNumberBlurred);
},
// EXPIRY
get expiry() {
return this.userExpiry.map((expiry) => {
if (expiry) {
return expiry.split("-")
}
});
},
get expiryError() {
return this.expiry.map(this.validateExpiry).toProperty();
},
get showExpiryError() {
return this.showOnlyWhenBlurredOnce(this.expiryError, this.userExpiryBlurred);
},
// CVC
get cvc () {
return this.userCVC;
},
get cvcError() {
return this.cvc.map(this.validateCVC).toProperty();
},
get showCVCError() {
return this.showOnlyWhenBlurredOnce(this.cvcError, this.userCVCBlurred);
},
get isCardInvalid(){
return Kefir.combine([this.cardError, this.expiryError, this.cvcError],
(cardError, expiryError, cvcError) => {
return !!(cardError || expiryError || cvcError)
});
},
get card() {
return Kefir.combine([this.cardNumber, this.expiry, this.cvc],
(cardNumber, expiry, cvc) => {
return {cardNumber , expiry , cvc};
});
},
// STREAM< Promise<Number> | undefined >
get paymentPromises(){
return Kefir.combine([this.payClicked], [this.card], (payClicked, card) => {
if (payClicked) {
console.log("Asking for token with", card);
return new Promise((resolve) => {
setTimeout(() => {
resolve(1000);
}, 2000);
})
}
});
},
// STREAM< STREAM<STATUS> >
// This is a stream of streams of status objects.
get paymentStatusStream () {
return this.paymentPromises.map((promise) => {
if (promise) {
// STREAM<STATUS>
return Kefir.concat([
Kefir.constant({
status: "pending"
}),
Kefir.fromPromise(promise).map((value) => {
return {
status: "resolved",
value: value
};
})
]);
} else {
// STREAM
return Kefir.constant({
status: "waiting"
});
}
});
},
// STREAM<STATUS> //{status: "waiting"} | {status: "resolved"}
get paymentStatus(){
return this.paymentStatusStream.flatMap().toProperty();
},
pay(event) {
event.preventDefault();
this.payClicked.emitter.value(true)
},
// HELPER FUNCTIONS
validateCard(card) {
if (!card) {
return "There is no card"
}
if (card.length !== 16) {
return "There should be 16 characters in a card";
}
},
validateExpiry(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";
}
},
validateCVC(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";
}
},
showOnlyWhenBlurredOnce(errorStream, blurredStream) {
const errorEvent = errorStream.map((error) => {
if (!error) {
return {
type: "valid"
}
} else {
return {
type: "invalid",
message: error
}
}
});
const focusEvents = blurredStream.map((isBlurred) => {
if (isBlurred === undefined) {
return {};
}
return isBlurred ? {
type: "blurred"
} : {
type: "focused"
};
});
return Kefir.merge([errorEvent, focusEvents])
.scan((previous, event) => {
switch (event.type) {
case "valid":
return Object.assign({}, previous, {
isValid: true,
showCardError: false
});
case "invalid":
return Object.assign({}, previous, {
isValid: false,
showCardError: previous.hasBeenBlurred
});
case "blurred":
return Object.assign({}, previous, {
hasBeenBlurred: true,
showCardError: !previous.isValid
});
default:
return previous;
}
}, {
hasBeenBlurred: false,
showCardError: false,
isValid: false
}).map((state) => {
return state.showCardError
});
}
}
});
Disable the payment button while payments are pending
The problem
Let’s prevent the Pay button from being clicked while the payment is processing.
What you need to know
- You know everything you need to know.
The solution
Update the JavaScript tab to:
import { Component, kefir as Kefir } from "//unpkg.com/can@5/ecosystem.mjs";
Component.extend({
tag: "cc-payment",
view: `
<form on:submit="this.pay(scope.event)">
{{# if(this.showCardError.value) }}
<div class="message">{{ this.cardError.value }}</div>
{{/ if }}
{{# if(this.showExpiryError.value) }}
<div class="message">{{ this.expiryError.value }}</div>
{{/ if }}
{{# if(this.showCVCError.value) }}
<div class="message">{{ this.cvcError.value }}</div>
{{/ if }}
<input type="text" name="number" placeholder="Card Number"
on:input:value:to="this.userCardNumber.value"
on:blur="this.userCardNumberBlurred.emitter.value(true)"
{{# if(this.showCardError.value) }}class="is-error"{{/ if }}/>
<input type="text" name="expiry" placeholder="MM-YY"
on:input:value:to="this.userExpiry.value"
on:blur="this.userExpiryBlurred.emitter.value(true)"
{{# if(this.showExpiryError.value) }}class="is-error"{{/ if }}/>
<input type="text" name="cvc" placeholder="CVC"
on:input:value:to="this.userCVC.value"
on:blur="this.userCVCBlurred.emitter.value(true)"
{{# if(this.showCVCError.value) }}class="is-error"{{/ if }}/>
<button disabled:from="this.disablePaymentButton.value">
{{# eq(this.paymentStatus.value.status, "pending") }}Paying{{ else }}Pay{{/ eq }} \${{ this.amount.value }}
</button>
</form>
`,
ViewModel: {
amount: {
default: () => Kefir.constant(1000)
},
userCardNumber: {
default: () => Kefir.emitterProperty()
},
userCardNumberBlurred: {
default: () => Kefir.emitterProperty()
},
userExpiry: {
default: () => Kefir.emitterProperty()
},
userExpiryBlurred: {
default: () => Kefir.emitterProperty()
},
userCVC: {
default: () => Kefir.emitterProperty(),
},
userCVCBlurred: {
default: () => Kefir.emitterProperty()
},
payClicked: {
default: () => Kefir.emitterProperty()
},
get cardNumber() {
return this.userCardNumber.map((card) => {
if (card) {
return card.replace(/[\s-]/g, "");
}
});
},
get cardError() {
return this.cardNumber.map(this.validateCard);
},
get showCardError() {
return this.showOnlyWhenBlurredOnce(this.cardError, this.userCardNumberBlurred);
},
// EXPIRY
get expiry() {
return this.userExpiry.map((expiry) => {
if (expiry) {
return expiry.split("-")
}
});
},
get expiryError() {
return this.expiry.map(this.validateExpiry).toProperty();
},
get showExpiryError() {
return this.showOnlyWhenBlurredOnce(this.expiryError, this.userExpiryBlurred);
},
// CVC
get cvc () {
return this.userCVC;
},
get cvcError() {
return this.cvc.map(this.validateCVC).toProperty();
},
get showCVCError() {
return this.showOnlyWhenBlurredOnce(this.cvcError, this.userCVCBlurred);
},
get isCardInvalid(){
return Kefir.combine([this.cardError, this.expiryError, this.cvcError],
(cardError, expiryError, cvcError) => {
return !!(cardError || expiryError || cvcError)
});
},
get card() {
return Kefir.combine([this.cardNumber, this.expiry, this.cvc],
(cardNumber, expiry, cvc) => {
return {cardNumber , expiry , cvc};
});
},
// STREAM< Promise<Number> | undefined >
get paymentPromises(){
return Kefir.combine([this.payClicked], [this.card], (payClicked, card) => {
if (payClicked) {
console.log("Asking for token with", card);
return new Promise((resolve) => {
setTimeout(() => {
resolve(1000);
}, 2000);
})
}
});
},
// STREAM< STREAM<STATUS> >
// This is a stream of streams of status objects.
get paymentStatusStream () {
return this.paymentPromises.map((promise) => {
if (promise) {
// STREAM<STATUS>
return Kefir.concat([
Kefir.constant({
status: "pending"
}),
Kefir.fromPromise(promise).map((value) => {
return {
status: "resolved",
value: value
};
})
]);
} else {
// STREAM
return Kefir.constant({
status: "waiting"
});
}
});
},
// STREAM<STATUS> //{status: "waiting"} | {status: "resolved"}
get paymentStatus(){
return this.paymentStatusStream.flatMap().toProperty();
},
get disablePaymentButton() {
return Kefir.combine([this.isCardInvalid, this.paymentStatus],
(isCardInvalid, paymentStatus) => {
return (isCardInvalid === true) || !paymentStatus || paymentStatus.status === "pending";
}).toProperty(() => {
return true;
});
},
pay(event) {
event.preventDefault();
this.payClicked.emitter.value(true)
},
// HELPER FUNCTIONS
validateCard(card) {
if (!card) {
return "There is no card"
}
if (card.length !== 16) {
return "There should be 16 characters in a card";
}
},
validateExpiry(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";
}
},
validateCVC(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";
}
},
showOnlyWhenBlurredOnce(errorStream, blurredStream) {
const errorEvent = errorStream.map((error) => {
if (!error) {
return {
type: "valid"
}
} else {
return {
type: "invalid",
message: error
}
}
});
const focusEvents = blurredStream.map((isBlurred) => {
if (isBlurred === undefined) {
return {};
}
return isBlurred ? {
type: "blurred"
} : {
type: "focused"
};
});
return Kefir.merge([errorEvent, focusEvents])
.scan((previous, event) => {
switch (event.type) {
case "valid":
return Object.assign({}, previous, {
isValid: true,
showCardError: false
});
case "invalid":
return Object.assign({}, previous, {
isValid: false,
showCardError: previous.hasBeenBlurred
});
case "blurred":
return Object.assign({}, previous, {
hasBeenBlurred: true,
showCardError: !previous.isValid
});
default:
return previous;
}
}, {
hasBeenBlurred: false,
showCardError: false,
isValid: false
}).map((state) => {
return state.showCardError
});
}
}
});
Result
When complete, you should have a working credit card payment form like the following CodePen:
See the Pen Credit Card Guide (Advanced) by Bitovi (@bitovi) on CodePen.