Mitra Profil: WA/TG contacts + Keluar-only (no Hapus Akun)
Replaces the placeholder "Hubungi Koordinator" row with two real
contacts pulled from backend config (support_handles_json), and drops
the "Hapus Akun" CTA. Mirrors the figma BestieProfile design but uses
the same WA/TG channel as the customer Tanya Admin sheet — business
decided the same ops team triages both audiences.
Backend:
- Promote support-handles route from /api/client to /api/shared
(renamed file + export). Both apps now consume the same endpoint;
hitting /api/client/* from mitra would violate the per-app
convention in mitra_app/CLAUDE.md.
- client_app provider updated to /api/shared/support-handles.
Mitra app:
- New support_handles_provider mirroring the client_app one. Adds a
`displayHandle` getter that strips the URL scheme for the subtitle
("https://wa.me/X" → "wa.me/X", "https://t.me/Y" → "t.me/Y") so the
row looks like the figma without exposing raw URLs.
- Profil screen now lists: Chat WhatsApp Kami, Chat Telegram Kami,
Syarat & Ketentuan, Kebijakan Privasi. Danger zone simplified to
Keluar only — mitras request account deletion through the same
WA/TG channels (no separate self-service path).
- url_launcher added as a runtime dep, launches deeplinks in
externalApplication mode with graceful snackbar fallback when
parsing or launching fails.
Updates [[feedback-mitra-internal-audience]] — pre-login rule still
holds (no admin CTAs on S3a/S3b/AccountInactive), but the post-login
Profil tab now does surface WA/TG. Overrides decided 2026-05-21.
Verified on emulator-5556: Profil tab renders both rows with handles
from `wa.me/6285173310010` + `t.me/halobestie`, Keluar present, no
Hapus Akun button.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,7 @@ import { clientPaymentRoutes } from './routes/public/client.payment.routes.js'
|
||||
import { clientMitraAvailabilityRoutes } from './routes/public/client.mitra-availability.routes.js'
|
||||
import { publicBestieAvailabilityRoutes } from './routes/public/public.bestie-availability.routes.js'
|
||||
import { clientOnboardingRoutes } from './routes/public/client.onboarding.routes.js'
|
||||
import { clientSupportRoutes } from './routes/public/client.support.routes.js'
|
||||
import { sharedSupportRoutes } from './routes/public/shared.support.routes.js'
|
||||
import { sharedChatRoutes } from './routes/public/shared.chat.routes.js'
|
||||
import { errorHandler } from './plugins/error-handler.js'
|
||||
import { registerWebSocketPlugin, registerWebSocketRoute } from './plugins/websocket.js'
|
||||
@@ -38,10 +38,10 @@ export const buildPublicApp = async () => {
|
||||
app.register(clientPaymentRoutes, { prefix: '/api/client/payment-sessions' })
|
||||
app.register(clientMitraAvailabilityRoutes, { prefix: '/api/client/mitra-availability' })
|
||||
app.register(publicBestieAvailabilityRoutes, { prefix: '/api/public/bestie' })
|
||||
// Phase 4: onboarding-state + support handles. Both are tiny so they live in their
|
||||
// own files rather than bloating client.auth.routes / shared.config.routes.
|
||||
// Onboarding-state stays client-only (anonymous customer flow). Support
|
||||
// handles are shared — both client and mitra apps link the same WA/TG.
|
||||
app.register(clientOnboardingRoutes, { prefix: '/api/client' })
|
||||
app.register(clientSupportRoutes, { prefix: '/api/client' })
|
||||
app.register(sharedSupportRoutes, { prefix: '/api/shared' })
|
||||
|
||||
// WebSocket route (registered at app level, not prefixed)
|
||||
registerWebSocketRoute(app)
|
||||
|
||||
@@ -2,11 +2,15 @@ import { authenticate } from '../../plugins/auth.js'
|
||||
import { getSupportHandles } from '../../services/config.service.js'
|
||||
|
||||
/**
|
||||
* Phase 4 — Tanya Admin sheet handles. Sourced from `app_config.support_handles_json`,
|
||||
* Support channels (WA + Telegram). Sourced from `app_config.support_handles_json`,
|
||||
* editable by CC. Authenticated so unauthenticated callers can't enumerate the
|
||||
* support channels (rate-limit hardening, not a secret).
|
||||
* channels (rate-limit hardening, not a secret).
|
||||
*
|
||||
* Originally registered under /api/client (Phase 4 Tanya Admin sheet). Promoted
|
||||
* to /api/shared when the mitra Profil screen started linking the same WA/TG
|
||||
* contacts — same data, both audiences.
|
||||
*/
|
||||
export const clientSupportRoutes = async (app) => {
|
||||
export const sharedSupportRoutes = async (app) => {
|
||||
app.get('/support-handles', { preHandler: authenticate }, async (_request, reply) => {
|
||||
const handles = await getSupportHandles()
|
||||
return reply.send({ success: true, data: handles })
|
||||
@@ -27,7 +27,7 @@ class SupportHandles {
|
||||
|
||||
final supportHandlesProvider = FutureProvider<SupportHandles>((ref) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final response = await api.get('/api/client/support-handles');
|
||||
final response = await api.get('/api/shared/support-handles');
|
||||
final data = response['data'] as Map<String, dynamic>? ?? const {};
|
||||
return SupportHandles.fromJson(data);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../core/auth/auth_notifier.dart';
|
||||
import '../../core/theme/halo_tokens.dart';
|
||||
import '../../core/theme/widgets/widgets.dart';
|
||||
import 'support_handles_provider.dart';
|
||||
|
||||
/// Bestie Profil tab — mirrors
|
||||
/// `figma-bestie/project/screens/v5.jsx::BestieProfile`.
|
||||
@@ -10,23 +12,23 @@ import '../../core/theme/widgets/widgets.dart';
|
||||
/// Lives inside branch 2 of the shell (`router.dart`), so `BestieTabBar` is
|
||||
/// rendered by `ShellScreen` — this screen owns body content only.
|
||||
///
|
||||
/// Stage 4 deviation from the JSX: the Figma "Chat WhatsApp Kami / Chat
|
||||
/// Telegram Kami" rows surface customer-facing admin handles. Mitras are
|
||||
/// internal-only audience (see project memory `feedback_mitra_internal_audience`),
|
||||
/// so those two rows are replaced with a single "Hubungi Koordinator" entry
|
||||
/// pointing at the internal coordinator channel.
|
||||
/// Menu items mirror the figma: Chat WhatsApp Kami + Chat Telegram Kami
|
||||
/// (both deep-link via `support_handles_json` from backend config), then
|
||||
/// Syarat & Ketentuan + Kebijakan Privasi (TODO content). Danger zone is
|
||||
/// Keluar (logout) only — account deletion is intentionally not exposed
|
||||
/// in-app; mitras request deletion via the same WA/TG channels.
|
||||
class ProfilScreen extends ConsumerWidget {
|
||||
const ProfilScreen({super.key});
|
||||
|
||||
// TODO(stage-4): replace with `PackageInfo.fromPlatform().version` when
|
||||
// the `package_info_plus` package is added in a future change. Hardcoded
|
||||
// for now to avoid pulling a new dependency.
|
||||
// the `package_info_plus` package is added in a future change.
|
||||
static const String _appVersion = '1.0.0';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final authState = ref.watch(mitraAuthProvider);
|
||||
final authData = authState.valueOrNull;
|
||||
final handlesAsync = ref.watch(supportHandlesProvider);
|
||||
|
||||
final profile = authData is MitraAuthAuthenticatedData ? authData.profile : null;
|
||||
final displayName = (profile?['display_name'] as String?) ?? 'Bestie';
|
||||
@@ -45,20 +47,18 @@ class ProfilScreen extends ConsumerWidget {
|
||||
_ProfileCard(displayName: displayName, phone: phone),
|
||||
const SizedBox(height: 28),
|
||||
_MenuList(
|
||||
onTapCoordinator: () => _snack(
|
||||
context,
|
||||
'Hubungi koordinator via grup internal — info lengkap segera tersedia',
|
||||
),
|
||||
handles: handlesAsync.valueOrNull,
|
||||
onTapWa: () => _launchHandle(context, handlesAsync.valueOrNull?.wa),
|
||||
onTapTelegram: () => _launchHandle(context, handlesAsync.valueOrNull?.telegram),
|
||||
onTapTerms: () => _snack(context, 'Segera tersedia'),
|
||||
onTapPrivacy: () => _snack(context, 'Segera tersedia'),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_DangerZone(
|
||||
onLogout: () => _confirmLogout(context, ref),
|
||||
onDelete: () => _snack(
|
||||
context,
|
||||
'Hubungi koordinator untuk penghapusan akun',
|
||||
),
|
||||
HaloButton(
|
||||
label: 'Keluar',
|
||||
fullWidth: true,
|
||||
variant: HaloButtonVariant.secondary,
|
||||
onPressed: () => _confirmLogout(context, ref),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const _VersionFooter(version: _appVersion),
|
||||
@@ -69,6 +69,22 @@ class ProfilScreen extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _launchHandle(BuildContext context, SupportHandle? handle) async {
|
||||
if (handle == null || handle.deeplink.isEmpty) {
|
||||
_snack(context, 'Kontak belum tersedia');
|
||||
return;
|
||||
}
|
||||
final uri = Uri.tryParse(handle.deeplink);
|
||||
if (uri == null) {
|
||||
_snack(context, 'Tautan tidak valid');
|
||||
return;
|
||||
}
|
||||
final ok = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
if (!ok && context.mounted) {
|
||||
_snack(context, 'Gagal membuka ${handle.label}');
|
||||
}
|
||||
}
|
||||
|
||||
void _snack(BuildContext context, String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
@@ -221,27 +237,41 @@ class _ProfileCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Menu list — 3 stacked _MenuTile items ───────────────────────────────
|
||||
// ─── Menu list — WA + Telegram + Terms + Privacy ─────────────────────────
|
||||
class _MenuList extends StatelessWidget {
|
||||
final VoidCallback onTapCoordinator;
|
||||
final SupportHandles? handles;
|
||||
final VoidCallback onTapWa;
|
||||
final VoidCallback onTapTelegram;
|
||||
final VoidCallback onTapTerms;
|
||||
final VoidCallback onTapPrivacy;
|
||||
|
||||
const _MenuList({
|
||||
required this.onTapCoordinator,
|
||||
required this.handles,
|
||||
required this.onTapWa,
|
||||
required this.onTapTelegram,
|
||||
required this.onTapTerms,
|
||||
required this.onTapPrivacy,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final waSub = handles?.wa?.displayHandle ?? '';
|
||||
final tgSub = handles?.telegram?.displayHandle ?? '';
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_MenuTile(
|
||||
icon: Icons.support_agent_outlined,
|
||||
label: 'Hubungi Koordinator',
|
||||
subtitle: 'via grup koordinator internal',
|
||||
onTap: onTapCoordinator,
|
||||
icon: Icons.chat_bubble_outline,
|
||||
label: 'Chat WhatsApp Kami',
|
||||
subtitle: waSub.isEmpty ? null : waSub,
|
||||
onTap: onTapWa,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_MenuTile(
|
||||
icon: Icons.send_outlined,
|
||||
label: 'Chat Telegram Kami',
|
||||
subtitle: tgSub.isEmpty ? null : tgSub,
|
||||
onTap: onTapTelegram,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_MenuTile(
|
||||
@@ -342,73 +372,6 @@ class _MenuTile extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Danger zone — Keluar (secondary) + Hapus Akun (danger text) ─────────
|
||||
class _DangerZone extends StatelessWidget {
|
||||
final VoidCallback onLogout;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
const _DangerZone({required this.onLogout, required this.onDelete});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
HaloButton(
|
||||
label: 'Keluar',
|
||||
fullWidth: true,
|
||||
variant: HaloButtonVariant.secondary,
|
||||
onPressed: onLogout,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// Hapus Akun — destructive ghost-style with danger border + text.
|
||||
Material(
|
||||
color: HaloTokens.surface,
|
||||
borderRadius: HaloRadius.pill,
|
||||
child: InkWell(
|
||||
borderRadius: HaloRadius.pill,
|
||||
onTap: onDelete,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: HaloRadius.pill,
|
||||
border: Border.all(
|
||||
color: HaloTokens.danger.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.delete_outline,
|
||||
size: 17,
|
||||
color: HaloTokens.danger,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Hapus Akun',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: HaloTokens.danger,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Version footer ──────────────────────────────────────────────────────
|
||||
class _VersionFooter extends StatelessWidget {
|
||||
final String version;
|
||||
|
||||
43
mitra_app/lib/features/profile/support_handles_provider.dart
Normal file
43
mitra_app/lib/features/profile/support_handles_provider.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/api/api_client_provider.dart';
|
||||
|
||||
class SupportHandle {
|
||||
final String label;
|
||||
final String deeplink;
|
||||
const SupportHandle({required this.label, required this.deeplink});
|
||||
|
||||
factory SupportHandle.fromJson(Map<String, dynamic> json) =>
|
||||
SupportHandle(
|
||||
label: json['label'] as String? ?? '',
|
||||
deeplink: json['deeplink'] as String? ?? '',
|
||||
);
|
||||
|
||||
/// Display form for the menu subtitle: strip the URL scheme so
|
||||
/// "https://wa.me/6285173310010" → "wa.me/6285173310010" and
|
||||
/// "https://t.me/halobestie" → "t.me/halobestie". Falls back to the
|
||||
/// raw deeplink when there's no scheme to strip.
|
||||
String get displayHandle {
|
||||
if (deeplink.isEmpty) return '';
|
||||
final stripped = deeplink.replaceFirst(RegExp(r'^https?://'), '');
|
||||
return stripped;
|
||||
}
|
||||
}
|
||||
|
||||
class SupportHandles {
|
||||
final SupportHandle? wa;
|
||||
final SupportHandle? telegram;
|
||||
const SupportHandles({this.wa, this.telegram});
|
||||
|
||||
factory SupportHandles.fromJson(Map<String, dynamic> json) {
|
||||
SupportHandle? parse(dynamic v) =>
|
||||
v is Map<String, dynamic> ? SupportHandle.fromJson(v) : null;
|
||||
return SupportHandles(wa: parse(json['wa']), telegram: parse(json['telegram']));
|
||||
}
|
||||
}
|
||||
|
||||
final supportHandlesProvider = FutureProvider<SupportHandles>((ref) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final response = await api.get('/api/shared/support-handles');
|
||||
final data = response['data'] as Map<String, dynamic>? ?? const {};
|
||||
return SupportHandles.fromJson(data);
|
||||
});
|
||||
@@ -1004,6 +1004,70 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.2"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.30"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.4.1"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.5"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_platform_interface
|
||||
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
url_launcher_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.5"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -31,6 +31,7 @@ dependencies:
|
||||
# Navigation
|
||||
go_router: ^13.2.1
|
||||
flutter_local_notifications: ^21.0.0
|
||||
url_launcher: ^6.3.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user