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

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 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?, #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::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 ApplicationHelper

#better_number_to_currency, #check_force_logout, #check_or_cross, #check_or_times, #embedded_tab_frame_id, #error_messages, #general_disclaimer_on_product_installation_and_local_codes, #gridjs_from_html_table, #gridjs_table, #is_wy_ip, #line_break, #parent_layout, #pass_or_fail, #render_error_messages_list, #render_video_card, #resolved_auth_form_turbo_frame, #return_path_or, #safe_css_color, #set_return_path_if_present, #set_section_if_present, #tab_frame_id, #to_underscore, #track_page?, #turbo_section_wrapper, #turbo_tabs_request?, #url_on_same_domain_as_request, #widget_index_daily_focus_index_path, #working_hours?, #yes_or_no, #yes_or_no_highlighted, #yes_or_no_with_check_or_cross, #youtube_video

Methods included from UppyUploaderHelper

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

Methods included from ImagesHelper

#image_asset_tag, #image_asset_url

Methods included from SeoHelper

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

Methods included from UrlsHelper

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

Methods included from IconHelper

#account_nav_icon, #fa_icon, #star_rating_html

Instance Method Details

#add_selections_to_cartObject



213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
# File 'app/controllers/www/quote_builder_controller.rb', line 213

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_quoteObject



269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
# File 'app/controllers/www/quote_builder_controller.rb', line 269

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



933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
# File 'app/controllers/www/quote_builder_controller.rb', line 933

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



911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
# File 'app/controllers/www/quote_builder_controller.rb', line 911

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



951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
# File 'app/controllers/www/quote_builder_controller.rb', line 951

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.

Returns:

  • (Boolean)


834
835
836
837
838
839
840
841
842
# File 'app/controllers/www/quote_builder_controller.rb', line 834

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

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.



1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
# File 'app/controllers/www/quote_builder_controller.rb', line 1022

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



1138
1139
1140
1141
1142
1143
# File 'app/controllers/www/quote_builder_controller.rb', line 1138

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



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

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.



1093
1094
1095
1096
1097
1098
# File 'app/controllers/www/quote_builder_controller.rb', line 1093

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_paramsObject



844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
# File 'app/controllers/www/quote_builder_controller.rb', line 844

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

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



1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
# File 'app/controllers/www/quote_builder_controller.rb', line 1115

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_codeObject



468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
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
# File 'app/controllers/www/quote_builder_controller.rb', line 468

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_planObject



359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
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
# File 'app/controllers/www/quote_builder_controller.rb', line 359

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_systemObject



311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
# File 'app/controllers/www/quote_builder_controller.rb', line 311

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_urlObject



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

def generate_shared_quote_url
  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_metasObject



874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
# File 'app/controllers/www/quote_builder_controller.rb', line 874

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 = {}) ⇒ Object



718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
# File 'app/controllers/www/quote_builder_controller.rb', line 718

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_quotesObject



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

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



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
709
710
711
712
713
714
715
716
# File 'app/controllers/www/quote_builder_controller.rb', line 684

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_quoteObject



196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'app/controllers/www/quote_builder_controller.rb', line 196

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_optionsObject



678
679
680
681
682
# File 'app/controllers/www/quote_builder_controller.rb', line 678

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_lossObject



450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
# File 'app/controllers/www/quote_builder_controller.rb', line 450

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

#parse_params_and_get_optionsObject



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
608
609
610
611
612
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
# File 'app/controllers/www/quote_builder_controller.rb', line 565

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



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

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



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

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



979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
# File 'app/controllers/www/quote_builder_controller.rb', line 979

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_planObject



339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
# File 'app/controllers/www/quote_builder_controller.rb', line 339

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



808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
# File 'app/controllers/www/quote_builder_controller.rb', line 808

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

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.



1103
1104
1105
1106
1107
1108
1109
1110
1111
# File 'app/controllers/www/quote_builder_controller.rb', line 1103

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

Recursively sanitize param values to remove null bytes and invalid encodings



663
664
665
666
667
668
669
670
671
672
673
674
675
676
# File 'app/controllers/www/quote_builder_controller.rb', line 663

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_systemObject



255
256
257
258
259
260
261
262
263
264
265
266
267
# File 'app/controllers/www/quote_builder_controller.rb', line 255

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_breadcrumbObject



861
862
863
864
865
866
867
868
869
870
871
872
# File 'app/controllers/www/quote_builder_controller.rb', line 861

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

#showObject



13
14
15
16
17
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
117
118
119
# File 'app/controllers/www/quote_builder_controller.rb', line 13

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

    # This is for posting from the homepage or a landing page
    params[:room_type] ||= params[:environment] == 'Outdoor' ? params[:room_type_outdoor] : params[:room_type_indoor]
    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



901
902
903
904
905
906
907
908
909
# File 'app/controllers/www/quote_builder_controller.rb', line 901

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

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



1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
# File 'app/controllers/www/quote_builder_controller.rb', line 1050

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