Add pending chat requests CTA on mitra home screen
- Backend: new GET /api/mitra/chat-requests/pending endpoint - Backend: getPendingRequestsForMitra() queries unresponded notifications for sessions still in pending_acceptance status - Mitra app: loadPendingRequests() fetches on screen load + status toggle - Mitra app: activeRequestCount getter exposes queue size - Mitra app: _PendingRequestsBanner widget shows count with tap-to-view Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { authenticate } from '../../plugins/auth.js'
|
import { authenticate } from '../../plugins/auth.js'
|
||||||
import { getMitraByFirebaseUid } from '../../services/mitra.service.js'
|
import { getMitraByFirebaseUid } from '../../services/mitra.service.js'
|
||||||
import { acceptPairingRequest, declinePairingRequest, getSessionStatus } from '../../services/pairing.service.js'
|
import { acceptPairingRequest, declinePairingRequest, getSessionStatus, getPendingRequestsForMitra } from '../../services/pairing.service.js'
|
||||||
import { getActiveSessionsByMitra, getActiveSessionsByMitraWithUnread, endSession, getMitraHistory } from '../../services/session.service.js'
|
import { getActiveSessionsByMitra, getActiveSessionsByMitraWithUnread, endSession, getMitraHistory } from '../../services/session.service.js'
|
||||||
import { respondToExtension } from '../../services/extension.service.js'
|
import { respondToExtension } from '../../services/extension.service.js'
|
||||||
import { EndedBy } from '../../constants.js'
|
import { EndedBy } from '../../constants.js'
|
||||||
@@ -23,6 +23,12 @@ const resolveMitra = async (request, reply) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const mitraChatRoutes = async (app) => {
|
export const mitraChatRoutes = async (app) => {
|
||||||
|
// Get pending chat requests for this mitra
|
||||||
|
app.get('/pending', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
|
||||||
|
const requests = await getPendingRequestsForMitra(request.mitra.id)
|
||||||
|
return reply.send({ success: true, data: requests })
|
||||||
|
})
|
||||||
|
|
||||||
// Check if a session is still pending acceptance (for notification validation)
|
// Check if a session is still pending acceptance (for notification validation)
|
||||||
app.get('/:sessionId/status', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
|
app.get('/:sessionId/status', { preHandler: [authenticate, resolveMitra] }, async (request, reply) => {
|
||||||
const session = await getSessionStatus(request.params.sessionId)
|
const session = await getSessionStatus(request.params.sessionId)
|
||||||
|
|||||||
@@ -307,6 +307,19 @@ export const expirePairingRequest = async (sessionId) => {
|
|||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getPendingRequestsForMitra = async (mitraId) => {
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT cs.id AS session_id, cs.duration_minutes, cs.is_free_trial, cs.created_at
|
||||||
|
FROM chat_request_notifications crn
|
||||||
|
JOIN chat_sessions cs ON cs.id = crn.session_id
|
||||||
|
WHERE crn.mitra_id = ${mitraId}
|
||||||
|
AND crn.response IS NULL
|
||||||
|
AND cs.status = ${SessionStatus.PENDING_ACCEPTANCE}
|
||||||
|
ORDER BY cs.created_at ASC
|
||||||
|
`
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
export const getSessionStatus = async (sessionId) => {
|
export const getSessionStatus = async (sessionId) => {
|
||||||
const [session] = await sql`
|
const [session] = await sql`
|
||||||
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by,
|
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by,
|
||||||
|
|||||||
@@ -72,9 +72,54 @@ class ChatRequest extends _$ChatRequest {
|
|||||||
|
|
||||||
ApiClient get _apiClient => ref.read(apiClientProvider);
|
ApiClient get _apiClient => ref.read(apiClientProvider);
|
||||||
|
|
||||||
|
/// Number of active requests (currently displayed + queued).
|
||||||
|
int get activeRequestCount {
|
||||||
|
final current = state is ChatRequestIncomingData ? 1 : 0;
|
||||||
|
return current + _pendingQueue.length;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ChatRequestData build() => const ChatRequestIdleData();
|
ChatRequestData build() => const ChatRequestIdleData();
|
||||||
|
|
||||||
|
/// Fetch pending requests from backend and populate the queue.
|
||||||
|
Future<void> loadPendingRequests() async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.get('/api/mitra/chat-requests/pending');
|
||||||
|
final requests = response['data'] as List<dynamic>;
|
||||||
|
if (requests.isEmpty) return;
|
||||||
|
|
||||||
|
for (final r in requests) {
|
||||||
|
final sessionId = r['session_id'] as String;
|
||||||
|
// Skip if already showing or queued
|
||||||
|
if (state is ChatRequestIncomingData &&
|
||||||
|
(state as ChatRequestIncomingData).sessionId == sessionId) continue;
|
||||||
|
if (_pendingQueue.any((q) => q['session_id'] == sessionId)) continue;
|
||||||
|
|
||||||
|
final data = {
|
||||||
|
'session_id': sessionId,
|
||||||
|
'duration_minutes': r['duration_minutes'],
|
||||||
|
'is_free_trial': r['is_free_trial'],
|
||||||
|
'created_at': r['created_at'],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (state is ChatRequestIncomingData ||
|
||||||
|
state is ChatRequestStaleData ||
|
||||||
|
state is ChatRequestAcceptingData) {
|
||||||
|
_pendingQueue.add(data);
|
||||||
|
} else {
|
||||||
|
state = ChatRequestIncomingData(
|
||||||
|
sessionId,
|
||||||
|
durationMinutes: r['duration_minutes'] as int?,
|
||||||
|
isFreeTrial: r['is_free_trial'] as bool?,
|
||||||
|
createdAt: r['created_at'] != null
|
||||||
|
? DateTime.tryParse(r['created_at'] as String)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> startListening() async {
|
Future<void> startListening() async {
|
||||||
// Don't reset state if showing a request, stale message, or actively accepting
|
// Don't reset state if showing a request, stale message, or actively accepting
|
||||||
if (state is ChatRequestIncomingData ||
|
if (state is ChatRequestIncomingData ||
|
||||||
|
|||||||
@@ -17,10 +17,23 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
? authData.profile['display_name'] as String
|
? authData.profile['display_name'] as String
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
// Load pending requests if mitra is already online
|
||||||
|
final statusState = ref.watch(onlineStatusProvider);
|
||||||
|
if (statusState is StatusLoadedData && statusState.isOnline) {
|
||||||
|
final requestState = ref.watch(chatRequestProvider);
|
||||||
|
if (requestState is ChatRequestIdleData) {
|
||||||
|
Future.microtask(() {
|
||||||
|
ref.read(chatRequestProvider.notifier).startListening();
|
||||||
|
ref.read(chatRequestProvider.notifier).loadPendingRequests();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Listen for status changes to start/stop chat request listening
|
// Listen for status changes to start/stop chat request listening
|
||||||
ref.listen(onlineStatusProvider, (prev, next) {
|
ref.listen(onlineStatusProvider, (prev, next) {
|
||||||
if (next is StatusLoadedData && next.isOnline) {
|
if (next is StatusLoadedData && next.isOnline) {
|
||||||
ref.read(chatRequestProvider.notifier).startListening();
|
ref.read(chatRequestProvider.notifier).startListening();
|
||||||
|
ref.read(chatRequestProvider.notifier).loadPendingRequests();
|
||||||
} else if (next is StatusLoadedData && !next.isOnline) {
|
} else if (next is StatusLoadedData && !next.isOnline) {
|
||||||
ref.read(chatRequestProvider.notifier).stopListening();
|
ref.read(chatRequestProvider.notifier).stopListening();
|
||||||
}
|
}
|
||||||
@@ -44,6 +57,7 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
const _StatusToggle(),
|
const _StatusToggle(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
const _PendingRequestsBanner(),
|
||||||
const _ActiveSessionsButton(),
|
const _ActiveSessionsButton(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -111,6 +125,51 @@ class _StatusToggle extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _PendingRequestsBanner extends ConsumerWidget {
|
||||||
|
const _PendingRequestsBanner();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final requestState = ref.watch(chatRequestProvider);
|
||||||
|
final count = ref.read(chatRequestProvider.notifier).activeRequestCount;
|
||||||
|
|
||||||
|
if (count == 0) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final isShowingOverlay = requestState is ChatRequestIncomingData ||
|
||||||
|
requestState is ChatRequestStaleData;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: Card(
|
||||||
|
color: Colors.blue.shade50,
|
||||||
|
child: ListTile(
|
||||||
|
leading: Badge(
|
||||||
|
label: Text('$count'),
|
||||||
|
child: const Icon(Icons.notifications_active, color: Colors.blue),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
'$count permintaan chat menunggu',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
subtitle: isShowingOverlay
|
||||||
|
? null
|
||||||
|
: const Text('Ketuk untuk melihat'),
|
||||||
|
onTap: isShowingOverlay
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
// Re-advance queue to show the next request overlay
|
||||||
|
final notifier = ref.read(chatRequestProvider.notifier);
|
||||||
|
if (requestState is ChatRequestListeningData) {
|
||||||
|
// Requests are queued but none is displayed — trigger next
|
||||||
|
notifier.ignore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _ActiveSessionsButton extends ConsumerWidget {
|
class _ActiveSessionsButton extends ConsumerWidget {
|
||||||
const _ActiveSessionsButton();
|
const _ActiveSessionsButton();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user