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:
402
requirement/phase4-mitra-prehome-plan.md
Normal file
402
requirement/phase4-mitra-prehome-plan.md
Normal 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 A1–A4) 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 3–4
|
||||
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; // 0–5, 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.
|
||||
Reference in New Issue
Block a user