Files
sase.tr/docs/design-specs/FN-420-vin-decode-error-branches-ux-spec.md
Fusion 4fc7d35c7a feat(FN-420): UX spec for VIN-decode error branches (subscription-wall, unknown-VIN)
Commits merged:
- feat(FN-420): UX spec for VIN-decode error branches (subscription-wall, unknown-VIN)

Files changed:
.../FN-420-vin-decode-error-branches-ux-spec.md    | 280 +++++++++++++++++++++
 1 file changed, 280 insertions(+)

Fusion-Task-Id: FN-420
2026-05-17 10:25:10 +00:00

19 KiB
Raw Blame History

FN-420 — VIN-decode Error Branches UX Spec

Date: 2026-05-17 Designer: agent-08d09be5 Status: Spec — implementation deferred to follow-up task Parent: FN-367 (canonical retry banner shipped), FN-415 (verification done; these branches explicitly out of scope) Target file: apps/web/src/routes/dashboard/search.tsx (error banner at lines ~414475)


1. Context

FN-367 shipped a generic role="alert" retry banner for VIN-decode failures, gated to exclude two branches:

{!error.includes("abone olun") && !error.includes("tanınamadı") && (
  <Button ... data-faro-user-action-name="vin-decode-retry">Tekrar Dene</Button>
)}

Today those two branches resolve to:

  • "abone olun" — inline destructive-colored banner with an <a> link reading abone olun (i.e. the upgrade affordance is a low-affordance inline link inside a red error block; visually conflicts with the message — subscription is a paywall, not an error).
  • "tanınamadı" — destructive banner shows the raw server message; below it, a low-emphasis variant="outline" "Şase no doğru, sistem yöneticisine gönder" button. No retry, no explanation, no alternative path.

Both branches were named in FN-415's "out of scope, spawn separately" list. They are the residual rage-click surfaces after FN-367.

This spec defines the inline affordances for each branch. It is UX-only — the follow-up implementation task will wire copy, components, telemetry, and tests.


2. Subscription-Wall Branch (error.includes("abone olun"))

2.1 Semantic class — NOT an error

Subscription required is a gated-feature state, not a failure. The current pattern (destructive red banner, AlertCircle, "Şase çözümlenemedi" headline) miscommunicates this — the user reads it as "something broke" and is primed to rage-click. We replace the destructive banner with a distinct upgrade-prompt affordance for this branch only.

  • Role: Keep role="alert" + aria-live="assertive" (per FN-367 baseline) — the state still requires user acknowledgement. Use aria-atomic="true" so the full block is re-announced.
  • Component: Distinct upgrade-prompt block — NOT the destructive banner. Same outer rounded-xl border p-4 shell so the layout slot is stable, but neutral/brand semantic color (not destructive).

2.2 Visual treatment

Token Generic error (FN-367) Subscription wall (FN-420)
Border border-destructive/40 border-brand/30
Background bg-destructive/10 bg-brand/[0.07]
Icon AlertCircle text-destructive Sparkles text-brand (lucide Sparkles)
Headline color text-destructive text-foreground
Headline weight font-medium font-semibold
Body color text-destructive/90 text-muted-foreground
Primary CTA Button default variant (red) Button default variant (brand)

Icon swap rationale: AlertCircle is reserved for failures; Sparkles is the project's existing premium-feature affordance (matches the "Pro" framing used on /dashboard/subscription).

2.3 Copy (plain Turkish, existing voice)

  • Headline: Şase çözümleme Pro özelliğidir
  • Body: Araç verilerine ve OEM kataloğuna erişmek için aboneliğinizi başlatın. İptal hakkı her zaman sizde.
  • Primary CTA label: Aboneliği başlat
  • Secondary CTA label (text-only, inline below primary): Planları gör/dashboard/subscription (same route, but framed as "browse" rather than commit). On mobile both buttons stack; on desktop the secondary is a variant="link" button sized to baseline.

Voice check: "abone olun" (current server message) becomes "Aboneliği başlat" (CTA) — imperative, matches the verb tense in proceedToPayment flow. "Pro özelliği" matches existing copy on /dashboard/subscription plan cards.

2.4 Primary action

  • Destination: <Link to="/dashboard/subscription"> rendered as a <Button> (use Button asChild pattern already in use in the repo). NOT the generic retry handler. NOT /dashboard/subscription/pay — user has not selected a plan yet.
  • Implementation note (for follow-up task): Reuse the existing Button component with asChild so the click-target is a single anchor — preserves middle-click / cmd-click behavior.

2.5 Hit-target

  • Primary button: h-11 w-full rounded-xl (44 px tall × full container width; matches existing vin-decode-retry button) — meets FN-415 ≥44 × 44 baseline.
  • Secondary "Planları gör" link/button: h-11 minimum on mobile (<sm), may compress to h-9 on ≥sm since pointer hit targets relax there per FN-415 acceptance.

2.6 Telemetry hooks

  • data-faro-user-action-name="vin-decode-subscribe-cta" on the primary button.
  • data-faro-user-action-name="vin-decode-subscribe-secondary" on the "Planları gör" affordance.
  • PostHog events (fire from the click handlers, not from render):
    • vin_decode_subscription_wall_shown — fired once when the banner first renders for a given error. Properties: { vin: cleanVin, query_source: querySourceRef.current }.
    • vin_decode_subscription_cta_clicked — fired on primary click. Properties: { vin, cta: "primary" }.
    • vin_decode_subscription_secondary_clicked — fired on secondary click. Properties: { vin, cta: "secondary" }.
  • These names align with the existing vin_decode_* namespace in search.tsx (vin_decode_success, vin_decode_error, vin_decode_retry_clicked).

2.7 Layout — Mobile (375 × 667)

┌───────────────────────────────────────┐
│ ✨  Şase çözümleme Pro özelliğidir    │  ← icon top-left, headline
│                                       │
│ Araç verilerine ve OEM kataloğuna     │  ← body, muted
│ erişmek için aboneliğinizi başlatın.  │
│ İptal hakkı her zaman sizde.          │
│                                       │
│ ┌───────────────────────────────────┐ │
│ │      Aboneliği başlat        →    │ │  ← primary, h-11, w-full
│ └───────────────────────────────────┘ │
│                                       │
│            Planları gör               │  ← secondary, centered link
└───────────────────────────────────────┘

2.8 Layout — Desktop (≥sm)

Same block, same vertical order. The primary CTA stays full-width within the form column (form is centered with max-w-lg or similar; check existing wrapper). Secondary stays a centered text link.

Rationale for not putting buttons side-by-side: the form column is narrow and the primary action should dominate. Side-by-side increases mis-tap risk for the secondary action which is the "wait, just browse" off-ramp.


3. Unknown-VIN Branch (error.includes("tanınamadı"))

3.1 Semantic class — soft failure

Server returned "VIN not in database." This is a failure but it is not retryable — re-decoding the same VIN with the same backend will return the same result. The current "report to admin" CTA is correct as a tertiary affordance but is too thin as the primary path: most users mistype a VIN or paste from the wrong field, so the highest-value affordance is "fix the VIN you just entered" or "search a different way."

We keep the destructive banner shell (this is still a failure path) but replace the primary action set:

  1. Primary: Clear-and-re-enter (most likely cause: typo).
  2. Secondary: Help — opens a tooltip/inline tip explaining valid VIN format. No external help-article link is needed; the format rule is short and lives entirely inline.
  3. Tertiary: Existing "Şase no doğru, sistem yöneticisine gönder" — preserved for the genuine "VIN is correct but not indexed" case. Demoted visually (variant="ghost" or variant="link").
  4. No retry button. Retrying the same VIN against the same backend is the rage-click pattern FN-367 was built to prevent — surfacing a Tekrar Dene here would teach the wrong mental model.

3.2 Visual treatment

  • Outer block: same destructive banner as generic error — role="alert", aria-live="assertive", border-destructive/40, bg-destructive/10, AlertCircle text-destructive. This branch IS an error.
  • CTA cluster sits inside the banner below the body copy, same slot as the FN-367 retry button.

3.3 Copy (plain Turkish)

  • Headline: Şase tanınmadı
  • Body: Girdiğiniz şase numarası veritabanımızda bulunamadı. Genellikle bir yazım hatası olur — 17 hane, sadece harf ve rakam, "I", "O", "Q" kullanılmaz.
  • Primary CTA label: Şaseyi temizle ve yeniden gir
  • Secondary CTA label: Şase formatı nedir? (inline help disclosure; toggles a short tip — see §3.5)
  • Tertiary CTA label (preserved from current): Şase no doğru, sistem yöneticisine gönder — demoted to variant="ghost" (text-only, no border), kept below the cluster.

Why not "Tekrar Dene": explicitly rejected — see §3.1. Why not "Manuel parça araması": out of scope; there is no manual-parts search route on /dashboard/search today. If product wants this affordance, file a follow-up referencing FN-420.

3.4 Primary action — "Şaseyi temizle ve yeniden gir"

On click:

  1. Set vin state to "".
  2. Clear error, preview, previewError.
  3. Call inputRef.current?.focus().
  4. Fire vin_decode_unknown_clear_clicked (see §3.6).

Implementation note: this is exactly the existing input's clear-and-refocus behavior; reuse the setVin("") path and inputRef.

3.5 Secondary action — "Şase formatı nedir?"

Inline disclosure (not a modal, not a navigation). Use a <details> element or local useState boolean. When open, renders:

Geçerli bir VIN:
- Tam olarak 17 karakter
- Sadece harf (AZ, "I" "O" "Q" hariç) ve rakam (09)
- Boşluk veya tire yok

Çoğu araçta motor bölmesi etiketinde, ön cam altında veya
ruhsatta bulunur.

This duplicates info that the existing VIN_REGEX enforces but is currently undocumented in the UI. Keep it inline; no help-center link.

3.6 Telemetry hooks

  • data-faro-user-action-name="vin-decode-unknown-help" on the "Şase formatı nedir?" toggle.
  • data-faro-user-action-name="vin-decode-unknown-clear" on the primary "Şaseyi temizle ve yeniden gir" button.
  • data-faro-user-action-name="vin-decode-unknown-report" on the demoted report-to-admin button (rename from current — current has no Faro hook).
  • PostHog events:
    • vin_decode_unknown_shown — fired once when the banner first renders. Properties: { vin: cleanVin, query_source: querySourceRef.current }.
    • vin_decode_unknown_clear_clicked — primary action. Properties: { vin }.
    • vin_decode_unknown_help_toggled — secondary disclosure. Properties: { vin, opened: boolean }.
    • vin_decode_unknown_report_clicked — tertiary report. Properties: { vin }. (Today no PostHog event fires for this click; wire it as part of follow-up.)

3.7 Hit-target

  • Primary button: h-11 w-full rounded-xl (44 px) — meets FN-415 baseline.
  • Secondary disclosure trigger: h-11 on mobile; the trigger text wraps inside a button-shaped target. On ≥sm may compress to h-9.
  • Tertiary report button: h-11 w-full (kept at 44 px — it's still a real action, just visually de-emphasized via variant, not via size).

3.8 Layout — Mobile (375 × 667)

┌───────────────────────────────────────┐
│ ⚠  Şase tanınmadı                     │
│                                       │
│ Girdiğiniz şase numarası               │
│ veritabanımızda bulunamadı.            │
│ Genellikle bir yazım hatası olur —     │
│ 17 hane, sadece harf ve rakam,         │
│ "I", "O", "Q" kullanılmaz.             │
│                                       │
│ ┌───────────────────────────────────┐ │
│ │  Şaseyi temizle ve yeniden gir    │ │  ← primary, h-11, w-full
│ └───────────────────────────────────┘ │
│                                       │
│         Şase formatı nedir? ▾          │  ← secondary, ghost
│                                       │
│  Şase no doğru, yöneticiye gönder      │  ← tertiary, ghost, demoted
└───────────────────────────────────────┘

When the help disclosure is open, the inline tip expands between the primary button and the tertiary report row; the banner grows vertically — no overlay, no scroll trap.

3.9 Layout — Desktop (≥sm)

Same vertical stack inside the form column. No side-by-side variants — the primary action should remain dominant; the secondary/tertiary are clearly subordinate.


4. Cross-Cutting Specifications

4.1 ARIA

  • Both branches: keep role="alert" and aria-live="assertive". Subscription-wall is not an "error" semantically but it interrupts the user's task — alert role is the correct AT cue.
  • Add aria-atomic="true" to the alert container so screen readers re-announce the full block on state transitions (e.g. submit → error change).
  • Help disclosure (§3.5) uses native <details>/<summary> for free accessibility, OR a useState boolean with aria-expanded + aria-controls on the trigger.

4.2 Animation / micro-interaction

  • Banner appearance: keep current behavior (no explicit transition). Avoid Framer Motion / explicit fade-in — the role="alert" announcement is what matters; visual fade can delay AT delivery.
  • Help disclosure: no animation required (native <details> is fine). If using useState, an instant show/hide is acceptable.

4.3 Locator stability (per frontend-testability-hook-convention)

  • NO data-testid attributes. Locators are ordered: data-faro-user-action-name > getByRole({ name }) > stable text.
  • All data-faro-user-action-name strings introduced here (vin-decode-subscribe-cta, vin-decode-subscribe-secondary, vin-decode-unknown-clear, vin-decode-unknown-help, vin-decode-unknown-report) become public locator contract — changing them requires updating verification specs (per FN-415 pattern).
  • Banner role + headline text (Şase çözümleme Pro özelliğidir, Şase tanınmadı) are stable text locators; rely on them in tests rather than CSS class selectors.

4.4 Telemetry event naming convention

All events use the vin_decode_* namespace already in search.tsx. Branch-specific suffixes:

Branch Shown Action (primary) Action (secondary)
Subscription wall vin_decode_subscription_wall_shown vin_decode_subscription_cta_clicked vin_decode_subscription_secondary_clicked
Unknown VIN vin_decode_unknown_shown vin_decode_unknown_clear_clicked vin_decode_unknown_help_toggled, vin_decode_unknown_report_clicked

Common properties: vin: cleanVin, query_source: querySourceRef.current. Click events additionally include any branch-specific state (e.g. opened: boolean for the help toggle).

4.5 Branch routing in JSX

The existing guard on the retry button (!error.includes("abone olun") && !error.includes("tanınamadı")) is the existing brittle string-match pattern. The implementation task should:

  1. Compute a errorBranch value once per render: "subscription" | "unknown" | "generic" | null.
  2. Render the matching block. Do NOT compound the string-match — derive the branch once.
  3. Keep the substring tests ("abone olun", "tanınamadı") as-is for now; refactoring the server contract to a typed error code is out of scope for this UX spec and tracked separately if it comes up.

5. Non-Goals (reiterated)

  • No payment-flow redesign. The upgrade CTA lands on /dashboard/subscription (plan picker), not directly into checkout.
  • No new generic error states. FN-367's banner+retry remains canonical for any error string that does not contain "abone olun" or "tanınamadı".
  • No backend / server-error-shape changes.
  • No code edits — implementation goes to a follow-up task.
  • No manual-parts-search affordance — would require a new route; file separately if product wants it.

6. Acceptance for Follow-up Implementation Task

A follow-up implementation task is scopable from this spec with no further design questions. Implementation acceptance:

  1. Subscription-wall branch renders the upgrade-prompt block per §2 — neutral/brand colors, Sparkles icon, headline + body + primary Aboneliği başlat button (linking to /dashboard/subscription) + secondary Planları gör link.
  2. Unknown-VIN branch renders the failure banner per §3 — destructive colors, AlertCircle, headline Şase tanınmadı, body explaining the format rule, primary Şaseyi temizle ve yeniden gir, inline help disclosure, demoted tertiary report.
  3. No Tekrar Dene button shows on either branch (existing guard preserved or replaced by errorBranch switch).
  4. All data-faro-user-action-name hooks in §4.3 present and stable.
  5. All PostHog events in §4.4 fire from click handlers with the documented property shapes.
  6. All interactive elements ≥44 × 44 px on <sm viewport per FN-415 baseline.
  7. Verification test in apps/web/playwright/ covers: subscription-wall renders + CTA navigates to /dashboard/subscription; unknown-VIN renders + clear button empties input + help disclosure toggles. Locators per §4.3 — no data-testid.
  8. No regression on generic-error branch (existing FN-367 retry banner still renders unchanged for any other error string).

7. Open Questions (for product, not blocking implementation)

  • Should the subscription-wall vin_decode_subscription_wall_shown event be deduped per session (one fire per user) or per error occurrence (every render)? Spec says per render; product can adjust during implementation review without a re-spec.
  • If the user is on a trial that just expired vs. has never had a subscription, the copy could differ ("trial expired" vs. "Pro feature"). Backend currently returns the same "abone olun" string for both. Distinguishing these would require a server contract change — out of scope.