var Domify_TEMPLATE_SETTING = {
		// !!! NOTE: since we directly manipulate textNodes content, we can safely use {{ }} content without escaping.
		interpolate: /\{\{(.+?)\}\}/g,
		escape: /\{\<(.+?)\>\}/g,
		evaluate: /\{\%([\s\S]+?)\%\}/g
};

var IE_STYLE_NAME = "_style";
var BLANK_IMAGE_URL = window.STATIC_URL + "images/blank.gif";

Domify = function(root) {
	var TEMPLATE_SETTING = Domify_TEMPLATE_SETTING;
	var root = root;

	var attrs = [];
	var attrs_ = [];

	var ieStyles = [];
	var ieStyleNodes= [];
	var ieStyles_ = [];

	var nodes = [];
	var nodes_ = [];

	var imageNodes = [];
	var divBgNodes = [];
	var BLANK_IMAGE_BG_URL = "url(" + BLANK_IMAGE_URL + ")";

	function parse(node)
	{
		if(!node || !node.nodeType)
			return;

		var attributes = node.attributes;
		if(attributes)
		{
			// IE doesn't allow unrecognized strings in "style" attribute. It will strip off all {{...}} from styles. So we change "style=" to "_style=" for IE before adding node to DOM (refer to the $masterNode logic in this file). And we need to properly handle them here.
			if(isIE())
			{
				var styleValue = null;
				for(var i=0; i<attributes.length; i++)
				{
					var attr = attributes[i];
					if(!attr) continue;
					if(hasTemplate(attr.value))
					{
						if(attr.name == IE_STYLE_NAME)
						{
							addNonRepeat(ieStyles, ieStyles_, attr, attr.value);
							ieStyleNodes.push(node);
						}
						else
						{
							addNonRepeat(attrs, attrs_, attr, attr.value);
						}
					}
					// Need to reset non-templated "_style=" replacements.
					else if(attr.name == IE_STYLE_NAME)
					{
						styleValue = attr.value;
					}
				}
				if(styleValue)
					node.setAttribute('style', styleValue);
			}
			else
			{
				for(var i=0; i<attributes.length; i++)
				{
					var attr = attributes[i];
					if(!attr) continue;
					if(hasTemplate(attr.value))
						addNonRepeat(attrs, attrs_, attr, attr.value);
				}
			}
		}

		if(node.nodeName == 'IMG')
		{
			if(hasTemplate(decodeURIComponent(node.src)) && imageNodes.indexOf(node) < 0)
			{
				imageNodes.push(node);
			}
		}
		if(node.nodeName == 'DIV' && node.className && node.className.indexOf('domify-bg-img') >= 0 && node.style)// && node.style.backgroundImage)
		{
			//if(hasTemplate(decodeURIComponent(node.style.backgroundImage)) && divBgNodes.indexOf(node) < 0)
			if(divBgNodes.indexOf(node) < 0)
			{
				divBgNodes.push(node);
			}
		}

		if(node.nodeType == Node.TEXT_NODE)
		{
			if(hasTemplate(node.textContent))
			{
				addNonRepeat(nodes, nodes_, node, node.textContent);
			}
		}
		else
		{
			var c = node.childNodes;
			if(c)
			{
				for(var i=0; i<c.length; i++)
					parse(c[i]);
			}
		}
	}

	function addNonRepeat(all, all_, one, one_value)
	{
		if(all.indexOf(one) >= 0)
			return;

		all.push(one);
		all_.push(_.template(one_value, TEMPLATE_SETTING));
	}

	function hasTemplate(s)
	{
		if(!s) return false;
		return s.match(/\{[\{\%\<]/g) != null;
	}

	parse(root);

	function render(data)
	{
		for(var i=0; i<nodes.length; i++)
		{
			var node = nodes[i];
			var newContent = nodes_[i](data);
			if(node.textContent != newContent)
				node.textContent = newContent;
		}
		for(var i=0; i<attrs.length; i++)
		{
			var attr = attrs[i];
			var newValue = attrs_[i](data);
			if(attr.value != newValue)
				attr.value = newValue;
		}
		if(isIE())
		{
			for(var i=0; i<ieStyles.length; i++)
			{
				var style = ieStyles[i];
				var node = ieStyleNodes[i];
				var newValue = ieStyles_[i](data);
				if(node.getAttribute('style') != newValue)
					node.setAttribute('style', newValue);
			}
		}
	}

	function resetImages()
	{
		for(var i=0; i<divBgNodes.length; i++)
		{
			var node = divBgNodes[i];
			node.style.backgroundImage = BLANK_IMAGE_BG_URL;
		}
		for(var i=0; i<imageNodes.length; i++)
			imageNodes[i].src = BLANK_IMAGE_URL;
	}

	return {
		render: render,
		resetImages: resetImages,
	};
};


var EVENT_ITEM_CARD_TAPPED = 'BATCH-ITEM-CARD-TAPPED';
BatchItem = function() {
	
	var loadingMoreAsync = false;
	var isLoadMore = true;
	var card_type = 'simple';
	var show_shop = false;
	var shop = EMPTY_DICT;
	var apiUrl = location.pathname + (location.pathname.endsWith('/') ? "" : "/") + 'api/' + location.search;
	var apiMethod = 'GET';	// 'GET' or 'POST'
	var apiPostData = {};	// used when apiMethod is 'POST'
	var skipBridgeCache = false;
	var offset = 0;
	var batch_max = 20; //20 for faster loading for first page. Subsequence will be larger.
	var display_batch = 16; //Resulting pages should be more than pages to trigger load more.
	var pages_to_trigger_load_more = 3; //This should be less than display_batch's resulting pages.
	var loadingFromBridge = false;
	var loadingFromServer = false;
	var root = null;
	var skipCheckAdult = false;
	var itemRoot = "item-list";
	var onLoadCallback;
	var onFailCallback;
	// var initialLoadingTimer = null;
	// Configurable to false when item-list is embedded inside a page (for example shop page), in which case the initial
	// loading text would overlap with existing page content as it has "fixed" position to avoid DOM manipulation slowness.
	// var showInitialLoading = true;
	var needDropWord = false;
	//!!!Note: To be initialized in loadData()!!! Following values could be [] here.
	var $window = $(window);
	var $document = $(document);
	var $loadingText = $('.loading-text');
	// var $initialLoadingIndicator = $('.initial-loading-indicator');
	var $nomore = $('.nomore');
	var emptyHolderClassName = '.empty_holder';
	var $emptyHolder = null;
	var cardTemplate = null;
	var lastScrollMs = 0;
	var isActive = true;
	var skipAnimation = false;
	var tapManager = new GTapManager();
	var keepAbnormalItems = false;
	var excludeItemIds = [];
	var isInBoostPage = false;
	var retryOnNetworkError = true;
	var onLoadEndCallback = null;
	var CARD_HEIGHT = 0;
	var CARD_WIDTH = 0;
	var ROOT_EMPTY_HEIGHT = null;
	var ROOT_Y = null;
	var ROOT_Y_OFFSET = 0; //Temporary offset during gtab tab switching.
	var ROOT_WIDTH = null;
	var ROOT_BASIC_HEIGHT = null;
	var ROOT_HEIGHT = null;
	var WINDOW_HEIGHT = 0;
	var allItems = [];
	var freeNodes = [];
	var recycleNodes = true;
	var isAdultSafe = false;
	var boostManager = null;
	var hasShopPart = false;
	var CARD_COUNT_PER_SCREEN = 0;
	var initialBatch = null;
	var skipPrepareRoot = false;
	var funcOnTapItemCard = null;
	var funcOnTapShop = null;
	var funcOnHeightChange = null;
	var searchKeyword = null;
	var sortType = null;
	var funcGetShopUserName = null;
	var hideOfficialShopLabel = false;
	var onAdsItemAppear = null;
	var offsetStr = 'newest';
	var parseResFunc = null;
	var offsetObj = null;

	function setOptions(options){
		if (options) {
			if ("card_type" in options) { card_type = options.card_type; show_shop = (card_type!='simple');}
			if ("isLoadMore" in options) { isLoadMore = options.isLoadMore; if(!isLoadMore){$loadingText.hide();}}
			if ("preloaded" in options) { preloaded = options.preloaded;}
			if ("apiUrl" in options) { apiUrl = options.apiUrl;}
			if ("apiMethod" in options) { apiMethod = options.apiMethod;}
			if ("apiPostData" in options) { apiPostData = options.apiPostData;}
			if ("skipBridgeCache" in options) { skipBridgeCache = options.skipBridgeCache;}
			// if ("showInitialLoading" in options) {showInitialLoading = options.showInitialLoading;}
			if ("root" in options) { itemRoot = options.root; /*Reinit roots if already has one*/ if(root) initRoots();}
			if ("onLoadCallback" in options) { onLoadCallback = options.onLoadCallback;}
			if ("onFailCallback" in options) { onFailCallback = options.onFailCallback;}
			if ("skipAnimation" in options) {skipAnimation = options.skipAnimation;}
			if ("keepAbnormalItems" in options) {keepAbnormalItems = options.keepAbnormalItems;}
			if ("needDropWord" in options) { needDropWord = options.needDropWord; }
			if ('excludeItemIds' in options) { excludeItemIds = options.excludeItemIds; }
			if ("initialBatchLimit" in options) {batch_max = options.initialBatchLimit;}
			if ("initialOffset" in options) {offset = options.initialOffset;}
			if ("displayBatchOverride" in options) {display_batch = options.displayBatchOverride;}
			if ("isInBoostPage" in options) {isInBoostPage = options.isInBoostPage;}
			if ("skipCheckAdult" in options) {skipCheckAdult = options.skipCheckAdult;}
			if ("retryOnNetworkError" in options) {retryOnNetworkError = options.retryOnNetworkError;}
			if ("recycleNodes" in options) {recycleNodes = options.recycleNodes;}
			if ("ROOT_Y" in options) {ROOT_Y = options.ROOT_Y;}
			if ("ROOT_Y_OFFSET" in options) {ROOT_Y_OFFSET = options.ROOT_Y_OFFSET;}
			if ("ROOT_WIDTH" in options) {ROOT_WIDTH = options.ROOT_WIDTH;}
			if ("ROOT_HEIGHT" in options) {ROOT_HEIGHT = options.ROOT_HEIGHT;}
			if ("ROOT_BASIC_HEIGHT" in options) {ROOT_BASIC_HEIGHT = options.ROOT_BASIC_HEIGHT;}
			if ("onLoadEndCallback" in options) {onLoadEndCallback = options.onLoadEndCallback;}
			if ("cardTemplate" in options) {cardTemplate = options.cardTemplate;}
			if ("boostManager" in options) {boostManager = options.boostManager;}
			if ("emptyHolderClassName" in options) {emptyHolderClassName = options.emptyHolderClassName; $emptyHolder = $(emptyHolderClassName);}
			if ("initialBatch" in options) {initialBatch = options.initialBatch;}
			if ("skipPrepareRoot" in options) {skipPrepareRoot = options.skipPrepareRoot;}
			if ("funcOnTapItemCard" in options) {funcOnTapItemCard = options.funcOnTapItemCard;}
			if ("funcOnTapShop" in options) {funcOnTapShop = options.funcOnTapShop;}
			if ("funcOnHeightChange" in options) {funcOnHeightChange = options.funcOnHeightChange;}
			if ("WINDOW_HEIGHT" in options) {WINDOW_HEIGHT = options.WINDOW_HEIGHT;}
			if ("searchKeyword" in options) {searchKeyword = options.searchKeyword;}
			if ("sortType" in options) {sortType = options.sortType;}
			if ("funcGetShopUserName" in options) {funcGetShopUserName = options.funcGetShopUserName;}
			if ("hideOfficialShopLabel" in options) {hideOfficialShopLabel = options.hideOfficialShopLabel;}
			if ("onAdsItemAppear" in options) {onAdsItemAppear = options.onAdsItemAppear;}
			if ("offsetStr" in options) { offsetStr = options.offsetStr;}
			if ("parseResFunc" in options) { parseResFunc = options.parseResFunc; }
			if ("offsetObj" in options) { offsetObj = options.offsetObj;}
		}
		if (window.batch_item_pagination_number) {
			offset = batch_max * (batch_item_pagination_number - 1);
		}
	}

	function onDismissAdult(e)
	{
		isAdultSafe = true;
		for(var i=0; i<allItems.length; i++)
		{
			var item = allItems[i];
			if(item.__node__)
				renderItem(item);
		}
	}

	function fadeOutCard(item)
	{
		if(!item || !item.__node__) return;
		var $node = item.__node__;
		// Tricky: After we fade out the Card, it is still there for reuse.
		// hideNode will add visibility:hidden to the node, however, the children with visibility:visible will still show.
		// so I set the root div opacity:0 with item.dismissed to hide all children.
		// Hide the mask when start to fade out and remove its owner_attr to avoid re-render.
		var $ownerMask = $node.find('.mask-for-owner');
		item.owner_attr = EMPTY_DICT;
		item.item_status_for_owner = 'owner_normal';
		$ownerMask.find('.button').hide(); // This is for better UX, this button is ugly to fadeout.
		$ownerMask.css('visibility', 'hidden');
		$node.fadeOut(function(){
			var idx = allItems.indexOf(item);
			if(idx >= 0)
				allItems.splice(idx, 1);
			resetNode(item);
			$node.css('display', ''); // !!! Tricky: This has been set to "none" by fadeOut. We need to set it back in order for DOM reuse.
			updateRootHeight(true);
			updateViewPort(true, true);
			$node.find('.margin-holder').css('opacity', 0);
			item.dismissed = true;
			$ownerMask.find('.button').show();
		});
	}

	function owner_callback(e){
		var t = $(e.currentTarget);
		
		e.preventDefault();
		e.stopPropagation();

		if(t.hasClass('ic_close_white'))
			return owner_new_mark_done_callback(e);
		if(t.hasClass('owner_new'))
			return owner_new_share_callback(e);
		if(t.hasClass('owner_deleted'))
			return owner_deleted_dismiss_callback(e);
		if(t.hasClass('owner_reviewing') || t.hasClass('owner_banned') || t.hasClass('action--update-product'))
			return owner_update_product_callback(e);
		if(t.hasClass('owner_offensive_hide'))
			return owner_offensive_hide_callback(e)
		if(t.hasClass('owner_bank_new'))
			return owner_bank_new_callback(e);
		if(t.hasClass('owner_new_unlisted'))
			return owner_new_share_callback(e);
	}


	function owner_offensive_hide_callback(e) {
		e.preventDefault();
		msg = window.LOCALE == 'MY' ? i18n.t('msg_product_offensive_hide_my') : i18n.t('msg_product_offensive_hide');
		bridgeCallHandler('showPopUp', {
			popUp: {message: msg, okText: i18n.t('label_ok')}
		});
	}

	function owner_bank_new_callback(e) {
		e.preventDefault();
		bridgeCallHandler('showMissingBankScamPopup', {
			urlQueryString: 'return_page=me',
		});
	}

	function owner_new_mark_done_callback(e){
		var t = $(e.currentTarget);
		var item = itemOfNode(t);
		if(!item) return;

		var p = t.parents('.mask-for-owner');
		var shopid = item.shopid;
		var itemid = item.itemid;

		// Note: we need to do the visual first without waiting for ajax at all.
		p.fadeOut(function(){
			p.css('display', ''); // This has been set to "none" by fadeOut. We need to set it back.
			item.owner_attr = EMPTY_DICT;
			item.item_status_for_owner = 'owner_normal';
			renderItem(item);
		});

		$.post("/shop/"+shopid+"/item/"+itemid+"/mark_done/",{
			"csrfmiddlewaretoken":csrf
		},function(e){
			if(e==0){
				//p.parents('.margin-holder-inner').find('.item-href').attr('href', '/item/#shopid='+shopid+'&itemid='+itemid);
				//bridge = window.WebViewJavascriptBridge;
				//if(bridge){
				//	handleNavigateItem(bridge);
				//}
			}
		}).fail(function(e){
			// alert_message(i18n.t('msg_server_error'));
			// loading = false;
		});
	}
	function owner_new_share_callback(e){
		var t = $(e.currentTarget);
		var item = itemOfNode(t);
		if(!item) return;

		var shopid = item.shopid;
		var itemid = item.itemid;
		var username = funcGetShopUserName ? funcGetShopUserName(shopid, itemid) : '';
		var itemname = item.name;
		var itemdesc = item.description;
		var itemcurrency = item.currency;
		var itemprice = item.price;
		var itemimage = item.image;
		var shopUrl = location.origin.replace("mall.", "") + "/" + username;
		var itemUrl = shopUrl + "/" + itemid + "/";
		e.preventDefault();
		if( window.WebViewJavascriptBridge) {
			window.WebViewJavascriptBridge.callHandler("share",{
				shopID:shopid,
				itemID:itemid,
				itemName:itemname,
				itemDesc:itemdesc,
				itemCurrency:itemcurrency,
				itemPrice:itemprice,
				itemImage:itemimage,
				username:username,
				url:itemUrl},function(e){});
		} else {
			showProm(i18n.t('msg_edit_btn_pop'));
		}
	}
	function owner_deleted_dismiss_callback(e){
		var t = $(e.currentTarget);
		var item = itemOfNode(t);
		
		if(!item) return;

		var p = t.parents('.mask-for-owner');
		var shopid = item.shopid;
		var itemid = item.itemid;
		// Note: This action is more critical than the "owner_mask" logic so we need to make sure this is mask is dismissed only after ajax succeeds.
		$.post("/shop/"+shopid+"/item/"+itemid+"/dismiss/",{
			"csrfmiddlewaretoken":csrf
		},function(e){
			console.log(e);
			if(e==0){
				fadeOutCard(item);
			}else{
				alert_message(i18n.t('msg_server_error') + " (" + e + ")");
			}
		}).fail(function(e){
			alert_message(i18n.t('msg_server_error'));
		});
	}
	function owner_update_product_callback(e){
		var t = $(e.currentTarget);
		var item = itemOfNode(t);
		if(!item) return;

		if(isShopOnVacation && item.status == 3) {
			alert_message(i18n.t('msg_seller_on_vacation'));
			return;
		}

		var p = t.parents('.mask-for-owner');
		var i = t.parents('li');
		var shopid = item.shopid;
		var itemid = item.itemid;

		if (item.is_category_failed) {
			window.BI_ANALYTICS && BI_ANALYTICS.updateWrongCategoryEdit({
				shop_id: shopid,
				product_id: itemid,
				view: 1, // 1 means grid view
			});
		}

		if (window.WebViewJavascriptBridge) {
			window.WebViewJavascriptBridge.callHandler("showEditProduct",{itemID:itemid,shopID:shopid},function(e){});
		} else {
			showProm(i18n.t('msg_edit_btn_pop'));
		}
	}

	function card_unlike_handler(e) {
		e.preventDefault();
		e.stopImmediatePropagation();
		e.stopPropagation();
		
		fav_handler(e, fadeOutCard);
	}

	function card_hide_handler(e) {
		e.preventDefault();
		e.stopImmediatePropagation();
		e.stopPropagation();
		var currentActiveButton = $(".btn-hide.active");
		currentActiveButton.removeClass("active");
		if (!currentActiveButton || currentActiveButton[0] != e.currentTarget ) {
			$(e.currentTarget).addClass("active");
		}
		$(e.currentTarget).find(".btn-hide-pop-out").off("tap").on("tap", function(e) {
			e.preventDefault();
			e.stopPropagation();
			var item = itemOfNode($(e.currentTarget));
			if(!item) return;
			var itemid = item.itemid;
			var shopid = item.shopid;

			if (itemid) {
				// hide item
				var data = { itemid: parseInt(itemid), csrfmiddlewaretoken: csrf, shopid: parseInt(shopid)};
				if (window.CURRENT_JUST_FOR_YOU_BATCH != null) {
					data['home_batch'] = window.CURRENT_JUST_FOR_YOU_BATCH;
					data['is_home'] = true;
				} else {
					data['is_home'] = false;
				}
				$.post("/hide_just_for_you/api/", data, function() {
					// hide the item
					//$(".item-card[itemid=#{itemid}]".replace("#{itemid}", itemid)).remove();
					fadeOutCard(item);
					alert_message_nonblock(i18n.t('msg_hide_item_success'));
				})
			}
		})
	}

	function free_shipping_handler(e) {
		e.preventDefault();
		e.stopPropagation();
		var message = '';
		if (LOCALE == 'ID') {
			message = i18n.t('msg_id_free_shipping');
		} else if (LOCALE == "TH") {
			message = i18n.t('msg_free_shipping_th');
		} else if (LOCALE == "TW") {
			message = i18n.t('msg_free_shipping_tw');
		} else if (LOCALE == "MY") {
			message = i18n.t('label_shipping_promotion_van_my');
		} else if (LOCALE == "VN") {
			message = i18n.t('msg_free_shipping_vn');
		} else if (LOCALE == "SG") {
			message = i18n.t('msg_free_shipping_sg');
		} else if (LOCALE == "PH") {
			message = i18n.t('msg_free_shipping_ph');
		}

		bridgeCallHandler('showPopUp', {
			popUp: {
				message: message,
				okText: i18n.t('label_ok')
			}
		})
	}

	function card_like_handler(e){
		e.preventDefault();
		e.stopPropagation();
		if (window.WebViewJavascriptBridge) {
			window.WebViewJavascriptBridge.callHandler("login",{},function(re){
				if(re.status == 1)
				{
					fav_handler(e);
				}
			})
		}
		else {
			if(loggedin){
				fav_handler(e);
			}
			else{
				askLogin();
			}
		}
	}

	function fav_handler(e, successCallback){
		BJUtil.popUserPrivacy();
		var $node = $(e.currentTarget);
		var item = itemOfNode($node);
		if(!item) return;

		var elm = $node.find('.icon_like');
		var shopid = item.shopid;
		var itemid = item.itemid;
		if (item.liked) {
			alert_message(i18n.t('msg_deleted_from_your_favorite'), 1000);
			item.icon_like = "ic_offerlist_like";
			item.liked = false;
			item.liked_count--;
			renderItem(item);
			$.ajax({
				url : "/buyer/unlike/shop/"+shopid+"/item/"+itemid+"/",
				method : "POST",
				data : {
					"csrfmiddlewaretoken":csrf
				},
				timeout : 5000,
				success : function(e){
					successCallback && successCallback(item);
				},
				error : function(e){
					alert_message(i18n.t('msg_server_error'));
				}
			});
		} else {
			alert_message(i18n.t('msg_added_to_your_favorite'), 1000);
			item.icon_like = "ic_offerlist_liked";
			item.liked = true;
			item.liked_count++;
			renderItem(item);
			$.ajax({
				url : "/buyer/like/shop/"+shopid+"/item/"+itemid+"/",
				method : "POST",
				data : {
					"csrfmiddlewaretoken":csrf
				},
				timeout : 5000,
				success : function(e){
					successCallback && successCallback(item);
				},
				error : function(e){
					alert_message(i18n.t('msg_server_error'));
				}
			});
		}
	}
	
	function card_comment_handler(e){
		var bridge = window.WebViewJavascriptBridge;
		// If no bridge, then simply open card.
		if(!bridge)
			return true;
		e.preventDefault();
		e.stopPropagation();
		var $node = $(e.currentTarget);
		var item = itemOfNode($node);
		if(!item) return;
		var shopid = item.shopid;
		var itemid = item.itemid;
		if (bridge) {
			bridge.callHandler('showItemComments', {shopID:shopid, itemID:itemid}, function(e){});
		}
	}

	function postPullItemData(e, callback)
	{
		// Obtain the cookie value for each batch just in case the user has clicked "yes".
		if(!isAdultSafe)
			isAdultSafe = BJUtil.isAdultSafe();
		var items = e.items;
		var toAdd = items;
		if(excludeItemIds && excludeItemIds.length > 0)
		{
			toAdd = items.filter(function(item) {
				return excludeItemIds.indexOf(item.itemid) < 0 && (keepAbnormalItems || item.status == 1);
			});
		}

		for(var i=0; i<toAdd.length; i++)
		{
			excludeItemIds.push(toAdd[i].itemid);
		}

		if(isIOS())
		{
			var imageUrls = toAdd.map(function(v, i){return window.ITEM_IMAGE_BASE_URL + v.image + '_tn';});
			function funcPreload(imgUrls)
			{
				for(var i=0; i<imgUrls.length; i++)
					preloadImage(imgUrls[i]);
			}
			// Prefetch images.
			var bridge = window.WebViewJavascriptBridge;
			// Use the non-chained version of handler checking to avoid iOS app freezing.
			if(bridge && bridge.appHasHandler('loadImages'))
			{
				// console.log('loadImages-start: imageUrls=' + imageUrls.length);
				bridge.callHandler("loadImages", {imageUrls: imageUrls}, function(){
					// console.log('loadImages-end: imageUrls=' + imageUrls.length);
					funcPreload(imageUrls);
					// NOTE: DO NOT call burpScroll when inactive. Otherwise would accidentally trigger scrolling when user swipe-to-switch-tab in main-cate page.
					if(isActive && bridge.appHasHandler('burpScroll'))
					{
						bridge.callHandler("burpScroll", {}, null);
						// !!! SUPER TRICKY: This is to workaround the burpScroll side-effect which would make webkit unable to properly handle the position:fixed items.
						var _lastScrollTop = scrollTopFunc();
						var _interval = setInterval(function(){
							var st = scrollTopFunc();
							if(st != _lastScrollTop)
							{
								_lastScrollTop = st;
								return;
							}
							// Reaching here means scrolling has stopped (either by touch, or by inertia).
							clearInterval(_interval);
							// If we don't call this once burpScroll stops, any position:fixed items on screen will have their touchable areas scrolled outside of screen (while they can still be seen on screen).
							// For example: The sticky headers in main-cate page, shop page, and sub-cate page.
							window.scrollTo(window.scrollX, window.scrollY);
						}, 50);
					}
				});
			}
			else
			{
				funcPreload(imageUrls);
			}
		}
		// Do this before adding the items into rendering list so that the callback has a chance to manipulate the item list (for example removing the odd one).
		if (onLoadCallback){
			onLoadCallback(toAdd);
		}

		allItems.push.apply(allItems, toAdd);
		// !!! NOTE: No longer checks against batch_max. The HTTP API now fetches item IDs first and then fetch batch item info, and then skips deleted/banned products. This could result in non-full-result-list when item info is not properly updated.
		if (items.length < 1 || e.nomore){
			markNoMore();
		}
		else {
			$loadingText.hide(); // Note: If "e.nomore" works in all cases, we don't have to do this, but e.nomore sometimes cannot return correct value due to server complexity (for example when it has no definite answer to "nomore") so we have to hide Loading text as a remedy.
		}

		if(callback)
			callback(e);
		var oldOffset = offset;
		//Note: MUST do this after the bridge saving above because the bridge saving depends on original offset value.
		//Use batch_max to workaround the case when returned items are less than requested.
		offset += batch_max;//e.items.length;
		// Reset the value after all above logics to avoid second run before the above is done.
		loadingFromServer = false;
		// Note: Must do this before the above line because we need loadingFromServer to be false in order to trigger $window.scroll().
		if(oldOffset == 0)
		{
			// Increase batch size for subsequent loads. Note: For faster loading, first page loads less than subsequent pages.
			// !!! Note: This line must be after the above code to avoid inconsistency.
			batch_max = 50;
		}
	}

	function markNoMore()
	{
		if(!isLoadMore)
			return;
		isLoadMore = false;
		if (isActive)
		{
			$loadingText.hide();

			if(/*root.find('li').length > 0*/allItems.length > 0 || $emptyHolder.length == 0)
				$nomore.show();
			else
				$emptyHolder.show();
		}
		if (onLoadEndCallback) {
			onLoadEndCallback();
		}
	}

	function pullItemDataFromServer(callback) {
		if(!isLoadMore){
			return false;
		}
		//console.log('pullFromServer: loadingFromServer=' + loadingFromServer);
		if(loadingFromServer){
			return false;
		}

		// console.log('LoadFromServer!!!');
		loadingFromServer = true;

		function successFunc(e){
			//e = JSON.parse(e);
			// If don't check this, the subsequent logic could crash due to non-existing e.items, resulting in the whole batchItem not working forever.
			if( parseResFunc ){
				e = parseResFunc(e);
			}

			if(!e || !e.items)
			{
				loadingFromServer = false;
				pullDataFromBridgeOnNetworkError(callback, '<no-items-in-response>');
				return;
			}
			
			// Save to bridge for future fallback.
			if( window.WebViewJavascriptBridge ) {
				var key = apiUrl+"_"+offset+"_"+batch_max;
				var bridge = window.WebViewJavascriptBridge;
				//Only cache the first batch.
				if(offset == 0)
				{
					// !!! MUST do this BEFORE postPullItemData() because that function would add __node__ to items, resulting in cyclic reference.
					var toSave = JSON.stringify(e);
					// Do this later cause on Android in main-cate page, when the finger isn't released while swiping to next tab, this will block.
					setTimeout(function(){
						bridge.callHandler("save",{key:key,data:toSave,persist:1},function(data){});
					}, 1000);

				}
			}

			postPullItemData(e, callback);
		//});
		}

		function failFunc()
		{
			loadingFromServer = false;
			 //Use bridge as fallback.
			pullDataFromBridgeOnNetworkError(callback, '<timeout>');
		}

		function ajaxFunc()
		{
			var postObj = {limit:batch_max, need_drop_word: needDropWord};
			postObj[offsetStr] = offset;
			var ajaxSettings = {cache:true, dataType: "json", url: apiUrl, data:postObj, success:successFunc};
			if (apiMethod === 'POST') {
				ajaxSettings['method'] = 'POST';
				ajaxSettings['contentType'] = 'application/json; charset=utf-8';
				_.extend(postObj, apiPostData);
				ajaxSettings['data'] = JSON.stringify(postObj);
			}
			$.ajax(ajaxSettings).fail(function(e){
				failFunc();
			});
		}

		// We do this to avoid batch-items to be loaded BEFORE other items in page are loaded. For example, in cate page, the cate images should be loaded before item list are loaded. Otherwise perceptually the page is too slow.
		runAfterDomReady(function(){
			if(offset == 0 && initialBatch)
			{
				successFunc(initialBatch);
				initialBatch = null;
				return;
			}
			var bridge = window.WebViewJavascriptBridge;
			// !!! TRICKY: DO NOT DO THIS FOR THE FIRST BATCH!!!
			// Reason is that on iOS, the ajax callback will deadlock with page reload if app's page cache invalidates.
			if(bridge && bridge.appHasHandler('ajax') && offset > 0)
			{
				var query = [];
				var data = {limit:batch_max, need_drop_word: needDropWord};
				data[offsetStr] = offset;
				for (var key in data) {
					if(data.hasOwnProperty(key))
						query.push(encodeURIComponent(key) + '=' + encodeURIComponent(data[key]));
				}
				var delim = apiUrl.indexOf('?') >= 0 ? '&' : '?';
				var url = apiUrl + (query.length ? delim + query.join('&') : '');
				if(!url.startsWith("http"))
					url = location.origin + url;
				// !!! Apparently iOS does NOT send body when method is GET. We can't do this then.
				// Tricky: Include the header inside body for GET request.
				// var body = {};
				// body['X-Requested-For'] = q;
				// body[Base64.test()] = asfq(q);

				// We can't use the same cookie key across requests because the ajax bridge is async, and by the time the native iOS app reads the cookie, it may have been changed in JS space already.
				// Then what we do now is to still include the values in the cookie, but give each cookie a unique key so that it won't overwrite each other across requests.
				var cookieKey = parseInt(Math.random() * 1000000);
				// And we need a way to tell the server side about the key. Here it is:
				url += (url.indexOf('?') >= 0 ? '&' : '?') + '__ck__=' + cookieKey;
				var q = aqfgu(url);
				var v = asfq(q);
				// Note: Set the expiry just in case.
				$.cookie('X-Requested-For-' + cookieKey, q, {expires: new Date(curTs() + 40000)});
				$.cookie(Base64.test() + cookieKey, v, {expires: new Date(curTs() + 40000)});

				var responseCallback = function(res){
					$.removeCookie('X-Requested-For-' + cookieKey);
					$.removeCookie(Base64.test() + cookieKey);
					if(!res || !res.respCode || !res.body || res.respCode != 200)
					{
						failFunc();
						return;
					}
					try
					{
						successFunc(JSON.parse(res.body));
					}
					catch(e)
					{
						console.error("ajax: e=" + e);
						failFunc();
					}
				};

				if (apiMethod === 'POST') {
					_.extend(data, apiPostData);
					bridge.callHandler("ajax", {url: url, method: "POST", body:JSON.stringify(data), timeout: 35000}, responseCallback);
				}
				else {
					bridge.callHandler("ajax", {url: url, method: "GET", /*body:JSON.stringify(body), */timeout: 35000}, responseCallback);
				}
			}
			else
			{
				ajaxFunc();
			}
		});
		return true;
	}

	function logError(msg)
	{
		var params = {page:location.pathname + location.search, batchapi:apiUrl, userid:window.USERID, limit:batch_max, need_drop_word: needDropWord};
		params[offsetStr] = offset;
		var log_data = {'url': 'batch-item-timeout',
			'sent_data': JSON.stringify(params),
			'response_data': msg
		};
		BJUtil.sentryLog('batch-item-timeout', log_data, "error");

	}
	function handleLoadError(callback, errmsg)
	{
		logError(errmsg);
		if(retryOnNetworkError)
		{
			// Use timeout to avoid stack overflow.
			setTimeout(function(){
				pullItemDataFromServer(callback);
			}, 5000); // To avoid overloading.
		}

		// This is to reset the loadingFromServer flag. Otherwise the whole batch loader will stop working.
		if(callback)
			callback(null);
		if(onFailCallback)
			onFailCallback();
	}

	function pullDataFromBridgeOnNetworkError(callback, errmsg)
	{
		if(skipBridgeCache || loadingFromBridge)
			return;
		if(!window.WebViewJavascriptBridge)
		{
			handleLoadError(callback, errmsg);
			return;
		}

		loadingFromBridge = true;

		var key = apiUrl+"_"+offset+"_"+batch_max;
		var bridge = window.WebViewJavascriptBridge;
		// console.log('LoadFromBridge!!!');
		bridge.callHandler("load",{key:key,persist:1},function(data){
			if (data.data) {
			//Now bridge is only fallback so don't trigger server load again.
//				// !!! Tricky: Must call this before setting isLoadMore=false otherwise the server loading may be skipped forever.
//				pullItemDataFromServer(callback,true);
				var e = JSON.parse(data.data);
				postPullItemData(e, callback);
			}
			else
			{
				handleLoadError(callback, errmsg);
			}
			//Now bridge is only fallback so don't trigger server load again.
//			else
//			{
//				pullItemDataFromServer(callback);
//			}
			loadingFromBridge = false;
		})
	}

	function pullItemData(callback) {
		if(!isLoadMore){
			return false;
		}
		// Now always tries server first and only use bridge as fallback. This is to avoid the case that the user always
		// sees out-dated data and can only see the update-to-date data after a second visit of the page.
		return pullItemDataFromServer(callback);
	}

	var curRootHeight = -1;
	var curDocumentHeight = -1;
	function updateRootHeight(force)
	{
		if(!root || !isActive) return;
		var itemCount = allItems.length;
		var height = Math.ceil(itemCount / 2) * CARD_HEIGHT + ROOT_BASIC_HEIGHT;
		height = Math.max(height, ROOT_EMPTY_HEIGHT);
		if(!force && height == curRootHeight)
			return;
		curRootHeight = height;
		// console.log('updateRootHeight: ' + height);

		// Note: We reverted back to sync mode on all platforms because requestAnimationFrame could fail some assumptions about the execution order.
		// Also, since this function is only called when the page grows, and not very frequently, it should be ok.
		root.css({'height':height + 'px'});
		// We save this so that onScroll() doesn't need to access DOM (which in turn will force re-layout).
		curDocumentHeight = $document.height();
		if(funcOnHeightChange)
			funcOnHeightChange(curDocumentHeight, curRootHeight);
	}

	function reflowBatch(startIdx, endIdx)
	{
		endIdx = Math.min(allItems.length, endIdx);

		//console.log('reflow: start=' + startIdx + " end=" + endIdx + " all=" + allItems.length);

		for(var i=startIdx; i<endIdx; i++)
		{
			var item = allItems[i];
			reflowOne(i, item.__node__);
		}
	}

	//function cleanDict(d)
	//{
	//	for(var k in d)
	//	{
	//		if(d.hasOwnProperty(k))
	//			delete d.k;
	//	}
	//	return d;
	//}

	// Disabled as we now cache the rendering dict in item obj.
	// !!! Reusing object would avoid GC!!!
	//var renderDomifyR = {};

	var DEFAULT_DOMIFY_R = null;
	var EMPTY_DICT = {};
	var ITEM_STATUS_TO_DISABLE_BOOST = {'sold_out': true, 'deleted': true, 'banned': true, 'offensive_hide': true};

	function _renderItemMutable(item, r)
	{
		r.class_adult = item.is_adult && !skipCheckAdult && !isAdultSafe ? 'adultcntrl age-' + ADULT_AGE : '';
		r.badge_soldout_display = item.item_status == "sold_out" ? '' : 'display: none;';
		r.class_liked = item.liked ? 'liked' : '';
		r.owner_attr = item.owner_attr || EMPTY_DICT;

		if(hasShopPart)
			r.text_tstamp = item.ctime && !isNaN(item.ctime) ? DateDiff.getDiff(new Date(item.ctime * 1000), new Date()) : '';
		else
			r.text_tstamp = '';
		// Note: isInBoostPage condition is super important.
		if(isInBoostPage)
		{
			r.boost_container_display = item.shopid == CURRENT_SHOPID ? '' : 'display: none;';
			if((item.item_status in ITEM_STATUS_TO_DISABLE_BOOST) || item.owner_attr && item.owner_attr.btn_name == 'label_verify_user_info' || is_item_unlisted(item) )
				r.boost_btn_status = 'disabled';
			else
				r.boost_btn_status = item.boostable ? 'boostable' : 'boosted';
			if(item.boosting)
				r.boost_btn_text = i18n.t('label_promoting');
			else if(item.boosted)
				r.boost_btn_text = i18n.t('label_promote_in');
			else
				r.boost_btn_text = i18n.t('label_promote_now');
			if(item.boosted && item.ptime && item.pwait)
			{
				var endTime = new Date(item.ptime * 1000 + item.pwait * 1000);
				r.boost_countdown_text = DateDiff.inFullFormat(new Date(), endTime);
			}
			else
			{
				r.boost_countdown_text = '';
			}
			r.boost_countdown_display = item.boosted ? '' : 'display: none;';
		}
		// The else condition is important as we are no longer reuse the default dict in this case.
		else
		{
			r.boost_container_display = 'display: none;';
			r.boost_countdown_display = 'display: none;';
		}

		r.mask_for_owner_visibility = $.isEmptyObject(r.owner_attr) ? '' : 'visibility: visible;';
		// for owner status with cover, we need display the item as normal;
		r.item_status = $.isEmptyObject(r.owner_attr) ? item.item_status: 'normal';
		
		r.item_after_dismiss_visibility = item.dismissed? 'opacity:0':'';

		if (is_item_unlisted(item)){
			if(!r.mask_for_owner_visibility || (r.mask_for_owner_visibility && item.item_status_for_owner === 'owner_new_unlisted') )
				r.unlisted_style = '';
		}else {
			r.unlisted_style = 'display:none;';
		}
	}

	function _renderItemImmutable(item, r)
	{
		// This is to be compatible with v2 item data structure.
		if (item.item_rating){
			_.extend(item, item.item_rating);
		}
		if (item.liked){
			item.icon_like = 'ic_offerlist_liked';
		}
		else{
			item.icon_like = 'ic_offerlist_like';
		}

		var item_href = '';
		var shop_href = '';
		if(isShopeeApp())
		{
			// Very important, to avoid user triggering link before tap events are in effect.
			item_href = 'javascript:void(0)';
			shop_href = 'javascript:void(0)';
		}
		else
		{
			item_href = SeoUtil.getItemUrlSeo(item.name, item.shopid, item.itemid);
			shop_href = '/shop/#shopid=' + item.shopid;
		}
		revertTWDiscountForEnglishIfNeeded(item);
		r.text_currency = dollarsign_getter(item.currency);
		r.text_price = bee_dollarfy(item.price, item.currency);
		if (item.price_max != item.price_min) {
			r.text_price_max = bee_dollarfy(item.price_max, item.currency);
			r.price_max_display = '';
		} else {
			r.price_max_display = 'display:none';
		}
		if(item.price_before_discount && item.price_before_discount != item.price && item.price_max_before_discount == item.price_min_before_discount && item.price_min == item.price_max) // add item.price_max_before_discount != item.price_min_before_discount check to not show "strike through" when there's price ranges
		{
			r.text_price_before_discount = bee_dollarfy(item.price_before_discount, item.currency);
			r.price_before_discount_display =  '';
		}
		else
		{
			r.text_price_before_discount = '';
			r.price_before_discount_display =  'display: none;';
		}
		
		r.free_shipping_visibility = item.show_free_shipping ? 'display: inline-block;' : 'display: none;';
		r.service_by_shopee_icon_visibility = item.service_by_shopee_flag ? 'display: inline-block;' : 'display: none;';
		r.white_list_visibility = item.white_list ? 'visibility: visible;' : 'visibility: hidden;';
		r.badge_promotion_display = item.discount ? '' : 'display: none;';
		r.wholesale_display = item.can_use_wholesale ? '' : 'display: none;';
		r.coin_earn_label_display = item.coin_earn_label ? '' : 'display: none;';
		if (hideOfficialShopLabel) {
			r.shopee_verified_display = 'display: none;';
			r.official_shop_display = 'display: none;';
			r.official_shop_label_in_title_display = 'display: none;';
		} else {
			r.shopee_verified_display = item.show_shopee_verified_label ? '' : 'display: none;';
			r.official_shop_display = item.show_official_shop_label_in_normal_position ? '' : 'display: none;';
			r.official_shop_label_in_title_display = item.show_official_shop_label_in_title ? '' : 'display: none;';
		}
		r.is_shopee_verified = item.is_shopee_verified ? '' : 'display: none;';
		r.can_use_wholesale = item.can_use_wholesale ? '' : 'display: none;';
		if (sortType == SEARCH_ENUM.SORT_TYPES.SORTBY_ITEM_SOLD_DESC && window.SHOW_SOLD_COUNT) {
			r.like_visibility = 'display: none;';
			if (!item.sold) {
				r.sold_number_visibility = 'display: none;';
			} else {
				r.sold_empty_visibility = 'display: none;';
			}
		} else {
			r.sold_visibility = 'display: none;';
		}
		
		if (item.badge_icon_type) {
			switch (item.badge_icon_type) {
				case 1: // ItemBadgeType.SERVICE_BY_SHOPEE_TW_8H
					r.service_by_shopee_icon_cls = 'ic_service_by_shopee--8h';
					break;
				case 2: // ItemBadgeType.SERVICE_BY_SHOPEE_TW_24H
					r.service_by_shopee_icon_cls = 'ic_service_by_shopee--24h';
					break;
				case 3: // ItemBadgeType.SERVICE_BY_SHOPEE_TW_48H
					r.service_by_shopee_icon_cls = 'ic_service_by_shopee--48h';
					break;
				case 4: // ItemBadgeType.SERVICE_BY_SHOPEE_NON_SPECIAL
					r.service_by_shopee_icon_cls = 'ic_service_by_shopee--non-special';
					break;
				case 5: // ItemBadgeType.SERVICE_BY_SHOPEE_ID_24H
					r.service_by_shopee_icon_cls = 'ic_service_by_shopee--id-24h';
					break;
				case 6: // ItemBadgeType.BADGE_TYPE_VN_24H
					r.service_by_shopee_icon_cls = 'ic_service_by_shopee--vn-24h';
					break;
				case 7: // ItemBadgeType.BADGE_TYPE_VN_4H
					r.service_by_shopee_icon_cls = 'ic_service_by_shopee--vn-4h';
					break;
				case 8: // ItemBadgeType.BADGE_TYPE_MY_24H
					r.service_by_shopee_icon_cls = 'ic_service_by_shopee--id-24h';
					break;
				case 9: // ItemBadgeType.BADGE_TYPE_TH_24H
					r.service_by_shopee_icon_cls = 'ic_service_by_shopee--id-24h';
					break;
				case 10: // ItemBadgeType.BADGE_TYPE_PH_24H
					r.service_by_shopee_icon_cls = 'ic_service_by_shopee--id-24h';
					break;
				case 11: // ItemBadgeType.BADGE_TYPE_SG_24H
					r.service_by_shopee_icon_cls = 'ic_service_by_shopee--id-24h';
					break;

			}
		}
		
		r.price_style = '';
		
		
		if (item.hidden_price_display){
			r.price_masked_style = '';
			r.price_style =  'display: none;';
		} else {
			r.price_masked_style = 'display: none';
		}
		
		if (item.preview_info && !window.isShownToOwner){
			r.class_preview_mask = 'preview-mask--show-normal';
		} else {
			r.class_preview_mask = '';
		}

		
		if(item.image)
		{
			r.img_url = window.ITEM_IMAGE_BASE_URL + item.image + '_tn';
			r.bg_img_style = "background-image : url(" + r.img_url + ");";
		}
		else
		{
			r.img_url = BLANK_IMAGE_URL;
			r.bg_img_style = '';
		}
		r.item_href = item_href;
		r.shop_href = shop_href;
		if(item.shopimage)
			r.shop_img_url = window.ITEM_IMAGE_BASE_URL + item.shopimage + '_tn';
		else
			r.shop_img_url = BLANK_IMAGE_URL;


		var roundedScore = Number(item.rating_star).toFixed(1);
		for (var i=0;i<5; i++){
			var current_score = roundedScore - i;
			if (current_score >= 1){
				r['star_cls_'+i] = 'ic_rating_score_solid';
			} else if (current_score >= 0.5) {
				r['star_cls_'+i] = 'ic_rating_score_half';
			} else {
				r['star_cls_'+i] = 'ic_rating_score_hollow';
			}
		}
		if (item.rating_count && item.rating_count.length > 0 && item.rating_count[0]>0){
			r.rating_count_display = '(' + item.rating_count[0] + ')';
			r.rating_inline_style = '';
			r.rating_empty_inline_style = 'display:none;';
		}  else {
			r.rating_inline_style = 'display:none;';
			if (r.sold_number_visibility) {
				r.rating_empty_inline_style = 'display:none;';
			} else {
				r.rating_empty_inline_style = '';
			}
		}
		if (!item.has_lowest_price_guarantee) {
			r.lowest_price_style = 'display:none;';
		} else {
			r.lowest_price_style = '';
		}
		if (item.adsid){
			r.shopee_ads_style = '';
			r.product_video_style = 'display:none;';
			if (onAdsItemAppear && offset < batch_max) {
				onAdsItemAppear();
			}
		} else {
			r.shopee_ads_style = 'display:none;';
			if (item.videos && item.videos.length) {
				r.product_video_style = '';
			} else {
				r.product_video_style = 'display:none;';
			}
		}

		r.owner_warning_cover_attr = item.owner_warning_cover_attr || EMPTY_DICT;
		r.warning_cover_for_owner_visibility = $.isEmptyObject(r.owner_warning_cover_attr) ? 'visibility: hidden;' : 'visibility: visible;';

		r.item_name_class = 'truncate-line2';
		r.installment_tag_style = 'display: none;';
		r.bundle_deal_tag_style = 'display: none;';
		// installment tag
		if ((window.CC_INSTALLMENT_PAYMENT_ELIGIBILITY && item.is_cc_installment_payment_eligible) || 
			(window.NON_CC_INSTALLMENT_PAYMENT_ELIGIBILITY && item.is_non_cc_installment_payment_eligible)) {
			r.item_name_class = 'truncate';
			r.installment_tag_style = '';
		}
		if (item.can_use_bundle_deal && item.bundle_deal_info) {
			r.item_name_class = 'truncate';
			r.bundle_deal_tag_style = '';
			r.bundle_deal_label = item.bundle_deal_info.bundle_deal_label;
		}
		
		if (item.preview_info){
			r.badge_promotion_display = 'display: none;';
			r.wholesale_display = 'display: none;';
			r.product_video_style = 'display: none;';
			r.bundle_deal_tag_style = 'display: none;';
			r.badge_soldout_display = 'display: none;';
			r.installment_tag_style = 'display: none;';
			r.lowest_price_style = 'display: none;';
		}
	}

	function _renderItemBlank()
	{
		if(DEFAULT_DOMIFY_R)
		{
			return DEFAULT_DOMIFY_R;
		}

		r = {};
		r.price_before_discount_display = 'display: none;';
		r.text_currency = '';
		r.text_price = '';
		r.text_price_max = '';
		r.text_price_before_discount = '';
		r.price_max_display = 'display: none;';
		r.free_shipping_visibility = 'display: none;';
		r.service_by_shopee_icon_visibility = 'display: none;';
		r.white_list_visibility = 'visibility: hidden;';
		r.badge_soldout_display = 'display: none;';
		r.badge_promotion_display = 'display: none;';
		// !!! TRICKY: This is a 1-px GIF. It's to make sure when being cleared, the image is shown as blank. If we put any wrong URL here, the previous image in this card won't be cleared. Also, initial image would appear with a broken image icon on some Android phones if URL is wrong or empty
		// Note: the base64 approach would create a new bitmap everytime. I'm not sure if that's good.
		// A second approach is to use the '//:0' trick. '//' will ensure the protocol is same as current location; ':0" is the port without hostname. Since 0 is not a valid port, there'd be no network attempts.
		// We can't use the '//:0' approach because it does NOT clear the image when we reuse a card.
		// Note: The base64 approach would generate one new bitmap every time, which is wasting ram and cpu. So we go with the actual image link instead.
		r.shop_img_url = BLANK_IMAGE_URL; //'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs='; //'//:0';
		r.img_url = BLANK_IMAGE_URL; //'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs='; //'//:0';
		r.bg_img_style = "background-image : url(" + BLANK_IMAGE_URL + ");";
		r.item_href = 'javascript:void(0)';
		r.shop_href = 'javascript:void(0)';
		r.class_liked = '';
		r.class_adult = '';
		r.boost_container_display = 'display: none';
		r.boost_btn_status = '';
		r.boost_btn_text = '';
		r.boost_countdown_display = 'display: none';
		r.boost_countdown_text = '';
		r.owner_attr = EMPTY_DICT;
		r.mask_for_owner_visibility = 'visibility: hidden;';
		r.warning_cover_for_owner_visibility = 'visibility: hidden;';
		r.text_tstamp = '';
		r.owner_warning_cover_attr = EMPTY_DICT;
		r.item_status = 'normal';
		r.item_after_dismiss_visibility = '';
		DEFAULT_DOMIFY_R = r;
		return r;
	}

	function renderDomify(domify, item)
	{
		if(!domify) return;

		var r = null;//cleanDict(renderDomifyR);
		if(item)
		{
			if(item._r)
			{
				r = item._r;
				// Mutable content must be rendered every time.
				_renderItemMutable(item, r);
			}
			else
			{
				// Here we render the immutable parts. These parts can be cached inside item once they are rendered.
				r = {};
				_renderItemImmutable(item, r);
				// Mutable content must be rendered every time.
				_renderItemMutable(item, r);
				// Note: cache the render dict into the item obj for reuse. If don't cache, then there would be too many temporary strings created in each rendering, resulting in GC.
				item._r = r;
			}
		}
		else
		{
			r = _renderItemBlank();
		}

		item = item || EMPTY_DICT;

		var dict = getMasterRenderDict();
		dict['item'] = item;
		dict['r'] = r;
		domify.render(dict);
	}

	var masterRenderDict = null;

	function getMasterRenderDict()
	{
		if(!masterRenderDict)
		{
			masterRenderDict = {
				'ITEM_IMAGE_BASE_URL':window.ITEM_IMAGE_BASE_URL,
				'card_type':card_type,
				'shop':shop,
				'show_shop':show_shop,
				"LOCALE" : LOCALE,
				"isInBoostPage":isInBoostPage,
				"CURRENT_SHOPID":window.CURRENT_SHOPID,
			};
		}
		return masterRenderDict;
	}

	function reflowOne(idx, $node)
	{
		if(!$node) return;
		var transX = 0;
		var transY = CARD_HEIGHT * Math.floor(idx / 2);
		if((idx % 2) != 0)
		{
			transX = CARD_WIDTH;
		}
		// !!! 3d significantly reduces visual glitches on some Android devices such as VEGA.
		if(isAndroid())
		{
			var trans = 'translate3d(' + transX + 'px, ' + transY + 'px, 0)';
			$node.css({'transform':trans, '-webkit-transform':trans});
		}
		// Note: For iOS devices, setting 3d for card slows down the scrolling.
		else
		{
			$node.css({'transform':'translate(' + transX + 'px, ' + transY + 'px)'});
		}
		$node.data('idx', idx);
	}

	function renderItem(item)
	{
		if(!item || !item.__node__)
			return;
		renderOne(item, item.__node__);
	}

	function renderOne(item, $node)
	{
		var domify = $node[0]._domify_;
		renderDomify(domify, item);
		//if (!skipCheckAdult)
		//	BJUtil.listenAge($node);
		showNode($node);
	}

	function _renderBatchHelper(i, startIdx, endIdx)
	{
		var item = allItems[i];
		if(item.__node__)
		{
			// console.log('_renderBatchHelper.has_node|i=' + i + ' startIdx=' + startIdx + ' endIdx=' + endIdx + ', item,node', item, item.__node__);
			return;
		}
		var $node = allocateItemNode(i, item, startIdx, endIdx);
		// console.log('_renderBatchHelper.render|i=' + i + ' startIdx=' + startIdx + ' endIdx=' + endIdx + ' item,$node', item, $node);
		renderOne(item, $node);
		reflowOne(i, $node);
	}

	function renderBatch(startIdx, endIdx)
	{
		endIdx = Math.min(allItems.length, endIdx);

		// console.log('render: dir=' + scrollDirection + ' start=' + startIdx + " end=" + endIdx + " all=" + allItems.length);

		var wanted = 0;
		for(var i=startIdx; i<endIdx; i++)
		{
			var item = allItems[i];
			if(!item.__node__)
				wanted++;
			// else
			// 	console.log('renderBatch.has_node|item,node', item, item.__node__);
		}
		releaseNodes(startIdx, endIdx, wanted);

		// Reverse rendering for scrolling up, so that bottom items would appear earlier.
		if(scrollDirection < 0)
		{
			for(var i=endIdx-1; i>=startIdx; i--)
			{
				_renderBatchHelper(i, startIdx, endIdx);
			}
		}
		else
		{
			// console.log("before|start=" + startIdx + " end=" + endIdx);
			for(var i=startIdx; i<endIdx; i++)
			{
				// console.log("rendering|i=" + i + " start=" + startIdx + " end=" + endIdx);
				_renderBatchHelper(i, startIdx, endIdx);
			}
			// console.log("after|i=" + i);
		}
		curRenderedStartIdx = startIdx;
		curRenderedEndIdx = endIdx;
	}

	function allocateItemNode(idx, item, startIdx, endIdx)
	{
		if(freeNodes.length <= 0)
			releaseNodes(startIdx, endIdx, 1);
		// SHOULD NEVER HAPPEN!
		if(freeNodes.length <= 0)
			return;
		var $node = freeNodes.pop();
		$node.data('idx', idx);
		item.__node__ = $node;
		// console.log('allocateItemNode|item,node', item, $node);
		return $node;
	}

	function resetNode(item)
	{
		var $node = item.__node__;
		if(!$node)
			return;
		delete item.__node__;
		// console.log('resetNode|item,node', item, item.__node__)
		$node.removeData('idx');
		var domify = $node[0]._domify_;
		//No need to render whole card with empty data. Only need to reset images to blank ones because all other data are replaced in-place in next rendering, but images need time to load.
		//renderDomify(domify);
		if(domify)
			domify.resetImages();
		hideNode($node);
		freeNodes.push($node);
	}
	function releaseNodes(startIdx, endIdx, wanted)
	{
		if(freeNodes.length >= wanted)
		{
			// console.log('releaseNodes_enough|freeNodesCount=' + freeNodes.length + " wanted=" + wanted, freeNodes);
			return;
		}
		if(recycleNodes)
		{
			// console.log("recycleNode: wanted=" + wanted + " free=" + freeNodes.length);
			for(var i=0; i<startIdx; i++)
				resetNode(allItems[i]);
			for(var i=endIdx; i<allItems.length; i++)
				resetNode(allItems[i]);
		}
		if(freeNodes.length < wanted)
		{
			var COUNT = wanted - freeNodes.length;
			// console.log("releaseNodes_addNewNodes|itemCount=" + allItems.length + ",newNodeCount=" + COUNT);
			addNewNodes(COUNT);
		}
	}

	//function assignNodeSizes(node)
	//{
	//	if(!node) return;
	//	if(node.childNodes && node.childNodes.length == 1 && node.childNodes[0].nodeType == Node.TEXT_NODE)
	//	{
	//		//var style = window.getComputedStyle(node);
	//		//var w = style.width;
	//		//var h = style.height;
	//		var w = node.offsetWidth;
	//		var h = node.offsetHeight;
	//		console.log(w + 'x' + h, node);
	//		if(w > 0 && h > 0)
	//		{
	//			node.style.width = w + 'px';
	//			node.style.height = h + 'px';
	//		}
	//		return;
	//	}
	//	var children = node.children;
	//	if(!children) return;
	//	for(var i=0; i<children.length; i++)
	//	{
	//		assignNodeSizes(children[i]);
	//	}
	//}

	var $masterNode = null;
	//var MASTER_NODE = null;
	function addNewNodes(count)
	{
		if($masterNode == null)
		{
			// NOTE: Need to have the place holders untouched so we don't domify the master node here.
			//try
			//{
			//	var tmp = document.createElement('div');
			//	tmp.innerHTML = cardTemplate;
			//	MASTER_NODE = tmp.children[0];
			//	MASTER_NODE.style.transform = 'translate(100000px, 100000px)';
			//	//MASTER_NODE.style.visibility = 'hidden';
			//}
			//catch(e)
			//{
			//	var $tmp = $(cardTemplate);
			//	hideNode($tmp);
				//MASTER_NODE = $tmp[0];
			//}

			//// Try to fix width/height of as many nodes as possible for faster rendering.
			//// !!! TRICKY: To obtain sizes, we should render a properly configured card instead of master node, because master node has too many place holders, which would make the layout wrong.
			var fixSizes = [];
			//var cardSize = [];
			// If root isn't in render tree, then skip this step, and go without the pre-calculating of sizes.
			if(root.css('display') != 'none')
			{
				var item = {name: 'wwwwwwwwwwwwwwwww', price: 999999999999, currency: get_default_currency(), liked_count: 999, cmt_count: 999};
				var r = {};
				_renderItemImmutable(item, r);
				_renderItemMutable(item, r);
				var dict = getMasterRenderDict();
				dict['item'] = item;
				dict['r'] = r;
				var toRender = _.template(cardTemplate, Domify_TEMPLATE_SETTING)(dict);
				var $node = $(toRender);
				$node.css({'opacity': '0'});
				// !!! Must insert to root for corret layout calculation.
				root.append($node[0]);
				//assignNodeSizes($masterNode[0]);
				$node.find('.fix-size').each(function(){
					var $self = $(this);
					var w = Math.floor($self.width());
					// For <img> with width: 100%, the h will be 0, and we will use the width as height.
					var h = Math.floor($self.height()) || w;
					$self.css({'width': w + 'px', 'height': h + 'px'});
					fixSizes.push([w, h]);
				});
				$node.remove();
			}

			if(isIE())
			{
				cardTemplate = cardTemplate.replace(/style.*=/g, IE_STYLE_NAME + "=");
			}
			cardTemplate = revertTWDiscountForEnglishIfNeededTemplate(cardTemplate);
			$masterNode = $(cardTemplate);
			//assignNodeSizes($masterNode[0]);
			if(fixSizes.length > 0)
			{
				var idx = 0;
				$masterNode.find('.fix-size').each(function(){
					var $self = $(this);
					var size = fixSizes[idx];
					idx++;
					var w = size[0];
					var h = size[1];
					$self.css({'width': w + 'px', 'height': h + 'px'});
				});
			}
			// NOTE: We now only set the card size, no longer set the sub node sizes, as the sub node sizes could be very much dependent on the content.
			var w = CARD_WIDTH;
			var h = CARD_HEIGHT;
			$masterNode.css({'width': w + 'px', 'height': h + 'px'});
			hideNode($masterNode);
		}

		var toAdd = [];

		while(count > 0)
		{
			var $node = $masterNode.clone();
			//var $node = $(MASTER_NODE.cloneNode(true));
			var domify = new Domify($node[0]);
			$node[0]._domify_ = domify;
			renderDomify(domify);
			register_node_events($node);
			// Update: The following is wrong. Non-3d layers are rendered too.
			// In iOS app, only 3D layers would get their images rendered during scrolling.
			//if((isShopeeApp() && isIOS()) || isChromeIOS())
			//	$node.find('img').css({'-webkit-transform': 'translate3d(0,0,0)'});
			toAdd.push($node);
			freeNodes.push($node);

			count--;
		}

		root.append(toAdd);
	}

	function itemOfNode($node)
	{
		if(!$node.hasClass('item-card'))
			$node = $node.parents('.item-card');
		var idx = $node.data('idx');
		if(undefined == idx)
			return null;
		return allItems[idx];
	}

	function _ontap_href($node, url, pattern, callback, shopid, itemid)
	{
		var bridge = window.WebViewJavascriptBridge;
		// if(!bridge) return;

		//var url = $node.attr("href") && $node.attr("href") != 'javascript:void(0)' ? $node.attr("href") : $node.attr("_href");
		//url = djangofy_apply(url, {item:item});
		if(url && url.indexOf('review-part')>-1){
			return;
		}
		if (url && (!pattern || url.indexOf(pattern) < 0))
			return;

		if ($node.hasClass('banned')){
			return;
		}
		var navParams = callback(location.origin + url, $node);
		navParams['preloadKey'] = 'item';
		bridgeCallHandler('navigate', navParams, null);
	}
	function _ontap_item_href(e)
	{
		if(!tapManager.tap())
			return;
		e.preventDefault();
		e.stopPropagation();
		var $node = $(this);
		var item = itemOfNode($node);
		if(!item) return;

		$document.trigger(EVENT_ITEM_CARD_TAPPED);

		if(funcOnTapItemCard)
			funcOnTapItemCard(item, allItems.indexOf(item));

		if (item.item_type == 1) {
			redirect_to_dp_item(item)
			return;
		}

		var url = '/item/#shopid=' + item.shopid + '&itemid=' + item.itemid + '&data=' + encodeURIComponent(itemToString(item)) + '&source_url=' + encodeURIComponent(location.href);
		_ontap_href($node, url, '/item/', bridge_capture_link_item_callback, item.shopid, item.itemid);
	}

	function redirect_to_dp_item(item) {
		
		var dp_tracking_sourcce = null;
		if (location.pathname == '/buyer/seen_item/') {
			dp_tracking_sourcce = '25';
		}
		if(dp_tracking_sourcce) {
			dp_tracking_sourcce = '?dp_from_source=' + dp_tracking_sourcce;
		} else {
			dp_tracking_sourcce = '';
		}
		var url = location.origin.replace('mall.', '') + (window.LOCALE == 'ID' ? '/produk-digital' : '/digital-product') + '/m/items/' + item.reference_item_id + dp_tracking_sourcce;
		bridgeCallHandler('navigate', {url: url});
	}

	function rating_handler(e)
	{
		if(!tapManager.tap())
			return;
		e.preventDefault();
		e.stopPropagation();
		var $node = $(this);
		var item = itemOfNode($node);
		if(!item) return;

		var targetUrl = '/shop/{0}/item/{1}/rating/'.f(item.shopid, item.itemid);
		BJUtil.navigate(targetUrl, false, false, {presentModalWebOnly: true});
	}

	function _ontap_shop_href(e)
	{
		e.preventDefault();
		e.stopPropagation();
		var $node = $(this);
		var item = itemOfNode($node);
		if(!item) return;

		if(funcOnTapShop)
			funcOnTapShop(item, allItems.indexOf(item));

		var url = '/shop/#shopid=' + item.shopid;
		_ontap_href($node, url, '/shop/', bridge_capture_link_shop_callback);
	}

	function stringifyFilter(k, v)
	{
		if(k == '__node__' || k == '__image__')
		{
			return undefined;
		}
		return v;
	}
	function itemToString(item)
	{
		return JSON.stringify(item, stringifyFilter);
	}

	function register_node_events($node)
	{
		if(window.WebViewJavascriptBridge)
		{
			$node.find('a.item-href').each(function(){
				var $self = $(this);
				$self.off('click').off("tap").on("tap", _ontap_item_href);


				$self.on('touchstart', function(e){
					tapManager.touchstart(e);
				});
				////Note: Only apply the 'pressed' effect after 100ms so that touch-and-scroll won't trigger it.
				//var pressTimeout = setTimeout(function(){
				//	$self.addClass('pressed');
				//}, 100);
				$self.on('touchend', function(e){
					//clearTimeout(pressTimeout);
					//This is necessary for Android Shopee app which won't trigger tap (and thus won't depress) after long press.
					//Note: Don't do this for 'tap' because it would double-trigger the effect.
					if(e.type == 'touchend')
					{
						//$self.removeClass('pressed');
						tapManager.touchend(e);
					}
				});
				$self.on('touchcancel touchmove', function(e){
					//clearTimeout(pressTimeout);
					//$self.removeClass('pressed');
					if(e.type == 'touchmove')
						tapManager.touchmove(e);
				});
			});

			$node.find('a.shop-href').each(function(){
				var $self = $(this);
				$self.off('click').off("tap").on("tap", _ontap_shop_href);
			});
		}
		else
		{
			$node.find('a.item-href').on('tap', function(e){
				var $self = $(this);
				var item = itemOfNode($self);
				if(!item) return;

				if(funcOnTapItemCard)
					funcOnTapItemCard(item, allItems.indexOf(item));

				if (item.item_type == 1) {
					redirect_to_dp_item(item)
				}

				bridgeCallHandler('navigate', {
					url: this.href,
					target: this.target
				});
				
				return false;
			});
		}
		// Update 20170328: Changed from .item_rating to .item_rating__rating-info to disable navigation if no rating.
		$node.find('.item_rating__rating-info').off('click').off("tap").on("tap", rating_handler);
		$node.find(".owner").on("tap", owner_callback);
		$node.find('.item-status .likes').on('tap',card_like_handler);
		$node.find('.item-status .comments').on('tap',card_comment_handler);
		$node.find('.btn-hide').on('tap',card_hide_handler);
		$node.find('.unlike').off('tap').on('tap', card_unlike_handler);
		$node.find('.free-shipping-icon').on('tap',free_shipping_handler);

		setTimeout(function() {
			var likesWidth = $node.find('.likes').width();
			var ratesWidth = $node.find('.item_rating').width();
			var totalWidth = likesWidth + ratesWidth;
			var holderWidth = $node.find('.bottom.normal').width();
			if (totalWidth > holderWidth) {
				$node.find('.item_rating__rating-info__count').hide();
			}
		}, 0);

		if(isInBoostPage && boostManager) {
			boostManager.listenPtimeCountDown($node.find('.pcountdown'));
			boostManager.listenBoost($node.find('.boost-btn'));
		}
	}

	function showNode($node)
	{
		// Use visibility to avoid modifying render tree. We use absolute position anyway.
		//$node.css({'visibility': 'visible'});
		$node[0].style.visibility = 'visible';
	}
	function hideNode($node)
	{
		// Use visibility to avoid modifying render tree. We use absolute position anyway.
		//$node.css({'visibility': 'hidden'});
		$node[0].style.visibility = 'hidden';

		// !!! Disabled the following because on certain phones (such as XiaoMi Mi3), the translate() trick is extremely sluggish. So we choose to use visibility style and bear the cost of style-recalc.
		// NOTE: Use this to avoid recalc of styles in showNode() (because we don't need showNode() anymore -- reflow will set the translate() in one go).
		//$node.css({'transform': 'translate(100000px, 100000px)'});
		//if($node[0])
		//	$node[0].style.transform = 'translate(100000px, 100000px)';
	}

	var loadedImages = [];
	function preloadImage(imageUrl)
	{
		if(!isIOS())
			return;
		if(!isShopeeApp() && !isChromeIOS())
			return;

		// Need to retain decoded images in memory so that iOS's UIWebView could render it during scrolling.
		if(imageUrl)
		{
			var image = new Image();
			image.src = imageUrl;
			loadedImages.push(image);
		}
	}

	//function getTranslateY($node)
	//{
	//	if(!$node) return 0;
	//	var ret = parseInt($node.css('transform').split(',')[5]);
	//	return isNaN(ret) ? 0 : ret;
	//}

	var curViewPortY = -1;
	var curRenderedStartIdx = -1;
	var curRenderedEndIdx = -1;
	var RENDER_THRESHOLD = 10;
	//var RENDER_PRELOAD_IMAGE_LOOKAHEAD = 10;
	// !!! Note: Be very careful for the buffer! It could crash the app on larger screen phones with lower memory (such as Samsung Note 2 or iPhone 6 Plus).
	var RENDER_BUFFER_FORWARD = (isAndroid() ? 4 : 4);
	var RENDER_BUFFER_BACKWARD = (isAndroid() ? 0.5: 0.5);

	var rendering = false;
	function updateViewPort(force, reflowOnly, skipLoadMore)
	{
		if(!root)
			return;

		// Update: root.position().top will trigger relayout. So we disabled it. Now solely rely on caller page to set ROOT_Y_OFFSET before/after translating root position.
		// Note: root.position().top is to remove any temporary translation that's added to root (for example during tab switching in main-cate page).
		// !!! NOTE: $(window).scrollTop() WILL force synchronous re-layout!!! And jank will happen.
		var scrollTop = Math.max(0, scrollTopFunc() - /*root.position().top -*/ ROOT_Y_OFFSET - ROOT_Y); //getTranslateY(root)
		if(!force && curViewPortY == scrollTop)
		{
			// console.log('updateViewPort.skipping-sameTop', curViewPortY, scrollTop);
			return;
		}

		// !!! SUPER TRICKY: The onScroll event triggered by our iOS app is actually causing the JS running in two different threads (app-triggered JS runs on the app's UI thread; whereas webpage JS runs in WebCoreThread).
		// Without this checking, double-entrance will cause interlaced rendering, and result is wrong.
		if(rendering)
		{
			// console.log('updateViewPort.skipping-double-entrance!!!');
			return;
		}
		rendering = true;

		var viewPortStartIdx = Math.max(0, Math.floor(scrollTop / CARD_HEIGHT) * 2);
		var viewPortEndIdx = Math.ceil((scrollTop + WINDOW_HEIGHT) / CARD_HEIGHT) * 2;
		// console.log('updateViewPort: root=' + itemRoot + ' dir=' + scrollDirection + " ROOT_Y_OFFSET=" + ROOT_Y_OFFSET + " ROOT_Y=" + ROOT_Y + " scrollTop=" + scrollTop + " curRenderedStartIdx=" + curRenderedStartIdx + " curRenderedEndIdx=" + curRenderedEndIdx + " viewPortStartIdx=" + viewPortStartIdx + " viewPortEndIdx=" + viewPortEndIdx);

		if(force || curRenderedStartIdx < 0 || curRenderedEndIdx < 0 || // Forced or never rendered
			curRenderedEndIdx < viewPortStartIdx || curRenderedStartIdx > viewPortEndIdx || // Or rendered view port does not intersect with current view port
			(scrollDirection < 0 && viewPortStartIdx - curRenderedStartIdx < RENDER_THRESHOLD) || // Or rendered view port is going to be exhausted due to scrolling
			(scrollDirection >= 0 && curRenderedEndIdx - viewPortEndIdx < RENDER_THRESHOLD)
		)
		{
			curViewPortY = scrollTop;

			var RENDER_BUFFER_UP = WINDOW_HEIGHT;
			var RENDER_BUFFER_DOWN = WINDOW_HEIGHT;
			if(scrollDirection >= 0)
			{
				RENDER_BUFFER_UP *= RENDER_BUFFER_BACKWARD;
				RENDER_BUFFER_DOWN *= RENDER_BUFFER_FORWARD;
			}
			else if(scrollDirection < 0)
			{
				RENDER_BUFFER_UP *= RENDER_BUFFER_FORWARD;
				RENDER_BUFFER_DOWN *= RENDER_BUFFER_BACKWARD;
			}

			var renderStartIdx = Math.max(0, Math.floor((scrollTop - RENDER_BUFFER_UP) / CARD_HEIGHT) * 2);
			var renderEndIdx = Math.min(allItems.length, Math.ceil((scrollTop + WINDOW_HEIGHT + RENDER_BUFFER_DOWN) / CARD_HEIGHT) * 2);

			// Note: DO NOT return here. Because we still need to process the load-more logic.
			if(force || (curRenderedStartIdx != renderStartIdx || curRenderedEndIdx != renderEndIdx))
			{
				// console.log('updateViewPortRender: root=' + itemRoot + ' dir=' + scrollDirection + " upBuffer=" + RENDER_BUFFER_UP + " downBuffer=" + RENDER_BUFFER_DOWN + " viewPortStartIdx=" + viewPortStartIdx + " viewPortEndIdx=" + viewPortEndIdx + " renderStart=" + renderStartIdx + " renderEnd=" + renderEndIdx);

				if(reflowOnly)
				{
					reflowBatch(renderStartIdx, renderEndIdx);
				}
				else
				{
					renderBatch(renderStartIdx, renderEndIdx);
				}
			}
		}
		//Disabled. Too slow.
		//else if(isIOS && isShopeeApp())
		//{
		//	if(scrollDirection >= 0)
		//	{
		//		for(var i=curRenderedEndIdx; i<curRenderedEndIdx+RENDER_PRELOAD_IMAGE_LOOKAHEAD&&i<allItems.length; i++)
		//			preloadImageForItem(allItems[i]);
		//	}
		//	else if(scrollDirection < 0)
		//	{
		//		for(var i=curRenderedStartIdx; i>curRenderedStartIdx-RENDER_PRELOAD_IMAGE_LOOKAHEAD&&i>=0; i--)
		//			preloadImageForItem(allItems[i]);
		//	}
		//}

		if(!skipLoadMore)
		{
			var LOAD_LOOK_AHEAD = WINDOW_HEIGHT * 6;
			var loodLookAheadEndIdx = Math.ceil((scrollTop + WINDOW_HEIGHT + LOAD_LOOK_AHEAD) / CARD_HEIGHT) * 2;
			if(loodLookAheadEndIdx > allItems.length && isLoadMore)
			{
				// console.log("loadMore: allItemCount=" + allItems.length + ",loadLookAheadEndIdx=" + loodLookAheadEndIdx);
				loadMore();
			}
		}
		rendering = false;
	}

	function loadMore(done){
		if(loadingMoreAsync || !isLoadMore)
			return;

		loadingMoreAsync = true;

		if(isActive)
			$loadingText.show();
		var ret = pullItemData(function(e){
			updateRootHeight(true);
			// !!! 3rd param is to avoid triggering loadMore again.
			updateViewPort(true, false, true);
			loadingMoreAsync = false;
			if(isActive)
				$loadingText.hide();
			// Do this after updating the page, so that callback func can do proper subsequent processing (for example in category-items.js)
			if(done)
				done(e);
			// if(initialLoadingTimer)
			// {
			// 	clearTimeout(initialLoadingTimer);
			// 	initialLoadingTimer = null;
			// }
			// if(isActive)
			// 	$initialLoadingIndicator.hide();
		});
		//False value means async processing didn't start (and therefore the above callback will never be called), so we MUST mark loading as false.
		if(!ret)
		{
			if(isActive)
				$loadingText.hide();
			loadingMoreAsync = false;
		}
	}

	var scrollDirection = 0;
	var lastScrollTopForDir = -1;
	var ignoreNextScroll = false;

	//var renderLastScrollTop = -1;
	//var renderLastTs = 0;
	//var renderCoolDown = false;

	function onScroll(event, documentHeightOverride, windowHeightOverride, scrollTopOverride) {
		if(!isActive) return;
		if(ignoreNextScroll)
		{
			ignoreNextScroll = false;
			return;
		}
		// !!! NOTE: $(window).scrollTop() WILL force synchronous re-layout!!! And jank will happen.
		var scrollTop = scrollTopOverride || scrollTopFunc();

		// Following logic is no longer used because the crashing part is actually from the 3D CSS style of swiper.
		//var duration = curTs() - renderLastTs;
		//var distance = Math.abs(scrollTop - renderLastScrollTop);
		//var speed = 1000 * distance / duration; // px / sec
		//var FPS = 1; //Don't go beyond this FPS.
		//var THRESHOLD = FPS * WINDOW_HEIGHT;
		//console.log("batch-onscroll: distance=" + distance + " duration=" + duration + " speed=" + speed + " threshold=" + THRESHOLD + " coolDown=" + renderCoolDown);

		//renderLastScrollTop = scrollTop;
		//renderLastTs = curTs();

		// To avoid iPhone 6 Plus out of memory crashing, we should skip rendering if the scrolling velocity is huge.
		//if(speed >= THRESHOLD)
		//{
		//	//$window.scrollTop(scrollTopFunc());
		//	//renderCoolDown = 10;
		//	renderCoolDown = true;
		//	setTimeout(function(){renderCoolDown = false; $window.scroll();}, 1000);
		//}
		//if(renderCoolDown)
		//{
		//	return;
		//}

		// NOTE: When scrollTop is 0, force rendering because this may happen due to "jump to top" and we need to make sure the last scrolling position is indeed rendered, even when it happens within 80ms.
		// NOTE: Force rendering if is on PC (no need to optimize for PC).
		if(isPC() || curTs() - lastScrollMs > 80 || scrollTop == 0)
		{
			//var startMs = curTs();

			var isBouncing = !(scrollTop + WINDOW_HEIGHT <= curDocumentHeight/*$document.height()*/ && scrollTop >= 0);
			//Ignore bouncing.
			if(!isBouncing)
			{
				if(scrollTop > lastScrollTopForDir)
					scrollDirection = 1;
				else if(scrollTop < lastScrollTopForDir)
					scrollDirection = -1;
				else
					scrollDirection = 0;
				lastScrollTopForDir = scrollTop;
			}

			//console.log("batchitem-scroll");

			// Do not render if is bouncing (including bouncing at bottom, or right after pull-to-reload in iOS).
			if(!isBouncing)
			{
				// Force render if scrollTop is 0 (to make sure back-to-top gets rendered).
				updateViewPort(scrollTop == 0, false);
			}

			lastScrollMs = curTs();

			//var processTime = curTs() - startMs;
			//console.log('onScroll: processTime=' + processTime);
		}

		gtopOnScroll(scrollTop);
	}

	function observeScroll() {
		$window.on('scroll', onScroll);
	}

	function injectTouchEvents($items)
	{
		// Do not do the tap-highlight when accessing from browser because the following logic would mess with the target=_blank thing.
		if (skipAnimation || !isShopeeApp()) return;

		$items.on('touchstart', function(e){
			tapManager.touchstart(e);
			var $self = $(this);
			////Note: Only apply the 'pressed' effect after 100ms so that touch-and-scroll won't trigger it.
			//var pressTimeout = setTimeout(function(){
			//	$self.addClass('pressed');
			//}, 100);
			$self.on('tap touchend', function(e){
				//clearTimeout(pressTimeout);
				//This is necessary for Android Shopee app which won't trigger tap (and thus won't depress) after long press.
				//Note: Don't do this for 'tap' because it would double-trigger the effect.
				if(e.type == 'touchend')
				{
					//$self.removeClass('pressed');
					tapManager.touchend(e);
				}
			});
			$self.on('touchcancel touchmove', function(e){
				//clearTimeout(pressTimeout);
				//$self.removeClass('pressed');
				if(e.type == 'touchmove')
					tapManager.touchmove(e);
			});
		});


		//Used by the following block.
		var func = function($self, e, tapFuncs) {
			//Run through the original tap even handlers.
			if(tapFuncs)
			{
				for(var i=0; i<tapFuncs.length; i++)
				{
					var func = tapFuncs[i];
					if(func.handler)
						func.handler.call($self[0], e);
				}
			}
		};

		//!!! This hack is to make the "pressed" animation work.
		//MUST do the following AFTER the bridge_capture_link() calls.
		$href_items.each(function(idx){
			var $self = $(this);
			var eventList = $._data(this, "events");
			var tapFuncs = null;
			// Capture and override tap events of href items.
			if(eventList && eventList.tap)
			{
				tapFuncs = eventList.tap;
				eventList.tap = null;
			}
			//No longer needed, as we already skip the whole block for non-app access.
			//else if(isPC())
			//{
			//	//Just in case some stupid Windows PC sometimes cannot trigger default a-href actions.
			//	tapFuncs = [{handler:function(e){
			//			location.href = $self.attr('href');
			//		}}];
			//}

			var $parent = $self.parents('.item-card');
			//Do animation on tapping card.
			$parent.off('tap').on('tap', function(e){
				//console.log("itemcard.tap");
				if(!tapManager.tap())
					return;
				var $_self = $(this);
				// If already pressed, just depress and then trigger.
				if($_self.hasClass('pressed'))
				{
					//Depress.
					$_self.removeClass('pressed');
					//Trigger. Note we don't wait for the above to take visual effect.
					func($_self, e, tapFuncs);
				}
				// If not yet pressed, then press, depress, and then trigger.
				else
				{
					//Press.
					$_self.addClass('pressed');
					//Depress. Note this setTimeout() is necessary for the press effect to be done before next step.
					setTimeout(function(){
						$_self.removeClass('pressed');
						//Trigger. Note we don't wait for the above to take visual effect.
						func($_self, e, tapFuncs);
					}, 50);
				}
			});
		});
	}

	// Prepare for removing this batch item from screen.
	function destroy()
	{
		if(window.WebViewJavascriptBridge)
		{
			window.WebViewJavascriptBridge.unregisterHandler("viewWillReappear", getReAppearHandlerId());
		}
		//Don't care about scrolling anymore. Better do this one the first to avoid unnecessary scrolling triggering in the following operations.
		if($window)
			$window.off('scroll', null, onScroll);
		// It's important to release the height.
		if(root)
			root.css({'height':''});
		// Stop working.
		retire();
		if(allItems)
		{
			//Put back all nodes to freeNodes.
			for(var i=0; i<allItems.length; i++)
				resetNode(allItems[i]);
		}
		// This is too slow. Skipped. Should manipulate DOM in caller.
		// if(freeNodes)
		// {
		// 	//Remove all nodes from DOM tree.
		// 	for(var i=0; i<freeNodes.length; i++)
		// 		freeNodes[i].remove();
		// }
		allItems = [];
		freeNodes = [];
	}

	function getNode(selector, root)
	{
		// Get it from root first. If not exists then try global.
		var ret = $(selector, root);
		if(ret.length == 0)
			ret = $(selector);
		return ret;
	}

	function restoreState(restoreToState, initialLoadCallback)
	{
		if(!isActive)
			return false;

		var data = null;
		try
		{
			data = JSON.parse(restoreToState);
		}
		catch(e)
		{
			console.log('batchItem.restoreState.e|apiUrl=' + apiUrl, e);
			return false;
		}
		if(!data)
			return false;

		var _isLoadMore = true;
		if(data.isLoadMore != null)
			_isLoadMore = data.isLoadMore;
		if(data.offset != null)
			offset = data.offset;
		if(data.allItems != null)
			allItems = data.allItems;
		updateRootHeight(true);
		updateViewPort(true);

		if(onLoadCallback)
			onLoadCallback(allItems);
		if(!_isLoadMore)
			markNoMore();
		if(initialLoadCallback)
			initialLoadCallback({items: allItems});

		return true;
	}

	function getStateToSave()
	{
		if(!isActive) return null;
		try
		{
			return JSON.stringify({'allItems':allItems, 'isLoadMore': isLoadMore, 'offset': offset}, stringifyFilter);
		}
		catch(e)
		{
			console.log('batchItem.getStateToSave.e|apiUrl=' + apiUrl, e);
			return null;
		}
	}

	function initRoots()
	{
		WINDOW_HEIGHT = $window.height();

		cardTemplate = cardTemplate || $("#item-card").text();

		root = $("ul."+itemRoot);
		$loadingText = getNode('.loading-text', root.parent());
		// $initialLoadingIndicator = getNode('.initial-loading-indicator', root.parent());
		$nomore = getNode('.nomore', root.parent());
		$emptyHolder = getNode(emptyHolderClassName, root.parent());
		$emptyHolder.hide();

		var rootHeight = root.height();
		var rootMinHeight = parseInt(root.css('min-height'));

		if(ROOT_WIDTH == null) ROOT_WIDTH = root.width();
		if(ROOT_HEIGHT == null) ROOT_HEIGHT = rootHeight;
		//Only use root intial height as basic height when min-height isn't specified. Because min-height doesn't really count as basic height.
		if(ROOT_BASIC_HEIGHT == null) ROOT_BASIC_HEIGHT = (rootHeight == rootMinHeight ? 0 : rootHeight);
		if(ROOT_EMPTY_HEIGHT == null) ROOT_EMPTY_HEIGHT = ROOT_HEIGHT || 0;
		// We should always get the top position relative to the document, so we shouldn't use "root[0].offsetTop" which returns value relative to the parent node.
		// The problem happened in main-cate page with banners -> after switching sorting method -> the ROOT_Y of new batch-item would be calculated wrongly and the items will be rendered with glitches.
		if(ROOT_Y == null) {
			var t = root.offset();
			if(t) {
				ROOT_Y = t.top;
				var s = scrollTopFunc();
				// Tricky: jQuery.offset().top will SOMETIMES return negative value if page has scrolled; we need to negate that.
				if(s < 0)
					ROOT_Y -= s;
			}
			if(ROOT_Y == null) {
				ROOT_Y = 0;
			}
		}

		CARD_WIDTH = Math.ceil(ROOT_WIDTH / 2);
		CARD_HEIGHT = CARD_WIDTH + 106; // 106 is the height of the bottom area in the card.
		if(cardTemplate.indexOf('shop-part') > 0)
		{
			hasShopPart = true;
			CARD_HEIGHT += 25; //Shop-part height.
		}
		// Note: the second condition alone doesn't work because in Shop page, all item careds would have the part but only Shop owner's own items would show them (the "Likes" tab cards won't show them).
		if(isInBoostPage && cardTemplate.indexOf('boost-container') > 0)
			CARD_HEIGHT += 45; //Boost height.

		// DO NOT prepopulate any cards now, because they will occupy page height due to visibility:hidden.
		// We now populate exact number of cards on-demand instead.
		// Pre-populate 1-screenful of item cards first.
		//var SCREENFUL = 1;//isIOS() ? 10 : 5;
		//CARD_COUNT_PER_SCREEN = Math.ceil(WINDOW_HEIGHT * SCREENFUL / CARD_HEIGHT) * 2;
		//addNewNodes(CARD_COUNT_PER_SCREEN);

		$document.on(BJUtil.EVENT_DISMISS_ADULT, onDismissAdult);
	}

	function truncateItems(lengthAfterTruncate)
	{
		root.css({'height':''});
	}

	// Used by home_new.js, after first attempt fails.
	function loadCurrentBatch()
	{
		loadMore();
	}


	var forceNextRender = false;
	function foreground()
	{
		setActive(true);
		// Ignore next scroll event because the updateRootHeight() would trigger the scroll event, and causing visual glitches such as empty screen.
		ignoreNextScroll = true;
		updateRootHeight(true);
		// Do not render here. Let the caller decide whether to call render() in different situations.
		//render();
		if(!isLoadMore)
		{
			$nomore.show();
			$loadingText.hide();
		}
		else
		{
			$nomore.hide();
		}
	}

	function background()
	{
		setActive(false);
		if(root)
			root.css({'height': ''});
	}

	function setActive(v)
	{
		if(v != isActive)
			lastScrollTopForDir = -1;
		if(!v)
			forceNextRender = true;

		isActive = v;
	}

	function loadData(options, done){
		setOptions(options);

		var restoreToState = null;
		if (options && "restoreToState" in options) {
			restoreToState = options.restoreToState;
		}

		initRoots();

		if(!skipPrepareRoot)
			BatchItem.prepareRootNode(root);

		//Totally disabled this. The logic no longer applies because we now pre-populate a number of cards in initRoots() already.
		//var preloaded = root.children("li");
		//if(preloaded.length == 0){ //shop page does not have preloaded cards and offset is not overwritten by others, since default is 0
		//	offset = 0;
		//}
		//else
		//{
		//	offset = preloaded.length;
		//	like_handler();
		//	comment_handler();
		//	//if (offset < BATCH_LOAD_LIMIT){
		//	//	$loadingText.hide();
		//	//	$nomore.show();
		//	//	isLoadMore = false;
		//	//}
		//}

		observeScroll();

		// Note: needLoad could NOT be tied to "isActive", because there are cases when a list should be loaded when it's not active (for example in multiple swipeable tabs).
		// Therefore we add a new option to skip initial load if needed.
		var needLoad = !(options && options.skipInitialLoad);
		if(restoreToState)
		{
			if(restoreState(restoreToState, done))
			{
				needLoad = false;
			}
		}
		if(needLoad)
		{
			// //delay showing of the initial loading text. only show it when loading takes longer than 2 seconds.
			// if(showInitialLoading && isActive)
			// {
			// 	initialLoadingTimer = setTimeout(function(){
			// 		if(isActive)
			// 			$initialLoadingIndicator.show();
			// 	}, 2000);
			// }
			loadMore(done); //Initial loading.
		}

		// parital update UI
		if (window.WebViewJavascriptBridge) {
			var bridge = window.WebViewJavascriptBridge;
			bridge.registerHandler("viewWillReappear",function(e) {
				//location.reload();

				bridge.callHandler("load", {key : "pending-update"}, function(e) {
					if (e.data) {
						var data = JSON.parse(e.data);
						//console.log(data);
						if (data.itemId) {
							for(var i=0; i<allItems.length; i++)
							{
								var item = allItems[i];
								if(item.itemid == data.itemId)
								{
									var shopId = item.shopid;
									var url = "/item_data/?shopid=" + shopId + "&itemid=" + data.itemId;
									// Disabled this because anti-crawler sig is calculated from query string rather than path.
									// var url = "/shop/"+shopId+"/item/"+data.itemId+"/";
									(function(item){getJSON(url, function(e){
										item.liked = e.item_detail.liked;
										item.icon_like = e.item_detail.icon_like;
										item.liked_count = e.item_detail.liked_count;
										item.cmt_count = e.item_detail.cmt_count;
										renderItem(item);
										console.log("pending-update: ", e);
									}).fail(function() {})})(item);
									bridge.callHandler("save", {key : 'pending-update', data : '', persist : 0}, function() {});
									break;
								}
							}
						}
					}
				});
			}, getReAppearHandlerId());
		}
	}

	function getReAppearHandlerId()
	{
		return 'batch-item-reappear-' + apiUrl;
	}

	function render(force)
	{
		updateViewPort(forceNextRender || force);
		forceNextRender = false;
	}

	var curViewPortStartIdx = -1;
	var curViewPortEndIdx = -1;
	var curViewPortStartEndScrollTop = -1;
	var EMPTY_ARRAY = [];

	function itemsOnScreen(doRounding)
	{
		doRounding = doRounding || false;
		if(!isActive) return EMPTY_ARRAY;

		var scrollTop = scrollTopFunc();
		if(scrollTop != curViewPortStartEndScrollTop)
		{
			curViewPortStartEndScrollTop = scrollTop;
			var offsetTop = (offsetObj && offsetObj.offsetTop) ? offsetObj.offsetTop : 0;
			var offsetBottom = (offsetObj && offsetObj.offsetBottom) ? offsetObj.offsetBottom : 0;
			var vpstart = scrollTop - ROOT_Y + offsetTop;
			var vpend = vpstart + WINDOW_HEIGHT - offsetBottom;
			if(vpend <= 0 || vpstart >= curRootHeight)
			{
				curViewPortStartIdx = -1;
				curViewPortEndIdx = -1;
			}
			else
			{
				if(doRounding)
				{
					curViewPortStartIdx = Math.max(0, Math.round(vpstart / CARD_HEIGHT) * 2);
					curViewPortEndIdx = Math.round(vpend / CARD_HEIGHT) * 2;
				}
				else
				{
					curViewPortStartIdx = Math.max(0, Math.floor(vpstart / CARD_HEIGHT) * 2);
					curViewPortEndIdx = Math.ceil(vpend / CARD_HEIGHT) * 2;
				}
			}
		}

		var start = curViewPortStartIdx;
		var end = curViewPortEndIdx;
		if(start < 0 || end < 0)
			return EMPTY_ARRAY;

		var toRet = allItems.slice(start, end);
		toRet.map(function(item, index) {
			item['batch_item_index'] = index+start;
			item['searchKeyword'] = searchKeyword;
			item['sortType'] = sortType;
		});
		return toRet;
	}

	function retire()
	{
		// Retire is used when this batchItem is no longer the first one in the screen, and thus shouldn't manage the following items.
		// $initialLoadingIndicator = $();
		if($loadingText)
			$loadingText.hide();
		$loadingText = $();
		$emptyHolder = $();
		// DO NOT call markNoMore because it has side effects.
		isLoadMore = false;
		$nomore = $();

	}

	return {
		loadData:loadData,
		destroy:destroy,
		markNoMore:markNoMore,
		options:setOptions,
		truncateItems:truncateItems,
		tapManager:tapManager,
		retire:retire,
		setActive:setActive,
		foreground: foreground,
		background: background,
		onScroll: onScroll,
		loadCurrentBatch: loadCurrentBatch,
		render: render,
		itemOfNode: itemOfNode,
		renderItem: renderItem,
		allItems: function(){return allItems;},
		itemsOnScreen: itemsOnScreen,
		$root: function(){return root;},
		getStateToSave: getStateToSave,
	}
};

BatchItem.prepareRootNode = function($node)
{
	//160905: Apparently iPhone 6s Plus also crashes for users. So we now detect both 6 plus and 6s plus.
	//160420: With 3d, iPhone 6 Plus definitely crashes when scrolling fast. I've now disabled this for iPhone 6 Plus screen size. But there's no way to distinguish 6 Plus and 6s Plus.
	//160330: !!! TRICKY: translate3d() WILL mess with gtab's tab switching (which depends on transform -> translate). So if your item-list is itself a gtab swiper-slide, you must do the following in your parent page.
	//160320: Re-enabled this because it makes the scroll-and-rendering on iOS generally smoother. Without this, while scrolling, item list could be left empty for a while after changing scrolling direction. (Side-effect: burpScroll appears jerky, but we have to bear with it.)
	//160216: Apparently no longer needed with the new rendering mechanism.
	//This could be sluggish but still need to keep it because it's even worse user experience if images don't load while scrolling.
	//Tricky: To make iOS render preloaded images while scrolling, need to add this style to body node:
	// -webkit-transform: translateZ(0);
	// Note: Older iPhones (such as 5s) are sluggish with this if they are in Battery Saving Mode, but we can only bear with it. Without this trick, the item cards won't be rendered during scrolling, which is even worse.
	if(isIOS() && !isIPhone6or6sPlus())// && screen.width > 320)
	{
		// Note: experiment shows the polyfill of "transform" is done automatically (either by jQuery or JSContext).
		$node.css({'-webkit-backface-visibility': 'hidden', '-webkit-perspective': '1000', 'transform': 'translate3d(0,0,0)'});
	}
};

function convertItemDataForTracking(item) {
	var data = {
		itemid: item.itemid,
		shopid: item.shopid,
	};
	if (item.info) { data.info = item.info; }
	if (item.recommendation_info) { data.recommendation_info = item.recommendation_info; }
	if (item.from) { data.from = item.from; }
	if (typeof item.algorithm !== 'undefined') { data.algorithm = item.algorithm; }
	if (item.hasOwnProperty('batch_item_index')) { data.item_index = item.batch_item_index }
	if (item.adsid) {
		data.is_ads = 1;
		data.location_in_ads = BJUtil.calculateLocationInAds(item.batch_item_index);
	} else {
		data.is_ads = 0;
		data.location_in_ads = null;
	}
	return data;
}
