Class: Www::QuoteBuilderController

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

Constant Summary collapse

HEATED_AREA_SQFT_LIMIT =
20_000
ROOM_AREA_SQFT_LIMIT =
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, #append_token, #bypass_forgery_protection?, #chat_enabled?, #cloudflare_cleared?, #default_catalog, #default_url_options, #enable_turbo_frames, #find_publication, #fix_invalid_accept_header, #init_js_utils, #is_globals_call?, #layout_by_resource, #locale_store, #redirect_to, #require_employee_for_crm, #set_base_host, #set_real_ip, #set_report_errors_for, #should_render_layout?, #stamp_impersonation_context, #warmlyyours_canada_ip?, #warmlyyours_ip?, #y

Methods included from Controllers::ReturnPathHandling

#check_for_return_path, #redirect_to_return_path_or_default

Methods included from Controllers::AnalyticsEvents

#consume_queued_analytics_events, #track_event

Methods included from Controllers::DeviceDetection

#device_detector, #is_ie?

Methods included from Controllers::SubdomainDetection

#is_crm_request?, #is_www_request?, #json_request?

Methods included from Controllers::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, #safe_referer_or_fallback

Methods included from Controllers::TurnstileVerification

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

Methods included from Controllers::CloudflareCaching

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

Methods included from Controllers::Webpackable

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

Methods included from Controllers::Localizable

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

Methods included from Controllers::Authenticable

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

Methods included from ApplicationHelper

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

Methods included from UppyUploaderHelper

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

Methods included from ImagesHelper

#image_asset_tag, #image_asset_url

Methods included from SeoHelper

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

Methods included from UrlsHelper

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

Methods included from IconHelper

#account_nav_icon, #fa_icon, #star_rating_html

Instance Method Details

#add_selections_to_cartObject



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

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



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

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



929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
# File 'app/controllers/www/quote_builder_controller.rb', line 929

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



907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
# File 'app/controllers/www/quote_builder_controller.rb', line 907

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



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

def apply_tier_to_item(item, max_discounts)
  return unless item['price'].present?

  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)


830
831
832
833
834
835
836
837
838
# File 'app/controllers/www/quote_builder_controller.rb', line 830

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.



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

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?

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

  # 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



1135
1136
1137
1138
1139
1140
# File 'app/controllers/www/quote_builder_controller.rb', line 1135

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



733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
# File 'app/controllers/www/quote_builder_controller.rb', line 733

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 { |args|
      next unless args[:length]&.positive? && args[:width]&.positive?

      { l: args[:length] * 12, w: args[:width] * 12 }
    },
    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.



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

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



840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
# File 'app/controllers/www/quote_builder_controller.rb', line 840

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



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

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



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

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.' unless sku.present?
          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.count > 0) && 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 if ord
  opp.destroy if opp
end

#finish_request_planObject



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

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



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

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



148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
# File 'app/controllers/www/quote_builder_controller.rb', line 148

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



870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
# File 'app/controllers/www/quote_builder_controller.rb', line 870

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



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

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



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

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_entity
  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_entity
  elsif params[:environment] === 'Indoor' and @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_entity
  else
    result = RoomConfiguration::CalculateQuote.call(rhc_args_from_options(@options))
    status = result.error_status.present? ? :unprocessable_entity : :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



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

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 { |args|
      next unless args[:length]&.positive? && args[:width]&.positive?

      { l: args[:length] * 12, w: args[:width] * 12 }
    },
    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



192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'app/controllers/www/quote_builder_controller.rb', line 192

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



674
675
676
677
678
# File 'app/controllers/www/quote_builder_controller.rb', line 674

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



446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
# File 'app/controllers/www/quote_builder_controller.rb', line 446

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



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

def parse_params_and_get_options
  init_current_user
  set_locale

  # If params are "null" or "", the value should be nil
  p = Normalizr.normalize(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].present?

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



775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
# File 'app/controllers/www/quote_builder_controller.rb', line 775

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



994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
# File 'app/controllers/www/quote_builder_controller.rb', line 994

def recalculate_bom_price(bom)
  return unless bom['includedItems'].present?

  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



975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
# File 'app/controllers/www/quote_builder_controller.rb', line 975

def recalculate_bundle_price(bundle)
  return unless bundle['includedItems'].present?

  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



335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
# File 'app/controllers/www/quote_builder_controller.rb', line 335

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 : nil)
  @contact_point = ContactPoint.new(detail: email, category: :email)

  unless (@options[:custom_room_plan] || @options[:zones]).present? # 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



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

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.



1101
1102
1103
1104
1105
1106
1107
1108
# File 'app/controllers/www/quote_builder_controller.rb', line 1101

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



659
660
661
662
663
664
665
666
667
668
669
670
671
672
# File 'app/controllers/www/quote_builder_controller.rb', line 659

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



251
252
253
254
255
256
257
258
259
260
261
262
263
# File 'app/controllers/www/quote_builder_controller.rb', line 251

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



857
858
859
860
861
862
863
864
865
866
867
868
# File 'app/controllers/www/quote_builder_controller.rb', line 857

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



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

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



897
898
899
900
901
902
903
904
905
# File 'app/controllers/www/quote_builder_controller.rb', line 897

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



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

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