Class: Www::ProductCatalogPresenter

Inherits:
BasePresenter
  • Object
show all
Includes:
ApplicationHelper, Memery, Presenters::ProductHelpers
Defined in:
app/presenters/www/product_catalog_presenter.rb

Overview

Presenter for WWW SKU detail pages. Encapsulates pricing, media,
related products, and Schema.org generation for a single catalog item.

Direct Known Subclasses

Feed::Google::GoogleProductPresenter

Defined Under Namespace

Classes: DocRecord

Constant Summary collapse

CARD_IMAGE_ORDER =

Card image order.

%w[WYS_CARD WYS_MAIN].freeze
%i[installation_methods controls control_electrical_rough_in_kits upgrades accessories installation_kits insulating_underlayments].freeze
LAZY_SECTIONS =

Sections deferred to Turbo Frame lazy-load endpoints for human visitors.
Bots still get full inline rendering (lazy_rendering? is false).

%i[installation_plans showcases related_products documents reviews faq terms_and_conditions surcharges].freeze

Constants included from ApplicationHelper

ApplicationHelper::CF_WEB_ANALYTICS_SKIP_PATHS, ApplicationHelper::MARKDOWN_ALLOWED_ATTRIBUTES, ApplicationHelper::MARKDOWN_ALLOWED_TAGS

Constants included from SeoHelper

SeoHelper::AWARDS, SeoHelper::CA_ADDRESS, SeoHelper::CA_BUSINESS_HOURS, SeoHelper::CA_CONTACT_POINT, SeoHelper::CA_CURRENCIES, SeoHelper::CA_DESCRIPTION, SeoHelper::CA_FOUNDING_DATE, SeoHelper::CA_GLOBAL_LOCATION_NUMBER, SeoHelper::CA_LEGAL_NAME, SeoHelper::CA_LOCAL_BUSINESS, SeoHelper::CA_ONLINE_STORE, SeoHelper::CA_RETURN_POLICY, SeoHelper::CA_SALES_DEPARTMENT, SeoHelper::CA_SERVICE_AREA, SeoHelper::CA_URL, SeoHelper::CA_VAT_ID, SeoHelper::CA_WAREHOUSE_DEPARTMENT, SeoHelper::CA_WAREHOUSE_HOURS, SeoHelper::COMPANY_EMAIL, SeoHelper::COMPANY_LOGO, SeoHelper::COMPANY_NAME, SeoHelper::COMPANY_SLOGAN, SeoHelper::EXPERTISE, SeoHelper::FAX_NUMBER, SeoHelper::GS1_COMPANY_PREFIX, SeoHelper::ISO6523_CODE, SeoHelper::PAYMENT_METHODS, SeoHelper::PHONE_NUMBER, SeoHelper::PRIMARY_NAICS, SeoHelper::REFUND_TYPE, SeoHelper::RETURN_FEES, SeoHelper::RETURN_METHOD, SeoHelper::RETURN_POLICY_CATEGORY, SeoHelper::SECONDARY_NAICS, SeoHelper::SOCIAL_PROFILES, SeoHelper::US_ADDRESS, SeoHelper::US_BUSINESS_HOURS, SeoHelper::US_CONTACT_POINT, SeoHelper::US_CURRENCIES, SeoHelper::US_DESCRIPTION, SeoHelper::US_FOUNDING_DATE, SeoHelper::US_GLOBAL_LOCATION_NUMBER, SeoHelper::US_IMAGE, SeoHelper::US_LEGAL_NAME, SeoHelper::US_LOCAL_BUSINESS, SeoHelper::US_ONLINE_STORE, SeoHelper::US_RETURN_POLICY, SeoHelper::US_SALES_DEPARTMENT, SeoHelper::US_SERVICE_AREA, SeoHelper::US_TAX_ID, SeoHelper::US_URL, SeoHelper::US_WAREHOUSE_DEPARTMENT, SeoHelper::US_WAREHOUSE_HOURS

Constants included from IconHelper

IconHelper::CUSTOM_ICON_MAP, IconHelper::CUSTOM_SVG_DIR, IconHelper::DEFAULT_FAMILY

Delegated Instance Attributes collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from ApplicationHelper

#better_number_to_currency, #cf_web_analytics?, #check_or_cross, #check_or_times, #embedded_tab_frame_id, #error_messages, #general_disclaimer_on_product_installation_and_local_codes, #markdown_to_html, #parent_layout, #pass_or_fail, #render_error_messages_list, #render_video_card, #resolved_auth_form_turbo_frame, #return_path_or, #safe_css_color, #set_return_path_if_present, #set_section_if_present, #tab_frame_id, #to_underscore, #turbo_section_wrapper, #turbo_tabs_request?, #url_on_same_domain_as_request, #working_hours?, #yes_or_no, #yes_or_no_highlighted, #yes_or_no_with_check_or_cross

Methods included from UppyUploaderHelper

#file_uploader, #image_uploader, #large_file_uploader_s3, #lead_sketch_uploader, #public_warranty_card_uploader, #rma_image_uploader, #rma_image_uploader_s3, #uppy_uploader, #video_uploader, #warranty_card_uploader

Methods included from ImagesHelper

#image_asset_tag, #image_asset_url, #picture_asset_tag

Methods included from SeoHelper

#add_page_schema, #add_webpage_schema, #canada?, #collect_schema, #company_social_links, #ensure_context_json, #json_ld_script_tag, #local_business_schema, #online_store_id, #online_store_schema, #page_main_entity, #page_main_entity_json, #render_auto_collection_page_schema, #render_collection_page_schema, #render_local_business_schema, #render_online_store_schema, #render_page_schemas, #render_page_video_schemas, #render_webpage_schema, #render_webpage_schema_with_collections, #usa?

Methods included from UrlsHelper

#catalog_breadcrumb_links, #catalog_link, #catalog_link_for_product_line, #catalog_link_for_sku, #cms_link, #delocalized_path, #path_to_sales_product_sku, #path_to_sales_product_sku_for_product_line, #path_to_sales_product_sku_for_product_line_slug, #product_line_from_catalog_link, #protocol_neutral_url, #sanitize_external_url, #valid_external_url?

Methods included from IconHelper

#account_nav_icon, #fa_icon, #star_rating_html

Class Method Details

.load_sku(item_sku, catalog_id) ⇒ Object

Load sku.

Parameters:

  • item_sku (Object)
  • catalog_id (Object)

Returns:

  • (Object)


29
30
31
32
# File 'app/presenters/www/product_catalog_presenter.rb', line 29

def self.load_sku(item_sku, catalog_id)
  p = ViewProductCatalog.find_by(item_sku:, catalog_id:)
  new(p)
end

Instance Method Details

#accessoriesObject

Accessories.

Returns:

  • (Object)


1303
1304
1305
1306
# File 'app/presenters/www/product_catalog_presenter.rb', line 1303

def accessories
  res = Item::Materials::CompatibleAccessories.new.process(item:, context: :sales)
  available_products_scope_new_only.merge(res.items)
end

#allow_add_to_cart?Boolean

Whether allow add to cart.

Returns:

  • (Boolean)


1792
1793
1794
1795
1796
1797
# File 'app/presenters/www/product_catalog_presenter.rb', line 1792

def allow_add_to_cart?
  return false if display_where_to_buy?
  return false if restricted_to_trade?

  item_is_web_accessible?
end

We are discontinuing sending sales to amazon at this point
But code will remain here just in case this changes



1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
# File 'app/presenters/www/product_catalog_presenter.rb', line 1822

def amazon_affiliate_link
  nil
  # if catalog_id == CatalogConstants::US_CATALOG_ID
  #   amz_catalog_ids = CatalogConstants::AMAZON_US_CATALOG_IDS
  #   affiliate_code_url = "?tag=warmlyyours-20"
  #   amz_url = "https://www.amazon.com/dp/"
  # else
  #   amz_catalog_ids = CatalogConstants::AMAZON_CA_CATALOG_IDS
  #   affiliate_code_url = "?tag=warmlyyours08-20"
  #   amz_url = "https://www.amazon.ca/dp/"
  # end

  # asin = item.view_product_catalogs.where(catalog_id: amz_catalog_ids).where(catalog_item_state: 'active').where.not(third_party_part_number: nil).pick(:third_party_part_number)
  # link = amz_url + asin + affiliate_code_url if asin.present?
  # link
end

#available_products_scopeObject

Available products scope.

Returns:

  • (Object)


1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
# File 'app/presenters/www/product_catalog_presenter.rb', line 1182

def available_products_scope
  catalog = Catalog.locale_to_catalog
  raise 'Invalid catalog locale' unless catalog

  # Use preload instead of includes to avoid massive JOINed queries
  # ViewProductCatalog already has most data denormalized; preload
  # associations needed for rendering related products
  ViewProductCatalog
    .joins(:item)
    .merge(Item.goods.non_publications)
    .preload(
      :item,
      item: [
        :image_profiles,
        { image_profiles: :image },
        :primary_image,
        :primary_product_line,
        :product_category,
        { primary_product_line: [] },
        { product_category: [] }
      ]
    )
    .visible_to_public
    .where(catalog_id: catalog.id)
end

#available_products_scope_new_onlyObject

Available products scope new only.

Returns:

  • (Object)


1211
1212
1213
# File 'app/presenters/www/product_catalog_presenter.rb', line 1211

def available_products_scope_new_only
  available_products_scope.excluding_refurbished
end

#available_services_scopeObject

Available services scope.

Returns:

  • (Object)


1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
# File 'app/presenters/www/product_catalog_presenter.rb', line 1253

def available_services_scope
  catalog = Catalog.locale_to_catalog
  raise 'Invalid catalog locale' unless catalog

  # Use preload instead of includes to avoid massive JOINed queries
  ViewProductCatalog
    .joins(:item)
    .merge(Item.services)
    .preload(:item)
    .visible_to_public
    .where(catalog_id: catalog.id)
end

#axis_optionsObject

Axis options.

Returns:

  • (Object)


387
388
389
390
391
392
393
# File 'app/presenters/www/product_catalog_presenter.rb', line 387

def axis_options
  return {} unless (vs = variants(include_self: true)).present? && variant_axis_tokens.any?

  variant_axis_tokens.index_with do |t|
    vs.filter_map { |v| v.spec_output(t) }.uniq.sort
  end
end

Breadcrumb.

Returns:

  • (Object)


199
200
201
202
203
204
205
# File 'app/presenters/www/product_catalog_presenter.rb', line 199

def breadcrumb
  h.safe_join(
    breadcrumb_links_array.map do |link_info|
      (:li, link_to(link_info[:name], link_info[:url]), class: 'breadcrumb-item')
    end
  )
end

Breadcrumb links array.

Returns:

  • (Object)


133
134
135
136
137
138
139
# File 'app/presenters/www/product_catalog_presenter.rb', line 133

def breadcrumb_links_array
  links_array = []
  return links_array unless primary_product_line

  links_array += product_line_presenter.breadcrumb_links_array
  inject_category_landing_page_breadcrumb(links_array)
end

#build_additional_properties_from_specsObject

Build Schema.org PropertyValue array from our public specifications system



1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
# File 'app/presenters/www/product_catalog_presenter.rb', line 1347

def build_additional_properties_from_specs
  return [] if public_specifications.blank?

  public_specifications.first(12).filter_map do |spec|
    # Use the rendered output text (stripped) for value
    value_text = ActionView::Base.full_sanitizer.sanitize(spec.output.to_s).squish
    next if value_text.blank?

    SchemaDotOrg::PropertyValue.new(name: spec.name, value: value_text)
  end
end

#cable_help_configObject

Cable help offcanvas configuration
Returns nil if no cable help should be shown, otherwise returns config hash



1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
# File 'app/presenters/www/product_catalog_presenter.rb', line 1841

def cable_help_config
  return nil unless show_cable_help?

  pl_ltree = item_primary_product_line_slug_ltree&.to_s
  if pl_ltree&.start_with?('floor_heating.tempzone')
    {
      offcanvas_id: 'offcanvas-cable-tempzone',
      offcanvas_partial: 'offcanvas_cable_tempzone',
      title: 'How Much Cable Will I Need?',
      needs_variants: true
    }
  elsif pl_ltree&.start_with?('snow_melting.cable')
    {
      offcanvas_id: 'offcanvas-cable-snow-melt',
      offcanvas_partial: 'offcanvas_cable_snow_melt',
      title: 'How Much Cable Will I Need?'
    }
  elsif pl_ltree&.include?('roof_and_gutter_deicing')
    {
      offcanvas_id: 'offcanvas-cable-roof-gutter',
      offcanvas_partial: 'offcanvas_cable_roof_gutter',
      title: 'How Much Cable Will I Need?'
    }
  elsif pl_ltree&.start_with?('floor_heating.slab_heat.cable')
    {
      offcanvas_id: 'offcanvas-cable-slab',
      offcanvas_partial: 'offcanvas_cable_slab',
      title: 'How Much Cable Will I Need?'
    }
  end
end

#cache_keyObject

Cache key.

Returns:

  • (Object)


36
37
38
# File 'app/presenters/www/product_catalog_presenter.rb', line 36

def cache_key
  "#{product.cache_key}/#{r.locale}"
end

#canonical_paths(request_path, request_url: nil) ⇒ Object

Removed: unused predicate helper roll_or_mat

Parameters:

  • request_path (Object)
  • request_url (Object, nil) (defaults to: nil)

Returns:

  • (Object)


522
523
524
525
# File 'app/presenters/www/product_catalog_presenter.rb', line 522

def canonical_paths(request_path, request_url: nil)
  # This piece of code avoids duplicate titles and descriptions for those items whose product line page is the same as the product code page
  h.build_canonical_paths(request_path, locales: item_available_locales, request_url:)
end

Carousel assets.

Returns:

  • (Object)


261
262
263
264
265
266
267
268
269
270
271
# File 'app/presenters/www/product_catalog_presenter.rb', line 261

def carousel_assets
  assets_images = images.dup
  assets_videos = videos.dup
  # Prefer two stills before the first video so shoppers see multiple angles before video.
  assets = assets_images.shift(2)
  first_video = assets_videos.shift
  assets << first_video if first_video
  assets += assets_images
  assets += assets_videos
  assets.compact
end

#combined_repairability_notice_textObject

Combined repairability notice text.

Returns:

  • (Object)


773
774
775
776
777
778
779
780
781
782
783
# File 'app/presenters/www/product_catalog_presenter.rb', line 773

def combined_repairability_notice_text
  product_line_with_notice = repairability_product_line_for_item
  return if product_line_with_notice.blank?

  notice_fr = product_line_with_notice.try(:repairability_notice_fr_ca)
  notice_en = product_line_with_notice.try(:repairability_notice_en_ca)
  combined_notice = [notice_fr, notice_en].compact_blank.join("\n\n").presence
  return combined_notice if combined_notice.present?

  ProductLine.default_repairability_notice
end

#compute_single_section(name) ⇒ Object

Compute data for a single section without loading all other sections.
Used by the lazy-loaded Turbo Frame endpoint to avoid the allocation cost
of building every section on each request.

Parameters:

  • name (Object)

Returns:

  • (Object)


820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
# File 'app/presenters/www/product_catalog_presenter.rb', line 820

def compute_single_section(name)
  case name
  when :features
    f = item.features
    return { section_name: 'Features', features: f } if f.present?
  when :notices
    notices = product_notices
    if notices.any?
      label = I18n.t('products.notices.section_name', default: 'Notices')
      return { nav_name: label, section_name: label, notices: notices }
    end
  when :description
    return { section_name: description_header, description_html: description_html }
  when :specifications
    grouped = public_specifications_grouped
    return { section_name: section_name(name), specifications_grouped: grouped } if grouped.any?
  when :documents
    grouped_documents = documents_grouped_by_category
    return { section_name: section_name(name), documents_grouped_by_category: grouped_documents } if grouped_documents.any?
  when :videos
    return { section_name: section_name(name), videos: videos } if videos.any?
  when :faq
    section_faqs = faqs
    if section_faqs.any?
      return { nav_name: section_name(name), section_name: 'Frequently Asked Questions',
               faqs: section_faqs, top_faqs: section_faqs.first(5), product_name: product_line.display_name }
    end
  when :terms_and_conditions
    return { section_name: section_name(name), terms_and_conditions: terms_and_conditions } if terms_and_conditions.present?
  when :surcharges
    return { section_name: 'Potential Surcharges', surcharges: surcharges } if surcharges.present?
  when :reviews
    internal_reviews = rating.product_reviews
    return { section_name: section_name(name), top_reviews: top_rating.product_reviews,
             reviews: internal_reviews, average_rating: rating.avg_stars,
             review_count: rating.review_count, product_name: product_line.display_name }
  when :installation_plans
    return { section_name: section_name(name), floor_plans: floor_plans } if floor_plans.present?
  when :showcases
    if showcases_for_item.present?
      return { nav_name: 'Real Projects', section_name: section_name(name),
               showcases: showcases_for_item, product_name: product_line&.display_name }
    end
  when :related_products
    return compute_related_products_section
  end

  nil
end

#control_electrical_rough_in_kitsObject

Control electrical rough in kits.

Returns:

  • (Object)


1311
1312
1313
1314
# File 'app/presenters/www/product_catalog_presenter.rb', line 1311

def control_electrical_rough_in_kits
  res = Item::Materials::CompatibleControlElectricalRoughInKits.new.process(item:)
  available_products_scope_new_only.merge(res.items)
end

#controlsObject

Controls.

Returns:

  • (Object)


1269
1270
1271
1272
# File 'app/presenters/www/product_catalog_presenter.rb', line 1269

def controls
  res = Item::Materials::CompatibleControls.new.process(item:)
  available_products_scope_new_only.merge(res.items).reorder(:sale_price, :price)
end

#coverageObject

Coverage.

Returns:

  • (Object)


535
536
537
# File 'app/presenters/www/product_catalog_presenter.rb', line 535

def coverage
  spec_output(:coverage)
end

#description_headerObject

Description header.

Returns:

  • (Object)


70
71
72
73
74
# File 'app/presenters/www/product_catalog_presenter.rb', line 70

def description_header
  return 'Description' unless primary_product_line

  "#{primary_product_line.public_name} Description"
end

#description_htmlObject

Description html.

Returns:

  • (Object)


78
79
80
# File 'app/presenters/www/product_catalog_presenter.rb', line 78

def description_html
  item.public_description_html
end

#description_textObject

Description text.

Returns:

  • (Object)


84
85
86
# File 'app/presenters/www/product_catalog_presenter.rb', line 84

def description_text
  item.public_description_text
end

#dimensionsObject

Dimensions.

Returns:

  • (Object)


529
530
531
# File 'app/presenters/www/product_catalog_presenter.rb', line 529

def dimensions
  "#{width} × #{length}"
end

#discount_percentage(precision: 2) ⇒ Object

Discount percentage.

Parameters:

  • precision (Integer) (defaults to: 2)

Returns:

  • (Object)


1619
1620
1621
1622
1623
1624
1625
# File 'app/presenters/www/product_catalog_presenter.rb', line 1619

def discount_percentage(precision: 2)
  return nil unless original_price && effective_price
  return nil if original_price == effective_price || original_price.zero?

  p = 100 - ((effective_price.to_r / original_price.to_r) * 100).floor
  h.number_with_precision(p, strip_insignificant_zeros: true, precision:, round_mode: :down)
end

#discounted_product?Boolean

Whether discounted product.

Returns:

  • (Boolean)


1682
1683
1684
1685
1686
# File 'app/presenters/www/product_catalog_presenter.rb', line 1682

def discounted_product?
  return false unless effective_price && original_price

  effective_price != original_price
end

#display_best_product_tahoe?Boolean

Whether display best product tahoe.

Returns:

  • (Boolean)


1785
1786
1787
1788
# File 'app/presenters/www/product_catalog_presenter.rb', line 1785

def display_best_product_tahoe?
  item.pl_descendant_of_path?(LtreePaths::PL_TOWEL_WARMER_CLASSIC_TAHOE) &&
    item.pc_descendant_of_path?(LtreePaths::PC_TOWEL_WARMERS)
end

#display_dealers?Boolean

Whether display dealers.

Returns:

  • (Boolean)


1814
1815
1816
1817
1818
# File 'app/presenters/www/product_catalog_presenter.rb', line 1814

def display_dealers?
  !refurbished_item? &&
    item.pl_descendant_of_path?(LtreePaths::PL_TOWEL_WARMER_COSMOPOLITAN) &&
    item.pc_descendant_of_path?(LtreePaths::PC_TOWEL_WARMERS)
end

#display_features_prominently?Boolean

This determines if we display features right below the price and above add to cart
if there are no variations present we can do so

Returns:

  • (Boolean)


720
721
722
# File 'app/presenters/www/product_catalog_presenter.rb', line 720

def display_features_prominently?
  features.present? && (refurbished_item? || single_sku?)
end

#display_how_much_cable_will_i_need?Boolean

Whether display how much cable will i need.

Returns:

  • (Boolean)


1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
# File 'app/presenters/www/product_catalog_presenter.rb', line 1759

def display_how_much_cable_will_i_need?
  cable_paths = [
    LtreePaths::PL_FLOOR_HEATING_TEMPZONE_CABLE,
    LtreePaths::PL_FLOOR_HEATING_TEMPZONE_RULER_CABLE,
    LtreePaths::PL_SNOW_MELTING_CABLE,
    LtreePaths::PL_FLOOR_HEATING_SLAB_HEAT_CABLE
  ]
  cable_paths.any? { |path| item.pl_descendant_of_path?(path) } &&
    item.pc_descendant_of_path?(LtreePaths::PC_HEATING_ELEMENTS_HEATING_CABLES)
end

#display_pro_contact_us_for_quote?Boolean

Whether display pro contact us for quote.

Returns:

  • (Boolean)


1801
1802
1803
# File 'app/presenters/www/product_catalog_presenter.rb', line 1801

def display_pro_contact_us_for_quote?
  restricted_to_trade?
end

#display_roof_gutter_deicing_quote?Boolean

Whether display roof gutter deicing quote.

Returns:

  • (Boolean)


1778
1779
1780
1781
# File 'app/presenters/www/product_catalog_presenter.rb', line 1778

def display_roof_gutter_deicing_quote?
  item.pl_descendant_of_path?(LtreePaths::PL_ROOF_GUTTER_DEICING) &&
    item.pc_descendant_of_path?(LtreePaths::PC_HEATING_ELEMENTS)
end

#display_what_size_panel_do_i_need?Boolean

Whether display what size panel do i need.

Returns:

  • (Boolean)


1772
1773
1774
# File 'app/presenters/www/product_catalog_presenter.rb', line 1772

def display_what_size_panel_do_i_need?
  item.pc_descendant_of_path?(LtreePaths::PC_INFRARED_HEATING_PANELS)
end

#display_where_to_buy?Boolean

For items specifically sold through channels, which at present are only these towel warmers
we check for a combination of product line + category match
It is possible that we could use also will_be_restricted_for_sales on item to check for this
so there is a code consolidation opportunity here
Refurb items are always available to buy directly

Returns:

  • (Boolean)


1748
1749
1750
1751
1752
1753
1754
1755
# File 'app/presenters/www/product_catalog_presenter.rb', line 1748

def display_where_to_buy?
  # !is_refurbished_item? &&
  # (
  #   item_primary_product_line_url.starts_with?('towel-warmer-premier') ||
  #   item_primary_product_line_url.starts_with?('towel-warmer-elevate')
  # ) && product_category_url.starts_with?('goods-towel-warmers')
  false # Everything's up for grab
end

#documentsObject

Documents.

Returns:

  • (Object)


284
285
286
287
288
289
290
291
292
293
# File 'app/presenters/www/product_catalog_presenter.rb', line 284

def documents
  retrieve_documents([:sales]).all_documents.map do |r|
    DocRecord.new(category: r.product_category_name,
                  sku: r.sku,
                  name: r.name,
                  content_url: r.content_url,
                  featured_position: r.featured_position,
                  star?: r.star?)
  end
end

#documents_grouped_by_categoryObject

Documents grouped by category.

Returns:

  • (Object)


298
299
300
301
302
303
304
305
306
307
308
309
310
311
# File 'app/presenters/www/product_catalog_presenter.rb', line 298

def documents_grouped_by_category
  res = {}
  retrieve_documents([:sales]).publications_grouped.each do |cat, documents|
    res[cat.name] = documents.map do |r|
      DocRecord.new(category: r.product_category_name,
                    sku: r.sku,
                    name: r.name,
                    content_url: r.content_url,
                    featured_position: r.featured_position,
                    star?: r.star?)
    end
  end
  res
end

#effective_price_formattedObject

Effective price formatted.

Returns:

  • (Object)


571
572
573
574
575
# File 'app/presenters/www/product_catalog_presenter.rb', line 571

def effective_price_formatted
  return unless effective_price

  h.better_number_to_currency(effective_price, unit: currency_symbol)
end

#environ_mat_product?Boolean

Inject for Environ Easy Mat products only.

Returns:

  • (Boolean)


166
167
168
# File 'app/presenters/www/product_catalog_presenter.rb', line 166

def environ_mat_product?
  item.pl_descendant_of_path?(LtreePaths::PL_FLOOR_HEATING_ENVIRON_EASY_MAT)
end

#expected_soon?Boolean

Whether expected soon.

Returns:

  • (Boolean)


1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
# File 'app/presenters/www/product_catalog_presenter.rb', line 1580

def expected_soon?
  return false unless catalog_item

  # Check if there are any items on order or expected soon
  catalog_item.next_available_by_warehouse_with_depth_limit(use_alternate_warehouse: true, max_depth: 5).any? do |_warehouse_name, on_order_data|
    on_order_data&.next_available_date && on_order_data.next_available_date <= 30.days.from_now
  end
rescue StandardError => e
  # Log the recursion error and return false
  ErrorReporting.error(e,
    catalog_item_id: catalog_item&.id,
    product_sku: item_sku)
  false
end

#faqsObject

Faqs.

Returns:

  • (Object)


324
325
326
# File 'app/presenters/www/product_catalog_presenter.rb', line 324

def faqs
  Item::ArticleRetriever.new(article_types: %w[faq], sales: true, add_vote_data: true).process(item:).articles
end

#featuresObject

Alias for Item#features

Returns:

  • (Object)

    Item#features

See Also:



23
# File 'app/presenters/www/product_catalog_presenter.rb', line 23

delegate :features, to: :item

#floor_heating_cable_product?Boolean

Check if item belongs to a generic floor heating cable product line.

Returns:

  • (Boolean)


181
182
183
184
185
186
187
# File 'app/presenters/www/product_catalog_presenter.rb', line 181

def floor_heating_cable_product?
  [
    LtreePaths::PL_FLOOR_HEATING_TEMPZONE_CABLE,
    LtreePaths::PL_FLOOR_HEATING_TEMPZONE_THIN_CABLE,
    LtreePaths::PL_FLOOR_HEATING_TEMPZONE_RULER_CABLE
  ].any? { |path| item.pl_descendant_of_path?(path) }
end

#floor_heating_mat_product?Boolean

Check if item belongs to a generic floor heating mat product line
(TempZone only — Environ and Slab Heat have dedicated landing pages).

Returns:

  • (Boolean)


172
173
174
175
176
177
178
# File 'app/presenters/www/product_catalog_presenter.rb', line 172

def floor_heating_mat_product?
  [
    LtreePaths::PL_FLOOR_HEATING_TEMPZONE_FLEX_ROLL,
    LtreePaths::PL_FLOOR_HEATING_TEMPZONE_EASY_MAT,
    LtreePaths::PL_FLOOR_HEATING_TEMPZONE_SHOWER_MAT
  ].any? { |path| item.pl_descendant_of_path?(path) }
end

#floor_plansObject

Floor plans.

Returns:

  • (Object)


315
316
317
318
319
# File 'app/presenters/www/product_catalog_presenter.rb', line 315

def floor_plans
  return nil unless product_category.is_heating_element?

  Item::FloorPlanRetriever.new(max_rooms: 1).process(product_line).all_rooms
end

#free_shipping_regionsObject

Free shipping regions.

Returns:

  • (Object)


1450
1451
1452
1453
# File 'app/presenters/www/product_catalog_presenter.rb', line 1450

def free_shipping_regions
  # Based on coupon shipping restrictions FREEGND0520
  store_item.store.country.states.where.not(code: %w[AK AS FM GU HI MH NU NL MP NT PW PE PR VI YT]).pluck(:code)
end

#identifierObject

Identifier.

Returns:

  • (Object)


214
215
216
# File 'app/presenters/www/product_catalog_presenter.rb', line 214

def identifier
  "sku:#{item_sku}"
end

#image_asset_slug_for_card(forced_profile_type: nil) ⇒ Object

Product card thumbnail only — does not change PDP #images / carousel order.
Order: WYS_CARD, then WYS_MAIN.

Parameters:

  • forced_profile_type (String, nil) (defaults to: nil)

    if set, only that WYS_* slot (or nil)



450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
# File 'app/presenters/www/product_catalog_presenter.rb', line 450

def image_asset_slug_for_card(forced_profile_type: nil)
  locale_prefix = I18n.locale.to_s.split('-').first
  wys_profiles = item.image_profiles.to_a.select do |p|
    p.image_type.to_s.start_with?('WYS_') && p.locale == locale_prefix
  end

  if forced_profile_type.present?
    p = wys_profiles.find { |pr| pr.image_type == forced_profile_type }
    return p.image.slug if p&.image

    return nil
  end

  CARD_IMAGE_ORDER.each do |t|
    p = wys_profiles.find { |pr| pr.image_type == t }
    next unless p&.image

    return p.image.slug
  end

  nil
end

#image_tag(options) ⇒ String?

Image tag for the item's primary image.

Parameters:

  • options (Hash)

    options forwarded to
    ImagePresenter#image_tag

Options Hash (options):

  • size (String)

    dimensions string, e.g. '600x600>'

Returns:

  • (String, nil)

    HTML tag, or nil without a primary image



557
558
559
560
561
# File 'app/presenters/www/product_catalog_presenter.rb', line 557

def image_tag(options)
  return unless (img = item.primary_image)

  Www::ImagePresenter.new(img).image_tag(options)
end

#image_url(options = {}) ⇒ String?

URL of the item's primary image.

Parameters:

  • options (Hash) (defaults to: {})

    image variant options forwarded to
    Image#image_url

Options Hash (options):

  • size (String)

    dimensions string, e.g. '600x600>'

Returns:

  • (String, nil)


479
480
481
482
483
# File 'app/presenters/www/product_catalog_presenter.rb', line 479

def image_url(options = {})
  return unless primary_image

  primary_image.image_url(options)
end

#imagesObject

Images.

Returns:

  • (Object)


220
221
222
223
224
225
226
227
228
229
230
231
232
233
# File 'app/presenters/www/product_catalog_presenter.rb', line 220

def images
  # PDP carousel uses WYS (WarmlyYours Site) image profiles only — no tag-based fallback.
  # IMPORTANT: Filter in Ruby to avoid N+1 queries when image_profiles is preloaded.
  # The controller preloads item: [:image_profiles] so we filter in memory.
  all_profiles = item.image_profiles.to_a

  # Filter for WYS_* types (omit WYS_CARD — listing-only, not on PDP carousel)
  wys_profiles = all_profiles
                 .select { |p| p.image_type.to_s.start_with?('WYS_') }
                 .reject { |p| p.image_type == 'WYS_CARD' }
                 .sort_by { |p| ImageProfile::IMAGE_TYPES.keys.map(&:to_s).index(p.image_type.to_s) || 999 }

  wys_profiles.map { |p| Www::ImagePresenter.new(p.image, h) }
end

#inject_category_landing_page_breadcrumb(links_array) ⇒ Object

Injects category-specific landing pages into breadcrumb when the item belongs
to a product line that has show_in_sales_portal=false (and thus gets skipped
by the normal breadcrumb builder), but we have a dedicated landing page for it.

Parameters:

  • links_array (Object)

Returns:

  • (Object)


146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'app/presenters/www/product_catalog_presenter.rb', line 146

def inject_category_landing_page_breadcrumb(links_array)
  # Only inject entries for product lines that have show_in_sales_portal=false and
  # therefore won't appear naturally in the breadcrumb from breadcrumb_links_array.
  link = if item.is_thermostat? && item.pl_descendant_of_path?(LtreePaths::PL_FLOOR_HEATING)
           { name: 'Thermostats', url: h.cms_link('/floor-heating/thermostats') }
         elsif environ_mat_product?
           { name: 'Environ Mats', url: h.cms_link('/floor-heating/environ-mats') }
         elsif floor_heating_mat_product?
           { name: 'Heated Floor Mats', url: h.cms_link('/floor-heating/heated-floor-mat') }
         elsif floor_heating_cable_product?
           { name: 'Heating Cables', url: h.cms_link('/floor-heating/heating-cable') }
         elsif snow_melting_mat_product?
           { name: 'Snow Melt Mats', url: h.cms_link('/snow-melting/mat') }
         end

  links_array.insert(1, link) if link
  links_array
end

#installation_kitsObject

Installation kits.

Returns:

  • (Object)


1295
1296
1297
1298
# File 'app/presenters/www/product_catalog_presenter.rb', line 1295

def installation_kits
  res = Item::Materials::CompatibleInstallationKits.new.process(item:)
  available_products_scope_new_only.merge(res.items)
end

#installation_methodsObject

Installation methods.

Returns:

  • (Object)


1285
1286
1287
1288
1289
1290
# File 'app/presenters/www/product_catalog_presenter.rb', line 1285

def installation_methods
  res = Item::Materials::CompatibleMembranes.new.process(item:)
  all_item_skus = res.items.pluck(:sku)
  all_item_skus.presence&.<<(ItemConstants::TZ_CABLE_GRIPSTRIP_SKU)
  available_products_scope_new_only.where(item_sku: all_item_skus)
end

#insulating_underlaymentsObject

Insulating underlayments.

Returns:

  • (Object)


1319
1320
1321
1322
# File 'app/presenters/www/product_catalog_presenter.rb', line 1319

def insulating_underlayments
  res = Item::Materials::CompatibleInsulations.new.process(item:)
  available_products_scope_new_only.merge(res.items)
end

#itemObject

Alias for Product#item

Returns:

  • (Object)

    Product#item

See Also:



22
# File 'app/presenters/www/product_catalog_presenter.rb', line 22

delegate :item, to: :product

#item_group_nameObject

Item group name.

Returns:

  • (Object)


404
405
406
407
408
# File 'app/presenters/www/product_catalog_presenter.rb', line 404

def item_group_name
  return unless variants?

  r.item.item_grouping_info&.item_group_name
end

#item_gtin13_for_structured_dataObject

Item gtin13 for structured data.

Returns:

  • (Object)


51
52
53
# File 'app/presenters/www/product_catalog_presenter.rb', line 51

def item_gtin13_for_structured_data
  product.try(:gtin13) || product.upc ? "0#{product.try(:upc)}" : nil
end

#lengthObject

Length.

Returns:

  • (Object)


496
497
498
# File 'app/presenters/www/product_catalog_presenter.rb', line 496

def length
  spec_output(:length)
end

#load_rating_snapshotObject

Load rating snapshot.

Returns:

  • (Object)


1233
1234
1235
# File 'app/presenters/www/product_catalog_presenter.rb', line 1233

def load_rating_snapshot
  ::Reviews::RatingSnapshot.for_sku(item_sku, fallback: fallback_to_internal_reviews?)
end

#new_productObject

New product.

Returns:

  • (Object)


1690
1691
1692
1693
1694
1695
# File 'app/presenters/www/product_catalog_presenter.rb', line 1690

def new_product
  return nil if item.new_item_id.blank?
  return unless (np = ViewProductCatalog.find_by(catalog_id:, item_id: item.new_item_id))

  self.class.new(np)
end

#new_product_priceObject

New product price.

Returns:

  • (Object)


1700
1701
1702
1703
1704
# File 'app/presenters/www/product_catalog_presenter.rb', line 1700

def new_product_price
  return unless new_product

  new_product.price
end

#new_product_price_formattedObject

New product price formatted.

Returns:

  • (Object)


1708
1709
1710
1711
1712
# File 'app/presenters/www/product_catalog_presenter.rb', line 1708

def new_product_price_formatted
  return unless new_product_price

  h.better_number_to_currency(new_product_price, unit: currency_symbol)
end

#non_refursbished_skuObject

Non refursbished sku.

Returns:

  • (Object)


1647
1648
1649
# File 'app/presenters/www/product_catalog_presenter.rb', line 1647

def non_refursbished_sku
  new_product.item_sku
end

Notices for side links.

Returns:

  • (Object)


760
761
762
763
764
765
766
767
768
769
# File 'app/presenters/www/product_catalog_presenter.rb', line 760

def notices_for_side_links
  return [] unless show_notices_section?

  product_notices.map do |notice|
    {
      title: notice[:title],
      anchor: "##{notice[:id]}"
    }
  end
end

#og_imageObject

Og image.

Returns:

  • (Object)


108
109
110
# File 'app/presenters/www/product_catalog_presenter.rb', line 108

def og_image
  primary_image&.image_url || DEFAULT_SOCIAL_ICON
end

#original_priceObject

What is the original price of the item? it is the non refurb price if this is a refurb or the regular price



1668
1669
1670
# File 'app/presenters/www/product_catalog_presenter.rb', line 1668

def original_price
  price_non_refurbished || price
end

#original_price_formattedObject

Original price formatted.

Returns:

  • (Object)


1674
1675
1676
1677
1678
# File 'app/presenters/www/product_catalog_presenter.rb', line 1674

def original_price_formatted
  return unless original_price

  h.better_number_to_currency(original_price, unit: currency_symbol)
end

#page_descriptionObject

Page description.

Returns:

  • (Object)


64
65
66
# File 'app/presenters/www/product_catalog_presenter.rb', line 64

def page_description
  product&.effective_seo_description
end

#page_titleObject

Page title.

Returns:

  • (Object)


57
58
59
# File 'app/presenters/www/product_catalog_presenter.rb', line 57

def page_title
  product&.seo_title || product&.public_short_name
end

#panel_size_help_configObject

Panel size help offcanvas configuration



2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
# File 'app/presenters/www/product_catalog_presenter.rb', line 2002

def panel_size_help_config
  return nil unless display_what_size_panel_do_i_need?

  pl_ltree = item_primary_product_line_slug_ltree&.to_s
  if pl_ltree&.start_with?('infrared_heating_panels.lava')
    {
      offcanvas_id: 'offcanvas-size-lava',
      offcanvas_partial: 'offcanvas_size_lava',
      title: 'What Size Panel Do I Need?'
    }
  elsif pl_ltree&.start_with?('infrared_heating_panels.ember')
    {
      offcanvas_id: 'offcanvas-size-ember',
      offcanvas_partial: 'offcanvas_size_ember',
      title: 'What Size Panel Do I Need?'
    }
  end
end

#price_formattedObject

Price formatted.

Returns:

  • (Object)


565
566
567
# File 'app/presenters/www/product_catalog_presenter.rb', line 565

def price_formatted
  h.better_number_to_currency(price, unit: currency_symbol)
end

#price_non_refurbishedObject

Price non refurbished.

Returns:

  • (Object)


1653
1654
1655
1656
1657
# File 'app/presenters/www/product_catalog_presenter.rb', line 1653

def price_non_refurbished
  return unless new_product

  new_product.price
end

#price_non_refurbished_formattedObject

Price non refurbished formatted.

Returns:

  • (Object)


1661
1662
1663
1664
1665
# File 'app/presenters/www/product_catalog_presenter.rb', line 1661

def price_non_refurbished_formatted
  return unless price_non_refurbished

  h.better_number_to_currency(price_non_refurbished, unit: currency_symbol)
end

#pricing_program_effective_price_formatted(discount) ⇒ Object

Pricing program effective price formatted.

Parameters:

  • discount (Object)

Returns:

  • (Object)


580
581
582
583
584
585
# File 'app/presenters/www/product_catalog_presenter.rb', line 580

def pricing_program_effective_price_formatted(discount)
  return unless discount

  p = original_price - (original_price * discount)
  h.better_number_to_currency(p, unit: currency_symbol)
end

#primary_imageObject

Primary image.

Returns:

  • (Object)


443
444
445
# File 'app/presenters/www/product_catalog_presenter.rb', line 443

def primary_image
  images.first
end

#product_country_isoObject

Product country iso.

Returns:

  • (Object)


1457
1458
1459
# File 'app/presenters/www/product_catalog_presenter.rb', line 1457

def product_country_iso
  store_item.store.country.iso
end

#product_dimension_imageObject

Product dimension image.

Returns:

  • (Object)


238
239
240
241
242
243
244
245
246
247
248
249
# File 'app/presenters/www/product_catalog_presenter.rb', line 238

def product_dimension_image
  retrieved_images = Item::ImageRetriever.new(tags: 'product-dimension',
                                              item_images_query_limit: 1,
                                              product_line_query_limit: 0,
                                              max_images: 1,
                                              ignore_kit_components: true,
                                              ignore_item_primary_image: true,
                                              ignore_vignette_installation_image: true,
                                              ignore_product_line_primary_image: true)
                                         .process(item).all_images
  retrieved_images.map { |img| Www::ImagePresenter.new(img, h) }&.first
end

#product_lineObject

Product line.

Returns:

  • (Object)


603
604
605
# File 'app/presenters/www/product_catalog_presenter.rb', line 603

def product_line
  sales_portal_product_line || primary_product_line.root
end

#product_line_presenterObject

Product line presenter.

Returns:

  • (Object)


127
128
129
# File 'app/presenters/www/product_catalog_presenter.rb', line 127

def product_line_presenter
  Www::ProductLinePresenter.new(primary_product_line, h)
end

#product_noticesObject

Product notices.

Returns:

  • (Object)


726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
# File 'app/presenters/www/product_catalog_presenter.rb', line 726

def product_notices
  notices = []

  # Quebec repairability notices (only for Canadian locale)
  if h.canada?
    repairability_notice_text = combined_repairability_notice_text

    if repairability_notice_text.present?
      notices << {
        id: 'quebec_repairability',
        title: 'Quebec Repairability Notice',
        text: repairability_notice_text
      }
    end
  end

  # Future notices can be added here following the same pattern
  # if condition_for_another_notice?
  #   title = "Notice Title"
  #   text = "Notice text..."
  #   notices << { id: 'another_notice_key', title: title, text: text } if text.present?
  # end

  notices
end

#product_review_info(page: 1, limit: 5) ⇒ Object

Product review info.

Parameters:

  • page (Integer) (defaults to: 1)
  • limit (Integer) (defaults to: 5)

Returns:

  • (Object)


618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
# File 'app/presenters/www/product_catalog_presenter.rb', line 618

def product_review_info(page: 1, limit: 5)
  review_info = ::ReviewsIo.fetch_reviews(sku: item_sku, page: page, limit: limit)
  apply_reviews_snapshot(review_info)
rescue StandardError => e
  Rails.logger.error("Error fetching product reviews for #{item_sku}: #{e.message}")
  begin
    ErrorReporting.error(e, item_sku: item_sku)
  rescue StandardError
    nil
  end
  # Return empty result to prevent breaking the page
  {
    reviews: [],
    star_avg: nil,
    num: 0,
    name: nil,
    item_sku: item_sku,
    updated_at: Time.current
  }
end

#product_type_pathObject

Removed: was unused in codebase



208
209
210
# File 'app/presenters/www/product_catalog_presenter.rb', line 208

def product_type_path
  @product_type_path ||= Feed::ProductTypeGenerator.process(item:, separator: '/')
end

#public_specificationsObject

Public specifications.

Returns:

  • (Object)


420
421
422
423
424
425
426
427
428
429
430
431
432
# File 'app/presenters/www/product_catalog_presenter.rb', line 420

def public_specifications
  # For kits we will do a special treatment and break it down per item
  if item_is_kit
    item.get_kit_items(spec_only: true).flat_map do |ki|
      ki.public_specifications.map do |s|
        s.grouping = "#{s.grouping} (#{ki.sku})"
        s
      end
    end
  else
    item.public_specifications
  end
end

#public_specifications_groupedObject

Public specifications grouped.

Returns:

  • (Object)


437
438
439
# File 'app/presenters/www/product_catalog_presenter.rb', line 437

def public_specifications_grouped
  public_specifications.sort_by(&:name).group_by(&:grouping).reject { |_group, specifications| specifications.empty? }
end

#quote_builder_urlObject

Alias for Product_line_presenter#quote_builder_url

Returns:

  • (Object)

    Product_line_presenter#quote_builder_url

See Also:



21
# File 'app/presenters/www/product_catalog_presenter.rb', line 21

delegate :quote_builder_url, :show_product_sample_request?, to: :product_line_presenter, allow_nil: true

#rating(page: 1, limit: @options[:product_reviews_limit] || 5) ⇒ Object

Rating.

Parameters:

  • page (Integer) (defaults to: 1)
  • limit (Object) (defaults to: @options[:product_reviews_limit] || 5)

Returns:

  • (Object)


650
651
652
653
654
655
656
657
658
659
660
661
662
663
# File 'app/presenters/www/product_catalog_presenter.rb', line 650

def rating(page: 1, limit: @options[:product_reviews_limit] || 5)
  Rating.new(product_review_info(page:, limit:), item)
rescue StandardError => e
  Rails.logger.error("Error creating rating for #{item_sku}: #{e.message}")
  # Return empty rating to prevent breaking the page
  Rating.new({
    reviews: [],
    star_avg: nil,
    num: 0,
    name: nil,
    item_sku: item_sku,
    updated_at: Time.current
  }, item)
end

#rating_snapshotObject

Rating snapshot.

Returns:

  • (Object)


677
678
679
# File 'app/presenters/www/product_catalog_presenter.rb', line 677

def rating_snapshot
  @rating_snapshot ||= load_rating_snapshot
end

#refurbished_item?Boolean

Whether refurbished item.

Returns:

  • (Boolean)


1641
1642
1643
# File 'app/presenters/www/product_catalog_presenter.rb', line 1641

def refurbished_item?
  item_condition == 'refurbished' && new_item_id.present?
end

#refurbished_priceObject

Refurbished price.

Returns:

  • (Object)


1606
1607
1608
# File 'app/presenters/www/product_catalog_presenter.rb', line 1606

def refurbished_price
  refurbished_product.price
end

#refurbished_price_discountObject

Refurbished price discount.

Returns:

  • (Object)


1629
1630
1631
# File 'app/presenters/www/product_catalog_presenter.rb', line 1629

def refurbished_price_discount
  discount_percentage
end

#refurbished_price_formattedObject

Refurbished price formatted.

Returns:

  • (Object)


1612
1613
1614
# File 'app/presenters/www/product_catalog_presenter.rb', line 1612

def refurbished_price_formatted
  h.better_number_to_currency(refurbished_product.price, unit: currency_symbol)
end

#refurbished_productObject

Refurbished product.

Returns:

  • (Object)


1597
1598
1599
1600
1601
# File 'app/presenters/www/product_catalog_presenter.rb', line 1597

def refurbished_product
  return unless (p = available_products_scope.find_by(item_id: refurbished_item_id))

  self.class.new(p)
end

Related services.

Returns:

  • (Object)


1327
1328
1329
# File 'app/presenters/www/product_catalog_presenter.rb', line 1327

def related_services
  available_services_scope
end

#repairability_product_line_for_itemObject

Repairability product line for item.

Returns:

  • (Object)


787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
# File 'app/presenters/www/product_catalog_presenter.rb', line 787

def repairability_product_line_for_item
  item_primary_line = item.primary_product_line
  return if item_primary_line.blank?

  item_primary_line.self_and_ancestors.find do |product_line|
    next if product_line.blank?

    has_notice_for_ca = product_line.try(:repairability_notice_fr_ca).present? ||
                        product_line.try(:repairability_notice_en_ca).present?
    next unless has_notice_for_ca

    categories = Array(product_line.product_category_ids).compact
    next true if categories.blank?

    next false if item.product_category_id.blank? || item.pc_path_ids.blank?

    category_paths = ProductCategory.where(id: categories).pluck(:ltree_path_ids).compact
    category_paths.any? { |path| item.pc_path_ids.to_s.start_with?(path.to_s) }
  end
end

#restricted_to_trade?Boolean

Whether restricted to trade.

Returns:

  • (Boolean)


1807
1808
1809
1810
# File 'app/presenters/www/product_catalog_presenter.rb', line 1807

def restricted_to_trade?
  item.pl_descendant_of_path?(LtreePaths::PL_FLOOR_HEATING_TEMPZONE_RULER_CABLE) &&
    item.pc_descendant_of_path?(LtreePaths::PC_HEATING_ELEMENTS_HEATING_CABLES)
end

#retrieve_documents(categories = [:all]) ⇒ Object

Retrieve documents.

Parameters:

  • categories (Array) (defaults to: [:all])

Returns:

  • (Object)


277
278
279
# File 'app/presenters/www/product_catalog_presenter.rb', line 277

def retrieve_documents(categories = [:all])
  Item::PublicationRetriever.new.process(item_sku, categories:, all_product_lines: false)
end

#review_last_updated_atObject

Review last updated at.

Returns:

  • (Object)


642
643
644
# File 'app/presenters/www/product_catalog_presenter.rb', line 642

def review_last_updated_at
  product_review_info[:updated_at]
end

Reviews link.

Returns:

  • (Object)


610
611
612
# File 'app/presenters/www/product_catalog_presenter.rb', line 610

def reviews_link
  h.catalog_link(item, section: :reviews)
end

#sale_price_formattedObject

Sale price formatted.

Returns:

  • (Object)


589
590
591
592
593
# File 'app/presenters/www/product_catalog_presenter.rb', line 589

def sale_price_formatted
  return unless sale_price

  h.better_number_to_currency(sale_price, unit: currency_symbol)
end

#sale_price_in_effect?Boolean

Whether sale price in effect.

Returns:

  • (Boolean)


597
598
599
# File 'app/presenters/www/product_catalog_presenter.rb', line 597

def sale_price_in_effect?
  r.sale_price_in_effect
end

#sale_price_percentage_off_displayObject

Sale price percentage off display.

Returns:

  • (Object)


1635
1636
1637
# File 'app/presenters/www/product_catalog_presenter.rb', line 1635

def sale_price_percentage_off_display
  discount_percentage
end

#schema_availabilityObject

Schema availability.

Returns:

  • (Object)


690
691
692
693
694
695
696
697
# File 'app/presenters/www/product_catalog_presenter.rb', line 690

def schema_availability
  # InStock
  # PreOrder
  # Discontinued
  # OutOfStock
  # LimitedAvailability
  h.tag(:link, itemprop: 'availability', href: schema_availability_url)
end

#schema_availability_urlObject

Schema availability url.

Returns:

  • (Object)


684
685
686
# File 'app/presenters/www/product_catalog_presenter.rb', line 684

def schema_availability_url
  "https://schema.org/#{availability}"
end

#schema_dot_org_merchant_return_policyObject

Schema dot org merchant return policy.

Returns:

  • (Object)


1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
# File 'app/presenters/www/product_catalog_presenter.rb', line 1406

def schema_dot_org_merchant_return_policy
  # Reflects https://www.warmlyyours.com/company/return-policy
  SchemaDotOrg::MerchantReturnPolicy.new(
    applicableCountry: product_country_iso,
    # No restocking fees; customer covers shipping for remorse/damaged installation
    returnFees: 'https://schema.org/FreeReturn',
    # No general time limit on returns; 30-day window applies to advance replacement
    returnPolicyCategory: 'https://schema.org/MerchantReturnUnlimitedWindow',
    returnWithin: SchemaDotOrg::QuantitativeValue.new(value: 30, unitCode: 'DAY'),
    # Returns are mailed back; label available to download and print
    returnMethod: ['https://schema.org/ReturnByMail'],
    returnLabelSource: 'https://schema.org/ReturnLabelDownloadAndPrint',
    # Exchanges for damaged-in-transit are free return shipping
    returnShippingFees: 'https://schema.org/FreeReturn'
  )
end

#schema_dot_org_offer_shipping_detailsObject

Build shipping details for standard (non-oversize) items: free shipping with a
fixed $0 rate and delivery window. Oversize/freight items ship at a variable,
quote-based rate that can't be expressed as a fixed shippingRate — and Google
requires shippingRate on OfferShippingDetails — so we omit shippingDetails
entirely for oversize rather than emit an invalid node (was triggering
"shippingRate is required for OfferShippingDetails" rich-result errors).



1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
# File 'app/presenters/www/product_catalog_presenter.rb', line 1429

def schema_dot_org_offer_shipping_details
  return nil if item&.oversize?

  SchemaDotOrg::OfferShippingDetails.new(
    shippingRate: SchemaDotOrg::MonetaryAmount.new(
      value: 0.0,
      currency: currency
    ),
    shippingDestination: SchemaDotOrg::DefinedRegion.new(
      addressCountry: product_country_iso,
      addressRegion: free_shipping_regions
    ),
    deliveryTime: SchemaDotOrg::ShippingDeliveryTime.new(
      handlingTime: SchemaDotOrg::QuantitativeValue.new(minValue: 0, maxValue: 3, unitCode: 'DAY'),
      transitTime: SchemaDotOrg::QuantitativeValue.new(minValue: 1, maxValue: 5, unitCode: 'DAY')
    )
  )
end

#schema_dot_org_structure(exclude_segments: []) ⇒ Object

Schema dot org structure.

Parameters:

  • exclude_segments (Array) (defaults to: [])

Returns:

  • (Object)


1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
# File 'app/presenters/www/product_catalog_presenter.rb', line 1464

def schema_dot_org_structure(exclude_segments: [])
  schema = SchemaDotOrg::Product.new
  # Stable product IRI so reviews and other entities can reference it via @id
  schema.id_iri = url
  schema.name = title
  schema.category = product_type_path
  schema.model = item_sku
  schema.sku = item_sku
  schema.gtin13 = item_gtin13_for_structured_data
  schema.itemCondition = schema_item_condition_url
  schema.image = structured_data_product_image_url
  schema.description = Oembed::ProductProvider.sanitize_schema_text(
    item.public_description_text.presence || title
  )
  schema.brand = Brand.warmlyyours.schema_dot_org_structure
  schema.url = url
  schema.mpn = item_sku # Manufacturer Part Number
  schema.isAccessoryOrSparePartFor = nil # Not applicable for thermostats
  schema.isRelatedTo = nil # Could be enhanced for related products
  schema.manufacturer = Brand.warmlyyours.schema_dot_org_structure
  schema.material = 'Electronic Components'
  schema.color = 'White' # Most thermostats are white
  additional_specs = build_additional_properties_from_specs
  schema.additionalProperty = additional_specs if additional_specs.present?

  schema.offers = schema_offer_struc

  # Add aggregate rating using cached rating_snapshot (avoids external API call)
  # Full reviews are no longer embedded in schema for performance reasons
  begin
    snapshot = rating_snapshot
    if exclude_segments.exclude?(:aggregate_rating) && snapshot[:count].to_i.positive?
      schema.aggregateRating = SchemaDotOrg::AggregateRating.new(
        ratingValue: snapshot[:average].to_f, # Allow decimals (e.g., 4.5)
        reviewCount: snapshot[:count].to_i
      )
    end
    # NOTE: Individual reviews are no longer embedded in Product schema.
    # This significantly improves page load performance by avoiding external API calls.
    # Google still picks up aggregate rating data for rich snippets.
  rescue StandardError => e
    # Log error but don't break schema generation
    Rails.logger.error("Error adding reviews to schema for #{item_sku}: #{e.message}")
    begin
      ErrorReporting.error(e, item_sku: item_sku)
    rescue StandardError
      nil
    end
  end

  schema
end

#schema_item_conditionObject

Schema item condition.

Returns:

  • (Object)


714
715
716
# File 'app/presenters/www/product_catalog_presenter.rb', line 714

def schema_item_condition
  h.tag(:link, itemprop: 'itemCondition', href: schema_item_condition_url)
end

#schema_item_condition_urlObject

Schema item condition url.

Returns:

  • (Object)


701
702
703
704
705
706
707
708
709
710
# File 'app/presenters/www/product_catalog_presenter.rb', line 701

def schema_item_condition_url
  case item_condition
  when 'refurbished'
    'https://schema.org/RefurbishedCondition'
  when 'damaged'
    'https://schema.org/DamagedCondition'
  else
    'https://schema.org/NewCondition'
  end
end

#schema_offer_strucObject

Schema offer struc.

Returns:

  • (Object)


1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
# File 'app/presenters/www/product_catalog_presenter.rb', line 1361

def schema_offer_struc
  o = SchemaDotOrg::Offer.new
  o.url = url
  o.priceCurrency = currency
  o.availability = schema_availability_url
  o.itemCondition = schema_item_condition_url
  o.sku = item_sku
  o.gtin13 = item_gtin13_for_structured_data

  if sale_price_in_effect?
    sale_end = sale_price_expiration_date || Date.current.end_of_month
    # offers.price is the price the customer actually pays (the sale price), so
    # it matches the displayed price and avoids Merchant Center price mismatches.
    # The regular/list price (feed <g:price>) is conveyed as a StrikethroughPrice
    # priceSpecification — the type Google requires for the struck-through
    # reference price in product rich results (SalePrice here is rejected).
    o.price = sale_price
    o.priceValidUntil = sale_end

    o.priceSpecification = [
      SchemaDotOrg::PriceSpecification.new(
        price: price.to_s,
        priceCurrency: currency,
        priceType: 'https://schema.org/StrikethroughPrice'
      )
    ]
  else
    o.price = price
    o.priceValidUntil = (Date.current + 30.days).iso8601
  end

  # Enhanced offer details
  o.hasMerchantReturnPolicy = schema_dot_org_merchant_return_policy
  o.seller = Www::SeoHelper.online_store_schema

  # Add free shipping details when applicable (non-oversize items)
  if (sd = schema_dot_org_offer_shipping_details)
    o.shippingDetails = sd
  end

  o
end

#sectionsObject

rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity



871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
# File 'app/presenters/www/product_catalog_presenter.rb', line 871

def sections
  # Don't change this order unless you get clearance from Julia
  sections = %i[description specifications installation_plans showcases related_products documents surcharges faq terms_and_conditions reviews]
  sections.insert(0, :features) unless display_features_prominently?
  # Insert notices section between features and description if applicable
  if show_notices_section?
    features_index = sections.index(:features)
    if features_index
      sections.insert(features_index + 1, :notices)
    else
      # If features is not in sections (displayed prominently), insert before description
      description_index = sections.index(:description)
      sections.insert(description_index, :notices) if description_index
    end
  end
  related_products_section = RELATED_PRODUCT_SUBSECTIONS
  main_category = product_category.name
  main_category_sym = main_category.delete(' ').underscore.to_sym
  sections.delete(main_category_sym)
  s = {}
  # We keep track here of products that were already rendered so we don't re-render them in multiple sections.
  rendered_catalog_item_ids = []
  sections.each do |section|
    if lazy_rendering? && LAZY_SECTIONS.include?(section)
      meta = (section)
      s[section] = meta if meta
      next
    end

    case section
    when :features
      features = item.features
      if features.present?
        s[section] = {
          section_name: 'Features',
          features:
        }
      end
    when :notices
      if (notices = product_notices).any?
        section_name = I18n.t('products.notices.section_name', default: 'Notices')
        s[section] = {
          nav_name: section_name,
          section_name: section_name,
          notices: notices
        }
      end
    when :description
      s[section] = {
        section_name: description_header,
        description_html:
      }
    when :specifications
      if public_specifications_grouped.any?
        s[section] = {
          section_name: section_name(section),
          specifications_grouped: public_specifications_grouped
        }
      end
    when :documents
      if documents_grouped_by_category.any?
        s[section] = {
          section_name: section_name(section),
          documents_grouped_by_category:
        }
      end
    when :videos
      if videos.any?
        s[section] = {
          section_name: section_name(section),
          videos:
        }
      end
    when :faq
      if faqs.any?
        s[section] = {
          nav_name: section_name(section),
          section_name: 'Frequently Asked Questions',
          faqs:,
          top_faqs: faqs.first(5),
          product_name: product_line.display_name
        }
      end
    when :terms_and_conditions
      if terms_and_conditions.present?
        s[section] = {
          section_name: section_name(section),
          terms_and_conditions:
        }
      end
    when :surcharges
      if surcharges.present?
        s[section] = {
          section_name: 'Potential Surcharges',
          surcharges:
        }
      end
    when :reviews
      # Always show reviews section so users can write a review
      internal_reviews = rating.product_reviews
      s[section] = {
        section_name: section_name(section),
        top_reviews: top_rating.product_reviews,
        reviews: internal_reviews,
        average_rating: rating.avg_stars,
        review_count: rating.review_count,
        product_name: product_line.display_name
      }
    when :installation_plans
      if floor_plans.present?
        s[section] = {
          section_name: section_name(section),
          floor_plans:
        }
      end
    when :showcases
      if showcases_for_item.present?
        s[section] = {
          nav_name: 'Real Projects',
          section_name: section_name(section),
          showcases: showcases_for_item,
          product_name: product_line&.display_name
        }
      end
    when :related_products
      # Collect all related products first to batch-preload their roots
      all_related_products = []
      related_products_section.each do |related_product_section|
        section_products = send(related_product_section)
        next if section_products.blank?

        section_products = section_products.reject { |p| rendered_catalog_item_ids.include?(p.id) }
        next if section_products.blank?

        all_related_products.concat(section_products)
      end

      # Preload root system product lines and root product categories to avoid N+1 queries
      preload_product_type_roots(all_related_products) if all_related_products.any?

      # Now create presenters and organize by section
      related_products_section.each do |related_product_section|
        section_products = send(related_product_section)
        next if section_products.blank?

        section_products = section_products.reject { |p| rendered_catalog_item_ids.include?(p.id) }
        next if section_products.blank?

        s[section] ||= {}
        s[section][related_product_section] = {
          section_name: section_name(related_product_section),
          section_intro: section_intro(related_product_section),
          products: section_products.map { |p| Www::ProductCatalogPresenter.new(p, h) }
        }
        rendered_catalog_item_ids += section_products.map(&:id)
      end
    else
      section_products = send(section)
      next if section_products.blank?

      section_products = section_products.reject { |p| rendered_catalog_item_ids.include?(p.id) }
      if section_products.present?
        # Preload root system product lines and root product categories to avoid N+1 queries
        preload_product_type_roots(section_products)

        # Wrap our products in a presenter
        s[section] = {
          section_name: section.to_s.titleize,
          section_intro: section_intro(section),
          products: section_products.map { |p| Www::ProductCatalogPresenter.new(p, h) }.sort_by(&:effective_price)
        }
        rendered_catalog_item_ids += section_products.map(&:id)
      end
    end
  end
  s
end

#shapeObject

Shape.

Returns:

  • (Object)


514
515
516
# File 'app/presenters/www/product_catalog_presenter.rb', line 514

def shape
  spec_output(:shape)
end

#show_cable_help?Boolean

Whether show cable help.

Returns:

  • (Boolean)


1875
1876
1877
1878
1879
1880
1881
1882
# File 'app/presenters/www/product_catalog_presenter.rb', line 1875

def show_cable_help?
  # Floor heating cables
  return true if display_how_much_cable_will_i_need?
  # Roof and gutter deicing
  return true if display_roof_gutter_deicing_quote?

  false
end

#show_notices_section?Boolean

Whether show notices section.

Returns:

  • (Boolean)


754
755
756
# File 'app/presenters/www/product_catalog_presenter.rb', line 754

def show_notices_section?
  product_notices.any?
end

#show_product_sample_request?Object

Alias for Product_line_presenter#show_product_sample_request?

Returns:

  • (Object)

    Product_line_presenter#show_product_sample_request?

See Also:



21
# File 'app/presenters/www/product_catalog_presenter.rb', line 21

delegate :quote_builder_url, :show_product_sample_request?, to: :product_line_presenter, allow_nil: true

#show_quote_builder_link?Boolean

Whether show quote builder link.

Returns:

  • (Boolean)


1722
1723
1724
# File 'app/presenters/www/product_catalog_presenter.rb', line 1722

def show_quote_builder_link?
  item.pc_descendant_of_path?(LtreePaths::PC_HEATING_ELEMENTS) && product_line_presenter.show_quote_builder_link?
end

#show_support_portal_link?Boolean

Whether show support portal link.

Returns:

  • (Boolean)


1728
1729
1730
# File 'app/presenters/www/product_catalog_presenter.rb', line 1728

def show_support_portal_link?
  support_portal_url.present?
end

#showcases_for_itemObject

Fetch published showcases featuring this product (either directly or via quotes)
Returns up to 4 showcases, ordered by most recently updated



1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
# File 'app/presenters/www/product_catalog_presenter.rb', line 1334

def showcases_for_item
  Showcase
    .published
    .for_item(item.id)
    .with_main_image
    .preload(:region_state)
    .order_by_images
    .order(updated_at: :desc)
    .limit(4)
end

#single_sku?Boolean

Whether single sku.

Returns:

  • (Boolean)


121
122
123
# File 'app/presenters/www/product_catalog_presenter.rb', line 121

def single_sku?
  variants_count <= 1
end

#size_overallObject

Size overall.

Returns:

  • (Object)


508
509
510
# File 'app/presenters/www/product_catalog_presenter.rb', line 508

def size_overall
  spec_output(:size_overall)
end

#smart_service?Boolean

Whether smart service.

Returns:

  • (Boolean)


1716
1717
1718
# File 'app/presenters/www/product_catalog_presenter.rb', line 1716

def smart_service?
  item.pc_descendant_of_path?(LtreePaths::PC_SERVICES)
end

#snow_melting_mat_product?Boolean

Check if item belongs to the snow melting mat product line (PowerMat /
OmniMat). snow_melting.mat is show_in_sales_portal=false so it is skipped by
the normal breadcrumb and must be injected; snow_melting.cable is
sales-portal-visible and already appears, so it needs no injection.

Returns:

  • (Boolean)


193
194
195
# File 'app/presenters/www/product_catalog_presenter.rb', line 193

def snow_melting_mat_product?
  item.pl_descendant_of_path?(LtreePaths::PL_SNOW_MELTING_MAT)
end

#spec_output_for_axis(token) ⇒ Object

Spec output for axis.

Parameters:

  • token (Object)

Returns:

  • (Object)


414
415
416
# File 'app/presenters/www/product_catalog_presenter.rb', line 414

def spec_output_for_axis(token)
  spec_output(token)
end

#stockObject

Stock.

Returns:

  • (Object)


1530
1531
1532
1533
# File 'app/presenters/www/product_catalog_presenter.rb', line 1530

def stock
  # this is only used for refurbished items
  catalog_item&.store_item&.qty_available
end

#stock?Boolean

Whether stock.

Returns:

  • (Boolean)


1537
1538
1539
1540
# File 'app/presenters/www/product_catalog_presenter.rb', line 1537

def stock?
  # this is only used for refurbished items
  stock.positive?
end

#stock_indicator_bulletString

Stock status indicator for facet buttons
Returns a small colored bullet indicating stock level

Returns:

  • (String)

    HTML for stock indicator bullet



1545
1546
1547
1548
# File 'app/presenters/www/product_catalog_presenter.rb', line 1545

def stock_indicator_bullet
  color = stock_indicator_color
  h.tag.span('', class: "stock-bullet stock-bullet-#{color}", title: stock_indicator_title)
end

#stock_indicator_colorSymbol

Color class for stock indicator: green, orange, red

Returns:

  • (Symbol)


1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
# File 'app/presenters/www/product_catalog_presenter.rb', line 1552

def stock_indicator_color
  return :red unless product_available?

  qty = store_item_qty_available.to_i
  warn_threshold = item&.qty_warn_on_stock.to_i
  warn_threshold = 10 if warn_threshold.zero?

  # Orange for low stock (qty <= 3 or below warning threshold)
  qty <= warn_threshold ? :orange : :green
end

#stock_indicator_titleString

Tooltip text for stock indicator

Returns:

  • (String)


1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
# File 'app/presenters/www/product_catalog_presenter.rb', line 1565

def stock_indicator_title
  return 'Out of Stock' unless product_available?

  qty = store_item_qty_available.to_i
  if qty <= 3
    "Only #{qty} left"
  elsif qty <= 10
    'Low Stock'
  else
    'In Stock'
  end
end

#structured_data_product_image_urlObject

JSON-LD Product requires image for Google merchant listings. PDP carousel
images are WYS_* profiles only. If the item has no photos in the library,
we still emit a stable https URL (see PRODUCT_STRUCTURED_DATA_PLACEHOLDER_IMAGE).



488
489
490
491
492
# File 'app/presenters/www/product_catalog_presenter.rb', line 488

def structured_data_product_image_url
  image_url(size: '600x600>').presence ||
    item.primary_image_url(size: '600x600>').presence ||
    PRODUCT_STRUCTURED_DATA_PLACEHOLDER_IMAGE
end

#support_portal_urlObject

Support portal url.

Returns:

  • (Object)


1734
1735
1736
1737
1738
1739
1740
# File 'app/presenters/www/product_catalog_presenter.rb', line 1734

def support_portal_url
  # Would this product be visible for support?
  return unless Item.goods_visible_for_support.exists?(sku: item_sku)

  # And build a support url
  h.catalog_link(item, section: :support)
end

#surchargesObject

Surcharges.

Returns:

  • (Object)


99
100
101
102
103
104
# File 'app/presenters/www/product_catalog_presenter.rb', line 99

def surcharges
  return nil unless smart_service?

  # Content originates from trusted CMS input
  product.item.terms_of_service&.html_safe
end

#terms_and_conditionsObject

Terms and conditions.

Returns:

  • (Object)


90
91
92
93
94
95
# File 'app/presenters/www/product_catalog_presenter.rb', line 90

def terms_and_conditions
  return nil unless smart_service?

  # Content originates from trusted CMS input
  product.item.terms_and_conditions&.html_safe
end

#to_json_ld(exclude_segments: []) ⇒ Object

To json ld.

Parameters:

  • exclude_segments (Array) (defaults to: [])

Returns:

  • (Object)


1520
1521
1522
1523
1524
1525
1526
# File 'app/presenters/www/product_catalog_presenter.rb', line 1520

def to_json_ld(exclude_segments: [])
  s = schema_dot_org_structure(exclude_segments: exclude_segments)
  s.to_s
rescue StandardError => e
  ErrorReporting.error e
  Rails.logger.error e
end

#top_rating(page: 1, limit: 10) ⇒ Object

Top rating.

Parameters:

  • page (Integer) (defaults to: 1)
  • limit (Integer) (defaults to: 10)

Returns:

  • (Object)


670
671
672
# File 'app/presenters/www/product_catalog_presenter.rb', line 670

def top_rating(page: 1, limit: 10)
  Rating.new(product_review_info(page:, limit:), item)
end

#upgradesObject

Upgrades.

Returns:

  • (Object)


1277
1278
1279
1280
# File 'app/presenters/www/product_catalog_presenter.rb', line 1277

def upgrades
  res = Item::Materials::CompatibleUpgrades.new.process(item:)
  available_products_scope_new_only.merge(res.items).reorder(:sale_price, :price)
end

#urlObject

Url.

Returns:

  • (Object)


42
43
44
45
46
47
# File 'app/presenters/www/product_catalog_presenter.rb', line 42

def url
  path = item.canonical_path
  return nil unless path

  h.cms_link("/#{path}", r.locale, scheme: 'https', host: WEB_HOSTNAME_WITHOUT_PORT, port: APP_PORT_NUMBER)
end

#variants(include_self: false, axis_filters: {}) ⇒ Object

Variants.

Parameters:

  • include_self (Boolean) (defaults to: false)
  • axis_filters (Hash) (defaults to: {})

Returns:

  • (Object)


332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
# File 'app/presenters/www/product_catalog_presenter.rb', line 332

def variants(include_self: false, axis_filters: {})
  # `catalog_item.variants` includes a `.order(product_categories.priority, ...)`
  # via `catalog_items_in_same_catalog`. AR's `.ids` adds an implicit
  # `GROUP BY catalog_items.id` for the join'd relation, which PG strict mode
  # rejects when the ORDER BY references a non-grouped column. We re-sort
  # the actual results below; drop the inherited order for the ID pluck.
  catalog_item_ids = catalog_item.variants(include_self:, axis_filters: axis_filters)&.reorder(nil)&.ids || []
  all_sort_keys = variant_sort_keys.compact.uniq.presence || ['price asc', 'item_sku asc']

  # Separate database column sort keys from spec-based sort keys
  db_sort_keys, spec_sort_keys = all_sort_keys.partition { |k| !k.to_s.start_with?('spec:') }
  db_sort_keys = ['price asc', 'item_sku asc'] if db_sort_keys.empty?

  results = ViewProductCatalog.visible_to_public
                              .where(item_condition: 'new', catalog_item_id: catalog_item_ids, catalog_id:)
                              .preload(:item)
                              .reorder(*db_sort_keys)
                              .map { |pc| Www::ProductCatalogPresenter.new(pc) }

  # Apply spec-based sorting in Ruby if specified - sort by all keys together
  # Supports format: "spec:token asc" or "spec:token:unit asc" (e.g., "spec:width:in asc")
  if spec_sort_keys.any?
    parsed_keys = spec_sort_keys.filter_map do |spec_key|
      match = spec_key.to_s.match(/^spec:(\w+)(?::(\w+))?\s+(asc|desc)$/i)
      next unless match

      { token: match[1].to_sym, unit: match[2], desc: match[3].downcase == 'desc' }
    end

    if parsed_keys.any?
      results = results.sort do |a, b|
        comparison = 0
        parsed_keys.each do |key|
          va = a.spec_value(key[:token], output_unit: key[:unit])
          vb = b.spec_value(key[:token], output_unit: key[:unit])
          # Compare numerically when both are numeric, otherwise as strings
          comparison = if va.is_a?(Numeric) && vb.is_a?(Numeric)
                         va <=> vb
                       else
                         va.to_s <=> vb.to_s
                       end
          comparison = -comparison if key[:desc]
          break unless comparison.zero?
        end
        comparison
      end
    end
  end

  results
end

#variants?Boolean

Whether variants.

Returns:

  • (Boolean)


398
399
400
# File 'app/presenters/www/product_catalog_presenter.rb', line 398

def variants?
  variants.present?
end

#variants_countObject

Variants count.

Returns:

  • (Object)


114
115
116
# File 'app/presenters/www/product_catalog_presenter.rb', line 114

def variants_count
  variants(include_self: true).size
end

#videosObject

Videos.

Returns:

  • (Object)


254
255
256
# File 'app/presenters/www/product_catalog_presenter.rb', line 254

def videos
  Item::VideoRetriever.new(tags: (smart_service? ? 'for-service-page' : 'for-product-page'), ignore_individual_query_limits: true).process(item).all_videos
end

#voltageObject

Voltage.

Returns:

  • (Object)


547
548
549
# File 'app/presenters/www/product_catalog_presenter.rb', line 547

def voltage
  spec_output(:voltage)
end

#wattsObject

Watts.

Returns:

  • (Object)


541
542
543
# File 'app/presenters/www/product_catalog_presenter.rb', line 541

def watts
  spec_output(:watts)
end

#widthObject

Width.

Returns:

  • (Object)


502
503
504
# File 'app/presenters/www/product_catalog_presenter.rb', line 502

def width
  spec_output(:width)
end