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
19 KiB
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 ~414–475)
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 readingabone 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-emphasisvariant="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. Usearia-atomic="true"so the full block is re-announced. - Component: Distinct upgrade-prompt block — NOT the destructive banner. Same outer
rounded-xl border p-4shell 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 avariant="link"button sized to baseline.
Voice check: "abone olun" (current server message) becomes "Aboneliği başlat" (CTA) — imperative, matches the verb tense in
proceedToPaymentflow. "Pro özelliği" matches existing copy on/dashboard/subscriptionplan cards.
2.4 Primary action
- Destination:
<Link to="/dashboard/subscription">rendered as a<Button>(useButton asChildpattern 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
Buttoncomponent withasChildso 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 existingvin-decode-retrybutton) — meets FN-415 ≥44 × 44 baseline. - Secondary "Planları gör" link/button:
h-11minimum on mobile (<sm), may compress toh-9on≥smsince 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 insearch.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:
- Primary: Clear-and-re-enter (most likely cause: typo).
- 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.
- 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"orvariant="link"). - 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 Denehere 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 tovariant="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/searchtoday. If product wants this affordance, file a follow-up referencing FN-420.
3.4 Primary action — "Şaseyi temizle ve yeniden gir"
On click:
- Set
vinstate to"". - Clear
error,preview,previewError. - Call
inputRef.current?.focus(). - 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 (A–Z, "I" "O" "Q" hariç) ve rakam (0–9)
- 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-11on mobile; the trigger text wraps inside a button-shaped target. On≥smmay compress toh-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"andaria-live="assertive". Subscription-wall is not an "error" semantically but it interrupts the user's task —alertrole 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 auseStateboolean witharia-expanded+aria-controlson the trigger.
4.2 Animation / micro-interaction
- Banner appearance: keep current behavior (no explicit transition). Avoid
Framer Motion/ explicit fade-in — therole="alert"announcement is what matters; visual fade can delay AT delivery. - Help disclosure: no animation required (native
<details>is fine). If usinguseState, an instant show/hide is acceptable.
4.3 Locator stability (per frontend-testability-hook-convention)
- NO
data-testidattributes. Locators are ordered:data-faro-user-action-name>getByRole({ name })> stable text. - All
data-faro-user-action-namestrings 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:
- Compute a
errorBranchvalue once per render:"subscription" | "unknown" | "generic" | null. - Render the matching block. Do NOT compound the string-match — derive the branch once.
- 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:
- Subscription-wall branch renders the upgrade-prompt block per §2 — neutral/brand colors,
Sparklesicon, headline + body + primaryAboneliği başlatbutton (linking to/dashboard/subscription) + secondaryPlanları görlink. - 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. - No
Tekrar Denebutton shows on either branch (existing guard preserved or replaced byerrorBranchswitch). - All
data-faro-user-action-namehooks in §4.3 present and stable. - All PostHog events in §4.4 fire from click handlers with the documented property shapes.
- All interactive elements ≥44 × 44 px on
<smviewport per FN-415 baseline. - 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 — nodata-testid. - 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_shownevent 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.