Files
sase.tr/docs/design-specs/post-p0-subscription-audit.md
Fusion cd24444f66
Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled
docs(audit): mark sections 4/5/9 as shipped to stop Designer dupes
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.
2026-05-14 06:36:24 +00:00

16 KiB
Raw Blame History

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 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: 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.
// 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.
// 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
  1. 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/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.

// 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:

// 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.
// 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.
// 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)

// 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.

File: apps/web/src/routes/dashboard.tsx, line ~393

<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).