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>
1204 lines
52 KiB
Markdown
1204 lines
52 KiB
Markdown
# Phase 4 — Implementation Plan
|
||
|
||
> **Status (2026-05-10):** Stages 0–8 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 0–1 are pure groundwork that
|
||
unblocks everything else; stages 2–8 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 2–8.
|
||
|
||
---
|
||
|
||
# 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").
|