Product Comparison Table — Architecture & Usage Guide
Overview
Www::ProductCompareTableComponent renders a responsive, scrollable product comparison table
used on all product controls / thermostat pages. Each product appears as a column; feature
rows run horizontally with a sticky label column on the left.
The component is driven entirely by a static SKU_FEATURES hash — no additional database
queries are needed beyond the standard product presenter load. This keeps it fast and easy
to maintain.
Used on:
floor-heating/thermostatsradiant-heat-panels/controlssnow-melting/controlstowel-warmer/controlspipe-freeze-protection/controlsroof-and-gutter-deicing/controls
Files
| File | Purpose |
|---|---|
app/components/www/product_compare_table_component.rb |
Ruby component: data loading, feature lookup, helpers |
app/components/www/product_compare_table_component.html.erb |
ERB template: 5-sub-row header + feature rows |
client/stylesheets/www/03-components/product_compare_table_component.scss |
Scoped SCSS: sticky column, cell alignment, responsive breakpoints |
Constructor Parameters
Www::ProductCompareTableComponent.new(
skus: Array, # required — ordered list of product SKUs
features: Array, # optional — feature keys to show; auto-detected if nil
section_title: String, # optional — H2 above the table
section_description: String, # optional — paragraph below the title
sort_by_price: Boolean # default: true — set false to preserve SKU array order
)
sort_by_price
By default products are sorted ascending by effective_price. Pass sort_by_price: false
when the SKU array encodes a meaningful order (e.g. thermostat tier: WiFi → Programmable →
Non-programmable → Power module). All other callers retain the default true.
Template Structure
The header is split into 4 dedicated sub-rows so that varying title / tagline lengths
never push sibling product columns out of vertical alignment:
┌────────────────────────────────────────────────────────┐
│ IMAGE ROW │ [img] │ [img] │ [img] │ [img] │ align-items: end
│ TITLE ROW │ Title │ Title │ Title │ Title │ align-items: stretch
│ TAGLINE ROW* │ tag │ tag │ tag │ tag │ align-items: stretch
│ SKU+PRICE ROW │ [sku] │ [sku] │ [sku] │ [sku] │ align-items: stretch
│ "Features" ↙ │ $249 │ $259 │ $159 │ $149 │
├────────────────┼───────┼───────┼───────┼───────────────┤
│ WiFi-Enabled │ ✓ │ ✗ │ ✗ │ ✗ │ feature rows
│ Programmable │ ✓ │ ✓ │ ✗ │ — │
│ … │ │ │ │ │
├────────────────┼───────┼───────┼───────┼───────────────┤
│ │[View] │[View] │[View] │ [View] │ CTA row
└────────────────┴───────┴───────┴───────┴───────────────┘
* Tagline row is omitted when no product has a short_description.
Feature Value Types
Each entry in SKU_FEATURES[sku][feature_key] can be:
| Value | Renders as |
|---|---|
true |
Green check-circle icon (with tooltip popover if tip defined) |
false |
Muted × icon |
String |
Small text badge (e.g. "Up to 10 schedules", "LED touch") |
:aerial / :slab |
Yellow "Pair with…" badge linking to the sensors section |
nil / absent |
"N/A" in muted text |
Sensor features (moisture_sensor, air_temp_sensor, slab_temp_sensor) additionally
render true as a green "Included" pill badge instead of a plain checkmark.
Adding a New SKU
- Add the SKU to
SKU_FEATURESinproduct_compare_table_component.rb:
'MY-SKU' => {
'wifi' => true,
'remote_support' => false,
'voice_control' => true,
'programmable' => true,
'schedules' => 'Up to 6 per day',
'floor_sensor_included' => true,
'display' => '3.5" touchscreen',
'max_load' => '15A @ 120/240V'
},
- Include the SKU in the page's SKU array:
tstat_skus = %w[UWG5-4999-WY MY-SKU UTN5-4999]
- Pass the array to the component. If the SKU has no entry in
SKU_FEATURES, all its
feature cells fall back tofalse(× icon).
Adding a New Feature Row
- Add a tooltip in
FEATURE_TOOLTIPS:
'my_feature' => 'Plain-English explanation shown on hover/focus.'
- Add a display name in
feature_display_name:
when 'my_feature' then 'My Feature Label'
-
Add the value for every relevant SKU in
SKU_FEATURES. -
Include
'my_feature'in thefeatures:array on the calling page.
Alignment Architecture
The SCSS (.product-compare-table) defines two complementary rules:
.feature-cell {
display: flex;
align-items: center;
justify-content: center; // product value cells: horizontally centred
text-align: center;
}
.sticky-column {
justify-content: flex-start; // overrides feature-cell — label column always left
text-align: left;
}
Without the .sticky-column override, short single-line labels (e.g. "WiFi-Enabled")
would appear visually centred while long wrapping labels (e.g. "Remote Troubleshooting /
Updates") appeared left — an inconsistency caused by flex centering a short inline text node.
Mobile / Responsive Behaviour
- The
table-scroll-containerusesoverflow-x: auto— the table scrolls horizontally
on narrow viewports rather than collapsing columns. - A dynamic
min-widthinline style (200 + products.size × 160 px) is applied to the
inner wrapper to prevent columns from becoming unreadably narrow before the scroll kicks in. - The sticky label column (
position: sticky; left: 0) remains visible as the user scrolls
right, keeping feature labels always in view. min-widthof the sticky column scales down at tablet / compact-laptop breakpoints:
240px→200px→180px→160px.
Floor-Heating Thermostats Page
URL
/floor-heating/thermostats
Section Order
| # | Section ID | Component / Content |
|---|---|---|
| 1 | hero | Www::FullWidthLandingPageHeaderComponent |
| 2 | benefits | Www::BenefitsListComponent — 4 universal thermostat guarantees |
| 3 | comparison | Www::ProductCompareTableComponent — thermostats + nJoin module |
| 4 | accessories | Www::ProductCardsComponent — sensor + 3 rough-in kits |
| 5 | guide | "Which thermostat is right for you?" — 3 editorial cards |
| 6 | shop-by-system | Internal cross-reference links (floor type / room / system) |
| 7 | showcases | Www::ShowcaseGridComponent — conditional on CMS content |
| 8 | videos | Www::VideoSectionComponent — conditional on CMS content |
| 9 | documents | Www::CardGridComponent — conditional on CMS content |
| 10 | posts | Www::CardGridComponent — conditional on CMS content |
| 11 | faq | Www::FaqListComponent — conditional on CMS content |
| 12 | contact | lazy_lead_form_modal |
SKU Configuration
# US market
tstat_skus = %w[UWG5-4999-WY UDG4-4999 UTN5-4999 USG5-4000]
accessory_skus = %w[FH-BACKUP-SENSOR FHE-ROUGH-IN-KIT-S1 FHE-ROUGH-IN-KIT-S2 FHE-ROUGH-IN-KIT-S3]
# Canada
tstat_skus = %w[UWG5-4999-WY UDG4-4999-WY UTN5-4999 USG-4000]
accessory_skus = %w[FH-BACKUP-SENSOR FHE-ROUGH-IN-KIT-S1 FHE-ROUGH-IN-KIT-S2 FHE-ROUGH-IN-KIT-S3]
The nJoin power module (USG5-4000 / USG-4000) appears last in the comparison table
(sort_by_price: false preserves insertion order) because it is not a thermostat — it
extends a paired thermostat's capacity for high-load zones.
Cross-Reference Section (SEO / Anti-Cannibalization)
The #shop-by-system section provides 24 internal links grouped by:
- By Floor Type (8 links) — tile, LVT, engineered wood, laminate, bamboo, nailed
hardwood, concrete, carpet - By Room (8 links) — bathroom, kitchen, bedroom, living room, basement, shower,
laundry/mudroom, sunroom - By System Type (5 links) — mats, cable, custom mats, Prodeso, underlayment
This signals to Google that /floor-heating/thermostats is about controls and the
linked pages are the authoritative source for each floor type / room — preventing keyword
overlap and reinforcing the topical hierarchy under /floor-heating.
The pattern mirrors towel-warmer/controls → /towel-warmer/* cross-references.
Tagline (Short Description)
The component reads item.short_description from the product database and surfaces it as a
muted subtitle beneath each product title in the comparison header:
tagline: presenter.item&.short_description.presence
The tagline row is omitted entirely when no product in the comparison has a short
description, keeping the header clean for legacy SKUs.