What the PDP prototype demonstrates and what the production build must supply. One type-aware page on a single shared skeleton, adapting across four product types. Real VapeHub design system, not the mock's chrome. Companion to the design pack's product brief.
The prototype's 4-type switcher and the stock-state toggle are review affordances (they let you flip between templates and lifecycle states in one file) — in production each type renders as its own URL and stock comes from the catalogue, not a button.
The current live PDP ships the entire <main> as a "Loading product…" spinner: H1, price, variant selector, gallery, description, reviews are all client-rendered, absent from the server HTML. This is both the organic-decline root cause (crawlers see a spinner) and a conversion/reliability risk (a JS error, slow 4G or old Android leaves a spinner-locked buy box).
Server-render (or stream) the buy box — title, price, flavour selector, stock, add-to-basket, specs, reviews, FAQ, and the whole content deck below. The existing JSON-LD (Product + Breadcrumb + FAQ + aggregateRating) is the right shape — keep it, but see §7 on aggregateRating. This one change aligns the SEO-recovery and conversion agendas and is the highest-ROI fix on the site. The skeleton treatment in the prototype is for deferred data only (live stock, review counts) — never for the buy box itself.
SSR'ing the buy box is necessary but not sufficient. Each of the four type URLs must serve, in the initial HTML: a unique <title> and meta description, a self-referential rel=canonical (the prototype ships neither), and OG/Twitter tags. Pick one canonical URL form and hold it everywhere — the prototype is inconsistent (schema @id/url are slash-less like /hayati-pro-max-plus while nav/internal links use trailing slashes, and the pod category appears as /prefilled-pod-kits/, /prefilled-pods/ and /hayati-pods/). Align nav, breadcrumb, JSON-LD @id/url, internal links and rel=canonical to the single chosen form before it fragments canonical signals.
All four share the same skeleton: sticky gallery + buy box (2-col desktop; single column + sticky Add bar on mobile, container-query breakpoint ~860px), then a content deck (description → specs → VapeHub Tested → reviews → FAQ → cross-sell). What changes per type is which selectors lead and which content blocks appear:
Type (data-p) | Reference product | Lead selector | Type-specific blocks |
|---|---|---|---|
Device / Kit (device) | Hayati Pro Max Plus 6000 (/hayati-pro-max-plus) | Flavour range (searchable) | Range-comparison table (this vs siblings, cost-per-1k-puffs), VapeHub Tested block |
Pod / Refill (pod) | Hayati Pro Max Plus Refill Pods (/hayati-pro-max-plus-pods) | Flavour, compatibility-first | Compatibility panel (fits / doesn't fit, hard rule), pack-size |
E-Liquid (liquid) | Elfliq Nic Salt 10ml (/elfliq-nic-salts-by-elf-bar) | Strength-first, then flavour | VG/PG ratio, nic-salt vs freebase note, shortfill maths, flavour glossary |
Nicotine Pouches (oral) | VELO Nicotine Pouches (/velo-nicotine-pouches) | Strength + format | Format (slim/mini), pouches-per-can, tobacco-free framing, "start low" guidance |
Each type keeps its own JSON-LD Product schema with its real canonical URL, brand, price and aggregateRating. Don't invent variants, stock or review counts — every one resolves to a real catalogue SKU or is flagged.
The flavour is the product for kits/pods — treat it as first-class, never a <select>.
aria-disabled, not selectable). A search box + profile filters (fruit / menthol / etc.) are pure in-place filters over present DOM nodes — they never fetch.Multibuy is a primary AOV lever, so it's selectable bundle cards (radio-style), not a footnote. Real deal tiers from the prototype (confirm against the promo engine):
| Type | Single | Tier 1 | Tier 2 |
|---|---|---|---|
| Device | £7.99 | 3 for £23 (£7.67 ea) | 4 for £30 |
| Pod | £5.49 | 3 for £15 (£5.00 ea) | 6 for £30 |
| E-Liquid | £2.89 | 5 for £10 (£2.00 ea) | 15 for £30 |
| Pouches | £3.99 | 3 for £10 (£3.33 ea) | 9 for £30 |
One stock value per product/variant drives the whole buy box. instock + low are buyable; soldout + discontinued are not, and must suppress every promise we can't honour:
aria-disabled).Notify-me validates the email client-side in the prototype; production posts it to a back-in-stock list keyed to the variant SKU and emails on restock.
The one inverted band on the page (Device type). It reports VapeHub's own measured data vs the manufacturer claim — e.g. measured 4,872 puffs vs a claimed 6,000 — plus per-profile real-use estimates and cost-per-day. This is genuine E-E-A-T and the block competitors don't have. It must carry real test figures or be withheld — never fabricated numbers, and the disclosure line (how/when tested) stays.
aggregateRating / review in the JSON-LD must mirror genuinely displayed, real reviews — emit it only when it does. The prototype's ratings are sample data (flagged in a markup comment); shipping star ratings in structured data that aren't backed by shown reviews breaches Google's review-snippet policy (manual-action risk) and the no-fabrication rule.name to the visible H1 — the prototype's schema names drift from the on-page titles on two types (e.g. schema "Hayati Pro Max Plus Pods" vs H1 "…Refill Pods"). Emit the same product name in both.priceValidUntil, hasMerchantReturnPolicy, shippingDetails. The data already exists on-page (30-day returns, £30 free-delivery threshold, dispatch calendar) to populate them.In the buy box: "Free next-day delivery over £30 · £2.99 under", 30-day returns, plain packaging. Live dispatch countdown (same engine as basket/checkout): before 16:00 UK Mon–Fri → "Order within {Xh Ym Zs} for same-day dispatch" (tabular-nums); after cutoff / weekend / holiday → "ships {next working day}". Real/server time, never resets per visit. Fix the sitewide copy mismatch: same-day dispatch, not "shipping".
aria-pressed; OOS options aria-disabled; the flavour readout is an aria-live region.prefers-reduced-motion disables the gallery zoom + transitions.dataLayer events — view_item, select_variant, bundle_option_select, add_to_cart, atb_blocked, crosssell_click — wire these to the real tag manager so nothing silently breaks again.Multibuy promo model (per-SKU vs mix-and-match group) · real variant matrix + per-variant stock/SKUs/images · real review data + Trustpilot score · VapeHub Tested figures per product · dispatch calendar · loyalty earn rate · back-in-stock provider.
No "disposable" wording · 18+ throughout · no health / "safer" / quit claims · plain unbranded packaging · factual button + description copy.
Compliance eyeball — pouch copy. The Nicotine Pouches descriptor "no vapour, no smoke, no smell" is factual (it describes the format) and low-risk, but reads as an implicit comparison to smoking — get it signed off with the rest of the copy.
Open compliance item — the "Ban-Proof Big Puffs" range name. It carries no "disposable" and no health claim, so it clears the black-letter rules, but framing a range around the ban and high puff-counts is exactly what Trading Standards / ASA are scrutinising next. Get legal/ASA sign-off on the range name (used in nav, breadcrumb, gallery tag, JSON-LD breadcrumb and body copy) before production.