Class: Www::QuoteBuilderController

Inherits:
ApplicationController show all
Defined in:
app/controllers/www/quote_builder_controller.rb

Overview

Controller: quote builder.

Constant Summary collapse

HEATED_AREA_SQFT_LIMIT =

Limit for heated area sqft.

20_000
ROOM_AREA_SQFT_LIMIT =

Limit for room area sqft.

30_000

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

Instance Method Summary collapse

Methods inherited from ApplicationController

#account_impersonated?, #add_to_flash, #after_sign_in_path_for, #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?, #skip_layout_for_turbo_frame?, #stamp_impersonation_context, #tab_frame_breakout_request?, #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, #registration_lead_type, #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::TurboSafeRedirect

#redirect_to

Methods included from Controllers::TrackingDetection

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

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, #render_unpermitted_parameters, #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_edge_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!, #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 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?

Instance Method Details

#add_selections_to_cartvoid

This method returns an undefined value.

Creates/updates the room configuration and quote from the current
selections, then returns the add-to-cart path as JSON.



231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
# File 'app/controllers/www/quote_builder_controller.rb', line 231

def add_selections_to_cart
  parse_params_and_get_options
  result = RoomConfiguration.update_or_create_from_rhc_args(
    rhc_args_from_options(@options),
    @options,
    @context_user.customer,
    params[:system_name]
  )
  if result[:success]
    @room = result[:room]
    # try to get an existing quote or create one
    unless (@quote = @room.quotes.completed_quotes.first || @room.quotes.pending.first)
      @quote = Quote.auto_quote_from_room(
        @room,
        {
          force_single_origin: true,
          do_not_set_shipping_address: true
        }
      )
    end
    if @quote.id.present?
      respond_to do |format|
        format.json do
          render json: { path: add_to_cart_my_quote_path(@quote, room_id: @room.id) }
        end
      end
    else
      respond_to do |format|
        format.json do
          render json: { error_message: result[:errors] || "Could not add to cart: #{@quote.errors_to_s}" }, status: :internal_server_error
        end
      end
    end
  else
    respond_to do |format|
      format.json do
        render json: { error_message: result[:errors] || "Could not add to cart: #{result[:errors]}" }, status: :internal_server_error
      end
    end
  end
end

#add_selections_to_quotevoid

This method returns an undefined value.

Creates/updates the room configuration from the current selections and
attaches it to the customer's quote, returning the quote path as JSON.



294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
# File 'app/controllers/www/quote_builder_controller.rb', line 294

def add_selections_to_quote
  parse_params_and_get_options
  result = RoomConfiguration.update_or_create_from_rhc_args(
    rhc_args_from_options(@options),
    @options,
    @context_user.customer,
    params[:system_name]
  )
  if result[:success]
    @room = result[:room]
    # try to get an existing quote or create one
    unless (@quote = @room.quotes.completed_quotes.first || @room.quotes.pending.first)
      @quote = Quote.auto_quote_from_room(
        @room,
        {
          force_single_origin: true,
          do_not_set_shipping_address: true
        }
      )
    end
    if @quote.id.present?
      respond_to do |format|
        format.json do
          render json: { path: my_quote_path(@quote) }
        end
      end
    else
      respond_to do |format|
        format.json do
          render json: { error_message: result[:errors] || "Could not add to cart: #{@quote.errors_to_s}" }, status: :internal_server_error
        end
      end
    end
  else
    respond_to do |format|
      format.json do
        render json: { error_message: result[:errors] || "Could not add to cart: #{result[:errors]}" }, status: :internal_server_error
      end
    end
  end
end

#apply_tier_pricing_to_bom(bom, max_discounts) ⇒ Hash

Applies tier pricing to a single BOM's included items, controls, and
accessories, then recalculates its totals.

Parameters:

  • bom (Hash)

    the BOM to price

  • max_discounts (Hash{String => Numeric})

    per-SKU max discount caps

Returns:

  • (Hash)

    the priced BOM



1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
# File 'app/controllers/www/quote_builder_controller.rb', line 1126

def apply_tier_pricing_to_bom(bom, max_discounts)
  # Apply to includedItems (heating elements, membranes, etc.)
  bom['includedItems']&.each { |item| apply_tier_to_item(item, max_discounts) }

  # Apply to controls
  bom['controls']&.each do |control|
    control['includedItems']&.each { |item| apply_tier_to_item(item, max_discounts) }
    recalculate_bundle_price(control)
  end

  # Apply to accessories
  bom['accessories']&.each { |acc| apply_tier_to_item(acc, max_discounts) }

  # Recalculate BOM totals
  recalculate_bom_price(bom)
  bom
end

#apply_tier_pricing_to_boms(boms, catalog_id) ⇒ Array<Hash>

Applies the current user's tier discount to every BOM's items, capped
per-item by each SKU's max_discount.

Parameters:

  • boms (Array<Hash>)

    the BOMs to price

  • catalog_id (Integer)

    the catalog to look up max_discount caps in

Returns:

  • (Array<Hash>)

    the BOMs with tier pricing applied



1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
# File 'app/controllers/www/quote_builder_controller.rb', line 1098

def apply_tier_pricing_to_boms(boms, catalog_id)
  return boms if tier_discount_pct.zero?

  # Collect all SKUs to fetch max_discount caps
  all_skus = boms.flat_map do |bom|
    (bom['includedItems'] || []).map { |i| i['sku'] } +
      (bom['controls'] || []).flat_map { |c| (c['includedItems'] || []).map { |i| i['sku'] } } +
      (bom['accessories'] || []).map { |a| a['sku'] }
  end.compact.uniq

  # Fetch max_discount caps for all SKUs in one query
  max_discounts = CatalogItem.joins(:item)
                             .where(items: { sku: all_skus })
                             .where(catalog_id: catalog_id)
                             .pluck('items.sku', :max_discount)
                             .to_h

  boms.map do |bom|
    apply_tier_pricing_to_bom(bom.deep_dup, max_discounts)
  end
end

#apply_tier_to_item(item, max_discounts) ⇒ void

This method returns an undefined value.

Applies tier pricing to a single item hash in place, keeping whichever
of the tier or sale price is better for the customer.

Parameters:

  • item (Hash)

    the item to price (mutated in place)

  • max_discounts (Hash{String => Numeric})

    per-SKU max discount caps



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
# File 'app/controllers/www/quote_builder_controller.rb', line 1150

def apply_tier_to_item(item, max_discounts)
  return if item['price'].blank?

  original_price = item['price'].to_f
  return if original_price.zero?

  # Get max discount cap for this SKU (default 100% if not set)
  max_discount = max_discounts[item['sku']] || 100
  effective_discount_pct = [tier_discount_pct, max_discount].min
  effective_discount_factor = effective_discount_pct / 100.0

  tier_price = (original_price * (1 - effective_discount_factor)).round(2)

  # Customer gets the better discount: tier or sale
  current_sale_price = item['sale_price']&.to_f
  if current_sale_price.present? && current_sale_price < tier_price
    # Sale price is better, keep it
    return
  end

  # Tier price is better - apply it
  item['tier_price'] = tier_price
  item['tier_discount_pct'] = effective_discount_pct.round(0)
  item['original_price'] = original_price
  item['sale_price'] = tier_price
  item['is_tier_pricing'] = true
end

#can_auto_calculate_quotes?(options) ⇒ Boolean

Check if we have sufficient parameters to auto-calculate quotes on page load.
This allows users coming from the mini form to see results immediately.

Parameters:

  • options (Hash, nil)

    Parsed quote builder options

Options Hash (options):

  • heated_area (Numeric, String, nil)

    Heated area in square feet

  • room_area (Numeric, String, nil)

    Total room area in square feet

  • floor_type_key (String, nil)

    Floor type SEO key

  • sub_floor_type_key (String, nil)

    Sub-floor type SEO key

  • room_type_key (String, nil)

    Room type SEO key

Returns:

  • (Boolean)

    true when enough parameters are present to auto-calculate



979
980
981
982
983
984
985
986
987
# File 'app/controllers/www/quote_builder_controller.rb', line 979

def can_auto_calculate_quotes?(options)
  return false if options.blank?

  # Required: heated_area OR room_area, floor_type_key, sub_floor_type_key, room_type_key
  (options[:heated_area].to_f.positive? || options[:room_area].to_f.positive?) &&
    options[:floor_type_key].present? &&
    options[:sub_floor_type_key].present? &&
    options[:room_type_key].present?
end

#capture_visitor_email(email) ⇒ void

This method returns an undefined value.

Capture an optional visitor email from a mini quote form.
If the email already exists as a contact point on the customer we skip it;
otherwise we add it as a new contact point. For guests with no email this
effectively sets their first email. Links the Visit and transitions
guest → lead_qualify so the CRM can follow up.

Parameters:

  • email (String, nil)

    the visitor-submitted email



1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
# File 'app/controllers/www/quote_builder_controller.rb', line 1233

def capture_visitor_email(email)
  return if email.blank?

  # Sanitize: strip non-ASCII, collapse whitespace, downcase — mirrors
  # ContactPoint#normalize_format so typos like "john @gmail .com" are fixed.
  email = email.to_s.gsub(/\P{ASCII}/u, '').scan(/\S/).join.downcase
  return unless email.match?(URI::MailTo::EMAIL_REGEXP)

  # Only add the email if it doesn't already exist on this customer
  already_exists = @context_user.contact_points.find_emails(email).exists?

  @context_user.contact_points.create!(category: ContactPoint::EMAIL, detail: email) unless already_exists

  # Link the Visit to this now-identifiable customer
  if (visit_id = session[:visit_id]) && (visit = Visit.find_by(id: visit_id))
    visit.update(user_id: @context_user.id) if visit.user_id.nil?
  end

  # Transition guest → lead_qualify so the CRM picks up the contact
  @context_user.qualify_lead if @context_user.guest?
rescue StandardError => e
  # Non-blocking — don't let email capture break the quote flow
  Rails.logger.warn "[QuoteBuilderController#capture_visitor_email] #{e.message}"
end

#collect_bom_items(bom) ⇒ Array<Hash>

Flattens a BOM's included items, control items, and accessory items into
one array.

Parameters:

  • bom (Hash)

    the BOM to collect items from

Returns:

  • (Array<Hash>)

    the flattened items



1371
1372
1373
1374
1375
1376
# File 'app/controllers/www/quote_builder_controller.rb', line 1371

def collect_bom_items(bom)
  items = Array(bom['includedItems'])
  (bom['controls'] || []).each { |c| items += Array(c['includedItems']) }
  (bom['accessories'] || []).each { |a| items += Array(a['includedItems']) }
  items
end

#convert_and_map_options_to_json_form(options) ⇒ Hash

Convert parsed options (including per-choice option lists) into JSON form for the builder UI.

Parameters:

  • options (Hash)

    Parsed quote builder options

Options Hash (options):

  • environment (String, nil)

    'Indoor' or 'Outdoor'

  • room_type_key (String, nil)

    Selected room type SEO key

  • room_type_options (Array)

    Available room types

  • sub_floor_type_key (String, nil)

    Selected sub-floor type SEO key

  • sub_floor_type_options (Array)

    Available sub-floor types

  • floor_type_key (String, nil)

    Selected floor type SEO key

  • floor_type_options (Array)

    Available floor types

  • coverage_state (String, nil)

    Selected coverage type

  • coverage_options (Array)

    Available coverage types

  • membrane_type (String, nil)

    Selected membrane type

  • membrane_options (Array)

    Available membrane types

  • cable_spacing (String, nil)

    Selected cable spacing

  • cable_spacing_options (Array)

    Available cable spacings

  • voltage_id (Integer, nil)

    Selected voltage ID

  • voltage_options (Array)

    Available voltages

  • expansion_joint_spacing (String, nil)

    Expansion joint spacing

  • skip_expansion_joint (Boolean, nil)

    Skip expansion joint planning

  • heated_area (Numeric, String, nil)

    Heated area in square feet

  • room_area (Numeric, String, nil)

    Total room area in square feet

  • custom_room_plan (Boolean, nil)

    Whether a custom room plan is used

  • installation_postal_code (String, nil)

    Installation ZIP/postal code

  • zones (Array<Hash>)

    Zone dimension hashes with :length/:width in feet

  • use_in_stock_only (Boolean, nil)

    Restrict to in-stock products

  • display_trade_pro_options (Boolean, nil)

    Show trade pro options

Returns:

  • (Hash)

    Builder state hash in JSON form



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
879
880
881
882
883
884
885
886
887
888
889
890
891
# File 'app/controllers/www/quote_builder_controller.rb', line 851

def convert_and_map_options_to_json_form(options)
  {
    environment: options[:environment],
    roomType: options[:room_type_key],
    roomTypeOptions: options[:room_type_options],
    subfloorType: options[:sub_floor_type_key],
    subfloorTypeOptions: options[:sub_floor_type_options].map { |o| { value: o.first, key: o.last } },
    floorType: options[:floor_type_key],
    floorTypeOptions: options[:floor_type_options].map { |o| { value: o.first, key: o.last } },
    coverageType: options[:coverage_state],
    coverageTypeOptions: options[:coverage_options].map { |o| { value: o.first, key: o.last } },
    membraneType: options[:membrane_type],
    membraneTypeOptions: [
      { value: nil, key: nil },
      *options[:membrane_options].map { |(v, k)| { value: v, key: k } }
    ],
    cableSpacing: options[:cable_spacing],
    cableSpacingOptions: [
      { value: nil, key: nil },
      *options[:cable_spacing_options].map { |(v, k)| { value: v, key: k } }
    ],
    voltage: options[:voltage_id],
    voltageOptions: [
      { value: nil, key: nil },
      *options[:voltage_options].map { |o| { value: o, key: o } }
    ],
    expansionJointSpacing: options[:expansion_joint_spacing],
    skipExpansionJoint: options[:skip_expansion_joint],
    heatedArea: options[:heated_area],
    roomArea: options[:room_area],
    customRoomPlan: options[:custom_room_plan],
    installationPostalCode: options[:installation_postal_code] == 'null' ? nil : options[:installation_postal_code],
    zones: options[:zones].filter_map do |args|
      next unless args[:length]&.positive? && args[:width]&.positive?

      { l: args[:length] * 12, w: args[:width] * 12 }
    end,
    useInStockOnly: options[:use_in_stock_only],
    displayTradeProOptions: options[:display_trade_pro_options]
  }
end

#derive_heated_area_from_zones!Object

Derive heated_area (and room_area) from zones when not explicitly provided.
Called after loading @options from params or RhcParamSet. Covers entries from
zone-only forms (e.g. driveway mini form) where no heated_area was submitted.



1315
1316
1317
1318
1319
1320
# File 'app/controllers/www/quote_builder_controller.rb', line 1315

def derive_heated_area_from_zones!
  return unless @options[:heated_area].to_f.zero? && @options[:zones].present?

  @options[:heated_area] = @options[:zones].sum { |z| z[:sqft].to_f }
  @options[:room_area] = @options[:heated_area] if @options[:room_area].to_f.zero?
end

#determine_environment_from_paramsString

Infers Indoor/Outdoor from the submitted room-type/floor-type keys.

Returns:

  • (String)

    'Indoor' or 'Outdoor'



1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
# File 'app/controllers/www/quote_builder_controller.rb', line 1019

def determine_environment_from_params
  # Try to determine environment from room_type_key first
  if params[:room_type_key].present?
    room_type = RoomType.find_by(seo_key: params[:room_type_key])
    return room_type.environment if room_type&.environment.present?
  end

  # Try to determine environment from floor_type_key
  if params[:floor_type_key].present?
    floor_type = FloorType.find_by(seo_key: params[:floor_type_key])
    return floor_type.environment if floor_type&.environment.present?
  end

  # Default to Indoor if we can't determine from parameters
  'Indoor'
end

#enrich_boms_with_canonical_urls(boms) ⇒ void

This method returns an undefined value.

Batch-enrich BOM items with canonical URLs so JS components can link directly
to hierarchical catalog paths (e.g. /:product_line/:sku).

Parameters:

  • boms (Array<Hash>)

    the BOMs whose items get canonicalUrl set (mutated in place)



1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
# File 'app/controllers/www/quote_builder_controller.rb', line 1343

def enrich_boms_with_canonical_urls(boms)
  all_items = boms.flat_map { |bom| collect_bom_items(bom) }
  skus = all_items.filter_map { |i| i['sku'] }.uniq
  return if skus.empty?

  items = Item.includes(:primary_product_line).where(sku: skus).to_a
  pls = items.filter_map(&:primary_product_line).uniq(&:id)
  if pls.any?
    paths = ProductLine.canonical_paths_for(pls)
    pls.each { |pl| pl.instance_variable_set(:@canonical_path, paths[pl.id]) }
  end

  canonical_urls = items.each_with_object({}) do |item, h|
    url = item.canonical_url
    h[item.sku] = url if url
  end

  all_items.each do |item|
    url = canonical_urls[item['sku']]
    item['canonicalUrl'] = url if url
  end
end

#estimate_shipping_from_postal_codevoid

This method returns an undefined value.

Estimates shipping costs for the given items/postal code using a
throwaway order, returning cached shipping-option groups as JSON.



512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
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
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
# File 'app/controllers/www/quote_builder_controller.rb', line 512

def estimate_shipping_from_postal_code
  init_current_user
  set_locale
  errors = []
  if (postal_code = params[:postal_code]).present?
    ord_attributes = { state: 'in_shipping_estimate', order_reception_type: 'Online', contact: (@context_user if @context_user.is_contact?), do_not_detect_shipping: true, is_www_ship_by_zip: true }
    if (cart_id = params[:cart_id].to_i) > 0
      cart = Order.carts.where(id: cart_id).first
      ord = cart.deep_dup
      ord.update(ord_attributes)
      cart.line_items.goods.each do |li|
        ord.add_line_item(catalog_item_id: li.catalog_item_id, quantity: li.quantity)
      end
      ord.save
    end
    ord ||= @context_user.customer.orders.create(ord_attributes)
    opp = @context_user.create_quote_builder_project
    opp.update_column(:installation_postal_code, params[:postal_code])
    opp.orders << ord
    ord.reload
    if params[:items].present?
      params[:items].each do |item|
        sku, qty = item.split('|')
        next if sku.blank?

        qty = qty.to_i
        if sku.present? && (qty > 0)
          ci = ord.catalog.catalog_items.public_catalog_items.by_skus(sku).first
          if ci
            ord.add_line_item(catalog_item_id: ci.id, quantity: qty)
          else
            errors << "We could not find sku #{sku} in catalog."
          end
        else
          errors << 'SKU must be present.' if sku.blank?
          errors << 'Qty must be 1 or more.' unless qty > 0
        end
      end
      ord.do_not_detect_shipping = true
      ord.is_www_ship_by_zip = true
      ord.save
    end
  else
    errors << 'Zip or Postal code must be present.'
  end
  errors << 'Items must must be present.' unless ord.line_items.any?
  respond_to do |format|
    format.json do
      if ord && ord.installation_postal_code.present? && ord.line_items.any? && errors.empty?
        res, dq, economy_shipping_costs, ground_shipping_costs, faster_shipping_costs, freight_shipping_costs = Rails.cache.fetch("estimate_shipping_from_postal_code:#{postal_code}:md5_hash_key:#{Delivery.md5_hash_items(ord.line_items.goods)}",
                                                                                                                                  expires_in: 1.week) do
          ord.is_www_ship_by_zip = true
          res = ord.retrieve_shipping_costs
          dq = begin
            ord.deliveries.quoting.first
          rescue StandardError
            nil
          end
          shipping_costs_hash = dq&.sorted_shipping_costs_www_hash
          economy_shipping_costs_arr = shipping_costs_hash[:economy] || []
          ground_shipping_costs_arr = shipping_costs_hash[:ground] || []
          faster_shipping_costs_arr = shipping_costs_hash[:faster] || []
          freight_shipping_costs_arr = shipping_costs_hash[:freight] || []
          economy_shipping_costs = economy_shipping_costs_arr.map do |sc|
            { cost: number_with_precision(sc.cost, precision: 2), description: sc.shipping_option.description, delivery_commitment: sc.shipping_option.delivery_commitment, code: sc.shipping_option.name, carrier: sc.shipping_option.carrier }
          end
          ground_shipping_costs = ground_shipping_costs_arr.map do |sc|
            { cost: number_with_precision(sc.cost, precision: 2), description: sc.shipping_option.description, delivery_commitment: sc.shipping_option.delivery_commitment, code: sc.shipping_option.name, carrier: sc.shipping_option.carrier }
          end
          faster_shipping_costs = faster_shipping_costs_arr.map do |sc|
            { cost: number_with_precision(sc.cost, precision: 2), description: sc.shipping_option.description, delivery_commitment: sc.shipping_option.delivery_commitment, code: sc.shipping_option.name, carrier: sc.shipping_option.carrier }
          end
          freight_shipping_costs = freight_shipping_costs_arr.map do |sc|
            { cost: number_with_precision(sc.cost, precision: 2), description: 'Motor Freight', delivery_commitment: sc.shipping_option.delivery_commitment, code: sc.shipping_option.name, carrier: sc.shipping_option.carrier }
          end
          [res, dq, economy_shipping_costs, ground_shipping_costs, faster_shipping_costs, freight_shipping_costs]
        end
        if res.all? { |r| r && r['code'] == 'ok' } && dq.present? && (economy_shipping_costs + ground_shipping_costs + faster_shipping_costs + freight_shipping_costs).any?
          render json: {
            postal_code:,
            economy_shipping_costs:,
            ground_shipping_costs:,
            faster_shipping_costs:,
            freight_shipping_costs:
          }
        else
          render json: { error_message: 'No shipping options found!' }, status: :internal_server_error
        end
      else
        render json: { error_message: errors.join(' ') }, status: :internal_server_error
      end
    end
  end
  ord&.destroy
  opp&.destroy
end

#finish_request_planvoid

This method returns an undefined value.

Submits the installation-plan request: saves the room/contact info and
either sends the room to engineering or routes to the sign-in/upload step.



395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
# File 'app/controllers/www/quote_builder_controller.rb', line 395

def finish_request_plan
  parse_params_and_get_options
  if (share_key = @options[:share_key]) && (rhc_param_set = RhcParamSet.find_by(md5_hash_key: share_key)) && rhc_param_set.processed_parameters
    @options = rhc_param_set.processed_parameters.deep_symbolize_keys
  end
  @options[:share_key] = share_key
  derive_heated_area_from_zones!
  @system_name = params[:system_name]
  email = params[:contact_point] ? params[:contact_point][:detail] : nil
  @contact_point = ContactPoint.new(detail: email, category: :email)
  @room_layout_image = Upload.new(attachment: params[:room_layout_image][:attachment], category: :room_layout) if (params[:room_layout_image] && params[:room_layout_image][:attachment]).present?

  if @contact_point.valid?
    # valid email, so set it as primary email unless already in the user's contact points, whereupon we set the existing contact point as the primary
    if (cp = @context_user.contact_points.find { |cpt| cpt.detail == @contact_point.detail })
      cp.move_to_top
    else
      @context_user.email = @contact_point.detail
    end
    if @context_user.guest? || @context_user.has_guest_individual_name?
      if params[:customer_name].present?
        @context_user.name = params[:customer_name]
        @context_user.save
      end
      @context_user.qualify_lead if @context_user.guest?
    end

    result = RoomConfiguration.update_or_create_from_rhc_args(rhc_args_from_options(@options), @options, @context_user.customer, @system_name, params[:room_layout_image])
    if result[:success]
      @room = result[:room]
      room_can_be_designed = @room.room_plan.present? || @room.has_zone_information? || @room.room_layout_attached?
      if room_can_be_designed
        if @room.ready_for_engineering?
          next_step_message = 'Your floor plan was submitted to our design team for an installation plan and bill of materials.'
          next_step_path = (@room, info: next_step_message)
        else
          next_step_message = "Your floor plan is not ready to send to our design team for an installation plan. #{@room.errors_to_s}"
          next_step_path = my_room_path(@room, warning: next_step_message)
        end
      else
        next_step_message = 'Please provide detailed measurements, layout or floorplan for this room/area.'
        next_step_path = upload_my_room_path(@room, warning: next_step_message)
      end
      if 
        # here we send them to my rooms flow if they have an account or if we need measurements, make them sign in or up if they need to (no choice here)
        respond_to do |format|
          format.turbo_stream { redirect_to cms_link(next_step_path) }
          format.html { redirect_to next_step_path }
        end
      elsif !
        if room_can_be_designed
          # here we can simply send the room to engineering
          res = @room.send_to_engineering_and_create_crm_quote
          if res
            flash[:info] = "Plan request sent to our engineering team for design with installation plan number: #{@room.reference_number}. You will be contacted by email within 2 business days."
            respond_to do |format|
              format.turbo_stream { render :request_plan_submitted }
              format.html { redirect_to quote_builder_path(share_key: @options[:share_key]) }
            end
          else
            flash[:error] = "Could not request plan: #{@room.errors_to_s}, #{@room.quotes.last&.errors&.full_messages&.join(' ')}"
            respond_to do |format|
              format.turbo_stream { render :request_plan }
              format.html { redirect_to quote_builder_path(share_key: @options[:share_key]) }
            end
          end
        else
          # authenticate_account({ :account_email => email, :after_authenticate_path => next_step_path })
          flash[:error] = 'Could not request plan: please attach a layout or floorplan for this heated space '
          respond_to do |format|
            format.turbo_stream { render :request_plan }
            format.html { redirect_to quote_builder_path(share_key: @options[:share_key]) }
          end
        end
      end
    else
      flash[:error] = "Could not request plan: #{result[:errors]}"
      respond_to do |format|
        format.turbo_stream { render :request_plan }
        format.html { redirect_to quote_builder_path(share_key: @options[:share_key]) }
      end
    end
  else
    flash[:error] = "Could not request plan, problems with email: #{@contact_point.errors_to_s}"
    respond_to do |format|
      format.turbo_stream { render :request_plan }
      format.html { redirect_to quote_builder_path(share_key: @options[:share_key]) }
    end
  end
end

#finish_save_systemvoid

This method returns an undefined value.

Saves the room/quote from a shared-quote's stored params, then either
applies customer pricing (signed in) or prompts authentication.



340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
# File 'app/controllers/www/quote_builder_controller.rb', line 340

def finish_save_system
  parse_params_and_get_options
  if (share_key = @options[:share_key]) && (rhc_param_set = RhcParamSet.find_by(md5_hash_key: share_key)) && rhc_param_set.processed_parameters
    @options = rhc_param_set.processed_parameters.deep_symbolize_keys
  end
  @options[:share_key] = share_key
  derive_heated_area_from_zones!
  result = RoomConfiguration.update_or_create_from_rhc_args(rhc_args_from_options(@options), @options, @context_user.customer, params[:system_name])
  if result[:success]
    @room = result[:room]
    # try to get an existing quote first or create one
    @quote = @room.quotes.last || Quote.auto_quote_from_room(@room, { force_single_origin: true, do_not_set_shipping_address: true })
    if 
      flash.now[:info] = 'Quote and room/heated space were saved.'
      respond_to do |format|
        format.html { redirect_to (@room) }
      end
    else
      (account_email: @context_user.email, after_authenticate_path: (@room))
    end
  else
    flash.now[:error] = "Could not save system: #{result[:errors]}"
    respond_to do |format|
      format.html { redirect_to quote_builder_path(share_key: @options[:share_key], show_save_system: 't') }
    end
  end
end

#generate_shared_quote_urlvoid

This method returns an undefined value.

Persists the current quote-builder selections as a shareable
RhcParamSet and redirects to (or returns) its share-key URL.



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
198
199
200
201
202
203
204
205
# File 'app/controllers/www/quote_builder_controller.rb', line 157

def generate_shared_quote_url
  # Landing-page snow-melting forms post project_type="Snow Melting" (and a
  # split room_type_outdoor) rather than an explicit environment. Without this
  # normalization parse_room_params defaults environment to "Indoor", so the
  # stored param set — and the shared quote it produces — would silently open
  # in floor-heating mode. See #normalize_environment_params!.
  normalize_environment_params!
  parse_params_and_get_options

  # Capture visitor email (optional field from driveway/mini quote forms).
  # Sets the email on the guest customer and links the Visit, giving the CRM
  # a known contact tied to this quote session.
  capture_visitor_email(params[:visitor_email])

  # For Indoor projects coming from mini form: if room_area is provided but heated_area is not,
  # calculate heated_area as ~80% of room_area (the typical coverage ratio)
  if @options[:environment] == 'Indoor' && @options[:room_area].to_f.positive? && @options[:heated_area].to_f.zero?
    @options[:heated_area] = (@options[:room_area].to_f * 0.80).floor
  # If only heated_area is provided, calculate room_area (legacy behavior)
  elsif @options[:environment] == 'Indoor' && @options[:heated_area].to_f.positive? && @options[:room_area].to_f.zero?
    @options[:room_area] = (@options[:heated_area].to_f * 1.25).ceil
  end

  # Here we query for an exact match by querying for both where processed_parameters contains the @options hash and where the @options hash contains the processed_parameters
  processed_parameters = @options.except(:share_key) || {}
  # Find or create by unique md5 in a race-safe manner
  md5_hash_key = RhcParamSet.generate_md5_hash_key(processed_parameters)
  rhc_param_set = RhcParamSet.create_or_find_by!(md5_hash_key:) do |r|
    r.processed_parameters = processed_parameters
  end

  destination_url = if params[:system_type].present?
                      quote_builder_entry_url(system_type: params[:system_type], share_key: rhc_param_set.md5_hash_key)
                    else
                      quote_builder_url(share_key: rhc_param_set.md5_hash_key)
                    end

  respond_to do |format|
    # Turbo form submissions: use 303 See Other to trigger full page navigation
    format.turbo_stream { redirect_to destination_url }
    format.html { redirect_to destination_url }
    format.json do
      render json: {
        url: destination_url,
        share_key: rhc_param_set.md5_hash_key
      }
    end
  end
end

#get_quote_builder_metasHash

SEO title/description/keywords for the current system type. Exposed as
a helper method for the view.

Returns:

  • (Hash)

    the meta tag values



1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
# File 'app/controllers/www/quote_builder_controller.rb', line 1056

def get_quote_builder_metas
  if params[:system_type] == 'snow-melting'
    {
      title: 'Electric Snow Melting & Heated Driveway Cost Estimator - WarmlyYours',
      description: 'Use this intuitive tool to build a free quote for radiant snow melting system. Whether you\'re interested in a heated driveway or trying to increase the safety of your outdoor walkway, stairs, or patio, this tool will help you find the right electric heating system for you.',
      keywords: 'Electric Snow Melting Heated Driveway Cost Estimator WarmlyYours'
    }
  elsif params[:system_type] == 'floor-heating'
    {
      title: 'Get a Free Floor Heating Quote | Cost Estimator | WarmlyYours',
      description: 'Get a free custom floor heating quote in minutes. Enter your room dimensions and get an instant cost estimate for electric radiant floor heating systems.',
      keywords: 'floor heating quote cost estimator WarmlyYours'
    }
  else
    {
      title: 'Cost Estimator & Quote Builder - WarmlyYours',
      description: 'Get a free, instant quote for a heating system with this simple to use tool. We\'ll help you find the right electric warming rolls, mats, or cables for any application',
      keywords: 'Cost Estimator Quote Builder WarmlyYours'
    }
  end
end

#get_quote_info_to_json_form(options, rhc_result = {}) ⇒ Hash

Build the full quote payload in JSON form from parsed options and the RHC result.

Parameters:

  • options (Hash)

    Parsed quote builder options; all keys documented in #get_selections
    are accepted, plus the ones below

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

    Radiant heat calculator result (an :output list of BOMs)

Options Hash (options):

  • step (Integer, nil)

    Wizard step number (bumped to 1 when output exists)

  • catalog_id (Integer, nil)

    Catalog used for tier pricing

  • share_key (String, nil)

    Share key for the configuration

Returns:

  • (Hash)

    Quote payload including selections and sorted, priced output



805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
# File 'app/controllers/www/quote_builder_controller.rb', line 805

def get_quote_info_to_json_form(options, rhc_result = {})
  options[:step] = 1 if rhc_result[:output].present? && options[:step].to_i.zero?

  # Use a local variable to avoid mutating rhc_result — Service::Result is read-only ([]=  is not defined)
  output = rhc_result[:output]
  output = apply_tier_pricing_to_boms(output, options[:catalog_id]) if output.present? && tier_discount_pct.positive?
  output&.sort_by! { |item| item[:price] }
  enrich_boms_with_canonical_urls(output) if output.present?

  {
    selections: get_selections(options),
    shareKey: options[:share_key],
    hasTierPricing: tier_discount_pct.positive?,
    tierDiscountPct: tier_discount_pct.positive? ? tier_discount_pct.round(0) : nil,
    **rhc_result,
    output:
  }.compact
end

#get_quotesvoid

This method returns an undefined value.

Calculates and returns quote results as JSON, validating heated/room
area limits first.



122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
# File 'app/controllers/www/quote_builder_controller.rb', line 122

def get_quotes
  parse_params_and_get_options

  if @options[:heated_area].to_f > HEATED_AREA_SQFT_LIMIT
    # render json: { error_message: "Heated area requested is too large: #{@options[:heated_area]}, please limit heated area to #{HEATED_AREA_SQFT_LIMIT} sqft." }, status: 500
    render json: get_quote_info_to_json_form(
      @options,
      { error_status: :error, error_message: "Heated area requested is too large: #{@options[:heated_area].round} sq.ft., please limit heated area to #{HEATED_AREA_SQFT_LIMIT} sq.ft." }
    ), status: :unprocessable_content
  elsif @options[:room_area].to_f > ROOM_AREA_SQFT_LIMIT
    render json: get_quote_info_to_json_form(
      @options,
      { error_status: :error, error_message: "Room area requested is too large: #{@options[:room_area].round} sqft., please limit room area to #{ROOM_AREA_SQFT_LIMIT} sq.ft." }
    ), status: :unprocessable_content
  elsif (params[:environment] === 'Indoor') && (@options[:room_area].to_f < @options[:heated_area].to_f)
    render json: get_quote_info_to_json_form(
      @options,
      { error_status: :error, error_message: "Room area (#{@options[:room_area].round} sq.ft.) needs to be larger than or equal to heated area (#{@options[:heated_area].round} sq.ft.)." }
    ), status: :unprocessable_content
  else
    result = RoomConfiguration::CalculateQuote.call(rhc_args_from_options(@options))
    status = result.error_status.present? ? :unprocessable_content : :ok

    # Track successful quote completion for first-party analytics
    # This matches the client-side instant_quote_complete_fh / instant_quote_complete_sm events sent to GA4
    track_quote_completed(@options, result) if status == :ok

    render json: get_quote_info_to_json_form(@options, result), status:
  end
end

#get_selections(options) ⇒ Hash

Map the parsed quote options to the selections hash the React client renders.

Parameters:

  • options (Hash)

    Parsed quote builder options

Options Hash (options):

  • custom_room_plan (Boolean, nil)

    Whether a custom room plan is used

  • environment (String, nil)

    'Indoor' or 'Outdoor'

  • room_type_key (String, nil)

    Room type SEO key

  • sub_floor_type_key (String, nil)

    Sub-floor type SEO key

  • floor_type_key (String, nil)

    Floor type SEO key

  • cable_spacing (String, nil)

    Cable spacing selection

  • voltage_id (Integer, nil)

    Selected voltage ID

  • coverage_state (String, nil)

    Coverage type selection

  • membrane_type (String, nil)

    Membrane type selection

  • expansion_joint_spacing (String, nil)

    Expansion joint spacing

  • skip_expansion_joint (Boolean, nil)

    Skip expansion joint planning

  • heated_area (Numeric, String, nil)

    Heated area in square feet

  • room_area (Numeric, String, nil)

    Total room area in square feet

  • installation_postal_code (String, nil)

    Installation ZIP/postal code

  • zones (Array<Hash>)

    Zone dimension hashes with :length/:width in feet

  • use_in_stock_only (Boolean, nil)

    Restrict to in-stock products

  • display_trade_pro_options (Boolean, nil)

    Show trade pro options

  • step (Integer, nil)

    Wizard step number

  • selected_element_set (Object, nil)

    Selected heating element set

  • selected_control (Object, nil)

    Selected thermostat/control

  • selected_accessories (Array<Integer>)

    Selected accessory indices

Returns:

  • (Hash)

    Selections hash in JSON form



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
# File 'app/controllers/www/quote_builder_controller.rb', line 763

def get_selections(options)
  {
    customRoomPlan: options[:custom_room_plan],
    environment: options[:environment],
    roomType: options[:room_type_key],
    subfloorType: options[:sub_floor_type_key],
    floorType: options[:floor_type_key],
    cableSpacing: options[:cable_spacing],
    voltage: options[:voltage_id],
    coverageType: options[:coverage_state],
    membraneType: options[:membrane_type],
    expansionJointSpacing: options[:expansion_joint_spacing],
    skipExpansionJoint: options[:skip_expansion_joint],
    heatedArea: options[:heated_area],
    roomArea: options[:room_area],
    installationPostalCode: options[:installation_postal_code] == 'null' ? nil : options[:installation_postal_code],
    zones: options[:zones].filter_map do |args|
      next unless args[:length]&.positive? && args[:width]&.positive?

      { l: args[:length] * 12, w: args[:width] * 12 }
    end,
    useInStockOnly: options[:use_in_stock_only],
    displayTradeProOptions: options[:display_trade_pro_options],
    step: options[:step] || 0,
    selectedElement: options[:selected_element_set],
    selectedControl: options[:selected_control],
    selectedKit: nil,
    selectedAccessories: Array.new(options[:selected_accessories].max.to_i + 1) do |i|
      options[:selected_accessories].include?(i)
    end,
    issues: []
  }
end

#get_shared_quotevoid

This method returns an undefined value.

Loads a shared quote by its share key and re-renders the show view.



210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
# File 'app/controllers/www/quote_builder_controller.rb', line 210

def get_shared_quote
  rhc_param_set = RhcParamSet.find_by(md5_hash_key: params[:share_key])
  if rhc_param_set&.processed_parameters
    flash.now[:info] = 'Loaded pre-built quote'
    pars = rhc_param_set.processed_parameters.deep_symbolize_keys
    # Use RhcParamSet's built-in caching
    quote_result = rhc_param_set.calculate_quote(context_user: @context_user)
  else
    flash.now[:warning] = 'Could not load pre-built quote'
    pars = {}
    quote_result = RoomConfiguration::CalculateQuote.call(rhc_args_from_options(pars))
  end
  @options_for_client = convert_and_map_options_to_json_form(pars)
  @quotes_and_selections = get_quote_info_to_json_form(pars, quote_result).to_json
  render action: :show
end

#get_system_name_from_room_or_optionsvoid

This method returns an undefined value.

Sets @room/@system_name from the current user's room matching this
share key, or a room-type/floor-type-derived default name.



733
734
735
736
737
# File 'app/controllers/www/quote_builder_controller.rb', line 733

def get_system_name_from_room_or_options
  @room = @context_user.room_configurations.where(additional_notes: @options[:share_key]).first
  opp = @room.presence&.opportunity
  @system_name = opp&.name || "#{RoomType.find_by(seo_key: @options[:room_type_key]).name}, #{FloorType.find_by(seo_key: @options[:floor_type_key]).name}"
end

#heat_lossvoid

This method returns an undefined value.

Creates a heat-loss-calculation project/room and routes to the room
edit form (signing in first if needed).



490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
# File 'app/controllers/www/quote_builder_controller.rb', line 490

def heat_loss
  init_current_user
  set_locale
  @project = @context_user.create_quote_builder_project
  @project.update_column(:name, Opportunity.get_unique_name(@project.customer, 'Heat Loss Calculation Project'))
  @room = @project.create_heat_loss_room
  @room.update_column(:name, @project.next_room_name({ room_base_name: 'Heat Loss Calculation Room' }))
  @room.update_column(:reception_type, 'HeatLossCalculator')
  if 
    flash[:info] = 'Please set room/heated space information and then save to proceed.'
    respond_to do |format|
      format.html { redirect_to edit_my_room_path(@room, force_hlc: true) }
    end
  else
    (after_authenticate_path: edit_my_room_path(@room, force_hlc: true))
  end
end

#normalize_environment_params!void

This method returns an undefined value.

Normalizes the params posted by landing-page and mini quote forms
(the snow-melting hub, /snow-melting/mat, /cable, homepage) into the
canonical shape the parser expects. Those forms post project_type
("Snow Melting") or system_type ("snow-melting") plus a split
room_type_outdoor / room_type_indoor, whereas the in-builder UI posts
an explicit environment + room_type. #parse_room_params defaults a
missing environment to "Indoor" (floor heating), so a Snow Melting form
that never gets normalized silently yields a floor-heating quote.

Idempotent — uses ||=, so it is a no-op for in-builder requests that
already carry environment / room_type.



1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
# File 'app/controllers/www/quote_builder_controller.rb', line 1002

def normalize_environment_params!
  params[:environment] ||= if params[:project_type].present?
                             params[:project_type] == 'Snow Melting' ? 'Outdoor' : 'Indoor'
                           elsif params[:system_type].present?
                             # Set env based on the system_type in the URL
                             params[:system_type] == 'snow-melting' ? 'Outdoor' : 'Indoor'
                           else
                             # Determine environment from room_type_key or floor_type_key
                             determine_environment_from_params
                           end

  params[:room_type] ||= params[:environment] == 'Outdoor' ? params[:room_type_outdoor] : params[:room_type_indoor]
end

#parse_params_and_get_optionsvoid

This method returns an undefined value.

Parses the request params into @options, the normalized hash the rest
of the quote builder actions operate on.



613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
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
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
# File 'app/controllers/www/quote_builder_controller.rb', line 613

def parse_params_and_get_options
  init_current_user
  set_locale

  # If params are "null" or "", the value should be nil
  p = Heatwave::Normalizers.chain(params.to_h, :hash_compactor)
  # Sanitize to prevent null bytes or invalid encodings from reaching queries
  p = sanitize_param_value(p)
  p = p&.symbolize_keys
  # Trash heated_area if they switched to tire tracks
  p[:heated_area] = 0 if p[:coverage_type] == '4' && !p[:zones] && (p[:heated_area].presence&.to_f&.> 0)
  # Trash heated_area if they switched to concrete (need zones)
  p[:heated_area] = 0 if p[:floor_type_key] == 'concrete' && p[:zones].blank?

  result = GetRoomOptions.new.process(
    @context_user.customer.store.id,
    nil,
    {
      room_params: parse_room_params(p),
      public: true,
      room_quoting_method: 'instant_quoting'
    }
  )
  logger.debug "parse_params_and_get_options, result: #{result.inspect}"

  @options = {
    catalog_id: @context_user.customer.catalog.id,
    currency: @context_user.customer.catalog.currency,
    environment: result.environment,
    room_type_key: result.room_type_key,
    room_type_options: RoomType.options_by_environment_cached(result.environment),
    sub_floor_type_key: result.sub_floor_type_key,
    sub_floor_type_options: result.sub_floor_type_options_by_key,
    floor_type_key: result.floor_type_key,
    floor_type_options: result.floor_type_options_by_key,
    heating_system_product_line_url: result.heating_system_product_line_url,
    heating_system_product_line_options: result.heating_system_product_line_options_by_url,
    require_cable_spacing: result.require_cable_spacing,
    cable_spacing: result.cable_spacing,
    cable_spacing_options: result.cable_spacing_options,
    voltage_id: result.voltage_id,
    voltage_options: result.voltage_options,
    coverage_state: result.coverage_state,
    coverage_options: result.coverage_options,
    membrane_type: result.membrane_type,
    membrane_options: result.membrane_options
  }

  begin
    query = Addressable::URI.parse(request.referer.to_s).query
    @options[:share_key] = (Addressable::URI.form_unencode(query).find { |entry| entry.first == 'share_key' } || []).last || params[:share_key] if query
  rescue URI::InvalidURIError => e
    ErrorReporting.warning(e, "Unable to parse referer in quote builder: #{request.referer}")
  end

  @options[:use_in_stock_only] = params[:use_in_stock_only] == 'true'
  @options[:display_trade_pro_options] = params[:display_trade_pro_options] == 'true' ? true : (@context_user&.is_direct_pro? || false)
  @options[:zones] = (params[:zones] || '').split(',', -1).filter_map do |zone_string|
    # e.g.: "10x20" → { length: 10.0, width: 20.0, sqft: 200.0 }
    dims = zone_string.split('x').map(&:to_f)
    # Require exactly two positive dimensions; skip malformed strings (e.g. "null", "10", "")
    next unless dims.length >= 2 && dims[0].positive? && dims[1].positive?

    { length: dims[0], width: dims[1], sqft: dims[0] * dims[1] }
  end

  @options[:heated_area] = params[:heated_area].presence&.to_f
  @options[:room_area] = params[:room_area].presence&.to_f

  # Derive heated_area from zones when not explicitly provided (e.g. driveway mini form)
  derive_heated_area_from_zones!
  if params[:custom_room_plan].to_i > 0
    @options[:custom_room_plan] = params[:custom_room_plan].to_i
    @room_plan = begin
      @context_user.room_plans.find(params[:custom_room_plan])
    rescue StandardError
      nil
    end
  end

  @options[:installation_postal_code] = params[:installation_postal_code].presence
  @options[:expansion_joint_spacing] = params[:expansion_joint_spacing].presence&.to_f || 20.0
  @options[:skip_expansion_joint] = params[:skip_expansion_joint] == 'true'
  @options[:step] = params[:step].presence&.to_i
  @options[:selected_element_set] = safe_integer_param(params[:selected_element_set])
  @options[:selected_control] = safe_integer_param(params[:selected_control]) || (result.environment == 'Indoor' ? 2 : nil)
  @options[:selected_kit] = nil
  @options[:selected_accessories] = params[:selected_accessories].presence&.split(',', -1)&.map(&:to_i) || []

  # We don't want mis-matched data types in the same array...see convert_nullable_select_options
  @options[:membrane_options].shift
  @options[:voltage_options].shift

  logger.debug "parse_params_and_get_options, @options: #{JSON.pretty_generate(@options.as_json)}"
  @quotes_and_selections = {}
end

#parse_room_params(p) ⇒ Hash

Normalizes submitted room params against the environment's defaults.

Parameters:

  • p (Hash)

    the raw quote-builder params (or @options)

Returns:

  • (Hash)

    the normalized room params



897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
# File 'app/controllers/www/quote_builder_controller.rb', line 897

def parse_room_params(p)
  defaults = HeatingElementProductLineOption::ENVIRONMENTS_HASH[p[:environment].presence || 'Indoor']

  # TODO: create one query for all types
  room_params = {
    room_type: p[:room_type_key] || p[:room_type] || defaults[:default_room_type_seo_key],
    sub_floor_type: p[:sub_floor_type_key] || defaults[:default_sub_floor_type_seo_key] || 'wood',
    floor_type: p[:floor_type_key] || defaults[:default_floor_type_seo_key],
    cable_spacing: p[:cable_spacing],
    voltage_id: p[:voltage],
    coverage_state: p[:coverage_type],
    membrane_type: p[:membrane_type],
    heating_system_product_line: p[:heating_system_product_line_url],
    installation_postal_code: p[:installation_postal_code]
  }

  # Return and compact
  {
    **room_params,
    room_type_id: RoomType.where(seo_key: room_params[:room_type])&.first&.id,
    sub_floor_type_id: SubFloorType.where(seo_key: room_params[:sub_floor_type])&.first&.id,
    floor_type_id: FloorType.where(seo_key: room_params[:floor_type])&.first&.id,
    heating_system_product_line_id: (
      ProductLine.find_by(slug_ltree: LtreePaths.slug_ltree_from_legacy_hyphen_url(room_params[:heating_system_product_line_url])) ||
      ProductLine.find_by(slug_ltree: room_params[:heating_system_product_line_url])
    )&.id
  }.compact
end

#recalculate_bom_price(bom) ⇒ void

This method returns an undefined value.

Recomputes a BOM's price/sale price from its included items, in place.

Parameters:

  • bom (Hash)

    the BOM to recalculate (mutated in place)



1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
# File 'app/controllers/www/quote_builder_controller.rb', line 1206

def recalculate_bom_price(bom)
  return if bom['includedItems'].blank?

  bom['price'] = bom['includedItems'].sum { |i| (i['price'] || 0) * (i['qty'] || 1) }
  has_discount = bom['includedItems'].any? { |i| i['sale_price'].present? }
  has_tier_pricing = bom['includedItems'].any? { |i| i['is_tier_pricing'] }

  if has_discount
    bom['sale_price'] = bom['includedItems'].sum { |i| (i['sale_price'] || i['price'] || 0) * (i['qty'] || 1) }
    bom['sale_price'] = nil if bom['sale_price'] == bom['price']
  end

  # Propagate tier pricing flag to BOM
  return unless has_tier_pricing && bom['sale_price'].present?

  bom['is_tier_pricing'] = true
  bom['tier_discount_pct'] = tier_discount_pct.round(0)
end

#recalculate_bundle_price(bundle) ⇒ void

This method returns an undefined value.

Recomputes a bundle's (e.g. control's) price/sale price from its
included items, in place.

Parameters:

  • bundle (Hash)

    the bundle to recalculate (mutated in place)



1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
# File 'app/controllers/www/quote_builder_controller.rb', line 1183

def recalculate_bundle_price(bundle)
  return if bundle['includedItems'].blank?

  bundle['price'] = bundle['includedItems'].sum { |i| (i['price'] || 0) * (i['qty'] || 1) }
  has_discount = bundle['includedItems'].any? { |i| i['sale_price'].present? }
  has_tier_pricing = bundle['includedItems'].any? { |i| i['is_tier_pricing'] }

  if has_discount
    bundle['sale_price'] = bundle['includedItems'].sum { |i| (i['sale_price'] || i['price'] || 0) * (i['qty'] || 1) }
    bundle['sale_price'] = nil if bundle['sale_price'] == bundle['price']
  end

  # Propagate tier pricing flag to bundle
  return unless has_tier_pricing && bundle['sale_price'].present?

  bundle['is_tier_pricing'] = true
  bundle['tier_discount_pct'] = tier_discount_pct.round(0)
end

#request_planvoid

This method returns an undefined value.

Renders the "request an installation plan" form for the current selections.



371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
# File 'app/controllers/www/quote_builder_controller.rb', line 371

def request_plan
  parse_params_and_get_options
  if (share_key = @options[:share_key]) && (rhc_param_set = RhcParamSet.find_by(md5_hash_key: share_key)) && rhc_param_set.processed_parameters
    @options = rhc_param_set.processed_parameters.deep_symbolize_keys
  end
  @options[:share_key] = share_key
  get_system_name_from_room_or_options
  email = @context_user.email || &.email
  @contact_point = ContactPoint.new(detail: email, category: :email)

  if (@options[:custom_room_plan] || @options[:zones]).blank? # if we don't have a design tool plan or zones, then ask for upload
    @room_layout_image = @room.room_layout_image if @room
    @room_layout_image ||= Upload.new
  end
  respond_to do |format|
    format.turbo_stream { render :request_plan }
    format.html { render_404 }
  end
end

#rhc_args_from_options(options) ⇒ Hash

Translate parsed quote options into the argument hash the RHC service expects.

Parameters:

  • options (Hash)

    Parsed quote builder options

Options Hash (options):

  • catalog_id (Integer, nil)

    Catalog to price against

  • currency (String, nil)

    Currency override (defaults to the catalog's)

  • room_type_key (String, nil)

    Room type SEO key

  • sub_floor_type_key (String, nil)

    Sub-floor type SEO key

  • floor_type_key (String, nil)

    Floor type SEO key

  • heated_area (Numeric, String, nil)

    Heated area in square feet

  • room_area (Numeric, String, nil)

    Total room area (insulation surface) in square feet

  • installation_postal_code (String, nil)

    Installation ZIP/postal code

  • zones (Array<Hash>)

    Zone dimension hashes

  • use_in_stock_only (Boolean, nil)

    Restrict to in-stock products

  • display_trade_pro_options (Boolean, nil)

    Show trade pro options

  • expansion_joint_spacing (String, nil)

    Expansion joint spacing

  • skip_expansion_joint (Boolean, nil)

    Skip expansion joint planning

  • cable_spacing (String, nil)

    Cable spacing selection

  • voltage_id (Integer, nil)

    Selected voltage ID

  • coverage_state (String, nil)

    Coverage type selection

  • membrane_type (String, nil)

    Membrane type selection

Returns:

  • (Hash)

    Arguments for the radiant heat calculator



946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
# File 'app/controllers/www/quote_builder_controller.rb', line 946

def rhc_args_from_options(options)
  logger.debug "!!!!!!!!!!!!!!!!!! rhc_args_from_options(options), options: #{JSON.pretty_generate((options || {}).as_json)}"
  args = {}
  args[:catalog_id] = options[:catalog_id]
  args[:currency] = options[:currency] || Catalog.find(options[:catalog_id])&.currency || 'USD'
  args[:room_type] = RoomType.where(seo_key: options[:room_type_key]).first if options[:room_type_key].present?
  args[:sub_floor_type] = SubFloorType.where(seo_key: options[:sub_floor_type_key]).first if options[:sub_floor_type_key].present?
  args[:floor_type] = FloorType.where(seo_key: options[:floor_type_key]).first if options[:floor_type_key].present?
  args[:square_footage] = options[:heated_area]
  args[:insulation_surface] = options[:room_area]
  args[:installation_postal_code] = options[:installation_postal_code]
  args[:zones] = options[:zones]
  args[:use_in_stock_only] = options[:use_in_stock_only]
  args[:display_trade_pro_options] = options[:display_trade_pro_options]
  args[:expansion_joint_spacing] = options[:expansion_joint_spacing]
  args[:skip_expansion_joint] = options[:skip_expansion_joint]
  args[:cable_spacings] = options[:cable_spacing].present? ? [options[:cable_spacing]] : []
  args[:voltage] = Voltage.where(id: options[:voltage_id]).first if options[:voltage_id].present?
  args[:coverage_state] = options[:coverage_state]
  args[:membrane_type] = options[:membrane_type]
  logger.debug "!!!!!!!!!!!!!!!!!! rhc_args_from_options(options), args: #{args}"
  args
end

#safe_integer_param(value) ⇒ Integer?

Parse an integer param safely, rejecting JavaScript null/undefined strings
that would otherwise be silently converted to 0 by String#to_i.
Returns nil for blank, "null", "undefined", or non-numeric values.

Parameters:

  • value (String, Integer, nil)

    the raw param value

Returns:

  • (Integer, nil)

    the parsed integer, or nil when invalid



1328
1329
1330
1331
1332
1333
1334
1335
1336
# File 'app/controllers/www/quote_builder_controller.rb', line 1328

def safe_integer_param(value)
  return nil if value.blank?

  str = value.to_s.strip
  return nil if %w[null undefined NaN].include?(str)
  return nil unless str.match?(/\A-?\d+\z/)

  str.to_i
end

#sanitize_param_value(value) ⇒ String, ...

Recursively sanitize param values to remove null bytes and invalid encodings

Parameters:

  • value (String, Array, Hash, Object)

    the value to sanitize

Returns:

  • (String, Array, Hash, Object)

    the sanitized value



714
715
716
717
718
719
720
721
722
723
724
725
726
727
# File 'app/controllers/www/quote_builder_controller.rb', line 714

def sanitize_param_value(value)
  case value
  when String
    # Ensure valid UTF-8, drop invalid/undefined bytes, and remove null bytes
    sanitized = value.to_s.scrub
    sanitized.delete("\u0000")
  when Array
    value.map { |v| sanitize_param_value(v) }
  when Hash
    value.to_h.transform_values { |v| sanitize_param_value(v) }
  else
    value
  end
end

#save_systemvoid

This method returns an undefined value.

Renders the save-system confirmation form/frame for the current selections.



276
277
278
279
280
281
282
283
284
285
286
287
288
# File 'app/controllers/www/quote_builder_controller.rb', line 276

def save_system
  parse_params_and_get_options
  get_system_name_from_room_or_options
  respond_to do |format|
    format.html do
      # check that we have a savable system
      show_save_system = nil
      show_save_system = 't' if @options[:selected_element_set].present?
      redirect_to quote_builder_path(share_key: params[:share_key], show_save_system:)
    end
    format.turbo_stream { render :save_system }
  end
end

#set_breadcrumbvoid

This method returns an undefined value.

Sets @breadcrumb for the current system type.



1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
# File 'app/controllers/www/quote_builder_controller.rb', line 1039

def set_breadcrumb
  @breadcrumb = case params[:system_type]
                when 'snow-melting'
                  [{ name: 'Snow Melting', url: cms_link('/snow-melting') },
                   { name: 'Quote Builder' }]
                when 'floor-heating'
                  [{ name: 'Floor Heating', url: cms_link('/floor-heating') },
                   { name: 'Quote Builder' }]
                else
                  [{ name: 'Quote Builder' }]
                end
end

#showvoid

This method returns an undefined value.

Renders the quote builder tool, resolving a shared quote (share_key),
a custom room plan, or landing-page/mini-form params into calculated
quotes and selections.



18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'app/controllers/www/quote_builder_controller.rb', line 18

def show
  # If no system_type is specified, assume floor-heating as the default
  params[:system_type] ||= 'floor-heating'

  # For share_key requests: ensure non-guest users stay on their catalog's locale
  # This prevents a share link from switching a user's catalog/locale
  if params[:share_key].present? && !request.xhr?
    user_locale = @context_user&.locale&.to_sym
    url_locale = params[:locale]&.to_sym
    # If user is not a guest and URL locale differs from their catalog locale, redirect to their locale
    if user_locale.present? && url_locale.present? && user_locale != url_locale && !@context_user.guest?
      redirect_to quote_builder_path(share_key: params[:share_key])
      return
    end
  end

  if (params[:custom_room_plan].to_i > 0) && request.format != :json
    parse_params_and_get_options
    processed_parameters = @options.except(:share_key) || {}
    md5_hash_key = RhcParamSet.generate_md5_hash_key(processed_parameters)
    rhc_param_set = RhcParamSet.create_or_find_by!(md5_hash_key:) do |r|
      r.processed_parameters = processed_parameters
    end
    redirect_to quote_builder_url(share_key: rhc_param_set&.md5_hash_key)
  end

  if params[:share_key].present?
    rhc_param_set = RhcParamSet.find_by(md5_hash_key: params[:share_key])
    @options = rhc_param_set&.processed_parameters&.deep_symbolize_keys

    if @options.present?
      @options[:share_key] = params[:share_key]
      @options[:catalog_id] ||= @context_user.customer.catalog.id # this was not being saved in rhc params until 8/28/18 so use fallback for saved quotes previous to that point
      @options[:currency] ||= @context_user.customer.catalog.currency # this was not being saved in rhc params until 2/2/21 so use fallback for saved quotes previous to that point
      @room_plan = RoomPlan.find_by(id: @options[:custom_room_plan]) if @options[:custom_room_plan].to_i.positive?

      # Derive heated_area from zones for entries stored without it (e.g. driveway mini form)
      derive_heated_area_from_zones!

      # Use RhcParamSet's built-in caching for share_key URLs
      @quotes_and_selections = get_quote_info_to_json_form(
        @options,
        rhc_param_set.calculate_quote(context_user: @context_user)
      )

      # Save to a global so that we can embed it into the page to save an XHR
      @options_for_client = convert_and_map_options_to_json_form(@options)
      @heating_system_product_line_url = @quotes_and_selections
    else
      # redirect without share_key param
      flash[:warning] = 'Shared quote could not be found or has expired'
      # Use informational (not warning) since expired share links are expected behavior
      # from old bookmarks, search engine bots, or links created before Sept 2024 table reset
      ErrorReporting.from_controller(self).informational('Could not find RhcParamSet', { share_key: params[:share_key] })
      redirect_to action: :show
    end
  elsif @options.nil?
    # If we are posting from the homepage or a landing page
    @react_props ||= {}
    # Normalize landing-page / mini-form params (project_type or system_type,
    # plus room_type_outdoor / room_type_indoor) into the canonical
    # environment + room_type the parser expects.
    normalize_environment_params!
    parse_params_and_get_options

    # Auto-calculate quotes if we have sufficient parameters from mini form
    # This eliminates the need for the user to click "Get Quote" again
    if can_auto_calculate_quotes?(@options)
      # For Indoor projects: calculate the missing area value from the one provided
      if @options[:environment] == 'Indoor'
        if @options[:room_area].to_f.positive? && @options[:heated_area].to_f.zero?
          # Room area provided, calculate heated area as ~80% of room area
          @options[:heated_area] = (@options[:room_area].to_f * 0.80).floor
        elsif @options[:heated_area].to_f.positive? && @options[:room_area].to_f.zero?
          # Heated area provided, calculate room area as heated_area * 1.25
          @options[:room_area] = (@options[:heated_area].to_f * 1.25).ceil
        end
      end
      @quotes_and_selections = get_quote_info_to_json_form(
        @options,
        RoomConfiguration::CalculateQuote.call(rhc_args_from_options(@options))
      )
    else
      @quotes_and_selections = {}
    end

    # Save to a global so that we can embed it into the page to save an XHR
    @options_for_client = convert_and_map_options_to_json_form(@options)
  end

  get_system_name_from_room_or_options if params[:show_save_system].present?

  set_breadcrumb

  respond_to do |format|
    format.html {}
    format.json { render json: @options_for_client }
  end
end

#tier_discount_pctObject

============================================================================
Tier Pricing Methods
Applied to BOMs after caching to show personalized pricing for trade customers



1082
1083
1084
1085
1086
1087
1088
1089
1090
# File 'app/controllers/www/quote_builder_controller.rb', line 1082

def tier_discount_pct
  # @context_user is a Customer, Guest, Employee, etc. (all subclasses of Party)
  # Only Customer has pricing_program_discount
  @tier_discount_pct ||= if @context_user.respond_to?(:pricing_program_discount)
                           @context_user.pricing_program_discount.to_f
                         else
                           0.0
                         end
end

#track_quote_completed(options, result) ⇒ void

This method returns an undefined value.

Track successful quote completion for first-party analytics
Creates a $quote_completed_fh or $quote_completed_sm visit_event matching the
client-side instant_quote_complete_fh / instant_quote_complete_sm GA4 events

Parameters:

  • options (Hash)

    Parsed quote builder options

  • result (#output)

    RHC result whose BOM output provides the quote value

Options Hash (options):

  • share_key (String, nil)

    Excluded before hashing the configuration

  • room_type_key (String, nil)

    Room type SEO key

  • floor_type_key (String, nil)

    Floor type SEO key

  • sub_floor_type_key (String, nil)

    Sub-floor type SEO key

  • heated_area (Numeric, String, nil)

    Heated area in square feet

  • room_area (Numeric, String, nil)

    Total room area in square feet

  • environment (String, nil)

    'Indoor' (FH) or 'Outdoor' (SM)

  • currency (String, nil)

    Quote currency



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
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
# File 'app/controllers/www/quote_builder_controller.rb', line 1272

def track_quote_completed(options, result)
  visit_id = session[:visit_id]
  return unless visit_id && (visit = Visit.find_by(id: visit_id))

  # Calculate the lowest quote value from the BOM output
  quote_value = result.output&.map { |bom| bom[:price] || 0 }&.min || 0

  # Generate the share_key for this configuration
  processed_parameters = options.except(:share_key) || {}
  share_key = RhcParamSet.generate_md5_hash_key(processed_parameters)

  event_properties = {
    share_key: share_key,
    room_type: options[:room_type_key],
    floor_type: options[:floor_type_key],
    sub_floor_type: options[:sub_floor_type_key],
    heated_area: options[:heated_area],
    room_area: options[:room_area],
    environment: options[:environment],
    system_type: options[:environment] == 'Outdoor' ? 'snow-melting' : 'floor-heating',
    currency: options[:currency],
    value: quote_value,
    url: request.referer || request.original_url
  }.compact

  # FH (floor heating) or SM (snow melting) based on environment param (matches client-side logic)
  # Note: system_type is a show-action URL segment and is never passed to get_quotes by the React client.
  # The client uses environment == 'Outdoor' to distinguish SM from FH, so we do the same.
  event_name = options[:environment] == 'Outdoor' ? '$quote_completed_sm' : '$quote_completed_fh'
  visit.visit_events.create!(
    user_id: visit.user_id,
    name: event_name,
    time: Time.current,
    properties: event_properties
  )
rescue StandardError => e
  # Don't let tracking errors break the quote flow
  Rails.logger.error "[QuoteBuilderController#track_quote_completed] Error: #{e.message}"
end