Client Profile: save-phone banner for anonymous users

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 19:39:13 +08:00
parent bfb072ddfb
commit e6d991373e
3 changed files with 134 additions and 19 deletions

View File

@@ -123,6 +123,12 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
final name = _greetingName(authState.valueOrNull); final name = _greetingName(authState.valueOrNull);
final shownName = name.isEmpty ? 'kamu' : name; 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( return Scaffold(
backgroundColor: HaloTokens.bg, backgroundColor: HaloTokens.bg,
body: SafeArea( body: SafeArea(
@@ -207,6 +213,7 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
ref.read(authProvider.notifier).requestOtp(_e164Phone()) ref.read(authProvider.notifier).requestOtp(_e164Phone())
: null, : null,
), ),
if (!fromProfile) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
TextButton( TextButton(
onPressed: isLoading onPressed: isLoading
@@ -229,6 +236,7 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
), ),
), ),
], ],
],
), ),
), ),
), ),

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/auth/auth_notifier.dart'; import '../../core/auth/auth_notifier.dart';
import '../../core/theme/halo_tokens.dart'; import '../../core/theme/halo_tokens.dart';
import '../home/widgets/halo_tab_bar.dart'; import '../home/widgets/halo_tab_bar.dart';
@@ -19,6 +20,7 @@ class ProfileScreen extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final authData = ref.watch(authProvider).valueOrNull; final authData = ref.watch(authProvider).valueOrNull;
final isAnonymous = authData is AuthAnonymousData;
final (name, phone) = switch (authData) { final (name, phone) = switch (authData) {
AuthAuthenticatedData d => ( AuthAuthenticatedData d => (
@@ -53,6 +55,12 @@ class ProfileScreen extends ConsumerWidget {
), ),
), ),
const SizedBox(height: 18), const SizedBox(height: 18),
if (isAnonymous) ...[
_SavePhoneBanner(
onTap: () => context.push('/auth/register?from=profile'),
),
const SizedBox(height: 14),
],
_UserCard(name: name, phone: phone), _UserCard(name: name, phone: phone),
const SizedBox(height: 20), const SizedBox(height: 20),
_MenuCard(items: [ _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 { class _UserCard extends StatelessWidget {
final String name; final String name;
final String? phone; final String? phone;

View File

@@ -85,6 +85,17 @@ this doc.
### 1.2 Backend ### 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. - 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=<callsite-slug>` 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 ## 2. ESP screening + USP screen
> Figma: `screens/onboarding.jsx::S5ESP` and `S5USP`. Existing > Figma: `screens/onboarding.jsx::S5ESP` and `S5USP`. Existing