feat(client/analytics): GA4 funnel instrumentation + unified home CTA
Add Firebase Analytics (GA4) funnel tracking to client_app: - AnalyticsService typed wrapper (enum-gated, no PII) + analyticsProvider - FirebaseAnalyticsObserver on GoRouter (screen_name via nameExtractor) - user_id = customer UUID, user_type property, set on auth resolve/upgrade - funnel events: curhat_start, curhat_repeat_start, auth_*, onboarding_usp_view, payment_view, payment_method_select, payment_started, pairing_matched/no_bestie - bottom-sheet events: verif_choice_view/select, bestie_choice_view/select, extension_offer_view, chat_extension_requested - payment_started carries app_instance_id + ga_session_id in the /payment-requests body for future server-side stitching (backend ignores) - curhat_mode_pick screen name disambiguates the chat/call mode picker (/payment/method-pick) from the payment-channel picker (/payment/method) - unify both home CTAs to "Aku Mau Curhat" Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/analytics/analytics_service.dart';
|
||||
import '../../../core/api/api_client_provider.dart';
|
||||
import '../../../core/auth/auth_notifier.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
@@ -35,6 +36,9 @@ class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
|
||||
final data = next.valueOrNull;
|
||||
if (data is AuthAnonymousData && !_routedAfterLogin) {
|
||||
_routedAfterLogin = true;
|
||||
// Anonymous identity established — activation step 6.
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logAuthComplete(AnalyticsUserType.anonymous);
|
||||
_proceedAfterLogin();
|
||||
}
|
||||
});
|
||||
@@ -82,12 +86,17 @@ class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logVerifChoiceView();
|
||||
final choice = await VerifChoiceSheet.show(context);
|
||||
if (!mounted || choice == null) {
|
||||
// User dismissed the sheet — let them tap Lanjut again to retry.
|
||||
// User dismissed the sheet — let them tap Lanjut again to retry. No
|
||||
// select event: the view→select gap is the abandonment signal.
|
||||
_routedAfterLogin = false;
|
||||
return;
|
||||
}
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logVerifChoiceSelect(choice);
|
||||
if (!mounted) return;
|
||||
await routeForVerifChoice(context, ref, choice);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/analytics/analytics_service.dart';
|
||||
import '../../../core/api/api_client_provider.dart';
|
||||
import '../../../core/auth/auth_notifier.dart';
|
||||
import '../../../core/constants.dart';
|
||||
@@ -75,6 +76,14 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
||||
if (_errorMessage != null && mounted) {
|
||||
setState(() => _errorMessage = null);
|
||||
}
|
||||
// OTP verify resolved to a real identity — activation/repeat step 6.
|
||||
final data = next.valueOrNull;
|
||||
if (data is AuthAuthenticatedData || data is AuthNeedsDisplayNameData) {
|
||||
// ignore: discarded_futures
|
||||
ref
|
||||
.read(analyticsProvider)
|
||||
.logAuthComplete(AnalyticsUserType.verified);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -152,6 +161,8 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
||||
final code = _readCode();
|
||||
if (code.length == _kOtpLength && !_autoSubmitted && _otpRequestId != null) {
|
||||
_autoSubmitted = true;
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logAuthOtpSubmit();
|
||||
ref.read(authProvider.notifier).verifyOtp(_otpRequestId!, code);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/analytics/analytics_service.dart';
|
||||
import '../../../core/auth/auth_notifier.dart';
|
||||
import '../../../core/constants.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
@@ -222,8 +223,15 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
: 'kirim kode',
|
||||
fullWidth: true,
|
||||
onPressed: canSubmit
|
||||
? () =>
|
||||
ref.read(authProvider.notifier).requestOtp(_e164Phone())
|
||||
? () {
|
||||
// ignore: discarded_futures
|
||||
ref
|
||||
.read(analyticsProvider)
|
||||
.logAuthStart(AnalyticsAuthMethod.phone);
|
||||
ref
|
||||
.read(authProvider.notifier)
|
||||
.requestOtp(_e164Phone());
|
||||
}
|
||||
: null,
|
||||
),
|
||||
if (!fromProfile) ...[
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/analytics/analytics_service.dart';
|
||||
import '../../../core/chat/active_session_notifier.dart';
|
||||
import '../../../core/pairing/pairing_notifier.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
import '../../payment/state/payment_draft_provider.dart';
|
||||
import '../../../core/theme/widgets/widgets.dart';
|
||||
|
||||
/// Phase 4 Stage 5 — S9 Match-found screen.
|
||||
@@ -35,6 +37,15 @@ class _BestieFoundScreenState extends ConsumerState<BestieFoundScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Funnel step 12 — paired with a mitra. A targeted-mitra draft means the
|
||||
// repeat funnel; otherwise activation. Fire once on view.
|
||||
final isRepeat =
|
||||
ref.read(paymentDraftNotifierProvider).targetedMitraId != null;
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logPairingMatched(
|
||||
funnel:
|
||||
isRepeat ? AnalyticsFunnel.repeat : AnalyticsFunnel.activation,
|
||||
);
|
||||
ref.listenManual<PairingData>(pairingProvider, (prev, next) {
|
||||
if (!mounted) return;
|
||||
if (next is PairingActiveData) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/analytics/analytics_service.dart';
|
||||
import '../../../core/chat/active_session_notifier.dart';
|
||||
import '../../../core/chat/chat_notifier.dart';
|
||||
import '../../../core/chat/session_closure_notifier.dart';
|
||||
@@ -512,6 +513,8 @@ class _TimerBanner extends ConsumerWidget {
|
||||
return _BannerKind.none;
|
||||
}));
|
||||
void onExtend() {
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logExtensionOfferView(sessionId: sessionId);
|
||||
PricingBottomSheet.showForExtension(
|
||||
context,
|
||||
sessionId: sessionId,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/analytics/analytics_service.dart';
|
||||
import '../../../core/pairing/pairing_notifier.dart';
|
||||
import '../../payment/state/payment_draft_provider.dart';
|
||||
|
||||
/// Terminal failed-pairing screen.
|
||||
///
|
||||
@@ -13,11 +15,30 @@ import '../../../core/pairing/pairing_notifier.dart';
|
||||
/// Single CTA "Kembali ke beranda" resets the pairing notifier and routes
|
||||
/// home. PopScope falls back to home for deep-link entry per project memory
|
||||
/// rule "Deep-link pop fallback".
|
||||
class NoBestieScreen extends ConsumerWidget {
|
||||
class NoBestieScreen extends ConsumerStatefulWidget {
|
||||
const NoBestieScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<NoBestieScreen> createState() => _NoBestieScreenState();
|
||||
}
|
||||
|
||||
class _NoBestieScreenState extends ConsumerState<NoBestieScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Funnel drop-off marker — pairing failed. A targeted-mitra draft means
|
||||
// the repeat funnel; otherwise activation. Fire once on view.
|
||||
final isRepeat =
|
||||
ref.read(paymentDraftNotifierProvider).targetedMitraId != null;
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logPairingNoBestie(
|
||||
funnel:
|
||||
isRepeat ? AnalyticsFunnel.repeat : AnalyticsFunnel.activation,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/analytics/analytics_service.dart';
|
||||
import '../../../core/chat/chat_opening_provider.dart';
|
||||
import '../../../core/chat/session_closure_notifier.dart';
|
||||
import '../../../core/constants.dart';
|
||||
@@ -85,6 +86,10 @@ class _PricingBottomSheetState extends ConsumerState<PricingBottomSheet> {
|
||||
}
|
||||
|
||||
void _onConfirm(PriceTier tier) {
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logChatExtensionRequested(
|
||||
sessionId: widget.extensionSessionId,
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
ref.read(sessionClosureProvider.notifier).requestExtension(
|
||||
widget.extensionSessionId,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/analytics/analytics_service.dart';
|
||||
import '../../core/auth/auth_notifier.dart';
|
||||
import '../../core/auth/onboarding_intent_provider.dart';
|
||||
import '../../core/availability/mitra_availability_notifier.dart';
|
||||
@@ -81,6 +82,11 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
|
||||
/// CTA path for SHomeReturning. Returning users get the bestie-choice sheet
|
||||
/// when they have prior history, otherwise jump to the new-payment shell.
|
||||
Future<void> _onCurhatBestieBaruPressed(BuildContext context) async {
|
||||
// Returning user starting a fresh curhat (repeat funnel). The
|
||||
// bestie_reselect sub-event fires later from the history list if they pick
|
||||
// a known bestie; this marks the top of the repeat funnel.
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logCurhatRepeatStart();
|
||||
bool hasHistory;
|
||||
try {
|
||||
hasHistory = await ref.read(bestieHistoryHasItemsProvider.future);
|
||||
@@ -89,6 +95,8 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
if (hasHistory) {
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logBestieChoiceView();
|
||||
await BestieChoiceSheet.show(context);
|
||||
return;
|
||||
}
|
||||
@@ -101,6 +109,9 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
|
||||
/// (the call-sign check); display_name_screen kicks off `loginAnonymous`
|
||||
/// and pushes into the verif-choice sheet.
|
||||
void _onAkuMauCurhatPressed(BuildContext context) {
|
||||
// Top of the activation funnel — fresh user tapping the primary CTA.
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logCurhatStart(entryPoint: 'home_primary');
|
||||
context.push('/auth/display-name');
|
||||
}
|
||||
|
||||
@@ -172,7 +183,7 @@ class _SHome1stView extends ConsumerWidget {
|
||||
const _GreetingSubtitle(),
|
||||
const SizedBox(height: 32),
|
||||
_PrimaryCTA(
|
||||
label: 'aku mau curhat',
|
||||
label: 'Aku Mau Curhat',
|
||||
enabled: mitraAvailable,
|
||||
onPressed: onCTA,
|
||||
),
|
||||
@@ -395,7 +406,7 @@ class _SHomeReturningView extends ConsumerWidget {
|
||||
activeSessionAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) => _PrimaryCTA(
|
||||
label: 'curhat sama bestie baru',
|
||||
label: 'Aku Mau Curhat',
|
||||
enabled: mitraAvailable,
|
||||
onPressed: onCTA,
|
||||
),
|
||||
@@ -415,7 +426,7 @@ class _SHomeReturningView extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
return _PrimaryCTA(
|
||||
label: 'curhat sama bestie baru',
|
||||
label: 'Aku Mau Curhat',
|
||||
enabled: mitraAvailable,
|
||||
onPressed: onCTA,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/analytics/analytics_service.dart';
|
||||
import '../../../core/constants.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
import '../../../core/theme/widgets/widgets.dart';
|
||||
@@ -132,6 +133,13 @@ class BestieHistoryListScreen extends ConsumerWidget {
|
||||
return;
|
||||
}
|
||||
if (item.mitraId == null) return;
|
||||
// Repeat funnel: user re-selected a known bestie. mitra_ref
|
||||
// is opaque (hashed) — never the raw mitra id, per the
|
||||
// no-PII / opaque-mitra-identity rule.
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logBestieReselect(
|
||||
mitraRef: item.mitraId!.hashCode.toString(),
|
||||
);
|
||||
// Stamp the targeted mitra onto the payment draft; the
|
||||
// multi-screen payment flow (entry → method → waiting →
|
||||
// notif-gate → searching) reads it back to fire the
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/analytics/analytics_service.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
import '../../../core/theme/widgets/widgets.dart';
|
||||
import '../../payment/state/payment_draft_provider.dart';
|
||||
@@ -52,6 +53,8 @@ class BestieChoiceSheet extends ConsumerWidget {
|
||||
subtitle: 'lanjut cerita ke bestie yang pernah dengerin kamu.',
|
||||
icon: Icons.favorite_outline,
|
||||
onTap: () {
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logBestieChoiceSelect(knownBestie: true);
|
||||
Navigator.of(context).pop();
|
||||
context.push('/bestie/history');
|
||||
},
|
||||
@@ -62,6 +65,8 @@ class BestieChoiceSheet extends ConsumerWidget {
|
||||
subtitle: 'cari bestie baru yang siap dengerin sekarang.',
|
||||
icon: Icons.auto_awesome_outlined,
|
||||
onTap: () {
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logBestieChoiceSelect(knownBestie: false);
|
||||
// explicit reset — this branch is blast-only, clear any stale targeted mitra
|
||||
ref.read(paymentDraftNotifierProvider.notifier).reset();
|
||||
Navigator.of(context).pop();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/analytics/analytics_service.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
import '../../../core/theme/widgets/widgets.dart';
|
||||
import '../usp_seen_provider.dart';
|
||||
@@ -11,11 +12,30 @@ import '../usp_seen_provider.dart';
|
||||
///
|
||||
/// `verified` ➞ USP → OTP (`/auth/register`).
|
||||
/// `anonymous` ➞ USP → `/payment/method-pick`.
|
||||
class UspScreen extends ConsumerWidget {
|
||||
class UspScreen extends ConsumerStatefulWidget {
|
||||
final bool verified;
|
||||
|
||||
const UspScreen({super.key, required this.verified});
|
||||
|
||||
@override
|
||||
ConsumerState<UspScreen> createState() => _UspScreenState();
|
||||
}
|
||||
|
||||
class _UspScreenState extends ConsumerState<UspScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Activation funnel step 7 — fire on view (not teardown). One-shot:
|
||||
// initState runs once per screen instance.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
// ignore: discarded_futures
|
||||
ref
|
||||
.read(analyticsProvider)
|
||||
.logOnboardingUspView(verified: widget.verified);
|
||||
});
|
||||
}
|
||||
|
||||
static const _cards = [
|
||||
_UspCard(
|
||||
icon: Icons.bolt_outlined,
|
||||
@@ -40,7 +60,7 @@ class UspScreen extends ConsumerWidget {
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Padding(
|
||||
@@ -94,7 +114,7 @@ class UspScreen extends ConsumerWidget {
|
||||
HaloButton(
|
||||
label: 'aku ngerti, lanjut',
|
||||
fullWidth: true,
|
||||
onPressed: () => _onContinue(context, ref),
|
||||
onPressed: () => _onContinue(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -103,12 +123,12 @@ class UspScreen extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onContinue(BuildContext context, WidgetRef ref) async {
|
||||
Future<void> _onContinue(BuildContext context) async {
|
||||
// Persist the local + server flag before leaving — next time the user
|
||||
// hits VerifChoice, this screen is skipped.
|
||||
await ref.read(uspSeenProvider.notifier).markSeen();
|
||||
if (!context.mounted) return;
|
||||
if (verified) {
|
||||
if (widget.verified) {
|
||||
context.push('/auth/register');
|
||||
} else {
|
||||
context.push('/payment/method-pick');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/analytics/analytics_service.dart';
|
||||
import '../../../core/auth/onboarding_intent_provider.dart';
|
||||
import '../../../core/chat/chat_opening_provider.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
@@ -38,6 +39,18 @@ class _PaymentEntryScreenState extends ConsumerState<PaymentEntryScreen> {
|
||||
// inherit a stale onboarding intent.
|
||||
ref.read(onboardingIntentProvider.notifier).state =
|
||||
OnboardingIntent.recover;
|
||||
|
||||
// Funnel step 8 — payment entry. A targeted mitra (set just before this
|
||||
// screen by the bestie-history list) marks the repeat funnel; otherwise
|
||||
// it's activation. resetExceptTarget() above preserves that flag.
|
||||
final isRepeat =
|
||||
ref.read(paymentDraftNotifierProvider).targetedMitraId != null;
|
||||
// ignore: discarded_futures
|
||||
ref.read(analyticsProvider).logPaymentView(
|
||||
funnel:
|
||||
isRepeat ? AnalyticsFunnel.repeat : AnalyticsFunnel.activation,
|
||||
isRepeat: isRepeat,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/analytics/analytics_service.dart';
|
||||
import '../../../core/api/api_client_provider.dart';
|
||||
import '../../../core/constants.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
@@ -53,7 +54,21 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
|
||||
_error = null;
|
||||
});
|
||||
final api = ref.read(apiClientProvider);
|
||||
final analytics = ref.read(analyticsProvider);
|
||||
try {
|
||||
// ⭐ Capture GA4 stitching identifiers BEFORE the POST so the backend can
|
||||
// store them in product_metadata and replay them in the server-fired
|
||||
// payment_confirmed (Measurement Protocol). The backend currently
|
||||
// ignores unknown body fields — intentional; we send now, stitch later.
|
||||
final appInstanceId = await analytics.appInstanceId();
|
||||
final gaSessionId = await analytics.sessionId();
|
||||
if (!mounted) return;
|
||||
|
||||
final analyticsIds = <String, dynamic>{
|
||||
if (appInstanceId != null) 'app_instance_id': appInstanceId,
|
||||
if (gaSessionId != null) 'ga_session_id': gaSessionId,
|
||||
};
|
||||
|
||||
final body = <String, dynamic>{
|
||||
'mode': draft.mode.value,
|
||||
'duration_minutes': draft.durationMinutes,
|
||||
@@ -61,11 +76,27 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
|
||||
'is_first_session_discount': draft.isFirstSessionDiscount,
|
||||
'method': _selectedCode,
|
||||
if (draft.targetedMitraId != null) 'targeted_mitra_id': draft.targetedMitraId,
|
||||
if (analyticsIds.isNotEmpty) 'analytics': analyticsIds,
|
||||
};
|
||||
final response = await api.post('/api/client/payment-requests/', data: body);
|
||||
final data = response['data'] as Map<String, dynamic>;
|
||||
final paymentId = data['id'] as String;
|
||||
ref.read(paymentDraftNotifierProvider.notifier).setPaymentId(paymentId);
|
||||
|
||||
// ⭐ payment_started fires AFTER the id is known. A targeted mitra means
|
||||
// the returning/repeat funnel; otherwise activation.
|
||||
final isRepeat = draft.targetedMitraId != null;
|
||||
// ignore: discarded_futures
|
||||
analytics.logPaymentStarted(
|
||||
paymentRequestId: paymentId,
|
||||
amount: draft.priceIDR!,
|
||||
method: _selectedCode!,
|
||||
funnel: isRepeat ? AnalyticsFunnel.repeat : AnalyticsFunnel.activation,
|
||||
isRepeat: isRepeat,
|
||||
productType: draft.mode.value,
|
||||
durationMinutes: draft.durationMinutes!,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
context.push('/payment/waiting/$paymentId');
|
||||
} on DioException catch (e) {
|
||||
@@ -216,10 +247,20 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
|
||||
_expandedGroupIds.remove(g.id);
|
||||
}
|
||||
}),
|
||||
onSelect: (code) => setState(() {
|
||||
_selectedCode = code;
|
||||
_error = null;
|
||||
}),
|
||||
onSelect: (code) {
|
||||
// Funnel step 9 — method chosen. Fire once per pick
|
||||
// (not on every rebuild).
|
||||
if (code != _selectedCode) {
|
||||
// ignore: discarded_futures
|
||||
ref
|
||||
.read(analyticsProvider)
|
||||
.logPaymentMethodSelect(method: code);
|
||||
}
|
||||
setState(() {
|
||||
_selectedCode = code;
|
||||
_error = null;
|
||||
});
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ part of 'payment_draft_provider.dart';
|
||||
// **************************************************************************
|
||||
|
||||
String _$paymentDraftNotifierHash() =>
|
||||
r'1c81b22f25f525cd290f54618bee0b69de792998';
|
||||
r'e489a593f5e1cc2794d13566a9cf960bb89e45c6';
|
||||
|
||||
/// See also [PaymentDraftNotifier].
|
||||
@ProviderFor(PaymentDraftNotifier)
|
||||
|
||||
Reference in New Issue
Block a user