swatch-renderer.js 50 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389
  1. /**
  2. * Copyright © Magento, Inc. All rights reserved.
  3. * See COPYING.txt for license details.
  4. */
  5. define([
  6. 'jquery',
  7. 'underscore',
  8. 'mage/template',
  9. 'mage/smart-keyboard-handler',
  10. 'mage/translate',
  11. 'priceUtils',
  12. 'jquery/ui',
  13. 'jquery/jquery.parsequery',
  14. 'mage/validation/validation'
  15. ], function ($, _, mageTemplate, keyboardHandler, $t, priceUtils) {
  16. 'use strict';
  17. /**
  18. * Extend form validation to support swatch accessibility
  19. */
  20. $.widget('mage.validation', $.mage.validation, {
  21. /**
  22. * Handle form with swatches validation. Focus on first invalid swatch block.
  23. *
  24. * @param {jQuery.Event} event
  25. * @param {Object} validation
  26. */
  27. listenFormValidateHandler: function (event, validation) {
  28. var swatchWrapper, firstActive, swatches, swatch, successList, errorList, firstSwatch;
  29. this._superApply(arguments);
  30. swatchWrapper = '.swatch-attribute-options';
  31. swatches = $(event.target).find(swatchWrapper);
  32. if (!swatches.length) {
  33. return;
  34. }
  35. swatch = '.swatch-attribute';
  36. firstActive = $(validation.errorList[0].element || []);
  37. successList = validation.successList;
  38. errorList = validation.errorList;
  39. firstSwatch = $(firstActive).parent(swatch).find(swatchWrapper);
  40. keyboardHandler.focus(swatches);
  41. $.each(successList, function (index, item) {
  42. $(item).parent(swatch).find(swatchWrapper).attr('aria-invalid', false);
  43. });
  44. $.each(errorList, function (index, item) {
  45. $(item.element).parent(swatch).find(swatchWrapper).attr('aria-invalid', true);
  46. });
  47. if (firstSwatch.length) {
  48. $(firstSwatch).focus();
  49. }
  50. }
  51. });
  52. /**
  53. * Render tooltips by attributes (only to up).
  54. * Required element attributes:
  55. * - option-type (integer, 0-3)
  56. * - option-label (string)
  57. * - option-tooltip-thumb
  58. * - option-tooltip-value
  59. * - thumb-width
  60. * - thumb-height
  61. */
  62. $.widget('mage.SwatchRendererTooltip', {
  63. options: {
  64. delay: 200, //how much ms before tooltip to show
  65. tooltipClass: 'swatch-option-tooltip' //configurable, but remember about css
  66. },
  67. /**
  68. * @private
  69. */
  70. _init: function () {
  71. var $widget = this,
  72. $this = this.element,
  73. $element = $('.' + $widget.options.tooltipClass),
  74. timer,
  75. type = parseInt($this.attr('option-type'), 10),
  76. label = $this.attr('option-label'),
  77. thumb = $this.attr('option-tooltip-thumb'),
  78. value = $this.attr('option-tooltip-value'),
  79. width = $this.attr('thumb-width'),
  80. height = $this.attr('thumb-height'),
  81. $image,
  82. $title,
  83. $corner;
  84. if (!$element.length) {
  85. $element = $('<div class="' +
  86. $widget.options.tooltipClass +
  87. '"><div class="image"></div><div class="title"></div><div class="corner"></div></div>'
  88. );
  89. $('body').append($element);
  90. }
  91. $image = $element.find('.image');
  92. $title = $element.find('.title');
  93. $corner = $element.find('.corner');
  94. $this.hover(function () {
  95. if (!$this.hasClass('disabled')) {
  96. timer = setTimeout(
  97. function () {
  98. var leftOpt = null,
  99. leftCorner = 0,
  100. left,
  101. $window;
  102. if (type === 2) {
  103. // Image
  104. $image.css({
  105. 'background': 'url("' + thumb + '") no-repeat center', //Background case
  106. 'background-size': 'initial',
  107. 'width': width + 'px',
  108. 'height': height + 'px'
  109. });
  110. $image.show();
  111. } else if (type === 1) {
  112. // Color
  113. $image.css({
  114. background: value
  115. });
  116. $image.show();
  117. } else if (type === 0 || type === 3) {
  118. // Default
  119. $image.hide();
  120. }
  121. $title.text(label);
  122. leftOpt = $this.offset().left;
  123. left = leftOpt + $this.width() / 2 - $element.width() / 2;
  124. $window = $(window);
  125. // the numbers (5 and 5) is magick constants for offset from left or right page
  126. if (left < 0) {
  127. left = 5;
  128. } else if (left + $element.width() > $window.width()) {
  129. left = $window.width() - $element.width() - 5;
  130. }
  131. // the numbers (6, 3 and 18) is magick constants for offset tooltip
  132. leftCorner = 0;
  133. if ($element.width() < $this.width()) {
  134. leftCorner = $element.width() / 2 - 3;
  135. } else {
  136. leftCorner = (leftOpt > left ? leftOpt - left : left - leftOpt) + $this.width() / 2 - 6;
  137. }
  138. $corner.css({
  139. left: leftCorner
  140. });
  141. $element.css({
  142. left: left,
  143. top: $this.offset().top - $element.height() - $corner.height() - 18
  144. }).show();
  145. },
  146. $widget.options.delay
  147. );
  148. }
  149. }, function () {
  150. $element.hide();
  151. clearTimeout(timer);
  152. });
  153. $(document).on('tap', function () {
  154. $element.hide();
  155. clearTimeout(timer);
  156. });
  157. $this.on('tap', function (event) {
  158. event.stopPropagation();
  159. });
  160. }
  161. });
  162. /**
  163. * Render swatch controls with options and use tooltips.
  164. * Required two json:
  165. * - jsonConfig (magento's option config)
  166. * - jsonSwatchConfig (swatch's option config)
  167. *
  168. * Tuning:
  169. * - numberToShow (show "more" button if options are more)
  170. * - onlySwatches (hide selectboxes)
  171. * - moreButtonText (text for "more" button)
  172. * - selectorProduct (selector for product container)
  173. * - selectorProductPrice (selector for change price)
  174. */
  175. $.widget('mage.SwatchRenderer', {
  176. options: {
  177. classes: {
  178. attributeClass: 'swatch-attribute',
  179. attributeLabelClass: 'swatch-attribute-label',
  180. attributeSelectedOptionLabelClass: 'swatch-attribute-selected-option',
  181. attributeOptionsWrapper: 'swatch-attribute-options',
  182. attributeInput: 'swatch-input',
  183. optionClass: 'swatch-option',
  184. selectClass: 'swatch-select',
  185. moreButton: 'swatch-more',
  186. loader: 'swatch-option-loading'
  187. },
  188. // option's json config
  189. jsonConfig: {},
  190. // swatch's json config
  191. jsonSwatchConfig: {},
  192. // selector of parental block of prices and swatches (need to know where to seek for price block)
  193. selectorProduct: '.product-info-main',
  194. // selector of price wrapper (need to know where set price)
  195. selectorProductPrice: '[data-role=priceBox]',
  196. //selector of product images gallery wrapper
  197. mediaGallerySelector: '[data-gallery-role=gallery-placeholder]',
  198. // selector of category product tile wrapper
  199. selectorProductTile: '.product-item',
  200. // number of controls to show (false or zero = show all)
  201. numberToShow: false,
  202. // show only swatch controls
  203. onlySwatches: false,
  204. // enable label for control
  205. enableControlLabel: true,
  206. // control label id
  207. controlLabelId: '',
  208. // text for more button
  209. moreButtonText: $t('More'),
  210. // Callback url for media
  211. mediaCallback: '',
  212. // Local media cache
  213. mediaCache: {},
  214. // Cache for BaseProduct images. Needed when option unset
  215. mediaGalleryInitial: [{}],
  216. // Use ajax to get image data
  217. useAjax: false,
  218. /**
  219. * Defines the mechanism of how images of a gallery should be
  220. * updated when user switches between configurations of a product.
  221. *
  222. * As for now value of this option can be either 'replace' or 'prepend'.
  223. *
  224. * @type {String}
  225. */
  226. gallerySwitchStrategy: 'replace',
  227. // whether swatches are rendered in product list or on product page
  228. inProductList: false,
  229. // sly-old-price block selector
  230. slyOldPriceSelector: '.sly-old-price',
  231. // tier prise selectors start
  232. tierPriceTemplateSelector: '#tier-prices-template',
  233. tierPriceBlockSelector: '[data-role="tier-price-block"]',
  234. tierPriceTemplate: '',
  235. // tier prise selectors end
  236. // A price label selector
  237. normalPriceLabelSelector: '.normal-price .price-label'
  238. },
  239. /**
  240. * Get chosen product
  241. *
  242. * @returns int|null
  243. */
  244. getProduct: function () {
  245. var products = this._CalcProducts();
  246. return _.isArray(products) ? products[0] : null;
  247. },
  248. /**
  249. * @private
  250. */
  251. _init: function () {
  252. if (_.isEmpty(this.options.jsonConfig.images)) {
  253. this.options.useAjax = true;
  254. // creates debounced variant of _LoadProductMedia()
  255. // to use it in events handlers instead of _LoadProductMedia()
  256. this._debouncedLoadProductMedia = _.debounce(this._LoadProductMedia.bind(this), 500);
  257. }
  258. if (this.options.jsonConfig !== '' && this.options.jsonSwatchConfig !== '') {
  259. // store unsorted attributes
  260. this.options.jsonConfig.mappedAttributes = _.clone(this.options.jsonConfig.attributes);
  261. this._sortAttributes();
  262. this._RenderControls();
  263. this._setPreSelectedGallery();
  264. $(this.element).trigger('swatch.initialized');
  265. } else {
  266. console.log('SwatchRenderer: No input data received');
  267. }
  268. this.options.tierPriceTemplate = $(this.options.tierPriceTemplateSelector).html();
  269. },
  270. /**
  271. * @private
  272. */
  273. _sortAttributes: function () {
  274. this.options.jsonConfig.attributes = _.sortBy(this.options.jsonConfig.attributes, function (attribute) {
  275. return parseInt(attribute.position, 10);
  276. });
  277. },
  278. /**
  279. * @private
  280. */
  281. _create: function () {
  282. var options = this.options,
  283. gallery = $('[data-gallery-role=gallery-placeholder]', '.column.main'),
  284. productData = this._determineProductData(),
  285. $main = productData.isInProductView ?
  286. this.element.parents('.column.main') :
  287. this.element.parents('.product-item-info');
  288. if (productData.isInProductView) {
  289. gallery.data('gallery') ?
  290. this._onGalleryLoaded(gallery) :
  291. gallery.on('gallery:loaded', this._onGalleryLoaded.bind(this, gallery));
  292. } else {
  293. options.mediaGalleryInitial = [{
  294. 'img': $main.find('.product-image-photo').attr('src')
  295. }];
  296. }
  297. this.productForm = this.element.parents(this.options.selectorProductTile).find('form:first');
  298. this.inProductList = this.productForm.length > 0;
  299. },
  300. /**
  301. * Determine product id and related data
  302. *
  303. * @returns {{productId: *, isInProductView: bool}}
  304. * @private
  305. */
  306. _determineProductData: function () {
  307. // Check if product is in a list of products.
  308. var productId,
  309. isInProductView = false;
  310. productId = this.element.parents('.product-item-details')
  311. .find('.price-box.price-final_price').attr('data-product-id');
  312. if (!productId) {
  313. // Check individual product.
  314. productId = $('[name=product]').val();
  315. isInProductView = productId > 0;
  316. }
  317. return {
  318. productId: productId,
  319. isInProductView: isInProductView
  320. };
  321. },
  322. /**
  323. * Render controls
  324. *
  325. * @private
  326. */
  327. _RenderControls: function () {
  328. var $widget = this,
  329. container = this.element,
  330. classes = this.options.classes,
  331. chooseText = this.options.jsonConfig.chooseText;
  332. $widget.optionsMap = {};
  333. $.each(this.options.jsonConfig.attributes, function () {
  334. var item = this,
  335. controlLabelId = 'option-label-' + item.code + '-' + item.id,
  336. options = $widget._RenderSwatchOptions(item, controlLabelId),
  337. select = $widget._RenderSwatchSelect(item, chooseText),
  338. input = $widget._RenderFormInput(item),
  339. listLabel = '',
  340. label = '';
  341. // Show only swatch controls
  342. if ($widget.options.onlySwatches && !$widget.options.jsonSwatchConfig.hasOwnProperty(item.id)) {
  343. return;
  344. }
  345. if ($widget.options.enableControlLabel) {
  346. label +=
  347. '<span id="' + controlLabelId + '" class="' + classes.attributeLabelClass + '">' +
  348. $('<i></i>').text(item.label).html() +
  349. '</span>' +
  350. '<span class="' + classes.attributeSelectedOptionLabelClass + '"></span>';
  351. }
  352. if ($widget.inProductList) {
  353. $widget.productForm.append(input);
  354. input = '';
  355. listLabel = 'aria-label="' + $('<i></i>').text(item.label).html() + '"';
  356. } else {
  357. listLabel = 'aria-labelledby="' + controlLabelId + '"';
  358. }
  359. // Create new control
  360. container.append(
  361. '<div class="' + classes.attributeClass + ' ' + item.code + '" ' +
  362. 'attribute-code="' + item.code + '" ' +
  363. 'attribute-id="' + item.id + '">' +
  364. label +
  365. '<div aria-activedescendant="" ' +
  366. 'tabindex="0" ' +
  367. 'aria-invalid="false" ' +
  368. 'aria-required="true" ' +
  369. 'role="listbox" ' + listLabel +
  370. 'class="' + classes.attributeOptionsWrapper + ' clearfix">' +
  371. options + select +
  372. '</div>' + input +
  373. '</div>'
  374. );
  375. $widget.optionsMap[item.id] = {};
  376. // Aggregate options array to hash (key => value)
  377. $.each(item.options, function () {
  378. if (this.products.length > 0) {
  379. $widget.optionsMap[item.id][this.id] = {
  380. price: parseInt(
  381. $widget.options.jsonConfig.optionPrices[this.products[0]].finalPrice.amount,
  382. 10
  383. ),
  384. products: this.products
  385. };
  386. }
  387. });
  388. });
  389. // Connect Tooltip
  390. container
  391. .find('[option-type="1"], [option-type="2"], [option-type="0"], [option-type="3"]')
  392. .SwatchRendererTooltip();
  393. // Hide all elements below more button
  394. $('.' + classes.moreButton).nextAll().hide();
  395. // Handle events like click or change
  396. $widget._EventListener();
  397. // Rewind options
  398. $widget._Rewind(container);
  399. //Emulate click on all swatches from Request
  400. $widget._EmulateSelected($.parseQuery());
  401. $widget._EmulateSelected($widget._getSelectedAttributes());
  402. },
  403. /**
  404. * Render swatch options by part of config
  405. *
  406. * @param {Object} config
  407. * @param {String} controlId
  408. * @returns {String}
  409. * @private
  410. */
  411. _RenderSwatchOptions: function (config, controlId) {
  412. var optionConfig = this.options.jsonSwatchConfig[config.id],
  413. optionClass = this.options.classes.optionClass,
  414. sizeConfig = this.options.jsonSwatchImageSizeConfig,
  415. moreLimit = parseInt(this.options.numberToShow, 10),
  416. moreClass = this.options.classes.moreButton,
  417. moreText = this.options.moreButtonText,
  418. countAttributes = 0,
  419. html = '';
  420. if (!this.options.jsonSwatchConfig.hasOwnProperty(config.id)) {
  421. return '';
  422. }
  423. $.each(config.options, function (index) {
  424. var id,
  425. type,
  426. value,
  427. thumb,
  428. label,
  429. width,
  430. height,
  431. attr,
  432. swatchImageWidth,
  433. swatchImageHeight;
  434. if (!optionConfig.hasOwnProperty(this.id)) {
  435. return '';
  436. }
  437. // Add more button
  438. if (moreLimit === countAttributes++) {
  439. html += '<a href="#" class="' + moreClass + '">' + moreText + '</a>';
  440. }
  441. id = this.id;
  442. type = parseInt(optionConfig[id].type, 10);
  443. value = optionConfig[id].hasOwnProperty('value') ?
  444. $('<i></i>').text(optionConfig[id].value).html() : '';
  445. thumb = optionConfig[id].hasOwnProperty('thumb') ? optionConfig[id].thumb : '';
  446. width = _.has(sizeConfig, 'swatchThumb') ? sizeConfig.swatchThumb.width : 110;
  447. height = _.has(sizeConfig, 'swatchThumb') ? sizeConfig.swatchThumb.height : 90;
  448. label = this.label ? $('<i></i>').text(this.label).html() : '';
  449. attr =
  450. ' id="' + controlId + '-item-' + id + '"' +
  451. ' index="' + index + '"' +
  452. ' aria-checked="false"' +
  453. ' aria-describedby="' + controlId + '"' +
  454. ' tabindex="0"' +
  455. ' option-type="' + type + '"' +
  456. ' option-id="' + id + '"' +
  457. ' option-label="' + label + '"' +
  458. ' aria-label="' + label + '"' +
  459. ' option-tooltip-thumb="' + thumb + '"' +
  460. ' option-tooltip-value="' + value + '"' +
  461. ' role="option"' +
  462. ' thumb-width="' + width + '"' +
  463. ' thumb-height="' + height + '"';
  464. swatchImageWidth = _.has(sizeConfig, 'swatchImage') ? sizeConfig.swatchImage.width : 30;
  465. swatchImageHeight = _.has(sizeConfig, 'swatchImage') ? sizeConfig.swatchImage.height : 20;
  466. if (!this.hasOwnProperty('products') || this.products.length <= 0) {
  467. attr += ' option-empty="true"';
  468. }
  469. if (type === 0) {
  470. // Text
  471. html += '<div class="' + optionClass + ' text" ' + attr + '>' + (value ? value : label) +
  472. '</div>';
  473. } else if (type === 1) {
  474. // Color
  475. html += '<div class="' + optionClass + ' color" ' + attr +
  476. ' style="background: ' + value +
  477. ' no-repeat center; background-size: initial;">' + '' +
  478. '</div>';
  479. } else if (type === 2) {
  480. // Image
  481. html += '<div class="' + optionClass + ' image" ' + attr +
  482. ' style="background: url(' + value + ') no-repeat center; background-size: initial;width:' +
  483. swatchImageWidth + 'px; height:' + swatchImageHeight + 'px">' + '' +
  484. '</div>';
  485. } else if (type === 3) {
  486. // Clear
  487. html += '<div class="' + optionClass + '" ' + attr + '></div>';
  488. } else {
  489. // Default
  490. html += '<div class="' + optionClass + '" ' + attr + '>' + label + '</div>';
  491. }
  492. });
  493. return html;
  494. },
  495. /**
  496. * Render select by part of config
  497. *
  498. * @param {Object} config
  499. * @param {String} chooseText
  500. * @returns {String}
  501. * @private
  502. */
  503. _RenderSwatchSelect: function (config, chooseText) {
  504. var html;
  505. if (this.options.jsonSwatchConfig.hasOwnProperty(config.id)) {
  506. return '';
  507. }
  508. html =
  509. '<select class="' + this.options.classes.selectClass + ' ' + config.code + '">' +
  510. '<option value="0" option-id="0">' + chooseText + '</option>';
  511. $.each(config.options, function () {
  512. var label = this.label,
  513. attr = ' value="' + this.id + '" option-id="' + this.id + '"';
  514. if (!this.hasOwnProperty('products') || this.products.length <= 0) {
  515. attr += ' option-empty="true"';
  516. }
  517. html += '<option ' + attr + '>' + label + '</option>';
  518. });
  519. html += '</select>';
  520. return html;
  521. },
  522. /**
  523. * Input for submit form.
  524. * This control shouldn't have "type=hidden", "display: none" for validation work :(
  525. *
  526. * @param {Object} config
  527. * @private
  528. */
  529. _RenderFormInput: function (config) {
  530. return '<input class="' + this.options.classes.attributeInput + ' super-attribute-select" ' +
  531. 'name="super_attribute[' + config.id + ']" ' +
  532. 'type="text" ' +
  533. 'value="" ' +
  534. 'data-selector="super_attribute[' + config.id + ']" ' +
  535. 'data-validate="{required: true}" ' +
  536. 'aria-required="true" ' +
  537. 'aria-invalid="false">';
  538. },
  539. /**
  540. * Event listener
  541. *
  542. * @private
  543. */
  544. _EventListener: function () {
  545. var $widget = this,
  546. options = this.options.classes,
  547. target;
  548. $widget.element.on('click', '.' + options.optionClass, function () {
  549. return $widget._OnClick($(this), $widget);
  550. });
  551. $widget.element.on('change', '.' + options.selectClass, function () {
  552. return $widget._OnChange($(this), $widget);
  553. });
  554. $widget.element.on('click', '.' + options.moreButton, function (e) {
  555. e.preventDefault();
  556. return $widget._OnMoreClick($(this));
  557. });
  558. $widget.element.on('keydown', function (e) {
  559. if (e.which === 13) {
  560. target = $(e.target);
  561. if (target.is('.' + options.optionClass)) {
  562. return $widget._OnClick(target, $widget);
  563. } else if (target.is('.' + options.selectClass)) {
  564. return $widget._OnChange(target, $widget);
  565. } else if (target.is('.' + options.moreButton)) {
  566. e.preventDefault();
  567. return $widget._OnMoreClick(target);
  568. }
  569. }
  570. });
  571. },
  572. /**
  573. * Load media gallery using ajax or json config.
  574. *
  575. * @private
  576. */
  577. _loadMedia: function () {
  578. var $main = this.inProductList ?
  579. this.element.parents('.product-item-info') :
  580. this.element.parents('.column.main'),
  581. images;
  582. if (this.options.useAjax) {
  583. this._debouncedLoadProductMedia();
  584. } else {
  585. images = this.options.jsonConfig.images[this.getProduct()];
  586. if (!images) {
  587. images = this.options.mediaGalleryInitial;
  588. }
  589. this.updateBaseImage(this._sortImages(images), $main, !this.inProductList);
  590. }
  591. },
  592. /**
  593. * Sorting images array
  594. *
  595. * @private
  596. */
  597. _sortImages: function (images) {
  598. return _.sortBy(images, function (image) {
  599. return image.position;
  600. });
  601. },
  602. /**
  603. * Event for swatch options
  604. *
  605. * @param {Object} $this
  606. * @param {Object} $widget
  607. * @private
  608. */
  609. _OnClick: function ($this, $widget) {
  610. var $parent = $this.parents('.' + $widget.options.classes.attributeClass),
  611. $wrapper = $this.parents('.' + $widget.options.classes.attributeOptionsWrapper),
  612. $label = $parent.find('.' + $widget.options.classes.attributeSelectedOptionLabelClass),
  613. attributeId = $parent.attr('attribute-id'),
  614. $input = $parent.find('.' + $widget.options.classes.attributeInput);
  615. if ($widget.inProductList) {
  616. $input = $widget.productForm.find(
  617. '.' + $widget.options.classes.attributeInput + '[name="super_attribute[' + attributeId + ']"]'
  618. );
  619. }
  620. if ($this.hasClass('disabled')) {
  621. return;
  622. }
  623. if ($this.hasClass('selected')) {
  624. $parent.removeAttr('option-selected').find('.selected').removeClass('selected');
  625. $input.val('');
  626. $label.text('');
  627. $this.attr('aria-checked', false);
  628. } else {
  629. $parent.attr('option-selected', $this.attr('option-id')).find('.selected').removeClass('selected');
  630. $label.text($this.attr('option-label'));
  631. $input.val($this.attr('option-id'));
  632. $input.attr('data-attr-name', this._getAttributeCodeById(attributeId));
  633. $this.addClass('selected');
  634. $widget._toggleCheckedAttributes($this, $wrapper);
  635. }
  636. $widget._Rebuild();
  637. if ($widget.element.parents($widget.options.selectorProduct)
  638. .find(this.options.selectorProductPrice).is(':data(mage-priceBox)')
  639. ) {
  640. $widget._UpdatePrice();
  641. }
  642. $(document).trigger('updateMsrpPriceBlock',
  643. [
  644. parseInt($this.attr('index'), 10) + 1,
  645. $widget.options.jsonConfig.optionPrices
  646. ]);
  647. $widget._loadMedia();
  648. $input.trigger('change');
  649. },
  650. /**
  651. * Get human readable attribute code (eg. size, color) by it ID from configuration
  652. *
  653. * @param {Number} attributeId
  654. * @returns {*}
  655. * @private
  656. */
  657. _getAttributeCodeById: function (attributeId) {
  658. var attribute = this.options.jsonConfig.mappedAttributes[attributeId];
  659. return attribute ? attribute.code : attributeId;
  660. },
  661. /**
  662. * Toggle accessibility attributes
  663. *
  664. * @param {Object} $this
  665. * @param {Object} $wrapper
  666. * @private
  667. */
  668. _toggleCheckedAttributes: function ($this, $wrapper) {
  669. $wrapper.attr('aria-activedescendant', $this.attr('id'))
  670. .find('.' + this.options.classes.optionClass).attr('aria-checked', false);
  671. $this.attr('aria-checked', true);
  672. },
  673. /**
  674. * Event for select
  675. *
  676. * @param {Object} $this
  677. * @param {Object} $widget
  678. * @private
  679. */
  680. _OnChange: function ($this, $widget) {
  681. var $parent = $this.parents('.' + $widget.options.classes.attributeClass),
  682. attributeId = $parent.attr('attribute-id'),
  683. $input = $parent.find('.' + $widget.options.classes.attributeInput);
  684. if ($widget.productForm.length > 0) {
  685. $input = $widget.productForm.find(
  686. '.' + $widget.options.classes.attributeInput + '[name="super_attribute[' + attributeId + ']"]'
  687. );
  688. }
  689. if ($this.val() > 0) {
  690. $parent.attr('option-selected', $this.val());
  691. $input.val($this.val());
  692. } else {
  693. $parent.removeAttr('option-selected');
  694. $input.val('');
  695. }
  696. $widget._Rebuild();
  697. $widget._UpdatePrice();
  698. $widget._loadMedia();
  699. $input.trigger('change');
  700. },
  701. /**
  702. * Event for more switcher
  703. *
  704. * @param {Object} $this
  705. * @private
  706. */
  707. _OnMoreClick: function ($this) {
  708. $this.nextAll().show();
  709. $this.blur().remove();
  710. },
  711. /**
  712. * Rewind options for controls
  713. *
  714. * @private
  715. */
  716. _Rewind: function (controls) {
  717. controls.find('div[option-id], option[option-id]').removeClass('disabled').removeAttr('disabled');
  718. controls.find('div[option-empty], option[option-empty]').attr('disabled', true).addClass('disabled');
  719. },
  720. /**
  721. * Rebuild container
  722. *
  723. * @private
  724. */
  725. _Rebuild: function () {
  726. var $widget = this,
  727. controls = $widget.element.find('.' + $widget.options.classes.attributeClass + '[attribute-id]'),
  728. selected = controls.filter('[option-selected]');
  729. // Enable all options
  730. $widget._Rewind(controls);
  731. // done if nothing selected
  732. if (selected.length <= 0) {
  733. return;
  734. }
  735. // Disable not available options
  736. controls.each(function () {
  737. var $this = $(this),
  738. id = $this.attr('attribute-id'),
  739. products = $widget._CalcProducts(id);
  740. if (selected.length === 1 && selected.first().attr('attribute-id') === id) {
  741. return;
  742. }
  743. $this.find('[option-id]').each(function () {
  744. var $element = $(this),
  745. option = $element.attr('option-id');
  746. if (!$widget.optionsMap.hasOwnProperty(id) || !$widget.optionsMap[id].hasOwnProperty(option) ||
  747. $element.hasClass('selected') ||
  748. $element.is(':selected')) {
  749. return;
  750. }
  751. if (_.intersection(products, $widget.optionsMap[id][option].products).length <= 0) {
  752. $element.attr('disabled', true).addClass('disabled');
  753. }
  754. });
  755. });
  756. },
  757. /**
  758. * Get selected product list
  759. *
  760. * @returns {Array}
  761. * @private
  762. */
  763. _CalcProducts: function ($skipAttributeId) {
  764. var $widget = this,
  765. products = [];
  766. // Generate intersection of products
  767. $widget.element.find('.' + $widget.options.classes.attributeClass + '[option-selected]').each(function () {
  768. var id = $(this).attr('attribute-id'),
  769. option = $(this).attr('option-selected');
  770. if ($skipAttributeId !== undefined && $skipAttributeId === id) {
  771. return;
  772. }
  773. if (!$widget.optionsMap.hasOwnProperty(id) || !$widget.optionsMap[id].hasOwnProperty(option)) {
  774. return;
  775. }
  776. if (products.length === 0) {
  777. products = $widget.optionsMap[id][option].products;
  778. } else {
  779. products = _.intersection(products, $widget.optionsMap[id][option].products);
  780. }
  781. });
  782. return products;
  783. },
  784. /**
  785. * Update total price
  786. *
  787. * @private
  788. */
  789. _UpdatePrice: function () {
  790. var $widget = this,
  791. $product = $widget.element.parents($widget.options.selectorProduct),
  792. $productPrice = $product.find(this.options.selectorProductPrice),
  793. options = _.object(_.keys($widget.optionsMap), {}),
  794. result,
  795. tierPriceHtml;
  796. $widget.element.find('.' + $widget.options.classes.attributeClass + '[option-selected]').each(function () {
  797. var attributeId = $(this).attr('attribute-id');
  798. options[attributeId] = $(this).attr('option-selected');
  799. });
  800. result = $widget.options.jsonConfig.optionPrices[_.findKey($widget.options.jsonConfig.index, options)];
  801. $productPrice.trigger(
  802. 'updatePrice',
  803. {
  804. 'prices': $widget._getPrices(result, $productPrice.priceBox('option').prices)
  805. }
  806. );
  807. if (typeof result != 'undefined' && result.oldPrice.amount !== result.finalPrice.amount) {
  808. $(this.options.slyOldPriceSelector).show();
  809. } else {
  810. $(this.options.slyOldPriceSelector).hide();
  811. }
  812. if (typeof result != 'undefined' && result.tierPrices.length) {
  813. if (this.options.tierPriceTemplate) {
  814. tierPriceHtml = mageTemplate(
  815. this.options.tierPriceTemplate,
  816. {
  817. 'tierPrices': result.tierPrices,
  818. '$t': $t,
  819. 'currencyFormat': this.options.jsonConfig.currencyFormat,
  820. 'priceUtils': priceUtils
  821. }
  822. );
  823. $(this.options.tierPriceBlockSelector).html(tierPriceHtml).show();
  824. }
  825. } else {
  826. $(this.options.tierPriceBlockSelector).hide();
  827. }
  828. $(this.options.normalPriceLabelSelector).hide();
  829. _.each($('.' + this.options.classes.attributeOptionsWrapper), function (attribute) {
  830. if ($(attribute).find('.' + this.options.classes.optionClass + '.selected').length === 0) {
  831. if ($(attribute).find('.' + this.options.classes.selectClass).length > 0) {
  832. _.each($(attribute).find('.' + this.options.classes.selectClass), function (dropdown) {
  833. if ($(dropdown).val() === '0') {
  834. $(this.options.normalPriceLabelSelector).show();
  835. }
  836. }.bind(this));
  837. } else {
  838. $(this.options.normalPriceLabelSelector).show();
  839. }
  840. }
  841. }.bind(this));
  842. },
  843. /**
  844. * Get prices
  845. *
  846. * @param {Object} newPrices
  847. * @param {Object} displayPrices
  848. * @returns {*}
  849. * @private
  850. */
  851. _getPrices: function (newPrices, displayPrices) {
  852. var $widget = this,
  853. optionPriceDiff = 0,
  854. allowedProduct, optionPrices, basePrice, optionFinalPrice;
  855. if (_.isEmpty(newPrices)) {
  856. allowedProduct = this._getAllowedProductWithMinPrice(this._CalcProducts());
  857. optionPrices = this.options.jsonConfig.optionPrices;
  858. basePrice = parseFloat(this.options.jsonConfig.prices.basePrice.amount);
  859. if (!_.isEmpty(allowedProduct)) {
  860. optionFinalPrice = parseFloat(optionPrices[allowedProduct].finalPrice.amount);
  861. optionPriceDiff = optionFinalPrice - basePrice;
  862. }
  863. if (optionPriceDiff !== 0) {
  864. newPrices = this.options.jsonConfig.optionPrices[allowedProduct];
  865. } else {
  866. newPrices = $widget.options.jsonConfig.prices;
  867. }
  868. }
  869. _.each(displayPrices, function (price, code) {
  870. if (newPrices[code]) {
  871. displayPrices[code].amount = newPrices[code].amount - displayPrices[code].amount;
  872. }
  873. });
  874. return displayPrices;
  875. },
  876. /**
  877. * Get product with minimum price from selected options.
  878. *
  879. * @param {Array} allowedProducts
  880. * @returns {String}
  881. * @private
  882. */
  883. _getAllowedProductWithMinPrice: function (allowedProducts) {
  884. var optionPrices = this.options.jsonConfig.optionPrices,
  885. product = {},
  886. optionFinalPrice, optionMinPrice;
  887. _.each(allowedProducts, function (allowedProduct) {
  888. optionFinalPrice = parseFloat(optionPrices[allowedProduct].finalPrice.amount);
  889. if (_.isEmpty(product) || optionFinalPrice < optionMinPrice) {
  890. optionMinPrice = optionFinalPrice;
  891. product = allowedProduct;
  892. }
  893. }, this);
  894. return product;
  895. },
  896. /**
  897. * Gets all product media and change current to the needed one
  898. *
  899. * @private
  900. */
  901. _LoadProductMedia: function () {
  902. var $widget = this,
  903. $this = $widget.element,
  904. productData = this._determineProductData(),
  905. mediaCallData,
  906. mediaCacheKey,
  907. /**
  908. * Processes product media data
  909. *
  910. * @param {Object} data
  911. * @returns void
  912. */
  913. mediaSuccessCallback = function (data) {
  914. if (!(mediaCacheKey in $widget.options.mediaCache)) {
  915. $widget.options.mediaCache[mediaCacheKey] = data;
  916. }
  917. $widget._ProductMediaCallback($this, data, productData.isInProductView);
  918. setTimeout(function () {
  919. $widget._DisableProductMediaLoader($this);
  920. }, 300);
  921. };
  922. if (!$widget.options.mediaCallback) {
  923. return;
  924. }
  925. mediaCallData = {
  926. 'product_id': this.getProduct()
  927. };
  928. mediaCacheKey = JSON.stringify(mediaCallData);
  929. if (mediaCacheKey in $widget.options.mediaCache) {
  930. $widget._XhrKiller();
  931. $widget._EnableProductMediaLoader($this);
  932. mediaSuccessCallback($widget.options.mediaCache[mediaCacheKey]);
  933. } else {
  934. mediaCallData.isAjax = true;
  935. $widget._XhrKiller();
  936. $widget._EnableProductMediaLoader($this);
  937. $widget.xhr = $.ajax({
  938. url: $widget.options.mediaCallback,
  939. cache: true,
  940. type: 'GET',
  941. dataType: 'json',
  942. data: mediaCallData,
  943. success: mediaSuccessCallback
  944. }).done(function () {
  945. $widget._XhrKiller();
  946. });
  947. }
  948. },
  949. /**
  950. * Enable loader
  951. *
  952. * @param {Object} $this
  953. * @private
  954. */
  955. _EnableProductMediaLoader: function ($this) {
  956. var $widget = this;
  957. if ($('body.catalog-product-view').length > 0) {
  958. $this.parents('.column.main').find('.photo.image')
  959. .addClass($widget.options.classes.loader);
  960. } else {
  961. //Category View
  962. $this.parents('.product-item-info').find('.product-image-photo')
  963. .addClass($widget.options.classes.loader);
  964. }
  965. },
  966. /**
  967. * Disable loader
  968. *
  969. * @param {Object} $this
  970. * @private
  971. */
  972. _DisableProductMediaLoader: function ($this) {
  973. var $widget = this;
  974. if ($('body.catalog-product-view').length > 0) {
  975. $this.parents('.column.main').find('.photo.image')
  976. .removeClass($widget.options.classes.loader);
  977. } else {
  978. //Category View
  979. $this.parents('.product-item-info').find('.product-image-photo')
  980. .removeClass($widget.options.classes.loader);
  981. }
  982. },
  983. /**
  984. * Callback for product media
  985. *
  986. * @param {Object} $this
  987. * @param {String} response
  988. * @param {Boolean} isInProductView
  989. * @private
  990. */
  991. _ProductMediaCallback: function ($this, response, isInProductView) {
  992. var $main = isInProductView ? $this.parents('.column.main') : $this.parents('.product-item-info'),
  993. $widget = this,
  994. images = [],
  995. /**
  996. * Check whether object supported or not
  997. *
  998. * @param {Object} e
  999. * @returns {*|Boolean}
  1000. */
  1001. support = function (e) {
  1002. return e.hasOwnProperty('large') && e.hasOwnProperty('medium') && e.hasOwnProperty('small');
  1003. };
  1004. if (_.size($widget) < 1 || !support(response)) {
  1005. this.updateBaseImage(this.options.mediaGalleryInitial, $main, isInProductView);
  1006. return;
  1007. }
  1008. images.push({
  1009. full: response.large,
  1010. img: response.medium,
  1011. thumb: response.small,
  1012. isMain: true
  1013. });
  1014. if (response.hasOwnProperty('gallery')) {
  1015. $.each(response.gallery, function () {
  1016. if (!support(this) || response.large === this.large) {
  1017. return;
  1018. }
  1019. images.push({
  1020. full: this.large,
  1021. img: this.medium,
  1022. thumb: this.small
  1023. });
  1024. });
  1025. }
  1026. this.updateBaseImage(images, $main, isInProductView);
  1027. },
  1028. /**
  1029. * Check if images to update are initial and set their type
  1030. * @param {Array} images
  1031. */
  1032. _setImageType: function (images) {
  1033. var initial = this.options.mediaGalleryInitial[0].img;
  1034. if (images[0].img === initial) {
  1035. images = $.extend(true, [], this.options.mediaGalleryInitial);
  1036. } else {
  1037. images.map(function (img) {
  1038. if (!img.type) {
  1039. img.type = 'image';
  1040. }
  1041. });
  1042. }
  1043. return images;
  1044. },
  1045. /**
  1046. * Update [gallery-placeholder] or [product-image-photo]
  1047. * @param {Array} images
  1048. * @param {jQuery} context
  1049. * @param {Boolean} isInProductView
  1050. */
  1051. updateBaseImage: function (images, context, isInProductView) {
  1052. var justAnImage = images[0],
  1053. initialImages = this.options.mediaGalleryInitial,
  1054. imagesToUpdate,
  1055. gallery = context.find(this.options.mediaGallerySelector).data('gallery'),
  1056. isInitial;
  1057. if (isInProductView) {
  1058. imagesToUpdate = images.length ? this._setImageType($.extend(true, [], images)) : [];
  1059. isInitial = _.isEqual(imagesToUpdate, initialImages);
  1060. if (this.options.gallerySwitchStrategy === 'prepend' && !isInitial) {
  1061. imagesToUpdate = imagesToUpdate.concat(initialImages);
  1062. }
  1063. imagesToUpdate = this._setImageIndex(imagesToUpdate);
  1064. if (!_.isUndefined(gallery)) {
  1065. gallery.updateData(imagesToUpdate);
  1066. }
  1067. if (isInitial) {
  1068. $(this.options.mediaGallerySelector).AddFotoramaVideoEvents();
  1069. } else {
  1070. $(this.options.mediaGallerySelector).AddFotoramaVideoEvents({
  1071. selectedOption: this.getProduct(),
  1072. dataMergeStrategy: this.options.gallerySwitchStrategy
  1073. });
  1074. }
  1075. } else if (justAnImage && justAnImage.img) {
  1076. context.find('.product-image-photo').attr('src', justAnImage.img);
  1077. }
  1078. },
  1079. /**
  1080. * Set correct indexes for image set.
  1081. *
  1082. * @param {Array} images
  1083. * @private
  1084. */
  1085. _setImageIndex: function (images) {
  1086. var length = images.length,
  1087. i;
  1088. for (i = 0; length > i; i++) {
  1089. images[i].i = i + 1;
  1090. }
  1091. return images;
  1092. },
  1093. /**
  1094. * Kill doubled AJAX requests
  1095. *
  1096. * @private
  1097. */
  1098. _XhrKiller: function () {
  1099. var $widget = this;
  1100. if ($widget.xhr !== undefined && $widget.xhr !== null) {
  1101. $widget.xhr.abort();
  1102. $widget.xhr = null;
  1103. }
  1104. },
  1105. /**
  1106. * Emulate mouse click on all swatches that should be selected
  1107. * @param {Object} [selectedAttributes]
  1108. * @private
  1109. */
  1110. _EmulateSelected: function (selectedAttributes) {
  1111. $.each(selectedAttributes, $.proxy(function (attributeCode, optionId) {
  1112. var elem = this.element.find('.' + this.options.classes.attributeClass +
  1113. '[attribute-code="' + attributeCode + '"] [option-id="' + optionId + '"]'),
  1114. parentInput = elem.parent();
  1115. if (elem.hasClass('selected')) {
  1116. return;
  1117. }
  1118. if (parentInput.hasClass(this.options.classes.selectClass)) {
  1119. parentInput.val(optionId);
  1120. parentInput.trigger('change');
  1121. } else {
  1122. elem.trigger('click');
  1123. }
  1124. }, this));
  1125. },
  1126. /**
  1127. * Emulate mouse click or selection change on all swatches that should be selected
  1128. * @param {Object} [selectedAttributes]
  1129. * @private
  1130. */
  1131. _EmulateSelectedByAttributeId: function (selectedAttributes) {
  1132. $.each(selectedAttributes, $.proxy(function (attributeId, optionId) {
  1133. var elem = this.element.find('.' + this.options.classes.attributeClass +
  1134. '[attribute-id="' + attributeId + '"] [option-id="' + optionId + '"]'),
  1135. parentInput = elem.parent();
  1136. if (elem.hasClass('selected')) {
  1137. return;
  1138. }
  1139. if (parentInput.hasClass(this.options.classes.selectClass)) {
  1140. parentInput.val(optionId);
  1141. parentInput.trigger('change');
  1142. } else {
  1143. elem.trigger('click');
  1144. }
  1145. }, this));
  1146. },
  1147. /**
  1148. * Get default options values settings with either URL query parameters
  1149. * @private
  1150. */
  1151. _getSelectedAttributes: function () {
  1152. var hashIndex = window.location.href.indexOf('#'),
  1153. selectedAttributes = {},
  1154. params;
  1155. if (hashIndex !== -1) {
  1156. params = $.parseQuery(window.location.href.substr(hashIndex + 1));
  1157. selectedAttributes = _.invert(_.mapObject(_.invert(params), function (attributeId) {
  1158. var attribute = this.options.jsonConfig.mappedAttributes[attributeId];
  1159. return attribute ? attribute.code : attributeId;
  1160. }.bind(this)));
  1161. }
  1162. return selectedAttributes;
  1163. },
  1164. /**
  1165. * Callback which fired after gallery gets initialized.
  1166. *
  1167. * @param {HTMLElement} element - DOM element associated with a gallery.
  1168. */
  1169. _onGalleryLoaded: function (element) {
  1170. var galleryObject = element.data('gallery');
  1171. this.options.mediaGalleryInitial = galleryObject.returnCurrentImages();
  1172. },
  1173. /**
  1174. * Sets mediaCache for cases when jsonConfig contains preSelectedGallery on layered navigation result pages
  1175. *
  1176. * @private
  1177. */
  1178. _setPreSelectedGallery: function () {
  1179. var mediaCallData;
  1180. if (this.options.jsonConfig.preSelectedGallery) {
  1181. mediaCallData = {
  1182. 'product_id': this.getProduct()
  1183. };
  1184. this.options.mediaCache[JSON.stringify(mediaCallData)] = this.options.jsonConfig.preSelectedGallery;
  1185. }
  1186. }
  1187. });
  1188. return $.mage.SwatchRenderer;
  1189. });