DoneJS StealJS jQuery++ FuncUnit DocumentJS
3.14.1
5.0.0 4.3.0 2.3.35
  • About
  • Guides
  • API Docs
  • Community
  • Contributing
  • Bitovi
    • Bitovi.com
    • Blog
    • Design
    • Development
    • Training
    • Open Source
    • About
    • Contact Us
  • About
  • Guides
    • experiment
      • Chat Guide
      • TodoMVC Guide
      • ATM Guide
    • getting started
      • Setting Up CanJS
      • Reading the Docs (API Guide)
    • recipes
      • Credit Card Guide (Advanced)
      • Credit Card Guide (Simple)
      • CTA Bus Map (Medium)
      • File Navigator Guide (Advanced)
      • File Navigator Guide (Simple)
      • Playlist Editor (Advanced)
      • Signup and Login (Simple)
      • TodoMVC with StealJS
    • upgrade
      • Migrating to CanJS 3
      • Using Codemods
  • API Docs
  • Community
  • Contributing
  • GitHub
  • Twitter
  • Chat
  • Forum
  • News
Bitovi

Credit Card Guide (Advanced)

  • Edit on GitHub

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:

JS Bin on jsbin.com

To use the widget:

  1. Enter a Card Number, Expiration Date, and CVC.
  2. Click on the form so those inputs lose focus. The Pay button should become enabled.
  3. Click the Pay button to see the Pay button disabled for 2 seconds.
  4. 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:

JS Bin on jsbin.com

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://kefirjs.github.io/kefir/dist/kefir.min.js"></script>
<script src="https://unpkg.com/can@3/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 the input events produced by the <input> element and writes the <input>'s value to KEY.

  • 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 a last 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 from Kefir.combine. The reducer pattern is used here for illustrative purposes. It is able to support a larger set of stream transformations than Kefir.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 the expiry 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 the CVC 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 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.

    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 the innerPromise:

    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 call flatMap to flatten streams.

The solution

Update the view in the HTML tab to:

<script type='text/stache' id='app-view'>
<form on:submit="pay(scope.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(scope.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
        });
}

Result

When complete, you should have a working credit card payment form like the following JS Bin:

JS Bin on jsbin.com

CanJS is part of DoneJS. Created and maintained by the core DoneJS team and Bitovi. Currently 3.14.1.

On this page

Get help

  • Chat with us
  • File an issue
  • Ask questions
  • Read latest news