Skip to content

Commit 767b19f

Browse files
authored
Merge pull request #285 from RivianTrackr/feature/individual-tire-pages
Individual tire pages + SEO (2.0 Pillar 2, Phase 2A)
2 parents e4005f8 + 6d804d6 commit 767b19f

15 files changed

Lines changed: 1056 additions & 147 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ All notable changes to the Rivian Tire Guide plugin will be documented in this f
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

7+
## [1.52.0] - 2026-07-02
8+
9+
### Added
10+
- **Individual, crawlable tire pages (2.0 Pillar 2, Phase 2A).** Every tire now has a server-rendered, indexable URL at `/tires/{slug}/` — tire content (specs, price, Roamer efficiency, owner reviews) is in the initial HTML, not injected by JS. Adds a `slug` column (migration 17, auto-generated from brand+model+size and backfilled), a new `RTG_Tire_Page` route mirroring the standalone review-page pattern, per-tire `<title>`/meta/canonical/OG tags, and dedicated `Product` + `BreadcrumbList` JSON-LD (the `RTG_Schema` product builder was factored out for reuse instead of only emitting a catalog-wide `ItemList`). Tire-card titles now link to these pages; legacy `/tires/{tire_id}/` URLs 301 to the canonical slug. Remaining SEO work (sitemap entries, `?tire=` canonical consolidation, admin slug setting, on-page click tracking) is tracked in ROADMAP-2.0.md.
11+
712
## [1.51.0] - 2026-07-02
813

914
### Changed

ROADMAP-2.0.md

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
# Rivian Tire Guide — 2.0.0 Roadmap
2+
3+
> Status: proposal / planning. Current shipping version: **1.51.0**.
4+
> This document captures the findings of a full-codebase review (frontend/UX,
5+
> backend/data/AI, reviews/admin/SEO/analytics) and lays out what earns a
6+
> major-version release, with a detailed implementation plan for the chosen
7+
> first pillar (**Individual Tire Pages + SEO**).
8+
9+
---
10+
11+
## Positioning: what 2.0 means here
12+
13+
Today the plugin is a solid, well-architected **tire catalog you browse** — clean
14+
REST API, real-world Roamer efficiency data, moderated reviews, genuine
15+
click/search analytics, prepared statements throughout. A `2.0` (semver major)
16+
should be a landmark, not polish. The through-line:
17+
18+
> **From "tire catalog you browse" → "tire advisor that guides you," with every
19+
> tire individually discoverable on the open web.**
20+
21+
### Headline finding — the advertised AI feature does not exist
22+
23+
The workspace docs describe "AI-powered recommendations (Claude API)," but the
24+
review found **no Claude/Anthropic integration anywhere in the code**. The only
25+
trace is an analytics label ("Top AI Queries") and a `search_type` column in
26+
`wp_rtg_search_events` that is never populated with `'ai'`. A major version is
27+
the right moment to either **build the AI advisor for real** or **remove the
28+
claim**. Building it is the stronger move (see Pillar 1).
29+
30+
---
31+
32+
## The four pillars
33+
34+
| # | Pillar | Why it's a 2.0 feature |
35+
|---|--------|------------------------|
36+
| 1 | **AI Tire Advisor** | Delivers the advertised-but-missing feature. Conversational "best winter tires for my R1T under $300" → structured picks + reasoning. Rate-limit buckets, `search_type='ai'`, and transient caching are already scaffolded. |
37+
| 2 | **Individual Tire Pages + SEO***(first focus)* | Biggest organic-growth lever. Every tire is currently trapped inside one shortcode with no indexable URL. Dedicated `Product` pages + schema + sitemap unlock search traffic. |
38+
| 3 | **Reviews → community & trust** | Turns one-off reviews into a retention engine: verified-owner badges, photos, helpful votes, multi-axis ratings, AI-assisted moderation. |
39+
| 4 | **Accessibility + UX baseline** | The quality bar of a 2.0, and the highest-count gap area (13 a11y issues). Focus traps, focus rings, ARIA live regions, reduced-motion, live search. |
40+
41+
---
42+
43+
### Pillar 1 — AI Tire Advisor *(highest leverage; deferred to a later phase)*
44+
45+
Build a real Claude-powered recommender that turns natural-language questions
46+
into structured, explained tire picks over the live catalog.
47+
48+
- New endpoint, e.g. `POST /wp-json/rtg/v2/recommend``{ picks: [{tire_id, reasoning, confidence}], alternatives: [...] }`.
49+
- Claude structured output (tool_use / JSON schema) grounded in the catalog + user prefs (vehicle, budget, conditions).
50+
- Hybrid fallback to the existing filter engine for simple queries or on API failure.
51+
- Reuse the existing per-fingerprint rate limiter (`class-rtg-ajax.php`) and transient caching keyed by normalized query + vehicle.
52+
- Emit `search_type='ai'` events so the existing analytics "AI Queries" surfaces become real.
53+
- API key via `wp-config.php` constant; track token cost; use the latest Claude model.
54+
55+
**Enables:** AI-assisted review moderation (Pillar 3), smarter Roamer match
56+
suggestions.
57+
58+
---
59+
60+
### Pillar 2 — Individual Tire Pages + SEO ⭐ *(detailed plan below)*
61+
62+
Give every tire a crawlable, server-rendered URL (`/tires/{slug}/`) with a
63+
dedicated `Product` schema, canonical, per-tire OG/meta, breadcrumbs, and a
64+
sitemap — replacing today's JS-only `?tire=` deep links.
65+
66+
See **"Detailed plan: Pillar 2"** below.
67+
68+
---
69+
70+
### Pillar 3 — Reviews → community & trust
71+
72+
- **Verified-owner** badge (WP user meta / attestation) — credibility today is zero; anyone can review any tire.
73+
- **Review photos** (moderated upload) and **"Was this helpful?"** votes (adds a moderation + sort signal).
74+
- **Multi-axis ratings**: wet grip, snow grip, noise, comfort, wear — the axes shoppers actually research, vs a single star.
75+
- **Frontend review sorting/filtering** (Most Helpful / Recent / Highest, filter by vehicle) — the reviews drawer is an unsorted list today.
76+
- **AI-assisted moderation**: toxicity/spam/duplicate scoring on the pending queue (pairs with Pillar 1).
77+
- Light community surface: top reviewers, review counts, public review history.
78+
79+
Anchor files: `includes/class-rtg-reviews.php`, `includes/class-rtg-database.php`
80+
(ratings CRUD ~L1070–1159, moderation ~L1687–1703), `frontend/js/modules/ratings.js`,
81+
`admin/views/reviews-list.php`.
82+
83+
---
84+
85+
### Pillar 4 — Accessibility + UX baseline
86+
87+
**Accessibility (WCAG):**
88+
- Modal focus traps + Escape + focus-return on close (review, compare, tooltip, image modals).
89+
- Visible `:focus-visible` rings on all interactive elements (some buttons `outline:none`).
90+
- `aria-live` on the tire count / result count, `aria-busy` during async fetch.
91+
- `prefers-reduced-motion` must disable **all** animation (tooltip/drawer currently animate regardless).
92+
- `aria-describedby` wiring for guest-review privacy note; verify star-rating SR semantics and hover contrast.
93+
94+
**UX polish:**
95+
- Live search autocomplete/preview (search is button/Enter-only today).
96+
- Cascade feedback when vehicle selection narrows the size dropdown (silent today).
97+
- Adaptive price-slider bounds (hardcoded `$600` max regardless of data).
98+
- Mobile filter-drawer keyboard management + persistent state.
99+
- Distinguish "no tires exist" vs "filters too restrictive" in the empty state.
100+
101+
Anchor files: `frontend/js/rivian-tires.js`, `frontend/js/modules/{filters,ratings,search,tooltips,server}.js`,
102+
`frontend/css/rivian-tires.css`.
103+
104+
---
105+
106+
## Supporting work (fold in; don't headline)
107+
108+
- **Engagement:** saved & shareable comparisons (compare state isn't shareable), saved searches, price-drop / new-tire email alerts (`class-rtg-mailer.php` already exists).
109+
- **Admin productivity:** bulk **edit** (only bulk delete exists), CSV import dry-run/preview, insight cards ("N tires missing images/links").
110+
- **Architecture (enables the pillars):**
111+
- Split the 2,251-line `RTG_Database` god class into query/service classes.
112+
- Add `do_action('rtg_tire_*')` / `rtg_review_*` extensibility hooks.
113+
- Introduce an `RTG_Cache` wrapper (optional Redis/object-cache support).
114+
- **Retire the fragile 28-element array row format** (`row[24]` magic indices scattered across JS) in favor of keyed objects — the single riskiest foundation for new features.
115+
116+
## Explicitly deferred past 2.0
117+
118+
GraphQL layer, A/B-testing framework, REST auth/webhooks, session replay,
119+
retailer price-scraping (ToS/vendor risk). Real, but none load-bearing for the
120+
2.0 narrative — they'd dilute it.
121+
122+
---
123+
124+
## Detailed plan: Pillar 2 — Individual Tire Pages + SEO
125+
126+
### Problem
127+
128+
- A tire is only reachable via the `[rivian_tire_guide]` shortcode page; the
129+
"detail view" is a client-side `?tire=<id>` deep link.
130+
- `RTG_Meta` emits per-tire OG/Twitter tags for `?tire=`, but the canonical URL
131+
is the query-arg URL — not a clean, indexable path.
132+
- `RTG_Schema` emits one big `ItemList` of every tire on the catalog page.
133+
Google strongly prefers a dedicated **`Product`** page per item for product
134+
rich results.
135+
- Net effect: **zero individually rankable tire URLs.** For a tire guide, that
136+
is the largest missed organic-search opportunity.
137+
138+
### Goal
139+
140+
Every tire gets a stable, server-rendered, indexable URL:
141+
`https://site/tires/{brand}-{model}-{size}/` (canonical), carrying dedicated
142+
`Product` + `Review` + `BreadcrumbList` schema, per-tire `<title>`/meta/OG, and
143+
a sitemap entry — while the existing catalog and `?tire=` deep links keep
144+
working (301 → the new URL).
145+
146+
### Approach — mirror the existing standalone-page pattern
147+
148+
`RTG_Tire_Review` (`includes/class-rtg-tire-review.php`) is the template to copy:
149+
`add_rewrite_rule``query_vars``template_redirect` (render + `exit`), a
150+
one-shot `rtg_flush_rewrite` option to flush on activation/settings change, and
151+
a settings-driven slug. The new work is the same shape, plus **server-rendered
152+
HTML** (SEO requires the tire content in the initial response, not injected by JS).
153+
154+
### Phase 2A — Routing + server-rendered page (the core SEO win)
155+
156+
**New:** `includes/class-rtg-tire-page.php` (`RTG_Tire_Page`)
157+
- Rewrite: `^{tires_slug}/([^/]+)/?$``index.php?rtg_tire=$matches[1]` (default slug `tires`, configurable like `tire_review_slug`).
158+
- Resolve the path segment to a tire. Add a stable, human-readable **`slug`
159+
column** to `wp_rtg_tires` (migration 17) generated from brand+model+size via
160+
`sanitize_title`, with a uniqueness suffix; keep resolving by raw `tire_id` as
161+
a fallback so nothing 404s during backfill.
162+
- On no match → proper WP 404 (`$wp_query->set_404()` + status header), never a
163+
soft-200.
164+
- Reuse the `RTG_Tire_Review` security-header block (X-Content-Type-Options,
165+
X-Frame-Options, CSP, Referrer-Policy).
166+
167+
**New:** `frontend/templates/tire-page.php` — server-rendered
168+
- Full `<h1>` (brand model size), specs table, price/offer, category, warranty,
169+
Roamer real-world efficiency, and existing user reviews **in the initial HTML**.
170+
- Progressive enhancement: hydrate ratings/compare/favorite via the existing
171+
modules, but content must be present without JS.
172+
- Canonical `<link rel="canonical">` → the `/tires/{slug}/` URL.
173+
- Breadcrumb UI: Home → Tire Guide → {Category or Size} → {Tire}.
174+
- "Back to guide" + "Compare" + affiliate CTA (reuse click tracking).
175+
176+
**Modify:** `frontend/js/modules/cards.js` — point each card's title/CTA at the
177+
new `/tires/{slug}/` permalink (progressive: real `<a href>` so crawlers follow
178+
it), instead of only the `?tire=` deep link.
179+
180+
### Phase 2B — Structured data + meta per tire
181+
182+
**New:** `RTG_Schema::output_single_product( $tire )` (or a small
183+
`RTG_Tire_Page` method) emitting a **single `Product`** node (not `ItemList`)
184+
with `offers`, `aggregateRating`, up to N `review`s, and `additionalProperty`
185+
specs — the builder logic already exists in `class-rtg-schema.php` L38–165 and
186+
can be factored out and reused.
187+
- Add **`BreadcrumbList`** schema matching the on-page breadcrumb.
188+
- Add **`VideoObject`** when `review_link` points to a video (YouTube etc.) —
189+
the review videos are already linked, just unmarked.
190+
191+
**Modify:** `includes/class-rtg-meta.php`
192+
- Generate per-tire `<title>`, meta description, canonical, and `og:image` from
193+
the tire's own photo for the new route (today OG only fires on the shortcode
194+
page + `?tire=`). Point canonical at `/tires/{slug}/`.
195+
196+
### Phase 2C — Sitemap + redirects + discoverability
197+
198+
- **Sitemap:** register tire URLs. Prefer hooking the WP core sitemap provider
199+
(`wp_sitemaps_add_provider`) or Yoast/Rank Math if active; fall back to a
200+
custom `/tire-sitemap.xml`. Include `lastmod` from `updated_at`.
201+
- **301** legacy `?tire=<id>` (and `/tires/{raw_tire_id}/`) → the canonical
202+
`/tires/{slug}/` so existing shared links consolidate link equity.
203+
- Internal linking: link category/size facets and related tires from each page
204+
(crawl depth + topical clustering).
205+
- Flush rewrites once on upgrade via the existing `rtg_flush_rewrite` flag; add
206+
a settings field for the `tires` slug.
207+
208+
### Files at a glance
209+
210+
| Action | File |
211+
|--------|------|
212+
| New | `includes/class-rtg-tire-page.php` (routing, render, 404, redirects) |
213+
| New | `frontend/templates/tire-page.php` (server-rendered detail) |
214+
| New | `frontend/js/tire-page.js` + esbuild target (hydration only) |
215+
| Modify | `includes/class-rtg-schema.php` (factor out single-`Product`; add Breadcrumb/Video) |
216+
| Modify | `includes/class-rtg-meta.php` (per-tire title/canonical/OG on new route) |
217+
| Modify | `includes/class-rtg-activator.php` (migration 17: `slug` column + backfill; `DB_VERSION` 16→17) |
218+
| Modify | `includes/class-rtg-database.php` (slug generation, resolve-by-slug, sitemap query) |
219+
| Modify | `frontend/js/modules/cards.js` (link cards to `/tires/{slug}/`) |
220+
| Modify | `includes/class-rtg-admin.php` (settings: tires slug; show/edit slug in tire form) |
221+
| Modify | main plugin file (instantiate `RTG_Tire_Page`) |
222+
| Modify | `esbuild.config.mjs` (build `tire-page.js`) |
223+
224+
### Risks & watch-items
225+
226+
- **Rewrite-rule flushing** is a classic footgun — gate behind the one-shot flag; never flush on every request.
227+
- **Slug collisions / renames**: enforce uniqueness; when brand/model/size changes, keep the old slug 301-ing to avoid breaking indexed URLs.
228+
- **Thin-content risk**: a tire with no reviews and sparse specs is a weak page — consider `noindex` until it clears a minimum content threshold, and lean on Roamer data + spec richness.
229+
- **Duplicate content**: exactly one canonical per tire; the catalog `ItemList` and the per-tire `Product` must not compete — the per-tire page owns the `Product` node.
230+
- **Backfill correctness**: generate slugs for all existing rows in migration 17 and verify no collisions before shipping.
231+
232+
### Acceptance criteria
233+
234+
- `GET /tires/{slug}/` returns 200 with full tire content in the **initial HTML** (verify with JS disabled).
235+
- Google Rich Results Test passes for `Product` + `Review` + `BreadcrumbList`.
236+
- Legacy `?tire=<id>` 301s to the canonical URL; unknown slug returns a real 404.
237+
- Tire URLs appear in the sitemap with correct `lastmod`.
238+
- PHPCS clean; PHP 7.4/8.0/8.2 lint green; existing 83 JS tests pass; new tests for slug generation + route resolution.
239+
240+
---
241+
242+
## Appendix — review gap inventory (condensed)
243+
244+
Full detail lives in the review; headline counts:
245+
246+
- **Accessibility:** 13 issues (focus traps, focus rings, ARIA live/busy, reduced-motion, contrast).
247+
- **Missing shopper features:** 13 (saved comparisons, size wizard, price history, availability, multi-axis reviews, alerts).
248+
- **Frontend tech debt:** fragile array row format, monolithic `ratings.js`/`filters.js`, circular-dep hacks, inline styles in JS, DOM cache staleness.
249+
- **Backend:** no AI layer (despite the claim), `RTG_Database` god class, no extensibility hooks, REST is public-only, no down-migrations.
250+
- **SEO:** no individual tire pages, `ItemList`-only schema, no breadcrumb/video schema, query-arg canonicals (→ Pillar 2 addresses all of these).

frontend/css/rivian-tires.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -886,6 +886,13 @@
886886
color: var(--rtg-text-heading);
887887
line-height: 1.2;
888888
}
889+
.tire-card-model-link {
890+
color: inherit;
891+
text-decoration: none;
892+
}
893+
.tire-card-model-link:hover {
894+
color: var(--rtg-accent);
895+
}
889896

890897
/* Tags row */
891898
.tire-card-tags {

frontend/css/rivian-tires.min.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/js/modules/cards.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ export function createSingleCard(row) {
206206
tireId, size, diameter, brand, model, category, price, warranty, weight, tpms,
207207
tread, loadIndex, maxLoad, loadRange, speed, psi, utqg, tags, link, image,
208208
/* efficiencyScore */ , /* efficiencyGrade */ , reviewLink, /* createdAt */ ,
209-
roamerEfficiency, roamerTotalKm, roamerVehicleCount, roamerVehicleBreakdown
209+
roamerEfficiency, roamerTotalKm, roamerVehicleCount, roamerVehicleBreakdown, slug
210210
] = row;
211211

212212
if (!VALIDATION_PATTERNS.tireId.test(tireId)) {
@@ -345,7 +345,18 @@ export function createSingleCard(row) {
345345

346346
const modelEl = document.createElement('div');
347347
modelEl.className = 'tire-card-model';
348-
modelEl.textContent = safeString(model);
348+
// Link the title to the individual tire page (crawlable, canonical URL).
349+
const tirePageBase = (typeof rtgData !== 'undefined' && rtgData.settings && rtgData.settings.tirePageUrl) ? rtgData.settings.tirePageUrl : '';
350+
const safeSlug = safeString(slug).trim();
351+
if (tirePageBase && safeSlug) {
352+
const modelLink = document.createElement('a');
353+
modelLink.className = 'tire-card-model-link';
354+
modelLink.href = tirePageBase + encodeURIComponent(safeSlug) + '/';
355+
modelLink.textContent = safeString(model);
356+
modelEl.appendChild(modelLink);
357+
} else {
358+
modelEl.textContent = safeString(model);
359+
}
349360
bodyEl.appendChild(modelEl);
350361

351362
const ratingDiv = document.createElement('div');

0 commit comments

Comments
 (0)