Files
halobestie-clone/requirement/phase4-customer-flow-plan.md
ramadhan sjamsani 8c212cb464 Phase 4 PRD + plan: customer-flow redesign (Figma alignment)
Adds the Phase 4 requirement docs that align the customer app with the new
HaloBestie Figma design dump.

- requirement/flow_customer.md: source-of-truth numbered flow (input)
- requirement/flow_customer.mermaid.md: 6 mermaid diagrams + Figma cross-ref
- requirement/phase4-customer-flow.md: PRD (15 functional sections)
- requirement/phase4-customer-flow-plan.md: 10-stage implementation plan
- .gitignore: exclude requirement/Figma.zip + extracted Figma/ folder

Resolved product decisions: no free trial (replaced by configurable
first-session discount), pricing has independent chat/call groups,
voice-call mode is chat-with-badge (mitra shares Meet link manually),
social login is server-driven via /api/shared/auth-providers, ESP tags
are info-only (not used for matching).

No code changes; implementation starts at plan stage 0 (design system).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:21:26 +08:00

867 lines
36 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
> 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.
---
# 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%).
---
# 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").