Files
halobestie-clone/requirement/phase4-mitra-prehome-plan.md
Ramadhan Sjamsani 9696eadeaf 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>
2026-05-19 22:01:28 +08:00

403 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.