# 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 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>` 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` 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 readStatus(); // notDetermined | granted | denied Future request(); // shows OS prompt (only if notDetermined) Future 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`). 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/.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").