/*
 ShopIgniter core JavaScript

*/
(function($, window, undefined) {
	
	var document = window.document;
	
/*
 * ShopIgniter core javascript
 */
var SI = {
	// components are initialized in the order they are included in code; to perform
	// actions after all components have initialized, they can implement a 'postInit' function
	init: function(data) {
		// extend default data with request-specific data
		$.extend(this.data, data);
		
		// initialize all components
		SI.object.applyEvery(SI, '_init');
		
		// call postInit on all components
		SI.object.applyEvery(SI, '_postInit');
		
		// call ready on each component once the dom-ready event has fired to ensure elements are available to bind events to
		$(function() {
			SI.object.applyEvery(SI, '_ready', [ window.document ]);
		});
		
		// every time something is added to the dom, make sure UI elements get re-attached
		$(window.document).bind('render.si', function(event, start, end) {
			var $context = $(event.target).children();
			
			if (end !== undefined) {
				// firefox 3's implementation of Array.slice cannot be called with two undefined args
				$context = $context.slice(start, end);
			} else if (start !== undefined) {
				$context = $context.slice(start);
			}
			
			SI.object.applyEvery(SI, '_ready', [ $context ]);
		});
	},
	
	// TODO: determine if this is the best file / place in the namespace for this function
	fetch: function(type, params, qualifiers, callback) {
		var controller = SI.string.pluralize(type),
			action = controller === type ? 'index' : 'show',
			url = '/' + SI.data.routes.store + '/';
		
		// qualifiers are optional
		if ($.isFunction(qualifiers)) {
			callback = qualifiers;
		} else {
			params._qualifiers = SI.object.qualifiers(qualifiers);
		}
		
		url += controller + '/' + action;
		
		$.get(url, params, function(response, status, request) {
			if ($.isFunction(callback)) {
				callback.call(SI, response, status, request);
			}
		}, params.structured ? 'json' : 'html');
	},
	
	// set defaults to prevent errors
	data: {
		routes			: {},
		aliases			: {},
		currency_symbol	: '$',
		cart			: {},
		paginate		: {},
		lang            : {}
	}
};

/*
 * AJAX related utility functions
 *
 * Depends:
 */
SI.ajax = {
	_init: function() {
		// add ajax handler to forms with the data-async attribute
		$(document).delegate('a.si-link:data(async)', 'click.si', this._handlers.asyncLink);
	},
	
	_handlers: {
		asyncLink: function(event) {
			var $link = $(this);
			
			$link.addClass('si-loading');
			
			SI.ajax.get($link.attr('href'), {}, true)
				.complete(function(results, message) {
					var event = $.Event();
					
					event.type = this.isResolved() ? 'success' : 'error';
					
					// trigger event based on success of ajax post
					$link.trigger(event, [ results, message ]);
					
					// perform default action if the event wasn't 'canceled'
					// NOTE: there is currently no default action for an error
					if (!event.isDefaultPrevented() && this.isResolved()) {
						SI.ajax._handlers.defaultAction(results, message);
					}
					
					$link.removeClass('si-loading');
				});
	
			event.preventDefault();
		},
		
		defaultAction: function(results, message) {
			// if there is a url given, redirect to it immediately
			if (results.return_url) {
				window.location.href = results.return_url;
				return;
			}
					
			// display the success message if there is one
			if (message) {
				SI.message.set(message);
			}
		}
	},
	
	post: function(settings, url, data, preventDefault) {
		return SI.ajax.request('POST', settings, url, data, preventDefault);
	},

	get: function(settings, url, data, preventDefault) {
		return SI.ajax.request('GET', settings, url, data, preventDefault);
	},

	// This function should only ever be used to request relative urls
	request: function(type, settings, url, data, preventDefault) {
		// the ajax settings object is optional
		if (!$.isPlainObject(settings)) {
			preventDefault = data;
			data = url;
			url = settings;
			settings = {};
		}
		
		// data is optional as well
		if (!$.isPlainObject(data)) {
			preventDefault = data;
			data = null;
		}
		
		// set the request method
		settings.type = type;
		
		// if there's data, add it to the ajax settings object
		if (data) {
			settings.data = data;
		}
		
		// the url is always relative, so if it doesn't start with a '/', assume
		// it is a store route
		if (url.charAt(0) !== '/') {
			url = '/' + SI.data.routes.store + '/' + url;
		}
		
		return SI.ajax.send(url, settings, preventDefault);
	},
	
	send: function(url, settings, preventDefault) {
		// create a wrapper deferred object that determines its state
		// based on the 'success' attribute of the JSON response
		var deferred = $.Deferred(),
			promise = {
				success : deferred.done,
				error   : deferred.fail,
				complete: deferred.always
			};
		
		// set the context so that resolve/reject can be called directly
		settings.context = deferred;
		
		$.ajax(url, settings)
			.done(function(response) {
				// the response will be an object if the headers indicated it's JSON
				if ($.isPlainObject(response)) {
					// for now, 'default' behavior is to redirect if possible, and set the message
					if (!preventDefault) {
						// NOTE: if a return url is set, this will redirect to it without resolving the deferred
						SI.ajax._handlers.defaultAction(response.results, response.message);
					}
					
					// reject the deferred if the response success is false
					deferred[response.success ? 'resolve' : 'reject'](response.results, response.message);
				} else {
					// if it wasn't a JSON request, just resolve
					deferred.resolve(response);
				}
			})
			// request failed completely
			.fail(function() {
				var message = 'request failed (' + url + ')'; 
				
				console.info(message);
				
				// TODO: handle this globally and don't bother rejecting the deferred at all?
				deferred.reject(false, message);
			});
		
		return deferred.promise(promise);
	}
};
	
/*
 * ShopIgniter browsing tools
 *
 * Depends:
 *  SI.stateManager
 */
SI.browsingTools = {
	params: [],
	
	components: [],
	
	_postInit: function() {
		// register all subcomponent parameters with the state manager
		SI.stateManager.subscribe(this.update, this.params);
	},
	
	subscribe: function(component) {
		// add to internal list
		SI.browsingTools.components.push(component);
		
		// merge the component's params with the global list of params
		$.merge(SI.browsingTools.params, component.getParams());
	},
	
	// pass along param changes to all sub components
	update: function(params) {
		var realParams = {};
		
		// call update on each subcomponent with only its own parameters
		$.each(SI.browsingTools.components, function(index, component) {
			var subParams = {};
			
			$.each(component.getParams(), function(i, paramName) {
				if (params[paramName] !== undefined) {
					subParams[paramName] = params[paramName];
				}
			});
			
			$.extend(realParams, component.update(subParams));
		});
		
		SI.browsingTools.loadPartials(realParams);
	},
	
	loadPartials: function(params) {
		var $partialTarget = SI.browsingTools.getPartialTarget(),
			partialType = $partialTarget.data('type'),
			qualifiers = params.qualifiers,
			opts = {
				query			: SI.data.query,
				with_template	: false,
				structured		: true
			};
		
		if (!partialType) {
			console.info('missing partial target class');
			return;
		}
		
		delete params.qualifiers;
		
		if (partialType === 'product' && SI.data.id) {
			// add the product context to the qualifier string and set as query param
			qualifiers[SI.data.type + '.id'] = SI.data.id;
		}
		
		params.context = SI.data.type;
		params.context_id = SI.data.id;
		
		// add loading class before firing the ajax call off
		$('.SI-loading-target').addClass('si-loading');
		
		SI.fetch(SI.string.pluralize(partialType), $.extend({}, opts, params), qualifiers, function(data) {
			var index;
			
			$('.SI-loading-target').removeClass('si-loading');
			
			// call trigger on each item if appending, on wrapper if replacing
			if (params.append) {
				index = $partialTarget.children().length;
				$partialTarget
					.append(data.results.output)
					.trigger('render', [ index ]);
			} else {
				$partialTarget
					.html(data.results.output)
					.trigger('render');
			}
			
			if (data.results) {
				SI.object.applyEvery(SI.browsingTools.components, 'postUpdate', [ data.results ]);
			}
		});
	},
	
	getPartialTarget: function(context) {
		var $target;
		
		context = context || window.document;
		
		$.each([ '', 'product', 'review' ], function(i, type) {
			$target = $('.SI-' + (type ? type + '-' : '') + 'partial-target', context);
			
			$target.data('type', type || 'product');
			return !$target.length;
		});
		
		return $target;
	}
};

/*
 * ShopIgniter cart
 *
 * Depends:
 *  SI.message
 */
SI.cart = {
	_init: function() {
		$(document)
			.delegate('form.si-social-buy-contribution-form', 'submit.si', this._handlers.addContribution)
			.delegate('.si-cart-item input.si-quantity-input', 'change.si', this._handlers.updateQuantity)
			.delegate('.si-cart-item a.si-remove-link', 'click.si', this._handlers.removeItem)
			.delegate('.si-cart-items .si-applied-coupon a.si-remove-link', 'click.si', this._handlers.removeCoupon);
	},
	
	_handlers: {
		removeItem: function() {
			var $item = $(this).parents('.si-cart-item'),
				itemId = $item.data('itemId');
			
			SI.cart.removeItem(itemId);
			
			return false;
		},
		
		removeCoupon: function() {
			var $item = $(this).parents('.si-applied-coupon'),
				couponCode = $item.data('couponCode');
			
			SI.cart.removeCoupon(couponCode);
			
			return false;
		},
		
		updateQuantity: function() {
			var $item = $(this).parents('.si-cart-item'),
				productId = $item.data('productId'),
				productOptionId = $item.data('productOptionId'),
				newQuantity = +$(this).val(),
				oldQuantity = $item.data('quantity');
			
			if (isNaN(newQuantity) || newQuantity - oldQuantity === 0) {
				$(this).val(oldQuantity);
				return false;
			}
			
			SI.cart.addProduct(productId, productOptionId, newQuantity - oldQuantity);
			
			return false;
		},
		
		addContribution: function() {
			var $form = $(this).closest('form'),
				$socialBuy = $(this).closest('.si-social-buy'),
				socialBuyKey = $socialBuy.data('key'),
				$amount = $(':input[name="amount"]', $form),
				amount = +$amount.val();
			
			if (!amount) {
				$amount.parent().contextMessage({
					message		: SI.data.lang.contribution_amount_error || 'error',
					typeClass	: 'si-cart-error',
					autoShow	: true
				});
				
				return false;
			}
			
			SI.cart.addContribution(socialBuyKey, amount)
				.complete(function() {
					// trigger the event based on success of request
					$form.trigger('addtocart', [ this.isResolved() ]);
				});
			
			return false;
		},
		
		addSuccess: function(results, message) {
			if (results.return_url) {
				window.location.href = results.return_url;
				return;
			}
			
			// add checkout link to message
			if (SI.data.lang.checkout_continue_link) {
				message += '<a href="/' + SI.data.routes.cart + '">' + SI.data.lang.checkout_continue_link + '</a>';
			}
			
			SI.message.set(message);
			
			// update the cart if possible
			if (results.cart) {
				SI.cart.update(results.cart);
			}
		},
		
		addFailure: function(results, message) {
			SI.message.set(message);
		}
	},
	
	addProduct: function(productId, productOptionId, quantity) {
		var data;
		
		if ($.isPlainObject(productOptionId)) {
			data = productOptionId;
		} else {
			data = {
				quantity            : quantity,
				product_option_id   : productOptionId
			};
		}
		
		data.quantity = data.quantity || 1;
		
		// make sure the quantity is at least as much as required
		if (data.quantity < SI.data.cart.min_quantity) {
			data.quantity = SI.data.cart.min_quantity;
		}
		
		return SI.ajax.post('cart/add/' + productId, data)
			.success(SI.cart._handlers.addSuccess)
			.error(SI.cart._handlers.addFailure);
	},
	
	addContribution: function(socialBuyKey, amount) {
		return SI.ajax.post('cart/add/' + socialBuyKey, {
			type    : 'contribution',
			amount  : amount
		}, false)
			.success(SI.cart._handlers.addSuccess)
			.error(SI.cart._handlers.addFailure);
	},
	
	removeItem: function(itemId) {
		return SI.ajax.post('cart/remove/' + itemId)
			.success(function(results) {
				// update the cart if possible
				if (results.cart) {
					SI.cart.update(results.cart);
				}
			});
	},
	
	removeCoupon: function(couponCode) {
		return SI.ajax.post('cart/remove_coupon/' + couponCode + '/1')
			.success(function(results) {
				// update the cart if possible
				if (results.cart) {
					SI.cart.update(results.cart);
				}
			});
	},
	
	clear: function() {
		return SI.ajax.post('cart/clear')
			.success(function(results) {
				// update the cart if possible
				if (results.cart) {
					SI.cart.update(results.cart);
				}
			});
	},
	
	update: function(cart) {
		// update any cart counts
		SI.cart.updateCount(cart.product_quantity);
		
		// update total (products with coupon discount)
		SI.cart.updateTotal(cart.total);
		
		// update cart items on the page
		SI.cart.updateItems(cart.items);
		
		// update coupons
		SI.cart.updateCoupons(cart.coupon_objs);
	},
	
	updateCount: function(count) {
		$('.si-cart-count').text(count);
	},
	
	updateTotal: function(total) {
		$('.si-cart-total').html(SI.data.currency_symbol + total);
	},
	
	// updates items in the cart using updated cart information
	updateItems: function(items) {
		$('.si-cart-items').each(function() {
			var container = this,
				$emptyItem = $('.si-no-results', this),
				$totalItem = $('.si-cart-totals', this),
				noOptionsText = $(this).data('noOptionsText'),
				removeText = $(this).data('removeText'),
				itemClass = $(this).data('itemClass'),
				isEmpty = !items.length,
				itemIds = [];
			
			// add/update cart items in the list
			$.each(items, function(i, item) {
				var $item = $('.si-cart-item:data(itemId=' + item.id + ')', container),
					hasQuantity = item.type_key !== 'contribution' && item.product_type_key != 'virtual_gift_card';
					$lastItem = null;
				
				if ($item.length) {
					// update the quantity if neccessary
					var $input = $('input.si-quantity-input', $item),
						itemInfo = '';
					
					if (+item.quantity !== +$item.data('quantity') || +item.quantity !== +$input.val()) {
						$input.val(item.quantity);
						$item.data('quantity', item.quantity);
						
						// update item total on table
						$('.si-item-total-cell span', $item).html(SI.data.currency_symbol + item.sub_total);
					}
					
					// TODO: move message text out of JS
					// update item info cell
					if (item.tax_exempt) {
						itemInfo += '<li class="si-tax-exempt">Tax exempt</li>';
					}
					if (item.coupon_names.length) {
						// update "coupon applies" message
						itemInfo += '<li class="si-coupon"><span class="si-color">' + item.coupon_names.join(', ') + '</span> coupon applies to this item</li>';
					}
					
					$('.si-item-info-cell ul', $item).html(itemInfo);
					
					// only happens when the cart items is a table
					$('.si-item-info-cell', $item)
						.toggle(Boolean(item.coupon_names))
						.siblings('.si-first-cell')
							.attr('colspan', item.coupon_names ? 1 : 2);
					
				} else {
					$item = $('<tr class="' + itemClass + '">' +
						'<td class="si-first-cell">' + (hasQuantity ? '<input class="si-quantity-input" type="text" value="' + item.quantity + '" />' : '') + '</td>' +
						'<td><a href="/' + item.category_url + '/' + item.link + '">' + item.name + '</a><br />' +
						'<small>' + (item.product_option_id ?  item.product_option_name : noOptionsText) + '</small></td>' +
						'<td><span class="si-pricing">' + SI.cart.buildPrice(item) + '</span></td>' +
						'<td class="si-last-cell"><a href="#" class="si-remove-link">' + removeText + '</a></td>' +
						'</tr>');
					
					if ($lastItem) {
						$item.insertAfter($lastItem);
					} else {
						$item.insertBefore($emptyItem);
					}
					
					// add handlers to 
					$('input.si-quantity-input', $item).change(SI.cart._handlers.updateQuantity);
					$('a.si-remove-link', $item).click(SI.cart._handlers.removeItem);
					
					$item.data({
						'itemId'            : +item.id,
						'quantity'          : +item.quantity,
						'productId'         : +item.product_id,
						'productOptionId'   : +item.product_option_id
					});
				}
				
				itemIds.push(+item.id);
				$lastItem = $item;
			});
			
			// remove any items that are in the list but not the cart items
			$('.si-cart-item', this).not('.si-no-results').each(function() {
				if ($.inArray($(this).data('itemId'), itemIds) === -1) {
					$(this).remove();
				}
			});
			
			// show/hide the empty message accordingly
			$totalItem.toggle(!isEmpty);
			$emptyItem.toggle(isEmpty);
		});
	},
	
	updateCoupons: function(coupons) {
		$('.si-cart-items').each(function() {
			var container = this,
				couponCodes = [];
			
			// add/update cart items in the list
			$.each(coupons, function(i, coupon) {
				
				var $coupon = $('.si-applied-coupon:data(couponCode=' + coupon.code + ')', container),
					$lastCoupon = null;
				
				if ($coupon.length) {
					// update the sub_total
					$('.si-item-total-cell', $coupon).html('-' + SI.data.currency_symbol + coupon.sub_total);
					
					// update error message if neccessary
					$('.si-inline-form-error', $coupon)
						.html(coupon.errors)
						.toggle(Boolean(coupon.errors));
						
				} else {
					var name = '<strong>Coupon: </strong>' + '<span class="si-coupon-name">' + coupon.name + '</span>',
						description = '<span class="si-coupon-description">' + coupon.description + '</span>',
						errors = '<span class="si-inline-form-error" ' + (coupon.errors ? '' : 'style="display:none;"') + '>' + coupon.errors + '</span',
						remove = '<a href="#" class="si-remove-link">remove</a>',
						sub_total = SI.data.currency_symbol + coupon.sub_total;
					
					// build the item based on the type
					$coupon = $('<tr class="si-applied-coupon si-attention">' +
									'<td class="si-first-cell" colspan="4">' + name + description + errors + '</td>' +
									'<td><strong class="si-right">Discount: </td>' +
									'<td class="si-item-total-cell">' + sub_total + '</td>' +
									'<td class="si-last-cell">' + remove + '</td>' +
								'</tr>');
					
					if ($lastCoupon) {
						$coupon.insertAfter($lastCoupon);
					} else {
						//$coupon.insertBefore($emptyItem);
					}
					
					$coupon.data({ couponCode : coupon.code });
				}
				
				couponCodes.push(coupon.code);
				$lastCoupon = $coupon;
			});
			
			// remove any items that are in the list but not the cart items
			$('.si-applied-coupon', this).each(function() {
				if ($.inArray($(this).data('couponCode'), couponCodes) === -1) {
					$(this).remove();
				}
			});
		});
	},
	
	// builds markup for displaying retail price, sale price, and price ranges
	buildPrice: function (item) {
		var hasRange = item.min_price && +item.min_price !== +item.max_price,
			retailPrice,
			salePrice,
			str;
		
		if ((item.product_type_key || item.type_key) === 'promotional_physical') {
			retailPrice = 'free';
		} else {
			if (hasRange) {
				if (+item.sale && item.retail_min_price > 0 && item.retail_max_price > 0) {
					str = ['<span class="si-retail-price si-on-sale">', SI.data.currency_symbol, item.retail_min_price,
						' - ',
						SI.data.currency_symbol, item.retail_max_price, '</span> <br />',
						'<span class="si-sale-price">', SI.data.currency_symbol, item.min_price, '</span>',
						' - ',
						'<span class="si-sale-price">', SI.data.currency_symbol, item.max_price, '</span>'].join('');
				} else {
					retailPrice = SI.data.currency_symbol + item.min_price + ' - ' + SI.data.currency_symbol + item.max_price;
					str = '<span class="si-retail-price si-not-on-sale">' + retailPrice + '</span>';
				}
			} else {
				if (+item.sale) {
					retailPrice = SI.data.currency_symbol + item.price;
					salePrice = SI.data.currency_symbol + item.sale_price;
				} else {
					retailPrice = SI.data.currency_symbol + item.price;
				}
				str = '<span class="si-retail-price ' + (+item.sale ? 'si-on-sale' : 'si-not-on-sale') + '">' + retailPrice + '</span>';
				if (+item.sale) {
					str += ' <span class="si-sale-price">' + salePrice + '</span>';
				}
			}
		}
		
		return str;
	}
};

/*
 * ShopIgniter checkout
 *
 * Depends:
 * 
 * @todo move page-specific UI logic to a 'view' file of some kind
 */
SI.checkout = {
	_ready: function(context) {
		if (SI.data.section === 'checkout') {
			// address step, toggling the shipping address fields
			$('input.si-same-as-billing', context).click(this.handlers.toggleShippingAddress);
			
			// payment step submit needs an additional error handler for gateway responses
			$('form.si-checkout-payment-form, form.si-checkout-single-step-form', context).bind('error.si', this.handlers.paymentGatewayFailure);
			
			// payment step apply coupon button
			$('form.si-checkout-payment-form a.si-apply-coupon-button', context).click(this.handlers.applyCoupon);
			
			$('form.si-checkout-payment-form .si-applied-coupon a.si-remove-link', context).click(SI.checkout.handlers.removeCoupon);
			
			// payment methods handler
			$('input.si-checkout-payment', context).change(this.handlers.paymentMethodSelect);
			
			// TODO: seems kinda hacky for the selector to be here and in the function
			if ($('.si-shipping-quote-list', context).length) {
				this.buildShippingQuotes();
			}
		}
	},
	
	handlers: {
		toggleShippingAddress: function() {
			$('#si-shipping-address').toggle();
		},
		
		paymentGatewayFailure: function(event, results) {
			// handle payment gateway errors on payment step
			if (results.gateway_response && !results.gateway_response.success) {
				SI.form.setErrorMessage(results.gateway_response_message);
				return false;
			}
		},
		
		paymentMethodSelect: function() {
			var gatewayType = $(this).data('type'),
				underTotal = $(this).data('underTotal'),
				showCCInfo = underTotal !== undefined ? !!underTotal : gatewayType !== 'manual';

			$('.si-points-instruction').toggle(gatewayType === 'points' && underTotal);

			$('.si-credits-instruction').toggle(gatewayType === 'store_credit' && underTotal);
			
			// show credit card info for everything but manual payment
			$('#si-credit-card-info').toggle(showCCInfo);
		},
		
		applyCoupon: function() {
			var $couponInput = $('input.si-coupon-input'),
				code = $couponInput.val();
			
			SI.form.clearErrors();
			$('#si-coupon-form').addClass('si-loading');
			
			SI.ajax.post('cart/apply_coupon', { code: code }, true)
				.success(function(results, message) {
					SI.message.set(message);
					SI.cart.updateTotal(results.cart_total);
					SI.checkout.updateCoupons(results.coupons);
					$couponInput.val('').focus();
					$('.si-points-instruction').html(results.points.payment_instruction);
					// TODO: update points here
					//results.points;
				})
				.error(function(results, message) {
					SI.form.setInlineError($('input.si-coupon-input'), message);
				})
				.complete(function() {
					$('#si-coupon-form').removeClass('si-loading');
				});
			
			return false;
		},
		
		removeCoupon: function() {
			var $item = $(this).parents('.si-applied-coupon'),
				couponCode = $item.data('couponCode');
			
			SI.checkout.removeCoupon(couponCode);
			
			return false;
		}
	},

	buildShippingQuotes: function() {
		var $list = $('.si-shipping-quote-list');

		SI.ajax.post('/' + SI.data.routes.checkout + '/display_quotes')
			.success(function(results) {
				// remove placeholder hidden field
				//$('input[type=hidden][name=shipping_quote]').remove();

				if (+results.tax_quote) {
					$('span.si-tax-total').html(SI.data.currency_symbol + results.tax_quote);
					$('.SI-tax-total-target').show();
				}

				$.each(results.shipping_quotes, function(index, quote) {
					var checked = +quote.rule_id === +results.shipping_rule_id,
						str;

					str = '<li class="si-shipping-' + quote.carrier_key + '">' +
						'<input type="radio" name="shipping_quote" class="si-radio" value="' + quote.rule_id + '" ' + (checked ? 'checked="checked"' : '') + '/>' +
						'<label class="si-radio"> &ndash; ' + quote.shipment_carrier_name + ' ' + quote.shipment_method_name + ' ' + (quote.description ? quote.description : '') + ' - ' +
						'<strong class="si-color">' + SI.data.currency_symbol + quote.rate + (+quote.tax ? ' (+' + SI.data.currency_symbol + quote.tax + ' tax)' : '') + '</strong> ' +
						'<span class="si-shipping-date">' + quote.delivery_info + '</span></label></li>';

					$list.append(str);
				});

				$('li.si-loading', $list).hide();
			});
//			.error(function() {
//				//$('span.si-form-error-message').html(data.message);
//			});
	},

	updateCoupons: function(coupons) {
		
		var $container = $('.si-applied-coupons'),
			coupon_str = '';
		
		$.each(coupons, function(i, coupon) {
			var errors = '<span class="si-inline-form-error" ' + (coupon.errors ? '' : 'style="display:none;"') + '>' + coupon.errors + '</span',
				remove = '<a href="#" class="si-remove-link si-invisitext">remove</a>',
				sub_total = SI.data.currency_symbol + coupon.sub_total;
			
			// build the i
			coupon_str += '<tr class="si-applied-coupon" data-coupon-code="' + coupon.code + '">' +
				'<td class="si-first-cell"><strong>' + coupon.name + '</strong>' +
				'<span class="si-coupon-description">' + coupon.description + '</span>' +
				errors +
				'</td>' +
				'<td><strong>Discount:</strong>-' + sub_total + '</td>' +
				'<td class="si-last-cell">' + remove + '</td>' +
				'</tr>';
		});

		$container.html(coupon_str);
		
		// add handlers
		$('a.si-remove-link', $container).click(SI.checkout.handlers.removeCoupon);
	},
	
	removeCoupon: function(couponCode) {
		return SI.ajax.post('cart/remove_coupon/' + couponCode)
			.success(function(results) {
				if (+results.cart.total) {
					SI.cart.updateTotal(results.cart.total);
					SI.checkout.updateCoupons(results.cart.coupon_objs);
				}
			});
	}
};

/*
 * DOM related utility functions
 *
 * Depends:
 */
SI.dom = {
	// returns a jQuery selector with the dom element representing an object
	// with the specified type and id.
	getElement: function(type, idOrObject, context) {
		if (isNaN(+idOrObject)) {
			return $(idOrObject);
		} else {
			context = context || document;
			return $('.si-' + type + ':data(id=' + (+idOrObject) + ')', context);
		}
	}
};
/*
 * ShopIgniter facebook integration
 *
 * Depends:
 */
SI.facebook = {
	_init: function () {
		// initialize Facebook SDK and parse XFBML tags if facebook integration is enabled
		if (SI.data.facebook && SI.data.facebook.enabled) {
			// don't block loading on facebook doing their ajax voodoo
			window.fbAsyncInit = this.asyncInit;
		}
	},
	
	_ready: function() {
		if (SI.data.facebook && SI.data.facebook.enabled && !$('#fb-root').length) {
			// add facebook's 'root' to the DOM
			$('<div>', { id: 'fb-root' }).prependTo('body');
			
			// fetch the FB SDK after the current event loop has finished so it doesn't block
			// other site related JS
			setTimeout(function() {
				$.getScript(document.location.protocol + '//connect.facebook.net/en_US/all.js');
			}, 0);
			
			// open merge dialog if merge is needed
			if (SI.data.facebook.merge_needed) {
				this.merge();
			}
		}
	},
	
	asyncInit: function() {
		var inIFrame = window.location !== window.parent.location;
		
		FB.init({
			appId               : SI.data.facebook.app_id,
			status	            : true, // check login status
			cookie	            : true, // enable cookies to allow the server to access the session
			xfbml	            : true, // parse XFBML
			oauth	            : true, // use new OAuth authentication protocol
			frictionlessRequests: true  // allow app requests to bypass request dialog
		});
		
		// set canvas resize if in iframe
		if (SI.data.channel === 'facebook' && inIFrame) {
			FB.Canvas.setAutoResize();
			FB.Canvas.scrollTo(0,0);
		}
		
		// register logout event callback
		FB.Event.subscribe('auth.logout', function() {
			if (SI.data.logged_in) {
				window.location = '/logout';
			}
		});
		
		// refresh the page when logging in
		FB.Event.subscribe('auth.login', function(response) {
			// redirect is in setTimeout() to ensure FB.Cookies.set is fired beforehand (fb bug #20499 work-around)
			setTimeout(function() {
				window.location.href = window.location.href;
			}, 0);
		});
		
		// add functionality to log out of facebook as well as the store
		$('a.si-logout-link').click(function(event) {
			// check that we're actually logged into facebook first
			FB.getLoginStatus(function(response) {
				if (response.authResponse) {
					FB.logout();
					
					// don't log out immediately, wait for the logout event to do it
					event.preventDefault();
				}
			});
		});
	},
	
	share: function(type, refId, shareUrl) {
		// first make sure the user is logged in and we have correct permissions
		FB.login(function(response) {
			if (response.authResponse) {
				// get the sharing info for the item
				SI.ajax.post('share/' + type + '/' + refId + '/facebook', { url: shareUrl || '' }, true)
					.success(function(results) {
						// display post-to-wall dialog
						FB.ui({
							display		: 'iframe',
							method		: 'feed',
							name		: results.name,
							description	: results.description,
							link		: results.url,
							picture		: results.image,
							caption		: results.caption,
							actions		: results.actions
						}, function(response) {
							if (response && response.post_id) {
								//SI.message.set('');
							}
						});
					});
			}
		}, { scope: 'publish_stream' });
	},
	
	validateRegister: function(form, callback) {
		SI.ajax.post('users/fb_plugin_validate_register/', form)
			.complete(function (results) {
				this.isResolved() ? callback() : callback(results);
			});
	},
	
	merge: function() {
		// user must go through extra step of merging logins
		$(document).ajaxDialog({
			url				: '/' + SI.data.routes.store + '/auth/merge_login/facebook?dialog=true',
			dialogID		: 'si-merge-login-dialog',
			autoOpen		: true,
			open			: function(event, ui) {
				// add handlers for closing the dialog
				$('.si-no-button', ui.dialog).click(function() {
					$(document).ajaxDialog('close');
				});
				$('.si-yes-button', ui.dialog).click(function() {
					window.location = '/' + SI.data.routes.store + '/auth/merge_login/facebook?return=true';
				});
			}
		});
	}
};

/*
 * ShopIgniter filter
 *
 * Depends:
 *  SI.stateManager
 *  SI.browsingTools
 */
SI.filter = {
	_init: function() {
		SI.browsingTools.subscribe(this);

		$(document)
			.delegate('select.si-filter-select', 'change.si', this.handlers.filterSelect)
			.delegate('a.si-remove-filters', 'click.si', this.handlers.removeAllFilters)
			// review filter handler
			.delegate('a.si-review-filter-link', 'click.si', this.handlers.reviewLink);
	},
	
	_ready: function(context) {
		// activate price range slider
		var $slider = $('div.si-product-price-slider', context);
		if ($slider.length) {
			var minPrice = +$slider.data('minPrice'),
				maxPrice = +$slider.data('maxPrice'),
				stepVal = +$slider.data('stepVal');
			
			if (minPrice !== maxPrice && stepVal) {
				$slider.slider({
					range	: true,
					min		: minPrice, 
					max		: maxPrice,
					step	: stepVal,
					values	: [ minPrice, maxPrice ],
					slide	: this.handlers.priceSlider,
					stop	: this.handlers.priceSlider
				});
			}
		}
	},
	
	handlers: {
		filterSelect: function() {
			var value = $(this).val(),
				params;
			
			if (value) {
				params = SI.string.parseObj($(this).attr('name'), value);
				
				if (SI.paginate) {
					SI.paginate.setPage(1);
				}
				
				SI.stateManager.updateParams(params, true);
			}
		},
		
		priceSlider: function(event, ui) {
			if (event.type === 'slidestop') {
				if (SI.paginate) {
					SI.paginate.setPage(1);
				}
				
				SI.stateManager.updateParams({ min_price: ui.values[0], max_price: ui.values[1] }, true);
			} else {
				$('.si-applied-filter:data(name=price_range)').html(SI.data.currency_symbol + ui.values[0] + '-' + SI.data.currency_symbol + ui.values[1]);
			}
		},
		
		removeFilter: function() {
			var name = $(this).data('name'),
				param = name.replace(/\[.*\]$/, ''),
				params;
			
			if (name !== param) {
				params = SI.string.parseObj(name);
			} else {
				params = param === 'price_range' ? [ 'min_price', 'max_price' ] : [ param ];
			}
			
			if (SI.paginate) {
				SI.paginate.setPage(1);
			}
			
			SI.stateManager.removeParams(params, true);
			
			return false;
		},
		
		removeAllFilters: function() {
			if (SI.paginate) {
				SI.paginate.setPage(1);
			}
			
			SI.stateManager.removeParams(SI.filter.getParams(), true);
				
			return false;
		},
		
		reviewLink: function() {
			var score = +$(this).data('score');
			
			if (SI.paginate) {
				SI.paginate.setPage(1);
			}
			
			SI.stateManager.updateParams({ score: score }, true);
			
			return false;
		}
	},
	
	getParams: function() {
		return [ 'min_price', 'max_price', 'category.id', 'brand.id', 'collection.id', 'attribute_value.id', 'option.id', 'score' ];
	},
	
	update: function(params) {
		var $current = $('.si-current-filters'),
			divider = $current.data('divider'),
			qualifiers = {};
		
		// format price params and strip group ids out of params to create qualifiers
		$.each(params, function(name, value) {
			if (name.slice(4) === 'price') {
				qualifiers[name + (name.slice(0, 3) === 'max' ? '<=' : '>=')] = value;
			} else {
				if ($.isPlainObject(value)) {
					qualifiers[name] = [];
					$.each(value, function(i, value) {
						qualifiers[name].push(value);
					});
				} else {
					qualifiers[name] = value;
				}
			}
		});
		
		// flatten params so the keys match the input/select name attributes
		params = SI.object.flatten(params);
		
		// add and update currently applied filters
		$.each(params, function(param, value) {
			var $applied,
				html = '';
			
			if (param === 'min_price' || param === 'max_price') {
				var $slider = $('.si-product-price-slider'),
					minPrice = params.min_price !== undefined ? params.min_price : $slider.data('minPrice'),
					maxPrice = params.max_price !== undefined ? params.max_price : $slider.data('maxPrice');
					
				param = 'price_range';
				html = SI.data.currency_symbol + minPrice + '-' + SI.data.currency_symbol + maxPrice;
			} else if (param === 'score') {
				html = $('.si-review-filter-link:data(score=' + value + ')').data('label');
			} else {
				var $select = $('.si-filter-select[name="' + param + '"]');
				
				html = $('option[value="' + params[param] + '"]', $select).text().replace(/\W*/, '');
			}
			
			// only add applied filter links if there is a container for them
			if ($current.length) {
				$applied = $('.si-applied-filter:data(name="' + param + '")', $current);
				
				// add the current filter link if it doesn't exist
				if (!$applied.length) {
					// insert a spacer if it's not the first element in the container
					if (!$current.is(':empty')) {
						$('<span>', {
							'class'	: 'si-divider',
							html	: divider
						}).appendTo($current);
					}
					
					$applied = $('<a>', {
						href	: '#',
						'class'	: 'si-applied-filter',
						data	: { name: param },
						click	: SI.filter.handlers.removeFilter
					}).appendTo($current);
				}
				
				$applied.html(html);
			}
		});
		
		// remove current filters that aren't applied
		$('.si-applied-filter').each(function() {
			var param = $(this).data('name');
			
			if (!(param in params) && (param !== 'price_range' || !('min_price' in params) || !('max_price' in params))) {
				// remove the following spacer if it was the first element in the container
				if ($(this).is(':first-child')) {
					$(this).next('span.si-divider').remove();
				}
				
				// remove the current filter link and any preceding spacer
				$(this)
					.prev('span.si-divider')
					.remove()
					.end()
					.remove();
			}
		});
		
		// set filter controls to their correct values
		$('.si-filter').each(function() {
			var param = $(this).attr('name'),
				value,
				$elem,
				minPrice,
				maxPrice;
			
			if (param === 'price_range') {
				$elem = $('.si-product-price-slider');
				minPrice = params.min_price !== undefined ? params.min_price : $elem.data('minPrice');
				maxPrice = params.max_price !== undefined ? params.max_price : $elem.data('maxPrice');
				
				$elem.slider('values', [ minPrice, maxPrice ]);
			} else {
				$elem = $('.si-filter-select[name="' + param + '"]');
				value = param in params ? params[param] : '';
				
				$elem.val(value);
					
				// also disable filter selects that are currently enabled
				if (value) {
					$elem.attr('disabled', true);
				} else {
					$elem.removeAttr('disabled');
				}
			}
		});
		
		$('.SI-current-filter-target').toggle(!$.isEmptyObject(params));
		
		return { 'qualifiers' : qualifiers };
	},
	
	postUpdate: function(data) {
		if (data.filter) {
			$('.si-filter-select').each(function() {
				var $select = $(this),
					value = $select.val(),
					type = $(this).data('type'),
					key = SI.string.pluralize(type),
					items = [],
					match = $select.attr('name').match(/\w+\[(\d+)\]/),
					groupId,
					attributeId;
				
				$('option', this).slice(1).remove();
				
				if (data.filter[key]) {
					if (type === 'attribute' && data.filter.attributes) {
						// attributes are too complex to handle generically
						attributeId = +match[1];
						$.each(data.filter.attributes, function(index, attribute) {
							if (+attribute.id === attributeId) {
								items = attribute.values;
								return false;
							}
						});
					} else if (data.filter[type + '_groups']) {
						// this block is gnarly, but it handles both options and collections
						groupId = +match[1];
						$.each(data.filter[type + '_groups'], function(index, groupItem) {
							if (+groupItem.id === groupId) {
								$.each(data.filter[key], function(index, item) {
									if (+item[type + '_group_id'] === +groupItem.id) {
										items.push(item);
									}
								});
							}
						});
					} else {
						items = data.filter[key];
					}
					
					$.each(SI.filter.getOptions(type, items), function(value, text) {
						$('<option>', {
							value	: value,
							html	: text
						}).appendTo($select);
					});
					
					// reselect the value that it originally had and hide/show
					$select
						.val(value)
						.toggle($select.children().length > 1);
				}
			});
			
			// hide/show filter-wrappers, selects
			$('.si-filter-form').each(function() {
				var showWrapper = false;
				
				$('.si-filter-select', this).each(function() {
					// the instructional text option doesn't count
					var numOptions = $(this).children().length - 1,
						showSelect = $(this).is(':disabled') || numOptions > 1;
					
					// hide the filter if there is only one option (it won't affect the filter)
					$(this).toggle(showSelect);
					
					showWrapper |= showSelect;
				});
				
				// can't use visibility state of selects since whole filter area could be hidden
				$(this).toggle(!!showWrapper);
			});
			
			SI.browsingTools.getPartialTarget().trigger('filterchange', []);
		}
	},
	
	getOptions: function(type, items, parent_id, level) {
		var i,
			options = {},
			indentStr = level ? '&not;' : '',
			hasChildren = type === 'category';
		
		level = level || 0;
		parent_id = parent_id || 0;
		
		for (i = 0; i < level; i++) {
			indentStr = '&nbsp;&nbsp;' + indentStr;
		}
		
		$.each(items, function(index, item) {
			if (!hasChildren || +item.parent_id === parent_id) {
				options[item.id] = indentStr + item.name;
				
				if (hasChildren) {
					$.extend(options, SI.filter.getOptions(type, items, +item.id, level + 1));
				}
			}
			
		});
		
		return options;
	}
};

/*
 * ShopIgniter generic ajax form submission
 *
 * Depends:
 */
SI.form = {
	_init: function() {
		$(document)
			// add ajax handler to forms with the data-async attribute
			.delegate('form:data(async)', 'submit.si', this._handlers.submit)
			// add handlers to make a.si-button links act like submit buttons
			.delegate('form a.si-action-button', 'click.si', function() {
				$(this).parents('form').submit();
				return false;
			});
	},
	
	_ready: function() {
		if (SI.data.errors) {
			if ($.isPlainObject(SI.data.errors)) {
				$.each(SI.data.errors, function(name, error) {
					SI.form.setInlineError($('input[name="' + name + '"]'), error);
				});
			} else {
				SI.form.setErrorMessage(SI.data.errors);
			}
			
			// don't set the errors more than once
			SI.data.errors = false;
		}
	},
	
	_handlers: {
		submit: function(event) {
			var $form = $(this),
				settings = {
					type    : 'POST',
					data    : $form.serialize()
				};
			
			// don't allow double submits
			if ($form.hasClass('si-disabled')) {
				return false;
			}
			
			// clear existing form errors
			SI.form.clearErrors();
			
			$form.addClass('si-loading si-disabled');
			
			SI.ajax.send($form.attr('action'), settings, true)
				.complete(function(results, message) {
					var event = $.Event();
					
					event.type = this.isResolved() ? 'success' : 'error';
					
					// trigger event based on success of ajax post
					$form.trigger(event, [ results, message ]);
					
					// perform default action if the event wasn't 'canceled'
					if (!event.isDefaultPrevented()) {
						if (this.isResolved()) {
							SI.ajax._handlers.defaultAction(results, message);
						} else {
							SI.form._handlers.error.apply($form.get(0), arguments);
						}
					}
					
					// fire blur event on all inputs (kind of hacky, but it can't hurt much)
					$('input, select', $form).blur();
					
					// if there was an error, re-enable the form
					if (!this.isResolved()) {
						$form.removeClass('si-disabled');
					}
					
					$form.removeClass('si-loading');
				});
			
			// don't let the form submit like normal
			event.preventDefault();
		},
		
		error: function(results, message) {
			var errors = $.extend({}, results);
			
			// show error message
			SI.form.setErrorMessage(message);
			
			// display inline errors
			$(':input', this).not(':submit').each(function() {
				var name = $(this).attr('name');
				
				// show there error and remove it so it can't get re-displayed
				if (errors[name]) {
					SI.form.setInlineError($(this), errors[name]);
					delete errors[name];
				}
			});
		}
	},
	
	clearErrors: function(form) {
		form = form || window.document;
		
		$('span.si-inline-form-error', form).remove();
		$('.si-form-error-message', form)
			.empty()
			.hide();
	},
	
	setErrorMessage: function(message) {
		$('.si-form-error-message')
			.html(message)
			.show();
	},
	
	setInlineError: function($input, error) {
		var $wrapper = $input.parents('.si-field-wrapper'),
			$error = $('<span>', {
				"class"	: 'si-inline-form-error',
				html	: error
			});
			
		// append the error to the field wrapper if one exists
		if ($wrapper.length) {
			$wrapper.append($error);
		} else {
			$error.insertAfter($input);
		}
	}
};

/*
 * ShopIgniter image handling
 *
 * Depends:
 */
SI.image = {
	options: {
		inAnimation		: { opacity: 1 },
		outAnimation	: { opacity: 0 },
		duration		: 'fast',
		easing			: 'swing'
	},
	
	_init: function() {
		// use .live() since images can get loaded after the dom ready event
		// (must attach .live() handlers only once)	
		$('.SI-image-gallery img.si-image').live('click', SI.image.handlers.click);
	},
	
	_ready: function(context) {
		$('span.si-image-placeholder:data(load) img', context).each(function() {
			SI.image.load(this);
		});
		
		// the first image in the gallery will be the main image
		$('.SI-image-gallery img.si-image:first', context).addClass('si-current');
	},
	
	handlers: {
		click: function() {
			var $container = $(this).parents('.SI-image-gallery-wrapper'),
				$target;
			
			// if there's no gallery wrapper, default to the whole document
			$container = $container.length ? $container : window.document;
			
			$target = $('.SI-image-gallery-target', $container);
			
			if ($target.length) {
				SI.image.swap($('img.si-image', $target), this);
				
				// move si-current class to the image that was swapped
				$('.SI-image-gallery img.si-image.si-current', $container).removeClass('si-current');
				$(this).addClass('si-current');
				
				return false;
			}
		}
	},
	
	load: function(image, options) {
		var $image = $(image),
			$container = $image.parents('.SI-image-gallery-wrapper'),
			sizes = SI.image.getSize(image),
			src = SI.image.getSrc(image),
			sizeKey = $image.data('size'),
			size,
			deferred = $.Deferred(),
			promise = {
				success : deferred.done,
				// if the image doesn't load properly, there's no way to tell
				error   : $.noop,
				complete: deferred.always
			};
		
		// don't do anything if the src attribute is the same as the current img's
		if (!$.isPlainObject(sizes) || !src || src === $image.attr('src')) {
			return false;
		}

		// find the correct size of the image, defaulting to the source size if not found
		size = sizeKey in sizes ? sizes[sizeKey] : sizes.source;
		
		// if there's no gallery wrapper, default to the whole document
		$container = $container.length ? $container : window.document;
		
		options = $.isPlainObject(options) ? $.extend(SI.image.options, options) : SI.image.options;
		
		// add loading div to the main image wrapper
		$('.SI-image-loading-target', $container).addClass('si-loading');
		
		$image.animate(options.outAnimation, options.duration, options.easing, function() {
			// load a dummy image to make sure the actual image is in browser cache
			$('<img/>').load(function() {
				$('.SI-image-loading-target', $container).removeClass('si-loading');
				
				// remove the placeholder span if there is one
				if ($image.parent('span.si-image-placeholder').length) {
					$image.parent().replaceWith($image);
				}
				
				$image.trigger('imageload');
				
				// once it has loaded, just change the src attribute on the current image
				$image
					.attr({
						src		: src,
						width	: size.width,
						height	: size.height
					})
					.animate(options.inAnimation, options.duration, options.easing, deferred.resolve);
			}).attr('src', src);
		});
		
		return deferred.promise(promise);
	},
	
	loadAll: function(options) {
		$('span.si-image-placeholder img').each(function() {
			SI.image.load(this, options);
		});
	},
	
	swap: function(mainImage, sourceImage, callback, options) {
		var $mainImage = $(mainImage),
			$thumbnail = $(sourceImage);
		
		// set the jQuery data of the main image to the thumbnail's data
		$mainImage
			.data($.extend({}, $thumbnail.data(), { size: $mainImage.data('size') }))
			.attr('alt', $thumbnail.attr('alt'));
		
		// if the images are surrounded by anchors, swap them as well
		if ($mainImage.parent('a').length && $thumbnail.parent('a').length) {
			$.each(['href', 'title', 'rel'], function(i, attrName) {
				var $parent = $mainImage.parent(),
					value = $thumbnail.parent().attr(attrName);
				
				if (value) {
					$parent.attr(attrName, value);
				} else {
					$parent.removeAttr(attrName);
				}
			});
		}
		
		return SI.image.load(mainImage, options);
	},
	
	resize: function(image, sizeKey, options) {
		// load will look at the data 'size'
		$(image).data('size', sizeKey);
		
		return SI.image.load(image, options);
	},
	
	getSize: function(image, sizeKey) {
		var sizeData = $(image).data('sizes'),
			formattedData = {};
		
		if (!$.isArray(sizeData)) {
			return false;
		}
		
		// turn the somewhat wonky size data into something more usable
		$.each(sizeData, function(i, value) {
			formattedData[value.key] = {
				height	: value.height,
				width	: value.width
			};
		});
		
		if (sizeKey !== undefined) {
			return formattedData[sizeKey];
		}
		
		return formattedData;
	},
	
	getSrc: function(image, sizeKey) {
		var data = $(image).data();
		
		sizeKey = sizeKey || data.size;
		
		if (!sizeKey || !$.isPlainObject(data)) {
			return false;
		}
		
		return data.path + data.filename + (sizeKey !== 'source' ? '_' + sizeKey : '') + '.' + data.extension;
	}
};

/*
 * ShopIgniter locations
 *
 * Depends:
 * 
 * @todo leverage jQuery-UI templates for the location list at some point
 */
SI.location = {
	
	postalCodeInfo: null,
	
	scopeNames: null,
		
	postalCodes: {},

	regions: ['region1', 'region2', 'region3', 'region4', 'region5', 'city'],
	
	_init: function() {
		// currently location inputs only appear in checkout
		if (SI.data.section === 'checkout') {
			$(document)
				.delegate('select.si-country-select', 'change.si', SI.location._handlers.countrySelect)
				.delegate('input.si-postal-code-input', 'keyup.si', SI.location._handlers.postalCodeChange);
		}
	},
	
	_ready: function(context) {
		if (SI.data.section === 'checkout') {
			// parse postal code and region meta data
			this.postalCodeInfo = $.parseJSON($('#si-postal-code-data', context).html());
			this.scopeNames = $.parseJSON($('#si-location-name-data', context).html());
			
			// update the list in case there is a valid country/postal code but no location data
			$.each([ 'billing', 'shipping' ], function (i, type) {
				if (!$('input[name="' + type + '_geocode_id"]').val()) {
					SI.location.updateList(type);
				}
			});
		}
	},
	
	_handlers: {
		countrySelect: function() {
			var type = SI.location.getInputType($(this)),
				countryId = +$(this).val(),
				postalCodeInfo = SI.location.postalCodeInfo[countryId];
			
			// any time the country changes, the currently displayed location is invalid
			SI.location.resetList(type, 'instruction');
			
			// the postal code input should only be enabled when a valid country is selected
			$('input.si-postal-code-input[name="' + type + '_postal_code"]')
				.prop('disabled', !postalCodeInfo || !postalCodeInfo.length || !countryId)
				.val('');
			
			// auto-check the manual override checkbox if the country has no postal code
			$('input[name="billingManualLocationEnabled"]').prop('checked', postalCodeInfo && !postalCodeInfo.length);
			
			$(this).trigger('countrychange', [ postalCodeInfo ]);
		},
		
		postalCodeChange: function() {
			SI.location.updateList(SI.location.getInputType($(this)));
		}
	},
	
	getInputType: function($elem) {
		var name = $($elem).attr('name');
		if (name.search('shipping') !== -1) {
			return 'shipping';
		} else if (name.search('billing') !== -1) {
			return 'billing';
		}
		
		return false;
	},
	
	updateList: function(type) {
		var countryId = +$('select.si-country-select[name="' + type + '_country_id"]').val(),
			postalCode = $('input.si-postal-code-input[name="' + type + '_postal_code"]').val(),
			validFormat = SI.location._validPostalCodeFormat(countryId, postalCode);
		
		// if the postal code value hasn't changed, don't check (meta character key events, etc.)
		if (postalCode !== this.postalCodes[type]) {
			this.postalCodes[type] = validFormat ? postalCode : null;
			
			if (validFormat) {
				this._buildList(type, countryId, postalCode);
			} else {
				SI.location.resetList(type, 'instruction');
				$('.si-location-list:data(type=' + type + ')').trigger('locationchange', [ false, false ]);
				//$('#si-' + type + '-override-link').swap('restore');
			}
		} 
	},
	
	resetList: function(type, state, message) {
		var $list = $('.si-location-list:data(type=' + type + ')'),
			$elem;
		
		$('li:not(.si-instruction,.si-loading,.si-error), select', $list).remove();
		$('li', $list).hide();
		
		if (state) {
			// reset the location id
			SI.location._setGeocodeId(type, '');

			$elem = $('li.si-' + state, $list);
			if (message) {
				$elem.html(message);
			}
			
			$elem.show();
		}
	},
	
	_buildList: function(type, countryId, postalCode) {
		var $list = $('.si-location-list:data(type=' + type + ')');
		
		$list.addClass('si-loading');
		this.resetList(type, 'loading', SI.data.lang.location_loading);
		
		SI.ajax.post('locations/find/' + type, {
			country_id:     countryId,
			postal_code:    postalCode
		}, true)
			.complete(function() {
				$list.removeClass('si-loading');
			})
			.success(function(results) {
				// if the postal code was changed to something bogus during the ajax request,
				// don't update the list
				if (!SI.location.postalCodes[type]) {
					SI.location.resetList(type, 'instruction');
					return;
				}
				
				// clear all messages
				SI.location.resetList(type);
				
				if (results.length > 1) {
					var prevStr = '',
						locationListCount = 0,
						$locationSelect = $('<select>', {
							name: type + '_select_location',
							change: function () {
								SI.location._setGeocodeId(type, $(':selected', this).val());
							}
						}),
						str;

					SI.location._setGeocodeId(type, results[0].id);

					$.each(results, function(count, location) {
						str = '';
						$.each(SI.location.regions, function(i, region) {
							if (location[region + '_label'] && location[region]) {
								str += location[region + '_label'] + ': ' + location[region] + ' ';
							} else if (region === 'city') {
								str += 'City: ' + location[region];
							}
						});
						if (str !== prevStr) {
							locationListCount++;
							$locationSelect.append(new Option(str, location.id));
							prevStr = str;
						}
					});

					if (locationListCount === 1) {		// We had multiple selections, but they all ended up being the same.
						SI.location._buildSingleLocation (type, $list, results[0]);
					} else {
						$list.append($locationSelect);
					}
				} else {	// if (data.results.length > 1)
					SI.location._buildSingleLocation (type, $list, results[0]);
				}
				
				$list.trigger('locationchange', [ true, true ]);
			})
			.error(function() {
				// could use message here, but not localized
				SI.location.resetList(type, 'error', SI.data.lang.location_error);
				
				$list.trigger('locationchange', [ true, false ]);
			});
	},

	_buildSingleLocation: function(type, $list, location) {
		SI.location._setGeocodeId(type, location.id);

		$.each(SI.location.regions, function(i, region) {
			if (location[region + '_label'] && location[region]) {
				SI.location._buildLocationListElement($list, location[region + '_label'], location[region]);
			} else if (region === 'city') {
				SI.location._buildLocationListElement($list, 'City', location[region]);
			}
		});
	},
	
	_buildLocationListElement: function ($list, label, value) {
		$list
			.append($('<li>')
			.append($('<strong>')
			.text(label + ': '))
			.append($('<span>')
			.text(value)));
	},

	_setGeocodeId: function (type, id) {
		$('input[name = ' + type + '_geocode_id]').val(id);
	},

	_validPostalCodeFormat: function(countryId, postalCode) {
		// if there's no postal code meta data, assume valid since we can't check
		if (!this.postalCodeInfo) {
			return true;
		}
		
		// have to have both a country and postal code to check
		if (!countryId || !postalCode) {
			return false;
		}
		
		// build regex to match postal code on
		var postalCodeRegex = new RegExp(this.postalCodeInfo[countryId].pattern, 'i');
		
		return postalCodeRegex.exec(postalCode) !== null;
	}
};

/*
 * ShopIgniter messaging
 *
 * Depends:
 * 
 */
SI.message = {
	_ready: function(context) {
		if (SI.data.message && $('.SI-message-target', context).length) {
			this.set(SI.data.message);
		}
	},
	
	set: function(message) {
		var $container = $('.SI-message-target');
		
		if (!$container.length) {
			console.info('missing message target class');
		}
		
		$container
			.html(message)
			.show();
		
		$(document).trigger('message', message);
	},
	
	remove: function() {
		$('.SI-message-target')
			.empty()
			.hide();
	}
};

/*
 * ShopIgniter navigation
 *
 * Depends:
 * 
 */
SI.navigation = {
	_init: function() {
		// add interface handlers
		$(document).delegate('.si-menu-select', 'change.si', SI.navigation.handlers.menuSelect);
	},
	
	handlers: {
		menuSelect: function() {
			if ($(this).val()) {
				window.location = '/' + $(this).val();
			}
		}
	}
};

/*
 * ShopIgniter object helper functions
 *
 * Depends:
 * 
 */
SI.object = {
	is: function(type, obj) {
		return obj !== undefined && obj !== null && type === Object.prototype.toString.call(obj).slice(8, -1).toLowerCase();
	},

	isInt: function(obj) {
		return SI.object.is('number', obj) && obj % 1 === 0;
	},
	
	// call a function on every attribute in an object
	applyEvery: function(obj, func, args) {
		var map = {};
		args = args || [];
		
		$.each(obj, function(key, elem) {
			var value;
			
			if ($.isPlainObject(elem)) {
				if ($.isFunction(func)) {
					value = func.apply(elem, args);
				} else if ($.isFunction(elem[func])) {
					value = elem[func].apply(elem, args);
				}
				
				if (value !== undefined) {
					map[key] = value;
				}
			}
		});
		
		return map;
	},
	
	// recursively compare two objects for equality, ignoring functions
	compare: function(obj1, obj2) {
		var i, isObj;
		
		// typeof is use here instead of $.isPlainObject to account for arrays
		if (typeof obj1 !== 'object' || typeof obj2 !== 'object') {
			return false;
		}
		
		for (i in obj1) {
			if (obj1.hasOwnProperty(i)) {
				isObj = typeof obj1[i] === 'object';
				if ((isObj && !SI.object.compare(obj1[i], obj2[i])) || (!isObj && obj1[i] !== obj2[i])) {
					return false;
				}
			}
		}
		
		for (i in obj2) {
			if (obj2.hasOwnProperty(i)) {
				if (obj1[i] === undefined) {
					return false;
				}
			}
		}
		
		return true;
	},
	
	// turns nested objects into a flat set of key value pairs, much like url parameterizing
	flatten: function(obj) {
		var rtn = {};
		
		$.each(obj, function(key, val) {
			if ($.isPlainObject(val)) {
				$.each(SI.object.flatten(val), function(flatKey, val) {
					rtn[key + '[' + flatKey + ']'] = val;
				});
			} else {
				rtn[key] = val;
			}
		});
		
		return rtn;
	},
	
	// creates an API _qualifier compatible string from the object
	qualifiers: function(obj) {
		var qualifiers = [];
		
		$.each(obj, function(name, value) {
			var match = name.match(/([\w\.]+)\s*([<>=]+)?/),
				values = $.isArray(value) ? value : [ value ];
			
			$.each(values, function(i, value) {
				var op = '';
				
				switch (match[2]) {
					case '<':
						op = 'lt';
						break;
					case '>':
						op = 'gt';
						break;
					case '<=':
						op = 'lte';
						break;
					case '>=':
						op = 'gte';
						break;
				}
				
				qualifiers.push(match[1] + ';' + (op ? op + ';' : '') + value);
			});
		});
		
		return qualifiers.join(',');
	},
	
	// deletes keys out of obj specified in the array keys
	// if an element in keys is an object, it removes all  
	removeKeys: function(obj, keys) {
		if (!$.isArray(keys)) {
			keys = [ keys ];
		}
		
		$.each(keys, function(i, key) {
			if ($.isPlainObject(key)) {
				$.each(key, function(attr, keys) {
					if ($.isPlainObject(obj[attr])) {
						SI.object.removeKeys(obj[attr], keys);
					}
					if ($.isEmptyObject(obj[attr])) {
						delete obj[attr];
					}
				});
			} else {
				// assume the key is a string
				delete obj[String(key)];
			}
		});
	}
};

/*
 * ShopIgniter pagination
 *
 * Depends:
 *  SI.stateManager
 *  SI.browsingTools
 */
SI.paginate = {
	page: 0,
	
	numPages: 0,
	
	_init: function() {
		this.page = SI.data.paginate ? +SI.data.paginate.page : 1;
		this.numPages = SI.data.paginate ? +SI.data.paginate.num_pages : 1;
		
		SI.browsingTools.subscribe(this);
		
		$(document)
			.delegate('input.si-page-input', 'change.si', this.handlers.pageInput)
			.delegate('a.si-next-page-link, a.si-prev-page-link', 'click.si', this.handlers.changePage)
			.delegate('a.si-view-all-link', 'click.si', this.handlers.viewAll)
			.delegate('a.si-more-link', 'click.si', this.handlers.moreResults);
	},
	
	_ready: function(context) {
		// prevent pagination form from submitting like normal
		$('form.si-page-form', context).bind('submit', false);
	},
	
	handlers: {
		pageInput: function() {
			var page = this.nodeName === 'INPUT' ? $(this).val() : $('.si-page-input', this).val();
			
			SI.paginate.setPage(page);
			SI.stateManager.commit();
			
			return false;
		},
		
		changePage: function() {
			var delta = $(this).is('.si-next-page-link') ? 1 : -1,
				page = SI.paginate.page + delta > 0 ? SI.paginate.page + delta : 1;
			
			SI.paginate.setPage(page);
			SI.stateManager.commit();
			
			return false;
		},
		
		moreResults: function() {
			if (SI.paginate.page < SI.paginate.numPages) {
				SI.stateManager.updateParams({
					page	: SI.paginate.page + 1,
					append	: true
				}, true);
			}
			
			return false;
		},
		
		viewAll: function() {
			SI.stateManager
				.removeParams([ 'page' ])
				.updateParams({ view_all: true })
				.commit();
				
			return false;
		}
	},
	
	getParams: function() {
		return [ 'page', 'view_all', 'append' ];
	},
	
	update: function(params) {
		if (params.page !== undefined) {
			if (!!params.view_all || +params.page <= 1 || +params.page > SI.paginate.numPages) {
				delete params.page;
			}
			
			if (!!params.append && +params.page > SI.paginate.page + 1) {
				params.offset = SI.data.paginate.per_page * SI.paginate.page;
				params.limit = SI.data.paginate.per_page * (+params.page - SI.paginate.page);
			}
		}
		
		// page one is the default
		if (+params.page === 1) {
			delete params.page;
		}
		
		return params;
	},
	
	postUpdate: function(data) {
		if (data.paginate) {
			var total = +data.paginate.total,
				curPage = +data.paginate.page,
				numPages = +data.paginate.num_pages;
			
			SI.paginate.page = curPage;
			SI.paginate.numPages = numPages;
			
			// hide/show the pagination wrapper depending on if there are pages to paginate through
			$('.SI-pagination-wrapper').toggle(numPages !== 1);
			
			// hide or show the more link depending on if there are more results to load
			$('a.si-more-link').toggle(!!numPages && curPage !== numPages);
			
			$('form.si-pagination').toggle(numPages !== 1);
			$('span.si-total-count').text(total);
			$('span.si-total-pages').text(numPages);
			$('input.si-page-input').val(curPage);
			$('span.si-current-page').text(curPage);
			
			// change the previous page button to a span if on the first page, or an anchor if not
			// change the next page button to a span if on the last page, or an anchor if not
			$.each({ '.si-prev-page-link': 1, '.si-next-page-link': numPages }, function(selector, value) {
				$(selector).each(function() {
					if (curPage === value && this.nodeName === 'A') {
						$(this).replaceWith($('<span>', {
							'class'	: $(this).attr('class'),
							html	: $(this).html()
						}));
					} else if (curPage !== value && this.nodeName !== 'A') {
						$(this).replaceWith($('<a>', {
							href	: '#',
							'class'	: $(this).attr('class'),
							html	: $(this).html(),
							click	: SI.paginate.handlers.changePage
						}));
					}
				});
			});
			
			SI.browsingTools.getPartialTarget().trigger('pagechange', [ curPage ]);
		}
	},
	
	setPage: function(page) {
		SI.stateManager.removeParams([ 'view_all', 'append' ]);
		
		// only set the page param if it's not already correct
		if (SI.paginate.page !== +page) {
			SI.stateManager.updateParams({ page: +page });
		}
	}
};

/*
 * Product related javascript
 *
 * Depends:
 *  SI.message
 */
SI.product = {
	_init: function() {
		$(document)
			.delegate('.si-options', 'click.si change.si', this._handlers.optionSelect)
			.delegate('.si-styles', 'click.si', this._handlers.styleSelect)
			// note that this only works in IE because jQuery makes the submit event bubble
			.delegate('form.si-cart-add-form', 'submit.si', this._handlers.addToCart)
			.delegate('form.si-wishlist-add-form', 'submit.si', this._handlers.addToWishlist)
			.delegate('form.si-stock-notification-form', 'submit.si', this._handlers.addNotification)
			// these forms are only wrappers for form elements
			.delegate('form.si-quantity-form, si-options-form', 'submit.si', function() {
				return false;
			})
			.delegate('.si-product input.si-quantity-input, .si-product select.si-denomination-select', 'change.si', function() {
				SI.product._updateSocialBuyLink($(this).closest('.si-product'));
			});
	},
	
	_ready: function(context) {
		// find any products in the context selector
		var $products = $(context).filter('.si-product');
		
		// if no products are directly in the selector, search within
		if (!$products.length) {
			$products = $('.si-product', context).has('.si-option-group');
		}

		$products.has('.si-option-group').each(function() {
			SI.product.resetOptions($(this), true);
		});
		
		// enable add to cart buttons
		$('form.si-cart-add-form input[type=submit]', context).prop('disabled', false);
	},
	
	_handlers: {
		optionSelect: function(event) {
			var $product = $(this).parents('.si-product'),
				$target = $(event.target),
				value;
			
			// remove cart error messages associated with this product
			$('.si-cart-error', $product).parent().contextMessage('hide');
			
			// find the value based on the options select type
			if ($(this).is('select')) {
				if (event.type === 'click') {
					return;
				}
				
				value = $(this).val();
			} else {
				// the anchor is what we want, but it could have an image inside it
				if ($target.is('img')) {
					$target = $target.parent();
				}
				
				value = $target.data('value');
				
				// if disabled, an actual value wasn't clicked on, or if the value hasn't changed, bail
				if ($(this).hasClass('si-disabled') || !$target.is('a:not(.si-disabled)') || value === $(this).data('optionId')) {
					return false;
				}
			}
			
			// set the option (has to happen for swatches)
			SI.product._selectOption($product, $(this), value);
			
			// update the valid options in all option selects
			SI.product._updateOptions($product);
			
			return false;
		},
		
		styleSelect: function(event) {
			var $product = $(this).parents('.si-product'),
				$target = $(event.target),
				value;

			// remove cart error messages associated with this product
			$('.si-cart-error', $product).parent().contextMessage('hide');
			
			if ($target.is('img')) {
				$target = $target.parent();
			}

			value = $target.data('value');
			
			if ($target.is('a') && value !== $(this).data('assetId')) {
				$(this)
					.data('assetId', value)
					.children().removeClass('si-current');
				$target.addClass('si-current');
			}
			
			SI.product._updateSocialBuyLink($product);
			
			return false;
		},
		
		addToCart: function(event) {
			SI.product.addToCart($(event.target).closest('.si-product'));
			
			return false;
		},
		
		addToWishlist: function() {
			var $product = $(this).parents('.si-product'),
				$input = $('input.si-wishlist-name, select', this),
				// if $input is a select, then cast the val to a number to indicate it's an id
				wishlistNameOrId = $input.is('input') ? $input.val() : +$input.val();
			
			SI.product.addToWishlist(wishlistNameOrId, $product);
			
			return false;
		},
			
		addNotification: function(event) {
			var $form = $(event.target),
				$product = $form.closest('.si-product'),
				$input = $('input[type=text]', $form),
				productId = $product.data('id'),
				productOptionId = $product.data('productOptionId'),
				email = $input.val() || null;
			
			$form.addClass('si-loading');
			SI.form.clearErrors($form);
			
			SI.ajax.post('notifications/stock/' + productId, {
				product_option_id   : productOptionId,
				email               : email
			})
				.error(function(message, errors) {
					SI.form.setInlineError($input, errors.email);
				})
				.complete(function() {
					$form.removeClass('si-loading');
					
					// TODO: trigger event
				});
			
			return false;
		}
	},
	
	/**
	 * Set Product Option
	 * 
	 * For a product, set the product option specified by id and update
	 * product data to reflect it.
	 * 
	 * @param product
	 * @param productOptionId
	 */
	setProductOption: function(product, productOptionId) {
		var $product = SI.dom.getElement('product', product),
			productOptions = $product.data('productOptions'),
			optionIds;
		
		// if the product has no product options or the id doesn't exist, bail
		if (!$product.length || !productOptions || !productOptions[productOptionId]) {
			return false;
		}
		
		optionIds = productOptions[productOptionId].option_ids;
		
		// manually set each option select/swatch to the correct option value
		$('.si-options', $product).each(function() {
			var optionGroupId = $(this).parent('.si-option-group').data('id');
			
			SI.product._selectOption($product, $(this), optionIds[optionGroupId]);
		});
		
		SI.product._updateOptions($product);
		
		return true;
	},
	
	/**
	 * Set Option
	 * 
	 * For a product and option group, set the select/swatch to the
	 * given option specified by id.
	 * 
	 * @param product id or dom object of the product
	 * @param optionGroup id or dom object of the option group
	 * @param optionId id of the option to set the select/swatch to
	 */
	setOption: function(product, optionGroup, optionId) {
		var $product = SI.dom.getElement('product', product),
			$options = $('.si-options', SI.dom.getElement('option-group', optionGroup, $product)),
			isSelect = $options.is('select');
		
		// if the option doesn't exist for the option group, or it's disabled, bail
		if (!$product.length ||
			 isSelect && ($options.is(':disabled') || !$('option:not(:disabled)[value="' + optionId + '"]', $options).length) ||
			!isSelect && ($options.is('.si-disabled') || !$('a:not(.si-disabled):data(value=' + optionId + ')', $options).length)) {
			return false;
		}
		
		// select the option in the select/swatch
		SI.product._selectOption($product, $options, optionId);
		
		// update the valid options in all option selects
		SI.product._updateOptions($product);
		
		return true;
	},
	
	/**
	 * Reset All Option Groups
	 * 
	 * For a product, disable all option group selects/swatches but the first one.
	 * 
	 * @param product product id or DOM object
	 * @param init used internally
	 */
	resetOptions: function(product, init) {
		var $product = SI.dom.getElement('product', product);
		
		if (!$product.length) {
			return false;
		}
		
		if (!init) {
			// no product option is selected any longer
			$product.removeData('productOptionId');
		}
		
		$('.si-options', $product).each(function() {
			var first = +$(this).data('index') === 0;
			
			if (init) {
				// if an option id is selected, don't disable the select
				if ($(this).data('optionId')) {
					return false;
				}
			} else {
				$(this).removeData('optionId');
			}
			
			if ($(this).is('select')) {
				$(this)
					.val('')
					.prop('disabled', !first);
			} else {
				$(this)
					.toggleClass('si-disabled', !first)
					.find('a')
						.removeClass('si-current');
			}
		});
		
		// auto-select options
		SI.product._updateOptions($product);
		
		return true;
	},
	
	/**
	 * Add Product to Cart
	 * 
	 * Given the currently selected product option (if it applies), add the
	 * specified product to the cart. If the quantity is not specified, it will
	 * be taken from a quantity input if it exists.
	 * 
	 * @param product id or DOM element
	 * @param quantity optional quantity of the product to add
	 */
	addToCart: function(product, quantity) {
		var $product = SI.dom.getElement('product', product),
			$form = $('form.si-cart-add-form', $product),
			$quantity = $('input.si-quantity-input', $product),
			productId = $product.length ? $product.data('id') : +product,
			data = {
				product_option_id   : $product.data('productOptionId') || undefined,
				asset_id            : +$('.si-styles', $product).data('assetId') || undefined,
				denomination_id     : +$('select.si-denomination-select', $product).val(),
				quantity            : quantity || +$quantity.val()
			};
		
		// if the product isn't a valid DOM element or id, bail
		if (!productId || $product.length && (
			!SI.product._checkOptions($product) ||
			!SI.product._checkDenomination($product)
		)) {
			$quantity
				.val(quantity || '')
				.blur();
			return false;
		}
		
		$form.addClass('si-loading');
		
		return SI.cart.addProduct(productId, data)
			.success(function() {
				$form.removeClass('si-loading');
				
				$quantity
					.val('')
					.blur();
				
				SI.product.resetOptions($product);
			})
			.complete(function() {
				// trigger the event based on success of request
				$form.trigger('addtocart', [ this.isResolved() ]);
			});
	},
	
	/**
	 * Add Product to a Wishlist
	 * 
	 * Given the currently selected product option (if it applies), add the product to
	 * an existing wishlist, or a new wishlist depending on the type of the first argument.
	 * 
	 * @param wishlistNameOrId a string for the name of a new wishlist, or an integer
	 * for the id of an existing wishlist
	 * @param product id or DOM element
	 * @param quantity optional quantity of the product to add
	 */
	addToWishlist: function(wishlistNameOrId, product, quantity) {
		var $product = SI.dom.getElement('product', product),
			$form = $('form.si-wishlist-add-form', $product),
			$quantity = $('input.si-quantity-input', $form),
			productId = $product.length ? $product.data('id') : +product,
			productOptionId = $product.data('productOptionId') || null,
			promise;

		// find the quantity if it's not supplied
		quantity = quantity || +$quantity.val();

		// if there's no product option id set, not all options were selected (or the product has no product options)
		if (!productId || $product.length && !SI.product._checkOptions($product)) {
			$quantity
				.val(quantity || '')
				.blur();
			return false;
		}

		$form.addClass('si-loading');
		
		if (SI.object.is('number', wishlistNameOrId)) {
			promise = SI.wishlist.addProduct(wishlistNameOrId, productId, productOptionId, quantity || 1);
		} else {
			promise = SI.wishlist.createAndAddProduct(wishlistNameOrId, productId, productOptionId, quantity || 1);
		}

		return promise
			.success(function() {
				$form.removeClass('si-loading');
				
				$quantity
					.val('')
					.blur();
				
				SI.product.resetOptions($product);
			})
			.complete(function() {
				$form.trigger('addtowishlist', [ this.isResolved() ]);
			});
	},
	
	_selectOption: function($product, $options, optionId) {
		var index = $options.data('index') + 1,
			$current,
			$next,
			name,
			data;
		
		// set the value for this option select
		$options.data('optionId', optionId);
		
		if ($options.is('select')) {
			$options.val(optionId);
			name = $('option[value="' + optionId + '"]', $options).text();
		} else {
			$('a.si-current', $options).removeClass('si-current');
			$current = $('a:data(value=' + optionId + ')', $options);
			
			$current.addClass('si-current');
			
			// the name of the option is the text or the alt attribute of the swatch
			name = $current.text() ? $current.text() : $('img', $current).attr('alt');
		}
		
		// enable the next option select
		$next = $('.si-options:data(index=' + index + ')', $product).removeData('optionId');
		
		if ($next.is('select')) {
			$next
				.prop('disabled', false)
				.val('');
		} else {
			$next.removeClass('si-disabled');
			$('a', $next).removeClass('si-current');
		}
		
		// reset and disable remaining option selects
		while(($next = $('.si-options:data(index=' + (++index) + ')', $product)).length) {
			$next.removeData('optionId');
			
			if ($next.is('select')) {
				$next
					.prop('disabled', true)
					.val('');
			} else {
				$next.addClass('si-disabled');
				$('a', $next).removeClass('si-current');
			}
		}
		
		// gather option data for event trigger
		data = {
			id		: +optionId,
			name	: name,
			key		: SI.string.keyify(name)
		};
		
		$options.parent('.si-option-group').trigger('optionchange', [ data ]);
		
		return true;
	},
	
	// display errors and returns false if product option not set
	_checkOptions: function($product) {
		var productOptionId = $product.data('productOptionId') || null,
			productOptions = $product.data('productOptions'),
			$options,
			index = 0,
			errorMessage;
		
		// remove cart error messages associated with this product
		$('.si-cart-error', $product).parent().contextMessage('hide');
		
		// if there's no product option id set, not all options were selected (or the product has no product options)
		if (!productOptionId && !$.isEmptyObject(productOptions)) {
			// find the first unselected option select and show its error
			while(($options = $('.si-options:data(index=' + (index++) + ')', $product)).length) {
				if (!$options.data('optionId')) {
					errorMessage = !SI.data.lang.product_options_error ? 'error'
						: SI.data.lang.product_options_error.replace('{option_group}', $options.parent().data('name'));
					
					$options.parent().contextMessage({
						message		: errorMessage,
						typeClass	: 'si-cart-error',
						autoShow	: true
					});
					
					break;
				}
			}
			return false;
		}
		
		return true;
	},
	
	_checkDenomination: function($product) {
		var $select = $('select.si-denomination-select', $product),
			message = SI.data.lang.product_denomination_error || 'error';
		
		if ($select.length && !$select.val()) {
			$select.closest('form').contextMessage({
				message		: message,
				typeClass	: 'si-cart-error',
				autoShow	: true
			});
			
			return false;
		}

		return true;
	},
	
	// iterates over option selects for a particular product and disables/enables valid options given
	// the set of selected options
	_updateOptions: function($product) {
		var $options,
			$valid,
			value,
			productOptions = $.extend({}, $product.data('productOptions')),
			optionGroupId,
			optionId,
			isSelect,
			index = 0;
		
		while(($options = $('.si-options:data(index=' + (index++) + ')', $product)).length) {
			isSelect = $options.is('select');
			optionGroupId = $options.parent('.si-option-group').data('id');
			
			// remove options from the current list that aren't in the current set of product options
			$options.children().each(function() {
				var optionId = +$(this).data('value') || +$(this).val(),
					valid = false;
				
				$.each(productOptions, function(productOptionId, productOption) {
					if (optionId === +productOption.option_ids[optionGroupId]) {
						valid = true;
						return false;
					}
				});
				
				if (isSelect) {
					$(this).prop('disabled', !valid);
				} else {
					$(this).toggleClass('si-disabled', !valid);
				}
			});
			
			if (isSelect) {
				$valid = $('option', $options).not(':disabled');
				value = $options.val();
			} else {
				$valid = $('a:not(.si-disabled)', $options);
				value = $('a.si-current', $options).data('value');
			}
			
			// if only one option is valid, auto-select it
			if ($valid.length === 1 && !value) {
				SI.product._selectOption($product, $options, $valid.data('value') || $valid.val());
			}
			
			// this value is not in the initial markup, but added when an option is selected
			optionId = +$options.data('optionId');
			
			// can only pare down the product options if we know an option id
			if (optionId) {
				// narrow down list of product options
				$.each(productOptions, function(productOptionId, productOption) {
					if (+productOption.option_ids[optionGroupId] !== optionId) {
						delete productOptions[productOptionId];
					}
				});
			}
			else
			{
				// if no option is selected, skip the rest
				break;
			}
		}
		
		// check if enough options were selected to choose a product option
		if (!$product.data('autoUpdate')) {
			SI.product._updateProduct($product);
		}
	},
	
	// finds the chosen product option based on selected option ids
	_findProductOption: function($product) {
		var product = $product.data('product'),
			productOptions = $product.data('productOptions'),
			selectedOptionIds = {},
			selectedProductOptionId = false,
			errors = false;
		
		// if there are no product options, there's nothing to look for
		if ($.isEmptyObject(productOptions)) {
			return;
		}
		
		// gather all option ids or set an error if not chosen
		$('.si-options', $product).each(function() {
			var optionGroupId = $(this).parent('.si-option-group').data('id'),
				value = $(this).data('optionId');
			
			if (value) {
				selectedOptionIds[optionGroupId] = +value;
			} else {
				errors = true;
				return false;
			}
		});
		
		// if not every option id was set, return
		if (errors) {
			return false;
		}
		
		// find the matching product option id
		$.each(productOptions, function(productOptionId, productOption) {
			var match = true;
			
			$.each(productOption.option_ids, function(optionGroupId, optionId) {
				if (+selectedOptionIds[optionGroupId] !== +optionId) {
					match = false;
					return false;
				}
			});
				
			if (match) {
				selectedProductOptionId = +productOptionId;
				return false;
			}
		});
		
		return selectedProductOptionId;
	},
	
	// updates page elements with product option specific data
	_updateProduct: function($product, productOptionId) {
		var filter = function() {
				// make sure the element isn't in another product
				return $(this).closest('.si-product').get(0) == $product.get(0);
			},
			$price = $('span.si-pricing', $product).filter(filter),
			$stockWrapper = $('span.si-stock-wrapper', $product).filter(filter),
			$stock = $('span.si-stock', $stockWrapper),
			$stockMessage = $stockWrapper.next('.si-out-of-stock', $product),
			product = $.extend({}, $product.data('product'), { id: $product.data('id') }),
			productOptions = $product.data('productOptions'),
			previousProductOptionId = $product.data('productOptionId') || false,
			productOption,
			stock,
			price;
		
		productOptionId = productOptionId || SI.product._findProductOption($product);
		
		// update product option related elements
		if (productOptionId) {
			productOption = $.extend({}, productOptions[productOptionId]);
			productOption.id = productOptionId;
			stock = +productOption.stock;
			price = SI.cart.buildPrice(productOption);
			
			// set the selected product option id to be used by the add to cart handler
			$product.data('productOptionId', productOptionId);
		}
		// if no product option id was found, reset using product's data
		else {
			stock = +product.stock;
			price = SI.cart.buildPrice(product);
			
			$product.removeData('productOptionId');
		}
		
		// update product data
		$stock.text(stock);
		$price.html(price);
		
		// show hide the notification form
		$('form.si-stock-notification-form', $product).toggle(!stock);
		
		// update social buy link
		SI.product._updateSocialBuyLink($product);
		
		// show the out of stock message if necessary
		if ($stockMessage.length) {
			$stockWrapper.toggle(!!stock);
			$stockMessage.toggle(!stock);
		}
		
		// TODO: stop-gap (see #2499)
		product.price_range = {
			min: product.retail_min_price,
			max: product.retail_max_price
		};
		delete product.retail_min_price;
		delete product.retail_max_price;
		
		product.sale_price_range = {
			min: product.min_price,
			max: product.max_price
		};
		delete product.min_price;
		delete product.max_price;
		
		if (previousProductOptionId !== productOptionId) {
			$product.trigger('productchange', [ product, productOption ]);
		}
	},
	
	// update url to social buy create link
	_updateSocialBuyLink: function($product) {
		var data = $product.data(),
			url = '/' + SI.data.routes.socialbuy + '/create/' + data.id,
			quantity = +$('input.si-quantity-input', $product).val(),
			assetId = +$('.si-styles', $product).data('assetId'),
			denominationId = +$('select.si-denomination-select', $product).val(),
			params = {};
		
		if (data.productOptionId) {
			params.product_option_id = data.productOptionId;
		}
		
		if (assetId) {
			params.asset_id = assetId;
		}

		if (denominationId) {
			params.denomination_id = denominationId;
		}
		
		if (quantity) {
			params.quantity = quantity;
		}
		
		if (!$.isEmptyObject(params)) {
			url += '?' + $.param(params);
		}
		
		$('a.si-social-buy-create-link', $product).attr('href', url);
	}
};

/*
 * ShopIgniter checkout
 *
 * Depends:
 */
SI.review = {
	starClasses: [
		'',
		'si-half-star',
		'si-one-star',
		'si-one-half-stars',
		'si-two-stars',
		'si-two-half-stars',
		'si-three-stars',
		'si-three-half-stars',
		'si-four-stars',
		'si-four-half-stars',
		'si-five-stars'
	],
	
	_init: function() {
		$(document)
			.delegate('.si-review-edit-form', 'success.si', this.handlers.editReviewSuccess)
			.delegate('.si-review-create-form', 'success.si', this.handlers.createReviewSuccess)
			.delegate('a.si-review-helpful-button', 'click.si', this.handlers.helpfulButton);
	},

	handlers: {
		createReviewSuccess: function(event, results) {
			$('.si-review-total').html(results.totals.review_count);
			
			// clear form
			$(':input').not('[type="submit"]').not('[type="hidden"]').val('');
			
			$('.SI-review-partial-target')
				.prepend(results.output)
				.trigger('render', [ 0, 1 ]);
		},
		
		editReviewSuccess: function(event, results) {
			var $review = $(this).parents('.si-review'),
				$rating = $('.si-rating-stars', $review),
				oldScore = +$('span', $rating).text() * 2;
			
			$('span', $rating).text(results.review.stars);
			$rating
				.removeClass(SI.review.starClasses[oldScore])
				.addClass(SI.review.starClasses[+results.review.score]);
			
			// update review
			$('.si-review-title', $review).html(results.review.headline);
			$('.si-review-pros', $review).html(results.review.tags_likes);
			$('.si-review-cons', $review).html(results.review.tags_dislikes);
			$('.si-review-body', $review).html(results.review.review_text);
			$('.si-review-author', $review).html(results.review.u_first + ' ' + results.review.u_last);
		},
		
		helpfulButton: function() {
			var $review = $(this).parents('.si-review'),
				reviewId = $review.data('reviewId'),
				helpfulVote = $(this).data('helpfulVote');
			
			SI.ajax.post('reviews/vote/' + reviewId, { vote: helpfulVote }, true)
				.success(function(results) {
					$('.si-vote-helpful', $review).html(results.review.count_helpful);
					$('.si-vote-total', $review).html((+results.review.count_unhelpful) + (+results.review.count_helpful));
				});
		}
	}
};

/*
 * ShopIgniter share
 *
 * Depends:
 * 
 */
SI.share = {
	_init: function() {
		$(document).delegate('a.si-share-link', 'click.si', this.handlers.shareLink);
	},
	
	handlers: {
		shareLink: function() {
			var data = $(this).data();
			
			switch(data.method) {
				case 'facebook':
					SI.facebook.share(data.type, data.id, data.url);
					break;
				
				case 'twitter':
					SI.twitter.share(data.type, data.id, data.url);
					break;
				
				case 'email':
					SI.share.email(data.type, data.id, data.url);
					break;
			}
			
			return false;
		}
	},
	
	email: function(type, refId, shareUrl) {
		SI.ajax.post('share/' + type + '/' + refId + '/email', { url: shareUrl || '' })
			.success(function(results) {
				$(document).ajaxDialog({
					url				: '/' + SI.data.routes.store + '/share/email/' + type + '?' + $.param(results),
					dialogID		: 'si-share-email-dialog',
					autoOpen		: true,
					open			: function(event, ui) {
						$('form.si-share-email-form', ui.dialog).live('success.si', function() {
							$(document).ajaxDialog('close');
						});
					}
				});
			});
	}
};
 
/*
 * ShopIgniter sorting
 *
 * Depends:
 *  SI.stateManager
 *  SI.browsingTools
 */
SI.sort = {
	_init: function() {
		SI.browsingTools.subscribe(this);
		
		$(document).delegate('select.si-sort-select', 'change.si', this.handlers.sortSelect);
	},
	
	handlers: {
		sortSelect: function(event) {
			if (SI.paginate) {
				SI.paginate.setPage(1);
			}
			
			SI.stateManager.updateParams({ sort_key: $(this).val() }, true);
		}
	},
	
	getParams: function() {
		return [ 'sort_key' ];
	},
	
	update: function(params, apply) {
		$('select.si-sort-select').val(params.sort_key);
		
		return params;
	},
	
	postUpdate: function(data) {
		if (data.filter) {
			SI.browsingTools.getPartialTarget().trigger('sortchange', [ data.filter.sort_key ]);
		}
	}
};

/*
 * ShopIgniter state manager
 *
 * Depends:
 *  jQuery hashchange plugin
 *  jQuery deparam plugin
 */
SI.stateManager = {
	urlPattern: /^([^#]*)#?\/?(.*)$/,
	
	subscribers: [],
	
	pending: null,
	
	current: '',
	
	_init: function() {
		// add global handler that looks for changes in the url hash fragment
		$(window).bind('hashchange', function(event) {
			SI.stateManager.checkHash();
		});
		
		// initialize the pending changes to the current state
		this.pending = $.deparam(this.getHash(), true, true);
	},
	
	_ready: function() {
		// trigger a hashchange event when all components are finished loading to initialize state
		$(window).trigger('hashchange');
	},
	
	// save a callback to be invoked upon state change, and any params that the callback cares about
	subscribe: function(callback, params) {
		SI.stateManager.subscribers.push({ params: params, callback: callback, state: {} });
	},

	// return current state params
    getParams: function() {
        return $.deparam(SI.stateManager.current, true, true);
    },
	
	forceUpdate: function() {
		this.checkHash(true);
	},
	
	// update the pending state by adding new params or updating existing params
	updateParams: function(params, commit) {
		// add or update any changes to params
		$.extend(true, this.pending, params);
		
		if (commit) {
			this.commit();
		}
		
		// make this call chainable
		return this;
	},
	
	// update the pending state by removing existing params
	removeParams: function(params, commit) {
		SI.object.removeKeys(this.pending, params);
		
		if (commit) {
			this.commit();
		}
		
		// make this call chainable
		return this;
	},
	
	commit: function() {
		// update the hash so to trigger a hashchange event
		window.location.hash = '/' + $.param(this.pending);
		
		// bypass the hashchange event since it has to wait on a timer
		//SI.stateManager.checkHash();
		
		// make this call chainable
		return this;
	},
	
	// compare the current state (stored in the url hash fragment) for changes and notify subscribers
	checkHash: function(forceUpdate) {
		var fragment = SI.stateManager.getHash(),
			state;
		
		// the internal state is the hash fragment, so if they don't differ, nothing changed
		if (!forceUpdate && SI.stateManager.current === fragment) {
			return;
		}
		
		// save the fragment as the new state
		SI.stateManager.current = fragment;
		
		// break apart the fragment to examine individual component states
		state = $.deparam(fragment, true, true);
		
		// notify subscribers that the state changed
		$.each(SI.stateManager.subscribers, function(i, subscriber) {
			var params = {};
			
			// cherry-pick the parameters that this callback cares about
			// note that the order this is done in is key to comparing the state
			$.each(state, function(param, value) {
				if ($.inArray(param, subscriber.params) !== -1) {
					params[param] = value;
				}
			});
			
			// only invoke the callback if the state has actually changed
			if (forceUpdate || !SI.object.compare(params, subscriber.state)) {
				subscriber.state = params;
				subscriber.callback.call(SI, params);
			}
		});
	},
	
	getHash: function() {
		return window.location.href.replace(SI.stateManager.urlPattern, '$2');
	}
};

/*
 * ShopIgniter string helper functions
 *
 * Depends:
 * 
 */
SI.string = {
	// TODO: add cases as needed
	pluralize: function(str) {
		var lastChar = str.charAt(str.length - 1);
		
		// crude check to make sure it isn't already plural
		if (lastChar === 's') {
			return str;
		}
		
		if (lastChar === 'y') {
			return str.slice(0, -1) + 'ies';
		}
		
		return str + 's';
	},
	
	// create nested objects from a string like 'foo[1][2]' and assign a value
	// e.g. { "foo": { "1": { "2": val } } }
	parseObj: function(str, val) {
		var i,
			obj,
			rtn = {},
			topKey = str.replace(/\[.*\]$/, ''),
			matches = str.match(/\[(\w+)\]/);
		
		if (matches && matches.length > 1) {
			for (i = matches.length - 1; i > 0; i--) {
				// if no value was specified, set it to the last key
				if (val === undefined) {
					val = matches[i--];
					break;
				}
				
				obj = {};
				obj[matches[i]] = val;
				val = obj;
			}
		}
		
		rtn[topKey] = val;
		return rtn;
	},
	
	// transforms the string into all lowercase and dashes
	keyify: function(str) {
		return str.toLowerCase().replace(/[^a-z0-9\-\.]/, '');
	}
};

/*
 * ShopIgniter twitter integration
 *
 * Depends:
 *   SI.popup
 */
SI.twitter = {
	share: function(type, refId, shareUrl) {
		// the post has to be synchronous to make the browser treat the callback as a user initiated action
		SI.ajax.post({ async: false }, 'share/' + type + '/' + refId + '/twitter', { url: shareUrl || '' }, true)
			.success(function(results) {
				var url = 'http://twitter.com/share';
				
				if (results.url || results.message) {
					url += '?';
					
					if (results.url) {
						url += 'url=' + encodeURIComponent(results.url) + '&';
					}
					
					if (results.message) {
						url += 'text=' + encodeURIComponent(results.message);
					}
				}
				
				SI.window.popup(url, 550, 450);
			});
	}
};

/*
 * ShopIgniter UI convenience
 *
 * Depends:
 *  image
 */
SI.ui = {
	_ready: function(context) {
		// add force login handler
		$('.SI-force-login *', context).click(this._handlers.forceLogin);
		
		// force selection of a product option
		$('.SI-force-valid-options *', context).click(this._handlers.forceValidOptions);
		
		// context messages
		$('.SI-context-message', context).contextMessage({
			messageAttr: 'error-message'
		});
		
		// dialogs
		$('.SI-context-dialog', context).contextDialog();
		$('.SI-dialog-light, .SI-ajax-dialog', context).ajaxDialog();
		
		// text input hints
		$('.si-layout-form input:text, input.SI-hint:text', context).hint();
		
		// toggles
		$('.SI-toggle', context).toggler();
		
		// swapping
		//$('.SI-swap', context).swap({
		//	replacementText: SI.data.lang.swap_replace
		//});
		
		//YES
		$('.SI-awesome', context).wrap('<marquee>');
		
		// progress bars (not a capital SI- class because it uses a data- attribute)
		$('.si-progress-bar').each(function() {
			$(this).progressbar({ value: +$(this).data('value') });
		});
		
		// fake password field for input hints
		$('.si-password', context).each(function() {
			// the imposter will always be the next item
			var $imposter = $(this).next('.si-password-imposter');
			
			// attach swapping functionality
			$(this).blur(SI.ui._handlers.passwordSwapOut);
			$imposter.focus(SI.ui._handlers.passwordSwapIn);
			
			// perform the initial swap if the real field wasn't auto-populated
			SI.ui._handlers.passwordSwapOut.call(this);
		});
	},
	
	_handlers: {
		forceLogin: function(event) {
			if (!SI.data.logged_in) {
				$(this).parents('.SI-force-login').contextMessage({
					message		: SI.data.lang.force_login,
					autoShow	: true
				});
				
				// prevent other handlers bound to this element from running
				event.stopImmediatePropagation();
				return false;
			}
		},
		
		// TODO: does this belong in the cart component?
		forceValidOptions: function(event) {
			var $product = $(this).parents('.si-product');
			
			if (!SI.product._checkOptions($product) || !SI.product._checkDenomination($product)) {
				event.stopImmediatePropagation();
				return false;
			}
		},
		
		passwordSwapIn: function() {
			$(this)
					.hide()
					.attr('disabled', true)
					.prev('.si-password')
					.show()
					.focus();
		},
		
		passwordSwapOut: function() {
			if (!$(this).val()) {
				$(this)
						.hide()
						.next('.si-password-imposter')
						.removeAttr('disabled')
						.show()
						.blur();
			}
		}
	}
};

/*
 * ShopIgniter view switcher
 *
 * Depends:
 *  SI.stateManager
 *  SI.browsingTools
 */
SI.view = {
	type: '',
	
	_init: function() {
		this.type = SI.data.view_type || '';
		
		SI.browsingTools.subscribe(this);
		
		$(document).delegate('a.si-view-link', 'click.si', this.handlers.viewToggle);
	},
	
	handlers: {
		viewToggle: function() {
			if (SI.paginate) {
				SI.paginate.setPage(1);
			}
			
			SI.stateManager
				.updateParams({ 'view_type' : $(this).data('viewType') })
				.commit();
			
			return false;
		}
	},
	
	getParams: function() {
		return [ 'view_type' ];
	},
	
	update: function(params) {
		return params;
	},
	
	postUpdate: function(data) {
		if (data.view_type) {
			$('a.si-view-link.si-active').removeClass('si-active');
			$('a.si-view-link:data(viewType=' + data.view_type + ')').addClass('si-active');
			
			SI.browsingTools.getPartialTarget().trigger('viewchange', [ data.view_type, SI.view.type ]);
			
			SI.view.type = data.view_type;
		}
	}
};

/*
 * ShopIgniter windows handling
 *
 * Depends:
 */
SI.window = {
	popup: function(url, width, height) {
		var top = (screen.height - height) / 2,
			left = (screen.width - width) / 2,
			popup;
		
		top = top < 0 ? 0 : top;
		left = left < 0 ? 0 : left;
		
		popup = window.open(url, '_blank', 'height=' + height + ', width=' + width + ', top=' + top + ', left=' + left);
		
		popup.window.focus();
		
		return popup;
	}
};

/*
 * ShopIgniter wishlist
 *
 * Depends:
 *  SI.message
 *
 */
SI.wishlist = {
    count: 0,

    _init: function() {
        this.count = SI.data.wishlist_count;
    },

    getCount: function() {
        return SI.wishlist.count;
    },

	show: function(wishlistId) {
		wishlistId = wishlistId || '';
		
		SI.ajax.get('wishlists/index/' + wishlistId + '?structured=true&with_template=false')
			.success(function(results) {
				$('#si-wishlist-name').text(results.wishlist.name);
				$('.si-select-wishlist select').val(results.wishlist.id);
                $('.si-no-results').hide();
				SI.wishlist.showItems(results.output);
				$('#si-wishlist-name-form input[type=text]').val(results.wishlist.name);
				$('.si-email-share').data('shareId', results.wishlist.id);
				
				$('#si-wishlist-items .si-product').each(function() {
					SI.product.resetOptions($(this));
				});

				if (!$('.si-product').length) {
					$('.si-no-results').show();
				} else {
					$('#si-wishlist-items').trigger('render');
				}
			});
	},
	
	showItems: function(items) {
		$('#si-wishlist-items').html(items);
		$('.context-dialog').contextDialog();
	},

	checkCategoryItems: function(categoryId) {
		if ($('.si-move-wishlist-item input.si-button:data(itemCategoryId=' + categoryId + ')').length === 0) {
			$('.si-wishlist-item-category:data(categoryId=' + categoryId +')').remove();
		}
	},
	
	create: function(name) {
		return SI.ajax.post('wishlists/add', { name: name })
			.success(function() {
                SI.wishlist.count++;
				$('.si-wishlist-count').text(SI.wishlist.count);
			});
	},

	createAndAddProduct: function(wishlistName, productId, productOptionId, quantity) {
		return SI.ajax.post('wishlists/add_wishlist_and_item', {
			product_id          : productId,
			product_option_id   : productOptionId || null,
			product_quantity    : +quantity,
			name                : wishlistName
		})
			.success(function(results) {
                SI.wishlist.count++;
				$('.si-wishlist-count').text(SI.wishlist.count);
				$('select.si-wishlist-id').append($('<option/>', {
					value   : results.wishlist.id,
					text    : results.wishlist.name
				}));
			});
	},

	remove: function(wishlistId) {
        return SI.ajax.post('wishlists/remove/' + wishlistId + '/')
            .success(function(){
                SI.wishlist.count--;
            });
	},

	update: function(wishlistId, wishlistData) {
		return SI.ajax.post('wishlists/edit/' + wishlistId + '/', wishlistData);
	},

	saveShared: function(wishlistData) {
        return SI.ajax.post('wishlists/save/', wishlistData)
            .success(function(){
                SI.wishlist.count++;
            });

	},

	addProduct: function(wishlistId, productId, productOptionId, quantity) {
		if (!SI.object.isInt(+wishlistId)) {
			return false;
		}
		
		return SI.ajax.post('wishlists/add_item/' + wishlistId, {
			product_id          : productId,
			product_option_id   : productOptionId || null,
			product_quantity    : +quantity
		});
	},

	removeItem: function(itemId) {
		if (!SI.object.isInt(+itemId)) {
			return false;
		}

		return SI.ajax.post('wishlists/remove_item/' + itemId);
	},
	
	updateItem: function(itemId, wishlistItemData) {
		return SI.ajax.post('wishlists/edit_item/' + itemId + '/', wishlistItemData);
	},
	
	copyItem: function(itemId, wishlistItemData) {
		return SI.ajax.post('wishlists/copy_item/' + itemId + '/', wishlistItemData);
	}
};

// console.log errors begone!
if (!window.console) {
	window.console = {
		log: function() {},
		info: function() {}
	};
}

// put SI in the global namespace
window.SI = SI;

}(jQuery, window));

