We have shown how you can create a simple cart application by defining three simple objects for each tarp item (product or service) that you wish to sell. The three objects are:

  1. A form to allow the user to enter or select the desired product options
  2. A cart item object used to store the product configuration in memory and validate the item
  3. A form_handler to transfer information between the form and the cart item object (in both directions)

Although these components are all very simple to build, if you have a store with dozens or even hundreds of products it could get a little unwieldy to have to create so many components. In this section we will describe an approach that involves fewer components. Handling products generically is more complicated, therefore this approach should only be taken if you have, or plan to have, more than, say, 5 different products.

Problems to be Solved

Supporting many products with a single web page introduces many problems that need to be resolved. Let's review the key challenges.

Middle Ground Solution

One solution that should be considered is to have a separate form for each individual product and having all product forms share a common form_handler which utilizes common cart item objects. This approach would take care of most of the requirements listed above since things like titles, on-page content, meta data, etc. can be defined when building the product pages so if each product has its own page there would be no need to set this information dynamically. The downside of this solution is that it requires a new page to be built whenever a new product is introduced.  If you want your store to be completely database driven you would not want to have to add new pages whenever you add new products. Therefore we will look at a solution that will allow any number of different products to be ordered utilizing the same page. Since one of the strengths of GenHelm is it's ability to create sophisticated pages without a database, we will build our generic product pages using information stored in datasheets in order to avoid having to define database tables. While this solution is certainly scalable to dozens of products, you should probably consider using a database if you think you will have hundreds of products or more.

Implementing Several Logical Pages using One Physical Page

You are probably familiar with the concept of passing a query sting to a certain page to control the behavior of the page. This is something we will do in our generic "product" page. Typically, the way this works is that you create a page, such as order, then you pass an identifier, indicating what to order, as a parameter to the page. The problem with this approach is that the page URLs would look like this:

www.example.com/order?type=roll-tarp

www.example.com/order?type=container-tarp

etc.

There is some evidence that pages with query string parameters are less SEO friendly and/or user friendly than folder style URLs. The GenHelm framework has a feature which allows us to use folder style URLs that behave like querystrings. To do so, we simply need to define an Advanced Page Property as we have done here:

URL Parameters Advanced Page Property

With this setting in place, the URLs shown above can be entered as:

www.example.com/order/roll-tarp

www.example.com/order/container-tarp

etc

Furthermore, the last component of the URL will behave as though this is passed as the type parameter in the querystring. In other words, the order page will be loaded in all cases and this page can use the type parameter to determine what is being ordered.

Defining Products in a Datasheet

In this example we will use the datasheet model to describe product information instead of using database tables. We will call our first datasheet product_type and we will define one row for each type of product that we plan to sell online. Below are the columns that we will define in our datasheet:

indexThis is a unique product type
implementationThis will refer to a separate "table" that describes how the product will be implemented
captionThis is the h3 heading to be used for the product on the order page
summaryThis is a description of the product that will be shown on the generic product page
imageidThis is the id of the image that should be shown on the product page
titleThis is the title that will be used on the order page
headingThis is the h2 heading that will be used on the order page
meta_description The meta_description will be used on the order page
cart_itemThis is the name of the item that will be shown in the cart
show_weightA flag that will be used to indicate whether the item's weight should be shown

As you can see, most of these values are used to configure the page and show a suitable page title and on-page content. The entire datasheet is too large to present here so we will just show the start of a couple of rows below:

product_type Datasheet

Designing a Product Item Class Hierarchy

In this example, we have a diverse range of products that can be ordered. Some have very few attributes as we see on this order form for Stretch Rope:

Simple product order form

Other products have many attributes as we show here on this form to order standard Roll Tarps:

Product Item with Many Properties

It is often helpful to create a grid, such as the following, that shows all of the products you want to sell as columns and all of the attributes of these products as rows.

Image generated by spec help/programming/generic-cart-items

Since multiple products have notes and color as attributes it might make sense to define these attributes in a base class. Since roll-tarps require a lot more properties than other products we should define these in a separate class. Here is a possible class hierarchy to define these products::

cart_item (base class defined in system)
   product extends cart_item (defines notes and color)
     stretch_rope extends product (defines length)
     roll-tarp extends product (defines all of the roll-tarp properties)

Technically, stretch_rope does not inherit any properties from product so we could have had stretch_rope inherit cart_item instead. Nevertheless, there may be common methods that we want to inherit so it is a good ideal for all products to inherit from a common base (defined as a class within the current site). Rather than naming this class stretch_rope we will call it special since we may use this for other products that have special one-off attributes.

Define Implementation Details using php_array_data

We will use the php_array_data model to define the class implementation details described above.

Class implementation defined in php_array_data

Notice that this definition also identifies the form that will be used to input the product definition. We could have defined these columns directly within the product_type datasheet however it is cleaner to normalize these implementation details so that the datasheet only needs a simple implementation column which points at one of the rows shown above. We need a row in our implementation array for each unique combination of cart item class and form. In this store we have 19 products but many share the same cart item object and/or form so we only require 9 implementations. In order to group all of the cart item objects into their own folder (rather than storing these in the site's classes root folder) we have specified the subfolder where our classes reside as part of our implementation. We could have also just hardcoded this subfolder in our form_handler since they are all the same.

Our Shopping Page

Here we show a typical shopping page.

Typical Shopping Page

The cart itself is simply a reference to the $cart() function. Here we show how the first couple of order links are rendered:

$link(order,type=roll-tarp,Standard Roll Tarp)
$link(order,type=canvas,Canvas Tarp)

Notice that both links reference the same page and pass type in the query string to indicate which product will be ordered. As we have discussed, this type value won't actually appear in the url as a parameter but rather it will be presented as the last part of the folder path.

Let's recap how our generic order form processing will work:

  1. The form_handler for the order page will get the product type parameter (path) from the querystring
  2. The type value will be used to index into the product_type datasheet to get information about the product (description, title, image, etc.)
  3. One of the columns in the datasheet is called implementation, this will be used to get the implementation details for the order from the product_implementation php_array_data definition.
  4. The product_implementation defines the item object to be loaded as well as the form to be used to collect the product details.

Note that we are still missing some important information we need to build our pages. As we can see from the product pages above for Roll Tarps and Stretch Rope, products can come in different sizes and each size has a different price and weight. We will use another datasheet to define this information.

product (SKU) datasheet

Here we see a snippet of the datasheet that we will use to store all of the product sizes associated with each product type.

product Datasheet

order Page

Recall that the order page is the page that allows us to order any product. Here we see what this page looks like:

Shop by type form

As you can see, the form is very simple. Let's go through the fields on the form.

..product_intro

Since this value begins with .. we know that it is a placeholder field. Our page_handler will populate this cell with a description of the product including an image. This descriptive information comes from the product_type datasheet.

:product_code,type:hidden

Since this value begins with : we know this is a field. This hidden field will be populated by some JavaScript when the user clicks on one of the product sizes. This tells the form_handler which size was ordered (which determines the price and weight of the product).

..sizes

This placeholder will be substituted for a series of radio buttons showing the product sizes that are available. The size information comes from the product datasheet.

$page(quantity)

Since the user needs to specify the quantity ordered for each product, we created a separate small form to input the quantity value. This is a simple form defined as a page_component which is shown here:

Page to collect the quantity

$page($cache_get(cart,subpage))

Since different products require different sets of attributes we don't know what form we will need at this point. Therefore we apply a late-binding technique in which the $page function is passed a "variable". In this case the variable value will come from another dollar function. Inside the form handler, once we know what product we are dealing with, we will get the form name from the product_implementation definition and we will use $cache_set to assign the form name.

$page(buttons_update_cart)

The final cell contains a reference to the buttons_update_cart page which is used to render the buttons on the order form.

In addition to the form fields, the order form also sets the form_handler and the transaction as we can see here.

Generic Form Settings

As with all forms that define transactions, the Page Type is set to "Transaction Start".

The actual transaction definition is very simple as we show here.

other_products Transaction Definition

standard_product_handler form_handler

Most of the logic to handle products generically is situated in the form_handler of the order form. We explain how to create the form_handler in the following sections.

Datasheets are supported by a system class named load_simple_table so we begin by declaring this. As with most cart item form_handlers, we extend cart_form_hander_base

Form Handler Setup Properties

We define the following three properties since we want form handler to retain these values between method calls. These are only used internally by the form_handler so we don't require get or set methods.

form_handler Properties

As we go through the code, let's assume the following product is being ordered.

Order form used in the example code

define_item_implementation Method

The define_item_implementation method is responsible for:

  1. Determining which product is being ordered - the product type is assigned to $this->item_type
  2. Looking up information about the product (from the product_type datasheet)
  3. Determining the product implementation details (defined using model php_array_data)
  4. Establish the subform to be loaded
  5. $this->class must be assigned the name of the cart item class
  6. Determining what sizes are available (by looking this up in a separate datashaeet)
function define_item_implementation() {
	// The product type determines which form needs to be loaded so get this first.
	// This needs to happen in pre_initialize since this is called before building the forms.
	$this->item_type = $this->site->parameters->get_or_default('type','');
	if ($this->item_type === '') {
		$this->site->redirect('','Product type is required');
	}
	$datasheet = new \system\load_simple_table('product_type');
 	$datasheet->select_single('index',$this->item_type,'==');
	$this->product_info = $datasheet->get_keyed_records();
	//
 	// The implementation column of the datasheet points to an index within the product_implementation php_array_data
 	$implementation = $this->product_info['implementation'];
	if (empty($implementation)) {
		$this->site->redirect('shop','Invalid product type specified '.$this->item_type.' Implementation missing');
	}
	$impl_data = $this->site->create_object('product_implementation','',false,'array_data');
	$this->item_class = $impl_data->get_class($implementation);  // Item object
	$this->item_subfolder = $impl_data->get_subfolder($implementation);  // product
	// Add the form name to the cache
	$form_name = $impl_data->get_form($implementation);
	if (empty($form_name)) {
		$form_name = '!'; // $page skips pageid !
	}
	$this->site->dollar_function('cache_set','cart','subpage',$form_name);
	// Set the meta description, page h1 tab and title tag using values from the product_type datasheet
  	$metatags = $this->site->layout->get_object('metatags');
	$metatags->define_meta_name('description',$this->product_info['meta_description'],false,__CLASS__);
	if ($this->product_info['heading']) {
		$this->site->dollar_function('tag_content','page_header',$this->product_info['heading']);
	}
	$this->site->dollar_function('container','title',$this->product_info['title']);
}

Some of the products don't require any additional properties. In this case, we set the form name to "!". This is a special value that the $page function treats as null.

Here we show the contents of $this->product_info after loading the product_type datasheet row for container-cover.

// Contents of $this->product_info
array ( 'index' => 'container-cover', 
       'implementation' => 'notes', 
       'has_sizes' => 'y', 
       'caption' => 'Container Cover', 
       'summary' => 'Used to cover industrial containers and waist bins. ... material which can be stetched to a certain extent.', 
       'imageid' => 'container-covers', 
       'title' => 'Industrial Container Covers', 
       'heading' => 'Industrial Container and Waste Bin Covers', 
       'meta_description' => 'Industrial container covers and disposal bin covers.', 
       'cart_item' => 'Container Cover', 
       'show_weight' => 'y')

Supporting Functions

There are a few supporting functions we require. Recall that the generic page has two placeholders named product_intro and sizes. We create some functions to support the building of these placeholders. Let's go through these next.

product_intro

This function calls $image to build an img tag if the product_type defines an image name. It then combines the image with a heading and summary text as we show here.

function product_intro() {
    if ($this->product_info['imageid'] > ''){
        $image_tag = $this->site->dollar_function('image',$this->product_info['imageid']);
    }
    else {
        $image_tag = '';
    }
    $h3 = $this->product_info['caption'];
    $summary = $this->product_info['summary'];
    $product_details = '<h3>'.$h3.'</h3>'.$image_tag.$summary;
    return $product_details;
}

load_sizes

Recall that a product can have many sizes and these sizes are located within a datasheet named products. We will make the load_sizes method flexible so that we can either load all of the sizes of a particular product type or we can load the information for a specific size.

function load_sizes($product_code=false) {
	if ($this->product_info['has_sizes'] !== 'y') {
		return false;
	}
	$products = new \system\load_simple_table('products'); // Load the products datasheet
	if ($product_code) {
		$products->select_single('index',$product_code,'=='); // Select specific product
	}
	else {
		$products->select_multi('type',$this->item_type,'=='); // Select rows equal to type
	}
	$this->sizes = $products->get_keyed_records();
	if (empty($this->sizes)) {
		return false;
	}
	return true;
}

Here we show the contents of the sizes array after loading the container-tarp sizes.

array ( 0 => array ('index' => 'container14', 'type' => 'container-cover', 'size' => '12\' x 14\'', 'price' => 204, 'weight' => 11), 
       1 => array ( 'index' => 'container16', 'type' => 'container-cover', 'size' => '12\' x 16\'', 'price' => 211, 'weight' => 12.3), 
       2 => array ( 'index' => 'container18', 'type' => 'container-cover', 'size' => '12\' x 18\'', 'price' => 219, 'weight' => 13.8),
       3 => array ( 'index' => 'container20', 'type' => 'container-cover', 'size' => '12\' x 20\'', 'price' => 224, 'weight' => 15.2),
       4 => array ( 'index' => 'container22', 'type' => 'container-cover', 'size' => '12\' x 22\'', 'price' => 229, 'weight' => 16.6),
       5 => array ( 'index' => 'container24', 'type' => 'container-cover', 'size' => '12\' x 24\'', 'price' => 234, 'weight' => 18), 
       6 => array ( 'index' => 'container26', 'type' => 'container-cover', 'size' => '12\' x 26\'', 'price' => 239, 'weight' => 19.4), 
       7 => array ( 'index' => 'container28', 'type' => 'container-cover', 'size' => '12\' x 28\'', 'price' => 244, 'weight' => 20.9))

sizes

The sizes function builds a series of radio buttons to allow the user to select which size they wish to order.

Function sizes() {
    if (!$this->load_sizes()) {
       return '';
    }
    $this->written = 0;
    $weight = $this->product_info['show_weight'] === 'y' ? '<th style="text-align:center"><b>Weight (lb)</b></th>' : '';
    $table = '<table class="order_items keep-columns"><tr><th style="text-align:center"><b>Description</b></th>'.$weight.
		'<th style="text-align:center"><b>Price</b></th>'.'<th style="text-align:center"><b>Select</b></th>'.'</tr>';
    $item_product_code = $this->item->get_product_code();
    foreach ($this->sizes as $keyindex => $values){
        $size = $values['size'];
        $product_code = $values['index'];
        $weight = $this->product_info['show_weight'] === 'y' ? '<td style="text-align:right">'.number_format($values['weight'], 1, '.', ',').'</td>' : '';
        $price = $this->cart->currency($values['price']);
        $selected = ($product_code === $item_product_code) ? ' checked="checked"' : '';
        $radio = '<input type="radio" name="product" value="'.$product_code.'"'.$selected.' onclick="document.inputForm.product_code.value = this.value;"/>';
        $table .= '<tr><td>'.$size.'</td>'.$weight.'<td style="text-align:right">'.$price.'</td>'.'<td style="text-align:center">'.$radio.'</td>'.'</tr>';
    }
    return $table.'</table>';
}

fill_form_placeholders

This function simply passes the output of the product_intro and sizes methods to the form's set_placeholder method to transfer this content onto the form.

function fill_form_placeholders() {
    $this->form->set_placeholder('product_intro',$this->product_intro());
    $this->form->set_placeholder('sizes',$this->sizes());
}

before_copy_item_to_form

This is a simple function that is called before the object item information is copied to the form. This just populates the placeholders.

function before_copy_item_to_form() {
    $this->fill_form_placeholders();
}

before_validate

Notice that the code to generate the list of radio buttons (used to select the desired size) contains the script:

onclick="document.inputForm.product_code.value = this.value;

This code populates the hidden product_code field. We need to check whether the user clicked on one of the radio buttons before proceeding to validate and build the order item.

function before_validate() {
  	// If the form contains the hidden product_code field, make sure it is set
    if (isset($this->field_list['product_code']) and empty($this->field_list['product_code']->value())) {
        $this->field_list['product_code']->assign_message_text('danger','Please choose one of the product options below',__LINE__.' '.__CLASS__);
        return false;
    }
    $this->fill_form_placeholders();
}

before_build_item

Recall that the form_handler calls the cart item's build_item method just before the item is added to the cart. Before calling this method we want to make sure the cart item contains all of the settings and values that were selected on the form. Most of the item's properties are set automatically by the base method copy_form_to_cart_item. We use the before_build_item method to copy information defined within our datasheets to the cart item (product) object.

function before_build_item() {
	$this->load_sizes($this->item->get_product_code());
	$this->item->set_description($this->product_info['cart_item']);
	$this->item->set_price($this->sizes['price']);
	$this->item->set_weight($this->sizes['weight']);
}  

Summary

In this help we have shown how to build a generic shopping cart which can support any number of products. When adding new products, the form handler would not have to change however you would need to define any new product types in the product_type datasheet and new sizes would need to be defined in the product datasheet. If the new product involves a new form or an new item class you would also need to create these components and add a new row to the product_implementation php_array_data defjnition.