Credit Card Guide (Advanced)
This 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:
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 CLONING THE FOLLOWING JS Bin:
This JS Bin 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:
Setup
The problem
We are going to try an alternate form of the basic CanJS setup. We
will still have a can-stache payment-view
and render it with a
viewModel
. But the viewModel
should be a plain JavaScript object
whose properties are all Kefir.js
streams.
We will render the static content in a template, 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:var 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.
var 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:
var 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:
<!DOCTYPE html>
<html>
<head>
<meta name="description" content="Credit Card Form">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>JS Bin</title>
</head>
<body>
<script type='text/stache' id="app-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 ${{amount.value}}</button>
</form>
</script>
<script src="https://rpominov.github.io/kefir/dist/kefir.min.js"></script>
<script src="https://unpkg.com/can/dist/global/can.all.js"></script>
<script type="text/javascript" src="https://js.stripe.com/v2/"></script>
</body>
</html>
Update the JavaScript tab to:
var viewModel = {
amount: Kefir.constant(1000)
};
var view = can.stache.from("app-view");
document.body.appendChild( view(viewModel) );
Read the card number
The problem
Users will be able to enter a card number like 1234-1234-1234-1234
.
Lets 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 a 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.var Kefir = require("can-kefir"); var 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:
var source = Kefir.sequentially(100, [1, 2, 3]); var 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 view in the HTML tab to:
<script type='text/stache' id='app-view'>
<form>
User Entered: {{userCardNumber.value}},
Card Number: {{cardNumber.value}}
<input type='text' name='number' placeholder='Card Number'
on:input:value:to="userCardNumber.value"/>
<input type='text' name='expiry' placeholder='MM-YY'/>
<input type='text' name='cvc' placeholder='CVC'/>
<button>Pay ${{amount.value}}</button>
</form>
</script>
Update the JavaScript tab to:
var viewModel = {
amount: Kefir.constant(1000),
userCardNumber: Kefir.emitterProperty()
};
viewModel.cardNumber = viewModel.userCardNumber.map((card) => {
if (card) {
return card.replace(/[\s-]/g, "");
}
});
var view = can.stache.from("app-view");
document.body.appendChild( view(viewModel) );
Output the card error
The problem
As someone types a card number, lets 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 view in the HTML tab to:
<script type='text/stache' id='app-view'>
<form>
<div class="message">{{cardError.value}}</div>
<input type='text' name='number' placeholder='Card Number'
on:input:value:to="userCardNumber.value"/>
<input type='text' name='expiry' placeholder='MM-YY'/>
<input type='text' name='cvc' placeholder='CVC'/>
<button>Pay ${{amount.value}}</button>
</form>
</script>
Update the JavaScript tab to:
var viewModel = {
amount: Kefir.constant(1000),
userCardNumber: Kefir.emitterProperty()
};
viewModel.cardNumber = viewModel.userCardNumber.map((card) => {
if (card) {
return card.replace(/[\s-]/g, "");
}
});
viewModel.cardError = viewModel.cardNumber.map(validateCard);
var view = can.stache.from("app-view");
document.body.appendChild( view(viewModel) );
// HELPER FUNCTIONS
function 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
Lets 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:var first = Kefir.sequentially(100, ["Justin", "Ramiya"]) var 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
:var firstEvents = first.map( (first) => { return {type: "first", value: first} }) var 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:
var 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:var state = merged.scan((previous, event) => { var 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:
var 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:
var 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 view in the HTML tab to:
<script type='text/stache' id='app-view'>
<form>
{{#if showCardError.value}}
<div class="message">{{cardError.value}}</div>
{{/if}}
<input type='text' name='number' placeholder='Card Number'
on:input:value:to="userCardNumber.value"
on:blur="userCardNumberBlurred.emitter.value(true)"
{{#if showCardError.value}}class='is-error'{{/if}}/>
<input type='text' name='expiry' placeholder='MM-YY'/>
<input type='text' name='cvc' placeholder='CVC'/>
<button>Pay ${{amount.value}}</button>
</form>
</script>
Update the JavaScript tab to:
var viewModel = {
amount: Kefir.constant(1000),
userCardNumber: Kefir.emitterProperty(),
userCardNumberBlurred: Kefir.emitterProperty()
};
viewModel.cardNumber = viewModel.userCardNumber.map((card) => {
if (card) {
return card.replace(/[\s-]/g, "");
}
});
viewModel.cardError = viewModel.cardNumber.map(validateCard).toProperty();
viewModel.showCardError = showOnlyWhenBlurredOnce(viewModel.cardError, viewModel.userCardNumberBlurred);
var view = can.stache.from("app-view");
document.body.appendChild( view(viewModel) );
// HELPER FUNCTIONS
function validateCard(card) {
if (!card) {
return "There is no card"
}
if (card.length !== 16) {
return "There should be 16 characters in a card";
}
}
function showOnlyWhenBlurredOnce(errorStream, blurredStream) {
var errorEvent = errorStream.map((error) => {
if (!error) {
return {
type: "valid"
}
} else {
return {
type: "invalid",
message: error
}
}
});
var 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
Lets 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 "Expirty must be formatted like MM-YY"; } }
The solution
Update the view in the HTML tab to:
<script type='text/stache' id='app-view'>
<form>
{{#if showCardError.value}}
<div class="message">{{cardError.value}}</div>
{{/if}}
{{#if showExpiryError.value}}
<div class="message">{{expiryError.value}}</div>
{{/if}}
<input type='text' name='number' placeholder='Card Number'
on:input:value:to="userCardNumber.value"
on:blur="userCardNumberBlurred.emitter.value(true)"
{{#if showCardError.value}}class='is-error'{{/if}}/>
<input type='text' name='expiry' placeholder='MM-YY'
on:input:value:to="userExpiry.value"
on:blur="userExpiryBlurred.emitter.value(true)"
{{#if showExpiryError.value}}class='is-error'{{/if}}/>
<input type='text' name='cvc' placeholder='CVC'/>
<button>Pay ${{amount.value}}</button>
</form>
</script>
Update the JavaScript tab to:
var viewModel = {
amount: Kefir.constant(1000),
userCardNumber: Kefir.emitterProperty(),
userCardNumberBlurred: Kefir.emitterProperty(),
userExpiry: Kefir.emitterProperty(),
userExpiryBlurred: Kefir.emitterProperty()
};
viewModel.cardNumber = viewModel.userCardNumber.map((card) => {
if (card) {
return card.replace(/[\s-]/g, "");
}
});
viewModel.cardError = viewModel.cardNumber.map(validateCard).toProperty();
viewModel.showCardError = showOnlyWhenBlurredOnce(viewModel.cardError, viewModel.userCardNumberBlurred);
// EXPIRY
viewModel.expiry = viewModel.userExpiry.map((expiry) => {
if (expiry) {
return expiry.split("-")
}
});
viewModel.expiryError = viewModel.expiry.map(validateExpiry).toProperty();
viewModel.showExpiryError = showOnlyWhenBlurredOnce(viewModel.expiryError, viewModel.userExpiryBlurred);
var view = can.stache.from("app-view");
document.body.appendChild( view(viewModel) );
// HELPER FUNCTIONS
function validateCard(card) {
if (!card) {
return "There is no card"
}
if (card.length !== 16) {
return "There should be 16 characters in a card";
}
}
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 "Expirty must be formatted like MM-YY";
}
}
function showOnlyWhenBlurredOnce(errorStream, blurredStream) {
var errorEvent = errorStream.map((error) => {
if (!error) {
return {
type: "valid"
}
} else {
return {
type: "invalid",
message: error
}
}
});
var 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
Lets 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 view in the HTML tab to:
<script type='text/stache' id='app-view'>
<form>
{{#if showCardError.value}}
<div class="message">{{cardError.value}}</div>
{{/if}}
{{#if showExpiryError.value}}
<div class="message">{{expiryError.value}}</div>
{{/if}}
{{#if showCVCError.value}}
<div class="message">{{cvcError.value}}</div>
{{/if}}
<input type='text' name='number' placeholder='Card Number'
on:input:value:to="userCardNumber.value"
on:blur="userCardNumberBlurred.emitter.value(true)"
{{#if showCardError.value}}class='is-error'{{/if}}/>
<input type='text' name='expiry' placeholder='MM-YY'
on:input:value:to="userExpiry.value"
on:blur="userExpiryBlurred.emitter.value(true)"
{{#if showExpiryError.value}}class='is-error'{{/if}}/>
<input type='text' name='cvc' placeholder='CVC'
on:input:value:to="userCVC.value"
on:blur="userCVCBlurred.emitter.value(true)"
{{#if showCVCError.value}}class='is-error'{{/if}}/>
<button>Pay ${{amount.value}}</button>
</form>
</script>
Update the JavaScript tab to:
var viewModel = {
amount: Kefir.constant(1000),
userCardNumber: Kefir.emitterProperty(),
userCardNumberBlurred: Kefir.emitterProperty(),
userExpiry: Kefir.emitterProperty(),
userExpiryBlurred: Kefir.emitterProperty(),
userCVC: Kefir.emitterProperty(),
userCVCBlurred: Kefir.emitterProperty()
};
viewModel.cardNumber = viewModel.userCardNumber.map((card) => {
if (card) {
return card.replace(/[\s-]/g, "");
}
});
viewModel.cardError = viewModel.cardNumber.map(validateCard).toProperty();
viewModel.showCardError = showOnlyWhenBlurredOnce(viewModel.cardError, viewModel.userCardNumberBlurred);
// EXPIRY
viewModel.expiry = viewModel.userExpiry.map((expiry) => {
if (expiry) {
return expiry.split("-")
}
});
viewModel.expiryError = viewModel.expiry.map(validateExpiry).toProperty();
viewModel.showExpiryError = showOnlyWhenBlurredOnce(viewModel.expiryError, viewModel.userExpiryBlurred);
// CVC
viewModel.cvc = viewModel.userCVC;
viewModel.cvcError = viewModel.cvc.map(validateCVC).toProperty();
viewModel.showCVCError = showOnlyWhenBlurredOnce(viewModel.cvcError, viewModel.userCVCBlurred);
var view = can.stache.from("app-view");
document.body.appendChild( view(viewModel) );
// HELPER FUNCTIONS
function validateCard(card) {
if (!card) {
return "There is no card"
}
if (card.length !== 16) {
return "There should be 16 characters in a card";
}
}
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 "Expirty must be formatted like MM-YY";
}
}
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";
}
}
function showOnlyWhenBlurredOnce(errorStream, blurredStream) {
var errorEvent = errorStream.map((error) => {
if (!error) {
return {
type: "valid"
}
} else {
return {
type: "invalid",
message: error
}
}
});
var 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
Lets disable the Pay button until the card, exiry, and cvc are valid.
What you need to know
Kefir.combine
can combine several values into a single value:var first = Kefir.sequentially(100, ["Justin", "Ramiya"]) var last = Kefir.sequentially(100, ["Shah", "Meyer"]).delay(50); // first: ---Justin---RamiyaX // last: ------Shah__---Meyer_X var 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 view in the HTML tab to:
<script type='text/stache' id='app-view'>
<form>
{{#if showCardError.value}}
<div class="message">{{cardError.value}}</div>
{{/if}}
{{#if showExpiryError.value}}
<div class="message">{{expiryError.value}}</div>
{{/if}}
{{#if showCVCError.value}}
<div class="message">{{cvcError.value}}</div>
{{/if}}
<input type='text' name='number' placeholder='Card Number'
on:input:value:to="userCardNumber.value"
on:blur="userCardNumberBlurred.emitter.value(true)"
{{#if showCardError.value}}class='is-error'{{/if}}/>
<input type='text' name='expiry' placeholder='MM-YY'
on:input:value:to="userExpiry.value"
on:blur="userExpiryBlurred.emitter.value(true)"
{{#if showExpiryError.value}}class='is-error'{{/if}}/>
<input type='text' name='cvc' placeholder='CVC'
on:input:value:to="userCVC.value"
on:blur="userCVCBlurred.emitter.value(true)"
{{#if showCVCError.value}}class='is-error'{{/if}}/>
<button disabled:from="isCardInvalid.value">
Pay ${{amount.value}}
</button>
</form>
</script>
Update the JavaScript tab to:
var viewModel = {
amount: Kefir.constant(1000),
userCardNumber: Kefir.emitterProperty(),
userCardNumberBlurred: Kefir.emitterProperty(),
userExpiry: Kefir.emitterProperty(),
userExpiryBlurred: Kefir.emitterProperty(),
userCVC: Kefir.emitterProperty(),
userCVCBlurred: Kefir.emitterProperty(),
};
viewModel.cardNumber = viewModel.userCardNumber.map((card) => {
if (card) {
return card.replace(/[\s-]/g, "");
}
});
viewModel.cardError = viewModel.cardNumber.map(validateCard).toProperty();
viewModel.showCardError = showOnlyWhenBlurredOnce(viewModel.cardError, viewModel.userCardNumberBlurred);
// EXPIRY
viewModel.expiry = viewModel.userExpiry.map((expiry) => {
if (expiry) {
return expiry.split("-")
}
});
viewModel.expiryError = viewModel.expiry.map(validateExpiry).toProperty();
viewModel.showExpiryError = showOnlyWhenBlurredOnce(viewModel.expiryError, viewModel.userExpiryBlurred);
// CVC
viewModel.cvc = viewModel.userCVC;
viewModel.cvcError = viewModel.cvc.map(validateCVC).toProperty();
viewModel.showCVCError = showOnlyWhenBlurredOnce(viewModel.cvcError, viewModel.userCVCBlurred);
viewModel.isCardInvalid = Kefir.combine([viewModel.cardError, viewModel.expiryError, viewModel.cvcError],
function(cardError, expiryError, cvcError) {
return !!(cardError || expiryError || cvcError)
});
var view = can.stache.from("app-view");
document.body.appendChild( view(viewModel) );
// HELPER FUNCTIONS
function validateCard(card) {
if (!card) {
return "There is no card"
}
if (card.length !== 16) {
return "There should be 16 characters in a card";
}
}
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 "Expirty must be formatted like MM-YY";
}
}
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";
}
}
function showOnlyWhenBlurredOnce(errorStream, blurredStream) {
var errorEvent = errorStream.map((error) => {
if (!error) {
return {
type: "valid"
}
} else {
return {
type: "invalid",
message: error
}
}
});
var 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, lets 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 callsdoSomething()
when the<div>
is clicked:<div on:click="doSomething(%event)"> ... </div>
Notice that it also passed the event object with
%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.var a = Kefir.sequentially(100, [0, 1, 2]); var b = Kefir.sequentially(100, [3, 4, 5]); var 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.
var count = Kefir.sequentially(100, [1, 2, 3]); var streamOfStreams = count.map( (count) => { return Kefir.interval(40, count).take(4) }); var 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
:var outerPromise = new Promise((resolve) => { setTimeout(() => { resolve("outer") }, 100); }); return innerPromise = new Promise((resolve) => { setTimeout(() => { resolve("inner") }, 200); }); var 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 view in the HTML tab to:
<script type='text/stache' id='app-view'>
<form on:submit="pay(%event)">
{{#if showCardError.value}}
<div class="message">{{cardError.value}}</div>
{{/if}}
{{#if showExpiryError.value}}
<div class="message">{{expiryError.value}}</div>
{{/if}}
{{#if showCVCError.value}}
<div class="message">{{cvcError.value}}</div>
{{/if}}
<input type='text' name='number' placeholder='Card Number'
on:input:value:to="userCardNumber.value"
on:blur="userCardNumberBlurred.emitter.value(true)"
{{#if showCardError.value}}class='is-error'{{/if}}/>
<input type='text' name='expiry' placeholder='MM-YY'
on:input:value:to="userExpiry.value"
on:blur="userExpiryBlurred.emitter.value(true)"
{{#if showExpiryError.value}}class='is-error'{{/if}}/>
<input type='text' name='cvc' placeholder='CVC'
on:input:value:to="userCVC.value"
on:blur="userCVCBlurred.emitter.value(true)"
{{#if showCVCError.value}}class='is-error'{{/if}}/>
<button disabled:from="isCardInvalid.value">
{{#eq paymentStatus.value.status "pending"}}Paying{{else}}Pay{{/eq}} ${{amount.value}}
</button>
</form>
</script>
Update the JavaScript tab to:
var viewModel = {
amount: Kefir.constant(1000),
userCardNumber: Kefir.emitterProperty(),
userCardNumberBlurred: Kefir.emitterProperty(),
userExpiry: Kefir.emitterProperty(),
userExpiryBlurred: Kefir.emitterProperty(),
userCVC: Kefir.emitterProperty(),
userCVCBlurred: Kefir.emitterProperty(),
payClicked: Kefir.emitterProperty(),
pay: function(event) {
event.preventDefault();
this.payClicked.emitter.value(true)
}
};
viewModel.cardNumber = viewModel.userCardNumber.map((card) => {
if (card) {
return card.replace(/[\s-]/g, "");
}
});
viewModel.cardError = viewModel.cardNumber.map(validateCard).toProperty(); // we’ll need this in the future
viewModel.showCardError = showOnlyWhenBlurredOnce(viewModel.cardError, viewModel.userCardNumberBlurred);
// EXPIRY
viewModel.expiry = viewModel.userExpiry.map((expiry) => {
if (expiry) {
return expiry.split("-")
}
});
viewModel.expiryError = viewModel.expiry.map(validateExpiry).toProperty();
viewModel.showExpiryError = showOnlyWhenBlurredOnce(viewModel.expiryError, viewModel.userExpiryBlurred);
// CVC
viewModel.cvc = viewModel.userCVC;
viewModel.cvcError = viewModel.cvc.map(validateCVC).toProperty();
viewModel.showCVCError = showOnlyWhenBlurredOnce(viewModel.cvcError, viewModel.userCVCBlurred);
viewModel.isCardInvalid = Kefir.combine([viewModel.cardError, viewModel.expiryError, viewModel.cvcError],
function(cardError, expiryError, cvcError) {
return !!(cardError || expiryError || cvcError)
});
viewModel.card = Kefir.combine([viewModel.cardNumber, viewModel.expiry, viewModel.cvc],
function(cardNumber, expiry, cvc) {
return {cardNumber , expiry , cvc};
});
// STREAM< Promise<Number> | undefined >
var paymentPromises = Kefir.combine([viewModel.payClicked], [viewModel.card], (payClicked, card) => {
if (payClicked) {
console.log("Asking for token with", card);
return new Promise(function(resolve) {
setTimeout(function() {
resolve(1000);
}, 2000);
})
}
});
// STREAM< STREAM<STATUS> >
// This is a stream of streams of status objects.
var paymentStatusStream = 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"}
viewModel.paymentStatus = paymentStatusStream.flatMap().toProperty();
var view = can.stache.from("app-view");
document.body.appendChild( view(viewModel) );
// HELPER FUNCTIONS
function validateCard(card) {
if (!card) {
return "There is no card"
}
if (card.length !== 16) {
return "There should be 16 characters in a card";
}
}
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 "Expirty must be formatted like MM-YY";
}
}
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";
}
}
function showOnlyWhenBlurredOnce(errorStream, blurredStream) {
var errorEvent = errorStream.map((error) => {
if (!error) {
return {
type: "valid"
}
} else {
return {
type: "invalid",
message: error
}
}
});
var 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
Lets 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 view in the HTML tab to:
<script type='text/stache' id='app-view'>
<form on:submit="pay(%event)">
{{#if showCardError.value}}
<div class="message">{{cardError.value}}</div>
{{/if}}
{{#if showExpiryError.value}}
<div class="message">{{expiryError.value}}</div>
{{/if}}
{{#if showCVCError.value}}
<div class="message">{{cvcError.value}}</div>
{{/if}}
<input type='text' name='number' placeholder='Card Number'
on:input:value:to="userCardNumber.value"
on:blur="userCardNumberBlurred.emitter.value(true)"
{{#if showCardError.value}}class='is-error'{{/if}}/>
<input type='text' name='expiry' placeholder='MM-YY'
on:input:value:to="userExpiry.value"
on:blur="userExpiryBlurred.emitter.value(true)"
{{#if showExpiryError.value}}class='is-error'{{/if}}/>
<input type='text' name='cvc' placeholder='CVC'
on:input:value:to="userCVC.value"
on:blur="userCVCBlurred.emitter.value(true)"
{{#if showCVCError.value}}class='is-error'{{/if}}/>
<button disabled:from="disablePaymentButton.value">
{{#eq paymentStatus.value.status "pending"}}Paying{{else}}Pay{{/eq}} ${{amount.value}}
</button>
</form>
</script>
Update the JavaScript tab to:
var viewModel = {
amount: Kefir.constant(1000),
userCardNumber: Kefir.emitterProperty(),
userCardNumberBlurred: Kefir.emitterProperty(),
userExpiry: Kefir.emitterProperty(),
userExpiryBlurred: Kefir.emitterProperty(),
userCVC: Kefir.emitterProperty(),
userCVCBlurred: Kefir.emitterProperty(),
payClicked: Kefir.emitterProperty(),
pay: function(event) {
event.preventDefault();
this.payClicked.emitter.value(true)
}
};
viewModel.cardNumber = viewModel.userCardNumber.map((card) => {
if (card) {
return card.replace(/[\s-]/g, "");
}
});
viewModel.cardError = viewModel.cardNumber.map(validateCard).toProperty(); // we’ll need this in the future
viewModel.showCardError = showOnlyWhenBlurredOnce(viewModel.cardError, viewModel.userCardNumberBlurred);
// EXPIRY
viewModel.expiry = viewModel.userExpiry.map((expiry) => {
if (expiry) {
return expiry.split("-")
}
});
viewModel.expiryError = viewModel.expiry.map(validateExpiry).toProperty();
viewModel.showExpiryError = showOnlyWhenBlurredOnce(viewModel.expiryError, viewModel.userExpiryBlurred);
// CVC
viewModel.cvc = viewModel.userCVC;
viewModel.cvcError = viewModel.cvc.map(validateCVC).toProperty();
viewModel.showCVCError = showOnlyWhenBlurredOnce(viewModel.cvcError, viewModel.userCVCBlurred);
viewModel.isCardInvalid = Kefir.combine([viewModel.cardError, viewModel.expiryError, viewModel.cvcError],
function(cardError, expiryError, cvcError) {
return !!(cardError || expiryError || cvcError)
});
viewModel.card = Kefir.combine([viewModel.cardNumber, viewModel.expiry, viewModel.cvc],
function(cardNumber, expiry, cvc) {
return {cardNumber , expiry , cvc};
});
// STREAM< Promise<Number> | undefined >
var paymentPromises = Kefir.combine([viewModel.payClicked], [viewModel.card], (payClicked, card) => {
if (payClicked) {
console.log("Asking for token with", card);
return new Promise(function(resolve) {
setTimeout(function() {
resolve(1000);
}, 2000);
})
}
});
// STREAM< STREAM<STATUS> >
// This is a stream of streams of status objects.
var paymentStatusStream = 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"}
viewModel.paymentStatus = paymentStatusStream.flatMap().toProperty();
viewModel.disablePaymentButton = Kefir.combine([viewModel.isCardInvalid, viewModel.paymentStatus],
function(isCardInvalid, paymentStatus) {
return (isCardInvalid === true) || !paymentStatus || paymentStatus.status === "pending";
}).toProperty(function() {
return true;
});
var view = can.stache.from("app-view");
document.body.appendChild( view(viewModel) );
// HELPER FUNCTIONS
function validateCard(card) {
if (!card) {
return "There is no card"
}
if (card.length !== 16) {
return "There should be 16 characters in a card";
}
}
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 "Expirty must be formatted like MM-YY";
}
}
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";
}
}
function showOnlyWhenBlurredOnce(errorStream, blurredStream) {
var errorEvent = errorStream.map((error) => {
if (!error) {
return {
type: "valid"
}
} else {
return {
type: "invalid",
message: error
}
}
});
var 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
});
}