Class: Www::ProductCardsComponent

Inherits:
ApplicationComponent show all
Includes:
ActionView::Helpers::TagHelper, ActionView::Helpers::UrlHelper
Defined in:
app/components/www/product_cards_component.rb

Overview

Www::ProductCardsComponent

Renders a responsive grid of product cards. Feed it product-line slugs,
SKUs, or pre-built option hashes. The component resolves product data
(images, links, ratings, pricing) and outputs accessible, SEO-safe markup.

Data inputs (mutually exclusive, checked in this order)
options: Array of option hashes or SKU strings.
skus: Array of SKU strings, or Hash { 'SKU' => { overrides } }.
product_line_slugs: Array of slugs, or Hash { slug => { overrides } }.

Section chrome
section_title, section_description, section_icon, section_svg_icon,
group_link, group_link_title

Layout
layout: :vertical (default) or :horizontal_full
row_cols_classes: Override the default Bootstrap grid classes.
row_dom_id: DOM id for the grid container (Turbo targeting).
carousel: Render cards in a Fancyapps carousel instead of a grid.
render_only_cards: Omit wrapper markup (for Turbo Stream appends).

Card display
simple_card: Minimal card (hides description, features, whats-included).
render_schema: Add CollectionPage schema entries (default true).
cta_label: CTA button label (default "View Details").
hide_cta: Hide the CTA button entirely (default false).
open_sections: Expand accordion sections by default (default false).
include_feature_collapse: Show features as collapsible (default true, false in simple_card).
show_page_description: Show short description (default true, false in simple_card).
show_price: Show price in footer (default true).
include_sku: Show SKU under title (default true).
include_whats_included: Show "What's Included?" (default true, false in simple_card).
image_fit: :contain (default, pad + bg-light) or nil (natural sizing).
motion: Per-card fadeInUp entrance animation (default true). Pass
false where the cards are swapped in a Turbo Frame and a
per-card slide-up would read as jumpy (e.g. towel-warmer).

Examples

Simple:
render Www::ProductCardsComponent.new(
simple_card: true,
product_line_slugs: [ProductLineUrls::FLOOR_HEATING_TEMPZONE_FLEX_ROLL]
)

Full featured:
render Www::ProductCardsComponent.new(
product_line_slugs: [ProductLineUrls::FLOOR_HEATING_CONTROL],
include_feature_collapse: true,
open_sections: true,
show_price: true,
include_sku: false
)

Constant Summary collapse

DEFAULT_ICON =

Default icon.

'box'.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods inherited from ApplicationComponent

#cms_link, #fetch_or_fallback, #image_asset_tag, #image_tag, #number_to_currency, #number_with_delimiter, #post_path, #post_url, #strip_tags

Constructor Details

#initialize(options: nil, product_line_slugs: nil, skus: nil, section_title: nil, section_icon: nil, section_svg_icon: nil, section_description: nil, group_link: nil, group_link_title: nil, simple_card: false, render_schema: true, cta_label: 'View Details', cta_modal_target: nil, row_cols_classes: nil, render_only_cards: false, layout: :vertical, carousel: false, row_dom_id: nil, show_page_description: nil, show_price: true, include_sku: true, include_whats_included: nil, image_fit: :contain, motion: true, hide_cta: false, open_sections: false, include_feature_collapse: nil) ⇒ ProductCardsComponent

Returns a new instance of ProductCardsComponent.



71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
# File 'app/components/www/product_cards_component.rb', line 71

def initialize(options: nil, product_line_slugs: nil, skus: nil,
               section_title: nil,
               section_icon: nil,
               section_svg_icon: nil,
               section_description: nil,
               group_link: nil, group_link_title: nil,
               simple_card: false,
               render_schema: true, cta_label: 'View Details',
               cta_modal_target: nil,
               row_cols_classes: nil,
               render_only_cards: false,
               layout: :vertical,
               carousel: false,
               row_dom_id: nil,
               show_page_description: nil,
               show_price: true,
               include_sku: true,
               include_whats_included: nil,
               image_fit: :contain,
               motion: true,
               hide_cta: false,
               open_sections: false,
               include_feature_collapse: nil)
  @section_title = section_title
  @section_icon = section_svg_icon.present? ? nil : (section_icon || DEFAULT_ICON)
  @section_svg_icon = section_svg_icon
  @section_description = section_description
  @group_link = group_link
  @group_link_title = group_link_title
  @simple_card = simple_card
  @render_schema = render_schema
  @cta_label = cta_label
  @cta_modal_target = cta_modal_target
  @layout = layout.to_sym

  @hide_cta = hide_cta
  @open_sections = open_sections
  @include_feature_collapse = include_feature_collapse.nil? ? !simple_card : include_feature_collapse
  @render_only_cards = render_only_cards

  @row_cols_classes = row_cols_classes || default_row_cols_classes

  @show_page_description = show_page_description.nil? ? !simple_card : show_page_description
  @include_whats_included = include_whats_included.nil? ? !simple_card : include_whats_included
  @include_sku = include_sku
  @show_price = show_price
  @motion = motion
  @image_fit = image_fit

  if @layout == :horizontal_full
    @include_feature_collapse = false
    @open_sections = true
  end

  # Defer resolving options until we have a view context (helpers) available
  @init_options = Array(options).compact.presence
  @init_skus = skus
  # Accept either an Array of slugs or a Hash of { slug => accessory_map }
  @slug_overrides = {}
  if product_line_slugs.is_a?(Hash)
    @init_product_line_slugs = product_line_slugs.keys.map(&:to_s)
    @slug_overrides = product_line_slugs.transform_keys(&:to_s)
  else
    @init_product_line_slugs = Array(product_line_slugs).compact.map(&:to_s)
  end
  @locale = I18n.locale
  @options = []
  @carousel = carousel
  @row_dom_id = row_dom_id

  super()
end

Instance Attribute Details

Returns the value of attribute carousel.



62
63
64
# File 'app/components/www/product_cards_component.rb', line 62

def carousel
  @carousel
end

#cta_labelObject (readonly)

Returns the value of attribute cta_label.



62
63
64
# File 'app/components/www/product_cards_component.rb', line 62

def cta_label
  @cta_label
end

#cta_modal_targetObject (readonly)

Returns the value of attribute cta_modal_target.



62
63
64
# File 'app/components/www/product_cards_component.rb', line 62

def cta_modal_target
  @cta_modal_target
end

Returns the value of attribute group_link.



62
63
64
# File 'app/components/www/product_cards_component.rb', line 62

def group_link
  @group_link
end

Returns the value of attribute group_link_title.



62
63
64
# File 'app/components/www/product_cards_component.rb', line 62

def group_link_title
  @group_link_title
end

#hide_ctaObject (readonly)

Returns the value of attribute hide_cta.



62
63
64
# File 'app/components/www/product_cards_component.rb', line 62

def hide_cta
  @hide_cta
end

#image_fitObject (readonly)

Returns the value of attribute image_fit.



62
63
64
# File 'app/components/www/product_cards_component.rb', line 62

def image_fit
  @image_fit
end

#include_feature_collapseObject (readonly)

Returns the value of attribute include_feature_collapse.



62
63
64
# File 'app/components/www/product_cards_component.rb', line 62

def include_feature_collapse
  @include_feature_collapse
end

#include_skuObject (readonly)

Returns the value of attribute include_sku.



62
63
64
# File 'app/components/www/product_cards_component.rb', line 62

def include_sku
  @include_sku
end

#include_whats_includedObject (readonly)

Returns the value of attribute include_whats_included.



62
63
64
# File 'app/components/www/product_cards_component.rb', line 62

def include_whats_included
  @include_whats_included
end

#layoutObject (readonly)

Returns the value of attribute layout.



62
63
64
# File 'app/components/www/product_cards_component.rb', line 62

def layout
  @layout
end

#motionObject (readonly)

Returns the value of attribute motion.



62
63
64
# File 'app/components/www/product_cards_component.rb', line 62

def motion
  @motion
end

#open_sectionsObject (readonly)

Returns the value of attribute open_sections.



62
63
64
# File 'app/components/www/product_cards_component.rb', line 62

def open_sections
  @open_sections
end

#optionsObject (readonly)

Returns the value of attribute options.



62
63
64
# File 'app/components/www/product_cards_component.rb', line 62

def options
  @options
end

#render_only_cardsObject (readonly)

Returns the value of attribute render_only_cards.



62
63
64
# File 'app/components/www/product_cards_component.rb', line 62

def render_only_cards
  @render_only_cards
end

#render_schemaObject (readonly)

Returns the value of attribute render_schema.



62
63
64
# File 'app/components/www/product_cards_component.rb', line 62

def render_schema
  @render_schema
end

#row_cols_classesObject (readonly)

Returns the value of attribute row_cols_classes.



62
63
64
# File 'app/components/www/product_cards_component.rb', line 62

def row_cols_classes
  @row_cols_classes
end

#row_dom_idObject (readonly)

Returns the value of attribute row_dom_id.



62
63
64
# File 'app/components/www/product_cards_component.rb', line 62

def row_dom_id
  @row_dom_id
end

#section_descriptionObject (readonly)

Returns the value of attribute section_description.



62
63
64
# File 'app/components/www/product_cards_component.rb', line 62

def section_description
  @section_description
end

#section_iconObject (readonly)

Returns the value of attribute section_icon.



62
63
64
# File 'app/components/www/product_cards_component.rb', line 62

def section_icon
  @section_icon
end

#section_svg_iconObject (readonly)

Returns the value of attribute section_svg_icon.



62
63
64
# File 'app/components/www/product_cards_component.rb', line 62

def section_svg_icon
  @section_svg_icon
end

#section_titleObject (readonly)

Returns the value of attribute section_title.



62
63
64
# File 'app/components/www/product_cards_component.rb', line 62

def section_title
  @section_title
end

#show_page_descriptionObject (readonly)

Returns the value of attribute show_page_description.



62
63
64
# File 'app/components/www/product_cards_component.rb', line 62

def show_page_description
  @show_page_description
end

#show_priceObject (readonly)

Returns the value of attribute show_price.



62
63
64
# File 'app/components/www/product_cards_component.rb', line 62

def show_price
  @show_price
end

#simple_cardObject (readonly)

Returns the value of attribute simple_card.



62
63
64
# File 'app/components/www/product_cards_component.rb', line 62

def simple_card
  @simple_card
end

Instance Method Details

#before_renderObject



191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
# File 'app/components/www/product_cards_component.rb', line 191

def before_render
  resolved_options =
    if @init_options.present?
      normalize_options(@init_options, @locale)
    elsif @init_skus.is_a?(Hash) && @init_skus.any?
      normalize_sku_hash(@init_skus, @locale)
    elsif Array(@init_skus).compact.any?
      normalize_options(Array(@init_skus).compact, @locale)
    else
      build_options_from_slugs(@init_product_line_slugs, @locale)
    end

  @options = resolved_options
  @options = Array(@options).map { |opt| opt.merge(open_sections: true) } if @open_sections
end

#card_component_for(option, index:, motion_override: motion) ⇒ Object



164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'app/components/www/product_cards_component.rb', line 164

def card_component_for(option, index:, motion_override: motion)
  Www::ProductCardComponent.new(
    option: option,
    index: index,
    simple_card: simple_card,
    include_sku: include_sku,
    include_feature_collapse: include_feature_collapse,
    include_whats_included: include_whats_included,
    show_page_description: show_page_description,
    show_price: show_price,
    hide_cta: hide_cta,
    cta_label: cta_label,
    cta_modal_target: cta_modal_target,
    open_sections: option[:open_sections] || open_sections,
    render_schema: render_schema,
    layout: layout,
    motion: motion_override,
    image_fit: image_fit
  )
end


207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
# File 'app/components/www/product_cards_component.rb', line 207

def carousel_config
  count = @options.size
  # Breakpoints mirror CardGridComponent (posts/videos) so product cards
  # maintain the same visual rhythm across all content carousels on a page.
  {
    type:       'slide',
    perPage:    [4, count].min,
    perMove:    1,
    gap:        '1rem',
    pagination: true,
    arrows:     true,
    drag:       true,
    padding:    count > 4 ? { right: '4%' } : { right: '0' },
    breakpoints: {
      1399 => { perPage: [4, count].min, padding: count > 4 ? { right: '4%' } : { right: '0' } },
      1199 => { perPage: [3, count].min, padding: count > 3 ? { right: '5%' } : { right: '0' } },
      991  => { perPage: [3, count].min, padding: count > 3 ? { right: '5%' } : { right: '0' } },
      767  => { perPage: [2, count].min, padding: count > 2 ? { right: '8%' } : { right: '0' } },
      575  => { perPage: 1,              padding: count > 1 ? { right: '18%' } : { right: '0' }, arrows: false }
    }
  }
end

#catalog_card_image_asset_slug(presenter) ⇒ Object



185
186
187
188
189
# File 'app/components/www/product_cards_component.rb', line 185

def catalog_card_image_asset_slug(presenter)
  return nil unless presenter.is_a?(Www::ProductCatalogPresenter) && presenter.item

  presenter.image_asset_slug_for_card
end

#resolve_option(option) ⇒ Object



144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
# File 'app/components/www/product_cards_component.rb', line 144

def resolve_option(option)
  return option unless option[:sku].present? && option[:presenter].blank?

  record = ViewProductCatalog.find_by(item_sku: option[:sku], catalog_id: Catalog.locale_to_catalog_id(I18n.locale))
  unless record
    Rails.logger.warn "[ProductCardsComponent] SKU not found in catalog: #{option[:sku]}"
    return nil
  end

  presenter = Www::ProductCatalogPresenter.new(record)
  {
    title: presenter.title,
    link: presenter.url,
    image_asset_id: catalog_card_image_asset_slug(presenter),
    review_data: presenter.product_review_info,
    presenter: presenter,
    page_description: presenter.page_description
  }.merge(option)
end