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 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, #check_force_logout, #check_or_cross, #check_or_times, #embedded_tab_frame_id, #error_messages, #general_disclaimer_on_product_installation_and_local_codes, #gridjs_from_html_table, #gridjs_table, #is_wy_ip, #line_break, #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, #track_page?, #turbo_section_wrapper, #turbo_tabs_request?, #url_on_same_domain_as_request, #widget_index_daily_focus_index_path, #working_hours?, #yes_or_no, #yes_or_no_highlighted, #yes_or_no_with_check_or_cross, #youtube_video

Methods included from UppyUploaderHelper

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

Methods included from ImagesHelper

#image_asset_tag, #image_asset_url

Methods included from SeoHelper

#add_page_schema, #add_webpage_schema, #canada?, #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



24
25
26
27
# File 'app/presenters/www/product_catalog_presenter.rb', line 24

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

Instance Method Details

#accessoriesObject



1103
1104
1105
1106
# File 'app/presenters/www/product_catalog_presenter.rb', line 1103

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

#allow_add_to_cart?Boolean

Returns:

  • (Boolean)


1517
1518
1519
1520
1521
1522
# File 'app/presenters/www/product_catalog_presenter.rb', line 1517

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



1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
# File 'app/presenters/www/product_catalog_presenter.rb', line 1541

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



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
# File 'app/presenters/www/product_catalog_presenter.rb', line 1003

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



1030
1031
1032
# File 'app/presenters/www/product_catalog_presenter.rb', line 1030

def available_products_scope_new_only
  available_products_scope.excluding_refurbished
end

#available_services_scopeObject



1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
# File 'app/presenters/www/product_catalog_presenter.rb', line 1063

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


150
151
152
153
154
155
156
# File 'app/presenters/www/product_catalog_presenter.rb', line 150

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


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

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



1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
# File 'app/presenters/www/product_catalog_presenter.rb', line 1141

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



1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
# File 'app/presenters/www/product_catalog_presenter.rb', line 1560

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



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

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

#canonical_paths(request_path, request_url: nil) ⇒ Object

Removed: unused predicate helper roll_or_mat



418
419
420
421
# File 'app/presenters/www/product_catalog_presenter.rb', line 418

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


203
204
205
206
207
208
209
210
211
212
213
# File 'app/presenters/www/product_catalog_presenter.rb', line 203

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



608
609
610
611
612
613
614
615
616
617
618
# File 'app/presenters/www/product_catalog_presenter.rb', line 608

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.



651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
# File 'app/presenters/www/product_catalog_presenter.rb', line 651

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



1109
1110
1111
1112
# File 'app/presenters/www/product_catalog_presenter.rb', line 1109

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

#controlsObject



1077
1078
1079
1080
# File 'app/presenters/www/product_catalog_presenter.rb', line 1077

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

#coverageObject



427
428
429
# File 'app/presenters/www/product_catalog_presenter.rb', line 427

def coverage
  spec_output(:coverage)
end

#description_headerObject



53
54
55
56
57
# File 'app/presenters/www/product_catalog_presenter.rb', line 53

def description_header
  return 'Description' unless primary_product_line

  "#{primary_product_line.public_name} Description"
end

#description_htmlObject



59
60
61
# File 'app/presenters/www/product_catalog_presenter.rb', line 59

def description_html
  item.public_description_html
end

#description_textObject



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

def description_text
  item.public_description_text
end

#dimensionsObject



423
424
425
# File 'app/presenters/www/product_catalog_presenter.rb', line 423

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

#discount_percentage(precision: 2) ⇒ Object



1384
1385
1386
1387
1388
1389
1390
# File 'app/presenters/www/product_catalog_presenter.rb', line 1384

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

Returns:

  • (Boolean)


1431
1432
1433
1434
1435
# File 'app/presenters/www/product_catalog_presenter.rb', line 1431

def discounted_product?
  return false unless effective_price && original_price

  effective_price != original_price
end

#display_best_product_tahoe?Boolean

Returns:

  • (Boolean)


1512
1513
1514
1515
# File 'app/presenters/www/product_catalog_presenter.rb', line 1512

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

Returns:

  • (Boolean)


1533
1534
1535
1536
1537
# File 'app/presenters/www/product_catalog_presenter.rb', line 1533

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)


563
564
565
# File 'app/presenters/www/product_catalog_presenter.rb', line 563

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

#display_how_much_cable_will_i_need?Boolean

Returns:

  • (Boolean)


1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
# File 'app/presenters/www/product_catalog_presenter.rb', line 1492

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

Returns:

  • (Boolean)


1524
1525
1526
# File 'app/presenters/www/product_catalog_presenter.rb', line 1524

def display_pro_contact_us_for_quote?
  restricted_to_trade?
end

#display_roof_gutter_deicing_quote?Boolean

Returns:

  • (Boolean)


1507
1508
1509
1510
# File 'app/presenters/www/product_catalog_presenter.rb', line 1507

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

Returns:

  • (Boolean)


1503
1504
1505
# File 'app/presenters/www/product_catalog_presenter.rb', line 1503

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)


1483
1484
1485
1486
1487
1488
1489
1490
# File 'app/presenters/www/product_catalog_presenter.rb', line 1483

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



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

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



233
234
235
236
237
238
239
240
241
242
243
244
245
246
# File 'app/presenters/www/product_catalog_presenter.rb', line 233

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



449
450
451
452
453
# File 'app/presenters/www/product_catalog_presenter.rb', line 449

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)


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

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

#expected_soon?Boolean

Returns:

  • (Boolean)


1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
# File 'app/presenters/www/product_catalog_presenter.rb', line 1354

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

#facetsObject



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

def facets
  return {} unless (vs = variants(include_self: true)).present? && facet_tokens

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

#faqsObject



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

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:



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

delegate :features, to: :item

#floor_heating_cable_product?Boolean

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

Returns:

  • (Boolean)


142
143
144
145
146
147
148
# File 'app/presenters/www/product_catalog_presenter.rb', line 142

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)


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

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



248
249
250
251
252
# File 'app/presenters/www/product_catalog_presenter.rb', line 248

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



1238
1239
1240
1241
# File 'app/presenters/www/product_catalog_presenter.rb', line 1238

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



164
165
166
# File 'app/presenters/www/product_catalog_presenter.rb', line 164

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)



362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
# File 'app/presenters/www/product_catalog_presenter.rb', line 362

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) ⇒ Object



439
440
441
442
443
# File 'app/presenters/www/product_catalog_presenter.rb', line 439

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

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

#image_url(options = {}) ⇒ Object



385
386
387
388
389
# File 'app/presenters/www/product_catalog_presenter.rb', line 385

def image_url(options = {})
  return unless primary_image

  primary_image.image_url(options)
end

#imagesObject



168
169
170
171
172
173
174
175
176
177
178
179
180
181
# File 'app/presenters/www/product_catalog_presenter.rb', line 168

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.



109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'app/presenters/www/product_catalog_presenter.rb', line 109

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') }
         end

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

#installation_kitsObject



1097
1098
1099
1100
# File 'app/presenters/www/product_catalog_presenter.rb', line 1097

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

#installation_methodsObject



1089
1090
1091
1092
1093
1094
# File 'app/presenters/www/product_catalog_presenter.rb', line 1089

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



1115
1116
1117
1118
# File 'app/presenters/www/product_catalog_presenter.rb', line 1115

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:



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

delegate :item, to: :product

#item_group_nameObject



325
326
327
328
329
# File 'app/presenters/www/product_catalog_presenter.rb', line 325

def item_group_name
  return unless variants?

  r.item.item_grouping_info&.item_group_name
end

#item_gtin13_for_structured_dataObject



40
41
42
# File 'app/presenters/www/product_catalog_presenter.rb', line 40

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

#lengthObject



400
401
402
# File 'app/presenters/www/product_catalog_presenter.rb', line 400

def length
  spec_output(:length)
end

#load_rating_snapshotObject



1047
1048
1049
# File 'app/presenters/www/product_catalog_presenter.rb', line 1047

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

#new_productObject



1437
1438
1439
1440
1441
1442
# File 'app/presenters/www/product_catalog_presenter.rb', line 1437

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



1445
1446
1447
1448
1449
# File 'app/presenters/www/product_catalog_presenter.rb', line 1445

def new_product_price
  return unless new_product

  new_product.price
end

#new_product_price_formattedObject



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

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



1404
1405
1406
# File 'app/presenters/www/product_catalog_presenter.rb', line 1404

def non_refursbished_sku
  new_product.item_sku
end


597
598
599
600
601
602
603
604
605
606
# File 'app/presenters/www/product_catalog_presenter.rb', line 597

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



81
82
83
# File 'app/presenters/www/product_catalog_presenter.rb', line 81

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



1421
1422
1423
# File 'app/presenters/www/product_catalog_presenter.rb', line 1421

def original_price
  price_non_refurbished || price
end

#original_price_formattedObject



1425
1426
1427
1428
1429
# File 'app/presenters/www/product_catalog_presenter.rb', line 1425

def original_price_formatted
  return unless original_price

  h.better_number_to_currency(original_price, unit: currency_symbol)
end

#page_descriptionObject



49
50
51
# File 'app/presenters/www/product_catalog_presenter.rb', line 49

def page_description
  product&.effective_seo_description
end

#page_titleObject



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

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

#panel_size_help_configObject

Panel size help offcanvas configuration



1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
# File 'app/presenters/www/product_catalog_presenter.rb', line 1719

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



445
446
447
# File 'app/presenters/www/product_catalog_presenter.rb', line 445

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

#price_non_refurbishedObject



1408
1409
1410
1411
1412
# File 'app/presenters/www/product_catalog_presenter.rb', line 1408

def price_non_refurbished
  return unless new_product

  new_product.price
end

#price_non_refurbished_formattedObject



1414
1415
1416
1417
1418
# File 'app/presenters/www/product_catalog_presenter.rb', line 1414

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



455
456
457
458
459
460
# File 'app/presenters/www/product_catalog_presenter.rb', line 455

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



355
356
357
# File 'app/presenters/www/product_catalog_presenter.rb', line 355

def primary_image
  images.first
end

#product_country_isoObject



1243
1244
1245
# File 'app/presenters/www/product_catalog_presenter.rb', line 1243

def product_country_iso
  store_item.store.country.iso
end

#product_dimension_imageObject



184
185
186
187
188
189
190
191
192
193
194
195
# File 'app/presenters/www/product_catalog_presenter.rb', line 184

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



472
473
474
# File 'app/presenters/www/product_catalog_presenter.rb', line 472

def product_line
  sales_portal_product_line || primary_product_line.root
end

#product_line_presenterObject



94
95
96
# File 'app/presenters/www/product_catalog_presenter.rb', line 94

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

#product_noticesObject



567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
# File 'app/presenters/www/product_catalog_presenter.rb', line 567

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



481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
# File 'app/presenters/www/product_catalog_presenter.rb', line 481

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



160
161
162
# File 'app/presenters/www/product_catalog_presenter.rb', line 160

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

#public_specificationsObject



336
337
338
339
340
341
342
343
344
345
346
347
348
# File 'app/presenters/www/product_catalog_presenter.rb', line 336

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



351
352
353
# File 'app/presenters/www/product_catalog_presenter.rb', line 351

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:



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

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



507
508
509
510
511
512
513
514
515
516
517
518
519
520
# File 'app/presenters/www/product_catalog_presenter.rb', line 507

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



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

def rating_snapshot
  @rating_snapshot ||= load_rating_snapshot
end

#refurbished_item?Boolean

Returns:

  • (Boolean)


1400
1401
1402
# File 'app/presenters/www/product_catalog_presenter.rb', line 1400

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

#refurbished_priceObject



1376
1377
1378
# File 'app/presenters/www/product_catalog_presenter.rb', line 1376

def refurbished_price
  refurbished_product.price
end

#refurbished_price_discountObject



1392
1393
1394
# File 'app/presenters/www/product_catalog_presenter.rb', line 1392

def refurbished_price_discount
  discount_percentage
end

#refurbished_price_formattedObject



1380
1381
1382
# File 'app/presenters/www/product_catalog_presenter.rb', line 1380

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

#refurbished_productObject



1369
1370
1371
1372
1373
# File 'app/presenters/www/product_catalog_presenter.rb', line 1369

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

  self.class.new(p)
end


1121
1122
1123
# File 'app/presenters/www/product_catalog_presenter.rb', line 1121

def related_services
  available_services_scope
end

#repairability_product_line_for_itemObject



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

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

Returns:

  • (Boolean)


1528
1529
1530
1531
# File 'app/presenters/www/product_catalog_presenter.rb', line 1528

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



216
217
218
# File 'app/presenters/www/product_catalog_presenter.rb', line 216

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

#review_last_updated_atObject



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

def review_last_updated_at
  product_review_info[:updated_at]
end


477
478
479
# File 'app/presenters/www/product_catalog_presenter.rb', line 477

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

#sale_price_formattedObject



462
463
464
465
466
# File 'app/presenters/www/product_catalog_presenter.rb', line 462

def sale_price_formatted
  return unless sale_price

  h.better_number_to_currency(sale_price, unit: currency_symbol)
end

#sale_price_in_effect?Boolean

Returns:

  • (Boolean)


468
469
470
# File 'app/presenters/www/product_catalog_presenter.rb', line 468

def sale_price_in_effect?
  r.sale_price_in_effect
end

#sale_price_percentage_off_displayObject



1396
1397
1398
# File 'app/presenters/www/product_catalog_presenter.rb', line 1396

def sale_price_percentage_off_display
  discount_percentage
end

#schema_availabilityObject



537
538
539
540
541
542
543
544
# File 'app/presenters/www/product_catalog_presenter.rb', line 537

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

#schema_availability_urlObject



533
534
535
# File 'app/presenters/www/product_catalog_presenter.rb', line 533

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

#schema_dot_org_merchant_return_policyObject



1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
# File 'app/presenters/www/product_catalog_presenter.rb', line 1196

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).



1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
# File 'app/presenters/www/product_catalog_presenter.rb', line 1219

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



1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
# File 'app/presenters/www/product_catalog_presenter.rb', line 1247

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



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

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

#schema_item_condition_urlObject



546
547
548
549
550
551
552
553
554
555
# File 'app/presenters/www/product_catalog_presenter.rb', line 546

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



1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
# File 'app/presenters/www/product_catalog_presenter.rb', line 1153

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



702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
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
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
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
869
870
871
872
873
874
875
876
877
878
# File 'app/presenters/www/product_catalog_presenter.rb', line 702

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



412
413
414
# File 'app/presenters/www/product_catalog_presenter.rb', line 412

def shape
  spec_output(:shape)
end

#show_cable_help?Boolean

Returns:

  • (Boolean)


1592
1593
1594
1595
1596
1597
1598
1599
# File 'app/presenters/www/product_catalog_presenter.rb', line 1592

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

Returns:

  • (Boolean)


593
594
595
# File 'app/presenters/www/product_catalog_presenter.rb', line 593

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:



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

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

#show_quote_builder_link?Boolean

Returns:

  • (Boolean)


1461
1462
1463
# File 'app/presenters/www/product_catalog_presenter.rb', line 1461

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

Returns:

  • (Boolean)


1465
1466
1467
# File 'app/presenters/www/product_catalog_presenter.rb', line 1465

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



1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
# File 'app/presenters/www/product_catalog_presenter.rb', line 1128

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

Returns:

  • (Boolean)


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

def single_sku?
  variants_count <= 1
end

#size_overallObject



408
409
410
# File 'app/presenters/www/product_catalog_presenter.rb', line 408

def size_overall
  spec_output(:size_overall)
end

#smart_service?Boolean

Returns:

  • (Boolean)


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

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

#spec_output_for_facet(token) ⇒ Object



332
333
334
# File 'app/presenters/www/product_catalog_presenter.rb', line 332

def spec_output_for_facet(token)
  spec_output(token)
end

#stockObject



1308
1309
1310
1311
# File 'app/presenters/www/product_catalog_presenter.rb', line 1308

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

#stock?Boolean

Returns:

  • (Boolean)


1313
1314
1315
1316
# File 'app/presenters/www/product_catalog_presenter.rb', line 1313

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



1321
1322
1323
1324
# File 'app/presenters/www/product_catalog_presenter.rb', line 1321

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)


1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
# File 'app/presenters/www/product_catalog_presenter.rb', line 1328

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)


1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
# File 'app/presenters/www/product_catalog_presenter.rb', line 1341

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).



394
395
396
397
398
# File 'app/presenters/www/product_catalog_presenter.rb', line 394

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



1469
1470
1471
1472
1473
1474
1475
# File 'app/presenters/www/product_catalog_presenter.rb', line 1469

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



74
75
76
77
78
79
# File 'app/presenters/www/product_catalog_presenter.rb', line 74

def surcharges
  return nil unless smart_service?

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

#terms_and_conditionsObject



67
68
69
70
71
72
# File 'app/presenters/www/product_catalog_presenter.rb', line 67

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



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

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



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

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

#upgradesObject



1083
1084
1085
1086
# File 'app/presenters/www/product_catalog_presenter.rb', line 1083

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

#urlObject



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

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, facet_filters: {}) ⇒ Object



259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
# File 'app/presenters/www/product_catalog_presenter.rb', line 259

def variants(include_self: false, facet_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:, facet_filters: facet_filters)&.reorder(nil)&.ids || []
  all_sort_keys = facet_sort_keys&.compact&.uniq || ['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

Returns:

  • (Boolean)


321
322
323
# File 'app/presenters/www/product_catalog_presenter.rb', line 321

def variants?
  variants.present?
end

#variants_countObject



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

def variants_count
  variants(include_self: true).size
end

#videosObject



198
199
200
# File 'app/presenters/www/product_catalog_presenter.rb', line 198

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



435
436
437
# File 'app/presenters/www/product_catalog_presenter.rb', line 435

def voltage
  spec_output(:voltage)
end

#wattsObject



431
432
433
# File 'app/presenters/www/product_catalog_presenter.rb', line 431

def watts
  spec_output(:watts)
end

#widthObject



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

def width
  spec_output(:width)
end