feat(FN-266): write post-P0 subscription UX audit deliverable
Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled

Commits merged:
- feat(FN-266): write post-P0 subscription UX audit deliverable

Files changed:
docs/design-specs/post-p0-subscription-audit.md | 351 ++++++++++++++++++++++++
 1 file changed, 351 insertions(+)

Fusion-Task-Id: FN-266
This commit is contained in:
Fusion
2026-05-13 05:47:30 +00:00
parent cc2bfbe2b0
commit a526cecc40

View File

@@ -0,0 +1,351 @@
# 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-16) and FN-199 (P0-710), 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 | ⚠️ Hardcoded aria-labels (not i18n) |
| 5 | Payment trust badges | P1 | ⚠️ Text badges instead of card brand SVGs |
| 6 | Skeleton loading | **P0** | ❌ Layout shift: md:grid-cols-2 vs lg:grid-cols-4 |
| 7 | Feature matrix | — | ✅ PASS |
| 8 | Trial urgency banner | — | ✅ PASS |
| 9 | Cancel-save flow | P1 | ⚠️ max-w-md tight on narrow mobile |
| 10 | Sticky checkout bar | P2 | ⚠️ Missing safe-area padding |
**Result: CONDITIONAL-PASS — 1 P0 regression, 3 P1 polish issues, 1 P2 observation.**
---
## 1. Pricing Cards — ✅ PASS
**Lines:** 884930 (plan comparison grid)
- **Grid layout:** `sm:grid-cols-2 lg:grid-cols-4` — correct 4-column layout on large screens.
- **Hover state:** `hover:shadow-md` transition on each `Card` — 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-4` with 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/50` with muted CTA (non-button `<div>` with `bg-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 gets `default`.
**Verdict:** All states, badges, and interactions render per design intent. No issues found.
---
## 2. CTA Progression — ✅ PASS
**Lines:** 924929 (card footer button), 9841026 (proceed button), 10361042 (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 `selectedPlanKey` is 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 PostHog `checkout_started`, navigates to `/dashboard/subscription/pay` with plan/period/brands in search params.
**Verdict:** CTA flow is correct end-to-end. No issues found.
---
## 3. Order Summary — ✅ PASS
**Lines:** 9901033
- **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:** `Separator` before 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:** 10681112
### What works:
- Three trust items rendered with correct i18n keys:
- `subscription.paymentTrustSSL` → "256-bit SSL" with `🔒` emoji
- `subscription.paymentTrustProvider` → "Iyzico altyapısı" with `·` separator
- `subscription.paymentTrustKVKK` → "KVKK uyumlu" with link to `/kvkk`
- Semantic `<section>` wrapper with descriptive `aria-label`.
- KVKK link has `underline underline-offset-2 hover:text-foreground` for affordance and hover feedback.
### Issues:
1. **Line 1068 — Hardcoded `aria-label`:** `aria-label="Ödeme güvencesi"` is a Turkish string literal. Should use `t("subscription.paymentTrustAriaLabel")` or equivalent i18n key.
```tsx
// 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"` to `tr.json` and `"paymentTrustAriaLabel": "Payment security"` to `en.json` under `subscription`.
- Replace the hardcoded string with `t("subscription.paymentTrustAriaLabel")`.
---
## 5. Payment Trust Badges — ⚠️ P1 (Text instead of card brand SVGs)
**Lines:** 10811112
### What works:
- Four card brands rendered: Visa, Mastercard, Troy, American Express.
- Each inside `<Badge variant="outline">` with consistent styling.
- `role="img"` on parent with descriptive `aria-label`.
- Individual badges marked `aria-hidden="true"` — correct accessibility pattern.
### Issues:
1. **Lines 10851112 — 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.
```tsx
// Current (lines 10851112):
<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
```
2. **Line 1081 — Hardcoded `aria-label`:** `"Kabul edilen kartlar: Visa, Mastercard, Troy, American Express"` is a Turkish string literal. Should use an i18n key.
```tsx
// 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/ui` or inline as simple SVGs.
- Replace text `Badge` components with icon components.
- Add `"paymentTrustAcceptedCards"` i18n key to both `tr.json` and `en.json`.
---
## 6. Skeleton Loading — ❌ P0 (Layout shift)
**Lines:** 396406 (skeleton) vs 884 (real grid)
### Issue:
The skeleton loading state does not match the real layout, causing a visible layout shift on load.
```tsx
// Skeleton (lines 400402):
<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:**
1. **Breakpoint:** Skeleton uses `md:` (768px), real grid uses `sm:` (640px) → skeleton stays 1-col between 640767px while real grid would be 2-col.
2. **Column count:** Skeleton is always 2-col on md+, real grid is 4-col on lg+ → visible expansion on lg screens.
3. **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:**
```tsx
// 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:** 11841233
- **Table structure:** Proper `<table>` with `<thead>` and `<tbody>` — semantically correct.
- **Column headers:** `aria-label` on each `<th>` with plan name i18n key — accessible.
- **Popular column:** Gets `text-primary` color styling — visual hierarchy.
- **Feature rows:** Alternating `bg-background` / `bg-muted/25` for 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-auto` wrapper — 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:
1. **Component exists and is implemented:** Full component at `components/trial-urgency-banner.tsx` with proper React patterns.
2. **Rendered in dashboard layout:** `<TrialUrgencyBanner />` at top of `<main>` in `dashboard.tsx` — visible on all dashboard pages including subscription.
3. **Display logic:** Shows only when `status === "trial"`, `endDate` exists, and `≤ 3 days` remain.
4. **Dismiss mechanism:** Stores dismissal in `localStorage` keyed by endDate (`trialBannerDismissed-{endDate}`) — persists across sessions until trial end date changes.
5. **i18n keys:** Uses `subscription.trialBanner.title`, `.description`, `.cta` — all present in both `tr.json` and `en.json`.
6. **Visual design:** Amber colorway (`bg-amber-50`, `border-amber-200`, `text-amber-900`) with `AlertTriangle` icon — consistent urgency signaling.
7. **CTA:** Links to `/dashboard/subscription` with "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:** 636751 (downgrade offer dialog), 754789 (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`.
- **Cancel confirm dialog:** Standard confirmation with destructive button.
- PostHog tracking: `cancel_flow_viewed`, `subscription_cancelled`.
- **Fallback:** When no downgrade path exists (`currentPlanKey` is "brand1"), cancel dialog opens directly.
### Issues:
1. **Line 637 — `max-w-md` may overflow on narrow mobile screens.** `max-w-md` = 28rem (448px). On a 320px-wide device, the dialog exceeds viewport width.
```tsx
// 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:** 11191146
### What works:
- **IntersectionObserver:** Watches `planCardsRef` with `threshold: 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-5xl` inner container — matches page content width.
### Issues:
1. **Line 1131 — No safe-area padding for notched devices.** The bar uses `fixed inset-x-0 bottom-0` but does not account for the iOS home indicator area.
```tsx
// 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:** 120131 (definition), 231 (usage)
```tsx
// 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.
```tsx
// 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
```tsx
<p className="text-center text-xs text-muted-foreground/60">
&copy; {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-16) and FN-199 (P0-710). No Playwright visual tests were possible in this execution environment (missing system libraries — see MEMORY.md Playwright limitation).