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:
@@ -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,27 +213,29 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|||||||
ref.read(authProvider.notifier).requestOtp(_e164Phone())
|
ref.read(authProvider.notifier).requestOtp(_e164Phone())
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
if (!fromProfile) ...[
|
||||||
TextButton(
|
const SizedBox(height: 4),
|
||||||
onPressed: isLoading
|
TextButton(
|
||||||
? null
|
onPressed: isLoading
|
||||||
// Skip ESPb/USPb — the verified branch already ran ESPa+USPa,
|
? null
|
||||||
// so the redirect alias drops the user straight at PickMethod.
|
// Skip ESPb/USPb — the verified branch already ran ESPa+USPa,
|
||||||
: () => context.go('/onboarding/anon/method'),
|
// so the redirect alias drops the user straight at PickMethod.
|
||||||
style: TextButton.styleFrom(
|
: () => context.go('/onboarding/anon/method'),
|
||||||
foregroundColor: HaloTokens.inkSoft,
|
style: TextButton.styleFrom(
|
||||||
minimumSize: const Size(0, 40),
|
foregroundColor: HaloTokens.inkSoft,
|
||||||
),
|
minimumSize: const Size(0, 40),
|
||||||
child: const Text(
|
),
|
||||||
'lanjut tanpa verifikasi (harga normal)',
|
child: const Text(
|
||||||
style: TextStyle(
|
'lanjut tanpa verifikasi (harga normal)',
|
||||||
fontFamily: HaloTokens.fontBody,
|
style: TextStyle(
|
||||||
fontSize: 12.5,
|
fontFamily: HaloTokens.fontBody,
|
||||||
decoration: TextDecoration.underline,
|
fontSize: 12.5,
|
||||||
decorationColor: HaloTokens.inkSoft,
|
decoration: TextDecoration.underline,
|
||||||
|
decorationColor: HaloTokens.inkSoft,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user