Files
halobestie-clone/requirement/phase4-customer-flow-plan.md
ramadhan sjamsani 350b92f1f3 Phase 4 Stage 10 backend: Chat-tab feeds (pending payments + cursor history)
Backend half of Stage 10 — the new Chat tab in the customer app that
replaces /chat/history with a 3-sub-tab list (Aktif / Pembayaran /
Selesai).

- New GET /api/client/payment-sessions/pending — returns the customer's
  pending initial + extension payment sessions. Filter is status='pending'
  AND expires_at > NOW(). Mitra info comes from session_extensions →
  chat_sessions for extension rows, payment_sessions.targeted_mitra_id
  for targeted-curhat-lagi initial rows. TTL reuses the existing
  payment_session_timeout_minutes app_config row (default 20m) — no new
  config row needed since payment is still mocked.

- getCustomerHistory migrated from offset (page/limit) to cursor
  pagination. Cursor is base64url(`<endedAtIso>|<id>`) with id-tiebreak
  in ORDER BY so rows with identical timestamps don't duplicate or skip
  across pages. SELECT now JOINs payment_sessions to surface `mode`
  (chat/call) for the Selesai-row voice-call pill.

- requirement/flow_customer.mermaid.md: new §7 Chat Tab subgraph + Figma
  cross-ref entry for SChatList.

- requirement/phase4-customer-flow-plan.md: Stage 10 plan section. Also
  carries forward earlier uncommitted "Post-Stage-8 corrections" notes
  from the Stage 9 sweep (boot path / SHome1st / onboarding fixes).

Tests: +7 for getCustomerPendingPayments (initial null mitra,
targeted-mitra fill, extension-via-session JOIN, mixed-newest-first,
expired excluded, non-pending excluded, customer scoping). +10 for
cursor history (empty, exact-fit, multi-page walk, same-timestamp
tiebreak, limit clamp, customer scoping, CLOSING+COMPLETED only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:04:58 +08:00

1204 lines
52 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.
# Phase 4 — Implementation Plan
> **Status (2026-05-10):** Stages 08 are code-complete and committed
> on master (commits `4ada7c9` through `862fc35`, plus the pre-Phase-4
> `4680c36` OTP test infrastructure). `flutter analyze` clean across
> both apps; backend Vitest 15/15. **Stage 9 (test sweep) is
> operator-driven and in progress** — see "Post-Stage-8 corrections"
> below for fixes applied during the visual sweep.
> See [phase4-customer-flow.md](phase4-customer-flow.md) for the PRD,
> [flow_customer.md](flow_customer.md) for the source-of-truth flow,
> [flow_customer.mermaid.md](flow_customer.mermaid.md) for cross-referenced
> diagrams. Visual reference is in `requirement/Figma/` (git-ignored).
This document is the build sequence: **what** files change, **in what order**,
with **API contracts** for new endpoints and **widget contracts** for the
reusable UI primitives. The "why" is in the PRD — don't restate it here.
---
## Build Order (10 stages)
The dependency graph forces this order. **Stages 01 are pure groundwork that
unblocks everything else; stages 28 are feature-shaped and shippable
independently; stage 9 is verification.**
0. **Design system foundation** — tokens, fonts, ThemeData, reusable widgets (no new screens yet)
1. **Backend foundation** — additive endpoints + config rows
2. **Onboarding redesign** — Verif Choice Sheet, ESP multi-select, USP, OTP-blocked popup
3. **Payment shell** — Pilih cara, Pemilihan harga, Cara bayar (QRIS-first), Waiting Payment, Pembayaran expired
4. **Notif gate + home banner**
5. **Pairing UX upgrades** — Soft-prompt, Searching state, S7 timeout, S9 Match, targeted-wait overlay
6. **Chat-room countdown UX** — 3-min snackbar, last-2-min danger, expired floating banner
7. **End-of-session sequence** — 2-step confirm, closing-message sheet, S11 thank-you
8. **Returning-user shell** — Bestie Choice Sheet, Bestie history visual upgrade, Tanya Admin sheet
9. **Test sweep** — Maestro flows, manual real-device run, visual regression
Within each stage, items are listed in dependency order. Each stage is
independently mergeable; nothing in stage N+1 hard-depends on stage N's UI
(only on stages 0 and 1).
---
# Stage 0 — Design System Foundation
The new flow leans hard on a tokenized look (Bricolage Grotesque + Poppins,
warm rose palette, pill buttons, soft shadows). client_app currently uses the
default `MaterialApp` theme — no `ThemeData`, no token file. Stage 0 sets that
up so every subsequent stage can compose screens from primitives.
## 0.1 Token file
> **New:** `client_app/lib/core/theme/halo_tokens.dart`
Mirrors `requirement/Figma/handoff/tokens.json` 1-for-1. Exports a single
`HaloTokens` class with `static const Color brand = Color(0xFFE17A9D);` etc.
Three palettes (`warm`, `calm`, `playful`) — ship with **warm only**; expose
the structure for the others but leave commented `TODO: phase5`.
Spacing scale is `HaloSpacing.s4`, `s8`, `s12`, … (matches Figma `1=4, 2=8`).
Radius: `HaloRadius.sm/md/lg/xl/pill` as `BorderRadius` constants.
Motion: `HaloMotion.fast = Duration(milliseconds: 180)` etc. Use the cubic
curve `Cubic(0.2, 0.8, 0.2, 1)` as `HaloMotion.ease`.
## 0.2 Fonts
Add to `client_app/pubspec.yaml`:
```yaml
fonts:
- family: BricolageGrotesque
fonts:
- asset: assets/fonts/BricolageGrotesque-Regular.ttf
- asset: assets/fonts/BricolageGrotesque-Bold.ttf
weight: 700
- family: Poppins
fonts:
- asset: assets/fonts/Poppins-Regular.ttf
- asset: assets/fonts/Poppins-Medium.ttf
weight: 500
- asset: assets/fonts/Poppins-SemiBold.ttf
weight: 600
- asset: assets/fonts/Poppins-Bold.ttf
weight: 700
- family: JetBrainsMono
fonts:
- asset: assets/fonts/JetBrainsMono-Regular.ttf
- asset: assets/fonts/JetBrainsMono-Bold.ttf
weight: 700
```
Download .ttf files from the Google Fonts CDN snapshot
(`fonts.google.com/specimen/...`). Place under `client_app/assets/fonts/`. Add
the `assets/fonts/` directory to the existing `flutter:` block.
## 0.3 ThemeData
> **New:** `client_app/lib/core/theme/halo_theme.dart`
Single `haloThemeData()` builder returning a `ThemeData` with:
- `colorScheme.fromSeed(seedColor: HaloTokens.brand)`, then override `primary/onPrimary/surface/onSurface/error` to match tokens
- `textTheme` mapped to the Figma scale (`displayLarge` → Bricolage 36/700, `titleLarge` → Bricolage 22/700, `bodyMedium` → Poppins 15/400, etc.)
- `elevatedButtonTheme` with pill radius + `HaloShadows.button`
- `inputDecorationTheme` matching the 64px-tall S2 Nama input
- `bottomSheetTheme` with 24px top corners + soft shadow
- `snackBarTheme` matching `HBSnackbar` (pill, dark backdrop)
Wire into `client_app/lib/main.dart::84` (the `MaterialApp.router`):
```dart
return MaterialApp.router(
title: 'Halo Bestie',
theme: haloThemeData(),
routerConfig: router,
);
```
## 0.4 Reusable widgets
> **New folder:** `client_app/lib/core/theme/widgets/`
Each widget is a thin Flutter port of a Figma primitive:
| Widget | Port of | Notes |
|---|---|---|
| `HaloButton` | `HBButton` | variants: `primary` / `secondary` / `ghost`; sizes: `sm` / `md` / `lg`; `onPressed: null` → disabled visuals |
| `HaloOrb` | `HBOrb` | gradient circle with `seed` int → deterministic color blend |
| `HaloStepDots` | `HBStepDots` | progress dots, e.g. 4 dots for onboarding |
| `HaloBottomSheet` | `HBBottomSheet` | helper that wraps `showModalBottomSheet` with the correct shape, drag handle, padding |
| `HaloPopup` | `HBPopup` | helper that wraps `showDialog` with title/body/icon/primary/secondary |
| `HaloSnackbar` | `HBSnackbar` | dark pill snackbar, 4s default; static `show(context, message, {icon})` |
| `HaloChip` | ESP chip | toggleable pill chip with icon + label |
**Naming:** `Halo*` prefix (avoids collision with Material's `Card`, `Chip`, etc.)
and matches the brand. Internal-only; no need for a published package.
## 0.5 Acceptance for Stage 0
- `flutter analyze` clean.
- `flutter run` launches with the warm palette visible on the existing
Splash and Home screens (they pick up the new ThemeData automatically).
- A simple harness screen (`lib/core/theme/_preview.dart`, dev-only,
routed at `/_theme_preview` with a build flag) renders all `Halo*`
widgets — used as a visual reference during stages 28.
---
# Stage 1 — Backend Foundation
Mostly additive endpoints + one migration that touches `payment_sessions` and
`chat_sessions`. Pricing stays mocked but moves from hardcoded to `app_config`.
## 1.1 Schema migration
> File: `backend/src/db/migrate.js` (single-file migration script — append).
```sql
-- 1. Replace is_free_trial with is_first_session_discount
ALTER TABLE payment_sessions
ADD COLUMN IF NOT EXISTS is_first_session_discount BOOLEAN NOT NULL DEFAULT false;
UPDATE payment_sessions
SET is_first_session_discount = is_free_trial
WHERE is_free_trial = true
AND is_first_session_discount = false;
ALTER TABLE payment_sessions DROP COLUMN IF EXISTS is_free_trial;
-- 2. Add mode column for chat vs voice call
ALTER TABLE payment_sessions
ADD COLUMN IF NOT EXISTS mode TEXT NOT NULL DEFAULT 'chat'
CHECK (mode IN ('chat', 'call'));
-- 3. Store ESP picks on chat session for info display
ALTER TABLE chat_sessions
ADD COLUMN IF NOT EXISTS topics TEXT[];
```
> **Backwards compat note:** any service code still reading `is_free_trial`
> will break — grep + cut over in §1.2. There is no production data to
> protect (Phase 3.7 was the first ship of `is_free_trial` and it never went
> live in real users per `project_pricing_still_mocked_3_7`).
## 1.2 Free-trial → first-session-discount cutover
> Files: `backend/src/services/pricing.service.js`,
> `backend/src/services/payment.service.js`, any other reader.
```bash
grep -rn "is_free_trial\|free_trial\|freeTrial" backend/src
```
For each hit:
- Replace the read with `is_first_session_discount`.
- Replace any "free trial" copy in API response strings with neutral
language (the pricing block carries `actual_price_idr` etc. now).
- Eligibility logic now reads:
- `users.phone_verified_at IS NOT NULL` (verified user), AND
- no `chat_sessions` row with `status IN ('completed','closed_by_user','closed_by_mitra')` for this customer.
## 1.3 New + rewritten endpoints
### `GET /api/client/onboarding-state` (new)
> File: `backend/src/routes/client/onboarding.routes.js` (new) or extend `auth.routes.js`.
```http
200 OK
{ "has_consulted_before": boolean,
"is_phone_verified": boolean,
"is_first_session_discount_eligible": boolean,
"is_anonymous": boolean
}
```
- `is_first_session_discount_eligible` is the AND of:
`is_phone_verified` && `!has_consulted_before` &&
`app_config.first_session_discount_enabled == 'true'`.
- Drives both `VerifChoiceSheet` visibility and S6 paywall display.
### `GET /api/client/chat-pricing` (rewrite)
> File: `backend/src/services/pricing.service.js`
```http
200 OK
{ "chat": {
"tiers": [
{ "id": "5", "minutes": 5, "price_idr": 5000, "tag": null },
{ "id": "12", "minutes": 12, "price_idr": 12000, "tag": "paling pas" },
{ "id": "30", "minutes": 30, "price_idr": 25000, "tag": "hemat" },
{ "id": "60", "minutes": 60, "price_idr": 45000, "tag": null },
{ "id": "120", "minutes": 120, "price_idr": 80000, "tag": "best deal" }
]
},
"call": {
"tiers": [
{ "id": "10", "minutes": 10, "price_idr": 9000, "tag": null },
{ "id": "20", "minutes": 20, "price_idr": 17000, "tag": "paling pas" },
{ "id": "45", "minutes": 45, "price_idr": 35000, "tag": null },
{ "id": "60", "minutes": 60, "price_idr": 45000, "tag": "hemat" }
]
},
"first_session_discount": {
"eligible": true,
"actual_price_idr": 2000,
"gimmick_price_idr": 12000,
"duration_minutes": 12,
"modes": ["chat"]
}
}
```
- Tiers come from `app_config.pricing_chat_tiers_json` and
`pricing_call_tiers_json` (JSON arrays). The discount block reads its four
config values and the per-customer eligibility check.
- `first_session_discount.eligible` is **per-customer** — uses the same
predicate as `onboarding-state.is_first_session_discount_eligible`.
### `GET /api/shared/auth-providers` (new)
> File: `backend/src/services/auth-providers.service.js` (new) +
> `backend/src/routes/shared/auth-providers.routes.js` (new).
```http
200 OK
{ "google": { "enabled": false },
"apple": { "enabled": false },
"phone": { "enabled": true } }
```
Probes env at module load:
```js
const enabled = (...keys) => keys.every(k => process.env[k] && process.env[k].trim());
const google = enabled('GOOGLE_OAUTH_CLIENT_ID', 'GOOGLE_OAUTH_CLIENT_SECRET');
const apple = enabled('APPLE_OAUTH_CLIENT_ID', 'APPLE_OAUTH_TEAM_ID',
'APPLE_OAUTH_KEY_ID', 'APPLE_OAUTH_PRIVATE_KEY');
```
### `GET /api/client/support-handles` (new)
```http
200 OK
{ "wa": { "label": "WhatsApp", "deeplink": "https://wa.me/62..." },
"telegram": { "label": "Telegram", "deeplink": "https://t.me/..." } }
```
Reads `app_config.support_handles_json`. CC will get a writable form for
this in stage 8 (or earlier if convenient).
## 1.4 Confirm `session_warning` event exists
> File: `backend/src/services/session-timer.service.js`
```bash
grep -n "session_warning\|three_minutes\|180" backend/src/services/session-timer.service.js
```
If absent, add: when `secondsLeft == 180`, emit once over the customer WS:
```json
{ "type": "session_warning", "kind": "three_minutes_left", "session_id": "..." }
```
## 1.5 Seed `app_config` rows
Append to seed step in `backend/src/db/migrate.js`:
```sql
INSERT INTO app_config (key, value) VALUES
('payment_method_qris_first', 'true'),
('payment_session_timeout_minutes', '20'),
('searching_timeout_minutes', '5'),
('end_session_two_step_confirm', 'true'),
('three_minute_warning_enabled', 'true'),
('first_session_discount_enabled', 'true'),
('first_session_discount_actual_price_idr', '2000'),
('first_session_discount_gimmick_price_idr', '12000'),
('first_session_discount_duration_minutes', '12'),
('first_session_discount_modes', '["chat"]'),
('pricing_chat_tiers_json', '[
{"id":"5","minutes":5,"price_idr":5000,"tag":null},
{"id":"12","minutes":12,"price_idr":12000,"tag":"paling pas"},
{"id":"30","minutes":30,"price_idr":25000,"tag":"hemat"},
{"id":"60","minutes":60,"price_idr":45000,"tag":null},
{"id":"120","minutes":120,"price_idr":80000,"tag":"best deal"}
]'),
('pricing_call_tiers_json', '[
{"id":"10","minutes":10,"price_idr":9000,"tag":null},
{"id":"20","minutes":20,"price_idr":17000,"tag":"paling pas"},
{"id":"45","minutes":45,"price_idr":35000,"tag":null},
{"id":"60","minutes":60,"price_idr":45000,"tag":"hemat"}
]'),
('support_handles_json',
'{"wa":{"label":"WhatsApp","deeplink":"https://wa.me/6285173310010"},
"telegram":{"label":"Telegram","deeplink":"https://t.me/halobestie"}}'
)
ON CONFLICT (key) DO NOTHING;
```
## 1.6 CC editor for new config rows
> File: `control_center/src/pages/AppConfigPage.tsx` (existing) — add fields.
A simple form section labeled "First-session discount" with the 5 keys. A
second section "Pricing tiers (mock)" with two textareas (JSON-validated)
for chat + call. A third section "Support handles" with WA + Telegram
inputs. **No backend route changes needed** — CC already has a generic
`PUT /internal/_config/:key` (verify name).
## 1.7 Acceptance for Stage 1
- Curl smoke against all four endpoints returns the documented shape.
- `backend/test/` Vitest covers:
- `chat-pricing` returns chat + call groups; eligibility flips when the
customer has a completed session.
- `auth-providers` returns `{enabled:false}` when env vars unset, `true`
when set.
- `session_warning` 3-min ping fires once.
- Migration is idempotent (re-run `migrate.js` on a populated DB does not
error or duplicate config rows).
- No frontend change required to merge stage 1.
---
# Stage 2 — Onboarding Redesign
> Resolves PRD §1, §2, §13.
## 2.1 Verif Choice Sheet
> **New:** `client_app/lib/features/auth/widgets/verif_choice_sheet.dart`
`HaloBottomSheet` with two buttons. Built on top of Stage 0 primitives.
Trigger location: `display_name_screen.dart` — after the user submits a name,
read `onboarding-state`. If `has_paid_first_session == true`, jump straight to
the duration picker; else show the sheet.
Routes the user to one of two GoRouter paths:
- `/onboarding/verif/esp`
- `/onboarding/anon/esp`
These are sibling shell routes that share the rest of the onboarding sequence.
## 2.2 ESP screen (multi-select, info-only)
> **New:** `client_app/lib/features/onboarding/screens/esp_screen.dart`
> **Replaces existing usage of:** `lib/features/chat/widgets/topic_selection_bottom_sheet.dart`
12 chips, multi-select. State held in a `StateProvider<Set<EspTopic>>` named
`espSelectionProvider`. Skip CTA writes an empty set + a `skipped: true` flag.
ESP is **purely informational** — the picks are persisted on
`chat_sessions.topics` (column added in stage 1.1) and surfaced to the mitra
on session start as a chip row above the first message bubble. **They do not
affect matching, pricing, or routing.** Existing `pairing.service.js`
topic-classification code stays untouched.
The mitra-side display (chip row above first bubble) is a small `mitra_app`
edit but considered in scope for stage 2 since it's read-only.
## 2.3 USP screen
> **New:** `client_app/lib/features/onboarding/screens/usp_screen.dart`
Static — four feature cards + CTA `aku ngerti, lanjut →`.
`HaloStepDots(total: 4, current: 2)` in the header.
## 2.4 OTP screens — visual re-skin only
> **Edit:** `client_app/lib/features/auth/screens/{register,otp}_screen.dart`
- Re-style with Stage 0 widgets. Keep **6-digit** (resolved decision).
- The Figma 4-digit boxes become 6 boxes laid out across the same horizontal
width — slightly tighter spacing.
## 2.5 OTP-blocked popup
> **New:** `client_app/lib/features/auth/widgets/otp_blocked_popup.dart`
`HaloPopup` shown when `otp_screen.dart` receives a 429 with
`error: "otp_retry_exhausted"`.
- Primary: **`lanjut tanpa verif`** → routes to `/onboarding/anon/method`
with the `espSelectionProvider` and any USP-acknowledged flag preserved
(no re-prompt).
- Secondary: `hubungi admin` → opens Tanya Admin sheet (built in stage 8;
stub a TODO `SnackBar` for now).
Backend-side: confirm `otp.service.js` returns the 429 shape
`{ error: "otp_retry_exhausted", retry_after_seconds: 1800 }`. If not, adjust.
## 2.6 Auth-providers gating (replace `ENABLE_SOCIAL_AUTH` build flag)
> **New:** `client_app/lib/core/auth/auth_providers_provider.dart`
> **Edit:** `client_app/lib/features/auth/screens/welcome_screen.dart`,
> `register_screen.dart`, any other screen with Google/Apple buttons.
> **Edit:** `client_app/lib/core/auth/social_auth_enabled.dart` — read from
> Riverpod provider instead of `bool.fromEnvironment`. Or delete this file
> and inline the check at button render sites.
Logic:
1. On app cold start (in `main.dart` post-bootstrap), `ref.read(authProvidersProvider.future)` once and cache.
2. Provider exposes `{google: bool, apple: bool, phone: bool}`.
3. Each social button site reads the corresponding flag and renders nothing if `false`.
4. If both are false, the welcome screen falls back to the phone-OTP-only layout.
**Memory update:** the implementer must update
`client_app/CLAUDE.md` to remove the `ENABLE_SOCIAL_AUTH` build-flag note.
## 2.7 Acceptance for Stage 2
- Maestro flow `02_onboarding_verified.yaml` covers Splash → Name → Verif Sheet
→ ESP (pick chip) → USP → S3a → S3b (6-digit) → arrival at S6 paywall (when
eligible) or duration picker (when not).
- Maestro flow `03_onboarding_anon.yaml` covers Splash → Name → Verif Sheet
("anon") → ESP → USP → arrival at the (Stage 3) Pilih cara route.
- Manual: trigger 5x failed OTP → blocked popup → "lanjut tanpa verif" lands
on the anonymous path with chips preserved.
- Auth providers: backend started without OAuth env vars → social buttons
hidden on welcome/register screens; setting envs and restarting backend +
app → buttons appear.
---
# Stage 3 — Payment Shell
> Resolves PRD §3, §4. Backend payment is **still mocked** — only the UI is
> being built out.
GoRouter additions (sibling routes under `/payment/`):
- `/payment/discount-paywall` — S6 first-session discount (verified eligibles only)
- `/payment/method-pick` — Pilih cara curhat (chat/call) — anonymous + non-eligible verified
- `/payment/duration-pick` — Pemilihan harga (rebuilds from selected mode group)
- `/payment/method` — Cara bayar (QRIS-first list)
- `/payment/waiting/:paymentId` — Waiting Payment with QR + 20-min countdown
- `/payment/expired/:paymentId` — Pembayaran expired
State bag: `paymentDraftProvider` — a Riverpod `Notifier<PaymentDraft>` that
holds `mode (chat|call)`, `durationId`, `priceIDR`, `paymentId?`,
`isFirstSessionDiscount` (bool). Cleared on arrival at the first stage when
entered fresh; persisted across back-nav.
## 3.1 S6 first-session discount paywall
> **New:** `client_app/lib/features/payment/screens/discount_paywall_screen.dart`
Renders only when `chat-pricing.first_session_discount.eligible == true`.
Layout matches `screens/onboarding.jsx::S6Paywall`:
- Struck-through `Rp{gimmick_price_idr}` next to prominent `Rp{actual_price_idr}`.
- Subtitle: "untuk {duration} menit ngobrol".
- CTA: `mulai · Rp{actual_price_idr}` → routes to `/payment/method` with
`paymentDraft = { mode: 'chat', duration_minutes: discount.duration_minutes,
price_idr: discount.actual_price_idr, isFirstSessionDiscount: true }`.
If `modes` config is `["chat","call"]` (ops enabled call for first session),
render a tiny mode toggle at the top — but in v1 default config, the screen
is chat-only and the toggle is hidden.
Routing decision (single point of truth):
```dart
// after S5b USP → next:
if (eligible && discount.enabled) /payment/discount-paywall
else /payment/method-pick
```
## 3.2 Pilih cara curhat
> **New:** `client_app/lib/features/payment/screens/method_pick_screen.dart`
Two cards (chat / call). The "premium" call indicator on the card is a
visual cue, not a hard-coded multiplier. Tapping a card stores the mode in
`paymentDraft.mode` and routes to `/payment/duration-pick`.
## 3.3 Pemilihan harga
> **New:** `client_app/lib/features/payment/screens/duration_pick_screen.dart`
Reads `paymentDraft.mode`. Renders the corresponding tier list
(`pricing.chat.tiers` or `pricing.call.tiers`). Top of the screen shows a
**chat | call mode toggle** — toggling rebuilds the list from the other
group and resets the selection.
Selecting a tier sets `paymentDraft.priceIDR` and
`paymentDraft.durationMinutes`; bottom CTA `{mode_icon} bayar Rp{price}`
routes to `/payment/method`.
## 3.4 Cara bayar
> **New:** `client_app/lib/features/payment/screens/payment_method_screen.dart`
Mirrors `screens/extras.jsx::SPaymentMethod`. QRIS at top with
"DIREKOMENDASIKAN" pill; 4 e-wallet options below. Tapping `bayar`:
1. Calls existing `POST /api/client/payment-sessions` (Phase 3.7) with
`{ mode, duration_minutes, price_idr, is_first_session_discount, method }`.
2. Routes to `/payment/waiting/:paymentId`.
## 3.5 Waiting Payment
> **New:** `client_app/lib/features/payment/screens/waiting_payment_screen.dart`
Renders a placeholder QR (use a `qr_flutter` package; add to pubspec) —
in mock mode the QR encodes the `paymentId` only. Real QR string comes from
the response.
State: `Timer.periodic(const Duration(seconds: 1))` ticks the 20-min
countdown for the header. Polling: `Timer.periodic(const Duration(seconds: 3))`
hits `GET /api/client/payment-sessions/:id`. On `paid` → route to
`/onboarding/notif-gate` (Stage 4). On `expired``/payment/expired/:paymentId`.
Polling pauses when app is backgrounded (`WidgetsBindingObserver`).
## 3.6 Pembayaran expired
> **New:** `client_app/lib/features/payment/screens/payment_expired_screen.dart`
Static screen + retry CTA → routes back to `/payment/method` with the
`paymentDraft` retained (so the user re-pays the same plan, same mode,
same discount flag if applicable).
## 3.7 Acceptance for Stage 3
- Curl: a fresh `payment_sessions` row with `is_first_session_discount=true`
goes to `paid` via the existing CC "mark as paid" tool → app advances.
- Maestro: `04_payment_expired.yaml` exercises the timeout path.
- Visual sanity: run `flutter run` for both eligible (S6 paywall first) and
ineligible (Pilih cara → Pemilihan harga first) users.
- Mode toggle on duration picker: switching chat → call rebuilds the option
list from `pricing.call.tiers`; selection state resets.
---
# Stage 4 — Notif Gate + Home Banner
> Resolves PRD §5.
## 4.1 OS-permission helper
> **New:** `client_app/lib/core/notifications/notif_permission.dart`
Wraps `firebase_messaging` and `permission_handler` into:
```dart
Future<NotifPermStatus> readStatus(); // notDetermined | granted | denied
Future<NotifPermStatus> request(); // shows OS prompt (only if notDetermined)
Future<void> openAppSettings(); // for "denied" path
```
Status cached in a Riverpod `notifPermissionProvider` that auto-refreshes when
the app foregrounds (existing `appLifecycleProvider` pattern).
## 4.2 Notif Gate full screen
> **New:** `client_app/lib/features/onboarding/screens/notif_gate_screen.dart`
Route: `/onboarding/notif-gate`. Shown post-payment (Stage 3 routes here).
If status is already `granted`, redirect immediately to the searching shell
(Stage 5). Otherwise render the screen with two CTAs:
- `izinkan notifikasi` → calls `request()`. After resolution (any), advance.
- `nanti aja` → advance.
## 4.3 Home banner
> **Edit:** `client_app/lib/features/home/home_screen.dart`
Above-the-fold thin amber banner if `notifPermissionProvider == denied`.
Dismissable for the session via a `homeNotifBannerDismissedProvider`
(`StateProvider<bool>`). Persists nothing across cold-start.
Tap `nyalain` → calls `openAppSettings()`.
## 4.4 Acceptance for Stage 4
- Cold-start with notif denied → banner visible. Dismiss → gone for session.
- Cold-restart → banner reappears.
- Notif Gate full screen: "nanti aja" advances; "izinkan" + grant advances; "izinkan" + deny advances and home banner shows.
---
# Stage 5 — Pairing UX Upgrades
> Resolves PRD §6, §7, §11.3 (targeted-wait overlay only — choice sheet & list
> visual upgrade live in stage 8).
## 5.1 Soft-prompt screen
> **Edit:** `client_app/lib/features/chat/screens/searching_screen.dart`
The existing screen already has a reflective-prompt-card phase before the
blast fires. Re-skin with Stage 0 widgets and confirm the CTA copy
`aku ngerti, lanjut →`.
## 5.2 Searching state visuals
- Replace the current spinner with the v3 pulsing-dots panel
(`screens/v3.jsx::SSearchPrompt`).
- No state-machine change.
## 5.3 5-min timeout state
- The pairing notifier (`lib/core/pairing/pairing_notifier.dart`) already
exposes a timeout state. Render the new copy + two CTAs:
- primary `coba cari lagi` → re-fires the blast (calls existing pairing
`retry` action).
- ghost `kembali ke home``context.go('/')`.
## 5.4 S9 Match-found re-skin
> **Edit:** `client_app/lib/features/chat/screens/bestie_found_screen.dart`
Render the `S9MatchV4` layout: orb + status dot + `halo, aku bestie {name}` +
CTA `mulai sesi {N} menit →`. `N` comes from the chat session's pricing tier.
## 5.5 Targeted-wait overlay
> **New:** `client_app/lib/features/chat/screens/targeted_waiting_screen.dart`
Route: `/chat/waiting-targeted/:mitraId`. Renders the `SWaitingBestie`
component with three sub-states (`waiting | accepted | declined`). The
20-second countdown pulls from the pairing notifier's existing
`PairingTargetedWaitingData`. On `accepted` → route to the chat screen; on
`declined` → show the BestieOfflinePopup (built in stage 8) overlaid.
Chat history's "Curhat lagi" button is updated to push this route instead of
the current intermediate.
## 5.6 Acceptance for Stage 5
- Maestro: `05_searching_timeout.yaml` — make the backend return no mitras for
6 minutes, confirm timeout state and both CTAs work.
- Manual: open chat history → tap "Curhat lagi" on an online bestie →
20s overlay → mitra accepts → chat opens.
---
# Stage 6 — Chat-room Countdown UX
> Resolves PRD §8.
## 6.0 Voice-call mode badge (header)
> **Edit:** `client_app/lib/features/chat/screens/chat_screen.dart` and the
> mitra_app equivalent (`mitra_app/lib/features/chat/screens/chat_screen.dart`).
The session payload (loaded from `GET /api/{client|mitra}/chat-sessions/:id`)
exposes `mode: 'chat' | 'call'` (sourced from `payment_sessions.mode`,
column added in stage 1.1). The chat header renders a small pill next to
the bestie/customer name:
- `mode == 'call'``📞 Voice Call` pill in `HaloTokens.accent` color.
- `mode == 'chat'` → either no pill, or a subtle `💬 Chat` pill (design
choice — default to no pill to reduce noise).
URL rendering inside chat bubbles already handles plain links; confirm
`meet.google.com/...` URLs launch the OS handler via `url_launcher`. No
special "join meet" badge — links are plain.
**No mitra composer helpers** for Meet links — mitra types/pastes the URL
manually as a normal message. (Resolved decision; see bottom.)
## 6.1 3-min snackbar
> **Edit:** `client_app/lib/features/chat/screens/chat_screen.dart`
Listen on the WS event stream for `session_warning.kind == 'three_minutes_left'`.
On fire, call `HaloSnackbar.show(context, 'sisa 3 menit lagi ya 🤍', icon: '⏳')`.
A `bool _threeMinShown` per-session flag prevents double-fire.
## 6.2 Last-2-min danger visuals
- Compute `remaining = secondsLeftProvider.watch(...)` (existing).
- When `remaining <= 120`, swap the timer pill style (`HaloTokens.danger`
background + bold `JetBrainsMono` text) and the progress bar color.
## 6.3 Floating expired banner
- When `remaining == 0` and the session is in closing-grace
(existing flag — see memory `Phase 3 Session-End Overhaul`), inject
`ChatExpiredBanner` widget above the input bar.
- Tap `perpanjang` → opens the time-up bottom sheet.
## 6.4 Time-up sheet upgrade
> **Edit (or replace):** `client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart`
Cut over to the 5-option layout with chat/call toggle. Behavior of the
`perpanjang` CTA is unchanged from Phase 3.7 — only UI changes.
## 6.5 Acceptance for Stage 6
- Maestro: `06_chat_countdown.yaml` — manipulate the session's `expires_at`
to drive 3-min snackbar, last-2-min visuals, and expired banner in one run.
- Manual: chat session, observe the visual transitions.
---
# Stage 7 — End-of-session Sequence
> Resolves PRD §10.
## 7.1 Step-1 confirm popup
> **New widget; trigger from chat screen.**
- File: `client_app/lib/features/chat/widgets/confirm_end_step1.dart`
- Primary `lanjut akhiri` → opens step-2 popup.
- Secondary `gak jadi, balik` → close, stay in chat.
## 7.2 Step-2 confirm popup
> **New:** `client_app/lib/features/chat/widgets/confirm_end_step2.dart`
- Primary `tulis pesan penutup` → opens closing-message sheet.
- Secondary `lewati saja` → calls existing close-session API and routes
to S11.
## 7.3 Closing-message bottom sheet
> **Replace existing goodbye composer** (currently a screen) with a
> bottom sheet at `client_app/lib/features/chat/widgets/closing_message_sheet.dart`.
Textarea + two CTAs:
- `kirim & akhiri sesi` → POSTs goodbye message + closes session.
- `lewat — langsung akhiri` → closes without sending.
## 7.4 S11 thank-you screen
> **New:** `client_app/lib/features/chat/screens/thank_you_screen.dart`
Route: `/chat/thank-you`. Replaces the current "navigate straight home"
behavior. CTA `balik ke home``context.go('/')`.
## 7.5 Mitra-rejects-close fallback
- If close API returns 409, show BestieOfflinePopup (built in stage 8) with
the "returning" variant. No new asset for this stage.
## 7.6 Acceptance for Stage 7
- Maestro: `07_end_session_2step.yaml` covers chat → akhiri → step1 → step2 →
closing message → thank-you → home.
- Manual: confirm the "gak jadi, balik" path returns cleanly to chat.
---
# Stage 8 — Returning-User Shell
> Resolves PRD §11 (choice + list visual upgrade) and §12.
## 8.1 Bestie Choice Sheet
> **New:** `client_app/lib/features/home/widgets/bestie_choice_sheet.dart`
`HaloBottomSheet` with two cards. Triggered from the home CTA when the user
has at least one prior session (`bestieHistoryHasItems` provider).
- `bestie yang udah kenal` → routes to chat history list.
- `bestie baru` → routes to soft-prompt + blast (existing).
## 8.2 Bestie history list — visual upgrade
> **Edit:** `client_app/lib/features/chat/screens/chat_history_screen.dart`
Render the v4 `BestieHistoryList` layout: orb + name + last-session date +
topic + sessions count + ONLINE pill (live from the existing presence
provider).
## 8.3 Bestie Offline Popup variants
> **Edit:** `client_app/lib/features/chat/widgets/bestie_unavailable_dialog.dart`
Add a `variant: 'returning' | 'new'` param. The existing dialog covers the
returning case; add the new-user copy ("semua bestie lagi istirahat") and
wire `tanya admin` ghost link.
## 8.4 Tanya Admin sheet
> **New:** `client_app/lib/features/support/widgets/tanya_admin_sheet.dart`
`HaloBottomSheet` with WA + Telegram buttons. Reads handles from Stage 1's
endpoint via a `supportHandlesProvider`.
Tapping launches `url_launcher` with the deeplink. No webview.
## 8.5 Acceptance for Stage 8
- Maestro: `08_returning_targeted.yaml` covers home → bestie choice sheet →
history list → online pick → 20s overlay → match.
- Manual: pick offline bestie → BestieOfflinePopup → tanya admin → WA opens.
---
# Post-Stage-8 corrections (2026-05-10, uncommitted)
The first visual sweep of the live app caught that the boot path was still
on Phase 1 plumbing — Splash → `/welcome` (Phase 1 social/phone picker) →
forms — instead of the mermaid §1 contract: **Splash → Home (1st time / returning)**.
The new home variants (`SHome1st`, `SHomeReturning`) had not been built;
`home_screen.dart` was the Phase 1 placeholder with a Material AppBar +
"Mulai Curhat" button.
Fixes applied in the working tree (not yet committed):
## C.1 `/welcome` retired
- Route + `WelcomeScreen` import + `welcome_screen.dart` file all removed.
- Router redirects (formerly pointing at `/welcome` for `AuthInitialData`,
`AsyncError`, and post-onboarding-carousel cases) now point at `/home`.
- The router carve-out comment that referenced `/welcome` as the bottom of
the navigation stack updated to reference `/home`.
- **Stage 2.6 of this plan is stale**: it described editing
`welcome_screen.dart` to read `authProvidersProvider`; that screen no
longer exists. The `authProvidersProvider` itself is preserved and is
now consumed only at the phone-OTP / future login-recovery surfaces.
## C.2 `home_screen.dart` rewritten to Figma §1 spec
- Renders `SHome1st` (`screens/v3.jsx::SHome1st`) for unauthenticated users
(any state that isn't `AuthAuthenticatedData` / `AuthAnonymousData`).
- Renders `SHomeReturning` (`SHomeReturning`) for authenticated /
anonymous users.
- Components: login-recover banner, "halo," / "halo, {name}" greeting
(brand-colored name on returning), `aku mau curhat` / `curhat sama
bestie baru` primary CTA, "curhatan sebelumnya" history section (live
data via `bestieHistoryProvider`), bottom 4-tab `HBTabBar` footer
(home / chat / kamu / premium SOON — only home + chat wired).
- `_NotifDeniedBanner` (Stage 4) preserved at the top.
- `_ActiveSessionCard` preserved on SHomeReturning so a user mid-session
can rejoin (not in Figma §1 but a hard UX requirement).
- Material `AppBar` removed — the Figma layout has none. Logout will land
on the `kamu` tab when that's built.
## C.3 Onboarding carousel destination fixed
- `OnboardingScreen._finish()` now navigates to `/home` instead of
`/welcome`. The 3-page intro carousel (`Langsung Curhat / 100% Anonim /
Bestie yang Relevan`) itself is kept for now — it is **not in the
mermaid §1**, but the operator chose minimum-touch correction. Full
retirement (delete `OnboardingScreen` + `onboardingDoneProvider` + the
`/onboarding` route + the gate at the top of `router.dart`) is a
follow-up.
## C.4 Defensive variant gate
- `HomeScreen` now treats anything that is *not* `AuthAuthenticatedData`
or `AuthAnonymousData` as "fresh" → renders `SHome1st`. This avoids the
unauthenticated-but-erroring user seeing `halo, kamu` (the returning
view) for a brief moment.
## C.5 Open gap — Login flow not in mermaid
`SHome1st`'s `masuk →` banner button currently routes to `/auth/register`
(phone-OTP entry). This is an interpretation, not a spec: the mermaid (and
Figma `SHome1st`'s `onLogin` callback) doesn't define the login destination.
**The mermaid needs a Login flow diagram** added — destinations from the
`masuk →` banner, OTP success → `AuthAuthenticatedData` → SHomeReturning.
Tracked in agent memory as `project_phase4_login_flow_gap.md`.
---
# Stage 9 — Test Sweep
## 9.1 Maestro flows
All under `client_app/.maestro/flows/`:
- `01_smoke.yaml` — keep passing (existing).
- `02_onboarding_verified.yaml` (stage 2)
- `03_onboarding_anon.yaml` (stage 2)
- `04_payment_expired.yaml` (stage 3)
- `05_searching_timeout.yaml` (stage 5)
- `06_chat_countdown.yaml` (stage 6)
- `07_end_session_2step.yaml` (stage 7)
- `08_returning_targeted.yaml` (stage 8)
Helper scripts (`peek_otp.js`, `reset_phone.js`) reused; add
`force_payment_state.js` and `force_session_warning.js` (call internal CC
endpoints).
## 9.2 Real-device run
- Single AVD + physical Android per memory `Test Infrastructure`.
- Capture screen recordings of each Maestro flow; save under
`requirement/phase4-testing/<flow>.mp4` (git-ignored — add to .gitignore).
## 9.3 Visual regression
- Manual sweep with the Figma `handoff/png/` images side-by-side. Goal: 95%
visual parity (copy must be exact; visual fidelity within 5%).
---
# Stage 10 — Chat Tab (3 sub-tabs)
> Added 2026-05-12 after design review. Figma source: `SChatList` in
> [requirement/Figma/screens/extras.jsx](Figma/screens/extras.jsx) (line 22+).
> Not yet in `flow_customer.mermaid.md` — §10.8 adds it.
## 10.1 Scope & goal
Replace the existing `/chat/history` destination (a flat list of closed sessions
backed by `bestie_history_provider`) with a new **Chat tab** screen that
contains three sub-tabs:
| Sub-tab | Contents | Tap behavior |
|---|---|---|
| `aktif` | The user's single ongoing session (0 or 1 item) | Resume the live chat room |
| `pembayaran` | Pending initial-session + extension payments | Resume the Xendit payment flow |
| `selesai` | Past sessions (status `COMPLETED` + `CLOSING`) — cursor-paginated 20/page | Open read-only transcript |
The chat icon in `HaloTabBar` already exists and points to `/chat/history`
only its **destination** changes. Bottom-nav structure is unchanged.
`bestie_history` (screen + provider) is **retired** in this stage.
## 10.2 Figma source
- `SChatList` — list layout, sub-tab pill counters, per-item visuals
- `S_pembayaran_kedaluwarsa` (same file, ~line 600) — expired-payment full
screen. **Copy says 20 menit**, see §10.6.
- Item visuals: `HBOrb` (avatar) + optional green `success`-color live dot;
name (`who`) bold; preview text muted; right-aligned timestamp
(`● live` when active); below-preview chips:
- `bayar Rp X.XXX` chip (amber) on `pembayaran` items
- `X menit` duration suffix on `selesai` items
## 10.3 Routes & navigation (client_app)
Each sub-tab gets its **own path** so deep links, back stack, and Maestro
tests all agree on the active tab (URL is the source of truth):
| Path | Sub-tab |
|---|---|
| `/chat` | Redirect → `/chat/aktif` |
| `/chat/aktif` | Aktif (default landing) |
| `/chat/pembayaran` | Pembayaran |
| `/chat/selesai` | Selesai |
Implementation: a single `ShellRoute` (or a shared scaffold widget passed
the active tab id) so the three paths render the same chrome (heading,
sub-tab pills, bottom `HaloTabBar`) with only the list body swapping.
Tapping a sub-tab pill calls `context.go('/chat/<id>')`.
Renames + cleanup:
- `HaloTabBar` `chat` tab `onTap`: `/chat/history``/chat` (which then
redirects to `/chat/aktif`).
- Old `/chat/history` route + `bestie_history_screen.dart` +
`bestie_history_provider.dart` deleted.
- `/chat/history/:sessionId` (read-only transcript) renamed to
**`/chat/transcript/:sessionId`** so no route lives under the retired
`/chat/history` parent. All inbound `context.push('/chat/history/...')`
updated.
Bottom-nav red-dot tap behavior: the chat tab still calls
`context.go('/chat')` (no special-case for the red dot). The user lands on
the default `aktif` tab. FCM payment-pending pushes (if/when wired) target
`/chat/pembayaran` directly.
## 10.4 Sub-tab content & item model
### aktif
- Backed by existing `/api/client/chat/session/active-with-unread` (already
wired via `activeSessionProvider`). No new endpoint.
- Always renders the active session even when the user is currently inside
the chat room (per decision §10.6 below).
- Voice-call sessions (`mode='call'`) render with a small **📞 Call** pill in
the same row (consistent with Stage 6.0 header-badge convention).
- Empty state copy: `belum ada chat di sini`.
### pembayaran
- Backed by **new** `GET /api/client/payment-sessions/pending` (§10.7).
- Two row kinds (preview copy differentiates):
- Initial-session: `menunggu pembayaran sesi`
- Extension: `menunggu pembayaran perpanjangan`
- Amber `bayar Rp X.XXX` chip per Figma.
- Empty state: `belum ada pembayaran tertunda`.
### selesai
- Backed by existing `GET /api/client/history` — switch from offset (`page`)
to cursor pagination (§10.7) and rename param.
- Per-item: `mins` (duration), preview = closing message (mitra's if present,
else customer's), relative timestamp.
- Empty state: `belum ada riwayat curhat`.
## 10.5 Badges
| Surface | Trigger | Visual |
|---|---|---|
| Bottom-nav `chat` tab | `pembayaran` count > 0 | Red dot (no number) |
| `aktif` sub-tab pill | Unread message count > 0 | Numeric badge (uses existing `unread_count` from `active-with-unread`) |
| `pembayaran` sub-tab pill | Pending payment count > 0 | Numeric badge (count from `/payments/pending`) |
| `selesai` sub-tab pill | — | **No badge** (overrides the Figma count pill) |
Bottom-nav red-dot data source: piggy-back on the same
`/api/client/payment-sessions/pending` call (its `total` field). Polled when the
`HaloTabBar` host screen mounts; refreshed by riverpod invalidation when a
payment is created or completed.
## 10.6 Decisions baked in
1. **Aktif always shows the live session.** Even when the user is on the chat
screen, the row stays in `aktif` — it represents state, not navigation.
2. **Voice-call sessions live in the same list with a Call pill.** Per memory
`project_phase4_chat_ux_improvements` and Stage 6.0.
3. **Pembayaran TTL reuses existing `payment_session_timeout_minutes`.**
Payment is still mocked (per memory `project_pricing_still_mocked_3_7`);
real Xendit is not wired yet. The `app_config.payment_session_timeout_minutes`
row (default `20`) already drives `expires_at` on `payment_sessions` rows
via `createPaymentSession`. The Figma "pembayaran kedaluwarsa" 20-min copy
already matches the default — no new app_config row needed for Stage 10.
When real Xendit lands, the same value is reused for the invoice TTL.
4. **Max 1 active session.** Aligns with existing pairing constraint; no
backend change.
## 10.7 Backend changes
### 10.7.1 New: `GET /api/client/payment-sessions/pending`
Returns pending initial-session + extension payment sessions for the
authenticated customer (not yet paid, not yet expired).
Query: `payment_sessions WHERE customer_id = $1 AND status = 'pending' AND
expires_at > NOW() ORDER BY created_at DESC`. `is_extension` drives the row
kind. For extension rows, the originating `chat_sessions` row is joined for
mitra info; for initial rows, mitra info is null until pairing happens.
```
GET /api/client/payment-sessions/pending
→ 200 {
success: true,
data: {
items: [
{
id: "pay_…", // payment_sessions.id
kind: "initial" | "extension",
mitra_id: "…" | null, // populated only for extension rows
mitra_display_name: "kak Dimas" | null,
amount: 2500,
duration_minutes: 30,
mode: "chat" | "call",
created_at: "…",
expires_at: "…" // already = created_at + payment_session_timeout_minutes
}
],
total: 1 // drives the bottom-nav red dot
}
}
```
Service: `getCustomerPendingPayments(customerId)` (new fn) in
`payment.service.js`. The existing `expireStalePaymentSessions` sweeper +
inline expiry check in `confirmPaymentSession` already covers the TTL flip;
the endpoint just filters `expires_at > NOW()` defensively in case the
sweeper hasn't run yet for a stale row.
### 10.7.2 Modify: `GET /api/client/history` → cursor pagination
- Add `cursor` (opaque, base64 of `ended_at + id`) and `limit` (default 20,
max 50) query params.
- Response shape changes from `{ items, total, page, limit }` to
`{ items, next_cursor, has_more }`.
- `total` removed; if a future UI needs it, expose a separate `/count`
endpoint.
- `bestie_history_provider` is deleted along with the screen — the new
`selesai_history_provider` uses cursor pagination on this endpoint.
### 10.7.3 Reuse: `GET /api/client/chat/session/active-with-unread`
No changes. The `aktif` tab calls this directly.
### 10.7.4 No new sweeper needed
The existing `expireStalePaymentSessions` already flips
`pending → expired` past `expires_at`. The Pembayaran query filters on
`expires_at > NOW()` to handle the gap between TTL expiry and the next
sweep tick, so no additional sweeper is needed for Stage 10.
## 10.8 Mermaid flow update (`flow_customer.mermaid.md`)
Add a `subgraph chat_tab` after the home subgraph:
```
chat_tab
chat_tab.entry — tap "💬 chat" in HaloTabBar
chat_tab.aktif — active session row → resume chat
chat_tab.pembayaran — pending payment row → resume Xendit
chat_tab.selesai — past session row → transcript
chat_tab.empty.{aktif,pembayaran,selesai} — empty states
```
Edges:
- `home_* → chat_tab.entry` (from any home variant)
- `chat_tab.aktif → S10_chat_room` (existing)
- `chat_tab.pembayaran → S7_waiting_payment` (Stage 3.5)
- `chat_tab.selesai → S_transcript` (existing read-only transcript)
(Wording above is a description — final mermaid syntax added during the
implementation commit.)
## 10.9 Flutter file changes (preview)
- New: `client_app/lib/features/chat_tab/screens/chat_tab_shell.dart` — the
shared scaffold (heading + sub-tab pills + body slot) rendered by all
three sub-tab paths via `ShellRoute`.
- New: `client_app/lib/features/chat_tab/screens/{aktif_view.dart,pembayaran_view.dart,selesai_view.dart}` — the body of each sub-tab.
- New: `client_app/lib/features/chat_tab/widgets/{chat_row.dart,sub_tab_pill.dart}`
- New: `client_app/lib/features/chat_tab/providers/{pending_payments_provider.dart,selesai_history_provider.dart}`
- Delete: `client_app/lib/features/home/providers/bestie_history_provider.dart`
- Delete: `client_app/lib/features/chat/screens/bestie_history_screen.dart`
(or wherever it lives — confirm during code stage)
- Modify: `client_app/lib/features/home/widgets/halo_tab_bar.dart` — change
`/chat/history``/chat`; add red-dot rendering driven by
`pendingPaymentsProvider.total`.
- Modify: `client_app/lib/router.dart`:
- Add `/chat` (redirect → `/chat/aktif`), `/chat/aktif`, `/chat/pembayaran`,
`/chat/selesai` (wrapped in a single `ShellRoute`).
- Rename `/chat/history/:sessionId``/chat/transcript/:sessionId`.
- Remove `/chat/history`.
- Modify: any caller that does `context.push('/chat/history/$id')` for the
transcript — grep and update to `/chat/transcript/$id`.
## 10.10 Out of scope (this stage)
- **Failed-payment retry from the list.** Pembayaran only shows
not-yet-paid + not-yet-expired. Failed/expired surface via the existing
S6/S7 "pembayaran kedaluwarsa" screen on direct payment-flow re-entry, not
the list.
- **Refund / dispute states.** No row kind for these.
- **Search / filter** in `selesai`.
- **Concurrent active sessions.** Aktif is 0-or-1 by backend constraint.
- **Voice-call as separate sub-tab.** Lives in the same list with a Call pill.
## 10.11 Acceptance for Stage 10
1. Tapping `💬 chat` navigates to `/chat`, which redirects to `/chat/aktif`.
Direct navigation to `/chat/pembayaran` or `/chat/selesai` lands on the
correct tab. Tapping a sub-tab pill updates the URL accordingly.
2. With an active session: row appears in `aktif`, tap → live chat room with
composer focused. Returning to `/chat` keeps the row visible.
3. With a pending initial-session payment: row appears in `pembayaran` with
`bayar Rp X.XXX` chip; tap → Stage 3.5 waiting-payment screen.
4. With a pending extension payment: row appears in `pembayaran` with the
extension preview copy; tap → extension payment screen.
5. After 20 minutes without payment: row disappears from `pembayaran`; the
"pembayaran kedaluwarsa" screen shows on re-entry (Stage 3.6 behavior
unchanged).
6. Bottom-nav `💬 chat` shows a red dot iff `pembayaran` total > 0.
7. `aktif` sub-tab pill shows unread count when > 0.
8. `pembayaran` sub-tab pill shows pending count when > 0.
9. `selesai` sub-tab pill shows no badge regardless.
10. `selesai` scrolls past 20 items via cursor pagination without duplicates
or gaps.
11. Voice-call sessions render with the 📞 Call pill in both `aktif` and
`selesai`.
12. `/chat/history` is gone from `router.dart`; `/chat/history/:sessionId` is
renamed to `/chat/transcript/:sessionId`; no dead inbound `context.push`
references remain.
13. Maestro: new flow `09_chat_tab.yaml` covering aktif → resume,
pembayaran → payment, selesai → transcript.
14. Backend tests cover `getCustomerPendingPayments` (initial only,
extension only, mixed, expired excluded) and the new cursor-paginated
`getCustomerHistory`.
---
# Resolved Decisions (2026-05-09 — recorded from product review)
| # | Decision |
|---|---|
| 1 | **OTP stays 6-digit.** Figma 4-digit is stylistic only; we keep backend security parity. The visual row of 6 boxes uses tighter spacing to fit the same width. |
| 2 | **First-session call-mode lock.** First sessions are chat-only by default but **configurable** via `app_config.first_session_discount_modes` (default `["chat"]`; ops can flip to `["chat","call"]` to enable). |
| 3 | **Tanya Admin handles are CC-config-driven.** No hard-coded constants — sourced from `app_config.support_handles_json`, edited via CC. |
| 4 | **First-session-chat-only behavior** is the configurable knob in #2; when pricing goes real, the same knob is reused. |
| 5 | **ESP is information-only.** Tags are persisted on `chat_sessions.topics` and shown to the mitra at session start. **No matching, no routing, no pricing impact.** Topic-aware matching is not in any current phase. |
| 6 | **OTP-blocked popup carries over ESP/USP** within the same OTP attempt — user is never re-prompted after falling back to anon. (Cross-session: the values clear on app close.) |
| 7 | **Voice-call mode is just chat with a different price group + a `📞 Voice Call` badge in the chat header.** Mitra shares Google Meet (or any) link as a normal chat message. **No validation, no in-app call media, no composer helpers** — purely ops-handled. |
| + | **No free trial.** The previous Phase 3 free-trial concept is removed. Replaced by a configurable first-session discount (PRD §3.5). |
| + | **Social login is server-driven.** Backend probes env at boot; client_app reads `GET /api/shared/auth-providers` and hides Google/Apple buttons when the corresponding keys are not configured. The `--dart-define=ENABLE_SOCIAL_AUTH` build flag is removed. |
---
# Risk Register
| Risk | Likelihood | Mitigation |
|---|---|---|
| Font assets bloat APK by ~3 MB | high | Subset to `latin-ext` only — drops to ~600 KB; use `flutter pub run flutter_font_subset` |
| ThemeData rollout breaks pre-Phase-3.7 screens | medium | Stage 0 includes a visual diff sweep on existing screens before merging |
| 20-min QRIS polling kills battery | low | Polling pauses on background; 3s interval is mild |
| `is_free_trial``is_first_session_discount` migration leaves a service hot reading the dropped column | medium | Stage 1.2 grep-and-replace pass before migration runs; CI integration test that boots the backend post-migration |
| First-session-discount eligibility check races with payment commit | low | Check is server-authoritative on `payment-sessions` create; client never decides eligibility |
| `S11` thank-you delay feels slow on flaky network | low | Optimistic close: navigate to S11 immediately, retry close API in background |
| Two-step end-session feels naggy | medium | A/B switch via `app_config.end_session_two_step_confirm` (already in stage 1.5) |
| Auth-providers cache stale after env change | low | Module-load probe is fine for prod (restart on env change); in dev, document that backend restart is needed for the flag to flip |
| Mitra pastes a non-Meet URL in a `mode='call'` session | low | Out of scope — ops handles. Phase 4 does no validation. Optional follow-up: detect link missing in last N seconds → mitra-side reminder snackbar (not in plan). |
---
# Memory Touchpoints
This phase will likely add or update these memory entries when work begins:
- `project_phase4_status.md` — kick-off + progress
- `project_design_system_setup.md` — that we now have a tokenized theme
- `project_otp_followups_resolved.md` — closes the OTP rate-limit followup item
- `feedback_*` — only if a new convention is established (e.g., a Halo* widget naming rule)
---
# Definition of Done (Phase 4)
1. All PRD acceptance criteria pass on the real-device run.
2. All 8 Maestro flows green on CI (or local single-emulator run).
3. `flutter analyze` clean across client_app.
4. `npm test` clean in backend; new endpoints covered.
5. Figma `png/` reference vs. live app: spot-check sweep ≥ 95% visual parity, copy 100% exact.
6. PRD's "Out of scope" list confirmed not introduced (no rating, no SOS, no subscription).
7. The 7 open questions above are answered + recorded in this plan (replace
the section with "Resolved decisions").