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:
254
client_app/lib/core/analytics/analytics_service.dart
Normal file
254
client_app/lib/core/analytics/analytics_service.dart
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
import '../../features/auth/widgets/verif_choice_sheet.dart' show VerifChoice;
|
||||||
|
|
||||||
|
part 'analytics_service.g.dart';
|
||||||
|
|
||||||
|
/// Which lifecycle funnel an event belongs to. GA4 reports filter on the
|
||||||
|
/// `funnel` custom dimension; the activation and repeat funnels deliberately
|
||||||
|
/// share event names and are split by this param (see
|
||||||
|
/// requirement/analytics-funnel-plan.md §2/§5).
|
||||||
|
enum AnalyticsFunnel {
|
||||||
|
activation('activation'),
|
||||||
|
repeat('repeat');
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
const AnalyticsFunnel(this.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identity method the user chose at the start of an auth flow. Low-cardinality
|
||||||
|
/// and PII-free — the value is the *channel*, never the phone/email itself.
|
||||||
|
enum AnalyticsAuthMethod {
|
||||||
|
phone('phone'),
|
||||||
|
google('google'),
|
||||||
|
apple('apple');
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
const AnalyticsAuthMethod(this.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User-property bucket for `user_type`. Anonymous = server-issued anon
|
||||||
|
/// customer with no identity; verified = phone/Google/Apple linked.
|
||||||
|
enum AnalyticsUserType {
|
||||||
|
anonymous('anonymous'),
|
||||||
|
verified('verified');
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
const AnalyticsUserType(this.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable screen names for the GoRouter observer. Keeping these as an enum
|
||||||
|
/// (rather than mapping raw `state.uri.path`) guarantees path params like
|
||||||
|
/// `:sessionId` / `:paymentId` / `:mitraId` never leak into `screen_name`.
|
||||||
|
enum AnalyticsScreen {
|
||||||
|
splash('splash'),
|
||||||
|
authDisplayName('auth_display_name'),
|
||||||
|
authRegister('auth_register'),
|
||||||
|
authOtp('auth_otp'),
|
||||||
|
authSetName('auth_set_name'),
|
||||||
|
authForceRegister('auth_force_register'),
|
||||||
|
onboardingUspVerified('onboarding_usp_verified'),
|
||||||
|
onboardingUspAnon('onboarding_usp_anon'),
|
||||||
|
onboardingNotifGate('onboarding_notif_gate'),
|
||||||
|
home('home'),
|
||||||
|
profile('profile'),
|
||||||
|
paymentEntry('payment_entry'),
|
||||||
|
paymentDiscountPaywall('payment_discount_paywall'),
|
||||||
|
// Route /payment/method-pick is actually the chat-vs-call MODE picker
|
||||||
|
// ("pilih cara curhat"), NOT the payment-channel picker (that's
|
||||||
|
// paymentMethod → /payment/method). Named accordingly to avoid funnel
|
||||||
|
// ambiguity between the two.
|
||||||
|
curhatModePick('curhat_mode_pick'),
|
||||||
|
paymentDurationPick('payment_duration_pick'),
|
||||||
|
paymentMethod('payment_method'),
|
||||||
|
paymentWaiting('payment_waiting'),
|
||||||
|
paymentExpired('payment_expired'),
|
||||||
|
chatSearching('chat_searching'),
|
||||||
|
chatFound('chat_found'),
|
||||||
|
chatNoBestie('chat_no_bestie'),
|
||||||
|
chatWaitingTargeted('chat_waiting_targeted'),
|
||||||
|
chatSession('chat_session'),
|
||||||
|
chatThankYou('chat_thank_you'),
|
||||||
|
chatTabAktif('chat_tab_aktif'),
|
||||||
|
chatTabPembayaran('chat_tab_pembayaran'),
|
||||||
|
chatTabSelesai('chat_tab_selesai'),
|
||||||
|
chatTranscript('chat_transcript'),
|
||||||
|
bestieHistory('bestie_history');
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
const AnalyticsScreen(this.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thin, typed façade over `FirebaseAnalytics.instance`.
|
||||||
|
///
|
||||||
|
/// Every funnel event in requirement/analytics-funnel-plan.md §5 is a named
|
||||||
|
/// method here so call sites never pass free-form strings — the only way to
|
||||||
|
/// log is through a method whose params are fixed and PII-free. New events get
|
||||||
|
/// added to the spec table first, then here (governance, §9).
|
||||||
|
class AnalyticsService {
|
||||||
|
AnalyticsService(this._analytics);
|
||||||
|
|
||||||
|
final FirebaseAnalytics _analytics;
|
||||||
|
|
||||||
|
// ── Identity & user properties (§4) ───────────────────────────────────────
|
||||||
|
|
||||||
|
/// Set the opaque customer UUID. Re-set on identity upgrade (anon→verified)
|
||||||
|
/// so the same `user_id` continues across the merge. Never a phone/name.
|
||||||
|
Future<void> setUserId(String? customerId) =>
|
||||||
|
_analytics.setUserId(id: customerId);
|
||||||
|
|
||||||
|
Future<void> setUserType(AnalyticsUserType type) =>
|
||||||
|
_analytics.setUserProperty(name: 'user_type', value: type.value);
|
||||||
|
|
||||||
|
Future<void> setIsReturning(bool isReturning) => _analytics.setUserProperty(
|
||||||
|
name: 'is_returning',
|
||||||
|
value: isReturning ? 'true' : 'false',
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Payment stitching helpers (§3) ────────────────────────────────────────
|
||||||
|
|
||||||
|
/// App-instance id required by the GA4 Measurement Protocol to stitch a
|
||||||
|
/// server-fired `payment_confirmed` back to this device's stream.
|
||||||
|
Future<String?> appInstanceId() => _analytics.appInstanceId;
|
||||||
|
|
||||||
|
/// Current GA session id — replayed server-side so the MP event lands in the
|
||||||
|
/// same session as the client funnel.
|
||||||
|
Future<int?> sessionId() => _analytics.getSessionId();
|
||||||
|
|
||||||
|
// ── Funnel events (§5) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Activation: Home primary "aku mau curhat" CTA.
|
||||||
|
Future<void> logCurhatStart({String? entryPoint}) => _log('curhat_start', {
|
||||||
|
'funnel': AnalyticsFunnel.activation.value,
|
||||||
|
if (entryPoint != null) 'entry_point': entryPoint,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Repeat: returning "curhat sama bestie baru" / "curhat lagi" path.
|
||||||
|
Future<void> logCurhatRepeatStart() => _log('curhat_repeat_start', {
|
||||||
|
'funnel': AnalyticsFunnel.repeat.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Repeat: tapped a known bestie row in `/bestie/history`. `mitraRef` is an
|
||||||
|
/// opaque token (never the mitra's name/id surfaced to GA) — pass an
|
||||||
|
/// already-hashed/short ref or omit.
|
||||||
|
Future<void> logBestieReselect({String? mitraRef}) => _log('bestie_reselect', {
|
||||||
|
'funnel': AnalyticsFunnel.repeat.value,
|
||||||
|
if (mitraRef != null) 'mitra_ref': mitraRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> logAuthStart(AnalyticsAuthMethod method) => _log('auth_start', {
|
||||||
|
'method': method.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> logAuthOtpSubmit() => _log('auth_otp_submit', const {});
|
||||||
|
|
||||||
|
Future<void> logAuthComplete(AnalyticsUserType userType) =>
|
||||||
|
_log('auth_complete', {'user_type': userType.value});
|
||||||
|
|
||||||
|
Future<void> logOnboardingUspView({required bool verified}) =>
|
||||||
|
_log('onboarding_usp_view', {'verified': verified});
|
||||||
|
|
||||||
|
Future<void> logPaymentView({
|
||||||
|
required AnalyticsFunnel funnel,
|
||||||
|
required bool isRepeat,
|
||||||
|
}) =>
|
||||||
|
_log('payment_view', {
|
||||||
|
'funnel': funnel.value,
|
||||||
|
'is_repeat': isRepeat,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> logPaymentMethodSelect({required String method}) =>
|
||||||
|
_log('payment_method_select', {'method': method});
|
||||||
|
|
||||||
|
/// ⭐ Fired only AFTER the POST /payment-requests returns an id.
|
||||||
|
Future<void> logPaymentStarted({
|
||||||
|
required String paymentRequestId,
|
||||||
|
required int amount,
|
||||||
|
required String method,
|
||||||
|
required AnalyticsFunnel funnel,
|
||||||
|
required bool isRepeat,
|
||||||
|
required String productType,
|
||||||
|
required int durationMinutes,
|
||||||
|
String currency = 'IDR',
|
||||||
|
}) =>
|
||||||
|
_log('payment_started', {
|
||||||
|
'payment_request_id': paymentRequestId,
|
||||||
|
'amount': amount,
|
||||||
|
'currency': currency,
|
||||||
|
'method': method,
|
||||||
|
'funnel': funnel.value,
|
||||||
|
'is_repeat': isRepeat,
|
||||||
|
'product_type': productType,
|
||||||
|
'duration_minutes': durationMinutes,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> logPairingMatched({required AnalyticsFunnel funnel}) =>
|
||||||
|
_log('pairing_matched', {'funnel': funnel.value});
|
||||||
|
|
||||||
|
Future<void> logPairingNoBestie({required AnalyticsFunnel funnel}) =>
|
||||||
|
_log('pairing_no_bestie', {'funnel': funnel.value});
|
||||||
|
|
||||||
|
// ── Bottom-sheet / modal funnel events (§5) ───────────────────────────────
|
||||||
|
|
||||||
|
/// Activation: post-name Verif Choice Sheet shown. Paired with
|
||||||
|
/// [logVerifChoiceSelect] — the gap between view and select is abandonment.
|
||||||
|
Future<void> logVerifChoiceView() =>
|
||||||
|
_log('verif_choice_view', const {});
|
||||||
|
|
||||||
|
/// Activation: user picked an identity branch in the Verif Choice Sheet.
|
||||||
|
/// Maps the [VerifChoice] enum to a low-cardinality, PII-free channel value.
|
||||||
|
Future<void> logVerifChoiceSelect(VerifChoice choice) =>
|
||||||
|
_log('verif_choice_select', {
|
||||||
|
'choice': switch (choice) {
|
||||||
|
VerifChoice.verified => 'verified',
|
||||||
|
VerifChoice.anonymous => 'anonymous',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Repeat: Bestie Choice Sheet shown from the returning-user Home CTA.
|
||||||
|
Future<void> logBestieChoiceView() =>
|
||||||
|
_log('bestie_choice_view', const {});
|
||||||
|
|
||||||
|
/// Repeat: user picked a bestie branch in the Bestie Choice Sheet.
|
||||||
|
Future<void> logBestieChoiceSelect({required bool knownBestie}) =>
|
||||||
|
_log('bestie_choice_select', {
|
||||||
|
'choice': knownBestie ? 'known_bestie' : 'new_bestie',
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Repeat: in-session extension upsell sheet shown (time-up / low-time).
|
||||||
|
Future<void> logExtensionOfferView({String? sessionId}) =>
|
||||||
|
_log('extension_offer_view', {
|
||||||
|
if (sessionId != null) 'session_id': sessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Repeat: user confirmed an extension from the upsell sheet (not dismiss
|
||||||
|
/// or "akhiri sesi"). Fired the moment the perpanjang CTA is committed.
|
||||||
|
Future<void> logChatExtensionRequested({String? sessionId}) =>
|
||||||
|
_log('chat_extension_requested', {
|
||||||
|
if (sessionId != null) 'session_id': sessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Internal ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> _log(String name, Map<String, Object?> params) async {
|
||||||
|
final clean = <String, Object>{
|
||||||
|
for (final e in params.entries)
|
||||||
|
if (e.value != null) e.key: e.value!,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await _analytics.logEvent(name: name, parameters: clean.isEmpty ? null : clean);
|
||||||
|
} catch (e) {
|
||||||
|
// Analytics must never break a user flow.
|
||||||
|
debugPrint('[analytics] failed to log $name: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep-alive so a single AnalyticsService (and its FirebaseAnalytics handle)
|
||||||
|
/// is shared across screens/notifiers for the app's lifetime.
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
AnalyticsService analytics(Ref ref) =>
|
||||||
|
AnalyticsService(FirebaseAnalytics.instance);
|
||||||
29
client_app/lib/core/analytics/analytics_service.g.dart
Normal file
29
client_app/lib/core/analytics/analytics_service.g.dart
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'analytics_service.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
String _$analyticsHash() => r'862f59acd499543968f9ee591cec55cdd1b50035';
|
||||||
|
|
||||||
|
/// Keep-alive so a single AnalyticsService (and its FirebaseAnalytics handle)
|
||||||
|
/// is shared across screens/notifiers for the app's lifetime.
|
||||||
|
///
|
||||||
|
/// Copied from [analytics].
|
||||||
|
@ProviderFor(analytics)
|
||||||
|
final analyticsProvider = Provider<AnalyticsService>.internal(
|
||||||
|
analytics,
|
||||||
|
name: r'analyticsProvider',
|
||||||
|
debugGetCreateSourceHash:
|
||||||
|
const bool.fromEnvironment('dart.vm.product') ? null : _$analyticsHash,
|
||||||
|
dependencies: null,
|
||||||
|
allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||||
|
// ignore: unused_element
|
||||||
|
typedef AnalyticsRef = ProviderRef<AnalyticsService>;
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||||
@@ -6,7 +6,7 @@ part of 'auth_notifier.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$authHash() => r'f0382b1380749cebe8182ad221023926768ebb92';
|
String _$authHash() => r'76cd43babe5503d35a35c8fd23ba32afbc4c8c2d';
|
||||||
|
|
||||||
/// See also [Auth].
|
/// See also [Auth].
|
||||||
@ProviderFor(Auth)
|
@ProviderFor(Auth)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ final chatRemainingSecondsProvider = AutoDisposeStreamProvider<int>.internal(
|
|||||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||||
// ignore: unused_element
|
// ignore: unused_element
|
||||||
typedef ChatRemainingSecondsRef = AutoDisposeStreamProviderRef<int>;
|
typedef ChatRemainingSecondsRef = AutoDisposeStreamProviderRef<int>;
|
||||||
String _$chatHash() => r'a42bf404f5b38034c6c9511259b64b1092af3caa';
|
String _$chatHash() => r'56f019ce6e527128ab42a71f56220c5412cfec0f';
|
||||||
|
|
||||||
/// See also [Chat].
|
/// See also [Chat].
|
||||||
@ProviderFor(Chat)
|
@ProviderFor(Chat)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ part of 'session_closure_notifier.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$sessionClosureHash() => r'a9aaf26da64d3489497d057e2b05741c08444e07';
|
String _$sessionClosureHash() => r'1ab9df044138115e232b3df494e2895177d9d66d';
|
||||||
|
|
||||||
/// See also [SessionClosure].
|
/// See also [SessionClosure].
|
||||||
@ProviderFor(SessionClosure)
|
@ProviderFor(SessionClosure)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ part of 'pairing_notifier.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$pairingHash() => r'd39980fe5b82348d03485006d0534ab597b93ceb';
|
String _$pairingHash() => r'e66bbf67e1013b3d25230ed01fe2595bff943b3a';
|
||||||
|
|
||||||
/// See also [Pairing].
|
/// See also [Pairing].
|
||||||
@ProviderFor(Pairing)
|
@ProviderFor(Pairing)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/analytics/analytics_service.dart';
|
||||||
import '../../../core/api/api_client_provider.dart';
|
import '../../../core/api/api_client_provider.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';
|
||||||
@@ -35,6 +36,9 @@ class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
|
|||||||
final data = next.valueOrNull;
|
final data = next.valueOrNull;
|
||||||
if (data is AuthAnonymousData && !_routedAfterLogin) {
|
if (data is AuthAnonymousData && !_routedAfterLogin) {
|
||||||
_routedAfterLogin = true;
|
_routedAfterLogin = true;
|
||||||
|
// Anonymous identity established — activation step 6.
|
||||||
|
// ignore: discarded_futures
|
||||||
|
ref.read(analyticsProvider).logAuthComplete(AnalyticsUserType.anonymous);
|
||||||
_proceedAfterLogin();
|
_proceedAfterLogin();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -82,12 +86,17 @@ class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ignore: discarded_futures
|
||||||
|
ref.read(analyticsProvider).logVerifChoiceView();
|
||||||
final choice = await VerifChoiceSheet.show(context);
|
final choice = await VerifChoiceSheet.show(context);
|
||||||
if (!mounted || choice == null) {
|
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;
|
_routedAfterLogin = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// ignore: discarded_futures
|
||||||
|
ref.read(analyticsProvider).logVerifChoiceSelect(choice);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await routeForVerifChoice(context, ref, choice);
|
await routeForVerifChoice(context, ref, choice);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../../core/analytics/analytics_service.dart';
|
||||||
import '../../../core/api/api_client_provider.dart';
|
import '../../../core/api/api_client_provider.dart';
|
||||||
import '../../../core/auth/auth_notifier.dart';
|
import '../../../core/auth/auth_notifier.dart';
|
||||||
import '../../../core/constants.dart';
|
import '../../../core/constants.dart';
|
||||||
@@ -75,6 +76,14 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
|||||||
if (_errorMessage != null && mounted) {
|
if (_errorMessage != null && mounted) {
|
||||||
setState(() => _errorMessage = null);
|
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();
|
final code = _readCode();
|
||||||
if (code.length == _kOtpLength && !_autoSubmitted && _otpRequestId != null) {
|
if (code.length == _kOtpLength && !_autoSubmitted && _otpRequestId != null) {
|
||||||
_autoSubmitted = true;
|
_autoSubmitted = true;
|
||||||
|
// ignore: discarded_futures
|
||||||
|
ref.read(analyticsProvider).logAuthOtpSubmit();
|
||||||
ref.read(authProvider.notifier).verifyOtp(_otpRequestId!, code);
|
ref.read(authProvider.notifier).verifyOtp(_otpRequestId!, code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/analytics/analytics_service.dart';
|
||||||
import '../../../core/auth/auth_notifier.dart';
|
import '../../../core/auth/auth_notifier.dart';
|
||||||
import '../../../core/constants.dart';
|
import '../../../core/constants.dart';
|
||||||
import '../../../core/theme/halo_tokens.dart';
|
import '../../../core/theme/halo_tokens.dart';
|
||||||
@@ -222,8 +223,15 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|||||||
: 'kirim kode',
|
: 'kirim kode',
|
||||||
fullWidth: true,
|
fullWidth: true,
|
||||||
onPressed: canSubmit
|
onPressed: canSubmit
|
||||||
? () =>
|
? () {
|
||||||
ref.read(authProvider.notifier).requestOtp(_e164Phone())
|
// ignore: discarded_futures
|
||||||
|
ref
|
||||||
|
.read(analyticsProvider)
|
||||||
|
.logAuthStart(AnalyticsAuthMethod.phone);
|
||||||
|
ref
|
||||||
|
.read(authProvider.notifier)
|
||||||
|
.requestOtp(_e164Phone());
|
||||||
|
}
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
if (!fromProfile) ...[
|
if (!fromProfile) ...[
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
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 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/analytics/analytics_service.dart';
|
||||||
import '../../../core/chat/active_session_notifier.dart';
|
import '../../../core/chat/active_session_notifier.dart';
|
||||||
import '../../../core/pairing/pairing_notifier.dart';
|
import '../../../core/pairing/pairing_notifier.dart';
|
||||||
import '../../../core/theme/halo_tokens.dart';
|
import '../../../core/theme/halo_tokens.dart';
|
||||||
|
import '../../payment/state/payment_draft_provider.dart';
|
||||||
import '../../../core/theme/widgets/widgets.dart';
|
import '../../../core/theme/widgets/widgets.dart';
|
||||||
|
|
||||||
/// Phase 4 Stage 5 — S9 Match-found screen.
|
/// Phase 4 Stage 5 — S9 Match-found screen.
|
||||||
@@ -35,6 +37,15 @@ class _BestieFoundScreenState extends ConsumerState<BestieFoundScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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) {
|
ref.listenManual<PairingData>(pairingProvider, (prev, next) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (next is PairingActiveData) {
|
if (next is PairingActiveData) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
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 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/analytics/analytics_service.dart';
|
||||||
import '../../../core/chat/active_session_notifier.dart';
|
import '../../../core/chat/active_session_notifier.dart';
|
||||||
import '../../../core/chat/chat_notifier.dart';
|
import '../../../core/chat/chat_notifier.dart';
|
||||||
import '../../../core/chat/session_closure_notifier.dart';
|
import '../../../core/chat/session_closure_notifier.dart';
|
||||||
@@ -512,6 +513,8 @@ class _TimerBanner extends ConsumerWidget {
|
|||||||
return _BannerKind.none;
|
return _BannerKind.none;
|
||||||
}));
|
}));
|
||||||
void onExtend() {
|
void onExtend() {
|
||||||
|
// ignore: discarded_futures
|
||||||
|
ref.read(analyticsProvider).logExtensionOfferView(sessionId: sessionId);
|
||||||
PricingBottomSheet.showForExtension(
|
PricingBottomSheet.showForExtension(
|
||||||
context,
|
context,
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
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 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/analytics/analytics_service.dart';
|
||||||
import '../../../core/pairing/pairing_notifier.dart';
|
import '../../../core/pairing/pairing_notifier.dart';
|
||||||
|
import '../../payment/state/payment_draft_provider.dart';
|
||||||
|
|
||||||
/// Terminal failed-pairing screen.
|
/// 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
|
/// Single CTA "Kembali ke beranda" resets the pairing notifier and routes
|
||||||
/// home. PopScope falls back to home for deep-link entry per project memory
|
/// home. PopScope falls back to home for deep-link entry per project memory
|
||||||
/// rule "Deep-link pop fallback".
|
/// rule "Deep-link pop fallback".
|
||||||
class NoBestieScreen extends ConsumerWidget {
|
class NoBestieScreen extends ConsumerStatefulWidget {
|
||||||
const NoBestieScreen({super.key});
|
const NoBestieScreen({super.key});
|
||||||
|
|
||||||
@override
|
@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(
|
return PopScope(
|
||||||
canPop: true,
|
canPop: true,
|
||||||
onPopInvokedWithResult: (didPop, _) {
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
|
|||||||
@@ -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 '../../../core/analytics/analytics_service.dart';
|
||||||
import '../../../core/chat/chat_opening_provider.dart';
|
import '../../../core/chat/chat_opening_provider.dart';
|
||||||
import '../../../core/chat/session_closure_notifier.dart';
|
import '../../../core/chat/session_closure_notifier.dart';
|
||||||
import '../../../core/constants.dart';
|
import '../../../core/constants.dart';
|
||||||
@@ -85,6 +86,10 @@ class _PricingBottomSheetState extends ConsumerState<PricingBottomSheet> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onConfirm(PriceTier tier) {
|
void _onConfirm(PriceTier tier) {
|
||||||
|
// ignore: discarded_futures
|
||||||
|
ref.read(analyticsProvider).logChatExtensionRequested(
|
||||||
|
sessionId: widget.extensionSessionId,
|
||||||
|
);
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
ref.read(sessionClosureProvider.notifier).requestExtension(
|
ref.read(sessionClosureProvider.notifier).requestExtension(
|
||||||
widget.extensionSessionId,
|
widget.extensionSessionId,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 'package:go_router/go_router.dart';
|
||||||
|
import '../../core/analytics/analytics_service.dart';
|
||||||
import '../../core/auth/auth_notifier.dart';
|
import '../../core/auth/auth_notifier.dart';
|
||||||
import '../../core/auth/onboarding_intent_provider.dart';
|
import '../../core/auth/onboarding_intent_provider.dart';
|
||||||
import '../../core/availability/mitra_availability_notifier.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
|
/// CTA path for SHomeReturning. Returning users get the bestie-choice sheet
|
||||||
/// when they have prior history, otherwise jump to the new-payment shell.
|
/// when they have prior history, otherwise jump to the new-payment shell.
|
||||||
Future<void> _onCurhatBestieBaruPressed(BuildContext context) async {
|
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;
|
bool hasHistory;
|
||||||
try {
|
try {
|
||||||
hasHistory = await ref.read(bestieHistoryHasItemsProvider.future);
|
hasHistory = await ref.read(bestieHistoryHasItemsProvider.future);
|
||||||
@@ -89,6 +95,8 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
|
|||||||
}
|
}
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
if (hasHistory) {
|
if (hasHistory) {
|
||||||
|
// ignore: discarded_futures
|
||||||
|
ref.read(analyticsProvider).logBestieChoiceView();
|
||||||
await BestieChoiceSheet.show(context);
|
await BestieChoiceSheet.show(context);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -101,6 +109,9 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
|
|||||||
/// (the call-sign check); display_name_screen kicks off `loginAnonymous`
|
/// (the call-sign check); display_name_screen kicks off `loginAnonymous`
|
||||||
/// and pushes into the verif-choice sheet.
|
/// and pushes into the verif-choice sheet.
|
||||||
void _onAkuMauCurhatPressed(BuildContext context) {
|
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');
|
context.push('/auth/display-name');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +183,7 @@ class _SHome1stView extends ConsumerWidget {
|
|||||||
const _GreetingSubtitle(),
|
const _GreetingSubtitle(),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
_PrimaryCTA(
|
_PrimaryCTA(
|
||||||
label: 'aku mau curhat',
|
label: 'Aku Mau Curhat',
|
||||||
enabled: mitraAvailable,
|
enabled: mitraAvailable,
|
||||||
onPressed: onCTA,
|
onPressed: onCTA,
|
||||||
),
|
),
|
||||||
@@ -395,7 +406,7 @@ class _SHomeReturningView extends ConsumerWidget {
|
|||||||
activeSessionAsync.when(
|
activeSessionAsync.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (_, __) => _PrimaryCTA(
|
error: (_, __) => _PrimaryCTA(
|
||||||
label: 'curhat sama bestie baru',
|
label: 'Aku Mau Curhat',
|
||||||
enabled: mitraAvailable,
|
enabled: mitraAvailable,
|
||||||
onPressed: onCTA,
|
onPressed: onCTA,
|
||||||
),
|
),
|
||||||
@@ -415,7 +426,7 @@ class _SHomeReturningView extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return _PrimaryCTA(
|
return _PrimaryCTA(
|
||||||
label: 'curhat sama bestie baru',
|
label: 'Aku Mau Curhat',
|
||||||
enabled: mitraAvailable,
|
enabled: mitraAvailable,
|
||||||
onPressed: onCTA,
|
onPressed: onCTA,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/analytics/analytics_service.dart';
|
||||||
import '../../../core/constants.dart';
|
import '../../../core/constants.dart';
|
||||||
import '../../../core/theme/halo_tokens.dart';
|
import '../../../core/theme/halo_tokens.dart';
|
||||||
import '../../../core/theme/widgets/widgets.dart';
|
import '../../../core/theme/widgets/widgets.dart';
|
||||||
@@ -132,6 +133,13 @@ class BestieHistoryListScreen extends ConsumerWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (item.mitraId == null) 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
|
// Stamp the targeted mitra onto the payment draft; the
|
||||||
// multi-screen payment flow (entry → method → waiting →
|
// multi-screen payment flow (entry → method → waiting →
|
||||||
// notif-gate → searching) reads it back to fire the
|
// notif-gate → searching) reads it back to fire the
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/analytics/analytics_service.dart';
|
||||||
import '../../../core/theme/halo_tokens.dart';
|
import '../../../core/theme/halo_tokens.dart';
|
||||||
import '../../../core/theme/widgets/widgets.dart';
|
import '../../../core/theme/widgets/widgets.dart';
|
||||||
import '../../payment/state/payment_draft_provider.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.',
|
subtitle: 'lanjut cerita ke bestie yang pernah dengerin kamu.',
|
||||||
icon: Icons.favorite_outline,
|
icon: Icons.favorite_outline,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
// ignore: discarded_futures
|
||||||
|
ref.read(analyticsProvider).logBestieChoiceSelect(knownBestie: true);
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
context.push('/bestie/history');
|
context.push('/bestie/history');
|
||||||
},
|
},
|
||||||
@@ -62,6 +65,8 @@ class BestieChoiceSheet extends ConsumerWidget {
|
|||||||
subtitle: 'cari bestie baru yang siap dengerin sekarang.',
|
subtitle: 'cari bestie baru yang siap dengerin sekarang.',
|
||||||
icon: Icons.auto_awesome_outlined,
|
icon: Icons.auto_awesome_outlined,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
// ignore: discarded_futures
|
||||||
|
ref.read(analyticsProvider).logBestieChoiceSelect(knownBestie: false);
|
||||||
// explicit reset — this branch is blast-only, clear any stale targeted mitra
|
// explicit reset — this branch is blast-only, clear any stale targeted mitra
|
||||||
ref.read(paymentDraftNotifierProvider.notifier).reset();
|
ref.read(paymentDraftNotifierProvider.notifier).reset();
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/analytics/analytics_service.dart';
|
||||||
import '../../../core/theme/halo_tokens.dart';
|
import '../../../core/theme/halo_tokens.dart';
|
||||||
import '../../../core/theme/widgets/widgets.dart';
|
import '../../../core/theme/widgets/widgets.dart';
|
||||||
import '../usp_seen_provider.dart';
|
import '../usp_seen_provider.dart';
|
||||||
@@ -11,11 +12,30 @@ import '../usp_seen_provider.dart';
|
|||||||
///
|
///
|
||||||
/// `verified` ➞ USP → OTP (`/auth/register`).
|
/// `verified` ➞ USP → OTP (`/auth/register`).
|
||||||
/// `anonymous` ➞ USP → `/payment/method-pick`.
|
/// `anonymous` ➞ USP → `/payment/method-pick`.
|
||||||
class UspScreen extends ConsumerWidget {
|
class UspScreen extends ConsumerStatefulWidget {
|
||||||
final bool verified;
|
final bool verified;
|
||||||
|
|
||||||
const UspScreen({super.key, required this.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 = [
|
static const _cards = [
|
||||||
_UspCard(
|
_UspCard(
|
||||||
icon: Icons.bolt_outlined,
|
icon: Icons.bolt_outlined,
|
||||||
@@ -40,7 +60,7 @@ class UspScreen extends ConsumerWidget {
|
|||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Padding(
|
title: const Padding(
|
||||||
@@ -94,7 +114,7 @@ class UspScreen extends ConsumerWidget {
|
|||||||
HaloButton(
|
HaloButton(
|
||||||
label: 'aku ngerti, lanjut',
|
label: 'aku ngerti, lanjut',
|
||||||
fullWidth: true,
|
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
|
// Persist the local + server flag before leaving — next time the user
|
||||||
// hits VerifChoice, this screen is skipped.
|
// hits VerifChoice, this screen is skipped.
|
||||||
await ref.read(uspSeenProvider.notifier).markSeen();
|
await ref.read(uspSeenProvider.notifier).markSeen();
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
if (verified) {
|
if (widget.verified) {
|
||||||
context.push('/auth/register');
|
context.push('/auth/register');
|
||||||
} else {
|
} else {
|
||||||
context.push('/payment/method-pick');
|
context.push('/payment/method-pick');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/analytics/analytics_service.dart';
|
||||||
import '../../../core/auth/onboarding_intent_provider.dart';
|
import '../../../core/auth/onboarding_intent_provider.dart';
|
||||||
import '../../../core/chat/chat_opening_provider.dart';
|
import '../../../core/chat/chat_opening_provider.dart';
|
||||||
import '../../../core/theme/halo_tokens.dart';
|
import '../../../core/theme/halo_tokens.dart';
|
||||||
@@ -38,6 +39,18 @@ class _PaymentEntryScreenState extends ConsumerState<PaymentEntryScreen> {
|
|||||||
// inherit a stale onboarding intent.
|
// inherit a stale onboarding intent.
|
||||||
ref.read(onboardingIntentProvider.notifier).state =
|
ref.read(onboardingIntentProvider.notifier).state =
|
||||||
OnboardingIntent.recover;
|
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/analytics/analytics_service.dart';
|
||||||
import '../../../core/api/api_client_provider.dart';
|
import '../../../core/api/api_client_provider.dart';
|
||||||
import '../../../core/constants.dart';
|
import '../../../core/constants.dart';
|
||||||
import '../../../core/theme/halo_tokens.dart';
|
import '../../../core/theme/halo_tokens.dart';
|
||||||
@@ -53,7 +54,21 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
|
|||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
|
final analytics = ref.read(analyticsProvider);
|
||||||
try {
|
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>{
|
final body = <String, dynamic>{
|
||||||
'mode': draft.mode.value,
|
'mode': draft.mode.value,
|
||||||
'duration_minutes': draft.durationMinutes,
|
'duration_minutes': draft.durationMinutes,
|
||||||
@@ -61,11 +76,27 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
|
|||||||
'is_first_session_discount': draft.isFirstSessionDiscount,
|
'is_first_session_discount': draft.isFirstSessionDiscount,
|
||||||
'method': _selectedCode,
|
'method': _selectedCode,
|
||||||
if (draft.targetedMitraId != null) 'targeted_mitra_id': draft.targetedMitraId,
|
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 response = await api.post('/api/client/payment-requests/', data: body);
|
||||||
final data = response['data'] as Map<String, dynamic>;
|
final data = response['data'] as Map<String, dynamic>;
|
||||||
final paymentId = data['id'] as String;
|
final paymentId = data['id'] as String;
|
||||||
ref.read(paymentDraftNotifierProvider.notifier).setPaymentId(paymentId);
|
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;
|
if (!mounted) return;
|
||||||
context.push('/payment/waiting/$paymentId');
|
context.push('/payment/waiting/$paymentId');
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
@@ -216,10 +247,20 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
|
|||||||
_expandedGroupIds.remove(g.id);
|
_expandedGroupIds.remove(g.id);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
onSelect: (code) => setState(() {
|
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;
|
_selectedCode = code;
|
||||||
_error = null;
|
_error = null;
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ part of 'payment_draft_provider.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$paymentDraftNotifierHash() =>
|
String _$paymentDraftNotifierHash() =>
|
||||||
r'1c81b22f25f525cd290f54618bee0b69de792998';
|
r'e489a593f5e1cc2794d13566a9cf960bb89e45c6';
|
||||||
|
|
||||||
/// See also [PaymentDraftNotifier].
|
/// See also [PaymentDraftNotifier].
|
||||||
@ProviderFor(PaymentDraftNotifier)
|
@ProviderFor(PaymentDraftNotifier)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
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 'core/analytics/analytics_service.dart';
|
||||||
import 'core/api/api_client_provider.dart';
|
import 'core/api/api_client_provider.dart';
|
||||||
import 'core/auth/auth_notifier.dart';
|
import 'core/auth/auth_notifier.dart';
|
||||||
import 'core/auth/auth_providers_provider.dart';
|
import 'core/auth/auth_providers_provider.dart';
|
||||||
@@ -28,6 +30,12 @@ void main() async {
|
|||||||
|
|
||||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||||
|
|
||||||
|
// Enable GA4 collection. Fire-and-forget so it never adds to cold-start
|
||||||
|
// latency; the SDK queues events until collection is on.
|
||||||
|
unawaited(
|
||||||
|
FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(true),
|
||||||
|
);
|
||||||
|
|
||||||
final messaging = FirebaseMessaging.instance;
|
final messaging = FirebaseMessaging.instance;
|
||||||
await messaging.requestPermission();
|
await messaging.requestPermission();
|
||||||
|
|
||||||
@@ -131,9 +139,41 @@ class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tracks the last user_id pushed to GA4 so we don't re-issue identical
|
||||||
|
// setUserId/user-property calls on every transient auth emission.
|
||||||
|
String? _analyticsUserId;
|
||||||
|
|
||||||
|
/// Mirror auth state into GA4 identity (§4): opaque customer UUID as
|
||||||
|
/// `user_id` + `user_type` property. Re-set on identity upgrade
|
||||||
|
/// (anon→verified) so the same user continues. Never sets phone/name.
|
||||||
|
void _syncAnalyticsIdentity(AuthData? data) {
|
||||||
|
final analytics = ref.read(analyticsProvider);
|
||||||
|
final (String? customerId, AnalyticsUserType? userType) = switch (data) {
|
||||||
|
AuthAnonymousData d => (d.customerId, AnalyticsUserType.anonymous),
|
||||||
|
AuthForceRegisterData d => (d.customerId, AnalyticsUserType.anonymous),
|
||||||
|
AuthAuthenticatedData d => (
|
||||||
|
d.profile['id'] as String?,
|
||||||
|
AnalyticsUserType.verified,
|
||||||
|
),
|
||||||
|
AuthNeedsDisplayNameData d => (
|
||||||
|
d.profile['id'] as String?,
|
||||||
|
AnalyticsUserType.verified,
|
||||||
|
),
|
||||||
|
_ => (null, null),
|
||||||
|
};
|
||||||
|
if (customerId == _analyticsUserId) return;
|
||||||
|
_analyticsUserId = customerId;
|
||||||
|
// ignore: discarded_futures
|
||||||
|
analytics.setUserId(customerId);
|
||||||
|
if (userType != null) {
|
||||||
|
// ignore: discarded_futures
|
||||||
|
analytics.setUserType(userType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// FCM registration on auth.
|
// FCM registration + analytics identity on auth.
|
||||||
ref.listen(authProvider, (prev, next) {
|
ref.listen(authProvider, (prev, next) {
|
||||||
final data = next.valueOrNull;
|
final data = next.valueOrNull;
|
||||||
if (data is AuthAuthenticatedData || data is AuthAnonymousData) {
|
if (data is AuthAuthenticatedData || data is AuthAnonymousData) {
|
||||||
@@ -142,6 +182,7 @@ class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
|
|||||||
// Logged out (or initial) — ensure the chat WS is closed.
|
// Logged out (or initial) — ensure the chat WS is closed.
|
||||||
ref.read(chatProvider.notifier).disconnect();
|
ref.read(chatProvider.notifier).disconnect();
|
||||||
}
|
}
|
||||||
|
_syncAnalyticsIdentity(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Global chat WebSocket lifecycle: connect whenever the user has an
|
// Global chat WebSocket lifecycle: connect whenever the user has an
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||||
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 'package:go_router/go_router.dart';
|
||||||
|
import 'core/analytics/analytics_service.dart';
|
||||||
import 'core/auth/auth_notifier.dart';
|
import 'core/auth/auth_notifier.dart';
|
||||||
import 'core/auth/onboarding_intent_provider.dart';
|
import 'core/auth/onboarding_intent_provider.dart';
|
||||||
import 'features/auth/screens/display_name_screen.dart';
|
import 'features/auth/screens/display_name_screen.dart';
|
||||||
@@ -54,12 +56,62 @@ class RouterNotifier extends ChangeNotifier {
|
|||||||
|
|
||||||
final routerProvider = Provider<GoRouter>((ref) => buildRouter(ref));
|
final routerProvider = Provider<GoRouter>((ref) => buildRouter(ref));
|
||||||
|
|
||||||
|
/// Maps a GoRoute path template (`Route.settings.name`) to a stable
|
||||||
|
/// `screen_name`. Keyed on the *template* (e.g. `/chat/session/:sessionId`)
|
||||||
|
/// so path params are never part of the logged name. Routes absent here are
|
||||||
|
/// dropped (return null → observer skips the screen_view).
|
||||||
|
const _screenNameByRoute = <String, AnalyticsScreen>{
|
||||||
|
'/splash': AnalyticsScreen.splash,
|
||||||
|
'/auth/display-name': AnalyticsScreen.authDisplayName,
|
||||||
|
'/auth/register': AnalyticsScreen.authRegister,
|
||||||
|
'/auth/otp': AnalyticsScreen.authOtp,
|
||||||
|
'/auth/set-name': AnalyticsScreen.authSetName,
|
||||||
|
'/auth/force-register': AnalyticsScreen.authForceRegister,
|
||||||
|
'/onboarding/verif/usp': AnalyticsScreen.onboardingUspVerified,
|
||||||
|
'/onboarding/anon/usp': AnalyticsScreen.onboardingUspAnon,
|
||||||
|
'/onboarding/notif-gate': AnalyticsScreen.onboardingNotifGate,
|
||||||
|
'/home': AnalyticsScreen.home,
|
||||||
|
'/profile': AnalyticsScreen.profile,
|
||||||
|
'/payment/entry': AnalyticsScreen.paymentEntry,
|
||||||
|
'/payment/discount-paywall': AnalyticsScreen.paymentDiscountPaywall,
|
||||||
|
'/payment/method-pick': AnalyticsScreen.curhatModePick,
|
||||||
|
'/payment/duration-pick': AnalyticsScreen.paymentDurationPick,
|
||||||
|
'/payment/method': AnalyticsScreen.paymentMethod,
|
||||||
|
'/payment/waiting/:paymentId': AnalyticsScreen.paymentWaiting,
|
||||||
|
'/payment/expired/:paymentId': AnalyticsScreen.paymentExpired,
|
||||||
|
'/chat/searching': AnalyticsScreen.chatSearching,
|
||||||
|
'/chat/found': AnalyticsScreen.chatFound,
|
||||||
|
'/chat/no-bestie': AnalyticsScreen.chatNoBestie,
|
||||||
|
'/chat/waiting-targeted/:mitraId': AnalyticsScreen.chatWaitingTargeted,
|
||||||
|
'/chat/session/:sessionId': AnalyticsScreen.chatSession,
|
||||||
|
'/chat/thank-you': AnalyticsScreen.chatThankYou,
|
||||||
|
'/chat/aktif': AnalyticsScreen.chatTabAktif,
|
||||||
|
'/chat/pembayaran': AnalyticsScreen.chatTabPembayaran,
|
||||||
|
'/chat/selesai': AnalyticsScreen.chatTabSelesai,
|
||||||
|
'/chat/transcript/:sessionId': AnalyticsScreen.chatTranscript,
|
||||||
|
'/bestie/history': AnalyticsScreen.bestieHistory,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `nameExtractor` for [FirebaseAnalyticsObserver]. GoRouter sets
|
||||||
|
/// `Route.settings.name` to the route's path template, so this strips path
|
||||||
|
/// params (`:sessionId` etc.) by construction.
|
||||||
|
String? _screenNameFor(RouteSettings settings) {
|
||||||
|
final name = settings.name;
|
||||||
|
if (name == null) return null;
|
||||||
|
return _screenNameByRoute[name]?.value;
|
||||||
|
}
|
||||||
|
|
||||||
GoRouter buildRouter(Ref ref) {
|
GoRouter buildRouter(Ref ref) {
|
||||||
final notifier = RouterNotifier(ref);
|
final notifier = RouterNotifier(ref);
|
||||||
|
final analyticsObserver = FirebaseAnalyticsObserver(
|
||||||
|
analytics: FirebaseAnalytics.instance,
|
||||||
|
nameExtractor: _screenNameFor,
|
||||||
|
);
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: kThemePreviewEnabled ? '/_theme_preview' : '/splash',
|
initialLocation: kThemePreviewEnabled ? '/_theme_preview' : '/splash',
|
||||||
refreshListenable: notifier,
|
refreshListenable: notifier,
|
||||||
|
observers: [analyticsObserver],
|
||||||
redirect: (context, state) {
|
redirect: (context, state) {
|
||||||
// Theme preview is dev-only and intentionally bypasses auth + onboarding
|
// Theme preview is dev-only and intentionally bypasses auth + onboarding
|
||||||
// gates so it can be opened on any device build.
|
// gates so it can be opened on any device build.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import firebase_analytics
|
||||||
import firebase_core
|
import firebase_core
|
||||||
import firebase_messaging
|
import firebase_messaging
|
||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
@@ -17,6 +18,7 @@ import url_launcher_macos
|
|||||||
import webview_flutter_wkwebview
|
import webview_flutter_wkwebview
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
FirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FirebaseAnalyticsPlugin"))
|
||||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||||
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
|
|||||||
@@ -297,6 +297,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.1"
|
||||||
|
firebase_analytics:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: firebase_analytics
|
||||||
|
sha256: "4f85b161772e1d54a66893ef131c0a44bd9e552efa78b33d5f4f60d2caa5c8a3"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "11.6.0"
|
||||||
|
firebase_analytics_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: firebase_analytics_platform_interface
|
||||||
|
sha256: a44b6d1155ed5cae7641e3de7163111cfd9f6f6c954ca916dc6a3bdfa86bf845
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.4.3"
|
||||||
|
firebase_analytics_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: firebase_analytics_web
|
||||||
|
sha256: c7d1ed1f86ae64215757518af5576ff88341c8ce5741988c05cc3b2e07b0b273
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.5.10+16"
|
||||||
firebase_core:
|
firebase_core:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ dependencies:
|
|||||||
# Firebase (Messaging only — Auth dropped in Phase 3.4, self-managed JWT now)
|
# Firebase (Messaging only — Auth dropped in Phase 3.4, self-managed JWT now)
|
||||||
firebase_core: ^3.12.1
|
firebase_core: ^3.12.1
|
||||||
firebase_messaging: ^15.2.5
|
firebase_messaging: ^15.2.5
|
||||||
|
# GA4 funnel analytics (no PII — see requirement/analytics-funnel-plan.md).
|
||||||
|
# ^11.x is the firebase_core ^3.x-compatible major.
|
||||||
|
firebase_analytics: ^11.4.4
|
||||||
|
|
||||||
# Social login (kept — buttons gated server-side via /api/shared/auth-providers
|
# Social login (kept — buttons gated server-side via /api/shared/auth-providers
|
||||||
# until the corresponding OAuth env vars are set on the backend)
|
# until the corresponding OAuth env vars are set on the backend)
|
||||||
|
|||||||
Reference in New Issue
Block a user