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,402 @@
# Mitra Pre-Home OTP — Implementation Plan
> **Status (2026-05-19):** Shipped. All five stages complete; 5 maestro
> flows green (`ts-mitra-A-01/03/04/05/06`). Plus a partial Bestie Home
> rebuild (greeting + status card + Ganti Status CTA + Pengingat). Remaining
> Bestie design work — BestieTabBar bottom nav, Undangan (both tabs), Profil,
> Incoming popups, Chat sesi aktif / durasi habis — is **not** in this plan;
> tracked in [[project-mitra-prehome-shipped]] memory.
>
> **Scope tweak during implementation:** mitras are internal-only — no public
> WhatsApp/Telegram admin CTAs surface on the pre-home screens. They reach the
> team through their existing internal channel (Slack, coordinator, etc).
> Stage 2 (admin contact helper + `url_launcher`) was removed; all "Hubungi
> admin" buttons stripped from S3a rate-limit popup, S3b blocked dialog, and
> AccountInactive. `support_handles_json` config remains the source of truth
> for customer-facing admin contact and will be fetched post-login by the
> mitra profile page in a separate change.
>
> Implements [flow_mitra.md §A](flow_mitra.md) (sub-flows A1A4) and the
> "Implementation gaps (mitra_app)" table in
> [flow_mitra.mermaid.md §A](flow_mitra.mermaid.md).
> Spec sources:
> - PRD / flow: [flow_mitra.md §A](flow_mitra.md)
> - Diagrams: [flow_mitra.mermaid.md §A](flow_mitra.mermaid.md)
>
> Backend behavior is already correct — every error code, retry-after detail,
> and `ACCOUNT_INACTIVE` gate is in
> [`otp.service.js`](../backend/src/services/otp.service.js) and
> [`mitra.auth.routes.js`](../backend/src/routes/public/mitra.auth.routes.js).
> **No backend changes in this plan.** All work is mitra_app-side.
This document is the build sequence: **what** files change, **in what order**,
with **state-machine contracts** and **error-code routing** spelled out per
screen. The "why" is in the PRD — don't restate it here.
---
## Build Order (5 stages — Stage 2 dropped during impl)
Stage 1 is foundation (typed error) that everything else builds on. Stages 34
are the two screens. Stage 5 adds the terminal state. Stage 6 is verification.
1. **Typed auth-error model** — replace string `AsyncError` with structured `MitraAuthError`
2. ~~**Admin contact helper** — single source of truth for WA / Telegram URLs + `url_launcher` wrapper~~**dropped**: mitras are internal-only, no public admin CTA needed on pre-home screens.
3. **S3a · Input WhatsApp** — replace generic snackbar with code-routed handling
4. **S3b · OTP verification** — resend timer, attempts hint, six new dialog states
5. **AccountInactiveScreen + router wiring** — terminal full-screen state for `ACCOUNT_INACTIVE`
6. **Manual verification** — drive every error path with `OTP_STATIC_CODE` + CC toggles
Each stage is independently mergeable. Stage 4 is the largest single change
(~150 LoC across one file).
---
# Stage 1 — Typed auth-error model
The current
[`auth_notifier.dart`](../mitra_app/lib/core/auth/auth_notifier.dart)
flattens every error into `AsyncError(string)` via `_otpRequestMessage` /
`_otpVerifyMessage`. The screen can read the localized message but loses the
error **code** and the `retry_after_seconds` detail — both of which the UI
needs to pick between snackbar / dialog / inline / full-screen, and to render
countdowns.
## 1.1 New class
> **New:** in [`auth_notifier.dart`](../mitra_app/lib/core/auth/auth_notifier.dart),
> alongside the existing `MitraAuthData` sealed hierarchy.
```dart
class MitraAuthError implements Exception {
final String code; // e.g. 'OTP_COOLDOWN', 'ACCOUNT_INACTIVE'
final String message; // localized, ready to show as-is
final int? retryAfterSeconds;
const MitraAuthError(this.code, this.message, {this.retryAfterSeconds});
@override
String toString() => message; // preserves existing snackbar fallback behavior
}
```
The `toString()` override means any screen that hasn't been updated yet will
keep working (just rendering the message string).
## 1.2 Parse helpers
Replace `_otpRequestMessage` / `_otpVerifyMessage` with a single
`_buildError(DioException e, Map<String, String> codeToMessage)` that:
1. Extracts `code`, `message`, and `details.retry_after_seconds` from
`e.response?.data?['error']` (defaulting safely).
2. Returns a `MitraAuthError`. The `codeToMessage` arg supplies the localized
fallback when the backend doesn't include a message string.
The two existing string maps (request vs verify) become `static const`
`Map<String, String>` lookups so the codeToMessage tables don't drift.
## 1.3 Update `requestOtp` / `verifyOtp`
Replace:
```dart
state = AsyncError(_otpRequestMessage(e), StackTrace.current);
```
with:
```dart
state = AsyncError(_buildError(e, _kRequestMessages), StackTrace.current);
```
The unknown-error fallback (the bare `catch(_)`) also returns a
`MitraAuthError('UNKNOWN', 'Gagal …')` so the screen never receives a raw
string.
## 1.4 Acceptance
- `auth_notifier.dart` exports `MitraAuthError`.
- Every `AsyncError` emitted by the notifier carries one.
- Existing call sites (login_screen / otp_screen) still compile because
`error.toString()` still returns the message.
---
# Stage 2 — ~~Admin contact helper~~ (dropped)
> Dropped during implementation. Mitras are an internal cohort and have a
> separate, non-public support channel (coordinator / Slack / WhatsApp group
> that ops onboards them through). Surfacing the customer-facing
> `support_handles_json` numbers on the pre-home screens would mix audiences.
>
> Consequence in the screens that follow: any "Hubungi admin" CTA is omitted.
> The IP-rate-limit popup, the attempts-exceeded dialog, and the
> AccountInactive screen all rely on the mitra knowing how to reach their
> internal contact.
>
> The post-login mitra profile page will still surface
> `support_handles_json` (separate change) — that's a different audience
> (the customer-facing admin who handles billing/escalation questions).
---
# Stage 3 — S3a · Input WhatsApp
Update [`login_screen.dart`](../mitra_app/lib/features/auth/screens/login_screen.dart)
to route on `MitraAuthError.code` instead of dumping every error into a
snackbar.
## 3.1 State additions
```dart
String? _phoneErrorText; // inline TextField error
int? _phoneRateLimitRetryAfter; // drives popup countdown
```
## 3.2 `ref.listen` rewrite
Replace the existing AsyncError branch:
```dart
if (next is AsyncError && next.error is MitraAuthError) {
final err = next.error as MitraAuthError;
switch (err.code) {
case 'PHONE_INVALID':
setState(() => _phoneErrorText = err.message);
break;
case 'OTP_COOLDOWN':
// server message already includes seconds
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(err.message)));
break;
case 'OTP_RATE_LIMIT_PHONE':
await _showRateLimitDialog(err, isIp: false);
break;
case 'OTP_RATE_LIMIT_IP':
await _showRateLimitDialog(err, isIp: true);
break;
default:
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(err.message)));
}
}
```
## 3.3 `_showRateLimitDialog`
`AlertDialog` with:
- Title: "Terlalu banyak permintaan untuk nomor ini" (or "…dari jaringan ini" for IP)
- Body: server message + retry-after subtext if present
- Single action: "Tutup" (Navigator.pop)
No live countdown inside the dialog (just static text "Coba lagi dalam N
menit"). Keeping it static avoids `Timer.periodic` inside a dialog — overkill
for an error popup. No admin CTA (see Stage 2 note).
## 3.4 `TextField` wiring
The phone field gets `errorText: _phoneErrorText`. Submitting clears
`_phoneErrorText` first so it doesn't linger on retry.
## 3.5 Acceptance
- Submitting `+99999` → field shows inline error, no snackbar.
- Submitting twice within 60s → snackbar with "Tunggu Ns…".
- Submitting 4× in an hour → rate-limit dialog with retry-after.
- Submitting from a heavily-trafficked IP (10/h) → IP rate-limit dialog with
"Hubungi admin" CTA that opens WA.
---
# Stage 4 — S3b · OTP verification
The biggest single file change.
[`otp_screen.dart`](../mitra_app/lib/features/auth/screens/otp_screen.dart)
gains: resend button + 60s countdown, local attempts-remaining hint, and
six branched error paths.
## 4.1 New state
```dart
Timer? _cooldownTicker;
int _cooldown = 60; // seconds until resend becomes enabled
int _attemptsUsed = 0; // 05, drives "Tersisa N percobaan"
String? _inlineError; // for CODE_INVALID
```
## 4.2 Cooldown timer
- Start in `initState` (cooldown begins the moment we land on this screen).
- `Timer.periodic(const Duration(seconds: 1), ...)` decrements `_cooldown`
until 0, then cancels itself.
- Cancel in `dispose()`. **Do not** touch ref in dispose — see
[`mitra_app/CLAUDE.md`](../mitra_app/CLAUDE.md) "no_ref_in_dispose" rule.
This timer doesn't need ref, so plain `dispose()` is fine.
## 4.3 Resend button
Below the OTP fields, above the verify button:
```dart
TextButton(
onPressed: _cooldown > 0 ? null : _resend,
child: Text(_cooldown > 0 ? 'Kirim ulang dalam ${_cooldown}s' : 'Kirim ulang kode'),
)
```
`_resend` calls `ref.read(mitraAuthProvider.notifier).requestOtp(widget.phone)`
and on success listener fires (`MitraAuthOtpSentData` re-emitted with a new
`otp_request_id`), the screen:
- resets `_attemptsUsed = 0`
- resets `_cooldown = 60` and restarts the ticker
- clears the input fields
## 4.4 Attempts-remaining hint
A small text widget below the input row:
```dart
if (_attemptsUsed > 0)
Text('Tersisa ${5 - _attemptsUsed} percobaan',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: ...))
```
Incremented in the `CODE_MISMATCH` branch (Stage 4.5).
## 4.5 `ref.listen` rewrite
```dart
if (next is AsyncError && next.error is MitraAuthError) {
final err = next.error as MitraAuthError;
switch (err.code) {
case 'CODE_INVALID':
setState(() => _inlineError = err.message);
break;
case 'CODE_MISMATCH':
setState(() {
_attemptsUsed += 1;
_inlineError = 'Kode salah. Tersisa ${5 - _attemptsUsed} percobaan';
});
_clearFieldsAndFocus();
break;
case 'OTP_ATTEMPTS_EXCEEDED':
await _showBlockedDialog();
break;
case 'OTP_EXPIRED':
case 'OTP_USED':
await _showResetDialog(err);
break;
case 'WRONG_FLOW':
await _showWrongFlowDialog(err);
break;
case 'ACCOUNT_INACTIVE':
if (mounted) context.go('/auth/inactive');
break;
default:
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(err.message)));
}
}
```
## 4.6 Dialog helpers
- **`_showBlockedDialog`** — "Terlalu banyak percobaan". Single CTA: "Minta
kode baru" (`context.pop()` → returns to S3a). No admin CTA (Stage 2 note).
- **`_showResetDialog`** — used by `OTP_EXPIRED` and `OTP_USED`. Single CTA
"Minta kode baru" → `context.pop()`.
- **`_showWrongFlowDialog`** — "Bukan akun mitra. Pastikan kamu pakai app yang
benar." Single CTA → `context.pop()`.
All dialogs use `barrierDismissible: false` — the user must choose a recovery
path.
## 4.7 Acceptance
- 5× wrong code → hint decrements, blocked dialog on 5th, "Minta kode baru"
pops to S3a.
- Wait 5 min after request, then submit → expired dialog.
- Resend within 60s of first request → cooldown blocks the button.
- After cooldown, tap resend → fields clear, hint clears, timer restarts.
- Submit code for a mitra with `is_active=false` → lands on AccountInactive.
---
# Stage 5 — AccountInactiveScreen + router
A terminal full-screen state. No back nav; the only way out is to contact
admin or sign in with a different phone.
## 5.1 New file
> **New:** `mitra_app/lib/features/auth/screens/account_inactive_screen.dart`
Wrapped in `PopScope(canPop: false)` so the system back button can't escape
the terminal state. Body: icon + title + body copy directing the mitra to
their internal coordinator, and a single "Pakai nomor lain" text button that
logs out and pops back to S3a. No public admin CTAs (Stage 2 note).
## 5.2 Router updates
In [`router.dart`](../mitra_app/lib/router.dart):
1. Add `GoRoute(path: '/auth/inactive', builder: (_, __) => const AccountInactiveScreen())`.
2. Update `redirect` to recognize `/auth/inactive` as an auth-route (so the
guard doesn't bounce the unauthenticated user away):
```dart
final isAuthRoute = state.matchedLocation.startsWith('/login') ||
state.matchedLocation.startsWith('/otp') ||
state.matchedLocation.startsWith('/auth');
```
## 5.3 Acceptance
- Verify with correct code against a mitra whose `is_active=false` →
AccountInactive renders. No back arrow, system back blocked by `PopScope`.
- Tap "Pakai nomor lain" → logged out, S3a shown.
---
# Stage 6 — Manual verification
No automated tests this round — pre-home OTP isn't currently covered by
Maestro (existing `ts-customer-*` flows are client_app-side). One mitra-side
Maestro flow could be added later as a follow-up.
## 6.1 Pre-conditions
- Backend running with `OTP_STATIC_CODE=111111` in env so we can submit a
predictable code without round-tripping `/internal/_test/peek-otp`.
- Seed two test mitras via control center:
- **Mitra A** — `+628111111111`, `is_active=true`
- **Mitra B** — `+628222222222`, `is_active=false`
## 6.2 Scenarios
| # | Trigger | Expected UI |
|---|---|---|
| 1 | S3a, submit `12345` (invalid format) | Inline TextField error "Nomor HP tidak valid." |
| 2 | S3a, submit valid phone twice within 60s | 2nd submit → snackbar "Tunggu Ns…" |
| 3 | S3a, submit valid phone 4× within 1h | 4th submit → phone rate-limit dialog with retry-after |
| 4 | S3a, submit Mitra A's phone | Pushes to S3b, 60s countdown visible |
| 5 | S3b, submit wrong code 4× | "Tersisa N percobaan" decrements 4→1 |
| 6 | S3b, submit wrong code 5th time | Blocked dialog with two CTAs |
| 7 | S3b, wait 5+ min then submit | Expired dialog → "Minta kode baru" pops to S3a |
| 8 | S3b, wait full 60s | Resend button enables; tapping resets attempts + timer |
| 9 | S3b, submit `111111` for Mitra A | Authenticated → /home |
| 10 | S3b, submit `111111` for Mitra B | AccountInactive screen |
| 11 | AccountInactive, tap "Pakai nomor lain" | Logged out, S3a shown |
| 12 | AccountInactive, press system back | Blocked by `PopScope` |
## 6.3 Sign-off
All 12 scenarios pass on a physical Android device (API 28+).
---
## Open questions
- **Resend countdown on backgrounding** — current plan uses elapsed-ticks not
wall-clock. If a mitra backgrounds the app for 90s, comes back, the timer
will still show "remaining" instead of immediately enabling. Acceptable for
v1; revisit if it causes confusion.
- **Maestro coverage** — out of scope here, but mitra-side onboarding has
zero E2E coverage today. Worth adding `ts-mitra-01-prehome-otp.yaml` once
the screens stabilize.