Class: Www::ProductsController

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

Overview

Controller: products.

Constant Summary

Constants included from Controllers::MasqueradeGuarded

Controllers::MasqueradeGuarded::DEFAULT_BLOCK_MESSAGE

Constants included from Controllers::AnalyticsEvents

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

Constants included from Controllers::ErrorRendering

Controllers::ErrorRendering::NON_CONTENT_PATH_PREFIXES

Instance Method Summary collapse

Methods included from Controllers::TrackingDetection

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

Methods inherited from BasePortalController

#current_ability, #portal_party, #set_webpack

Methods included from Controllers::MasqueradeGuarded

block_while_masquerading, #masquerade_blocks?

Methods inherited from ApplicationController

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

Methods included from Controllers::ReturnPathHandling

#check_for_return_path, #redirect_to_return_path_or_default

Methods included from Controllers::AnalyticsEvents

#consume_queued_analytics_events, #registration_lead_type, #track_event

Methods included from Controllers::DeviceDetection

#device_detector, #is_ie?

Methods included from Controllers::SubdomainDetection

#is_crm_request?, #is_www_request?, #json_request?

Methods included from Controllers::TurboSafeRedirect

#redirect_to

Methods included from Controllers::AcceleratedFileSending

#send_file_accelerated, #send_upload_accelerated

Methods included from Controllers::ErrorRendering

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

Methods included from Controllers::TurnstileVerification

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

Methods included from Controllers::CloudflareCaching

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

Methods included from Controllers::Webpackable

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

Methods included from Controllers::Localizable

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

Methods included from Controllers::Authenticable

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

Methods included from UrlsHelper

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

Instance Method Details

#add_to_cartvoid

This method returns an undefined value.

Adds the requested SKU/quantity to the current user's cart.



62
63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'app/controllers/www/products_controller.rb', line 62

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

#bulk_product_dataJSON

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

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

Reads from params:

  • skus[] (Array): SKU codes (max 50)
  • fields (String): Comma-separated list of price, stock, shipping, cta
    (default: all)

Examples:

Response for fields=price

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

Returns:

  • (JSON)

    Hash with:

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


155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'app/controllers/www/products_controller.rb', line 155

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

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

  # Parse requested fields - defaults to all if not specified. Safelisted to the
  # client-requestable fields: `schema` is intentionally excluded so it can only
  # be enabled internally, per-SKU, via schema_for (below) — a raw `fields=schema`
  # must not trigger the (DB-touching) offer build for every SKU.
  allowed_fields = %w[price stock shipping cta]
  requested_fields = if params[:fields].present?
                       params[:fields].split(',').map(&:strip).map(&:downcase) & allowed_fields
                     else
                       allowed_fields
                     end

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

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

  # Offer JSON-LD is only patched onto the single Product schema on a product
  # detail page, so build it only for that SKU (schema_for) — not every card
  # SKU in the request. Each offer build hits the DB (free_shipping_regions),
  # so scoping avoids a query-per-card on listing/related-product fetches.
  # Ignore schema_for unless it's actually one of the requested SKUs, so a stray
  # value can't add the offer build nor fragment the response cache.
  schema_for = params[:schema_for].presence
  schema_for = nil unless schema_for && skus.include?(schema_for)

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

    # Set @pcp for consolidated_product_payload to use
    @pcp = Www::ProductCatalogPresenter.new(vpc, view_context)
    sku_fields = requested_fields.dup
    sku_fields << 'schema' if sku == schema_for
    products_data[sku] = consolidated_product_payload(fields: sku_fields, customer: customer)
  end

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

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

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

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

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

This method returns an undefined value.

Adds a SKU/quantity to @cart and records it in @recently_added_items.

Parameters:

  • sku_code (String)

    the item SKU to add

  • quantity (Integer, String, nil) (defaults to: nil)

    the quantity to add (defaults to 1)

  • room_configuration_id (Integer, nil) (defaults to: nil)

    optional room configuration to attach the line to



246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
# File 'app/controllers/www/products_controller.rb', line 246

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

  return if sku_code.blank?

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

  return unless sku.present? && (quantity > 0)

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

#codeObject

Legacy route: 301 redirects to new hierarchical URL



50
51
52
53
54
55
56
57
# File 'app/controllers/www/products_controller.rb', line 50

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

#indexvoid

This method returns an undefined value.

Renders the all-products listing page.



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

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

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

#lineObject

Legacy route: 301 redirects to new hierarchical URL



40
41
42
43
44
45
46
47
# File 'app/controllers/www/products_controller.rb', line 40

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

#reviewsObject

Legacy route: 301 redirects to new hierarchical URL



78
79
80
81
82
83
84
85
# File 'app/controllers/www/products_controller.rb', line 78

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

#sectionObject

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



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

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

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

  # Browser TTL, NOT the 3-day default: these fragments are fetched with a
  # `Turbo-Frame` header, which routes them through the www-edge worker's
  # handleTurboRequest branch — that branch returns the origin response as-is
  # and never applies the `Cache-Control: no-cache` + content-aware ETag
  # rewrite page HTML gets. So the default `public_cache_expires_in: 3.days`
  # became a real 3-day copy in every visitor's browser, and no edge purge
  # (CRM site_maps, admin bar, Item#purge_edge_cache — all of which DO cascade
  # to these URLs via SiteMap#section_cache_urls) could reach it. The edge TTL
  # is unchanged at 4h via Cloudflare-CDN-Cache-Control.
  set_cloudflare_cache(time_in_secs: 4.hours.to_i, public_cache_expires_in: 1.minute, tags: %w[sale product])
  # Deliberately no fresh_when: the only validator available here is @product,
  # but a section's content turns on OTHER records — compatible controls and
  # accessories, product lines, publications — so an unchanged product row
  # would 304 browsers straight back onto content a purge had just dropped.
  # The fragment is edge-cached, so revalidation costs a CDN hit, not origin.
end