You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

1470 lines
52 KiB

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