Module: TowelWarmerFilterSlugs

Extended by:
ActiveSupport::Concern
Included in:
Www::TowelWarmerFiltersComponent, Www::TowelWarmersController
Defined in:
app/models/concerns/towel_warmer_filter_slugs.rb

Overview

TowelWarmerFilterSlugs

Provides URL slug mappings for SEO-friendly towel warmer filter URLs.
Maps human-readable slugs to database values for Ransack filtering.

URL Structure: /towel-warmer/finish/style/connection/mounting/size

Examples:
/towel-warmer/brushed-gold
/towel-warmer/brushed-gold/round-bars
/towel-warmer/brushed-gold/round-bars/hardwired
/towel-warmer/matte-black/square-bars/plug-in/wall-mounted
/towel-warmer/compact
/towel-warmer/large

Constant Summary collapse

FINISH_SLUGS =

Finish slugs -> database values

{
  'brushed-stainless-steel' => 'Brushed Stainless Steel',
  'polished-stainless-steel' => 'Polished Stainless Steel',
  'matte-black' => 'Matte Black',
  'brushed-gold' => 'Brushed Gold',
  'polished-gold' => 'Polished Gold'
}.freeze
COMPOSITE_FINISH_SLUGS =

Composite finish slugs that match multiple finishes (OR logic).
These appear in SLUG_TO_RANSACK but not in FINISH_SLUGS so individual
finish dropdowns/counts still work on a per-finish basis.

{
  'gold' => %w[Brushed\ Gold Polished\ Gold]
}.freeze
STYLE_SLUGS =

Style (bar shape) slugs -> database values
Keys are URL slugs, values are database values

{
  'round-bars' => 'Round',
  'square-bars' => 'Square'
}.freeze
STYLE_LABELS =

Human-readable labels for style options

{
  'round-bars' => 'Round Bars',
  'square-bars' => 'Square Bars'
}.freeze
CONNECTION_SLUGS =

Connection method slugs -> database values

{
  'hardwired' => 'Hardwired',
  'plug-in' => 'Plug-in',
  'plug-in-or-hardwired' => 'Plug-in or Hardwired'
}.freeze
CONNECTION_LABELS =

Human-readable labels for connection options

{
  'hardwired' => 'Hardwired',
  'plug-in' => 'Plug-in',
  'plug-in-or-hardwired' => 'Plug-in or Hardwired'
}.freeze
MOUNTING_SLUGS =

Mounting method slugs -> database values
Note: Database uses "Wall Mounted" (no hyphen) and "Free standing" (space)

{
  'wall-mounted' => 'Wall Mounted',
  'freestanding' => 'Freestanding'
}.freeze
MOUNTING_LABELS =

Human-readable labels for mounting options

{
  'wall-mounted' => 'Wall Mounted',
  'freestanding' => 'Freestanding'
}.freeze
SIZE_SLUGS =

Size tier slugs — map to size_classification spec values (set by towel_warmer_size_classification.rb)
Classification is based on physical height: Compact ≤ 30", Standard 30–40", Large > 40"

%w[compact standard large].freeze
SIZE_LABELS =

Slug -> size_classification spec value (stored in product_specifications JSONB)

{
  'compact'  => 'Compact',
  'standard' => 'Standard',
  'large'    => 'Large'
}.freeze
SLUG_TO_RANSACK =

Combined mapping of all slugs to their Ransack parameters
Used by the controller to parse URL segments

{
  # Finishes
  'brushed-stainless-steel' => { spec_finish_eq: 'Brushed Stainless Steel' },
  'polished-stainless-steel' => { spec_finish_eq: 'Polished Stainless Steel' },
  'matte-black' => { spec_finish_eq: 'Matte Black' },
  'brushed-gold' => { spec_finish_eq: 'Brushed Gold' },
  'polished-gold' => { spec_finish_eq: 'Polished Gold' },
  # Composite finish: matches both Brushed Gold and Polished Gold
  'gold' => { spec_finish_in: COMPOSITE_FINISH_SLUGS['gold'] },
  # Styles
  'round-bars' => { spec_bar_shape_eq: 'Round' },
  'square-bars' => { spec_bar_shape_eq: 'Square' },
  # Connection
  'hardwired' => { spec_connection_method_eq: 'Hardwired' },
  'plug-in' => { spec_connection_method_eq: 'Plug-in' },
  'plug-in-or-hardwired' => { spec_connection_method_eq: 'Plug-in or Hardwired' },
  # Mounting - Note: database uses "Wall Mounted" (no hyphen)
  'wall-mounted' => { spec_mounting_method_eq: 'Wall Mounted' },
  'freestanding' => { spec_mounting_method_eq: 'Freestanding' },
  # Size tiers - range-based (uses custom ransacker)
  'compact'  => { spec_size_eq: 'compact' },
  'standard' => { spec_size_eq: 'standard' },
  'large'    => { spec_size_eq: 'large' }
}.freeze
RANSACK_TO_SLUG =

Reverse mapping: database values to slugs (for URL generation)

{
  # Finishes
  'Brushed Stainless Steel' => 'brushed-stainless-steel',
  'Polished Stainless Steel' => 'polished-stainless-steel',
  'Matte Black' => 'matte-black',
  'Brushed Gold' => 'brushed-gold',
  'Polished Gold' => 'polished-gold',
  # Styles
  'Round' => 'round-bars',
  'Square' => 'square-bars',
  # Connection
  'Hardwired' => 'hardwired',
  'Plug-in' => 'plug-in',
  'Plug-in or Hardwired' => 'plug-in-or-hardwired',
  # Mounting
  'Wall Mounted' => 'wall-mounted',
  'Freestanding' => 'freestanding',
  # Size tiers (slugs map to themselves)
  'compact'  => 'compact',
  'standard' => 'standard',
  'large'    => 'large'
}.freeze

Class Method Summary collapse

Class Method Details

.available_filter_counts(base_scope, filter_type, current_filters = {}) ⇒ Hash

Calculate available filter counts for a given filter type based on current results
Uses a single GROUP BY query per filter type to avoid N+1 queries

Parameters:

  • base_scope (ActiveRecord::Relation)

    The base query scope

  • filter_type (Symbol)

    :finish, :style, :connection, :mounting, or :size

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

    Current active Ransack filter params

Returns:

  • (Hash)

    Hash of => count



260
261
262
263
264
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
306
307
308
309
310
311
312
313
314
315
# File 'app/models/concerns/towel_warmer_filter_slugs.rb', line 260

def available_filter_counts(base_scope, filter_type, current_filters = {})
  # Build a scope without the current filter type applied
  keys_to_strip = filter_keys_for_type(filter_type)
  other_filters = current_filters.reject { |k, _| keys_to_strip.include?(k) }
  scope_without_this_filter = base_scope.ransack(other_filters).result

  # Handle size specially since it's range-based
  if filter_type == :size
    return size_filter_counts(scope_without_this_filter)
  end

  # Get the JSONB field path for this filter type
  jsonb_field = case filter_type
                when :finish then "product_specifications -> 'finish' ->> 'raw'"
                when :style then "product_specifications -> 'bar_shape' ->> 'raw'"
                when :connection then "product_specifications -> 'connection_method' ->> 'raw'"
                when :mounting then "product_specifications -> 'mounting_method' ->> 'raw'"
                else return {}
                end

  # Get the slug-to-value mapping for this filter type
  slug_hash = case filter_type
              when :finish then FINISH_SLUGS
              when :style then STYLE_SLUGS
              when :connection then CONNECTION_SLUGS
              when :mounting then MOUNTING_SLUGS
              else {}
              end

  # Single GROUP BY query to get all counts
  raw_counts = scope_without_this_filter
                 .group(Arel.sql(jsonb_field))
                 .count

  # Map database values to slugs
  slug_hash.each_with_object({}) do |(slug, db_value), counts|
    # Direct match for the value
    count = raw_counts[db_value] || 0

    # For connection type, also add dual-connection products to Plug-in and Hardwired counts
    if filter_type == :connection && %w[Plug-in Hardwired].include?(db_value)
      dual_count = raw_counts['Plug-in or Hardwired'] || 0
      count += dual_count
    end

    # For mounting, handle partial matches (e.g., "Wall Mounted (20\" x 20 1/2\")" matches "Wall Mounted")
    if filter_type == :mounting
      raw_counts.each do |raw_value, raw_count|
        next if raw_value == db_value # Already counted
        count += raw_count if raw_value.to_s.start_with?(db_value)
      end
    end

    counts[slug] = count
  end
end

.build_filter_path(ransack_params) ⇒ String

Build URL path from Ransack parameters

Examples:

build_filter_path({ spec_finish_eq: 'Brushed Gold', spec_bar_shape_eq: 'Round' })
# => "brushed-gold/round-bars"

Parameters:

  • ransack_params (Hash)

    Current Ransack filter params

Returns:

  • (String)

    URL path segments joined by /



175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
# File 'app/models/concerns/towel_warmer_filter_slugs.rb', line 175

def build_filter_path(ransack_params)
  return '' if ransack_params.blank?

  segments = []

  # Order matters for consistent URLs: finish, style, connection, mounting, size
  if (finishes = ransack_params[:spec_finish_in])
    composite_slug = COMPOSITE_FINISH_SLUGS.find { |_slug, vals| vals.sort == Array(finishes).sort }&.first
    segments << composite_slug if composite_slug
  elsif (finish = ransack_params[:spec_finish_eq])
    segments << RANSACK_TO_SLUG[finish] if RANSACK_TO_SLUG[finish]
  end

  if (style = ransack_params[:spec_bar_shape_eq])
    segments << RANSACK_TO_SLUG[style] if RANSACK_TO_SLUG[style]
  end

  if (connection = ransack_params[:spec_connection_method_eq])
    segments << RANSACK_TO_SLUG[connection] if RANSACK_TO_SLUG[connection]
  end

  if (mounting = ransack_params[:spec_mounting_method_eq])
    segments << RANSACK_TO_SLUG[mounting] if RANSACK_TO_SLUG[mounting]
  end

  if (size = ransack_params[:spec_size_eq])
    segments << RANSACK_TO_SLUG[size] if RANSACK_TO_SLUG[size]
  end

  segments.join('/')
end

.filter_options_with_slugs(filter_type, available_counts: nil) ⇒ Array<Array>

Generate select options with slug values for the filter component

Parameters:

  • filter_type (Symbol)

    :finish, :style, :connection, :mounting, or :size

  • available_counts (Hash) (defaults to: nil)

    Optional hash of => count for dynamic filtering

Returns:

  • (Array<Array>)

    Options array for select helper [[label, slug, disabled], ...]



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/models/concerns/towel_warmer_filter_slugs.rb', line 214

def filter_options_with_slugs(filter_type, available_counts: nil)
  options = case filter_type
            when :finish
              FINISH_SLUGS.map { |slug, db_value| [db_value, slug] }
            when :style
              STYLE_LABELS.map { |slug, label| [label, slug] }
            when :connection
              CONNECTION_LABELS.map { |slug, label| [label, slug] }
            when :mounting
              MOUNTING_LABELS.map { |slug, label| [label, slug] }
            when :size
              SIZE_LABELS.map { |slug, label| [label, slug] }
            else
              []
            end

  # Add disabled status if counts are provided
  if available_counts
    options = options.map do |label, slug|
      count = available_counts[slug] || 0
      [label, slug, { disabled: count.zero? }]
    end
  end

  # Add "All" option at the beginning
  all_label = case filter_type
              when :finish then 'All Finishes'
              when :style then 'All Styles'
              when :connection then 'All Connection Types'
              when :mounting then 'All Mounting Types'
              when :size then 'All Sizes'
              else 'All'
              end

  [[all_label, '']] + options
end

.parse_filter_segments(filter_path) ⇒ Hash

Parse URL filter segments into Ransack parameters

Examples:

parse_filter_segments("brushed-gold/round-bars")
# => { spec_finish_eq: 'Brushed Gold', spec_bar_shape_eq: 'Round' }

Parameters:

  • filter_path (String)

    The filter path (e.g., "brushed-gold/round-bars")

Returns:

  • (Hash)

    Ransack parameters



154
155
156
157
158
159
160
161
162
163
# File 'app/models/concerns/towel_warmer_filter_slugs.rb', line 154

def parse_filter_segments(filter_path)
  return {} if filter_path.blank?

  segments = filter_path.to_s.split('/').map(&:strip).reject(&:blank?)
  segments.each_with_object({}) do |segment, params|
    if (ransack_params = SLUG_TO_RANSACK[segment])
      params.merge!(ransack_params)
    end
  end
end

.size_filter_counts(scope) ⇒ Hash

Calculate counts for each size tier using a single GROUP BY on size_classification spec

Parameters:

  • scope (ActiveRecord::Relation)

    The filtered scope

Returns:

  • (Hash)

    Hash of => count



322
323
324
325
326
327
328
329
330
331
# File 'app/models/concerns/towel_warmer_filter_slugs.rb', line 322

def size_filter_counts(scope)
  raw_counts = scope
                 .where.not("product_specifications -> 'size_classification' ->> 'raw' IS NULL")
                 .group(Arel.sql("lower(product_specifications -> 'size_classification' ->> 'raw')"))
                 .count

  SIZE_SLUGS.each_with_object({}) do |slug, counts|
    counts[slug] = raw_counts[slug] || 0
  end
end