The post-P0 subscription audit listed Sections 4 (trust copy aria-labels), 5 (payment badge SVGs), and 9 (cancel-save dialog width) as outstanding P1 polish items. All three have shipped to dev: - Section 4 → FN-294 /7036b18- Section 5 → FN-296 /e5201b2- Section 9 →1dfeb13(manual; FN-291/295 attempts both failed/duplicated) Designer's heartbeat kept reading the stale status table and re-spawning duplicate tasks (FN-291 → FN-296 → FN-326 chain for Section 5 alone). Mark them DONE with commit refs and add an explicit "do not create new tasks" note so heartbeats stop looping on it.
16 KiB
Post-P0 Subscription Page UX Audit
Date: 2026-05-13
Auditor: Designer (agent-08d09be5)
Source file: apps/web/src/routes/dashboard/subscription/index.tsx (1233 lines)
Context: P0 CRO overhaul across FN-203 (P0-1–6) and FN-199 (P0-7–10), shipped without pre-merge QA review.
Audit Summary
| # | Area | Severity | Status |
|---|---|---|---|
| 1 | Pricing cards | — | ✅ PASS |
| 2 | CTA progression | — | ✅ PASS |
| 3 | Order summary | — | ✅ PASS |
| 4 | Trust copy | P1 | ✅ DONE — i18n keys added (FN-294, commit 7036b18) |
| 5 | Payment trust badges | P1 | ✅ DONE — card brand SVGs shipped (FN-296, commit e5201b2) |
| 6 | Skeleton loading | P0 | 🟡 IN PROGRESS — FN-319 |
| 7 | Feature matrix | — | ✅ PASS |
| 8 | Trial urgency banner | — | ✅ PASS |
| 9 | Cancel-save flow | P1 | ✅ DONE — dialog capped at calc(100vw-2rem) below sm (commit 1dfeb13) |
| 10 | Sticky checkout bar | P2 | ⚠️ Missing safe-area padding |
Result: All P1 polish issues shipped. P0 regression (Section 6) tracked under FN-319. P2 observation (Section 10) is the only remaining item — non-blocking.
DO NOT create new tasks for Sections 4, 5, 7, 8, 9 — they have shipped commits in dev. Search
git log --grep="FN-XXX"to confirm before drafting.
1. Pricing Cards — ✅ PASS
Lines: 884–930 (plan comparison grid)
- Grid layout:
sm:grid-cols-2 lg:grid-cols-4— correct 4-column layout on large screens. - Hover state:
hover:shadow-mdtransition on eachCard— correct micro-interaction. - Selected state:
border-primary ring-2 ring-primary/20 dark:ring-primary/25— clear visual feedback. - "Popüler" badge: Absolute positioned
-top-3 left-1/2 -translate-x-1/2— centered above card edge. - "Mevcut Plan" badge: Absolute positioned
-top-3 right-4with green colorway (border-green-500 bg-green-50 text-green-700) — visually distinct from "Popüler". - Popular card styling:
border-primary/40,ring-2 ring-primary/25,shadow-brand,bg-primary/[0.07]— elevated visual treatment. - Current plan card styling:
border-green-500/50 bg-green-50/50with muted CTA (non-button<div>withbg-muted) — correctly prevents re-selecting current plan. - Button progression: "Plan Seç" → "Devam Et" on selection. Button variant transitions: unselected non-popular gets
outline, selected/popular getsdefault.
Verdict: All states, badges, and interactions render per design intent. No issues found.
2. CTA Progression — ✅ PASS
Lines: 924–929 (card footer button), 984–1026 (proceed button), 1036–1042 (order summary conditional)
- Button text transition:
t("subscription.choosePlan")→t("subscription.proceed")("Plan Seç"→"Devam Et"). - Button variant transition: Outline → filled (default) on selection for non-popular plans.
- aria-pressed: Set to
isSelected— screen readers get pressed-state feedback. - Order summary visibility: Conditionally renders only when
selectedPlanKeyis truthy (line ~990). - Order summary content: Plan name, billing period, brand count, total price with period label — all using i18n keys.
- Proceed to payment:
handleProceedToPayment()validates brand selection for non-full plans, fires PostHogcheckout_started, navigates to/dashboard/subscription/paywith plan/period/brands in search params.
Verdict: CTA flow is correct end-to-end. No issues found.
3. Order Summary — ✅ PASS
Lines: 990–1033
- Card structure:
Card > CardHeader (title) + CardContent (line items). Correct visual hierarchy. - Line items: Plan name, billing period, number of brands selected, separated by
Separator, then total price. - Price display:
formatTRY(summaryPrice)with period label suffix. - Conditional brand line: Only renders when
selectedBrandIds.length > 0. - Preceded by:
Separatorbefore the order summary card — clean visual separation from plan cards.
Verdict: Order summary renders correctly with all required data points. No issues found.
4. Trust Copy — ⚠️ P1 (Hardcoded aria-labels)
Lines: 1068–1112
What works:
- Three trust items rendered with correct i18n keys:
subscription.paymentTrustSSL→ "256-bit SSL" with🔒emojisubscription.paymentTrustProvider→ "Iyzico altyapısı" with·separatorsubscription.paymentTrustKVKK→ "KVKK uyumlu" with link to/kvkk
- Semantic
<section>wrapper with descriptivearia-label. - KVKK link has
underline underline-offset-2 hover:text-foregroundfor affordance and hover feedback.
Issues:
- Line 1068 — Hardcoded
aria-label:aria-label="Ödeme güvencesi"is a Turkish string literal. Should uset("subscription.paymentTrustAriaLabel")or equivalent i18n key.
// Current (line 1068):
<section aria-label="Ödeme güvencesi" ...>
// Recommended:
<section aria-label={t("subscription.paymentTrustAriaLabel")} ...>
Severity: P1 — Missed i18n; English-speaking screen reader users get Turkish label.
Recommended fix:
- Add
"paymentTrustAriaLabel": "Ödeme güvencesi"totr.jsonand"paymentTrustAriaLabel": "Payment security"toen.jsonundersubscription. - Replace the hardcoded string with
t("subscription.paymentTrustAriaLabel").
5. Payment Trust Badges — ⚠️ P1 (Text instead of card brand SVGs)
Lines: 1081–1112
What works:
- Four card brands rendered: Visa, Mastercard, Troy, American Express.
- Each inside
<Badge variant="outline">with consistent styling. role="img"on parent with descriptivearia-label.- Individual badges marked
aria-hidden="true"— correct accessibility pattern.
Issues:
- Lines 1085–1112 — Text badges instead of card brand logos/SVGs. Industry standard for payment trust sections is to use recognizable card brand logos (SVGs or icon font), not plain text. Text badges lack visual authority and brand recognition.
// Current (lines 1085–1112):
<Badge variant="outline" className="px-1.5 py-0 text-xs font-normal" aria-hidden="true">
Visa
</Badge>
// ... Mastercard, Troy, AmEx
// Recommended:
<CardBrandIcon brand="visa" className="h-5 w-auto opacity-70" />
// Using lucide-react or simple SVGs for each card brand
- Line 1081 — Hardcoded
aria-label:"Kabul edilen kartlar: Visa, Mastercard, Troy, American Express"is a Turkish string literal. Should use an i18n key.
// Current (line 1081):
aria-label="Kabul edilen kartlar: Visa, Mastercard, Troy, American Express"
// Recommended:
aria-label={t("subscription.paymentTrustAcceptedCards")}
Severity: P1 — Visual downgrade from established design patterns. Missed i18n for screen reader label.
Recommended fix:
- Add card brand SVG icons (Visa, Mastercard, Troy, AmEx) to
@sase/uior inline as simple SVGs. - Replace text
Badgecomponents with icon components. - Add
"paymentTrustAcceptedCards"i18n key to bothtr.jsonanden.json.
6. Skeleton Loading — ❌ P0 (Layout shift)
Lines: 396–406 (skeleton) vs 884 (real grid)
Issue:
The skeleton loading state does not match the real layout, causing a visible layout shift on load.
// Skeleton (lines 400–402):
<div className="grid gap-4 md:grid-cols-2"> // ← md breakpoint, 2 cols
<Skeleton className="h-48 w-full" /> // ← only 2 placeholders
<Skeleton className="h-48 w-full" />
</div>
// Real grid (line 884):
<div ref={planCardsRef} className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
// ← sm breakpoint (2 cols), lg breakpoint (4 cols)
// ← 4 plan cards rendered via plans.map()
</div>
Three mismatches:
- Breakpoint: Skeleton uses
md:(768px), real grid usessm:(640px) → skeleton stays 1-col between 640–767px while real grid would be 2-col. - Column count: Skeleton is always 2-col on md+, real grid is 4-col on lg+ → visible expansion on lg screens.
- Item count: Skeleton renders 2 placeholders, real grid renders 4 cards.
Impact: On desktop (lg+), the page loads showing 2 skeleton cards in a 2-column grid, then abruptly shifts to 4 cards in a 4-column grid. This is a Cumulative Layout Shift (CLS) issue.
Severity: P0 — Core Web Vitals regression (CLS). Degrades perceived performance.
Recommended fix:
// Recommended skeleton:
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} className="h-48 w-full" />
))}
</div>
Also consider adding skeleton placeholders for the title, billing toggle, Separator, and FeatureMatrix to avoid a multi-phase load appearance.
7. Feature Matrix — ✅ PASS
Lines: 1184–1233
- Table structure: Proper
<table>with<thead>and<tbody>— semantically correct. - Column headers:
aria-labelon each<th>with plan name i18n key — accessible. - Popular column: Gets
text-primarycolor styling — visual hierarchy. - Feature rows: Alternating
bg-background/bg-muted/25for zebra striping. - Check/Minus icons:
Check(text-primary) for included features,Minus(muted/40) for excluded — clear visual distinction. - Icon aria-labels:
t("common.yes")/t("common.no")— screen reader friendly. - Horizontal scroll:
overflow-x-autowrapper — works on narrow viewports. - Band positioning: Placed between plan cards and order summary — logical flow.
Verdict: Feature comparison table renders correctly with proper accessibility. No issues found.
8. Trial Urgency Banner — ✅ PASS
Component: apps/web/src/components/trial-urgency-banner.tsx
Integration: apps/web/src/routes/dashboard.tsx:386
What was verified:
- Component exists and is implemented: Full component at
components/trial-urgency-banner.tsxwith proper React patterns. - Rendered in dashboard layout:
<TrialUrgencyBanner />at top of<main>indashboard.tsx— visible on all dashboard pages including subscription. - Display logic: Shows only when
status === "trial",endDateexists, and≤ 3 daysremain. - Dismiss mechanism: Stores dismissal in
localStoragekeyed by endDate (trialBannerDismissed-{endDate}) — persists across sessions until trial end date changes. - i18n keys: Uses
subscription.trialBanner.title,.description,.cta— all present in bothtr.jsonanden.json. - Visual design: Amber colorway (
bg-amber-50,border-amber-200,text-amber-900) withAlertTriangleicon — consistent urgency signaling. - CTA: Links to
/dashboard/subscriptionwith "Plana Yükselt" button — correct navigation target.
Verdict: Trial urgency banner is fully implemented and correctly integrated. The previous audit's claim of "component never implemented" is incorrect. The component was likely built as part of FN-209 and integrated into the dashboard layout (not the subscription page directly, which is correct since it's a site-wide banner).
9. Cancel-Save Flow — ⚠️ P1 (Dialog width on mobile)
Lines: 636–751 (downgrade offer dialog), 754–789 (cancel confirm dialog)
What works:
- Downgrade dialog: Shows when subscription is "active" and a lower-tier plan exists.
- Displays target plan with savings calculation (
formatTRY). - Brand selector for non-full plans with proper maxBrands constraint.
- "Yine de İptal Et" → opens cancel confirm dialog. "Bu plana geç" → calls downgrade API.
- PostHog tracking:
downgrade_offer_shown,downgrade_offer_declined,downgrade_offer_accepted,cancel_save_clicked.
- Displays target plan with savings calculation (
- Cancel confirm dialog: Standard confirmation with destructive button.
- PostHog tracking:
cancel_flow_viewed,subscription_cancelled.
- PostHog tracking:
- Fallback: When no downgrade path exists (
currentPlanKeyis "brand1"), cancel dialog opens directly.
Issues:
- Line 637 —
max-w-mdmay overflow on narrow mobile screens.max-w-md= 28rem (448px). On a 320px-wide device, the dialog exceeds viewport width.
// Current (line 637):
<DialogContent className="max-w-md">
// Recommended:
<DialogContent className="max-w-[calc(100vw-2rem)] sm:max-w-md">
Severity: P1 — Dialog content may be cut off on iPhone SE-class devices.
Recommended fix: Add max-w-[calc(100vw-2rem)] as a mobile-safe fallback, with sm:max-w-md for larger screens.
10. Sticky Checkout Bar — ⚠️ P2 (Missing safe-area padding)
Lines: 1119–1146
What works:
- IntersectionObserver: Watches
planCardsRefwiththreshold: 0, rootMargin: "-80px 0px 0px 0px"— bar appears when plan cards scroll past 80px below viewport top. - Appearance condition: Only when
stickyBarVisible && selectedPlanKey— correct guard. - Animation:
animate-fade-in-up(0.5s cubic-bezier) — smooth entrance. - Backdrop blur:
bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80— frosted glass effect. - Content: Plan name, price with period label, "Devam Et" button → mirrors order summary.
- Width:
max-w-5xlinner container — matches page content width.
Issues:
- Line 1131 — No safe-area padding for notched devices. The bar uses
fixed inset-x-0 bottom-0but does not account for the iOS home indicator area.
// Current (line 1131):
<div className="fixed inset-x-0 bottom-0 z-50 animate-fade-in-up ...">
// Recommended:
<div className="fixed inset-x-0 bottom-0 z-50 animate-fade-in-up pb-safe ...">
Without pb-safe (or pb-[env(safe-area-inset-bottom)]), the bar's content may be partially obscured by the home indicator on iPhone X and newer.
Severity: P2 — Cosmetic issue affecting only notched iPhone users. Content remains visible and interactive; only the bottom padding is suboptimal.
Recommended fix: Add pb-safe utility class (requires Tailwind safe-area plugin) or inline pb-[env(safe-area-inset-bottom,0px)].
Additional Observations
A. planNameToKey indirection (P2)
Lines: 120–131 (definition), 231 (usage)
// Line 231:
const currentPlanKey = subscription?.plan?.name
? planNameToKey(subscription.plan.name)
: null;
The Subscription interface includes plan?: { name: string; key: string }, so subscription.plan.key is available but unused. The planNameToKey() function maps Turkish plan names to internal keys, adding an unnecessary failure point if plan names change or the user's locale differs.
// Recommended:
const currentPlanKey = subscription?.plan?.key ?? null;
Severity: P2 — Functionally correct for current data but fragile. Not a visual design issue.
B. Dashboard footer hardcoded copy (P2)
File: apps/web/src/routes/dashboard.tsx, line ~393
<p className="text-center text-xs text-muted-foreground/60">
© {new Date().getFullYear()} Sase.tr | Gizlilik Politikası, Kullanım Koşulları
</p>
Hardcoded Turkish text in the dashboard footer. Should use i18n keys for "Privacy Policy" and "Terms of Use".
Severity: P2 — Outside direct audit scope but visible on subscription page.
Follow-up Tasks
The following tasks should be created to address P0 and P1 findings:
| Priority | Issue | Recommended Task Title |
|---|---|---|
| P0 | Skeleton layout shift (Section 6, lines 400-402) | Fix subscription page skeleton grid to match real 4-column layout |
| P1 | Trust copy hardcoded aria-label (Section 4, line 1068) | Add i18n keys for subscription trust section aria-labels |
| P1 | Payment trust badges: text → SVGs + i18n aria-label (Section 5, lines 1081-1112) | Replace text-based payment trust badges with card brand SVGs |
| P1 | Cancel-save dialog mobile overflow (Section 9, line 637) | Add mobile-safe max-width to downgrade offer dialog |
Verification Notes
- Code reviewed: Full 1233-line
apps/web/src/routes/dashboard/subscription/index.tsx - Supporting files checked:
apps/web/src/components/trial-urgency-banner.tsx(TrialUrgencyBanner implementation)apps/web/src/routes/dashboard.tsx(dashboard layout — banner integration)apps/web/src/messages/tr.json(Turkish i18n keys)apps/web/src/messages/en.json(English i18n keys)apps/web/src/globals.css(animate-fade-in-up definition)
- Methodology: Static code analysis against design intent described in FN-203 (P0-1–6) and FN-199 (P0-7–10). No Playwright visual tests were possible in this execution environment (missing system libraries — see MEMORY.md Playwright limitation).