PDP subscription terms — DES-52 handoff notes
Honest Anchor module for the EllieMD product detail page. Replaces the legacy "$ per month / full N-week supply" pricing grid that ships today on every PDP and category catalog tile.
v1.1 — 5/7/26. All product data verified against EllieMD Linear "Document Source of Truth" project. Mock catalog now reflects the same orderPrice, orderCycle, and copy that engineering will read from Sanity. Composite cycle data and the new DSIP, Selank, Semax SKUs from the 4/14/26 source-doc refresh are folded in. Tretinoin (a placeholder SKU not present in the source pricing data) is removed; EllieVate Skin+ (real SKU, 30-day cycle) replaces it as the short-cycle / non-week contrast.
Vocabulary alignment
This module renders strings directly from orderCycle, orderPrice, and prescriptionCycle. PDP, checkout, and account portal all use the same vocabulary so the patient sees one consistent way of describing cadence and renewal across the journey.
| Concept | Module string | Surface |
|---|---|---|
| Section label | Subscription Terms | PDP focal block |
| Cycle price + cadence | $478 every 8 weeks (Lora 38px + sans 14px) | PDP focal block |
| Per-day softener | As low as $8.54/day | PDP focal block |
| Compound rhythm note | Cycle rhythm: 8wk supply, 2wk break. Your refill ships at the start of each new 10-week cycle. | PDP focal block when orderCycle.description is set |
| Renewal explainer | Renewal: Your card is charged $478 every 8 weeks. Cancel any time from your account. | PDP focal block |
| Reassessment explainer | Reassessment: Your provider re-checks your prescription every 8 weeks before each renewal ships. | PDP focal block |
| Inclusions micro-line | Includes medical consultation and free shipping. | PDP focal block, italic, lowest weight |
| Catalog tile price + cadence | $478 every 8 weeks (Lora 20px + sans 12px) | Category landing tile |
| Catalog tile renewal hint | Subscription · Cancel any time (or compound variant) | Category landing tile |
Renewal, Refill, and Reassessment are kept as distinct concepts. Definitions match DES-54 portal exactly:
- Renewal: the recurring charge to the card.
- Refill: the shipment that follows after provider review (account portal only — does not appear on PDP).
- Reassessment: the clinician re-check that gates each renewal.
PDP educational copy must preserve these distinctions rather than collapsing them into "your monthly order".
No "monthly" assumption anywhere. All cadence text flows through orderCycle.count + orderCycle.unit and pluralizes correctly across any value. The contract supports 'day' | 'week' | 'month' because the actual source pricing data uses all three (see "Cycle unit decision" below). Even SKUs with a 1-month cycle render as "every 1 month" — never "monthly". This is the central commitment of the module and the brief.
Deprecated terms — remove from the live PDP and catalog templates:
monthSupply→ derive everything fromorderCycle.count+orderCycle.unitretailPrice(when used as a per-month figure) → rename toorderPriceand treat as the actual cycle chargeweekSupply = monthSupply * 4(line 167 ofproduct_info.tsx) → delete the variable- "$X per month" / "Per Month" labels → delete
- "Full N-week supply" labels → delete (the
orderPriceIS the full-cycle supply by definition)
Dynamic vs static strings
Engineering needs to know which strings are interpolated from the API (orderCycle, orderPrice, prescriptionCycle) versus which are locked marketing copy that must not be edited per-product. The split below is the contract; everything dynamic is wrapped in a helper from lib/products.ts so there is exactly one place to change a phrase.
| String in the rendered module | Source | Helper / template | Notes |
|---|---|---|---|
Subscription Terms | Static — locked label | hardcoded | Never per-product. Same on every PDP. |
$478 (the price numeral) | Dynamic — product.orderPrice | inline | Lora 38px on PDP, 20px on catalog tile. Tabular nums. |
every 8 weeks (cadence phrase) | Dynamic — product.orderCycle | displayInterval(cycle) | Pluralizes across 'day' | 'week' | 'month'. Never returns "monthly". |
As low as $8.54/day | Dynamic — computed from orderPrice / (cycle.count * daysInUnit) | pricePerDay(price, cycle) | "As low as" prefix is static; the per-day figure is computed. |
Cycle rhythm: (label) | Static — locked label | hardcoded in subscription-terms.tsx | Only renders when orderCycle.description is set. |
8wk supply, 2wk break (rhythm body) | Dynamic — product.orderCycle.description | inline | Source string from Pricing 7bcaa. Stephanie copy review pending. |
Your refill ships at the start of each new 10-week cycle. | Dynamic — count and unit interpolated | inline template | Sentence template is locked; count and unit come from orderCycle. |
Renewal: (label) | Static — locked label | hardcoded | Colon convention shared with DES-54 portal. |
Your card is charged $478 every 8 weeks. Cancel any time from your account. | Dynamic + static — price + cadence interpolated into a locked sentence template | renewalLine(price, cycle) | "Cancel any time from your account" is locked copy required pre-purchase per the brief. Engineers must not omit or relocate it. |
Reassessment: (label) | Static — locked label | hardcoded | Same colon convention. |
Your provider re-checks your prescription every 8 weeks before each renewal ships. | Dynamic + static — cadence interpolated into a locked sentence template | reassessmentLine(prescriptionCycle) | Pulls from prescriptionCycle, not orderCycle, so the two can diverge later without a rewrite. |
Includes medical consultation and free shipping. | Static — locked marketing copy | hardcoded | Italic, lowest weight, end of block. |
| Disclaimer body text (state restrictions, HSA/FSA) | Dynamic — product.disclaimers[].body | inline | Per-SKU. Marker (e.g. superscript "1") is also per-disclaimer. Copy must come from EllieMD legal review, not authored by design. |
Subscription · Cancel any time (catalog tile) | Static + dynamic suffix — locked plus optional compound suffix | inline template in compact variant | The compound suffix · 8wk supply, 2wk break appends only when orderCycle.description exists. |
Hierarchy when both cadence and price appear (PDP variant, top-to-bottom):
- Cycle price numeral — Lora 38px, the loudest element
- Cadence phrase — sans 14px, baseline-aligned with the price
- Per-day softener — sans 12px, secondary teal
- Compound rhythm card (if applicable) — small white card, demoted from the price row
- Renewal strip — icon + label + body
- Reassessment strip — icon + label + body
- Disclaimer rows (if applicable) — italic-muted info icon
- Inclusions micro-line — italic, lowest visual weight
The price-and-cadence pair reads as one phrase visually. Renewal and Reassessment are explanatory and live below the trust-line so the patient encounters the headline price first, then the explanation, then the commitment ("cancel any time").
Source-of-truth audit (5/7/26)
Every product-specific value in src/lib/products.ts is now traced to one of the following Linear docs (project: EllieMD - Document Source of Truth):
| Doc | Slug | Used for |
|---|---|---|
| Product Pricing | 174cab7393b5 | All orderPrice, orderCycle.count, orderCycle.unit, orderCycle.description values |
| PSQ Compound Families | e192ed4b65fc | Product description (clinical "How" lines) and healthBenefits primary/secondary categories |
| PSQ V2 Compounds | 844226a10be3 | Role/evidence/risk + Semaglutide anchor copy ("Recommended to help regulate appetite and support weight management") |
| Longevity & Wellness Intake | 8c26f0a1c7af | Cadence confirmation for compound SKUs, intake screening notes |
| WL Injection Intake | 487904cbaecb | Semaglutide tier structure (1, 1.5, 2, 2.5, 3) and additive options (B12, Glycine, L-Carnitine) |
Diff against the v1 prototype
| SKU | Field | v1 (placeholder) | v1.1 (source-verified) | Source |
|---|---|---|---|---|
| MOTS-C | orderPrice | $398 | $478 | Pricing 7bcaa |
| MOTS-C | orderCycle.description | "8 weeks on, 2 weeks off" (invented) | "8wk supply, 2wk break" | Pricing 7bcaa |
| MOTS-C | description | SOURCE PENDING | Verified, traced to Compound Families e192e | Compound Families |
| MOTS-C | healthBenefits | TBD × 3 | Longevity Support / Energy Metabolism / Metabolic Health | V2 Compounds 8442 |
| Tesa/KPV | orderPrice | $520 | $500 | Pricing 7bcaa |
| Tesa/KPV | orderCycle.description | (none) | "8wk supply, 2wk break" | Pricing 7bcaa |
| Tesa/KPV | description | SOURCE PENDING | Verified | Compound Families |
| Tesa/KPV | healthBenefits | TBD × 3 | Muscle / Immune Modulation / Gut & Tissue Repair | Compound Families |
| DSIP | orderPrice | $0 (placeholder) | $398 | Pricing 7bcaa |
| DSIP | orderCycle.description | (none) | "8wk supply, 2wk break" | Pricing 7bcaa |
| DSIP | description | SOURCE PENDING | Verified | Compound Families |
| DSIP | healthBenefits | TBD × 3 | Sleep Quality / Mood / Cognitive Recovery | V2 Compounds 8442 |
| Semaglutide T1 | description | SOURCE PENDING | Verified, includes "Recommended to help regulate appetite and support weight management" | V2 Compounds 8442 |
| Semaglutide T1 | formulation | "GLP-1 injection" | "GLP-1 injection (B12 / Glycine / L-Carnitine additive)" | WL Injection intake 4879 |
| Semaglutide T1 | healthBenefits | SOURCE PENDING | Appetite Regulation / Weight Management / Metabolic Support | V2 Compounds 8442 |
| Tretinoin | (entire SKU) | Placeholder, invented | REMOVED — does not exist in source pricing data | Pricing 7bcaa (absent) |
| EllieVate Skin+ | (entire SKU) | — | ADDED — real SKU, $199/30 days, replaces Tretinoin as short-cycle / non-week contrast | Pricing 7bcaa, Compound Families e192e |
OrderCycle.unit (type) | — | 'week' only | 'day' | 'week' | 'month' to match what the source data actually contains | Pricing 7bcaa |
| Catalog scenario | — | NAD+, MOTS-C, Tesa/KPV, Sem (mixed categories) | NAD+, MOTS-C, Tesa/KPV, DSIP (Longevity-only — coherent with category landing) | Compound Families groupings |
| GLP-1 Support+ Vanilla | (entire SKU) | — | ADDED — real SKU, $119/1 month, the explicit unit: 'month' contrast SKU per brief acceptance criterion | Pricing 7bcaa |
Source vs patient-facing copy
A handful of strings come from internal/operational source documents (the pricing spreadsheet) rather than approved patient-facing marketing copy. They are preserved verbatim from source so the prototype is auditable, but flagged here for Stephanie review before patient exposure:
orderCycle.descriptionfor MOTS-C, Tesa/KPV, DSIP — currently"8wk supply, 2wk break". Source-faithful but reads like internal shorthand. A patient-facing rewrite (e.g."8 weeks of doses, then a 2-week break") needs Stephanie sign-off before it can replace the source string.- Disclaimer copy on the
nad-plus-restrictedstress scenario is illustrative only — actual state-restriction and HSA/FSA language must come from EllieMD legal review, not authored by design.
Per the strict EllieMD rule (no invented content), nothing else in the data file is authored copy.
Cycle unit decision
The previous prototype's OrderCycle.unit was constrained to 'week' only, on the rationale that consistent units across the catalog simplify the contract. Pulling the actual source pricing data from Linear changed that picture: the catalog has three units in active use.
| Unit | SKU count | Examples |
|---|---|---|
'week' | ~50 | Every peptide injection, every nasal spray, every GLP-1 SKU. Counts of 8, 10, 12. |
'day' | 1 | EllieVate Skin+ (30 days) |
'month' | 3 | Protein powders (1 month) — GLP-1 Support+ Vanilla scenario in the prototype |
Two ways to reconcile:
A. Normalize everything to weeks at the data layer. EllieVate 30 days → 4 weeks; protein 1 month → 4 weeks. Pros: single unit simplifies helpers. Cons: lossy (30 days isn't exactly 4 weeks; "month" semantics get destroyed); also requires Lauren's source spreadsheet to be re-authored, which we don't own.
B. Expand the contract to 'day' | 'week' | 'month'. Helpers pluralize across all three. Pros: zero-loss faithfulness to source; the central "no monthly assumption" commitment becomes literal — a 1-month SKU renders as "every 1 month" instead of being silently turned into weeks. Cons: marginally more helper logic.
Chose B, aligned with the brief's anti-monthly stance. The helpers in displayInterval, pricePerDay, etc. handle all three units. The visual module is unchanged — every SKU still renders the same Honest Anchor block.
Open question for engineering: The Sanity schema needs to expose unit as an enum the same way. If Sanity already has a flat cycleWeeks integer, that needs to grow into { count, unit } to match the contract. Flagging for the PDP engineer (Samir / Amir).
Page hierarchy
Where the Subscription Terms module sits on the PDP relative to other modules. This is the focal block for conversion — between the trust-signal cluster (shopping benefits bar) and the action (Purchase CTA).
PDP — productInfo component
The brief asks for a positioning note. Concrete order top-to-bottom inside the live productInfo Sanity component (src/app/product/components/product_info.tsx):
- Image gallery + product title (Lora) — primary identity
- Product description (Sanity rich text) — secondary
- Health benefits row (3 cards) — secondary
- Quote / verified-user testimonial — trust signal
- Shopping benefits bar (HSA/FSA, Free Shipping, 50 states, Medical Consultation) — trust signal
- Subscription Terms — Honest Anchor module — conversion anchor
- Purchase CTA — action
- ↓ Then secondary modules render below in Sanity-defined order:
banner·productShortBenefit·ferrisWheel·carouselMulti·video·experience·personalizedCards·productComparisons·howItWorks·productResultsInfoGraphic·testing·scientificSources·faq·readyToStart(per thePRODUCT_PAGE_COMPONENT_TYPESarray inproductPageV2.tsx)
The module sits above the fold on desktop. On mobile the Purchase CTA is sticky-bottom in production; the terms block scrolls into view immediately above the CTA so the patient cannot tap Purchase without having seen cycle price and renewal terms.
Category catalog tile — _products.tsx
The compact variant of the same module replaces the per-tile pricing block in src/app/(product_category)/_products.tsx (lines 188–208). Tile order top-to-bottom:
- Product image
- Title + tier
- Category + formulation subtitle
- Subscription Terms (compact variant) — cycle price + cadence + one-line renewal hint
- Learn more link to PDP
Full Renewal / Reassessment strip lives only on the PDP. Catalog tiles are scan surfaces — the patient gets the cycle price and the "subscription, cancel any time" reassurance, then clicks through.
Cross-surface alignment
| Surface | Where the module appears | Variant |
|---|---|---|
PDP (product_info.tsx) | Focal block above the Purchase CTA | pdp (full strip) |
Category catalog tiles (_products.tsx) | Inside each tile, replacing the legacy two-column pricing | compact |
| Checkout (existing) | Order summary panel | Vocabulary alignment only — checkout uses the same displayInterval(orderCycle) shape. Visual treatment owned by checkout team. |
| Account portal (DES-54) | Subscription Details table on /account/subscription/[id] | Same vocabulary; different visual register (table column instead of focal block). See DES-54 NOTES.md. |
Mobile / desktop behavior
Both viewports are reviewable separately via the dev toolbar VIEWPORT toggle. Same SubscriptionTerms component renders in both — the divergence is at the page-shell level (product-page.tsx vs product-page-mobile.tsx, category-catalog.tsx vs category-catalog-mobile.tsx).
Module-level (SubscriptionTerms)
The module is a single component with responsive sizing. The two visible variants:
- Desktop: Lora amount 38px, padding 24px, full Renewal/Reassessment strip with icon + label inline.
- Mobile (375px frame): Lora amount 32px, padding 18–20px, renewal strip stays vertical, compound note stays full-width.
The <sub> for footnoted disclaimers stays inline at every viewport. The "As low as $X.XX/day" softener wraps below the price/cadence pair on mobile.
PDP shell (product-page-mobile.tsx)
Material differences from desktop:
- Single column. No image-gallery / info two-column split. Image sits above the info stack.
- Sticky-bottom Purchase CTA. The CTA is bottom-anchored inside the 375px frame. Patient cannot tap Purchase without seeing the Subscription Terms block, which scrolls into view directly above the sticky CTA. This is the central mobile composition rule of the brief — pre-purchase transparency requires the patient encounter cycle price + renewal terms before the action.
- Shopping benefits bar drops to single column. Stacked checks instead of the desktop 2×2.
- Inline gallery placeholder. Production uses a horizontal swipeable gallery; the prototype shows the same icon block desktop uses, full-bleed within the tile.
Order top-to-bottom inside the mobile scroll body:
- Image
- Title + tier
- Description
- Health benefits row (3 cards stacked)
- Shopping benefits bar (single column)
- SUBSCRIPTION TERMS — Honest Anchor module
- (empty space below) — sticky CTA hovers
The desktop Position-relative-to-other-modules order (image→description→benefits→shopping bar→subscription terms→purchase) is preserved. The only divergence is the CTA mounting: inline on desktop, sticky-bottom on mobile.
Category catalog shell (category-catalog-mobile.tsx)
Single-column tile stack at 375px. Tile structure mirrors desktop (icon left, info right, compact SubscriptionTerms below). Smaller icon size (sm vs desktop md) so the info column gets more horizontal room. Compact SubscriptionTerms variant is identical across viewports — no per-viewport branching for the module itself.
Engineering implication
When integrating into the live repo, the live PDP and _products.tsx already have responsive styles for the surrounding shell. The SubscriptionTerms component drops into the existing mobile/desktop layout without further conditional rendering. The sticky-bottom CTA pattern in the production mobile PDP is unchanged — SubscriptionTerms simply replaces the legacy pricing block above it.
Category hero deviations
The brief asks for parity or intentional differences across category-specific heroes. Audit of the live (product_category) tree:
| Category | Hero pattern | Module behavior |
|---|---|---|
| Longevity | Default CategoryBanner | Default catalog tiles use compact module — no deviation |
| Weight Loss | Default CategoryBanner + WeightLossCalculator + PharmacyConversionCalculator | Default catalog tiles. Calculators are widgets that sit between the tiles and the rest of the page — they do not display cycle pricing |
| Microdose | Personalized HeroSection when useHeroPersonalized is on (currently false in code) | No pricing in hero today. Default catalog tiles unchanged |
| Sexual Health | Default CategoryBanner | Default catalog tiles. No deviation |
| Skincare | Default CategoryBanner | Default catalog tiles. No deviation |
Conclusion: No category-specific hero currently displays subscription pricing. The default tile + PDP module pattern carries every category. When useHeroPersonalized ships for Microdose, the PDP module remains the canonical price surface — personalized hero may add testimonials or copy variations but does not duplicate cycle price.
Stress cases
The module degrades gracefully in each:
- Compound cycle (MOTS-C, Tesa/KPV, DSIP — all "8wk supply, 2wk break"): Rhythm renders in a small note card just below the price row, before the Renewal strip. Same pattern handles any future compound the catalog adds — the new Selank and Semax SKUs (4/14/26 source refresh) use the same structure and will render identically.
- State-restricted SKU with long disclaimer: Disclaimer appears as a third row in the renewal strip with the same icon-+-text atom, italic-muted to demote it. Wraps freely without breaking layout.
- HSA/FSA reimbursement footnote: Attaches to specific claims via superscript and lives in the inclusions micro-line. Doesn't fight the price-row hierarchy.
- Empty / loading state: Skeleton holds the same layout shape — no jump on hydration. Renders while
orderCycleandorderPriceresolve from Sanity. - Non-week cycle (EllieVate Skin+ at 30 days): Renders as "every 30 days" — explicit proof that the module never silently coerces to "monthly". Same Lora 38px treatment as week-cycle SKUs.
- 5-digit cycle price: Lora 38px holds 4-digit prices ($897, $1,297) without breaking the price-row line. The largest current
orderPricein the catalog is GLP-1/GIP/NAD+ Oral Drops Tier 3 at $1,497 — still 4 digits. If catalog adds a 5-digit price, drop to 32px and re-test.
Prototype to production component map
The prototype uses descriptive names that don't match the production filenames the ticket points engineering at. Mapping below.
| Prototype file | Production touchpoint(s) |
|---|---|
subscription-terms.tsx | New shared atom. Place in src/components/. Imported by product_info.tsx (replaces the Pricing Grid JSX block) and by _products.tsx in (product_category) (replaces the per-tile pricing block). |
product-page.tsx (prototype shell) | Reference only — production already has productInfo Sanity component. Use this prototype to verify the module renders correctly inside the existing layout. |
category-catalog.tsx (prototype) | Reference only — production already has _products.tsx in (product_category). Use to verify the compact variant renders inside an existing tile. |
medication-icon.tsx | New shared atom (already in DES-54). Lucide-based, parameterized with size variants. Place in src/components/. |
lib/products.ts | Type and util location. OrderCycle, PrescriptionCycle, Product, MedicationForm, CycleUnit are types. displayInterval, priceCycleLine, pricePerDay, renewalLine, reassessmentLine are utilities. The data fetch lives on the production hooks layer (Sanity). |
Cadence handling rules
- All cadence text is driven from
OrderCycle = { count, unit: 'day' \| 'week' \| 'month', description? }. - Pluralize via
${cycle.unit}${count === 1 ? '' : 's'}. Centralized indisplayInterval. - The same row template renders 30-day, 4-week, 8-week, 10-week, 12-week, and 1-month cadences equivalently. No layout branches by count or unit.
- Short-cycle SKUs render as "every 30 days" or "every 1 month", never "monthly". This is the explicit commitment of the module and the brief.
- Compound cycles surface their on/off rhythm in
orderCycle.description, not as a different unit. The contract count is still the full cycle length (e.g. MOTS-Ccount = 10for a 10-week billing cycle, withdescription = '8wk supply, 2wk break'). - Per-day price computed via
orderPrice / (cycle.count * daysInUnit[cycle.unit]), wheredaysInUnit = { day: 1, week: 7, month: 30 }. Rounded to two decimals. Centralized inpricePerDay. - Renewal cadence and reassessment cadence are separate fields (
orderCyclevsprescriptionCycle). Initially they hold the same values for every SKU, but the module renders them through different helpers so the two can diverge later (e.g. quarterly billing with annual reassessment) without a rewrite.
Pre-purchase transparency requirements
Per the brief: cancellation terms must be visible pre-purchase. The Renewal line carries the canonical commitment string:
Cancel any time from your account.
This is locked copy — it must appear inline with the Renewal explainer, not as a separate footnote, so it's actually seen. The compact catalog-tile variant carries an abbreviated form: Subscription · Cancel any time. Both are required pre-purchase by the brief and verified by stakeholder review.
Engineering touchpoints
Each surface in the prototype maps to a named engineering touchpoint, called out in JSX comments adjacent to the relevant section:
| Touchpoint | Where it lives | Notes |
|---|---|---|
pdp_pricing_block | Subsection inside product_info.tsx (replaces lines 372–399) | The PDP focal block — full Renewal / Reassessment strip |
catalog_tile_price | Per-tile block inside _products.tsx in (product_category) (replaces lines 188–208) | Compact variant — cycle price + cadence + one-line renewal hint |
subscription_terms_atom | New shared component in src/components/subscription-terms.tsx | Single component, two variants (pdp and compact) |
Shared utilities exposed by @/lib/products:
displayInterval(cycle)→"every 8 weeks"/"every 30 days"/"every 1 month"priceCycleLine(price, cycle)→"$478 every 8 weeks"pricePerDay(price, cycle)→"$8.54/day"renewalLine(price, cycle)→"Your card is charged $478 every 8 weeks. Cancel any time from your account."reassessmentLine(prescriptionCycle)→"Your provider re-checks your prescription every 8 weeks before each renewal ships."
Pending review (before merge to live PDP)
- Stephanie copy review of compound description strings (
"8wk supply, 2wk break"is currently surfaced verbatim from source — needs patient-friendly rewrite or sign-off as-is). - Engineering confirmation (Samir / Amir) that the Sanity schema can expose
OrderCycle.unitas the three-value enum, not a flat integer. - Legal review of disclaimer copy in the
nad-plus-restrictedstress scenario before any state-restriction or HSA/FSA strings ship as production copy. - Lauren confirmation that the four catalog scenario products (NAD+, MOTS-C, Tesa/KPV, DSIP) are the right four to lead the Longevity category landing — these were chosen as the prototype's Longevity-only catalog grid based on PSQ Compound Families groupings, but the live category landing may include more SKUs.