From e6d991373edeb9fec74235555178b65f9a75427e Mon Sep 17 00:00:00 2001 From: Ramadhan Sjamsani Date: Fri, 22 May 2026 19:39:13 +0800 Subject: [PATCH] Client Profile: save-phone banner for anonymous users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anonymous customers now see a brand-gradient "Simpan Nomor HP" panel above the user card on the kamu tab, ported from the Figma SProfile save-phone banner. Tapping it pushes /auth/register?from=profile, which hides the "lanjut tanpa verifikasi (harga normal)" link — a user who re-entered the verif funnel from Profile shouldn't be re-offered the anon exit. Spec §1.3 added documenting the ?from= entry-point convention. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../auth/screens/register_screen.dart | 46 +++++---- .../lib/features/profile/profile_screen.dart | 96 +++++++++++++++++++ requirement/phase4-customer-flow.md | 11 +++ 3 files changed, 134 insertions(+), 19 deletions(-) diff --git a/client_app/lib/features/auth/screens/register_screen.dart b/client_app/lib/features/auth/screens/register_screen.dart index e0259da..886ca2d 100644 --- a/client_app/lib/features/auth/screens/register_screen.dart +++ b/client_app/lib/features/auth/screens/register_screen.dart @@ -123,6 +123,12 @@ class _RegisterScreenState extends ConsumerState { final name = _greetingName(authState.valueOrNull); final shownName = name.isEmpty ? 'kamu' : name; + // When the user arrives via the Profile "Simpan Nomor HP" CTA they've + // already committed to identifying — drop the anonymous escape hatch so + // there's only one way forward. + final fromProfile = + GoRouterState.of(context).uri.queryParameters['from'] == 'profile'; + return Scaffold( backgroundColor: HaloTokens.bg, body: SafeArea( @@ -207,27 +213,29 @@ class _RegisterScreenState extends ConsumerState { ref.read(authProvider.notifier).requestOtp(_e164Phone()) : null, ), - const SizedBox(height: 4), - TextButton( - onPressed: isLoading - ? null - // Skip ESPb/USPb — the verified branch already ran ESPa+USPa, - // so the redirect alias drops the user straight at PickMethod. - : () => context.go('/onboarding/anon/method'), - style: TextButton.styleFrom( - foregroundColor: HaloTokens.inkSoft, - minimumSize: const Size(0, 40), - ), - child: const Text( - 'lanjut tanpa verifikasi (harga normal)', - style: TextStyle( - fontFamily: HaloTokens.fontBody, - fontSize: 12.5, - decoration: TextDecoration.underline, - decorationColor: HaloTokens.inkSoft, + if (!fromProfile) ...[ + const SizedBox(height: 4), + TextButton( + onPressed: isLoading + ? null + // Skip ESPb/USPb — the verified branch already ran ESPa+USPa, + // so the redirect alias drops the user straight at PickMethod. + : () => context.go('/onboarding/anon/method'), + style: TextButton.styleFrom( + foregroundColor: HaloTokens.inkSoft, + minimumSize: const Size(0, 40), + ), + child: const Text( + 'lanjut tanpa verifikasi (harga normal)', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 12.5, + decoration: TextDecoration.underline, + decorationColor: HaloTokens.inkSoft, + ), ), ), - ), + ], ], ), ), diff --git a/client_app/lib/features/profile/profile_screen.dart b/client_app/lib/features/profile/profile_screen.dart index ff0a441..e098053 100644 --- a/client_app/lib/features/profile/profile_screen.dart +++ b/client_app/lib/features/profile/profile_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import '../../core/auth/auth_notifier.dart'; import '../../core/theme/halo_tokens.dart'; import '../home/widgets/halo_tab_bar.dart'; @@ -19,6 +20,7 @@ class ProfileScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final authData = ref.watch(authProvider).valueOrNull; + final isAnonymous = authData is AuthAnonymousData; final (name, phone) = switch (authData) { AuthAuthenticatedData d => ( @@ -53,6 +55,12 @@ class ProfileScreen extends ConsumerWidget { ), ), const SizedBox(height: 18), + if (isAnonymous) ...[ + _SavePhoneBanner( + onTap: () => context.push('/auth/register?from=profile'), + ), + const SizedBox(height: 14), + ], _UserCard(name: name, phone: phone), const SizedBox(height: 20), _MenuCard(items: [ @@ -154,6 +162,94 @@ class ProfileScreen extends ConsumerWidget { } } +class _SavePhoneBanner extends StatelessWidget { + final VoidCallback onTap; + const _SavePhoneBanner({required this.onTap}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [HaloTokens.brandSofter, HaloTokens.brandSoft], + ), + borderRadius: HaloRadius.lg, + border: Border.all(color: HaloTokens.brand, width: 1.5), + boxShadow: [ + BoxShadow( + color: HaloTokens.brand.withValues(alpha: 0.10), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 32, + height: 32, + decoration: const BoxDecoration( + color: HaloTokens.brand, + borderRadius: BorderRadius.all(Radius.circular(10)), + ), + alignment: Alignment.center, + child: const Icon( + Icons.smartphone, + size: 18, + color: Colors.white, + ), + ), + const SizedBox(width: 10), + const Expanded( + child: Text( + 'Biar riwayat curhat kamu tersimpan, yuk simpan Nomor Handphone kamu…', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 13, + fontWeight: FontWeight.w600, + color: HaloTokens.brandDark, + height: 1.4, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Material( + color: HaloTokens.brand, + borderRadius: HaloRadius.md, + child: InkWell( + onTap: onTap, + borderRadius: HaloRadius.md, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + alignment: Alignment.center, + child: const Text( + 'Simpan Nomor HP', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 13.5, + fontWeight: FontWeight.w600, + color: Colors.white, + letterSpacing: -0.07, + ), + ), + ), + ), + ), + ], + ), + ); + } +} + class _UserCard extends StatelessWidget { final String name; final String? phone; diff --git a/requirement/phase4-customer-flow.md b/requirement/phase4-customer-flow.md index fc426e2..76f1220 100644 --- a/requirement/phase4-customer-flow.md +++ b/requirement/phase4-customer-flow.md @@ -85,6 +85,17 @@ this doc. ### 1.2 Backend - New: `GET /api/client/onboarding-state` → returns `{ has_paid_first_session: bool }`. Used by client_app to pick whether to show this sheet. +### 1.3 Profile re-prompt for anon users (shipped 2026-05-22) + +> Figma: `screens/extras.jsx::SProfile` save-phone banner (lines 170-204). +> Code: `client_app/lib/features/profile/profile_screen.dart::_SavePhoneBanner`. + +For users who chose the anon path in §1 and later open the **kamu** (Profile) tab, a brand-gradient banner appears between the page title and the user card. Body copy: *"Biar riwayat curhat kamu tersimpan, yuk simpan Nomor Handphone kamu…"*. CTA `Simpan Nomor HP` pushes `/auth/register?from=profile`. + +- Banner gates on `authData is AuthAnonymousData` — disappears the moment the auth state flips to `AuthAuthenticatedData` after a successful OTP verify. +- The `?from=profile` query param causes `RegisterScreen` to **omit** the *"lanjut tanpa verifikasi (harga normal)"* escape-hatch link. A user who tapped the banner deliberately re-entered the verif funnel, so we don't re-offer the anon exit. +- Convention for future entry points: when pushing into `/auth/register` from somewhere that should branch the screen (copy, escape hatch, post-OTP destination), pass `?from=` and read `GoRouterState.of(context).uri.queryParameters['from']` in `RegisterScreen.build()`. The router's redirect preserves query params for anonymous users on `/auth/*` routes. + ## 2. ESP screening + USP screen > Figma: `screens/onboarding.jsx::S5ESP` and `S5USP`. Existing