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.

Defined Under Namespace

Classes: DocRecord

Constant Summary collapse

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, #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, #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



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

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

Instance Method Details

#accessoriesObject



1093
1094
1095
1096
# File 'app/presenters/www/product_catalog_presenter.rb', line 1093

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)


1514
1515
1516
1517
1518
1519
# File 'app/presenters/www/product_catalog_presenter.rb', line 1514

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



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

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



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

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



1020
1021
1022
# File 'app/presenters/www/product_catalog_presenter.rb', line 1020

def available_products_scope_new_only
  available_products_scope.excluding_refurbished
end

#available_services_scopeObject



1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
# File 'app/presenters/www/product_catalog_presenter.rb', line 1053

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


147
148
149
150
151
152
153
# File 'app/presenters/www/product_catalog_presenter.rb', line 147

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


95
96
97
98
99
100
101
# File 'app/presenters/www/product_catalog_presenter.rb', line 95

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



1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
# File 'app/presenters/www/product_catalog_presenter.rb', line 1131

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



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

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



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

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

#canonical_paths(request_path, request_url: nil) ⇒ Object

Removed: unused predicate helper roll_or_mat



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

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


200
201
202
203
204
205
206
207
208
209
210
# File 'app/presenters/www/product_catalog_presenter.rb', line 200

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



600
601
602
603
604
605
606
607
608
609
610
# File 'app/presenters/www/product_catalog_presenter.rb', line 600

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.



642
643
644
645
646
647
648
649
650
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
# File 'app/presenters/www/product_catalog_presenter.rb', line 642

def compute_single_section(name) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
  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



1099
1100
1101
1102
# File 'app/presenters/www/product_catalog_presenter.rb', line 1099

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

#controlsObject



1067
1068
1069
1070
# File 'app/presenters/www/product_catalog_presenter.rb', line 1067

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

#coverageObject



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

def coverage
  spec_output(:coverage)
end

#description_headerObject



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

def description_header
  return 'Description' unless primary_product_line

  "#{primary_product_line.public_name} Description"
end

#description_htmlObject



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

def description_html
  item.public_description_html
end

#description_textObject



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

def description_text
  item.public_description_text
end

#dimensionsObject



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

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

#discount_percentage(precision: 2) ⇒ Object



1381
1382
1383
1384
1385
1386
1387
# File 'app/presenters/www/product_catalog_presenter.rb', line 1381

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)


1428
1429
1430
1431
1432
# File 'app/presenters/www/product_catalog_presenter.rb', line 1428

def discounted_product?
  return false unless effective_price && original_price

  effective_price != original_price
end

#display_best_product_tahoe?Boolean

Returns:

  • (Boolean)


1509
1510
1511
1512
# File 'app/presenters/www/product_catalog_presenter.rb', line 1509

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)


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

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)


555
556
557
# File 'app/presenters/www/product_catalog_presenter.rb', line 555

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

#display_how_much_cable_will_i_need?Boolean

Returns:

  • (Boolean)


1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
# File 'app/presenters/www/product_catalog_presenter.rb', line 1489

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)


1521
1522
1523
# File 'app/presenters/www/product_catalog_presenter.rb', line 1521

def display_pro_contact_us_for_quote?
  restricted_to_trade?
end

#display_roof_gutter_deicing_quote?Boolean

Returns:

  • (Boolean)


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

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)


1500
1501
1502
# File 'app/presenters/www/product_catalog_presenter.rb', line 1500

def display_what_size_panel_do_i_need?
  item.pc_descendant_of_path?(LtreePaths::PC_RADIANT_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)


1480
1481
1482
1483
1484
1485
1486
1487
# File 'app/presenters/www/product_catalog_presenter.rb', line 1480

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



218
219
220
221
222
223
224
225
226
227
# File 'app/presenters/www/product_catalog_presenter.rb', line 218

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



230
231
232
233
234
235
236
237
238
239
240
241
242
243
# File 'app/presenters/www/product_catalog_presenter.rb', line 230

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



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

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)


124
125
126
# File 'app/presenters/www/product_catalog_presenter.rb', line 124

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

#expected_soon?Boolean

Returns:

  • (Boolean)


1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
# File 'app/presenters/www/product_catalog_presenter.rb', line 1351

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



304
305
306
307
308
309
310
# File 'app/presenters/www/product_catalog_presenter.rb', line 304

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



252
253
254
# File 'app/presenters/www/product_catalog_presenter.rb', line 252

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:



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

delegate :features, to: :item

#floor_heating_cable_product?Boolean

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

Returns:

  • (Boolean)


139
140
141
142
143
144
145
# File 'app/presenters/www/product_catalog_presenter.rb', line 139

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)


130
131
132
133
134
135
136
# File 'app/presenters/www/product_catalog_presenter.rb', line 130

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



245
246
247
248
249
# File 'app/presenters/www/product_catalog_presenter.rb', line 245

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



1235
1236
1237
1238
# File 'app/presenters/www/product_catalog_presenter.rb', line 1235

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



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

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)



354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
# File 'app/presenters/www/product_catalog_presenter.rb', line 354

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



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

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

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

#image_url(options = {}) ⇒ Object



377
378
379
380
381
# File 'app/presenters/www/product_catalog_presenter.rb', line 377

def image_url(options = {})
  return unless primary_image

  primary_image.image_url(options)
end

#imagesObject



165
166
167
168
169
170
171
172
173
174
175
176
177
178
# File 'app/presenters/www/product_catalog_presenter.rb', line 165

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.



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

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



1087
1088
1089
1090
# File 'app/presenters/www/product_catalog_presenter.rb', line 1087

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

#installation_methodsObject



1079
1080
1081
1082
1083
1084
# File 'app/presenters/www/product_catalog_presenter.rb', line 1079

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



1105
1106
1107
1108
# File 'app/presenters/www/product_catalog_presenter.rb', line 1105

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:



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

delegate :item, to: :product

#item_group_nameObject



317
318
319
320
321
# File 'app/presenters/www/product_catalog_presenter.rb', line 317

def item_group_name
  return unless variants?

  r.item.item_grouping_info&.item_group_name
end

#item_gtin13_for_structured_dataObject



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

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

#lengthObject



392
393
394
# File 'app/presenters/www/product_catalog_presenter.rb', line 392

def length
  spec_output(:length)
end

#load_rating_snapshotObject



1037
1038
1039
# File 'app/presenters/www/product_catalog_presenter.rb', line 1037

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

#new_productObject



1434
1435
1436
1437
1438
1439
# File 'app/presenters/www/product_catalog_presenter.rb', line 1434

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



1442
1443
1444
1445
1446
# File 'app/presenters/www/product_catalog_presenter.rb', line 1442

def new_product_price
  return unless new_product

  new_product.price
end

#new_product_price_formattedObject



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

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



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

def non_refursbished_sku
  new_product.item_sku
end


589
590
591
592
593
594
595
596
597
598
# File 'app/presenters/www/product_catalog_presenter.rb', line 589

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



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

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



1418
1419
1420
# File 'app/presenters/www/product_catalog_presenter.rb', line 1418

def original_price
  price_non_refurbished || price
end

#original_price_formattedObject



1422
1423
1424
1425
1426
# File 'app/presenters/www/product_catalog_presenter.rb', line 1422

def original_price_formatted
  return unless original_price

  h.better_number_to_currency(original_price, unit: currency_symbol)
end

#page_descriptionObject



46
47
48
# File 'app/presenters/www/product_catalog_presenter.rb', line 46

def page_description
  product&.effective_seo_description
end

#page_titleObject



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

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

#panel_size_help_configObject

Panel size help offcanvas configuration



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

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?('radiant_panel.lava')
    {
      offcanvas_id: 'offcanvas-size-lava',
      offcanvas_partial: 'offcanvas_size_lava',
      title: 'What Size Panel Do I Need?'
    }
  elsif pl_ltree&.start_with?('radiant_panel.ember')
    {
      offcanvas_id: 'offcanvas-size-ember',
      offcanvas_partial: 'offcanvas_size_ember',
      title: 'What Size Panel Do I Need?'
    }
  end
end

#price_formattedObject



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

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

#price_non_refurbishedObject



1405
1406
1407
1408
1409
# File 'app/presenters/www/product_catalog_presenter.rb', line 1405

def price_non_refurbished
  return unless new_product

  new_product.price
end

#price_non_refurbished_formattedObject



1411
1412
1413
1414
1415
# File 'app/presenters/www/product_catalog_presenter.rb', line 1411

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



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

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



347
348
349
# File 'app/presenters/www/product_catalog_presenter.rb', line 347

def primary_image
  images.first
end

#product_country_isoObject



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

def product_country_iso
  store_item.store.country.iso
end

#product_dimension_imageObject



181
182
183
184
185
186
187
188
189
190
191
192
# File 'app/presenters/www/product_catalog_presenter.rb', line 181

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



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

def product_line
  sales_portal_product_line || primary_product_line.root
end

#product_line_presenterObject



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

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

#product_noticesObject



559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
# File 'app/presenters/www/product_catalog_presenter.rb', line 559

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



473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
# File 'app/presenters/www/product_catalog_presenter.rb', line 473

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



157
158
159
# File 'app/presenters/www/product_catalog_presenter.rb', line 157

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

#public_specificationsObject



328
329
330
331
332
333
334
335
336
337
338
339
340
# File 'app/presenters/www/product_catalog_presenter.rb', line 328

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



343
344
345
# File 'app/presenters/www/product_catalog_presenter.rb', line 343

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:



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

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



499
500
501
502
503
504
505
506
507
508
509
510
511
512
# File 'app/presenters/www/product_catalog_presenter.rb', line 499

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



520
521
522
# File 'app/presenters/www/product_catalog_presenter.rb', line 520

def rating_snapshot
  @rating_snapshot ||= load_rating_snapshot
end

#refurbished_item?Boolean

Returns:

  • (Boolean)


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

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

#refurbished_priceObject



1373
1374
1375
# File 'app/presenters/www/product_catalog_presenter.rb', line 1373

def refurbished_price
  refurbished_product.price
end

#refurbished_price_discountObject



1389
1390
1391
# File 'app/presenters/www/product_catalog_presenter.rb', line 1389

def refurbished_price_discount
  discount_percentage
end

#refurbished_price_formattedObject



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

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

#refurbished_productObject



1366
1367
1368
1369
1370
# File 'app/presenters/www/product_catalog_presenter.rb', line 1366

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

  self.class.new(p)
end


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

def related_services
  available_services_scope
end

#repairability_product_line_for_itemObject



612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
# File 'app/presenters/www/product_catalog_presenter.rb', line 612

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


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

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



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

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

#review_last_updated_atObject



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

def review_last_updated_at
  product_review_info[:updated_at]
end


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

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

#sale_price_formattedObject



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

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)


460
461
462
# File 'app/presenters/www/product_catalog_presenter.rb', line 460

def sale_price_in_effect?
  r.sale_price_in_effect
end

#sale_price_percentage_off_displayObject



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

def sale_price_percentage_off_display
  discount_percentage
end

#schema_availabilityObject



529
530
531
532
533
534
535
536
# File 'app/presenters/www/product_catalog_presenter.rb', line 529

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

#schema_availability_urlObject



525
526
527
# File 'app/presenters/www/product_catalog_presenter.rb', line 525

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

#schema_dot_org_merchant_return_policyObject

rubocop:enable Metrics/AbcSize, Metrics/MethodLength



1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
# File 'app/presenters/www/product_catalog_presenter.rb', line 1187

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. Free shipping for standard items; delivery time only
for oversize/freight items (free shipping excludes oversized per policy).



1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
# File 'app/presenters/www/product_catalog_presenter.rb', line 1206

def schema_dot_org_offer_shipping_details
  if item&.oversize?
    SchemaDotOrg::OfferShippingDetails.new(
      shippingDestination: SchemaDotOrg::DefinedRegion.new(
        addressCountry: product_country_iso
      ),
      deliveryTime: SchemaDotOrg::ShippingDeliveryTime.new(
        handlingTime: SchemaDotOrg::QuantitativeValue.new(minValue: 1, maxValue: 5, unitCode: 'DAY'),
        transitTime: SchemaDotOrg::QuantitativeValue.new(minValue: 3, maxValue: 10, unitCode: 'DAY')
      )
    )
  else
    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
end

#schema_dot_org_structure(exclude_segments: []) ⇒ Object

rubocop:disable Metrics/AbcSize, Metrics/MethodLength



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

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 = 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



549
550
551
# File 'app/presenters/www/product_catalog_presenter.rb', line 549

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

#schema_item_condition_urlObject



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

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

rubocop:disable Metrics/AbcSize, Metrics/MethodLength



1144
1145
1146
1147
1148
1149
1150
1151
1152
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
# File 'app/presenters/www/product_catalog_presenter.rb', line 1144

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

  # Use the regular (list) price as offers.price so it matches the Google
  # Merchant feed's <g:price> value. Sale pricing is conveyed via a SalePrice
  # priceSpecification, mirroring the feed's <g:sale_price>.
  o.price = price

  if sale_price_in_effect?
    sale_end = sale_price_expiration_date || Date.current.end_of_month
    o.priceValidUntil = sale_end

    o.priceSpecification = [
      SchemaDotOrg::PriceSpecification.new(
        price: sale_price.to_s,
        priceCurrency: currency,
        priceType: 'https://schema.org/SalePrice',
        validThrough: sale_end
      )
    ]
  else
    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, Metrics/BlockLength



693
694
695
696
697
698
699
700
701
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
# File 'app/presenters/www/product_catalog_presenter.rb', line 693

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



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

def shape
  spec_output(:shape)
end

#show_cable_help?Boolean

Returns:

  • (Boolean)


1589
1590
1591
1592
1593
1594
1595
1596
# File 'app/presenters/www/product_catalog_presenter.rb', line 1589

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)


585
586
587
# File 'app/presenters/www/product_catalog_presenter.rb', line 585

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:



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

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

#show_quote_builder_link?Boolean

Returns:

  • (Boolean)


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

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)


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

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



1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
# File 'app/presenters/www/product_catalog_presenter.rb', line 1118

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)


87
88
89
# File 'app/presenters/www/product_catalog_presenter.rb', line 87

def single_sku?
  variants_count <= 1
end

#size_overallObject



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

def size_overall
  spec_output(:size_overall)
end

#smart_service?Boolean

Returns:

  • (Boolean)


1454
1455
1456
# File 'app/presenters/www/product_catalog_presenter.rb', line 1454

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

#spec_output_for_facet(token) ⇒ Object



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

def spec_output_for_facet(token)
  spec_output(token)
end

#stockObject



1305
1306
1307
1308
# File 'app/presenters/www/product_catalog_presenter.rb', line 1305

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

#stock?Boolean

Returns:

  • (Boolean)


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

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



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

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)


1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
# File 'app/presenters/www/product_catalog_presenter.rb', line 1325

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)


1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
# File 'app/presenters/www/product_catalog_presenter.rb', line 1338

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



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

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



1466
1467
1468
1469
1470
1471
1472
# File 'app/presenters/www/product_catalog_presenter.rb', line 1466

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



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

def surcharges
  return nil unless smart_service?

  # Content originates from trusted CMS input
  product.item.terms_of_service&.html_safe # rubocop:disable Rails/OutputSafety
end

#terms_and_conditionsObject



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

def terms_and_conditions
  return nil unless smart_service?

  # Content originates from trusted CMS input
  product.item.terms_and_conditions&.html_safe # rubocop:disable Rails/OutputSafety
end

#to_json_ld(exclude_segments: []) ⇒ Object

rubocop:enable Metrics/AbcSize, Metrics/MethodLength



1297
1298
1299
1300
1301
1302
1303
# File 'app/presenters/www/product_catalog_presenter.rb', line 1297

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



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

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

#upgradesObject



1073
1074
1075
1076
# File 'app/presenters/www/product_catalog_presenter.rb', line 1073

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

#urlObject



30
31
32
33
34
35
# File 'app/presenters/www/product_catalog_presenter.rb', line 30

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



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

def variants(include_self: false, facet_filters: {})
  catalog_item_ids = catalog_item.variants(include_self:, facet_filters: facet_filters)&.pluck(:id) || []
  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)


313
314
315
# File 'app/presenters/www/product_catalog_presenter.rb', line 313

def variants?
  variants.present?
end

#variants_countObject



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

def variants_count
  variants(include_self: true).size
end

#videosObject



195
196
197
# File 'app/presenters/www/product_catalog_presenter.rb', line 195

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



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

def voltage
  spec_output(:voltage)
end

#wattsObject



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

def watts
  spec_output(:watts)
end

#widthObject



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

def width
  spec_output(:width)
end