Class: Www::ProductsController

Inherits:
BasePortalController show all
Includes:
Controllers::Paginateable, Controllers::TrackingDetection
Defined in:
app/controllers/www/products_controller.rb

Constant Summary

Constants included from Controllers::MasqueradeGuarded

Controllers::MasqueradeGuarded::DEFAULT_BLOCK_MESSAGE

Constants included from Controllers::AnalyticsEvents

Controllers::AnalyticsEvents::MAX_QUEUED_EVENTS, Controllers::AnalyticsEvents::SESSION_KEY

Constants included from Controllers::ErrorRendering

Controllers::ErrorRendering::NON_CONTENT_PATH_PREFIXES

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

Instance Method Summary collapse

Methods included from Controllers::TrackingDetection

#bot_request?, #gdpr_country?, #gdpr_country_data, #prevent_bots, #set_tracking_cookie, #track_visitor?

Methods inherited from BasePortalController

#current_ability, #portal_party, #set_webpack

Methods included from Controllers::MasqueradeGuarded

block_while_masquerading, #masquerade_blocks?

Methods inherited from ApplicationController

#account_impersonated?, #add_to_flash, #append_token, #bypass_forgery_protection?, #chat_enabled?, #cloudflare_cleared?, #default_catalog, #default_url_options, #enable_turbo_frames, #find_publication, #fix_invalid_accept_header, #init_js_utils, #is_globals_call?, #layout_by_resource, #locale_store, #redirect_to, #require_employee_for_crm, #set_base_host, #set_real_ip, #set_report_errors_for, #should_render_layout?, #stamp_impersonation_context, #warmlyyours_canada_ip?, #warmlyyours_ip?, #y

Methods included from Controllers::ReturnPathHandling

#check_for_return_path, #redirect_to_return_path_or_default

Methods included from Controllers::AnalyticsEvents

#consume_queued_analytics_events, #track_event

Methods included from Controllers::DeviceDetection

#device_detector, #is_ie?

Methods included from Controllers::SubdomainDetection

#is_crm_request?, #is_www_request?, #json_request?

Methods included from Controllers::AcceleratedFileSending

#send_file_accelerated, #send_upload_accelerated

Methods included from Controllers::ErrorRendering

#excp_string, #mail_to_for_error_reporting, #render_400, #render_404, #render_406, #render_410, #render_500, #render_invalid_authenticity_token, #render_ip_spoof_error, #safe_referer_or_fallback

Methods included from Controllers::TurnstileVerification

#load_turnstile_script_tag, #turnstile_lazy_widget, #turnstile_script_tag, #turnstile_widget, #validate_turnstile!

Methods included from Controllers::CloudflareCaching

edge_cached, #edge_cached_action?, #reset_cloudflare_cache, #set_cloudflare_cache, #skip_session

Methods included from Controllers::Webpackable

#preload_webpack_fonts, #webpack_css_include, #webpack_css_url, #webpack_js_include, #wpd_is_running?

Methods included from Controllers::Localizable

#cloudflare_country_locale, #determine_request_locale, #geocoder_locale, #guest_user_locale_check, #locale_optional_www_auth_path?, #param_locale, #set_locale, #set_request_locale, #skip_localization?, #warmlyyours_ip_locale

Methods included from Controllers::Authenticable

#access_denied, #authenticate_account, #authenticate_account!, #authenticate_account_from_login_token!, #authenticate_account_from_token!, #check_is_a_manager, #check_is_a_sales_manager, #check_is_an_admin, #check_is_an_employee, #check_party, #clear_mismatched_guest_user, #create_guest_user, #credentials?, #current_or_guest_user, #current_or_guest_user_id_read_only, #current_user, #devise_mapping, #fully_logged_in?, #generate_bot_id, #guest_user, #identifiable?, #init_current_user, #initialize_guest, #load_context_user, #logging_in, #resource, #resource_name, #restrict_access_for_non_employees, #scrubbed_request_path, #user_object, #warn_on_session_guest_id_leak

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

Instance Method Details

#add_to_cartObject



54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'app/controllers/www/products_controller.rb', line 54

def add_to_cart
  @cart = @context_user.cart
  @cart.save
  qty = params[:quantity].to_i.abs
  qty > 99 ? 99 : qty # Force to be max of 99 units to avoid issues
  cart_add_item(params[:sku], params[:quantity])
  respond_to do |format|
    format.json do
      render json: {
        success: true
      }
    end
  end
end

#bulk_product_dataJSON

Consolidated bulk product data endpoint
Returns product data for one or more SKUs with field filtering

GET /products/bulk_product_data?skus[]=SKU1&skus[]=SKU2&fields=price
GET /products/bulk_product_data?skus[]=SKU1&fields=price,stock,shipping,cta

Examples:

Response for fields=price

{
  "products": {
    "TWS2-TAH06PH": {
      "sku": "TWS2-TAH06PH",
      "title": "Tahoe 6-Bar Towel Warmer",
      "price": {
        "current": 213.0,
        "original": 355.0,
        "formatted": "$213.00",
        "original_formatted": "$355.00",
        "discount_percentage": 40,
        "is_discounted": true,
        "savings": 142.0,
        "savings_formatted": "$142.00",
        "discount_type": "tier"
      }
    }
  },
  "has_tier_pricing": true,
  "program_name": "Pro Partner 40%",
  "fetched_at": "2026-01-15T20:00:00Z"
}

Parameters:

  • skus[]

    [Array] Array of SKU codes (max 50)

  • fields (String)

    Comma-separated list of fields: price, stock, shipping, cta (default: all)

Returns:

  • (JSON)

    Hash with:

    • products: Hash of SKU => product data
    • has_tier_pricing: Boolean indicating if customer has tier pricing
    • program_name: Name of tier pricing program (if applicable)
    • fetched_at: ISO8601 timestamp


132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
# File 'app/controllers/www/products_controller.rb', line 132

def bulk_product_data
  skus = Array(params[:skus]).compact.uniq.first(50) # Limit to 50 SKUs max

  if skus.empty?
    render json: { products: {}, has_tier_pricing: false, fetched_at: Time.current.iso8601 }
    return
  end

  # Parse requested fields - defaults to all if not specified
  requested_fields = if params[:fields].present?
                       params[:fields].split(',').map(&:strip).map(&:downcase)
                     else
                       %w[price stock shipping cta]
                     end

  # Get customer for tier pricing
  customer = &.customer
  has_tier_pricing = customer&.pricing_program_discount.to_f.positive?

  # Fetch all products from the appropriate catalog
  catalog_id = Catalog.locale_to_catalog_id(I18n.locale)
  products_by_sku = ViewProductCatalog
                      .where(catalog_id: catalog_id, item_sku: skus)
                      .includes(:catalog_item)
                      .preload(item: [
                        :primary_image,
                        { image_profiles: :image }
                      ])
                      .index_by(&:item_sku)

  # Build response for each SKU
  products_data = {}
  skus.each do |sku|
    vpc = products_by_sku[sku]
    next unless vpc

    # Set @pcp for consolidated_product_payload to use
    @pcp = Www::ProductCatalogPresenter.new(vpc, view_context)
    products_data[sku] = consolidated_product_payload(fields: requested_fields, customer: customer)
  end

  # Build cache key for HTTP caching
  # Include customer ID if they have tier pricing to make cache personalized
  cache_key = if has_tier_pricing
                "bulk_product_data/#{skus.sort.join(',')}/#{requested_fields.sort.join(',')}/customer/#{customer.id}"
              else
                "bulk_product_data/#{skus.sort.join(',')}/#{requested_fields.sort.join(',')}"
              end

  # Use HTTP caching with ETag
  return unless stale?(etag: cache_key, public: !has_tier_pricing)

  # Set cache time - private for tier pricing customers
  if has_tier_pricing
    expires_in 1.minute, private: true
  else
    expires_in 1.minute, public: true
  end

  render json: {
    products: products_data,
    has_tier_pricing: has_tier_pricing,
    program_name: has_tier_pricing ? customer.tier2_program_pricing&.title : nil,
    fetched_at: Time.current.iso8601
  }.compact
end

#cart_add_item(sku_code, quantity = nil, room_configuration_id = nil) ⇒ Object (protected)



201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
# File 'app/controllers/www/products_controller.rb', line 201

def cart_add_item(sku_code, quantity = nil, room_configuration_id = nil)
  # add item
  @recently_added_items ||= []

  return if sku_code.blank?

  sku = sku_code
  quantity = quantity.presence
  quantity = (quantity || 1).to_i
  room_configuration_id = room_configuration_id

  return unless sku.present? and quantity > 0

  # find catalog by locale
  ci = begin
    @cart.catalog.catalog_items.public_catalog_items.by_skus(sku).first
  rescue StandardError
    nil
  end
  if ci
    @cart.recalculate_shipping = true # doesn't hurt to set it
    @cart.recalculate_discounts = true # ensure tier2 and auto-apply discounts are calculated
    @cart.force_total_reset = true
    @new_line = @cart.add_line_item(catalog_item_id: ci.id, quantity: quantity, room_configuration_id: room_configuration_id, do_not_autosave: true)
    @recently_added_items << { id: @new_line.id, sku: @new_line.sku, name: @new_line.name, category: @new_line.reported_category_name, quantity: @new_line.quantity }
  else
    flash[:error] = "We could not find sku #{sku} in catalog."
  end
end

#codeObject

Legacy route: 301 redirects to new hierarchical URL



45
46
47
48
49
50
51
52
# File 'app/controllers/www/products_controller.rb', line 45

def code
  result = CatalogPathResolver.new.resolve_legacy_sku(params[:sku])
  if result.redirect?
    redirect_to result.redirect_to, status: :moved_permanently
  else
    redirect_to cms_link('/products'), status: :moved_permanently
  end
end

#indexObject



7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# File 'app/controllers/www/products_controller.rb', line 7

def index
  @catalog = default_catalog
  @product_lines = @catalog.product_lines_for_sales_portal.reorder(:priority, :public_name, :slug_ltree)

  @h1 = 'All WarmlyYours Products'
  @page_title = "All WarmlyYours Products in #{Country.country_name_from_locale}"
  @page_description = 'Find all WarmlyYours products in a single page. Electric Radiant Floor Heating, Outdoor Snow Melting Products, Roof and Gutter Deicing Systems, Pipe Freeze Protection, Mirrors and Defoggers, Towel Warmers and Panel Heaters'
  @filters = []
  b = {
    type: 'checkbox',
    title: 'Product Line',
    options: ['Floor Heating',
              'Snow Melting',
              'Towel Warmer',
              'LED Mirror',
              'Pipe Freeze Protection',
              'Roof & Gutter Deicing',
              'Radiant Panel',
              'Countertop Heater',
              'Underlayment',
              'Third Party Control Integration']
  }
  @filters << b
  set_cloudflare_cache(time_in_secs: 4.hours.to_i, tags: %w[sale product])
  fresh_when(etag: [@product_lines, I18n.locale], last_modified: @product_lines.maximum(:updated_at), public: true)
end

#lineObject

Legacy route: 301 redirects to new hierarchical URL



35
36
37
38
39
40
41
42
# File 'app/controllers/www/products_controller.rb', line 35

def line
  result = CatalogPathResolver.new.resolve_legacy_product_line(params[:product_line_url])
  if result.redirect?
    redirect_to result.redirect_to, status: :moved_permanently
  else
    redirect_to cms_link('/products'), status: :moved_permanently
  end
end

#reviewsObject

Legacy route: 301 redirects to new hierarchical URL



70
71
72
73
74
75
76
77
# File 'app/controllers/www/products_controller.rb', line 70

def reviews
  result = CatalogPathResolver.new.resolve_legacy_sku(params[:sku], section: :reviews)
  if result.redirect?
    redirect_to result.redirect_to, status: :moved_permanently
  else
    redirect_to cms_link('/products'), status: :moved_permanently
  end
end

#sectionObject

Lazy-loaded section endpoint for below-the-fold content
Renders a single section via Turbo Frame for performance



81
82
83
84
85
86
87
88
89
90
91
92
# File 'app/controllers/www/products_controller.rb', line 81

def section
  load_product(params[:sku], web_accessible_only: false, light: true)
  return head :not_found unless @product&.item_is_web_accessible

  @section_name = params[:section].to_sym
  @section_data = @pcp.compute_single_section(@section_name)
  @section_data = nil if @section_name == :related_products && @section_data.blank?
  @p = @pcp

  set_cloudflare_cache(time_in_secs: 4.hours.to_i, tags: %w[sale product])
  fresh_when(etag: [@product, @section_name, I18n.locale], last_modified: @product.updated_at, public: true)
end