Technical Blog

2 Posts authored by: dminor

Question: what errors are your customers seeing during checkout? Standard checkout optimization has us creating funnels in our analytics packages, identifying the choke points, and A/B testing variations of poorly performing pages. But how do we decide what changes to make to the page? Where are shoppers having problems? What if we've moved to a single page checkout – how do we identify problem areas?

 

Capturing the Information

 

One way to help answer these questions is to track the validation errors that customers see by using Google Analytics' event tracking utilities. Event tracking allows us to store arbitrary information in a hierarchical format within Google Analytics, so that we can examine our data in typical Google Analytics fashion. Using a relatively simple snippet of code in our Elastic Path velocity templates, we can store our form validation errors:

 


#set($errorsObj = $springMacroRequestContext.getErrors("expressCheckoutFormBean"))
#if ($errorsObj.errorCount > 0)

     <script type="text/javascript">
          #if($errorsObj.globalErrorCount > 0)
               #foreach($error in $errorsObj.globalErrors)
                    _gaq.push(["_trackEvent", "Checkout Error", "$error.code", "global"]);
               #end
          #end


          #if($errorsObj.fieldErrorCount > 0)
               #foreach($error in $errorsObj.fieldErrors)
                    _gaq.push(["_trackEvent", "Checkout Error", "$error.code", "$error.field"]);
               #end
          #end
     </script>
#end

 

You'll need to substitute the name of your own form bean for the call to getErrors(). If you have a multi-page checkout process, you may want to use a different event tracking label for each page, rather than just Checkout Error. Note also that this code uses the newer asynchronous format of the Google Analytics tracker – if you are using the old format, call the _trackEvent function of your GA tracker object.

 

Analyzing the Data

 

Now that we have the event tracking in place, what can we learn from the data? If we navigate in Google Analytics to Content → Event Tracking → Categories → Checkout Error, we can see exactly which errors customers experienced (one thing to keep in mind: every error will be counted, so one page view can result in multiple errors being recorded). More interesting though is the Ecommerce tab, which shows us in the Transactions column whether or not customers eventually checked out:

 

blog_image_2.jpg

 

This is over a 10 day time period. Not surprisingly, missing required fields are the most common error encountered. If we drill down into errors.required we can see exactly which required fields are tripping visitors up:

 

blog_image_3.jpg

 

The big winner is CVV code! Fortunately only 6 of 128 people failed to eventually checkout, and it's possible that not all of those 6 were legitimate shoppers. Still, we may be able to make the checkout process smoother if we explain what CVV is and where to find it. A surprising number of people seem to have left off their credit card number as well. Potentially customers are simply missing the credit card section altogether. Now we have some ideas for A/B tests we can run.

 

Other problem fields seem to be email and phone number. Not too shocking; people are wary about giving these out and were perhaps hoping they weren't actually required. Maybe we should do a better job of explaining why we need this information.

 

Looking back at the first Checkout Error screen, the big conversion drop-offs seem to be in places we'd expect: rejected credit card authorizations, incorrect address data, asking the customer to phone in their order, and no inventory. Hmm, what's this errors.whitespace?

 

blog_image_4.jpg

 

The validation rule is "no leading or trailing whitespace allowed", so someone probably had a space after their email address, couldn't figure out the error, and gave up. Stripping the whitespace prior to validation makes more sense and might have saved the order. From a net revenue standpoint this improvement isn't high on the list, but fixing it is 10 minutes of our time, and we can check to see if this rule applies to any other fields.

 

There is a great deal more information to delve into here, and over time we will build up a history that will allow us to spot new problems as they crop up. By adding a short and simple snippet of code, we've gained greater insight into our customers' behavior, and hopefully discovered some ways to increase our conversion rate and create a smoother checkout process.

 

David Minor works for women's sportswear retailer Team Estrogen, an Elastic Path customer.

0 Comments Permalink

Elastic Path comes with a very flexible attribute system, allowing you to customize the attributes associated with products, skus, customers, categories, and more. Attributes can be expressed as several different types -- strings, lists of strings, integers, decimals, booleans, images, etc. -- so that a great variety of information can be associated with your domain objects.

 

Editing this information is fairly straightforward in the Commerce Manager client. However, if you'd like to restrict an attribute to certain values, there is currently no way to enforce this in the client, and the potential exists for entering data incorrectly. This can cause problems if you are relying on the value of a particular attribute for functionality on your website, or perhaps for interfacing with your back-end systems.

 

Fortunately, we have the EP source code and can implement this functionality with a little effort, using a list of options stored in the database and some modifications to the Commerce Manager Client.

 

Domain Objects

 

Attribute values are derived from com.elasticpath.domain.attribute.impl.AbstractAttributeValueImpl, which is a "mapped superclass", meaning that there is no superclass db table and subclasses exist separately in their own db table. Since we need to maintain a per-attribute list of legal attribute values, we can simply leverage this existing infrastructure:

 

@Entity
@Table(name = AttributeOptionImpl.TABLE_NAME)
public class AttributeOptionImpl extends AbstractAttributeValueImpl {
 
     /**
      * The name of the table & generator to use for persistence.
      */
     public static final String TABLE_NAME = "TATTRIBUTEOPTION";
     
     private long uidPk;
 
     /**
      * Gets the unique identifier for this domain model object.
      *
      * @return the unique identifier.
      */
     @Id
     @Column(name = "UIDPK")
     @GeneratedValue(strategy = GenerationType.TABLE, generator = TABLE_NAME)
     @TableGenerator(name = TABLE_NAME, table = "JPA_GENERATED_KEYS", pkColumnName = "ID", valueColumnName = "LAST_VALUE", pkColumnValue = TABLE_NAME)
     public long getUidPk() {
          return uidPk;
     }
 
     /**
      * Sets the unique identifier for this domain model object.
      *
      * @param uidPk the new unique identifier.
      */
     public void setUidPk(final long uidPk) {
          this.uidPk = uidPk;
     }
}

 

Because the superclass already defines the link to the Attribute as well as the fields for storing the actual value, there is nothing more to define in the subclass. Don't forget to create TATTRIBUTEOPTION and add an entry to JPA_GENERATED_KEYS!

 

These attribute value options will belong to the Attribute entity, so we will need a "join" in com.elasticpath.domain.attribute.impl.AttributeImpl (or a subclass to minimize EP code changes):

 

     private List<AttributeValue> attributeOptions;
 
 
     @OneToMany(targetEntity = AttributeOptionImpl.class,
                  cascade = { CascadeType.ALL })
     @ElementJoinColumn(name = "ATTRIBUTE_UID", nullable = false)
     @ElementDependent
     @ElementForeignKey
     public List<AttributeValue> getAttributeOptions() {
          return this.attributeOptions;
     }
     
     public void setAttributeOptions(final List<AttributeValue> attributeOptions) {
          this.attributeOptions = attributeOptions;
     }

 

These methods will need to be exposed via the Attribute interface as well. You will also need to modify the fetch groups you are using when retrieving attributes, either by adding attribute options to the 'attributes' fetch group, or for better performance as a separate fetch group that is included as needed in the CM Client (such as in CM Client's ProductEditorInput.retrieveProduct()).

 

User Interface

 

Now that the domain objects have been defined, we can turn our attention towards the user interface. Because the attributes were built with extensibility in mind, editing attributes has been somewhat modularized, making it easier for us to plug in our attribute option selection where appropriate.

 

Dialog for Selecting Among Options

 

The first thing we'll do is create a simple dialog for choosing among our attribute options (I've left out some boilerplate such as setting the title):

public class AttributeOptionDialog extends AbstractEpDialog implements IValueRetriever {
     /**
      * the attribute being edited.
      */
     private Attribute attr;
 
 
     /**
      * the value of the handled attribute.
      */
     private Object value;
 
     private CCombo comboBox;
     
     private List<AttributeValueWithType> comboValues;
     
     /**
      * The constructor of the attribute option dialog window.
      * 
      * @param parentShell the parent shell object of the dialog window
      * @param value the attribute value passed in
      */
     public AttributeOptionDialog(final Shell parentShell, final Object value, Attribute attr) {
          super(parentShell, 2, false);
          this.value = value;
          this.attr = attr;
     }
 
     /**
      * Create the set and cancel button on the dialog window.
      * 
      * @param buttonsBarType The button bar type object passed in.
      * @param parent the parent composite
      */
     @Override
     protected void createEpButtonsForButtonsBar(final ButtonsBarType buttonsBarType, final Composite parent) {
          createEpOkButton(parent, CoreMessages.AbstractEpDialog_ButtonOK, null);
          createEpCancelButton(parent);
     }
 
     /**
      * Create the content of integer dialog window.
      * 
      * @param dialogComposite The dialog composite object
      */
     @Override
     protected void createEpDialogContent(final IEpLayoutComposite dialogComposite) {
          this.comboBox = dialogComposite.addComboBox(EpState.EDITABLE, dialogComposite.createLayoutData(IEpLayoutData.FILL,
                    IEpLayoutData.CENTER, true, true));
          this.comboBox.setVisibleItemCount(20);
     }
 
     /**
      * Handle button pressed event.
      */
     @Override
     protected void okPressed() {
          if (comboBox.getSelectionIndex() >= 0 && comboBox.getSelectionIndex() < comboValues.size()) {
               this.value = comboValues.get(comboBox.getSelectionIndex()).getValue();
          }
          super.okPressed();
     }
 
     /**
      * Get the input value.
      * @return the value input by the user.
      */
     public Object getValue() {
          return this.value;
     }
 
     /**
      * populate the control created in the dialog window.
      */
     @Override
     protected void populateControls() {
          if (attr.getAttributeOptions() != null ) {
               comboValues = new ArrayList<AttributeValueWithType>();
               for( AttributeValue option : attr.getAttributeOptions()) {
                    comboValues.add((AttributeValueWithType)option);
               }
               
               Collections.sort(comboValues);
 
               if (value != null) {
                    int index = 0;
                    for(AttributeValueWithType option : comboValues) {
                         comboBox.add(option.getStringValue());
                         if (value.equals(option.getValue())) {
                              comboBox.select(index);
                         }
 
                         index++;
                    }
               } else {
                    for(AttributeValueWithType option : comboValues) {
                         comboBox.add(option.getStringValue());
                    }
               }
          }
     }
}

 

This dialog will present the user with a drop down list containing the attribute options, sorted according to their natural ordering. When OK is pressed, the dialog's value will be set to the selected attribute value.

 

Now we need to modify the attribute editing code in com.elasticpath.cmclient.catalog.editors.attribute, which is utilized throughout the CM Client UI. In AttributeEditingSupport.java we'll find the code for managing the dialogs and cell editors used for editing attributes.

 

There are two paths a user can take for editing an attribute's value: a user can select the attribute and press the edit button, which presents a dialog, or click in the table cell, which leads to the same dialog in most cases, but is directly editable in the case of short text values. For simplicity's sake we will present our dialog when the user takes either of the two actions (another approach, for a more seamless UI, might be to switch to a ComboBoxEditor to place a dropdown menu directly in the cell). One final exception to worry about is multi-valued short text values -- we will display a modified version of the ShortTextMultiValueDialog.

 

Most of the cell editors use a DialogCellEditor for editing their contents. We will add a check to see if the attribute has AttributeOptions, and open our dialog instead. To keep our EP code changes to a minimum, we will insert a new class into the hierarchy between DialogCellEditor and the individual cell editor classes, which checks for attribute options and opens our new dialog if they exist:

 

public abstract class AttributeDialogCellEditor extends DialogCellEditor {
 
     private final AttributeValue attr;
 
     protected AttributeDialogCellEditor(final Composite parent,
               final AttributeValue attr) {
          super(parent);
          this.attr = attr;
     }
 
     @Override
     protected Object openDialogBox(Control cellEditorWindow) {
          if (!attr.getAttribute().isMultiValueEnabled() &&
                    attr.getAttribute().getAttributeOptions() != null &&
                    attr.getAttribute().getAttributeOptions().size() > 0) {
               final AttributeOptionDialog dialog = new AttributeOptionDialog(cellEditorWindow.getShell(), 
                         attr.getValue(), attr.getAttribute());
               dialog.open();
               return dialog.getValue();
          }
          return onOpenDialogBox(cellEditorWindow);
     }
     
     protected abstract Object onOpenDialogBox(Control cellEditorWindow);
}

 

Then we can make three minor changes to each of the dialog cell editors in AttributeEditingSupport.java -- change the class they extend to AttributeDialogCellEditor, add 'attr' as an argument to super(), and rename the openDialogBox() method to onOpenDialogBox().

 

To finish up attribute editing, there are two other places in AttributeEditingSupport where we need to display our dialog:

 

     public static Window getEditorDialog(final AttributeValue attr, final Shell shell) {
          /* Begin new code */
          if (!attr.getAttribute().isMultiValueEnabled() &&
                         attr.getAttribute().getAttributeOptions() != null &&
                         attr.getAttribute().getAttributeOptions().size() > 0) {
               return new AttributeOptionDialog(shell, attr.getValue(), attr.getAttribute());
          }
          /* End new code */

 

And:

 

     protected CellEditor getCellEditor(final AttributeValue attr) {
          final Table table = this.attributesTableViewer.getSwtTable();
          CellEditor result = null;
          switch (attr.getAttributeType().getTypeId()) {
          case AttributeType.SHORT_TEXT_TYPE_ID:
               if (attr.getAttribute().isMultiValueEnabled()
                              /* Begin new code */
                              || (attr.getAttribute().getAttributeOptions() != null &&
                              attr.getAttribute().getAttributeOptions().size() > 0)
                              /* End new code */
                         ) {
                    result = new ShortTextCellEditor(table, attr);
                    break;
               }

 

The former change will show our dialog when the user presses the edit button, and the latter will show our dialog when a short text value's cell is clicked (which is normally directly editable).

 

For multi-valued short text values, we need to modify the addAction() and editAction() methods of the ShortTextMultiValueDialog. Attribute values will be entered using our new dialog, if the attribute has options:

 

     private void addAction() {
          String addedValue = null;
          if ( model.getAttribute().getAttributeOptions() != null &&
               model.getAttribute().getAttributeOptions().size() > 0 ) {
               final AttributeOptionDialog dialog = new AttributeOptionDialog(getShell(),
                         null, model.getAttribute());
               if ( dialog.open() != Window.OK ) {
                    return;
               }
               addedValue = (String)dialog.getValue();
               
               if (addedValue == null) {
                    return;
               }
          } else {
               final ShortTextDialog dialog = new ShortTextDialog(getShell(),
                         null, false);
               final int result = dialog.open();
               if (result != Window.OK) {
                    return;
               }
               addedValue = dialog.getValue();
          }
 
          if (addedValue == null) {
               return;
          }
 
          getShortTextValues().add(addedValue);
          setShortTextValues(getShortTextValues());
          refreshViewer();
          setValue(getModel().getStringValue());
 
     }
 
 
     private void editAction() {
          String editedValue = null;
          final String originalString = getSelectedValue();
          if ( model.getAttribute().getAttributeOptions() != null &&
               model.getAttribute().getAttributeOptions().size() > 0 ) {
               final AttributeOptionDialog dialog = new AttributeOptionDialog(getShell(),
                         originalString, model.getAttribute());
               if ( dialog.open() != Window.OK ) {
                    return;
               }
               editedValue = (String)dialog.getValue();
               
               if (editedValue == null) {
                    return;
               }
          } else {
               final ShortTextDialog dialog = new ShortTextDialog(getShell(),
                         originalString, true);
               final int result = dialog.open();
               if (result != Window.OK) {
                    return;
               }
               editedValue = dialog.getValue();
               
               if (editedValue == null) {
                    return;
               }
          }
 
          List<String> editedList = new ArrayList<String>();
          for (String element : getShortTextValues()) {
               if (element.equals(originalString)) {
                    editedList.add(editedValue);
               } else {
                    editedList.add(element);
               }
          }
 
          setShortTextValues(editedList);
 
          shortTextValueTableViewer.setInput(editedList.toArray());
          shortTextValueTableViewer.getSwtTableViewer().refresh();
          setValue(getModel().getStringValue());
 
     }
 

 

Creating the Options

 

Finally, the administrator needs to input the list of options for each particular attribute. We will make the necessary changes in a subclass of com.elasticpath.cmclient.catalog.dialogs.catalog.CatalogAttributesAddEditDialog:

 

public class AttributeOptionCatalogAttributesAddEditDialog extends CatalogAttributesAddEditDialog 
     implements SelectionListener, ISelectionChangedListener {
     private IEpTableViewer attributesTableViewer;
 
     private Button removeButton;
 
     private Button addButton;
 
     public CatalogAttributesWithOptionsDialog(final Shell parentShell,
          final Attribute attribute, final boolean isGlobal) {
          super(parentShell, attribute, isGlobal);
     }

 

To createEpDialogContent(), we will add a table to display the options:

     protected void createEpDialogContent(
               final IEpLayoutComposite dialogComposite) {
          super.createEpDialogContent(dialogComposite);
          
          final IEpLayoutData labelData = dialogComposite.createLayoutData(
                    IEpLayoutData.END, IEpLayoutData.FILL);
          final IEpLayoutData fieldData = dialogComposite.createLayoutData(
                    IEpLayoutData.FILL, IEpLayoutData.FILL, true, false);
 
          dialogComposite.addLabelBold(
                    "Restrict values to", //$NON-NLS-1$
                    labelData);
 
          dialogComposite.addLabel(
                    "", //$NON-NLS-1$
                    fieldData);
          
          IEpLayoutComposite allowedValuesComposite = dialogComposite.addGridLayoutComposite(2, false, 
                    dialogComposite.createLayoutData(IEpLayoutData.FILL, IEpLayoutData.FILL, true, false, 2, 1));
          attributesTableViewer = allowedValuesComposite.addTableViewer(false,
                    EpState.EDITABLE, 
                    allowedValuesComposite.createLayoutData(IEpLayoutData.END, IEpLayoutData.FILL, false, true));
          
          attributesTableViewer.addTableColumn( "Value", //$NON-NLS-1$
                    400).setLabelProvider(new ColumnLabelProvider() {
               public String getText(final Object element) {
                    final AttributeValue value = (AttributeValue) element;
                    return value.getStringValue();
               }
          });
          
          attributesTableViewer.setContentProvider(new IStructuredContentProvider() {
               public Object[] getElements(final Object inputElement) {
                    Attribute attributeInput = (Attribute)inputElement;
                    if (attributeInput.getAttributeOptions() != null) {
                         List<AttributeValueWithType> attributeOptions = new ArrayList<AttributeValueWithType>();
                         for( AttributeValue av : attributeInput.getAttributeOptions() ) {
                              attributeOptions.add((AttributeValueWithType)av);
                         }
                         Collections.sort(attributeOptions);
                         return attributeOptions.toArray();
                    }
                    return new Object [0];
               }
               public void dispose() {}
               public void inputChanged(Viewer arg0, Object arg1, Object arg2) {}
          });
          
          IEpLayoutComposite buttonsComposite = allowedValuesComposite.addGridLayoutComposite(1, false, null);
 
          // create add button
          final Image addImage = CoreImageRegistry
                    .getImage(CoreImageRegistry.IMAGE_ADD);
          addButton = buttonsComposite.addPushButton(
                    "Add value", addImage, //$NON-NLS-1$
                    EpState.EDITABLE, buttonsComposite.createLayoutData(
                              IEpLayoutData.FILL, IEpLayoutData.BEGINNING));
          addButton.addSelectionListener(this);
          
          // create remove button
          final Image removeImage = CoreImageRegistry
                    .getImage(CoreImageRegistry.IMAGE_REMOVE);
          removeButton = buttonsComposite.addPushButton(
                    "Remove value", removeImage, //$NON-NLS-1$
                    EpState.EDITABLE, buttonsComposite.createLayoutData(
                              IEpLayoutData.FILL, IEpLayoutData.BEGINNING));
          removeButton.addSelectionListener(this);
 
          attributesTableViewer.getSwtTableViewer().addSelectionChangedListener(this);
     }

 

We will initialize our new controls in populateControls():

     public void populateControls() {
          super.populateControls();
          
          addButton.setEnabled(true);
          removeButton.setEnabled(true);
          attributesTableViewer.setInput(getAttribute());
     }
     

 

And (finally!), we can handle the add/remove functionality, leveraging the existing attribute editing dialogs for entering our values:

 

     public void widgetDefaultSelected(SelectionEvent arg0) {
          // not used
     }
     
     private AttributeValue getSelectedValue() {
          return (AttributeValue) ((IStructuredSelection) attributesTableViewer.getSwtTableViewer()
                    .getSelection()).getFirstElement();
     }
 
     public void widgetSelected(SelectionEvent event) {
 
          if ( event.getSource() == addButton) {
               addValue();
          } else if ( event.getSource() == removeButton) {
               final AttributeValue attribute = getSelectedValue();
               if ( attribute != null ) {
                    removeValue(attribute);
               }
          }
          
     }
     
     private void addValue() {
          AttributeValue newOpt = Application.getInstance().getElasticPath().getBean(ContextIdNames.ATTRIBUTE_OPTION);
          newOpt.setAttribute(attribute);
          newOpt.setLocalizedAttributeKey(attribute.getKey());
          newOpt.setAttributeType(attribute.getAttributeType());
          Window dialog = getEditorDialog(newOpt, getShell());
          if ( dialog.open() == Window.OK ) {
               final IValueRetriever retriever = (IValueRetriever) dialog;
               if ( retriever.getValue() != null ) {
                    newOpt.setValue(retriever.getValue());
                    List<AttributeValue> attributeOptions = attribute.getAttributeOptions();
                    if ( attributeOptions == null ) {
                         attributeOptions = new ArrayList<AttributeValue>();
                    }
                    attributeOptions.add(newOpt);
                    attribute.setAttributeOptions(attributeOptions);
                    attributesTableViewer.getSwtTableViewer().refresh();
               }
          }
     }
     
     private void removeValue(AttributeValue attr) {
          List<AttributeValue> attributeOptions = attribute.getAttributeOptions();
          if ( attributeOptions != null ) {
               for( int index = 0; index < attributeOptions.size(); index++ ) {
                    if ( attributeOptions.get(index).getUidPk() == attr.getUidPk() ) {
                         attributeOptions.remove(index);
                         attribute.setAttributeOptions(attributeOptions);
                         attributesTableViewer.getSwtTableViewer().refresh();
                         break;
                    }
               }
          }
     }
 
     public void selectionChanged(SelectionChangedEvent event) {
          //not used
     }
     
     private Window getEditorDialog(final AttributeValue attr, final Shell shell) {
 
          Window dialog = null;
          switch (attr.getAttributeType().getTypeId()) {
          case AttributeType.BOOLEAN_TYPE_ID:
               dialog = new BooleanDialog(shell, attr.getValue());
               break;
          case AttributeType.DATE_TYPE_ID:
               dialog = new DateTimeDialog(shell, attr.getValue(),
                         IEpDateTimePicker.STYLE_DATE);
               break;
          case AttributeType.DATETIME_TYPE_ID:
               dialog = new DateTimeDialog(shell, attr.getValue(),
                         IEpDateTimePicker.STYLE_DATE
                                   | IEpDateTimePicker.STYLE_DATE_AND_TIME);
               break;
          case AttributeType.DECIMAL_TYPE_ID:
               dialog = new DecimalDialog(shell, attr.getValue());
               break;
          case AttributeType.INTEGER_TYPE_ID:
               dialog = new IntegerDialog(shell, attr.getValue());
               break;
          case AttributeType.LONG_TEXT_TYPE_ID:
               dialog = new LongTextDialog(shell, attr.getValue());
               break;
          case AttributeType.SHORT_TEXT_TYPE_ID:
               dialog = new ShortTextDialog(shell, attr.getValue(), true);
               break;
          case AttributeType.IMAGE_TYPE_ID:               
               dialog = AssetManager.createAssetManagerImagesDialog(shell);
               break;
          case AttributeType.FILE_TYPE_ID:               
               dialog = AssetManager.createAssetManagerFilesDialog(shell);
               break;
          default:
               // throw new RuntimeException("Unknown attribute type");
          }
          return dialog;
     }

 

Lastly we just replace all references of CatalogAttributesAddEditDialog with our new AttributeOptionCatalogAttributesAddEditDialog, and we're done! While not a trivial change, the existing infrastructure certainly made our job easier. Now we can restrict attributes to particular values if we need to, and not have to worry about mistypings or misspellings.

 

David Minor works for women's sportswear retailer Team Estrogen, an Elastic Path customer.

1 Comments Permalink