DoneJS StealJS jQuery++ FuncUnit DocumentJS
6.0.1
5.33.2 4.3.0 3.14.1 2.3.35
  • About
  • Guides
  • API Docs
  • Community
  • Contributing
  • Bitovi
    • Bitovi.com
    • Blog
    • Design
    • Development
    • Training
    • Open Source
    • About
    • Contact Us
  • About
  • Guides
    • getting started
      • CRUD Guide
      • Setting Up CanJS
      • Technology Overview
    • topics
      • HTML
      • Routing
      • Service Layer
      • Debugging
      • Forms
      • Testing
      • Logic
      • Server-Side Rendering
    • app guides
      • Chat Guide
      • TodoMVC Guide
      • TodoMVC with StealJS
    • beginner recipes
      • Canvas Clock
      • Credit Card
      • File Navigator
      • Signup and Login
      • Video Player
    • intermediate recipes
      • CTA Bus Map
      • Multiple Modals
      • Text Editor
      • Tinder Carousel
    • advanced recipes
      • Credit Card
      • File Navigator
      • Playlist Editor
      • Search, List, Details
    • upgrade
      • Migrating to CanJS 3
      • Migrating to CanJS 4
      • Migrating to CanJS 5
      • Migrating to CanJS 6
      • Using Codemods
    • other
      • Reading the API Docs
  • API Docs
  • Community
  • Contributing
  • GitHub
  • Twitter
  • Chat
  • Forum
  • News
Bitovi

Credit Card

  • Edit on GitHub

This advanced guide walks through building a simple credit card payment form with validations. It doesn’t use can-define. Instead it uses Kefir.js streams to make a ViewModel. can-kefir is used to make the Kefir streams observable to can-stache.

In this guide, you will learn how to:

  • Use Kefir streams.
  • Use the event-reducer pattern.
  • Handle promises (and side-effects) with streams.

The final widget looks like:

See the Pen Credit Card Guide (Advanced) by Bitovi (@bitovi) on CodePen.

To use the widget:

  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 CLICKING THE “EDIT ON CODEPEN” BUTTON IN THE TOP RIGHT CORNER OF THE FOLLOWING EMBED::

See the Pen Credit Card Guide (Starter) by Bitovi (@bitovi) on CodePen.

This CodePen has initial prototype HTML and CSS which is useful for getting the application to look right.

The following sections are broken down into:

  • The problem — A description of what the section is trying to accomplish.
  • What you need to know — Information about CanJS that is useful for solving the problem.
  • The solution — The solution to the problem.

The following video walks through the entire guide; it was recorded for CanJS 3, but most of the same basic info applies:

Setup

The problem

We are going to try an alternate form of the basic CanJS setup. We will have an StacheElement with cc-payment as a custom tag. The component properties are all Kefir.js streams.

We will render the static content in the component view, but use a constant stream to hold the amount value.

What you need to know

  • Kefir.js allows you to create streams of events and transform those streams into other streams. For example, the following numbers stream produces three numbers with interval of 100 milliseconds:

    const numbers = Kefir.sequentially(100, [1, 2, 3]);
    

    Now let’s create another stream based on the first one. As you might guess, it will produce 2, 4, and 6.

    const numbers2 = numbers.map(x => x * 2);
    
  • Kefir supports both streams and properties. It’s worth reading Kefir’s documentation on the difference between streams and properties. In short:

    • Properties retain their value
    • Streams do not
  • Kefir.constant creates a property with the specified value:

    const property = Kefir.constant(1);
    
  • can-kefir integrates streams into CanJS, including can-stache templates. Output the value of a stream like:

    {{ stream.value }}
    

    Or the error like:

    {{ stream.error }}
    

The solution

Update the HTML tab to:

<cc-payment></cc-payment>

Update the JavaScript tab to:

import { kefir as Kefir, StacheElement } from "//unpkg.com/can@6/ecosystem.mjs";

class CCPayment extends StacheElement {
  static view = `
    <form>
      <input type="text" name="number" placeholder="Card Number">

      <input type="text" name="expiry" placeholder="MM-YY">

      <input type="text" name="cvc" placeholder="CVC">

      <button>Pay \${{ this.amount.value }}</button>
    </form>
  `;

  static props = {
    amount: {
      get default() {
        return Kefir.constant(1000);
      }
    }
  };
}

customElements.define("cc-payment", CCPayment);

Read the card number

The problem

Users will be able to enter a card number like 1234-1234-1234-1234.

Let’s read the card number entered by the user, print it back, and also print back the cleaned card number (the entered number with no dashes).

What you need to know

  • can-kefir adds an emitterProperty method that returns a Kefir property, but also adds an emitter object with with .value() and .error() methods. The end result is a single object that has methods of a stream and property access to its emitter methods.

    import { kefir as Kefir } from "//unpkg.com/can@6/ecosystem.mjs";
    
    const age = Kefir.emitterProperty();
    
    age.onValue(function(age) {
      console.log(age)
    });
    
    age.emitter.value(20) //-> logs 20
    
    age.emitter.value(30) //-> logs 30
    

    emitterProperty property streams are useful data sinks when getting user data.

  • Kefir streams and properties have a map method that maps values on one stream to values in a new stream:

    const source = Kefir.sequentially(100, [1, 2, 3]);
    const result = source.map(x => x + 1);
    // source: ---1---2---3X
    // result: ---2---3---4X
    
  • <input on:input:value:to="KEY"/> Listens to 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 JavaScript tab to:

import { kefir as Kefir, StacheElement } from "//unpkg.com/can@6/ecosystem.mjs";

class CCPayment extends StacheElement {
  static view = `
    <form>
      User Entered: {{ this.userCardNumber.value }},
      Card Number: {{ this.cardNumber.value }}

      <input type="text" name="number" placeholder="Card Number"
          on:input:value:to="this.userCardNumber.value">

      <input type="text" name="expiry" placeholder="MM-YY">

      <input type="text" name="cvc" placeholder="CVC">

      <button>Pay \${{ this.amount.value }}</button>
    </form>
  `;

  static props = {
    amount: {
      get default() {
        return Kefir.constant(1000);
      }
    },

    userCardNumber: {
      get default() {
        return Kefir.emitterProperty();
      }
    },

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

customElements.define("cc-payment", CCPayment);

Output the card error

The problem

As someone types a card number, let’s show the user a warning message about what they need to enter for the card number. It should go away if the card number is 16 characters.

What you need to know

  • Add the cardError message above the input like:

    <div class="message">{{cardError.value}}</div>
    
  • Validate a card with:

    function validateCard(card) {
      if (!card) {
        return "There is no card"
      }
      if (card.length !== 16) {
        return "There should be 16 characters in a card";
      }
    }
    

The solution

Update the JavaScript tab to:

import { kefir as Kefir, StacheElement } from "//unpkg.com/can@6/ecosystem.mjs";

class CCPayment extends StacheElement {
  static view = `
    <form>
      <div class="message">{{ this.cardError.value }}</div>

      <input type="text" name="number" placeholder="Card Number"
          on:input:value:to="this.userCardNumber.value">
  
      <input type="text" name="expiry" placeholder="MM-YY">
  
      <input type="text" name="cvc" placeholder="CVC">
  
      <button>Pay \${{ this.amount.value }}</button>
    </form>
  `;

  static props = {
    amount: {
      get default() {
        return Kefir.constant(1000);
      }
    },

    userCardNumber: {
      get default() {
        return Kefir.emitterProperty();
      }
    },

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

    get cardError() {
      return this.cardNumber.map(this.validateCard);
    }
  };

  validateCard(card) {
    if (!card) {
      return "There is no card";
    }
    if (card.length !== 16) {
      return "There should be 16 characters in a card";
    }
  }
}

customElements.define("cc-payment", CCPayment);

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 JavaScript tab to:

import { kefir as Kefir, StacheElement } from "//unpkg.com/can@6/ecosystem.mjs";

class CCPayment extends StacheElement {
  static view = `
    <form>
      {{# if(this.showCardError.value) }}
        <div class="message">{{ this.cardError.value }}</div>
      {{/ if }}

      <input type="text" name="number" placeholder="Card Number"
          on:input:value:to="this.userCardNumber.value"
          on:blur="this.userCardNumberBlurred.emitter.value(true)"
          {{# if(this.showCardError.value) }}class="is-error"{{/ if }}>

      <input type="text" name="expiry" placeholder="MM-YY">

      <input type="text" name="cvc" placeholder="CVC">

      <button>Pay \${{ this.amount.value }}</button>
    </form>
  `;

  static props = {
    amount: {
      get default() {
        return Kefir.constant(1000);
      }
    },

    userCardNumber: {
      get default() {
        return Kefir.emitterProperty();
      }
    },

    userCardNumberBlurred: {
      get default() {
        return Kefir.emitterProperty();
      }
    },

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

    get cardError() {
      return this.cardNumber.map(this.validateCard);
    },

    get showCardError() {
      return this.showOnlyWhenBlurredOnce(
        this.cardError,
        this.userCardNumberBlurred
      );
    }
  };

  validateCard(card) {
    if (!card) {
      return "There is no card";
    }
    if (card.length !== 16) {
      return "There should be 16 characters in a card";
    }
  }

  showOnlyWhenBlurredOnce(errorStream, blurredStream) {
    const errorEvent = errorStream.map(error => {
      if (!error) {
        return {
          type: "valid"
        };
      } else {
        return {
          type: "invalid",
          message: error
        };
      }
    });

    const focusEvents = blurredStream.map(isBlurred => {
      if (isBlurred === undefined) {
        return {};
      }
      return isBlurred
        ? {
            type: "blurred"
          }
        : {
            type: "focused"
          };
    });

    return Kefir.merge([errorEvent, focusEvents])
      .scan(
        (previous, event) => {
          switch (event.type) {
            case "valid":
              return Object.assign({}, previous, {
                isValid: true,
                showCardError: false
              });
            case "invalid":
              return Object.assign({}, previous, {
                isValid: false,
                showCardError: previous.hasBeenBlurred
              });
            case "blurred":
              return Object.assign({}, previous, {
                hasBeenBlurred: true,
                showCardError: !previous.isValid
              });
            default:
              return previous;
          }
        },
        {
          hasBeenBlurred: false,
          showCardError: false,
          isValid: false
        }
      )
      .map(state => {
        return state.showCardError;
      });
  }
}

customElements.define("cc-payment", CCPayment);

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 "Expiry must be formatted like MM-YY";
      }
    }
    

The solution

Update the JavaScript tab to:

import { kefir as Kefir, StacheElement } from "//unpkg.com/can@6/ecosystem.mjs";

class CCPayment extends StacheElement {
  static view = `
    <form>
      {{# if(this.showCardError.value) }}
        <div class="message">{{ this.cardError.value }}</div>
      {{/ if }}

      {{# if(this.showExpiryError.value) }}
        <div class="message">{{ this.expiryError.value }}</div>
      {{/ if }}

      <input type="text" name="number" placeholder="Card Number"
          on:input:value:to="this.userCardNumber.value"
          on:blur="this.userCardNumberBlurred.emitter.value(true)"
          {{# if(this.showCardError.value) }}class="is-error"{{/ if }}>

      <input type="text" name="expiry" placeholder="MM-YY"
          on:input:value:to="this.userExpiry.value"
          on:blur="this.userExpiryBlurred.emitter.value(true)"
          {{# if(this.showExpiryError.value) }}class="is-error"{{/ if }}>

      <input type="text" name="cvc" placeholder="CVC">

      <button>Pay \${{ this.amount.value }}</button>
    </form>
  `;

  static props = {
    amount: {
      get default() {
        return Kefir.constant(1000);
      }
    },

    userCardNumber: {
      get default() {
        return Kefir.emitterProperty();
      }
    },

    userCardNumberBlurred: {
      get default() {
        return Kefir.emitterProperty();
      }
    },

    userExpiry: {
      get default() {
        return Kefir.emitterProperty();
      }
    },

    userExpiryBlurred: {
      get default() {
        return Kefir.emitterProperty();
      }
    },

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

    get cardError() {
      return this.cardNumber.map(this.validateCard);
    },

    get showCardError() {
      return this.showOnlyWhenBlurredOnce(
        this.cardError,
        this.userCardNumberBlurred
      );
    },

    // EXPIRY
    get expiry() {
      return this.userExpiry.map(expiry => {
        if (expiry) {
          return expiry.split("-");
        }
      });
    },

    get expiryError() {
      return this.expiry.map(this.validateExpiry).toProperty();
    },

    get showExpiryError() {
      return this.showOnlyWhenBlurredOnce(
        this.expiryError,
        this.userExpiryBlurred
      );
    }
  };

  validateCard(card) {
    if (!card) {
      return "There is no card";
    }
    if (card.length !== 16) {
      return "There should be 16 characters in a card";
    }
  }

  validateExpiry(expiry) {
    if (!expiry) {
      return "There is no expiry. Format  MM-YY";
    }
    if (
      expiry.length !== 2 ||
      expiry[0].length !== 2 ||
      expiry[1].length !== 2
    ) {
      return "Expiry must be formatted like MM-YY";
    }
  }

  showOnlyWhenBlurredOnce(errorStream, blurredStream) {
    const errorEvent = errorStream.map(error => {
      if (!error) {
        return {
          type: "valid"
        };
      } else {
        return {
          type: "invalid",
          message: error
        };
      }
    });

    const focusEvents = blurredStream.map(isBlurred => {
      if (isBlurred === undefined) {
        return {};
      }
      return isBlurred
        ? {
            type: "blurred"
          }
        : {
            type: "focused"
          };
    });

    return Kefir.merge([errorEvent, focusEvents])
      .scan(
        (previous, event) => {
          switch (event.type) {
            case "valid":
              return Object.assign({}, previous, {
                isValid: true,
                showCardError: false
              });
            case "invalid":
              return Object.assign({}, previous, {
                isValid: false,
                showCardError: previous.hasBeenBlurred
              });
            case "blurred":
              return Object.assign({}, previous, {
                hasBeenBlurred: true,
                showCardError: !previous.isValid
              });
            default:
              return previous;
          }
        },
        {
          hasBeenBlurred: false,
          showCardError: false,
          isValid: false
        }
      )
      .map(state => {
        return state.showCardError;
      });
  }
}

customElements.define("cc-payment", CCPayment);

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 (Number.isNaN(parseInt(cvc))) {
        return "The CVC must be numbers";
      }
    }
    

The solution

Update the JavaScript tab to:

import { kefir as Kefir, StacheElement } from "//unpkg.com/can@6/ecosystem.mjs";

class CCPayment extends StacheElement {
  static view = `
    <form>
      {{# if(this.showCardError.value) }}
        <div class="message">{{ this.cardError.value }}</div>
      {{/ if }}

      {{# if(this.showExpiryError.value) }}
        <div class="message">{{ this.expiryError.value }}</div>
      {{/ if }}

      {{# if(this.showCVCError.value) }}
        <div class="message">{{ this.cvcError.value }}</div>
      {{/ if }}

      <input type="text" name="number" placeholder="Card Number"
          on:input:value:to="this.userCardNumber.value"
          on:blur="this.userCardNumberBlurred.emitter.value(true)"
          {{# if(this.showCardError.value) }}class="is-error"{{/ if }}>

      <input type="text" name="expiry" placeholder="MM-YY"
          on:input:value:to="this.userExpiry.value"
          on:blur="this.userExpiryBlurred.emitter.value(true)"
          {{# if(this.showExpiryError.value) }}class="is-error"{{/ if }}>

      <input type="text" name="cvc" placeholder="CVC"
          on:input:value:to="this.userCVC.value"
          on:blur="this.userCVCBlurred.emitter.value(true)"
          {{# if(this.showCVCError.value) }}class="is-error"{{/ if }}>

      <button>Pay \${{ this.amount.value }}</button>
    </form>
  `;

  static props = {
    amount: {
      get default() {
        return Kefir.constant(1000);
      }
    },

    userCardNumber: {
      get default() {
        return Kefir.emitterProperty();
      }
    },

    userCardNumberBlurred: {
      get default() {
        return Kefir.emitterProperty();
      }
    },

    userExpiry: {
      get default() {
        return Kefir.emitterProperty();
      }
    },

    userExpiryBlurred: {
      get default() {
        return Kefir.emitterProperty();
      }
    },

    userCVC: {
      get default() {
        return Kefir.emitterProperty();
      }
    },

    userCVCBlurred: {
      get default() {
        return Kefir.emitterProperty();
      }
    },

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

    get cardError() {
      return this.cardNumber.map(this.validateCard);
    },

    get showCardError() {
      return this.showOnlyWhenBlurredOnce(
        this.cardError,
        this.userCardNumberBlurred
      );
    },

    // EXPIRY
    get expiry() {
      return this.userExpiry.map(expiry => {
        if (expiry) {
          return expiry.split("-");
        }
      });
    },

    get expiryError() {
      return this.expiry.map(this.validateExpiry).toProperty();
    },

    get showExpiryError() {
      return this.showOnlyWhenBlurredOnce(
        this.expiryError,
        this.userExpiryBlurred
      );
    },

    // CVC
    get cvc() {
      return this.userCVC;
    },

    get cvcError() {
      return this.cvc.map(this.validateCVC).toProperty();
    },

    get showCVCError() {
      return this.showOnlyWhenBlurredOnce(this.cvcError, this.userCVCBlurred);
    }
  };

  validateCard(card) {
    if (!card) {
      return "There is no card";
    }
    if (card.length !== 16) {
      return "There should be 16 characters in a card";
    }
  }

  validateExpiry(expiry) {
    if (!expiry) {
      return "There is no expiry. Format  MM-YY";
    }
    if (
      expiry.length !== 2 ||
      expiry[0].length !== 2 ||
      expiry[1].length !== 2
    ) {
      return "Expiry must be formatted like MM-YY";
    }
  }

  validateCVC(cvc) {
    if (!cvc) {
      return "There is no CVC code";
    }
    if (cvc.length !== 3) {
      return "The CVC must be at least 3 numbers";
    }
    if (Number.isNaN(parseInt(cvc))) {
      return "The CVC must be numbers";
    }
  }

  showOnlyWhenBlurredOnce(errorStream, blurredStream) {
    const errorEvent = errorStream.map(error => {
      if (!error) {
        return {
          type: "valid"
        };
      } else {
        return {
          type: "invalid",
          message: error
        };
      }
    });

    const focusEvents = blurredStream.map(isBlurred => {
      if (isBlurred === undefined) {
        return {};
      }
      return isBlurred
        ? {
            type: "blurred"
          }
        : {
            type: "focused"
          };
    });

    return Kefir.merge([errorEvent, focusEvents])
      .scan(
        (previous, event) => {
          switch (event.type) {
            case "valid":
              return Object.assign({}, previous, {
                isValid: true,
                showCardError: false
              });
            case "invalid":
              return Object.assign({}, previous, {
                isValid: false,
                showCardError: previous.hasBeenBlurred
              });
            case "blurred":
              return Object.assign({}, previous, {
                hasBeenBlurred: true,
                showCardError: !previous.isValid
              });
            default:
              return previous;
          }
        },
        {
          hasBeenBlurred: false,
          showCardError: false,
          isValid: false
        }
      )
      .map(state => {
        return state.showCardError;
      });
  }
}

customElements.define("cc-payment", CCPayment);

Disable the pay button if any part of the card has an error

The problem

Let’s disable the Pay button until the card, expiry, and cvc are valid.

What you need to know

  • Kefir.combine can combine several values into a single value:
    const first = Kefir.sequentially(100, ["Justin", "Ramiya"])
    const last = Kefir.sequentially(100, ["Shah", "Meyer"]).delay(50);
    // first: ---Justin---RamiyaX
    // last:  ------Shah__---Meyer_X
    const fullName = Kefir.combine([first, last], (first, last) => {
      return first + " " + last;
    });
    // fullName: ---Justin Shah
    //             -Ramiya Shah
    //             -Ramiya MeyerX
    
  • childProp:from can set a property from another value:
    <input checked:from="someKey">
    

The solution

Update the JavaScript tab to:

import { kefir as Kefir, StacheElement } from "//unpkg.com/can@6/ecosystem.mjs";

class CCPayment extends StacheElement {
  static view = `
    <form>
      {{# if(this.showCardError.value) }}
        <div class="message">{{ this.cardError.value }}</div>
      {{/ if }}

      {{# if(this.showExpiryError.value) }}
        <div class="message">{{ this.expiryError.value }}</div>
      {{/ if }}

      {{# if(this.showCVCError.value) }}
        <div class="message">{{ this.cvcError.value }}</div>
      {{/ if }}

      <input type="text" name="number" placeholder="Card Number"
          on:input:value:to="this.userCardNumber.value"
          on:blur="this.userCardNumberBlurred.emitter.value(true)"
          {{# if(this.showCardError.value) }}class="is-error"{{/ if }}>

      <input type="text" name="expiry" placeholder="MM-YY"
          on:input:value:to="this.userExpiry.value"
          on:blur="this.userExpiryBlurred.emitter.value(true)"
          {{# if(this.showExpiryError.value) }}class="is-error"{{/ if }}>

      <input type="text" name="cvc" placeholder="CVC"
          on:input:value:to="this.userCVC.value"
          on:blur="this.userCVCBlurred.emitter.value(true)"
          {{# if(this.showCVCError.value) }}class="is-error"{{/ if }}>

      <button disabled:from="this.isCardInvalid.value">
        Pay \${{ this.amount.value }}
      </button>
    </form>
  `;

  static props = {
    amount: {
      get default() {
        return Kefir.constant(1000);
      }
    },

    userCardNumber: {
      get default() {
        return Kefir.emitterProperty();
      }
    },

    userCardNumberBlurred: {
      get default() {
        return Kefir.emitterProperty();
      }
    },

    userExpiry: {
      get default() {
        return Kefir.emitterProperty();
      }
    },

    userExpiryBlurred: {
      get default() {
        return Kefir.emitterProperty();
      }
    },

    userCVC: {
      get default() {
        return Kefir.emitterProperty();
      }
    },

    userCVCBlurred: {
      get default() {
        return Kefir.emitterProperty();
      }
    },

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

    get cardError() {
      return this.cardNumber.map(this.validateCard);
    },

    get showCardError() {
      return this.showOnlyWhenBlurredOnce(
        this.cardError,
        this.userCardNumberBlurred
      );
    },

    // EXPIRY
    get expiry() {
      return this.userExpiry.map(expiry => {
        if (expiry) {
          return expiry.split("-");
        }
      });
    },

    get expiryError() {
      return this.expiry.map(this.validateExpiry).toProperty();
    },

    get showExpiryError() {
      return this.showOnlyWhenBlurredOnce(
        this.expiryError,
        this.userExpiryBlurred
      );
    },

    // CVC
    get cvc() {
      return this.userCVC;
    },

    get cvcError() {
      return this.cvc.map(this.validateCVC).toProperty();
    },

    get showCVCError() {
      return this.showOnlyWhenBlurredOnce(this.cvcError, this.userCVCBlurred);
    },

    get isCardInvalid() {
      return Kefir.combine(
        [this.cardError, this.expiryError, this.cvcError],
        (cardError, expiryError, cvcError) => {
          return !!(cardError || expiryError || cvcError);
        }
      );
    }
  };

  validateCard(card) {
    if (!card) {
      return "There is no card";
    }
    if (card.length !== 16) {
      return "There should be 16 characters in a card";
    }
  }

  validateExpiry(expiry) {
    if (!expiry) {
      return "There is no expiry. Format  MM-YY";
    }
    if (
      expiry.length !== 2 ||
      expiry[0].length !== 2 ||
      expiry[1].length !== 2
    ) {
      return "Expiry must be formatted like MM-YY";
    }
  }

  validateCVC(cvc) {
    if (!cvc) {
      return "There is no CVC code";
    }
    if (cvc.length !== 3) {
      return "The CVC must be at least 3 numbers";
    }
    if (isNaN(parseInt(cvc))) {
      return "The CVC must be numbers";
    }
  }

  showOnlyWhenBlurredOnce(errorStream, blurredStream) {
    const errorEvent = errorStream.map(error => {
      if (!error) {
        return {
          type: "valid"
        };
      } else {
        return {
          type: "invalid",
          message: error
        };
      }
    });

    const focusEvents = blurredStream.map(isBlurred => {
      if (isBlurred === undefined) {
        return {};
      }
      return isBlurred
        ? {
            type: "blurred"
          }
        : {
            type: "focused"
          };
    });

    return Kefir.merge([errorEvent, focusEvents])
      .scan(
        (previous, event) => {
          switch (event.type) {
            case "valid":
              return Object.assign({}, previous, {
                isValid: true,
                showCardError: false
              });
            case "invalid":
              return Object.assign({}, previous, {
                isValid: false,
                showCardError: previous.hasBeenBlurred
              });
            case "blurred":
              return Object.assign({}, previous, {
                hasBeenBlurred: true,
                showCardError: !previous.isValid
              });
            default:
              return previous;
          }
        },
        {
          hasBeenBlurred: false,
          showCardError: false,
          isValid: false
        }
      )
      .map(state => {
        return state.showCardError;
      });
  }
}

customElements.define("cc-payment", CCPayment);

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 JavaScript tab to:

import { kefir as Kefir, StacheElement } from "//unpkg.com/can@6/ecosystem.mjs";

class CCPayment extends StacheElement {
  static view = `
    <form on:submit="this.pay(scope.event)">
      {{# if(this.showCardError.value) }}
        <div class="message">{{ this.cardError.value }}</div>
      {{/ if }}

      {{# if(this.showExpiryError.value) }}
        <div class="message">{{ this.expiryError.value }}</div>
      {{/ if }}

      {{# if(this.showCVCError.value) }}
        <div class="message">{{ this.cvcError.value }}</div>
      {{/ if }}

      <input type="text" name="number" placeholder="Card Number"
          on:input:value:to="this.userCardNumber.value"
          on:blur="this.userCardNumberBlurred.emitter.value(true)"
          {{# if(this.showCardError.value) }}class="is-error"{{/ if }}>
    
      <input type="text" name="expiry" placeholder="MM-YY"
          on:input:value:to="this.userExpiry.value"
          on:blur="this.userExpiryBlurred.emitter.value(true)"
          {{# if(this.showExpiryError.value) }}class="is-error"{{/ if }}>
    
      <input type="text" name="cvc" placeholder="CVC"
          on:input:value:to="this.userCVC.value"
          on:blur="this.userCVCBlurred.emitter.value(true)"
          {{# if(this.showCVCError.value) }}class="is-error"{{/ if }}>
    
      <button disabled:from="this.isCardInvalid.value">
        {{# eq(this.paymentStatus.value.status, "pending") }}Paying{{ else }}Pay{{/ eq }} \${{ this.amount.value }}
      </button>
    </form>
  `;

  static props = {
    amount: {
      get default() {
        return Kefir.constant(1000);
      }
    },

    userCardNumber: {
      get default() {
        return Kefir.emitterProperty();
      }
    },

    userCardNumberBlurred: {
      get default() {
        return Kefir.emitterProperty();
      }
    },

    userExpiry: {
      get default() {
        return Kefir.emitterProperty();
      }
    },

    userExpiryBlurred: {
      get default() {
        return Kefir.emitterProperty();
      }
    },

    userCVC: {
      get default() {
        return Kefir.emitterProperty();
      }
    },

    userCVCBlurred: {
      get default() {
        return Kefir.emitterProperty();
      }
    },

    payClicked: {
      get default() {
        return Kefir.emitterProperty();
      }
    },

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

    get cardError() {
      return this.cardNumber.map(this.validateCard);
    },

    get showCardError() {
      return this.showOnlyWhenBlurredOnce(
        this.cardError,
        this.userCardNumberBlurred
      );
    },

    // EXPIRY
    get expiry() {
      return this.userExpiry.map(expiry => {
        if (expiry) {
          return expiry.split("-");
        }
      });
    },

    get expiryError() {
      return this.expiry.map(this.validateExpiry).toProperty();
    },

    get showExpiryError() {
      return this.showOnlyWhenBlurredOnce(
        this.expiryError,
        this.userExpiryBlurred
      );
    },

    // CVC
    get cvc() {
      return this.userCVC;
    },

    get cvcError() {
      return this.cvc.map(this.validateCVC).toProperty();
    },

    get showCVCError() {
      return this.showOnlyWhenBlurredOnce(this.cvcError, this.userCVCBlurred);
    },

    get isCardInvalid() {
      return Kefir.combine(
        [this.cardError, this.expiryError, this.cvcError],
        (cardError, expiryError, cvcError) => {
          return !!(cardError || expiryError || cvcError);
        }
      );
    },

    get card() {
      return Kefir.combine(
        [this.cardNumber, this.expiry, this.cvc],
        (cardNumber, expiry, cvc) => {
          return { cardNumber, expiry, cvc };
        }
      );
    },

    // STREAM< Promise<Number> | undefined >
    get paymentPromises() {
      return Kefir.combine(
        [this.payClicked],
        [this.card],
        (payClicked, card) => {
          if (payClicked) {
            console.log("Asking for token with", card);
            return new Promise(resolve => {
              setTimeout(() => {
                resolve(1000);
              }, 2000);
            });
          }
        }
      );
    },

    // STREAM< STREAM<STATUS> >
    // This is a stream of streams of status objects.
    get paymentStatusStream() {
      return this.paymentPromises.map(promise => {
        if (promise) {
          // STREAM<STATUS>
          return Kefir.concat([
            Kefir.constant({
              status: "pending"
            }),
            Kefir.fromPromise(promise).map(value => {
              return {
                status: "resolved",
                value: value
              };
            })
          ]);
        } else {
          // STREAM
          return Kefir.constant({
            status: "waiting"
          });
        }
      });
    },

    // STREAM<STATUS> //{status: "waiting"} | {status: "resolved"}
    get paymentStatus() {
      return this.paymentStatusStream.flatMap().toProperty();
    }
  };

  pay(event) {
    event.preventDefault();
    this.payClicked.emitter.value(true);
  }

  validateCard(card) {
    if (!card) {
      return "There is no card";
    }
    if (card.length !== 16) {
      return "There should be 16 characters in a card";
    }
  }

  validateExpiry(expiry) {
    if (!expiry) {
      return "There is no expiry. Format  MM-YY";
    }
    if (
      expiry.length !== 2 ||
      expiry[0].length !== 2 ||
      expiry[1].length !== 2
    ) {
      return "Expiry must be formatted like MM-YY";
    }
  }

  validateCVC(cvc) {
    if (!cvc) {
      return "There is no CVC code";
    }
    if (cvc.length !== 3) {
      return "The CVC must be at least 3 numbers";
    }
    if (isNaN(parseInt(cvc))) {
      return "The CVC must be numbers";
    }
  }

  showOnlyWhenBlurredOnce(errorStream, blurredStream) {
    const errorEvent = errorStream.map(error => {
      if (!error) {
        return {
          type: "valid"
        };
      } else {
        return {
          type: "invalid",
          message: error
        };
      }
    });

    const focusEvents = blurredStream.map(isBlurred => {
      if (isBlurred === undefined) {
        return {};
      }
      return isBlurred
        ? {
            type: "blurred"
          }
        : {
            type: "focused"
          };
    });

    return Kefir.merge([errorEvent, focusEvents])
      .scan(
        (previous, event) => {
          switch (event.type) {
            case "valid":
              return Object.assign({}, previous, {
                isValid: true,
                showCardError: false
              });
            case "invalid":
              return Object.assign({}, previous, {
                isValid: false,
                showCardError: previous.hasBeenBlurred
              });
            case "blurred":
              return Object.assign({}, previous, {
                hasBeenBlurred: true,
                showCardError: !previous.isValid
              });
            default:
              return previous;
          }
        },
        {
          hasBeenBlurred: false,
          showCardError: false,
          isValid: false
        }
      )
      .map(state => {
        return state.showCardError;
      });
  }
}

customElements.define("cc-payment", CCPayment);

Disable the payment button while payments are pending

The problem

Let’s prevent the Pay button from being clicked while the payment is processing.

What you need to know

  • You know everything you need to know.

The solution

Update the JavaScript tab to:

import { kefir as Kefir, StacheElement } from "//unpkg.com/can@6/ecosystem.mjs";

class CCPayment extends StacheElement {
    static view = `
        <form on:submit="this.pay(scope.event)">
            {{# if(this.showCardError.value) }}
                <div class="message">{{ this.cardError.value }}</div>
            {{/ if }}

            {{# if(this.showExpiryError.value) }}
                <div class="message">{{ this.expiryError.value }}</div>
            {{/ if }}

            {{# if(this.showCVCError.value) }}
                <div class="message">{{ this.cvcError.value }}</div>
            {{/ if }}

            <input type="text" name="number" placeholder="Card Number"
                    on:input:value:to="this.userCardNumber.value"
                    on:blur="this.userCardNumberBlurred.emitter.value(true)"
                    {{# if(this.showCardError.value) }}class="is-error"{{/ if }}>

            <input type="text" name="expiry" placeholder="MM-YY"
                    on:input:value:to="this.userExpiry.value"
                    on:blur="this.userExpiryBlurred.emitter.value(true)"
                    {{# if(this.showExpiryError.value) }}class="is-error"{{/ if }}>
    
            <input type="text" name="cvc" placeholder="CVC"
                    on:input:value:to="this.userCVC.value"
                    on:blur="this.userCVCBlurred.emitter.value(true)"
                    {{# if(this.showCVCError.value) }}class="is-error"{{/ if }}>

            <button disabled:from="this.disablePaymentButton.value">
                {{# eq(this.paymentStatus.value.status, "pending") }}Paying{{ else }}Pay{{/ eq }} \${{ this.amount.value }}
            </button>
        </form>
  `;

    static props = {
        amount: {
            get default() {
                return Kefir.constant(1000);
            }
        },

        userCardNumber: {
            get default() {
                return Kefir.emitterProperty();
            }
        },

        userCardNumberBlurred: {
            get default() {
                return Kefir.emitterProperty();
            }
        },

        userExpiry: {
            get default() {
                return Kefir.emitterProperty();
            }
        },

        userExpiryBlurred: {
            get default() {
                return Kefir.emitterProperty();
            }
        },

        userCVC: {
            get default() {
                return Kefir.emitterProperty();
            }
        },

        userCVCBlurred: {
            get default() {
                return Kefir.emitterProperty();
            }
        },

        payClicked: {
            get default() {
                return Kefir.emitterProperty();
            }
        },

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

        get cardError() {
            return this.cardNumber.map(this.validateCard);
        },

        get showCardError() {
            return this.showOnlyWhenBlurredOnce(
                this.cardError,
                this.userCardNumberBlurred
            );
        },

        // EXPIRY
        get expiry() {
            return this.userExpiry.map(expiry => {
                if (expiry) {
                    return expiry.split("-");
                }
            });
        },

        get expiryError() {
            return this.expiry.map(this.validateExpiry).toProperty();
        },

        get showExpiryError() {
            return this.showOnlyWhenBlurredOnce(
                this.expiryError,
                this.userExpiryBlurred
            );
        },

        // CVC
        get cvc() {
            return this.userCVC;
        },

        get cvcError() {
            return this.cvc.map(this.validateCVC).toProperty();
        },

        get showCVCError() {
            return this.showOnlyWhenBlurredOnce(this.cvcError, this.userCVCBlurred);
        },

        get isCardInvalid() {
            return Kefir.combine(
                [this.cardError, this.expiryError, this.cvcError],
                (cardError, expiryError, cvcError) => {
                    return !!(cardError || expiryError || cvcError);
                }
            );
        },

        get card() {
            return Kefir.combine(
                [this.cardNumber, this.expiry, this.cvc],
                (cardNumber, expiry, cvc) => {
                    return { cardNumber, expiry, cvc };
                }
            );
        },

        // STREAM< Promise<Number> | undefined >
        get paymentPromises() {
            return Kefir.combine(
                [this.payClicked],
                [this.card],
                (payClicked, card) => {
                    if (payClicked) {
                        console.log("Asking for token with", card);
                        return new Promise(resolve => {
                            setTimeout(() => {
                                resolve(1000);
                            }, 2000);
                        });
                    }
                }
            );
        },

        // STREAM< STREAM<STATUS> >
        // This is a stream of streams of status objects.
        get paymentStatusStream() {
            return this.paymentPromises.map(promise => {
                if (promise) {
                    // STREAM<STATUS>
                    return Kefir.concat([
                        Kefir.constant({
                            status: "pending"
                        }),
                        Kefir.fromPromise(promise).map(value => {
                            return {
                                status: "resolved",
                                value: value
                            };
                        })
                    ]);
                } else {
                    // STREAM
                    return Kefir.constant({
                        status: "waiting"
                    });
                }
            });
        },

        // STREAM<STATUS> //{status: "waiting"} | {status: "resolved"}
        get paymentStatus() {
            return this.paymentStatusStream.flatMap().toProperty();
        },

        get disablePaymentButton() {
            return Kefir.combine(
                [this.isCardInvalid, this.paymentStatus],
                (isCardInvalid, paymentStatus) => {
                    return (
                        isCardInvalid === true ||
                        !paymentStatus ||
                        paymentStatus.status === "pending"
                    );
                }
            ).toProperty(() => {
                return true;
            });
        }
    };

    pay(event) {
        event.preventDefault();
        this.payClicked.emitter.value(true);
    }

    validateCard(card) {
        if (!card) {
            return "There is no card";
        }
        if (card.length !== 16) {
            return "There should be 16 characters in a card";
        }
    }

    validateExpiry(expiry) {
        if (!expiry) {
            return "There is no expiry. Format  MM-YY";
        }
        if (
            expiry.length !== 2 ||
            expiry[0].length !== 2 ||
            expiry[1].length !== 2
        ) {
            return "Expiry must be formatted like MM-YY";
        }
    }

    validateCVC(cvc) {
        if (!cvc) {
            return "There is no CVC code";
        }
        if (cvc.length !== 3) {
            return "The CVC must be at least 3 numbers";
        }
        if (isNaN(parseInt(cvc))) {
            return "The CVC must be numbers";
        }
    }

    showOnlyWhenBlurredOnce(errorStream, blurredStream) {
        const errorEvent = errorStream.map(error => {
            if (!error) {
                return {
                    type: "valid"
                };
            } else {
                return {
                    type: "invalid",
                    message: error
                };
            }
        });

        const focusEvents = blurredStream.map(isBlurred => {
            if (isBlurred === undefined) {
                return {};
            }
            return isBlurred
                ? {
                        type: "blurred"
                  }
                : {
                        type: "focused"
                  };
        });

        return Kefir.merge([errorEvent, focusEvents])
            .scan(
                (previous, event) => {
                    switch (event.type) {
                        case "valid":
                            return Object.assign({}, previous, {
                                isValid: true,
                                showCardError: false
                            });
                        case "invalid":
                            return Object.assign({}, previous, {
                                isValid: false,
                                showCardError: previous.hasBeenBlurred
                            });
                        case "blurred":
                            return Object.assign({}, previous, {
                                hasBeenBlurred: true,
                                showCardError: !previous.isValid
                            });
                        default:
                            return previous;
                    }
                },
                {
                    hasBeenBlurred: false,
                    showCardError: false,
                    isValid: false
                }
            )
            .map(state => {
                return state.showCardError;
            });
    }
}

customElements.define("cc-payment", CCPayment);

Result

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

See the Pen Credit Card Guide (Advanced) by Bitovi (@bitovi) on CodePen.

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

On this page

Get help

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