|
||||||||||||||||
|
|||||||||||
首页 > Pcb equipment > How we implemented a Classified Ads system in Ubercart 1/Drupal 5
We wrote code for Drupal 5/Ubercart 1.x to implement Classified Ads that could be purchased online and are then copied by Customer Service Reps for print publication. The parts that deal with print aspects could, of course, be skipped for an online-only system.
The basic elements:
The overall "philosophy" of our Classifieds installation is to get folks to enter more orderly, clearer information into their ads by fielding the "ad text" into many input fields. This is why we don't just provide an "ad text" field and leave it at that. We use 'form_alter' hooks to add fields specific to the ad subcategory (classification), then use jQuery to pop those values into a non-editable "ad text" field which is a product attribute, in a formatted way, which is then validated for length and other criteria. This is the "built" ad. Customers can edit the ad later, which means we need to re-validate it at that stage as well, making sure the customer hasn't changed the price of the item being sold, for instance, which would affect which ad packages are valid for the ad. We don't try to re-field the text upon editing (re-creating the fielded form they used at the beginning) since that would be too unwieldy.
Many of the functions deal with the 'intersection' between ad text and classification and item price that decides how much the ad will cost the customer. It was critical that we force the customer to choose a classification first, then an ad package that is available for that classification.
Another tricky bit was dealing with the repercussions of multiple taxonomies for each product node. Largely, the stumbling block was Drupal's desire to show ALL terms for a node in the breadcrumb, when in a classified ads system you only want to see the one you selected to get to the 'build your ad' page (the product node). This even required a minor hack to Ubercart core. Any hacks are listed at the end of this posting; we placed the information in blocks that show on the right column when admin or web developer role is viewing any admin page. In that way it's documented so we can deal with it if we upgrade.
We'll start with the core of it, our custom module...
Contains the following functions:
First a couple of setup items (attribute and pricing constants):
$path = drupal_get_path('module', 'our_classifieds');
_our_classifieds_constants(); // load up some static values
$request = referer_uri(); // looks like "http://blah/blah/catalog/122/automobiles" for form input page
$bpath = explode('/',$request); // 122 = automobiles, not the parent of automobiles
Then, we start on the first form we're interested in:
if ( preg_match("/uc\_product\_add\_to\_cart\_form\_[0-9]+/",$form_id)) {
$subcat = array_pop($bpath);
$subcatid = array_pop($bpath);
$nid = arg(1);
if (is_numeric($nid)) {
$node=node_load($nid);
$package = $node->model;
}
else {
$package = arg(1);
}
if(strpos($package,'ad_package_') ===false) {
$_SESSION['breadcrumb'] = '';
$_SESSION['subcategory'] = '';
$_SESSION['subcatid'] = '';
return;
}
... $form['sku'] = array('#type' => 'hidden',
'#title' => t('SKU'),
'#default_value' => $package,
'#value' => $package,
'#size' => 25,
'#weight' => -10,
);
// following the "return" in our first bullet point...
else {
if($subcatid > 0) {
$_SESSION['subcatid'] = $subcatid;
}
else {
$subcatid = $_SESSION['subcatid'];
}
}
$termobj=taxonomy_get_term($subcatid);
$termobj ? $nicesubcat = $termobj->name : $nicesubcat = $subcat;
// quick and dirty way to be sure subcategory is real:
$checksubcat = _our_classifieds_getLayout($nicesubcat);
if(strpos($checksubcat, "Invalid") !== FALSE) { // it is INVALID
if (isset($_SESSION['subcategory']) && $_SESSION['subcategory'] != '') { // fix with session var
$subcategory = $_SESSION['subcategory'];
}
$_SESSION['layout'] = $checksubcat;
}
else {
$_SESSION['subcategory'] = $nicesubcat;
}
// set breadcrumbs by taxonomy
$parents = array_reverse(taxonomy_get_parents_all($subcatid)); // an object
foreach($parents as $parent) {
$links[] = l($parent->name, "catalog/".$parent->tid."/".$parent->name);
}
drupal_set_breadcrumb($links); drupal_add_css("$path/our_classifieds.css");
drupal_add_css("$path/datePicker.css");
drupal_add_js("$path/date.js",'module','header');
drupal_add_js("$path/jquery.datePicker-2.1.2.js",'module','header');
drupal_add_js("$path/makePicker.js",'module','header',FALSE,FALSE);
drupal_add_js("$path/our_classifieds.js,'module','header'); // more about this below Date plugin source: http://jqueryjs.googlecode.com/svn/trunk/plugins/methods/date.js
DatePicker source: http://www.kelvinluck.com/assets/jquery/datePicker/v2/demo/
We use these Date scripts because the newspaper edition requires lead time between ad submittal and publication. The customer gets a calendar "widget" that only allows valid days to be chosen for when an ad starts.
$form['attributes'][ADTXT]['#type']='textarea';
$form['attributes'][ADTXT]['#description']='This is the text of your ad -- you will be able to edit it later';
$form['attributes'][ADTXT]['#rows']=6;
$form['attributes'][ADTXT]['#cols']=15;
$form['attributes'][ADTXT]['#validate'] = array('_our_classifieds_validate_adtxt' => array($package,false));
// certain packages only
$form['attributes'][NDAYS]['#size']='2';
$form['attributes'][NDAYS]['#weight']=-10;
$form['attributes'][NDAYS]['#validate'] = array('_our_classifieds_validate_addays' => _our_classifieds_valDays($pkgset));
// ad start date
$form['attributes'][SDATE]['#attributes'] = array('class'=>'date-pick');
$form['attributes'][SDATE]['#size']='10';
$form['submit']['#weight'] = 10;
// subcategory
$form['attributes'][SUBCT]['#value'] = $nicesubcat;
$form['attributes'][SUBCT]['#type'] = 'hidden';
$form['attributes'][SUBCT]['#size'] = '15'; $layout = _our_classifieds_getLayout($nicesubcat);
switch($layout) {
case 'Invalid':
$form['submit']= '';
$form['subcat_error'] = array(
'#value'=>'<p class="subcat-error"><strong>ERROR: Missing classification - </strong>This ad package cannot be ordered without first selecting a category and classification</p>',
);
$form['attributes'][NDAYS]['#type']='hidden';
$form['attributes'][SUBCT]['#type']='hidden';
$form['attributes'][SDATE]['#type']='hidden';
$form['attributes'][ADTXT]['#type']='hidden';
break;
case 'Layout1':
drupal_add_js("$path/our_classifieds.js",'module','header');
drupal_add_js("$path/our_c_transport.js",'module','header',FALSE,FALSE);
_our_classifieds_transportation_form($form);
break;
...(etc: 5 possible layouts) The next form we're interested in:
We need to change the display settings of some attribute fields:
elseif ($form_id == 'uc_orderupdate_update_cart_form') {
drupal_add_css("$path/our_classifieds.css");
// add the datepick class to the date fields
foreach($form as $key=>$value) {
if(strstr($key,'item-')) {
$package = $form[$key]['model']['#value'];
$form[$key]['attributes'][SDATE]['#attributes'] = array('class'=>'date-pick');
$form[$key]['attributes'][SUBCT]['#type']='hidden';
$form[$key]['attributes'][NDAYS]['#size']=3;
$form[$key]['attributes'][NDAYS]['#validate'] = array('_our_classifieds_validate_addays' => array($package));
$form[$key]['attributes'][ADTXT]['#type']='textarea';
$form[$key]['attributes'][ADTXT]['#attributes'] = array('class'=>'ad-display');
$form[$key]['attributes'][ADTXT]['#cols']=15;
$form[$key]['attributes'][ADTXT]['#rows']=8;
$form[$key]['attributes'][ADTXT]['#resizable'] = false;
$form[$key]['attributes'][ADTXT]['#validate'] = array('_our_classifieds_validate_adtxt' => array($package,true));
$form[$key]['showcat']=array(
'#value'=> "<div class=\"ad-show-subcat\"><strong>Ad classification: </strong>".$form[$key]['attributes'][SUBCT]['#default_value']."</div>",
'#weight'=> -9,
);
}
}
$_SESSION['subcategory'] = '';
}
Called by the validation attribute of the 'asking price' field, this checks the customer entered a price that is within the limits to be eligible for a particular ad package:
$thevalue = preg_replace('/[^0-9\.]/','',$formelement['#value']);
if (is_numeric($thevalue)) {
$thevalue = $thevalue + 0;
} else {
form_error($formelement, t('Price entered must be a number.'));
}
if (isset($min) && ($thevalue < $min)) {
form_error($formelement, t("Price entered must be at least ")."\$$min.");
}
if (isset($max) && ($thevalue > $max)) {
form_error($formelement, t("Price entered must be no higher than ")."\$$max.");
}
if(strpos($product->model,'ad_package_') !== false)foreach($items as $product) {
if(strpos($product->model,'ad_package_') !== false) {
$panes[] = array(
'id' => 'ad_edit_instrux',
'title' => t('Classified Ads Edit Msg'),
'enabled' => TRUE,
'weight' => -9,
'body' => '<div id="how-to-edit" style="width: 98%; border: 2px solid #c00; color: #f00; padding: 0.35em; margin:0; font-weight: bold; font-size: 120%; text-align: center">To edit the text of a Classified Ad, click in the product description below.</div>',
);
}... Simply an array of classifications (subcategories) with keys indicating which layout to use:
$layouts = array(
"Layout1" => array(
"Antique / Classic / Custom",
"Automobiles",
... ),
"Layout2" => array(
"Airplanes",
"All-Terrain Vehicles", ... ),
Checks if the $subcatname is in the $value for each $key. If so, returns the $key
-- Should probably be a hierarchical taxonomy setup instead, if this were written as a "contributed module".
Getting values from the main 'our_classifieds_price_calc' function...
Getting values from the main 'our_classifieds_price_calc' function...
$findit = array(
//has to be backward order. We had ranges of days: 1-2, 3,4,5,6-7...
//$lines => ($days=>$ppl, $days=>$ppl, $days=>ppl (price per line))
...
5 => array(3 => 0.00, 2 => 0.00, 1 => 0.00),
4 => array(3 => 0.00, 2 => 0.00, 1 => 0.00),
3 => array(3 => 0.00, 2 => 0.00, 1 => 0.00),
1 => array(3 => 0.00, 2 => 0.00, 1 => 0.00),
);
foreach ($findit as $key=>$value) {
if ($lines >= $key) {
$getit = $value;
break; //Stops as soon as the $lines is found
}
}
foreach ($getit as $key=>$value) {
if ($days >= $key) {
$per = $value;
break;
}
}
if ($per > 0) {
return $lines * $days * $per;
}
else return "error: line rate not found";
This is called by custom_price on the 'package' nodes. That is, uc_custom_price module adds a field for price calculation. See Product Nodes below
$subcat = $item->data['attributes'][SUBCT];//['#value']
$days = $item->data['attributes'][NDAYS];
$adtext = $item->data['attributes'][ADTXT];
$package = $item->model;
$pkg_settings = _our_classifieds_getAdRate($package);
$adlines = _our_classifieds_calcNumLines($package,$adtext);
case "ad_package_A":
case "ad_package_C":
$quicktotal = _our_classifieds_ByDaysCalc($adlines,$days,$package);
$extracharge = 0;
$linesover = 0;
break;
default:
$days = $pkg_settings['days'];
$linesover = $adlines - $pkg_settings['lines'];
if ($linesover > 0 && $pkg_settings['extra'] != 'error'){
$extracharge = round(($pkg_settings['extra']*$linesover),2);
$quicktotal = $pkg_settings['cost'] + $extracharge;
}
else {
$extracharge = 0;
$quicktotal = $pkg_settings['cost'];
}
}
Array that is searched to find the right ad cost factors for the package selected:
$rates = array(
'ad_package_A'=>array(
'low'=>0,
'high'=>999999999,
'days'=>1,
'maxdays'=>35,
'lines'=>3,
'maxlines'=>35,
'cost'=>PKGACOST,
'extra'=>PKGAEXTRA,
),
'ad_package_B'=>array(
'low'=>101,
'high'=>500,
'days'=>7,
'maxdays'=>7,
'lines'=>3,
'maxlines'=>35,
'cost'=>PKGBCOST,
'extra'=>PKGBEXTRA,
)...
function _our_classifieds_transportation_form(&$form) {
$form['header'] = array(
'#value' => "<h3 class=\"ad-cat\">Please enter your <span>$subcat</span> Transportation ad below</h3>",
'#weight' => -10,
);
# Make: Model: Year:
$form['makemodelyear'] = array(
'#type' => 'fieldset',
'#attributes' => array('class'=>'compact'),
'#title' => t('Make / Model / Year'),
'#collapsible' => FALSE,
'#collapsed' => FALSE,
'#weight' => -9,
);
$form['makemodelyear']['make'] = array(
'#type' => 'textfield',
'#title' => t('Make'),
'#required' => TRUE,
'#size' => '15',
);
... (lots more fields)
# now pull in some standard items (used in most layouts)
_our_classifieds_form_standardfields(&$form); // see below
return $form;
}
Just a bit of the standardfields function to show how validations can be set up:
switch ($form['sku']['#value']) {
case 'ad_package_B' : // $101-500
$validate = array(101, 500); break;
case 'ad_package_C' : // $501-2999
$validate = array(501, 2999); break;
...
}
$form['standardfields'] = array(
'#type' => 'fieldset',
'#attributes' => array('class'=>'compact'),
'#title' => '',
'#collapsible' => TRUE,
'#collapsed' => FALSE,
'#weight' => -3,
);
$form['standardfields']['pricing']['asking_price'] = array(
'#type' => 'textfield',
...
'#validate' => array('validate_price' => $validate),
// 'validate_price' is the function to call
);
...
$form['standardfields']['email_for_ad'] = array(
'#type' => 'textfield',
'#title' => t('E-mail address to appear in ad (optional)'),
'#required' => FALSE,
'#size' => '30',
'#weight' => 4,
);
return $form;
* See the forms API for further details: http://api.drupal.org/api/file/developer/topics/forms_api_reference.html...
This one appears at top of module for easy updating
Sets up ad rating (pricing) values and takes care of some attribute positional discrepancies between development install and live install:
DEFINE ("PKGBCOST",0.00);
DEFINE ("PKGBEXTRA",0.00);
...
DEFINE ("DOMAIN", $_SERVER['SERVER_NAME']);
if (DOMAIN=='xyz') {
// SPECIFIES POSITION IN ATTRIBUTES TABLE: for us, was different on LIVE server than DEV
// adtxt = ad text
// ndays = number of days (certain packages only)
// subct = subcategory
// sdate = ad start date
DEFINE ('ADTXT', 4);
DEFINE ('NDAYS', 6);
DEFINE ('SUBCT', 7);
DEFINE ('SDATE', 5);
}
else {
DEFINE ('ADTXT', 3);
DEFINE ('NDAYS', 4);
DEFINE ('SUBCT', 5);
DEFINE ('SDATE', 6);
}
Our system has just 9 product nodes. They are named in various ways: some ads are free for x lines/x days; some are of a specific type (Real Estate, for instance); and some are named to indicate the price-range of the item being sold and how many lines/days for the base price.
Each has a simple body such as:
For a special package:
* x lines, x consecutive days, $x.xx for each additional line
* Where the ad appears (web, print, special pages)
...OR...
For a "custom" ad:
<em>The cost depends on</em>
* length of ad
* number of days the ad runs (must be consecutive)
* Where the ad appears
Ubercart adds SKU (fielded as 'model') and pricing fields. We don't use the pricing for Ads (because the length of text a customer enters, and how many days, etc., decides the price), but we enter the last one, 'sell price' setting it to a base value, because it's required.
The uc_custom_price module gives us another field for the Product Nodes: Custom Code. In that field we use:
if(function_exists('our_classifieds_price_calc')) {
$item->price = our_classifieds_price_calc($item);
}
else $item->price = $item->price;
(If you don't check for the function's existence first, you get WSOD (white screen o'death) upon turning off our_classifieds module for any reason.)
This of course calls our price calculation function. Since we're not going to make any changes to the 'item' array, we just pass it by value. It's possible that this may change to create an Ubercart Line Item so that we might indicate how the customer's ad was rated for the Cart page.
The only other things in the Product Node are a Product Image (generic-looking "great ad package!" icons) and Taxonomy. Note that in selecting taxonomy terms for a 'product,' we do NOT select the parent of the terms, since we don't want products showing up for purchase before the customer has drilled all the way down to the Classification level. E.g.: The Node is categorized as
# Antiques & Collectibles
# Appliances
# Baby Items
# Bicycles
... but NOT under their parent 'Articles for Sale.'
See below, for more about Taxonomy.
We use them to store the ad text, the date to begin running the ad, the number of days to run (for some Products, not others), and the classification (subcategory).
IMPORTANT:
It's been said many times at the Ubercart site, but Create your Attributes before you create your Product Nodes.
Handy 'shortcut' for adding attributes
To add attributes to several products: Start on your Store Admin->View Products page. Go to a Product, then paste /edit/attributes/add to the end of the URL to get straight to the important screen (saves 3 clicks and 3 page loads). The /edit/attributes alone shows which ones have been assigned to that product, and allows you to delete or reorder them. Then, use your browser's history (or back-button dropdown) to go straight back to the View Products page. Lather, rinse, repeat. Doesn't help much if you have 40 or 50 products all needing new attributes, though.
Some functions (such as validating a phone number) were common to all, so put in a 'main' .js file. Others were specific to getting the fielded-form values into the ad text (attribute) for a particular classification. For those we built one function ('updateValue'), but different versions lived in different files, the appropriate file being added via drupal_add_js for the subcategory in effect. The "edit-whatever" fields that exist when a customer is entering their ad get the 'whatever' values from our form_alter. For example, in a file called classifieds_transport.js (the below is the full file):
function updateValue() {
var field_values = [];
// this puts all three of these fields together and makes them uppercase.
$('#edit-make, #edit-model, #edit-year').each(function(e) {
if($(this).val()) field_values.push($(this).val().toUpperCase());
});
// these fields are joined by commas
$('#edit-transmission, #edit-cylinder').each(function(e) {
if($(this).val()) field_values.push($(this).val()+',');
});
if($('#edit-mileage').val()) field_values.push($('#edit-mileage').val()+" miles,");
if($('#edit-color').val()) field_values.push($('#edit-color').val()+',');
$('#edit-loaded, #edit-antilock-brakes, #edit-power-steering, #edit-air-conditioning, #edit-cd, #edit-mp3, #edit-am-fm, #edit-stereo, #edit-gps, #edit-dvd-player, #edit-rebuilt-engine, #edit-low-miles, #edit-new-tires, #edit-runs-well, #edit-leather-interior, #edit-sunroof, #edit-moon-roof, #edit-very-clean, #edit-warranty').each(function(e) {
if ($(this).is(':checked')) field_values.push($(this).val()+',');
});
// this one is a 'free-flowing' field for descriptions -- NOT the product attribute called "AdText" that will be saved.
if($('#edit-adtext').val()) field_values.push($('#edit-adtext').val());
if($('#edit-asking-price').val()) field_values.push(formatPrice($('#edit-asking-price').val()));
if($('#edit-or-best-offer') && $('#edit-or-best-offer').is(':checked')) field_values.push($('#edit-or-best-offer').val());
if($('#edit-phone-for-ad').val()) field_values.push(formatPhone($('#edit-phone-for-ad').val()));
if($('#edit-phone-extension').val()) field_values.push("ext. "+$('#edit-phone-extension').val());
if($('#edit-email-for-ad').val()) field_values.push($('#edit-email-for-ad').val());
// fAdText is a variable set up in the shared js file that depends on
// whether we are on development or live server. Only necessary if there were
// attribute position discrepencies between two installations. It's value is
// something like "#edit-attributes-4"
// Note the default join is a space character. This is why fields that should be
// joined by commas were dealt with above by pushing some bunches into a smaller subset.
$(fAdText).val(field_values.join(' '));
}
And of course that function is triggered with functions in the main, shared jQuery file: our_classifieds.js
$(document).ready(function () {
setHandlers();
updateValue();
// make sure the customer doesn't type directly in the attribute field that holds the ad text
$(fAdText).focus(function() {
$('.node-add-to-cart:submit').focus();
});
$("#cart-form-products .cart-options").css( 'background-color','#ff9999');
});
function setHandlers(e) {
$(".add_to_cart :checkbox").click(updateValue);
$(".add_to_cart select").change(updateValue);
$(".add_to_cart input:not(textarea"+fAdText+")").blur(updateValue);
}
(I've left out a couple of validators for phone numbers and such)
As a small matter... a bit of custom css to deal with the field layouts... especially checkboxes in fieldsets, to keep the user input form to a manageable size and easy to navigate:
/* css for Our Classifieds forms */
.model, .display_price, .sell_price { display: none;}
form .description {clear: both;}
textarea {width: 350px;}
textarea#edit-attributes-3,textarea#edit-attributes-4,textarea.ad-display, .order .ad-text { font-family: Courier, 'Courier New', monospace; width: 16em;}
h3.ad-cat { font-style: italic; text-decoration: underline;}
h3.ad-cat span { color: #c00;}
fieldset.compact .form-item, fieldset.subset .form-item {
display: inline; float: left;
padding: 0 2em 4px 0;
margin: 0;
line-height: 120%;
}
fieldset.compact .form-item label, fieldset.subset .form-item label { display: inline; white-space: nowrap; }
fieldset.subset {background: transparent none; margin: 0; border: none; padding: 0;}
textarea.ad-text { border: 1px solid #fff; background:white; font-size: 12pt; width: 15em; height: 6em; line-height: 120%;}
#how-to-edit { display: block; width: 100%; border: 1px solid #ccc; color: #fff; padding: .25em; margin:0; font-weight: bold;}
Since hacks are a bad idea, we also needed a way to manage them. We created blocks with the following information and set them to appear only on admin pages in the right column (and only for the roles that would need to see them: webmaster, say).
$form['item-'. $key]['price'] = array('#type' => 'markup', '#value' => "<div class='item-price'><strong>Cost before edits:</strong> $".$item->price."</div>", '#weight' => -8);
$form['item-'. $key]['model'] = array('#type' => 'hidden', '#value' => $item->model, '#weight' => -9);
It comes in uc_orderupdate_update_cart_form right after 'qty' form field is set.
&& (isset($node->model) && strpos($node->model,'ad_package_')===false)' to the end of the 'if $a4 == true' condition. This stops the catalog module from overriding the special breadcrumb set in our_classifieds.module's hook_form function.if($attribute=='Classified Ad Text'){
$option_rows[] = t('@attribute to paste: <textarea class="order ad-text">@option</textarea>', array('@attribute' =>$attribute, '@option' => $option));
}
So since we've already hacked it there, why not show the number of lines for the ad:
if (is_array($options)) {
foreach ($options as $attribute => $option) {
$option_rows[] = t('@attribute: @option', array('@attribute' => $attribute, '@option' => $option));
// new bit here:
if($attribute=='Classified Ad Text') {
$pkgset = _our_classifieds_getAdRate($product->model);
$numlines = _our_classifieds_calcNumLines($product->model,$option);
$option_rows[] = t('Number of lines: @option', array('@option' => $numlines));
}
// end new bit
}
}
I don't know how useful all this will be to others interested in classified advertising via Ubercart, but I will be happy to answer questions as best I can. I hope some of this will give others a head start on their own systems.
Unfortunately, I have no idea what sorts of changes would be needed to make this work in Drupal 6/Ubercart2. Don't have the time to think about it. Have done some Drupal 6 installs, but little module work as yet, so not sure what all the differences are.
So here it is in a nutshell:
name = Our Classifieds
description = "Classified Ad Order Entry customizations for Ubercart"
dependencies = uc_store uc_product uc_cart taxonomy uc_custom_price
core = 5.x
php = 5.1 function our_classifieds_install() {
db_query("UPDATE {system} SET weight=%d WHERE name='%s'",5,"our_classifieds");
}
|