Mitra §A: pre-home (S3a/S3b/AccountInactive) + design system + Bestie Home

- Port halo_tokens + halo_theme + HaloButton to mitra_app (rose palette,
  Bricolage display, Poppins body, JetBrainsMono).
- Build S3a Input WhatsApp (figma-bestie BestieS3 first half) with
  +62 chip, leading-zero/62 normalization, allow '+' in input.
- Build S3b OTP verification (6-digit, 60s resend timer, attempts hint,
  Focus(canRequestFocus:false) for maestro inputText compat) with full
  error branching (CODE_MISMATCH, OTP_EXPIRED, OTP_USED, ATTEMPTS_EXCEEDED,
  WRONG_FLOW, ACCOUNT_INACTIVE).
- Add AccountInactive terminal screen for is_active=false mitras.
- Typed MitraAuthError with Indonesian-first localized messages +
  retryAfterSeconds passthrough.
- Rebuild home_screen.dart to match figma BestieHome (greeting + status
  card + Ganti Status CTA + Pengingat + 2-tile dark grid).
- Backend: POST /internal/_test/seed-mitra (idempotent) and
  PATCH /internal/mitras/:id (display_name update).
- Control center: inline Edit Nama on mitras row + expandable inline log
  table under clicked mitra (vs old below-table panel).
- 5 maestro flows ts-mitra-A-01/03/04/05/06 covering invalid input, happy
  path, account inactive, phone-format normalization, and the back-to-S3a
  regression. All green.

Plan + memory documented in:
- requirement/phase4-mitra-prehome-plan.md
- requirement/flow_mitra.md / flow_mitra.mermaid.md §A

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 22:01:28 +08:00
parent ad02ee252d
commit 9696eadeaf
37 changed files with 3406 additions and 326 deletions

View File

@@ -0,0 +1,231 @@
# Mitra App Flow — Mermaid Diagrams
> Generated from [`flow_mitra.md`](flow_mitra.md) and cross-checked against the
> Claude-Design handoff bundle in
> [`mitra_app/figma-bestie/project/screens/`](../mitra_app/figma-bestie/project/screens/)
> (HTML/JSX prototype — not part of the build).
>
> Scope:
> - **§A** Pre-home auth (phone OTP + all retry/limit edge cases). No Claude-Design
> screens exist for this section yet — diagrams are spec-only and flag the
> missing screens the mitra_app needs to build.
> - **§1§3** In-app flow once authenticated, mapped to the Bestie-* components
> in the Figma drop.
## Screen ↔ design-component map
| Flow node | Design component | Source |
|---|---|---|
| Home — online standby | `BestieHome` (online=true) | [v4.jsx:417](../mitra_app/figma-bestie/project/screens/v4.jsx#L417) |
| Home — offline | `BestieHomeOffline` (or `BestieHome` online=false) | [v5.jsx:188](../mitra_app/figma-bestie/project/screens/v5.jsx#L188) · [v4.jsx:417](../mitra_app/figma-bestie/project/screens/v4.jsx#L417) |
| Bottom nav (Home / Chat / Profil) | `BestieTabBar` | [v4.jsx:464](../mitra_app/figma-bestie/project/screens/v4.jsx#L464) |
| Undangan list — "Curhat Baru" tab | `BestieInvites` | [v4.jsx:480](../mitra_app/figma-bestie/project/screens/v4.jsx#L480) |
| Undangan list — "Perpanjang Curhat" tab | `BestieInvitesExtend` | [v5.jsx:75](../mitra_app/figma-bestie/project/screens/v5.jsx#L75) |
| Profil | `BestieProfile` | [v5.jsx:5](../mitra_app/figma-bestie/project/screens/v5.jsx#L5) |
| Incoming popup — new curhat | `BestieIncomingPopup` (variant=`new`) | [v5.jsx:129](../mitra_app/figma-bestie/project/screens/v5.jsx#L129) |
| Incoming popup — perpanjang | `BestieIncomingPopup` (variant=`extend`) | [v5.jsx:129](../mitra_app/figma-bestie/project/screens/v5.jsx#L129) |
| Chat room — sesi aktif | `BestieChatV5` (ended=false) · earlier draft `BestieChat` | [v5.jsx:222](../mitra_app/figma-bestie/project/screens/v5.jsx#L222) · [v4.jsx:523](../mitra_app/figma-bestie/project/screens/v4.jsx#L523) |
| Chat room — durasi habis | `BestieChatV5` (ended=true) | [v5.jsx:222](../mitra_app/figma-bestie/project/screens/v5.jsx#L222) |
> Design tokens & primitives (`HBOrb`, `HBButton`, palette `t`) come from
> [tokens.jsx](../mitra_app/figma-bestie/project/screens/tokens.jsx) and
> [primitives.jsx](../mitra_app/figma-bestie/project/screens/primitives.jsx) — see
> [project/CLAUDE.md](../mitra_app/figma-bestie/project/CLAUDE.md) before slicing.
---
## A. Pre-Home — auth + OTP retry scenarios
> Backend enforces four OTP limits in
> [`otp.service.js`](../backend/src/services/otp.service.js); defaults from
> [`config.service.js:215-218`](../backend/src/services/config.service.js#L215-L218):
> verify_max_attempts=**5**, resend_cooldown=**60s**, max_per_phone=**3/h**,
> max_per_ip=**10/h**, OTP_TTL=**5min**. All four are tunable via the control
> center config.
>
> The current mitra app
> ([login_screen.dart](../mitra_app/lib/features/auth/screens/login_screen.dart) /
> [otp_screen.dart](../mitra_app/lib/features/auth/screens/otp_screen.dart))
> renders raw `error.toString()` in a snackbar for every error — no resend
> button, no attempts-remaining hint, no blocked popup. Everything below marked
> 🆕 is missing UI the slicer needs to add.
### A.1 Boot → login → OTP request
```mermaid
flowchart TD
Boot["App boot"] --> Token{"Refresh token<br/>valid?"}
Token -->|"yes"| Home["→ Home (skip auth)"]
Token -->|"no / expired"| Login["S3a · Input WhatsApp<br/><i>login_screen.dart</i>"]
Login -->|"Kirim OTP"| ReqApi["POST /api/mitra/auth/otp/request"]
ReqApi --> ReqOk{"Response"}
ReqOk -->|"200 ok"| OtpScreen["→ S3b · OTP verification"]
ReqOk -->|"422 PHONE_INVALID"| ErrPhone["🆕 Inline field error<br/>'Format nomor salah'"]
ErrPhone --> Login
ReqOk -->|"429 OTP_COOLDOWN"| ErrCool["🆕 Snackbar w/ countdown<br/>'Tunggu N detik' (retry_after_seconds)"]
ErrCool --> Login
ReqOk -->|"429 OTP_RATE_LIMIT_PHONE<br/>(3 reqs / hour)"| ErrPhoneLim["🆕 Popup · 'Terlalu banyak<br/>permintaan untuk nomor ini'<br/>+ retry-after timer"]
ErrPhoneLim --> Login
ReqOk -->|"429 OTP_RATE_LIMIT_IP<br/>(10 reqs / hour)"| ErrIpLim["🆕 Popup · 'Terlalu banyak<br/>permintaan dari jaringan ini'<br/>+ Hubungi admin CTA"]
ErrIpLim --> Login
classDef missing fill:#ffe5e5,stroke:#c44979
class ErrPhone,ErrCool,ErrPhoneLim,ErrIpLim missing
```
### A.2 S3b verify — happy + every error path
```mermaid
flowchart TD
Otp["S3b · OTP verification (6-digit)<br/><i>otp_screen.dart</i><br/>🆕 + 'Kirim ulang kode' (60s cooldown)<br/>🆕 + 'Tersisa N percobaan' hint"]
Otp -->|"6 digits entered"| Verify["POST /api/mitra/auth/otp/verify"]
Verify --> Resp{"Response"}
Resp -->|"200 + is_active=true"| Home["→ Home<br/>(store tokens)"]
Resp -->|"422 CODE_INVALID"| BadFmt["🆕 Inline · 'Kode harus 6 digit'"]
BadFmt --> Otp
Resp -->|"401 CODE_MISMATCH<br/>(attempts &lt; 5)"| Wrong["🆕 Clear fields · focus 1st<br/>'Kode salah · tersisa N percobaan'"]
Wrong --> Otp
Resp -->|"429 OTP_ATTEMPTS_EXCEEDED<br/>(5th wrong attempt)"| Blocked["🆕 Popup · 'Terlalu banyak percobaan'<br/>CTAs: Minta kode baru / Hubungi admin"]
Blocked -->|"Minta kode baru"| ResendNew["→ back to S3a (prefilled)"]
Blocked -->|"Hubungi admin"| Admin["External · WA / TG"]
Resp -->|"410 OTP_EXPIRED<br/>(&gt; 5 min)"| Exp["🆕 Popup · 'Kode kadaluarsa'<br/>CTA: Minta kode baru"]
Exp --> ResendNew
Resp -->|"409 OTP_USED"| Used["🆕 Popup · 'Kode sudah dipakai'<br/>CTA: Minta kode baru"]
Used --> ResendNew
Resp -->|"400 WRONG_FLOW<br/>(non-mitra OTP)"| Wrong2["🆕 Popup · 'Bukan akun mitra'"]
Wrong2 --> ResendNew
Resp -->|"403 ACCOUNT_INACTIVE<br/>(code correct but mitra not approved)"| Inactive["🆕 Full-screen · 'Akun belum aktif'<br/>CTAs: WhatsApp admin / Telegram admin<br/>NO retry"]
classDef missing fill:#ffe5e5,stroke:#c44979
class Otp,BadFmt,Wrong,Blocked,Exp,Used,Wrong2,Inactive missing
```
### A.3 Resend cooldown — local timer (on S3b)
```mermaid
flowchart TD
OtpView["S3b mounted"] --> Timer["🆕 Local 60s countdown<br/>starts on every request"]
Timer --> Btn{"Cooldown done?"}
Btn -->|"no"| Disabled["'Kirim ulang dalam Ns'<br/>button disabled"]
Disabled --> Timer
Btn -->|"yes"| Enabled["'Kirim ulang kode' enabled"]
Enabled -->|"tap"| ReReq["POST /api/mitra/auth/otp/request"]
ReReq -->|"200"| Reset["replace otp_request_id<br/>reset local attempts counter<br/>restart 60s timer"]
Reset --> Timer
ReReq -->|"429 OTP_COOLDOWN"| BackendCool["🆕 Snackbar w/ server retry_after<br/>(should never happen if local timer is right)"]
classDef missing fill:#ffe5e5,stroke:#c44979
class Timer,Disabled,Enabled,Reset,BackendCool missing
```
### Implementation gaps (mitra_app)
| Screen / element | Where it lives today | What's missing |
|---|---|---|
| Inline phone-format error | [login_screen.dart](../mitra_app/lib/features/auth/screens/login_screen.dart) | All errors render as raw snackbar — needs field-level error + typed handler |
| Cooldown / rate-limit popups | login_screen.dart | No popup variants; `retry_after_seconds` is ignored |
| Resend button + 60s timer | [otp_screen.dart](../mitra_app/lib/features/auth/screens/otp_screen.dart) | Code comment hints at it (line 72) but no UI |
| Attempts-remaining hint | otp_screen.dart | No local counter; user has no warning before the 5th attempt locks them out |
| `OTP_ATTEMPTS_EXCEEDED` popup | otp_screen.dart | Renders as plain snackbar; no CTA to recover |
| `OTP_EXPIRED` / `OTP_USED` popup | otp_screen.dart | Same — plain snackbar |
| `WRONG_FLOW` popup | otp_screen.dart | Same — plain snackbar |
| `ACCOUNT_INACTIVE` screen | otp_screen.dart | Renders as plain snackbar; should be a full-screen state with admin contact CTAs |
> Design note: there's no Bestie-design equivalent for any of these screens
> (the figma-bestie drop starts at Home). Style with `t.brandSofter` / `t.danger`
> from [tokens.jsx](../mitra_app/figma-bestie/project/screens/tokens.jsx) for
> visual continuity, and reuse `HBButton` patterns from the customer-side
> [primitives.jsx](../mitra_app/figma-bestie/project/screens/primitives.jsx)
> until a mitra-specific design exists.
---
## 1. Home + availability gating
```mermaid
flowchart TD
Boot["App boot (post-auth)"] --> Status{"Mitra status?"}
Status -->|"offline"| HomeOff["Home · OFFLINE<br/><i>BestieHomeOffline</i>"]
Status -->|"online"| HomeOn["Home · standby (online)<br/><i>BestieHome online=true</i>"]
HomeOff -- "Ganti Status" --> HomeOn
HomeOn -- "Ganti Status" --> HomeOff
HomeOn --> Tabs["Bottom nav<br/><i>BestieTabBar</i>"]
HomeOff --> Tabs
Tabs -->|"Home"| HomeOn
Tabs -->|"Chat"| Undangan
Tabs -->|"Profil"| Profil
HomeOn -->|"tap Undangan tile"| Undangan["Undangan · Curhat Baru tab<br/><i>BestieInvites</i>"]
HomeOn -->|"tap Perpanjang tile"| UndanganExt["Undangan · Perpanjang tab<br/><i>BestieInvitesExtend</i>"]
HomeOn -->|"tap Profil"| Profil["Profil<br/><i>BestieProfile</i>"]
HomeOff -.->|"tiles disabled while offline"| HomeOff
```
---
## 2. Undangan list (tabbed) — accept / reject
```mermaid
flowchart TD
Entry["Home tile or Chat tab"] --> Tabs{"Which tab?"}
Tabs -->|"Curhat Baru"| InvNew["Undangan · Curhat Baru<br/><i>BestieInvites</i><br/>(new-client cards, brand pink)"]
Tabs -->|"Perpanjang Curhat"| InvExt["Undangan · Perpanjang<br/><i>BestieInvitesExtend</i><br/>(amber accent, +mins badge)"]
InvNew -->|"Tolak"| Home1["← back to Home"]
InvExt -->|"Tolak"| Home1
InvNew -->|"Terima"| ChatActive
InvExt -->|"Terima Perpanjangan"| ChatActive["Chat · sesi aktif<br/><i>BestieChatV5 ended=false</i>"]
```
> Note: both Undangan variants share the same header + tab bar. The amber palette
> + `+N mnt` badge on `BestieInvitesExtend` is the visual cue separating
> extension invites from new-curhat invites.
---
## 3. Incoming request popup → chat → session end
```mermaid
flowchart TD
Idle["Mitra online (any screen)"] --> Push{"Incoming request"}
Push -->|"new curhat"| PopNew["Popup · Curhat Baru<br/><i>BestieIncomingPopup variant=new</i><br/>(brand pink, 30s window)"]
Push -->|"perpanjang"| PopExt["Popup · Perpanjang<br/><i>BestieIncomingPopup variant=extend</i><br/>(amber, 10s auto-accept)"]
PopNew -->|"Tolak"| Idle
PopExt -->|"Tolak"| Idle
PopNew -->|"Terima Sekarang"| Chat
PopExt -->|"Terima · +N mnt"| Chat
Chat["Chat · sesi aktif<br/><i>BestieChatV5 ended=false</i><br/>(SISA WAKTU pill, input bar)"]
Chat -->|"timer hits 00:00"| Ended["Chat · durasi habis<br/><i>BestieChatV5 ended=true</i><br/>(SELESAI pill, input replaced by notice)"]
Ended -->|"tunggu perpanjang"| PopExt
Ended -->|"tutup obrolan"| Idle
```
---
## Open questions / gaps vs. design
- **No design for the "no invitations" empty state** of `BestieInvites` /
`BestieInvitesExtend` — both prototypes ship with 2 stub items. Confirm whether
the empty state should reuse the home `Undangan: Belum ada` tile copy.
- **Offline + incoming**: design only shows the popup on `Idle` (online). Spec is
silent on what happens if a request arrives while offline — likely suppressed
by backend, but worth confirming so we don't render a dead popup.
- **`BestieOfflinePopup`** ([v4.jsx:244](../mitra_app/figma-bestie/project/screens/v4.jsx#L244))
appears to be customer-side ("semua bestie lagi istirahat") — excluded from
this mitra flow.
- **Reject animation/feedback**: design returns to home with no toast. Confirm
whether a "ditolak" snackbar is desired for parity with extension UX.