DoneJS StealJS jQuery++ FuncUnit DocumentJS
4.3.0
5.0.0 3.13.1 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
      • Technology Overview
      • Reading the Docs (API Guide)
      • Experimental ES Module Usage
    • recipes
      • Canvas Clock (Simple)
      • 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)
      • Text Editor (Medium)
      • Tinder Carousel (Medium)
      • TodoMVC with StealJS
      • Video Player (Simple)
    • topics
      • Debugging
      • Forms
    • upgrade
      • Migrating to CanJS 3
      • Migrating to CanJS 4
      • 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:

Finished Credit Card Guide (Advanced) 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:

Starter Credit Card Guide (Advanced) 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; 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 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:

    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:

<!doctype html>
<title>Credit Card Guide (Advanced)</title>
<body>
<script src="https://unpkg.com/kefir@3/dist/kefir.min.js"></script>
<script src="https://unpkg.com/can@4/dist/global/can.all.js"></script>
<script src="https://js.stripe.com/v2/"></script>

<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>

Update the JavaScript tab to:

const viewModel = {
    amount: Kefir.constant(1000)
};

const 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.

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 from 'can-kefir';
    
    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 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:

const viewModel = {
    amount: Kefir.constant(1000),

    userCardNumber: Kefir.emitterProperty()
};

viewModel.cardNumber = viewModel.userCardNumber.map((card) => {
    if (card) {
        return card.replace(/[\s-]/g, "");
    }
});

const view = can.stache.from("app-view");

document.body.appendChild( view(viewModel) );

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

const 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);

const 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

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 a last 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 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:

const 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);

const 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) {
    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 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:

const 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);

const 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) {
    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 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:

const 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);

const 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) {
    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 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:

const 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)
    });

const 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) {
    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 the innerPromise:

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

const 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 >
const 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.
const 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();

const 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) {
    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 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:

const 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 >
const 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.
const 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;
});

const 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) {
    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 JS Bin:

Finished Credit Card Guide (Advanced) on jsbin.com

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

On this page

Get help

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