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
This commit is contained in:
Fusion
2026-05-17 10:25:04 +00:00
parent 5118c8e5e9
commit 4fc7d35c7a

View File

@@ -0,0 +1,280 @@
# 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:
```tsx
{!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.