diff --git a/backend/src/app.public.js b/backend/src/app.public.js index a4f01ea..a8dc52f 100644 --- a/backend/src/app.public.js +++ b/backend/src/app.public.js @@ -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) diff --git a/backend/src/routes/public/client.support.routes.js b/backend/src/routes/public/shared.support.routes.js similarity index 51% rename from backend/src/routes/public/client.support.routes.js rename to backend/src/routes/public/shared.support.routes.js index 487b199..29cf158 100644 --- a/backend/src/routes/public/client.support.routes.js +++ b/backend/src/routes/public/shared.support.routes.js @@ -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 }) diff --git a/client_app/lib/features/support/providers/support_handles_provider.dart b/client_app/lib/features/support/providers/support_handles_provider.dart index 5d27914..683b455 100644 --- a/client_app/lib/features/support/providers/support_handles_provider.dart +++ b/client_app/lib/features/support/providers/support_handles_provider.dart @@ -27,7 +27,7 @@ class SupportHandles { final supportHandlesProvider = FutureProvider((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? ?? const {}; return SupportHandles.fromJson(data); }); diff --git a/mitra_app/lib/features/profile/profil_screen.dart b/mitra_app/lib/features/profile/profil_screen.dart index 2376c43..b70fd7c 100644 --- a/mitra_app/lib/features/profile/profil_screen.dart +++ b/mitra_app/lib/features/profile/profil_screen.dart @@ -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 _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; diff --git a/mitra_app/lib/features/profile/support_handles_provider.dart b/mitra_app/lib/features/profile/support_handles_provider.dart new file mode 100644 index 0000000..3bd4122 --- /dev/null +++ b/mitra_app/lib/features/profile/support_handles_provider.dart @@ -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 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 json) { + SupportHandle? parse(dynamic v) => + v is Map ? SupportHandle.fromJson(v) : null; + return SupportHandles(wa: parse(json['wa']), telegram: parse(json['telegram'])); + } +} + +final supportHandlesProvider = FutureProvider((ref) async { + final api = ref.read(apiClientProvider); + final response = await api.get('/api/shared/support-handles'); + final data = response['data'] as Map? ?? const {}; + return SupportHandles.fromJson(data); +}); diff --git a/mitra_app/pubspec.lock b/mitra_app/pubspec.lock index a3f49ec..7f0e409 100644 --- a/mitra_app/pubspec.lock +++ b/mitra_app/pubspec.lock @@ -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: diff --git a/mitra_app/pubspec.yaml b/mitra_app/pubspec.yaml index 45145e0..7719477 100644 --- a/mitra_app/pubspec.yaml +++ b/mitra_app/pubspec.yaml @@ -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: