diff --git a/backend/src/routes/public/shared.chat.routes.js b/backend/src/routes/public/shared.chat.routes.js index be8b372..c7786a9 100644 --- a/backend/src/routes/public/shared.chat.routes.js +++ b/backend/src/routes/public/shared.chat.routes.js @@ -1,6 +1,6 @@ import { authenticate } from '../../plugins/auth.js' import { getMessages } from '../../services/chat.service.js' -import { getSessionClosures } from '../../services/closure.service.js' +import { getSessionClosures, hasUserSubmittedClosure } from '../../services/closure.service.js' import { registerDeviceToken } from '../../services/notification.service.js' import { flipSessionSensitivity } from '../../services/sensitivity.service.js' import { getDb } from '../../db/client.js' @@ -55,7 +55,8 @@ export const sharedChatRoutes = async (app) => { if (!session) { return reply.code(404).send({ success: false, error: { code: 'NOT_FOUND', message: 'Session not found' } }) } - return reply.send({ success: true, data: session }) + const goodbyeSubmittedByMe = await hasUserSubmittedClosure(sessionId, request.userType) + return reply.send({ success: true, data: { ...session, goodbye_submitted_by_me: goodbyeSubmittedByMe } }) }) // Get full transcript (read-only, for history) diff --git a/backend/src/services/closure.service.js b/backend/src/services/closure.service.js index fba68a6..8ac574f 100644 --- a/backend/src/services/closure.service.js +++ b/backend/src/services/closure.service.js @@ -144,3 +144,12 @@ export const getSessionClosures = async (sessionId) => { ORDER BY created_at ASC ` } + +export const hasUserSubmittedClosure = async (sessionId, userType) => { + const [row] = await sql` + SELECT 1 FROM session_closures + WHERE session_id = ${sessionId} AND user_type = ${userType} + LIMIT 1 + ` + return !!row +} diff --git a/client_app/lib/core/chat/chat_notifier.dart b/client_app/lib/core/chat/chat_notifier.dart index 19f2e5e..4520d3c 100644 --- a/client_app/lib/core/chat/chat_notifier.dart +++ b/client_app/lib/core/chat/chat_notifier.dart @@ -30,6 +30,7 @@ class ChatConnectedData extends ChatData { final bool sessionExpired; final bool sessionPaused; final bool sessionClosing; + final bool goodbyeSubmitted; final Map? extensionResponse; const ChatConnectedData({ @@ -39,6 +40,7 @@ class ChatConnectedData extends ChatData { this.sessionExpired = false, this.sessionPaused = false, this.sessionClosing = false, + this.goodbyeSubmitted = false, this.extensionResponse, }); @@ -49,6 +51,7 @@ class ChatConnectedData extends ChatData { bool? sessionExpired, bool? sessionPaused, bool? sessionClosing, + bool? goodbyeSubmitted, Map? extensionResponse, }) { return ChatConnectedData( @@ -58,6 +61,7 @@ class ChatConnectedData extends ChatData { sessionExpired: sessionExpired ?? this.sessionExpired, sessionPaused: sessionPaused ?? this.sessionPaused, sessionClosing: sessionClosing ?? this.sessionClosing, + goodbyeSubmitted: goodbyeSubmitted ?? this.goodbyeSubmitted, extensionResponse: extensionResponse ?? this.extensionResponse, ); } @@ -150,8 +154,12 @@ class Chat extends _$Chat { state = current.copyWith(sessionExpired: true); return; } + final goodbyeSubmittedByMe = data?['goodbye_submitted_by_me'] as bool? ?? false; state = current.copyWith( sessionClosing: status == SessionStatus.closing, + sessionPaused: status == SessionStatus.extending, + sessionExpired: false, + goodbyeSubmitted: goodbyeSubmittedByMe, ); } catch (e) { // ignore: avoid_print @@ -175,6 +183,7 @@ class Chat extends _$Chat { } final isClosing = sessionStatus == SessionStatus.closing; + final goodbyeSubmittedByMe = sessionData?['goodbye_submitted_by_me'] as bool? ?? false; final response = await _apiClient.get('/api/shared/chat/$sessionId/messages'); final messagesData = response['data'] as List; @@ -215,6 +224,7 @@ class Chat extends _$Chat { state = ChatConnectedData( messages: messages, sessionClosing: isClosing, + goodbyeSubmitted: goodbyeSubmittedByMe, ); } catch (e) { state = const ChatErrorData('Gagal terhubung ke chat.'); diff --git a/client_app/lib/features/chat/screens/chat_screen.dart b/client_app/lib/features/chat/screens/chat_screen.dart index 7368928..b1d65b4 100644 --- a/client_app/lib/features/chat/screens/chat_screen.dart +++ b/client_app/lib/features/chat/screens/chat_screen.dart @@ -233,15 +233,23 @@ class _ChatScreenState extends ConsumerState { // we mounted directly into a `closing` session (e.g. opened from history). // The chatProvider listener can't catch this case because it only fires on // transitions, not the current state at mount time. - final shouldShowGoodbye = closureState is ClosureShowGoodbyeData || - closureState is ClosureSubmittingData || - (state.sessionClosing && - !state.sessionExpired && - closureState is! ClosureCompleteData); + // Suppress when the customer has already submitted their goodbye — the + // session can stay in `closing` while waiting for the mitra to submit + // their own message or for the 5-min grace timer to auto-complete. + final shouldShowGoodbye = !state.goodbyeSubmitted && + (closureState is ClosureShowGoodbyeData || + closureState is ClosureSubmittingData || + (state.sessionClosing && + !state.sessionExpired && + closureState is! ClosureCompleteData)); if (shouldShowGoodbye) { return _buildGoodbyeView(closureState); } + if (state.sessionClosing && state.goodbyeSubmitted) { + return _buildAwaitingMitraGoodbyeView(state); + } + if (state.sessionPaused) { return _buildPausedView(); } @@ -496,4 +504,54 @@ class _ChatScreenState extends ConsumerState { ), ); } + + Widget _buildAwaitingMitraGoodbyeView(ChatConnectedData state) { + return Stack( + children: [ + Positioned.fill( + child: Container( + color: _kBgTint, + child: Image.asset( + 'assets/images/chat_pattern.png', + repeat: ImageRepeat.repeat, + fit: BoxFit.none, + ), + ), + ), + Column( + children: [ + Container( + width: double.infinity, + color: _kEndedBannerColor, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: const Row( + children: [ + Icon(Icons.hourglass_top, color: _kEndedBannerText, size: 20), + SizedBox(width: 8), + Expanded( + child: Text( + 'Pesan penutupmu sudah terkirim. Menunggu Bestie...', + style: TextStyle(color: _kEndedBannerText, fontWeight: FontWeight.w600), + ), + ), + ], + ), + ), + Expanded( + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(16), + itemCount: state.messages.length, + itemBuilder: (context, index) { + final msg = state.messages[index]; + final isMe = msg.senderType == UserType.customer; + return _buildMessageBubble(msg, isMe); + }, + ), + ), + ], + ), + ], + ); + } } diff --git a/mitra_app/lib/core/chat/mitra_chat_notifier.dart b/mitra_app/lib/core/chat/mitra_chat_notifier.dart index b433629..c41603e 100644 --- a/mitra_app/lib/core/chat/mitra_chat_notifier.dart +++ b/mitra_app/lib/core/chat/mitra_chat_notifier.dart @@ -29,6 +29,7 @@ class MitraChatConnectedData extends MitraChatData { final int? remainingSeconds; final bool sessionExpired; final bool sessionClosing; + final bool goodbyeSubmitted; final Map? extensionRequest; final TopicSensitivity topicSensitivity; @@ -38,6 +39,7 @@ class MitraChatConnectedData extends MitraChatData { this.remainingSeconds, this.sessionExpired = false, this.sessionClosing = false, + this.goodbyeSubmitted = false, this.extensionRequest, this.topicSensitivity = TopicSensitivity.regular, }); @@ -48,6 +50,7 @@ class MitraChatConnectedData extends MitraChatData { int? remainingSeconds, bool? sessionExpired, bool? sessionClosing, + bool? goodbyeSubmitted, Map? extensionRequest, bool clearExtensionRequest = false, TopicSensitivity? topicSensitivity, @@ -58,6 +61,7 @@ class MitraChatConnectedData extends MitraChatData { remainingSeconds: remainingSeconds ?? this.remainingSeconds, sessionExpired: sessionExpired ?? this.sessionExpired, sessionClosing: sessionClosing ?? this.sessionClosing, + goodbyeSubmitted: goodbyeSubmitted ?? this.goodbyeSubmitted, extensionRequest: clearExtensionRequest ? null : (extensionRequest ?? this.extensionRequest), topicSensitivity: topicSensitivity ?? this.topicSensitivity, ); @@ -124,6 +128,7 @@ class MitraChat extends _$MitraChat { } final isClosing = sessionStatus == SessionStatus.closing; + final goodbyeSubmittedByMe = sessionData?['goodbye_submitted_by_me'] as bool? ?? false; final sessionTopic = TopicSensitivity.fromString(sessionData?['topic_sensitivity'] as String?); final response = await _apiClient.get('/api/shared/chat/$sessionId/messages'); @@ -162,7 +167,12 @@ class MitraChat extends _$MitraChat { 'session_id': sessionId, })); - state = MitraChatConnectedData(messages: messages, sessionClosing: isClosing, topicSensitivity: sessionTopic); + state = MitraChatConnectedData( + messages: messages, + sessionClosing: isClosing, + goodbyeSubmitted: goodbyeSubmittedByMe, + topicSensitivity: sessionTopic, + ); } catch (e) { state = const MitraChatErrorData('Gagal terhubung ke chat.'); } diff --git a/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart b/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart index 66bc9b8..fa46ee1 100644 --- a/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart +++ b/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart @@ -268,11 +268,19 @@ class _MitraChatScreenState extends ConsumerState { return _buildExtensionView(state.extensionRequest!, extState); } - // Goodbye view - if (state.sessionClosing || extState is ExtensionShowGoodbyeData || extState is ExtensionSubmittingData) { + // Goodbye view — suppress if mitra has already submitted their goodbye; + // session can stay in `closing` while waiting for the customer to submit + // theirs or for the 5-min grace timer to auto-complete. + final showGoodbye = !state.goodbyeSubmitted && + (state.sessionClosing || extState is ExtensionShowGoodbyeData || extState is ExtensionSubmittingData); + if (showGoodbye) { return _buildGoodbyeView(extState); } + if (state.sessionClosing && state.goodbyeSubmitted) { + return _buildAwaitingCustomerGoodbyeView(state); + } + final bgTint = SensitivityTheme.of(state.topicSensitivity).bgTint; return Stack( @@ -544,4 +552,55 @@ class _MitraChatScreenState extends ConsumerState { ), ); } + + Widget _buildAwaitingCustomerGoodbyeView(MitraChatConnectedData state) { + final bgTint = SensitivityTheme.of(state.topicSensitivity).bgTint; + return Stack( + children: [ + Positioned.fill( + child: Container( + color: bgTint, + child: Image.asset( + 'assets/images/chat_pattern.png', + repeat: ImageRepeat.repeat, + fit: BoxFit.none, + ), + ), + ), + Column( + children: [ + Container( + width: double.infinity, + color: Colors.amber.shade100, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon(Icons.hourglass_top, color: Colors.amber.shade900, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Pesan penutupmu sudah terkirim. Menunggu user...', + style: TextStyle(color: Colors.amber.shade900, fontWeight: FontWeight.w600), + ), + ), + ], + ), + ), + Expanded( + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(16), + itemCount: state.messages.length, + itemBuilder: (context, index) { + final msg = state.messages[index]; + final isMe = msg.senderType == UserType.mitra; + return _buildMessageBubble(msg, isMe); + }, + ), + ), + ], + ), + ], + ); + } }