Add AI chat #168
@@ -28,6 +28,8 @@ services:
|
|||||||
JWT_SECRET: change_me_please_this_secret_key_is_long_enough_for_jwt_hmac_sha256
|
JWT_SECRET: change_me_please_this_secret_key_is_long_enough_for_jwt_hmac_sha256
|
||||||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
|
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
|
||||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY}
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY}
|
||||||
|
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
|
||||||
|
OPENROUTER_MODEL: ${OPENROUTER_MODEL:-openai/gpt-oss-120b:free}
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.petshop.backend.controller;
|
||||||
|
|
||||||
|
import com.petshop.backend.dto.ai.AiChatRequest;
|
||||||
|
import com.petshop.backend.dto.ai.AiChatResponse;
|
||||||
|
import com.petshop.backend.entity.Pet;
|
||||||
|
import com.petshop.backend.entity.User;
|
||||||
|
import com.petshop.backend.repository.PetRepository;
|
||||||
|
import com.petshop.backend.repository.UserRepository;
|
||||||
|
import com.petshop.backend.service.OpenRouterService;
|
||||||
|
import com.petshop.backend.util.AuthenticationHelper;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/ai-chat")
|
||||||
|
public class AiChatController {
|
||||||
|
|
||||||
|
private final OpenRouterService openRouterService;
|
||||||
|
private final PetRepository petRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public AiChatController(OpenRouterService openRouterService,
|
||||||
|
PetRepository petRepository,
|
||||||
|
UserRepository userRepository) {
|
||||||
|
this.openRouterService = openRouterService;
|
||||||
|
this.petRepository = petRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
private User getCurrentUser() {
|
||||||
|
try {
|
||||||
|
return AuthenticationHelper.getAuthenticatedUser(userRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
catch (RuntimeException ex) {
|
||||||
|
throw new UsernameNotFoundException(ex.getMessage(), ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/message")
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
public ResponseEntity<AiChatResponse> sendMessage(@Valid @RequestBody AiChatRequest request) {
|
||||||
|
if (request.getMessage() == null || request.getMessage().isBlank()) {
|
||||||
|
return ResponseEntity.badRequest().body(AiChatResponse.fail("Message cannot be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = getCurrentUser();
|
||||||
|
|
||||||
|
List<Pet> userPets;
|
||||||
|
try {
|
||||||
|
userPets = petRepository.findAllByOwner_IdOrderByPetNameAsc(user.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
catch (Exception e) {
|
||||||
|
userPets = Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String aiReply = openRouterService.chat(
|
||||||
|
request.getHistory(),
|
||||||
|
request.getMessage(),
|
||||||
|
userPets
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(AiChatResponse.ok(aiReply));
|
||||||
|
}
|
||||||
|
|
||||||
|
catch (IllegalStateException e) {
|
||||||
|
|
||||||
|
return ResponseEntity.status(503).body(AiChatResponse.fail("AI service is not configured. Please contact support."));
|
||||||
|
}
|
||||||
|
|
||||||
|
catch (Exception e) {
|
||||||
|
|
||||||
|
return ResponseEntity.status(502).body(AiChatResponse.fail("AI service is temporarily unavailable. Please try again later."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import com.petshop.backend.repository.UserRepository;
|
|||||||
import com.petshop.backend.service.ChatAttachmentStorageService;
|
import com.petshop.backend.service.ChatAttachmentStorageService;
|
||||||
import com.petshop.backend.service.ChatRealtimeService;
|
import com.petshop.backend.service.ChatRealtimeService;
|
||||||
import com.petshop.backend.service.ChatService;
|
import com.petshop.backend.service.ChatService;
|
||||||
|
import com.petshop.backend.service.OpenRouterAiService;
|
||||||
import com.petshop.backend.util.AuthenticationHelper;
|
import com.petshop.backend.util.AuthenticationHelper;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
@@ -32,15 +33,17 @@ public class ChatController {
|
|||||||
|
|
||||||
private final ChatService chatService;
|
private final ChatService chatService;
|
||||||
private final ChatRealtimeService chatRealtimeService;
|
private final ChatRealtimeService chatRealtimeService;
|
||||||
|
private final OpenRouterAiService openRouterAiService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final ChatAttachmentStorageService attachmentStorageService;
|
private final ChatAttachmentStorageService attachmentStorageService;
|
||||||
private final MessageRepository messageRepository;
|
private final MessageRepository messageRepository;
|
||||||
|
|
||||||
public ChatController(ChatService chatService, ChatRealtimeService chatRealtimeService,
|
public ChatController(ChatService chatService, ChatRealtimeService chatRealtimeService,
|
||||||
UserRepository userRepository, ChatAttachmentStorageService attachmentStorageService,
|
OpenRouterAiService openRouterAiService, UserRepository userRepository, ChatAttachmentStorageService attachmentStorageService,
|
||||||
MessageRepository messageRepository) {
|
MessageRepository messageRepository) {
|
||||||
this.chatService = chatService;
|
this.chatService = chatService;
|
||||||
this.chatRealtimeService = chatRealtimeService;
|
this.chatRealtimeService = chatRealtimeService;
|
||||||
|
this.openRouterAiService = openRouterAiService;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.attachmentStorageService = attachmentStorageService;
|
this.attachmentStorageService = attachmentStorageService;
|
||||||
this.messageRepository = messageRepository;
|
this.messageRepository = messageRepository;
|
||||||
@@ -55,7 +58,7 @@ public class ChatController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/conversations")
|
@PostMapping("/conversations")
|
||||||
@PreAuthorize("hasRole('CUSTOMER')")
|
@PreAuthorize("isAuthenticated()")
|
||||||
public ResponseEntity<ConversationResponse> createConversation(@Valid @RequestBody ConversationRequest request) {
|
public ResponseEntity<ConversationResponse> createConversation(@Valid @RequestBody ConversationRequest request) {
|
||||||
User user = getCurrentUser();
|
User user = getCurrentUser();
|
||||||
ConversationResponse response = chatService.createConversation(user.getId(), request);
|
ConversationResponse response = chatService.createConversation(user.getId(), request);
|
||||||
@@ -88,6 +91,7 @@ public class ChatController {
|
|||||||
MessageResponse message = chatService.sendMessage(id, user.getId(), user.getRole(), request);
|
MessageResponse message = chatService.sendMessage(id, user.getId(), user.getRole(), request);
|
||||||
chatRealtimeService.publishMessage(id, message);
|
chatRealtimeService.publishMessage(id, message);
|
||||||
chatRealtimeService.publishConversationUpdate(id);
|
chatRealtimeService.publishConversationUpdate(id);
|
||||||
|
openRouterAiService.generateAndSendReply(id, message.getId());
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(message);
|
return ResponseEntity.status(HttpStatus.CREATED).body(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +105,7 @@ public class ChatController {
|
|||||||
MessageResponse message = chatService.sendMessageWithAttachment(id, user.getId(), user.getRole(), file, content);
|
MessageResponse message = chatService.sendMessageWithAttachment(id, user.getId(), user.getRole(), file, content);
|
||||||
chatRealtimeService.publishMessage(id, message);
|
chatRealtimeService.publishMessage(id, message);
|
||||||
chatRealtimeService.publishConversationUpdate(id);
|
chatRealtimeService.publishConversationUpdate(id);
|
||||||
|
openRouterAiService.generateAndSendReply(id, message.getId());
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(message);
|
return ResponseEntity.status(HttpStatus.CREATED).body(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +144,7 @@ public class ChatController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/conversations/{id}/request-human")
|
@PostMapping("/conversations/{id}/request-human")
|
||||||
@PreAuthorize("hasRole('CUSTOMER')")
|
@PreAuthorize("isAuthenticated()")
|
||||||
public ResponseEntity<ConversationResponse> requestHumanTakeover(@PathVariable Long id) {
|
public ResponseEntity<ConversationResponse> requestHumanTakeover(@PathVariable Long id) {
|
||||||
User user = getCurrentUser();
|
User user = getCurrentUser();
|
||||||
ConversationResponse conversation = chatService.requestHumanTakeover(id, user.getId(), user.getRole());
|
ConversationResponse conversation = chatService.requestHumanTakeover(id, user.getId(), user.getRole());
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import com.petshop.backend.security.AppPrincipal;
|
|||||||
import com.petshop.backend.security.JwtUtil;
|
import com.petshop.backend.security.JwtUtil;
|
||||||
import com.petshop.backend.service.ChatRealtimeService;
|
import com.petshop.backend.service.ChatRealtimeService;
|
||||||
import com.petshop.backend.service.ChatService;
|
import com.petshop.backend.service.ChatService;
|
||||||
|
import com.petshop.backend.service.OpenRouterAiService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.messaging.handler.annotation.DestinationVariable;
|
import org.springframework.messaging.handler.annotation.DestinationVariable;
|
||||||
import org.springframework.messaging.handler.annotation.MessageExceptionHandler;
|
import org.springframework.messaging.handler.annotation.MessageExceptionHandler;
|
||||||
@@ -27,6 +28,7 @@ import java.util.Map;
|
|||||||
public class ChatWebSocketController {
|
public class ChatWebSocketController {
|
||||||
|
|
||||||
private final ChatService chatService;
|
private final ChatService chatService;
|
||||||
|
private final OpenRouterAiService openRouterAiService;
|
||||||
private final ChatRealtimeService chatRealtimeService;
|
private final ChatRealtimeService chatRealtimeService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final JwtUtil jwtUtil;
|
private final JwtUtil jwtUtil;
|
||||||
@@ -37,8 +39,10 @@ public class ChatWebSocketController {
|
|||||||
ChatRealtimeService chatRealtimeService,
|
ChatRealtimeService chatRealtimeService,
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
JwtUtil jwtUtil,
|
JwtUtil jwtUtil,
|
||||||
WebSocketAuthChannelInterceptor webSocketAuthChannelInterceptor
|
WebSocketAuthChannelInterceptor webSocketAuthChannelInterceptor,
|
||||||
|
OpenRouterAiService openRouterAiService
|
||||||
) {
|
) {
|
||||||
|
this.openRouterAiService = openRouterAiService;
|
||||||
this.chatService = chatService;
|
this.chatService = chatService;
|
||||||
this.chatRealtimeService = chatRealtimeService;
|
this.chatRealtimeService = chatRealtimeService;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
@@ -53,6 +57,7 @@ public class ChatWebSocketController {
|
|||||||
MessageResponse message = chatService.sendMessage(id, user.getId(), user.getRole(), request);
|
MessageResponse message = chatService.sendMessage(id, user.getId(), user.getRole(), request);
|
||||||
chatRealtimeService.publishMessage(id, message);
|
chatRealtimeService.publishMessage(id, message);
|
||||||
chatRealtimeService.publishConversationUpdate(id);
|
chatRealtimeService.publishConversationUpdate(id);
|
||||||
|
openRouterAiService.generateAndSendReply(id, message.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
@MessageExceptionHandler({IllegalArgumentException.class, RuntimeException.class})
|
@MessageExceptionHandler({IllegalArgumentException.class, RuntimeException.class})
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.petshop.backend.dto.ai;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class AiChatRequest {
|
||||||
|
|
||||||
|
private String message;
|
||||||
|
private List<ChatHistoryItem> history;
|
||||||
|
|
||||||
|
public AiChatRequest() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMessage() {
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMessage(String message) {
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ChatHistoryItem> getHistory() {
|
||||||
|
|
||||||
|
return history;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setHistory(List<ChatHistoryItem> history) {
|
||||||
|
this.history = history;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ChatHistoryItem {
|
||||||
|
private String role;
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
public ChatHistoryItem() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChatHistoryItem(String role, String content) {
|
||||||
|
this.role = role;
|
||||||
|
this.content = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRole() {
|
||||||
|
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRole(String role) {
|
||||||
|
this.role = role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getContent() {
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setContent(String content) {
|
||||||
|
this.content = content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.petshop.backend.dto.ai;
|
||||||
|
|
||||||
|
public class AiChatResponse {
|
||||||
|
|
||||||
|
private String message;
|
||||||
|
private boolean success;
|
||||||
|
private String error;
|
||||||
|
|
||||||
|
public AiChatResponse() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AiChatResponse ok(String message) {
|
||||||
|
AiChatResponse r = new AiChatResponse();
|
||||||
|
r.message = message;
|
||||||
|
r.success = true;
|
||||||
|
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AiChatResponse fail(String error) {
|
||||||
|
AiChatResponse r = new AiChatResponse();
|
||||||
|
r.success = false;
|
||||||
|
r.error = error;
|
||||||
|
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMessage() {
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMessage(String message) {
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isSuccess() {
|
||||||
|
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSuccess(boolean success) {
|
||||||
|
this.success = success;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getError() {
|
||||||
|
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setError(String error) {
|
||||||
|
this.error = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ public class MessageResponse {
|
|||||||
private Long id;
|
private Long id;
|
||||||
private Long conversationId;
|
private Long conversationId;
|
||||||
private Long senderId;
|
private Long senderId;
|
||||||
|
private String senderRole;
|
||||||
|
private String senderDisplayName;
|
||||||
private String senderAvatarUrl;
|
private String senderAvatarUrl;
|
||||||
private String content;
|
private String content;
|
||||||
private LocalDateTime timestamp;
|
private LocalDateTime timestamp;
|
||||||
@@ -82,6 +84,22 @@ public class MessageResponse {
|
|||||||
this.senderId = senderId;
|
this.senderId = senderId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getSenderRole() {
|
||||||
|
return senderRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSenderRole(String senderRole) {
|
||||||
|
this.senderRole = senderRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSenderDisplayName() {
|
||||||
|
return senderDisplayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSenderDisplayName(String senderDisplayName) {
|
||||||
|
this.senderDisplayName = senderDisplayName;
|
||||||
|
}
|
||||||
|
|
||||||
public String getContent() {
|
public String getContent() {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import java.util.stream.Collectors;
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class ChatService {
|
public class ChatService {
|
||||||
|
private static final String BOT_USERNAME = "ai.bot";
|
||||||
|
|
||||||
private final ConversationRepository conversationRepository;
|
private final ConversationRepository conversationRepository;
|
||||||
private final MessageRepository messageRepository;
|
private final MessageRepository messageRepository;
|
||||||
@@ -48,10 +49,6 @@ public class ChatService {
|
|||||||
User user = userRepository.findById(userId)
|
User user = userRepository.findById(userId)
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
|
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
|
||||||
|
|
||||||
if (user.getRole() != User.Role.CUSTOMER) {
|
|
||||||
throw new AccessDeniedException("Only customers can start new conversations");
|
|
||||||
}
|
|
||||||
|
|
||||||
Conversation conversation = new Conversation();
|
Conversation conversation = new Conversation();
|
||||||
conversation.setCustomerId(userId);
|
conversation.setCustomerId(userId);
|
||||||
conversation.setStatus(Conversation.ConversationStatus.OPEN);
|
conversation.setStatus(Conversation.ConversationStatus.OPEN);
|
||||||
@@ -93,6 +90,12 @@ public class ChatService {
|
|||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public Conversation getConversationEntity(Long id) {
|
||||||
|
return conversationRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Conversation not found"));
|
||||||
|
}
|
||||||
|
|
||||||
public ConversationResponse getConversation(Long conversationId, Long userId, User.Role role) {
|
public ConversationResponse getConversation(Long conversationId, Long userId, User.Role role) {
|
||||||
Conversation conversation = conversationRepository.findById(conversationId)
|
Conversation conversation = conversationRepository.findById(conversationId)
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("Conversation not found"));
|
.orElseThrow(() -> new ResourceNotFoundException("Conversation not found"));
|
||||||
@@ -155,6 +158,23 @@ public class ChatService {
|
|||||||
return toMessageResponse(message);
|
return toMessageResponse(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public MessageResponse saveBotMessage(Long conversationId, String content) {
|
||||||
|
conversationRepository.findById(conversationId)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Conversation not found"));
|
||||||
|
User botUser = getBotUser();
|
||||||
|
|
||||||
|
Message message = new Message();
|
||||||
|
message.setConversationId(conversationId);
|
||||||
|
message.setSenderId(botUser.getId());
|
||||||
|
message.setContent(content);
|
||||||
|
message.setIsRead(false);
|
||||||
|
message = messageRepository.save(message);
|
||||||
|
|
||||||
|
return toMessageResponse(message);
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public MessageResponse sendMessageWithAttachment(Long conversationId, Long userId, User.Role role, MultipartFile file, String content) {
|
public MessageResponse sendMessageWithAttachment(Long conversationId, Long userId, User.Role role, MultipartFile file, String content) {
|
||||||
Conversation conversation = conversationRepository.findById(conversationId)
|
Conversation conversation = conversationRepository.findById(conversationId)
|
||||||
@@ -210,7 +230,7 @@ public class ChatService {
|
|||||||
throw new AccessDeniedException("Conversation is closed");
|
throw new AccessDeniedException("Conversation is closed");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role != User.Role.CUSTOMER || !hasConversationAccess(conversation, userId, role)) {
|
if (!hasConversationAccess(conversation, userId, role)) {
|
||||||
throw new AccessDeniedException("You can only request human takeover for your own conversations");
|
throw new AccessDeniedException("You can only request human takeover for your own conversations");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,14 +291,60 @@ public class ChatService {
|
|||||||
|
|
||||||
private MessageResponse toMessageResponse(Message message) {
|
private MessageResponse toMessageResponse(Message message) {
|
||||||
MessageResponse response = MessageResponse.fromEntity(message);
|
MessageResponse response = MessageResponse.fromEntity(message);
|
||||||
userRepository.findById(message.getSenderId()).ifPresent(user -> {
|
userRepository.findById(message.getSenderId())
|
||||||
if (avatarStorageService.hasAvatar(user)) {
|
.ifPresentOrElse(
|
||||||
response.setSenderAvatarUrl(avatarStorageService.toOwnerAvatarUrl(user));
|
user -> populateSenderMetadata(response, user),
|
||||||
}
|
() -> {
|
||||||
});
|
response.setSenderRole("UNKNOWN");
|
||||||
|
response.setSenderDisplayName("Unknown");
|
||||||
|
}
|
||||||
|
);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void populateSenderMetadata(MessageResponse response, User user) {
|
||||||
|
response.setSenderRole(resolveSenderRole(user));
|
||||||
|
response.setSenderDisplayName(resolveSenderDisplayName(user));
|
||||||
|
if (avatarStorageService.hasAvatar(user)) {
|
||||||
|
response.setSenderAvatarUrl(avatarStorageService.toOwnerAvatarUrl(user));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveSenderRole(User user) {
|
||||||
|
if (BOT_USERNAME.equals(user.getUsername())) {
|
||||||
|
return "BOT";
|
||||||
|
}
|
||||||
|
return user.getRole() != null ? user.getRole().name() : "UNKNOWN";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveSenderDisplayName(User user) {
|
||||||
|
if (BOT_USERNAME.equals(user.getUsername())) {
|
||||||
|
return "AI Bot";
|
||||||
|
}
|
||||||
|
if (user.getFullName() != null && !user.getFullName().isBlank()) {
|
||||||
|
return user.getFullName();
|
||||||
|
}
|
||||||
|
StringBuilder displayName = new StringBuilder();
|
||||||
|
if (user.getFirstName() != null && !user.getFirstName().isBlank()) {
|
||||||
|
displayName.append(user.getFirstName().trim());
|
||||||
|
}
|
||||||
|
if (user.getLastName() != null && !user.getLastName().isBlank()) {
|
||||||
|
if (!displayName.isEmpty()) {
|
||||||
|
displayName.append(' ');
|
||||||
|
}
|
||||||
|
displayName.append(user.getLastName().trim());
|
||||||
|
}
|
||||||
|
if (!displayName.isEmpty()) {
|
||||||
|
return displayName.toString();
|
||||||
|
}
|
||||||
|
return user.getUsername() != null && !user.getUsername().isBlank() ? user.getUsername() : "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
private User getBotUser() {
|
||||||
|
return userRepository.findByUsername(BOT_USERNAME)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Bot user not found"));
|
||||||
|
}
|
||||||
|
|
||||||
public boolean hasConversationAccess(Long conversationId, Long userId, User.Role role) {
|
public boolean hasConversationAccess(Long conversationId, Long userId, User.Role role) {
|
||||||
Conversation conversation = conversationRepository.findById(conversationId)
|
Conversation conversation = conversationRepository.findById(conversationId)
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("Conversation not found"));
|
.orElseThrow(() -> new ResourceNotFoundException("Conversation not found"));
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package com.petshop.backend.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||||
|
import com.petshop.backend.entity.Conversation;
|
||||||
|
import com.petshop.backend.entity.Message;
|
||||||
|
import com.petshop.backend.entity.User;
|
||||||
|
import com.petshop.backend.repository.MessageRepository;
|
||||||
|
import com.petshop.backend.repository.UserRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class OpenRouterAiService {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(OpenRouterAiService.class);
|
||||||
|
private static final String BOT_USERNAME = "ai.bot";
|
||||||
|
|
||||||
|
@Value("${openrouter.api-key:}")
|
||||||
|
private String apiKey;
|
||||||
|
|
||||||
|
@Value("${openrouter.model:openai/gpt-oss-120b:free}")
|
||||||
|
private String model;
|
||||||
|
|
||||||
|
private final String openRouterUrl = "https://openrouter.ai/api/v1/chat/completions";
|
||||||
|
private final ChatService chatService;
|
||||||
|
private final ChatRealtimeService chatRealtimeService;
|
||||||
|
private final MessageRepository messageRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
|
||||||
|
public OpenRouterAiService(
|
||||||
|
ChatService chatService,
|
||||||
|
ChatRealtimeService chatRealtimeService,
|
||||||
|
MessageRepository messageRepository,
|
||||||
|
UserRepository userRepository
|
||||||
|
) {
|
||||||
|
this.chatService = chatService;
|
||||||
|
this.chatRealtimeService = chatRealtimeService;
|
||||||
|
this.messageRepository = messageRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.objectMapper = JsonMapper.builder().findAndAddModules().build();
|
||||||
|
this.httpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void generateAndSendReply(Long conversationId, Long triggerMessageId) {
|
||||||
|
CompletableFuture.runAsync(() -> generateReply(conversationId, triggerMessageId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void generateReply(Long conversationId, Long triggerMessageId) {
|
||||||
|
try {
|
||||||
|
if (triggerMessageId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (apiKey == null || apiKey.isBlank()) {
|
||||||
|
log.debug("Skipping OpenRouter reply for conversation {} because no API key is configured", conversationId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (model == null || model.isBlank()) {
|
||||||
|
log.warn("Skipping OpenRouter reply for conversation {} because no model is configured", conversationId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Conversation conversation = chatService.getConversationEntity(conversationId);
|
||||||
|
if (conversation.getStatus() == Conversation.ConversationStatus.CLOSED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (conversation.getHumanRequestedAt() != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (conversation.getMode() != Conversation.ConversationMode.AUTOMATED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
User botUser = userRepository.findByUsername(BOT_USERNAME)
|
||||||
|
.orElseThrow(() -> new IllegalStateException("Bot user not found"));
|
||||||
|
|
||||||
|
Message triggerMessage = messageRepository.findById(triggerMessageId).orElse(null);
|
||||||
|
if (triggerMessage == null || !conversationId.equals(triggerMessage.getConversationId())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Message> history = messageRepository.findByConversationIdOrderByTimestampAsc(conversationId);
|
||||||
|
if (history.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Message lastMessage = history.get(history.size() - 1);
|
||||||
|
if (!triggerMessageId.equals(lastMessage.getId())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String lastContent = normalizeContent(lastMessage.getContent());
|
||||||
|
if (lastContent.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lastMessage.getSenderId() != null && lastMessage.getSenderId().equals(botUser.getId())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
User lastSender = userRepository.findById(lastMessage.getSenderId()).orElse(null);
|
||||||
|
if (lastSender == null || lastSender.getRole() != User.Role.CUSTOMER) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, String>> messages = new ArrayList<>();
|
||||||
|
messages.add(Map.of(
|
||||||
|
"role", "system",
|
||||||
|
"content", "You are a helpful pet shop assistant. Provide concise and friendly answers. Do not output markdown, just plain text."
|
||||||
|
));
|
||||||
|
|
||||||
|
for (Message message : history) {
|
||||||
|
messages.add(Map.of(
|
||||||
|
"role", resolveRole(message, botUser.getId()),
|
||||||
|
"content", normalizeContent(message.getContent())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> requestBody = Map.of(
|
||||||
|
"model", model,
|
||||||
|
"messages", messages
|
||||||
|
);
|
||||||
|
|
||||||
|
String requestJson = objectMapper.writeValueAsString(requestBody);
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(openRouterUrl))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Authorization", "Bearer " + apiKey)
|
||||||
|
.timeout(Duration.ofSeconds(30))
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(requestJson))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
log.warn("OpenRouter API returned {} for conversation {}", response.statusCode(), conversationId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode responseNode = objectMapper.readTree(response.body());
|
||||||
|
String replyContent = responseNode.path("choices").path(0).path("message").path("content").asText("");
|
||||||
|
replyContent = normalizeContent(replyContent);
|
||||||
|
if (replyContent.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Conversation latestConversation = chatService.getConversationEntity(conversationId);
|
||||||
|
if (latestConversation.getStatus() == Conversation.ConversationStatus.CLOSED
|
||||||
|
|| latestConversation.getHumanRequestedAt() != null
|
||||||
|
|| latestConversation.getMode() != Conversation.ConversationMode.AUTOMATED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Message> latestHistory = messageRepository.findByConversationIdOrderByTimestampAsc(conversationId);
|
||||||
|
if (latestHistory.isEmpty() || !triggerMessageId.equals(latestHistory.get(latestHistory.size() - 1).getId())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var messageResponse = chatService.saveBotMessage(conversationId, replyContent);
|
||||||
|
chatRealtimeService.publishMessage(conversationId, messageResponse);
|
||||||
|
chatRealtimeService.publishConversationUpdate(conversationId);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.error("Failed to generate OpenRouter reply for conversation {}", conversationId, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveRole(Message message, Long botUserId) {
|
||||||
|
if (message.getSenderId() != null && message.getSenderId().equals(botUserId)) {
|
||||||
|
return "assistant";
|
||||||
|
}
|
||||||
|
return "user";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeContent(String content) {
|
||||||
|
return content == null ? "" : content.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package com.petshop.backend.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import com.petshop.backend.dto.ai.AiChatRequest;
|
||||||
|
import com.petshop.backend.entity.Pet;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class OpenRouterService {
|
||||||
|
|
||||||
|
private static final String OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
|
||||||
|
|
||||||
|
@Value("${openrouter.api-key:}")
|
||||||
|
private String apiKey;
|
||||||
|
|
||||||
|
@Value("${openrouter.model:meta-llama/llama-3.3-70b-instruct:free}")
|
||||||
|
private String model;
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(30))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
public String chat(List<AiChatRequest.ChatHistoryItem> history, String userMessage, List<Pet> userPets) {
|
||||||
|
if (apiKey == null || apiKey.isBlank()) {
|
||||||
|
throw new IllegalStateException("OpenRouter API key is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String systemPrompt = buildSystemPrompt(userPets);
|
||||||
|
String requestBody = buildRequestBody(systemPrompt, history, userMessage);
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(OPENROUTER_URL))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Authorization", "Bearer " + apiKey)
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
|
||||||
|
.timeout(Duration.ofSeconds(60))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
throw new RuntimeException("OpenRouter API returned status " + response.statusCode() + ": " + response.body());
|
||||||
|
}
|
||||||
|
|
||||||
|
return extractContent(response.body());
|
||||||
|
}
|
||||||
|
|
||||||
|
catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new RuntimeException("AI request was interrupted", e);
|
||||||
|
}
|
||||||
|
catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to call OpenRouter API: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildSystemPrompt(List<Pet> userPets) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("You are a helpful AI assistant for Leon's Pet Store, specializing in pet ownership and adoption advice. ");
|
||||||
|
sb.append("You help customers with pet care requirements, supplies (food, habitat, grooming, etc.), ");
|
||||||
|
sb.append("veterinary and grooming appointments, and general pet-related questions. ");
|
||||||
|
sb.append("You can recommend pets based on the user's experience level (beginner, intermediate, expert). ");
|
||||||
|
sb.append("Always be friendly, informative, and focused on pet-related topics.\n\n");
|
||||||
|
|
||||||
|
if (userPets != null && !userPets.isEmpty()) {
|
||||||
|
sb.append("The user currently owns the following pets:\n");
|
||||||
|
for (Pet pet : userPets) {
|
||||||
|
sb.append("- ").append(pet.getPetName());
|
||||||
|
sb.append(" (").append(pet.getPetSpecies());
|
||||||
|
|
||||||
|
if (pet.getPetBreed() != null && !pet.getPetBreed().isBlank()) {
|
||||||
|
sb.append(", ").append(pet.getPetBreed());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pet.getPetAge() != null) {
|
||||||
|
sb.append(", ").append(pet.getPetAge()).append(" year(s) old");
|
||||||
|
}
|
||||||
|
sb.append(")\n");
|
||||||
|
}
|
||||||
|
sb.append("Use this information to personalize your responses when relevant.\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
else {
|
||||||
|
sb.append("The user does not currently own any pets registered in the system.\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildRequestBody(String systemPrompt, List<AiChatRequest.ChatHistoryItem> history, String userMessage) throws Exception {
|
||||||
|
ObjectNode root = objectMapper.createObjectNode();
|
||||||
|
root.put("model", model);
|
||||||
|
|
||||||
|
ArrayNode messages = root.putArray("messages");
|
||||||
|
|
||||||
|
ObjectNode systemMsg = messages.addObject();
|
||||||
|
systemMsg.put("role", "system");
|
||||||
|
systemMsg.put("content", systemPrompt);
|
||||||
|
|
||||||
|
if (history != null) {
|
||||||
|
for (AiChatRequest.ChatHistoryItem item : history) {
|
||||||
|
ObjectNode msg = messages.addObject();
|
||||||
|
msg.put("role", item.getRole());
|
||||||
|
msg.put("content", item.getContent());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ObjectNode userMsg = messages.addObject();
|
||||||
|
userMsg.put("role", "user");
|
||||||
|
userMsg.put("content", userMessage);
|
||||||
|
|
||||||
|
return objectMapper.writeValueAsString(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractContent(String responseBody) throws Exception {
|
||||||
|
JsonNode root = objectMapper.readTree(responseBody);
|
||||||
|
JsonNode choices = root.path("choices");
|
||||||
|
if (choices.isArray() && choices.size() > 0) {
|
||||||
|
return choices.get(0).path("message").path("content").asText();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException("No content in OpenRouter response: " + responseBody);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ spring:
|
|||||||
application:
|
application:
|
||||||
name: petshop-backend
|
name: petshop-backend
|
||||||
|
|
||||||
|
config:
|
||||||
|
import: optional:file:.env[.properties]
|
||||||
|
|
||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -53,6 +56,10 @@ jwt:
|
|||||||
stripe:
|
stripe:
|
||||||
secret-key: ${STRIPE_SECRET_KEY:}
|
secret-key: ${STRIPE_SECRET_KEY:}
|
||||||
|
|
||||||
|
openrouter:
|
||||||
|
api-key: ${OPENROUTER_API_KEY:}
|
||||||
|
model: ${OPENROUTER_MODEL:openai/gpt-oss-120b:free}
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
com.petshop: ${LOG_LEVEL:INFO}
|
com.petshop: ${LOG_LEVEL:INFO}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
INSERT INTO users (username, password, email, firstName, lastName, fullName, phone, avatarUrl, role, staffRole, primaryStoreId, loyaltyPoints, active, tokenVersion)
|
||||||
|
SELECT 'ai.bot',
|
||||||
|
'$2a$10$mE0D/HrnCuqFeEqMy0NJwuy2jkoRYjQ7GrKcc/7QQ0r2AqnZTvyGq',
|
||||||
|
'bot@petshop.com',
|
||||||
|
'AI',
|
||||||
|
'Bot',
|
||||||
|
'AI Bot',
|
||||||
|
'000-000-0000',
|
||||||
|
'https://images.petshop.local/users/bot.webp',
|
||||||
|
'STAFF',
|
||||||
|
'CUSTOMER_SERVICE',
|
||||||
|
NULL,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM users
|
||||||
|
WHERE username = 'ai.bot'
|
||||||
|
);
|
||||||
@@ -153,7 +153,8 @@ INSERT INTO users (id, username, password, email, firstName, lastName, fullName,
|
|||||||
(97, 'alex.murray', '$2a$10$mE0D/HrnCuqFeEqMy0NJwuy2jkoRYjQ7GrKcc/7QQ0r2AqnZTvyGq', 'alex.murray@gmail.com', 'Alex', 'Murray', 'Alex Murray', '403-730-0097', 'https://images.petshop.local/users/097.webp', 'CUSTOMER', 'CUSTOMER', 2, 4, 1, 0),
|
(97, 'alex.murray', '$2a$10$mE0D/HrnCuqFeEqMy0NJwuy2jkoRYjQ7GrKcc/7QQ0r2AqnZTvyGq', 'alex.murray@gmail.com', 'Alex', 'Murray', 'Alex Murray', '403-730-0097', 'https://images.petshop.local/users/097.webp', 'CUSTOMER', 'CUSTOMER', 2, 4, 1, 0),
|
||||||
(98, 'alex.freeman', '$2a$10$mE0D/HrnCuqFeEqMy0NJwuy2jkoRYjQ7GrKcc/7QQ0r2AqnZTvyGq', 'alex.freeman@gmail.com', 'Alex', 'Freeman', 'Alex Freeman', '403-730-0098', 'https://images.petshop.local/users/098.webp', 'CUSTOMER', 'CUSTOMER', 3, 0, 1, 0),
|
(98, 'alex.freeman', '$2a$10$mE0D/HrnCuqFeEqMy0NJwuy2jkoRYjQ7GrKcc/7QQ0r2AqnZTvyGq', 'alex.freeman@gmail.com', 'Alex', 'Freeman', 'Alex Freeman', '403-730-0098', 'https://images.petshop.local/users/098.webp', 'CUSTOMER', 'CUSTOMER', 3, 0, 1, 0),
|
||||||
(99, 'alex.wells', '$2a$10$mE0D/HrnCuqFeEqMy0NJwuy2jkoRYjQ7GrKcc/7QQ0r2AqnZTvyGq', 'alex.wells@gmail.com', 'Alex', 'Wells', 'Alex Wells', '403-730-0099', 'https://images.petshop.local/users/099.webp', 'CUSTOMER', 'CUSTOMER', 1, 0, 1, 0),
|
(99, 'alex.wells', '$2a$10$mE0D/HrnCuqFeEqMy0NJwuy2jkoRYjQ7GrKcc/7QQ0r2AqnZTvyGq', 'alex.wells@gmail.com', 'Alex', 'Wells', 'Alex Wells', '403-730-0099', 'https://images.petshop.local/users/099.webp', 'CUSTOMER', 'CUSTOMER', 1, 0, 1, 0),
|
||||||
(100, 'alex.webb', '$2a$10$mE0D/HrnCuqFeEqMy0NJwuy2jkoRYjQ7GrKcc/7QQ0r2AqnZTvyGq', 'alex.webb@gmail.com', 'Alex', 'Webb', 'Alex Webb', '403-730-0100', 'https://images.petshop.local/users/100.webp', 'CUSTOMER', 'CUSTOMER', 2, 0, 1, 0);
|
(100, 'alex.webb', '$2a$10$mE0D/HrnCuqFeEqMy0NJwuy2jkoRYjQ7GrKcc/7QQ0r2AqnZTvyGq', 'alex.webb@gmail.com', 'Alex', 'Webb', 'Alex Webb', '403-730-0100', 'https://images.petshop.local/users/100.webp', 'CUSTOMER', 'CUSTOMER', 2, 0, 1, 0),
|
||||||
|
(101, 'ai.bot', '$2a$10$mE0D/HrnCuqFeEqMy0NJwuy2jkoRYjQ7GrKcc/7QQ0r2AqnZTvyGq', 'bot@petshop.com', 'AI', 'Bot', 'AI Bot', '000-000-0000', 'https://images.petshop.local/users/bot.webp', 'STAFF', 'CUSTOMER_SERVICE', NULL, 0, 1, 0);
|
||||||
|
|
||||||
INSERT INTO supplier (supId, supCompany, supContactFirstName, supContactLastName, supEmail, supPhone) VALUES
|
INSERT INTO supplier (supId, supCompany, supContactFirstName, supContactLastName, supEmail, supPhone) VALUES
|
||||||
(1, 'PetFood Inc', 'Robert', 'King', 'contact@petfood.com', '403-601-1001'),
|
(1, 'PetFood Inc', 'Robert', 'King', 'contact@petfood.com', '403-601-1001'),
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ public class MessageResponse {
|
|||||||
private Long id;
|
private Long id;
|
||||||
private Long conversationId;
|
private Long conversationId;
|
||||||
private Long senderId;
|
private Long senderId;
|
||||||
|
private String senderRole;
|
||||||
|
private String senderDisplayName;
|
||||||
private String senderAvatarUrl;
|
private String senderAvatarUrl;
|
||||||
private String content;
|
private String content;
|
||||||
private LocalDateTime timestamp;
|
private LocalDateTime timestamp;
|
||||||
@@ -38,6 +40,22 @@ public class MessageResponse {
|
|||||||
this.senderId = senderId;
|
this.senderId = senderId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getSenderRole() {
|
||||||
|
return senderRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSenderRole(String senderRole) {
|
||||||
|
this.senderRole = senderRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSenderDisplayName() {
|
||||||
|
return senderDisplayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSenderDisplayName(String senderDisplayName) {
|
||||||
|
this.senderDisplayName = senderDisplayName;
|
||||||
|
}
|
||||||
|
|
||||||
public String getSenderAvatarUrl() {
|
public String getSenderAvatarUrl() {
|
||||||
return senderAvatarUrl;
|
return senderAvatarUrl;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -403,6 +403,11 @@ public class ChatController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String resolveAuthorLabel(MessageResponse message) {
|
private String resolveAuthorLabel(MessageResponse message) {
|
||||||
|
if ("BOT".equalsIgnoreCase(message.getSenderRole())) {
|
||||||
|
return message.getSenderDisplayName() != null && !message.getSenderDisplayName().isBlank()
|
||||||
|
? message.getSenderDisplayName()
|
||||||
|
: "AI Bot";
|
||||||
|
}
|
||||||
Long currentUserId = UserSession.getInstance().getUserId();
|
Long currentUserId = UserSession.getInstance().getUserId();
|
||||||
if (message.getSenderId() != null && message.getSenderId().equals(currentUserId)) {
|
if (message.getSenderId() != null && message.getSenderId().equals(currentUserId)) {
|
||||||
return "You";
|
return "You";
|
||||||
|
|||||||
@@ -150,12 +150,16 @@ public class SaleController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void setupTables() {
|
private void setupTables() {
|
||||||
tvCart.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY);
|
tvCart.setColumnResizePolicy(TableView.UNCONSTRAINED_RESIZE_POLICY);
|
||||||
tvCart.setFixedCellSize(34);
|
tvCart.setFixedCellSize(34);
|
||||||
colCartProduct.setCellValueFactory(new PropertyValueFactory<>("prodName"));
|
colCartProduct.setCellValueFactory(new PropertyValueFactory<>("prodName"));
|
||||||
colCartQty.setCellValueFactory(new PropertyValueFactory<>("quantity"));
|
colCartQty.setCellValueFactory(new PropertyValueFactory<>("quantity"));
|
||||||
colCartUnitPrice.setCellValueFactory(new PropertyValueFactory<>("unitPrice"));
|
colCartUnitPrice.setCellValueFactory(new PropertyValueFactory<>("unitPrice"));
|
||||||
colCartTotal.setCellValueFactory(new PropertyValueFactory<>("total"));
|
colCartTotal.setCellValueFactory(new PropertyValueFactory<>("total"));
|
||||||
|
colCartProduct.setMinWidth(190);
|
||||||
|
colCartQty.setMinWidth(70);
|
||||||
|
colCartUnitPrice.setMinWidth(90);
|
||||||
|
colCartTotal.setMinWidth(90);
|
||||||
TableViewSupport.applyCurrencyColumn(colCartUnitPrice);
|
TableViewSupport.applyCurrencyColumn(colCartUnitPrice);
|
||||||
TableViewSupport.applyCurrencyColumn(colCartTotal);
|
TableViewSupport.applyCurrencyColumn(colCartTotal);
|
||||||
tvCart.setItems(cartItems);
|
tvCart.setItems(cartItems);
|
||||||
@@ -171,6 +175,14 @@ public class SaleController {
|
|||||||
colSaleUnitPrice.setCellValueFactory(new PropertyValueFactory<>("unitPrice"));
|
colSaleUnitPrice.setCellValueFactory(new PropertyValueFactory<>("unitPrice"));
|
||||||
colSaleTotal.setCellValueFactory(new PropertyValueFactory<>("total"));
|
colSaleTotal.setCellValueFactory(new PropertyValueFactory<>("total"));
|
||||||
colSalePaymentType.setCellValueFactory(new PropertyValueFactory<>("paymentMethod"));
|
colSalePaymentType.setCellValueFactory(new PropertyValueFactory<>("paymentMethod"));
|
||||||
|
colSaleId.setMinWidth(50);
|
||||||
|
colSaleDate.setMinWidth(150);
|
||||||
|
colEmployeeName.setMinWidth(150);
|
||||||
|
colServiceProduct.setMinWidth(260);
|
||||||
|
colSaleQuantity.setMinWidth(55);
|
||||||
|
colSaleUnitPrice.setMinWidth(100);
|
||||||
|
colSaleTotal.setMinWidth(100);
|
||||||
|
colSalePaymentType.setMinWidth(95);
|
||||||
TableViewSupport.applyCurrencyColumn(colSaleUnitPrice);
|
TableViewSupport.applyCurrencyColumn(colSaleUnitPrice);
|
||||||
TableViewSupport.applyCurrencyColumn(colSaleTotal);
|
TableViewSupport.applyCurrencyColumn(colSaleTotal);
|
||||||
|
|
||||||
@@ -179,6 +191,12 @@ public class SaleController {
|
|||||||
TableViewSupport.installDoubleClickAction(tvSales, selected -> openSaleDetailDialog(selected.getSaleId()));
|
TableViewSupport.installDoubleClickAction(tvSales, selected -> openSaleDetailDialog(selected.getSaleId()));
|
||||||
|
|
||||||
txtSearch.textProperty().addListener((obs, oldVal, newVal) -> applySalesFilter(newVal));
|
txtSearch.textProperty().addListener((obs, oldVal, newVal) -> applySalesFilter(newVal));
|
||||||
|
tvSales.widthProperty().addListener((obs, oldWidth, newWidth) -> updateSalesColumnWidths(newWidth.doubleValue()));
|
||||||
|
tvCart.widthProperty().addListener((obs, oldWidth, newWidth) -> updateCartColumnWidths(newWidth.doubleValue()));
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
updateSalesColumnWidths(tvSales.getWidth());
|
||||||
|
updateCartColumnWidths(tvCart.getWidth());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setupCreateSale() {
|
private void setupCreateSale() {
|
||||||
@@ -543,6 +561,58 @@ public class SaleController {
|
|||||||
lblCartTotal.setText(currency.format(total));
|
lblCartTotal.setText(currency.format(total));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void updateSalesColumnWidths(double tableWidth) {
|
||||||
|
double available = Math.max(tableWidth - 28.0, 0.0);
|
||||||
|
double baseWidth = 1125.0;
|
||||||
|
if (available <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (available <= baseWidth) {
|
||||||
|
colSaleId.setPrefWidth(60.0);
|
||||||
|
colSaleDate.setPrefWidth(170.0);
|
||||||
|
colEmployeeName.setPrefWidth(160.0);
|
||||||
|
colServiceProduct.setPrefWidth(320.0);
|
||||||
|
colSaleQuantity.setPrefWidth(70.0);
|
||||||
|
colSaleUnitPrice.setPrefWidth(115.0);
|
||||||
|
colSaleTotal.setPrefWidth(120.0);
|
||||||
|
colSalePaymentType.setPrefWidth(110.0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
double extra = available - baseWidth;
|
||||||
|
colSaleId.setPrefWidth(60.0);
|
||||||
|
colSaleDate.setPrefWidth(170.0 + extra * 0.18);
|
||||||
|
colEmployeeName.setPrefWidth(160.0 + extra * 0.18);
|
||||||
|
colServiceProduct.setPrefWidth(320.0 + extra * 0.42);
|
||||||
|
colSaleQuantity.setPrefWidth(70.0);
|
||||||
|
colSaleUnitPrice.setPrefWidth(115.0 + extra * 0.08);
|
||||||
|
colSaleTotal.setPrefWidth(120.0 + extra * 0.08);
|
||||||
|
colSalePaymentType.setPrefWidth(110.0 + extra * 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateCartColumnWidths(double tableWidth) {
|
||||||
|
double available = Math.max(tableWidth - 28.0, 0.0);
|
||||||
|
double baseWidth = 640.0;
|
||||||
|
if (available <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (available <= baseWidth) {
|
||||||
|
colCartProduct.setPrefWidth(310.0);
|
||||||
|
colCartQty.setPrefWidth(90.0);
|
||||||
|
colCartUnitPrice.setPrefWidth(120.0);
|
||||||
|
colCartTotal.setPrefWidth(120.0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
double extra = available - baseWidth;
|
||||||
|
colCartProduct.setPrefWidth(310.0 + extra * 0.55);
|
||||||
|
colCartQty.setPrefWidth(90.0);
|
||||||
|
colCartUnitPrice.setPrefWidth(120.0 + extra * 0.2);
|
||||||
|
colCartTotal.setPrefWidth(120.0 + extra * 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
private void setCreateSaleControlsDisabled(boolean disabled) {
|
private void setCreateSaleControlsDisabled(boolean disabled) {
|
||||||
cbProduct.setDisable(disabled);
|
cbProduct.setDisable(disabled);
|
||||||
spQuantity.setDisable(disabled);
|
spQuantity.setDisable(disabled);
|
||||||
|
|||||||
@@ -10,171 +10,185 @@
|
|||||||
<?import javafx.scene.control.TableView?>
|
<?import javafx.scene.control.TableView?>
|
||||||
<?import javafx.scene.control.TextField?>
|
<?import javafx.scene.control.TextField?>
|
||||||
<?import javafx.scene.control.ScrollPane?>
|
<?import javafx.scene.control.ScrollPane?>
|
||||||
|
<?import javafx.scene.layout.FlowPane?>
|
||||||
<?import javafx.scene.layout.HBox?>
|
<?import javafx.scene.layout.HBox?>
|
||||||
<?import javafx.scene.layout.Region?>
|
<?import javafx.scene.layout.Region?>
|
||||||
<?import javafx.scene.layout.VBox?>
|
<?import javafx.scene.layout.VBox?>
|
||||||
<?import javafx.scene.text.Font?>
|
<?import javafx.scene.text.Font?>
|
||||||
|
|
||||||
<ScrollPane fitToHeight="true" fitToWidth="false" hbarPolicy="AS_NEEDED" vbarPolicy="AS_NEEDED" pannable="true" xmlns="http://javafx.com/javafx/17" xmlns:fx="http://javafx.com/fxml/1" fx:controller="org.example.petshopdesktop.controllers.SaleController">
|
<ScrollPane fitToHeight="true" fitToWidth="true" hbarPolicy="AS_NEEDED" vbarPolicy="AS_NEEDED" pannable="true" xmlns="http://javafx.com/javafx/17" xmlns:fx="http://javafx.com/fxml/1" fx:controller="org.example.petshopdesktop.controllers.SaleController">
|
||||||
<content>
|
<content>
|
||||||
<VBox minHeight="-Infinity" minWidth="-Infinity" prefWidth="1100.0" spacing="6.0" style="-fx-font-size: 14px;">
|
<VBox minHeight="-Infinity" minWidth="0.0" maxWidth="Infinity" spacing="10.0" style="-fx-font-size: 14px;">
|
||||||
<padding>
|
|
||||||
<Insets bottom="6.0" left="6.0" right="6.0" top="6.0" />
|
|
||||||
</padding>
|
|
||||||
<children>
|
|
||||||
<HBox alignment="CENTER_LEFT" prefHeight="50.0" spacing="8.0">
|
|
||||||
<children>
|
|
||||||
<Label text="Sales" textFill="#2c3e50">
|
|
||||||
<font>
|
|
||||||
<Font name="System Bold" size="20.0" />
|
|
||||||
</font>
|
|
||||||
<HBox.margin>
|
|
||||||
<Insets />
|
|
||||||
</HBox.margin>
|
|
||||||
</Label>
|
|
||||||
<Label fx:id="lblModeNote" text="" textFill="#7f8c8d">
|
|
||||||
<font>
|
|
||||||
<Font name="System Bold" size="11.0" />
|
|
||||||
</font>
|
|
||||||
<padding>
|
|
||||||
<Insets top="4.0" />
|
|
||||||
</padding>
|
|
||||||
</Label>
|
|
||||||
<Label fx:id="lblStatus" text="" textFill="#16a085" visible="false" managed="true">
|
|
||||||
<font>
|
|
||||||
<Font name="System Bold" size="11.0" />
|
|
||||||
</font>
|
|
||||||
<padding>
|
|
||||||
<Insets right="8.0" />
|
|
||||||
</padding>
|
|
||||||
</Label>
|
|
||||||
<Region HBox.hgrow="ALWAYS" />
|
|
||||||
<Button fx:id="btnRefund" mnemonicParsing="false" onAction="#btnRefund" prefHeight="32.0" style="-fx-background-color: #FF6b6b; -fx-cursor: hand; -fx-background-radius: 8;" text="Process Refund" textFill="WHITE">
|
|
||||||
<font>
|
|
||||||
<Font name="System Bold" size="14.0" />
|
|
||||||
</font>
|
|
||||||
<padding>
|
|
||||||
<Insets bottom="12.0" left="24.0" right="24.0" top="12.0" />
|
|
||||||
</padding>
|
|
||||||
</Button>
|
|
||||||
<Button fx:id="btnRefresh" mnemonicParsing="false" onAction="#btnRefresh" prefHeight="32.0" prefWidth="96.0" style="-fx-background-color: #4ECDC4; -fx-cursor: hand; -fx-background-radius: 8;" text="Refresh" textFill="WHITE">
|
|
||||||
<font>
|
|
||||||
<Font name="System Bold" size="14.0" />
|
|
||||||
</font>
|
|
||||||
<padding>
|
|
||||||
<Insets bottom="12.0" left="24.0" right="24.0" top="12.0" />
|
|
||||||
</padding>
|
|
||||||
</Button>
|
|
||||||
</children>
|
|
||||||
</HBox>
|
|
||||||
|
|
||||||
<VBox fx:id="vbCreateSale" spacing="2.0" style="-fx-background-color: #ffffff; -fx-background-radius: 12; -fx-border-color: #e6e6e6; -fx-border-radius: 12; -fx-border-width: 1;">
|
|
||||||
<padding>
|
|
||||||
<Insets bottom="4.0" left="4.0" right="4.0" top="4.0" />
|
|
||||||
</padding>
|
|
||||||
<children>
|
|
||||||
<Label text="Create Sale" textFill="#2c3e50">
|
|
||||||
<font>
|
|
||||||
<Font name="System Bold" size="13.0" />
|
|
||||||
</font>
|
|
||||||
</Label>
|
|
||||||
<HBox alignment="CENTER_LEFT" spacing="6.0">
|
|
||||||
<children>
|
|
||||||
<ComboBox fx:id="cbProduct" prefHeight="24.0" prefWidth="240.0" promptText="Select a product" />
|
|
||||||
<Spinner fx:id="spQuantity" prefHeight="24.0" prefWidth="70.0" />
|
|
||||||
<Button fx:id="btnAddToCart" mnemonicParsing="false" onAction="#btnAddToCart" prefHeight="24.0" style="-fx-background-color: #FF6b6b; -fx-cursor: hand; -fx-background-radius: 8;" text="Add" textFill="WHITE">
|
|
||||||
<font>
|
|
||||||
<Font name="System Bold" size="13.0" />
|
|
||||||
</font>
|
|
||||||
<padding>
|
|
||||||
<Insets bottom="8.0" left="18.0" right="18.0" top="8.0" />
|
|
||||||
</padding>
|
|
||||||
</Button>
|
|
||||||
<Button fx:id="btnRemoveSelected" mnemonicParsing="false" onAction="#btnRemoveSelected" prefHeight="24.0" style="-fx-background-color: #34495E; -fx-cursor: hand; -fx-background-radius: 8;" text="Remove Selected" textFill="WHITE">
|
|
||||||
<font>
|
|
||||||
<Font name="System Bold" size="13.0" />
|
|
||||||
</font>
|
|
||||||
<padding>
|
|
||||||
<Insets bottom="8.0" left="18.0" right="18.0" top="8.0" />
|
|
||||||
</padding>
|
|
||||||
</Button>
|
|
||||||
</children>
|
|
||||||
</HBox>
|
|
||||||
<TableView fx:id="tvCart" prefHeight="72.0" style="-fx-background-color: white; -fx-background-radius: 10;" VBox.vgrow="NEVER">
|
|
||||||
<columns>
|
|
||||||
<TableColumn fx:id="colCartProduct" prefWidth="310.0" text="Product" />
|
|
||||||
<TableColumn fx:id="colCartQty" prefWidth="90.0" text="Qty" />
|
|
||||||
<TableColumn fx:id="colCartUnitPrice" prefWidth="120.0" text="Unit Price" />
|
|
||||||
<TableColumn fx:id="colCartTotal" prefWidth="120.0" text="Total" />
|
|
||||||
</columns>
|
|
||||||
</TableView>
|
|
||||||
<Separator />
|
|
||||||
<HBox alignment="CENTER_LEFT" spacing="12.0">
|
|
||||||
<children>
|
|
||||||
<Label text="Payment" textFill="#2c3e50">
|
|
||||||
<font>
|
|
||||||
<Font name="System Bold" size="13.0" />
|
|
||||||
</font>
|
|
||||||
</Label>
|
|
||||||
<ComboBox fx:id="cbPaymentMethod" prefHeight="34.0" prefWidth="160.0" />
|
|
||||||
<Region HBox.hgrow="ALWAYS" />
|
|
||||||
<Label text="Total:" textFill="#2c3e50">
|
|
||||||
<font>
|
|
||||||
<Font name="System Bold" size="13.0" />
|
|
||||||
</font>
|
|
||||||
</Label>
|
|
||||||
<Label fx:id="lblCartTotal" text="\$0.00" textFill="#2c3e50">
|
|
||||||
<font>
|
|
||||||
<Font name="System Bold" size="16.0" />
|
|
||||||
</font>
|
|
||||||
</Label>
|
|
||||||
<Region prefWidth="20.0" />
|
|
||||||
<Button fx:id="btnClearCart" mnemonicParsing="false" onAction="#btnClearCart" prefHeight="36.0" style="-fx-background-color: transparent; -fx-border-color: #FF6b6b; -fx-border-radius: 8; -fx-cursor: hand;" text="Clear" textFill="#FF6b6b">
|
|
||||||
<font>
|
|
||||||
<Font name="System Bold" size="13.0" />
|
|
||||||
</font>
|
|
||||||
<padding>
|
|
||||||
<Insets bottom="8.0" left="18.0" right="18.0" top="8.0" />
|
|
||||||
</padding>
|
|
||||||
</Button>
|
|
||||||
<Button fx:id="btnSaveSale" mnemonicParsing="false" onAction="#btnSaveSale" prefHeight="36.0" style="-fx-background-color: #4ECDC4; -fx-cursor: hand; -fx-background-radius: 8;" text="Save Sale" textFill="WHITE">
|
|
||||||
<font>
|
|
||||||
<Font name="System Bold" size="13.0" />
|
|
||||||
</font>
|
|
||||||
<padding>
|
|
||||||
<Insets bottom="8.0" left="18.0" right="18.0" top="8.0" />
|
|
||||||
</padding>
|
|
||||||
</Button>
|
|
||||||
</children>
|
|
||||||
</HBox>
|
|
||||||
</children>
|
|
||||||
</VBox>
|
|
||||||
|
|
||||||
<HBox alignment="CENTER_LEFT" prefHeight="24.0" spacing="10.0" style="-fx-background-color: white; -fx-background-radius: 14; -fx-border-width: 1; -fx-border-radius: 14; -fx-border-color: #e6e6e6;">
|
|
||||||
<padding>
|
<padding>
|
||||||
<Insets bottom="10.0" left="15.0" right="15.0" top="10.0" />
|
<Insets bottom="16.0" left="16.0" right="16.0" top="16.0" />
|
||||||
</padding>
|
</padding>
|
||||||
<children>
|
<children>
|
||||||
<TextField fx:id="txtSearch" prefHeight="22.0" prefWidth="150.0" promptText="Search sales..." style="-fx-border-width: 0; -fx-background-color: transparent;" HBox.hgrow="ALWAYS">
|
<HBox alignment="CENTER_LEFT" spacing="12.0" maxWidth="Infinity">
|
||||||
<font>
|
<children>
|
||||||
<Font size="15.0" />
|
<VBox spacing="2.0" HBox.hgrow="ALWAYS">
|
||||||
</font>
|
<children>
|
||||||
</TextField>
|
<Label text="Sales" textFill="#2c3e50">
|
||||||
|
<font>
|
||||||
|
<Font name="System Bold" size="20.0" />
|
||||||
|
</font>
|
||||||
|
</Label>
|
||||||
|
<HBox spacing="8.0">
|
||||||
|
<children>
|
||||||
|
<Label fx:id="lblModeNote" text="" textFill="#7f8c8d">
|
||||||
|
<font>
|
||||||
|
<Font name="System Bold" size="11.0" />
|
||||||
|
</font>
|
||||||
|
</Label>
|
||||||
|
<Label fx:id="lblStatus" text="" textFill="#16a085" visible="false" managed="true">
|
||||||
|
<font>
|
||||||
|
<Font name="System Bold" size="11.0" />
|
||||||
|
</font>
|
||||||
|
</Label>
|
||||||
|
</children>
|
||||||
|
</HBox>
|
||||||
|
</children>
|
||||||
|
</VBox>
|
||||||
|
<FlowPane hgap="8.0" maxWidth="Infinity" prefWrapLength="260.0" vgap="8.0">
|
||||||
|
<children>
|
||||||
|
<Button fx:id="btnRefund" mnemonicParsing="false" onAction="#btnRefund" prefHeight="32.0" style="-fx-background-color: #FF6b6b; -fx-cursor: hand; -fx-background-radius: 8;" text="Process Refund" textFill="WHITE">
|
||||||
|
<font>
|
||||||
|
<Font name="System Bold" size="14.0" />
|
||||||
|
</font>
|
||||||
|
<padding>
|
||||||
|
<Insets bottom="12.0" left="24.0" right="24.0" top="12.0" />
|
||||||
|
</padding>
|
||||||
|
</Button>
|
||||||
|
<Button fx:id="btnRefresh" mnemonicParsing="false" onAction="#btnRefresh" prefHeight="32.0" prefWidth="96.0" style="-fx-background-color: #4ECDC4; -fx-cursor: hand; -fx-background-radius: 8;" text="Refresh" textFill="WHITE">
|
||||||
|
<font>
|
||||||
|
<Font name="System Bold" size="14.0" />
|
||||||
|
</font>
|
||||||
|
<padding>
|
||||||
|
<Insets bottom="12.0" left="24.0" right="24.0" top="12.0" />
|
||||||
|
</padding>
|
||||||
|
</Button>
|
||||||
|
</children>
|
||||||
|
</FlowPane>
|
||||||
|
</children>
|
||||||
|
</HBox>
|
||||||
|
|
||||||
|
<VBox fx:id="vbCreateSale" maxWidth="Infinity" spacing="10.0" style="-fx-background-color: #ffffff; -fx-background-radius: 12; -fx-border-color: #e6e6e6; -fx-border-radius: 12; -fx-border-width: 1;">
|
||||||
|
<padding>
|
||||||
|
<Insets bottom="12.0" left="12.0" right="12.0" top="12.0" />
|
||||||
|
</padding>
|
||||||
|
<children>
|
||||||
|
<Label text="Create Sale" textFill="#2c3e50">
|
||||||
|
<font>
|
||||||
|
<Font name="System Bold" size="13.0" />
|
||||||
|
</font>
|
||||||
|
</Label>
|
||||||
|
<FlowPane hgap="8.0" maxWidth="Infinity" prefWrapLength="680.0" vgap="8.0">
|
||||||
|
<children>
|
||||||
|
<ComboBox fx:id="cbProduct" minWidth="220.0" prefHeight="24.0" prefWidth="320.0" promptText="Select a product" />
|
||||||
|
<Spinner fx:id="spQuantity" prefHeight="24.0" prefWidth="90.0" />
|
||||||
|
<Button fx:id="btnAddToCart" mnemonicParsing="false" onAction="#btnAddToCart" prefHeight="24.0" style="-fx-background-color: #FF6b6b; -fx-cursor: hand; -fx-background-radius: 8;" text="Add" textFill="WHITE">
|
||||||
|
<font>
|
||||||
|
<Font name="System Bold" size="13.0" />
|
||||||
|
</font>
|
||||||
|
<padding>
|
||||||
|
<Insets bottom="8.0" left="18.0" right="18.0" top="8.0" />
|
||||||
|
</padding>
|
||||||
|
</Button>
|
||||||
|
<Button fx:id="btnRemoveSelected" mnemonicParsing="false" onAction="#btnRemoveSelected" prefHeight="24.0" style="-fx-background-color: #34495E; -fx-cursor: hand; -fx-background-radius: 8;" text="Remove Selected" textFill="WHITE">
|
||||||
|
<font>
|
||||||
|
<Font name="System Bold" size="13.0" />
|
||||||
|
</font>
|
||||||
|
<padding>
|
||||||
|
<Insets bottom="8.0" left="18.0" right="18.0" top="8.0" />
|
||||||
|
</padding>
|
||||||
|
</Button>
|
||||||
|
</children>
|
||||||
|
</FlowPane>
|
||||||
|
<TableView fx:id="tvCart" prefHeight="120.0" style="-fx-background-color: white; -fx-background-radius: 10;" VBox.vgrow="ALWAYS">
|
||||||
|
<columns>
|
||||||
|
<TableColumn fx:id="colCartProduct" prefWidth="310.0" text="Product" />
|
||||||
|
<TableColumn fx:id="colCartQty" prefWidth="90.0" text="Qty" />
|
||||||
|
<TableColumn fx:id="colCartUnitPrice" prefWidth="120.0" text="Unit Price" />
|
||||||
|
<TableColumn fx:id="colCartTotal" prefWidth="120.0" text="Total" />
|
||||||
|
</columns>
|
||||||
|
</TableView>
|
||||||
|
<Separator />
|
||||||
|
<HBox alignment="CENTER_LEFT" spacing="12.0" maxWidth="Infinity">
|
||||||
|
<children>
|
||||||
|
<HBox alignment="CENTER_LEFT" spacing="8.0">
|
||||||
|
<children>
|
||||||
|
<Label text="Payment" textFill="#2c3e50">
|
||||||
|
<font>
|
||||||
|
<Font name="System Bold" size="13.0" />
|
||||||
|
</font>
|
||||||
|
</Label>
|
||||||
|
<ComboBox fx:id="cbPaymentMethod" prefHeight="34.0" prefWidth="160.0" />
|
||||||
|
</children>
|
||||||
|
</HBox>
|
||||||
|
<Region HBox.hgrow="ALWAYS" />
|
||||||
|
<HBox alignment="CENTER_LEFT" spacing="8.0">
|
||||||
|
<children>
|
||||||
|
<Label text="Total:" textFill="#2c3e50">
|
||||||
|
<font>
|
||||||
|
<Font name="System Bold" size="13.0" />
|
||||||
|
</font>
|
||||||
|
</Label>
|
||||||
|
<Label fx:id="lblCartTotal" text="\$0.00" textFill="#2c3e50">
|
||||||
|
<font>
|
||||||
|
<Font name="System Bold" size="16.0" />
|
||||||
|
</font>
|
||||||
|
</Label>
|
||||||
|
</children>
|
||||||
|
</HBox>
|
||||||
|
<FlowPane hgap="8.0" prefWrapLength="220.0" vgap="8.0">
|
||||||
|
<children>
|
||||||
|
<Button fx:id="btnClearCart" mnemonicParsing="false" onAction="#btnClearCart" prefHeight="36.0" style="-fx-background-color: transparent; -fx-border-color: #FF6b6b; -fx-border-radius: 8; -fx-cursor: hand;" text="Clear" textFill="#FF6b6b">
|
||||||
|
<font>
|
||||||
|
<Font name="System Bold" size="13.0" />
|
||||||
|
</font>
|
||||||
|
<padding>
|
||||||
|
<Insets bottom="8.0" left="18.0" right="18.0" top="8.0" />
|
||||||
|
</padding>
|
||||||
|
</Button>
|
||||||
|
<Button fx:id="btnSaveSale" mnemonicParsing="false" onAction="#btnSaveSale" prefHeight="36.0" style="-fx-background-color: #4ECDC4; -fx-cursor: hand; -fx-background-radius: 8;" text="Save Sale" textFill="WHITE">
|
||||||
|
<font>
|
||||||
|
<Font name="System Bold" size="13.0" />
|
||||||
|
</font>
|
||||||
|
<padding>
|
||||||
|
<Insets bottom="8.0" left="18.0" right="18.0" top="8.0" />
|
||||||
|
</padding>
|
||||||
|
</Button>
|
||||||
|
</children>
|
||||||
|
</FlowPane>
|
||||||
|
</children>
|
||||||
|
</HBox>
|
||||||
|
</children>
|
||||||
|
</VBox>
|
||||||
|
|
||||||
|
<HBox alignment="CENTER_LEFT" prefHeight="24.0" spacing="10.0" style="-fx-background-color: white; -fx-background-radius: 14; -fx-border-width: 1; -fx-border-radius: 14; -fx-border-color: #e6e6e6;" maxWidth="Infinity">
|
||||||
|
<padding>
|
||||||
|
<Insets bottom="10.0" left="15.0" right="15.0" top="10.0" />
|
||||||
|
</padding>
|
||||||
|
<children>
|
||||||
|
<TextField fx:id="txtSearch" prefHeight="22.0" promptText="Search sales..." style="-fx-border-width: 0; -fx-background-color: transparent;" HBox.hgrow="ALWAYS">
|
||||||
|
<font>
|
||||||
|
<Font size="15.0" />
|
||||||
|
</font>
|
||||||
|
</TextField>
|
||||||
|
</children>
|
||||||
|
</HBox>
|
||||||
|
<TableView fx:id="tvSales" prefHeight="270.0" style="-fx-background-color: white; -fx-background-radius: 12; -fx-padding: 2;" VBox.vgrow="ALWAYS">
|
||||||
|
<columns>
|
||||||
|
<TableColumn fx:id="colSaleId" minWidth="50.0" prefWidth="60.0" text="ID" />
|
||||||
|
<TableColumn fx:id="colSaleDate" minWidth="150.0" prefWidth="170.0" text="Date" />
|
||||||
|
<TableColumn fx:id="colEmployeeName" minWidth="150.0" prefWidth="160.0" text="Employee" />
|
||||||
|
<TableColumn fx:id="colServiceProduct" minWidth="260.0" prefWidth="320.0" text="Product" />
|
||||||
|
<TableColumn fx:id="colSaleQuantity" minWidth="55.0" prefWidth="70.0" text="Qty" />
|
||||||
|
<TableColumn fx:id="colSaleUnitPrice" minWidth="100.0" prefWidth="115.0" text="Unit Price" />
|
||||||
|
<TableColumn fx:id="colSaleTotal" minWidth="100.0" prefWidth="120.0" text="Total" />
|
||||||
|
<TableColumn fx:id="colSalePaymentType" minWidth="95.0" prefWidth="110.0" text="Payment" />
|
||||||
|
</columns>
|
||||||
|
</TableView>
|
||||||
</children>
|
</children>
|
||||||
</HBox>
|
|
||||||
<TableView fx:id="tvSales" prefHeight="270.0" prefWidth="1180.0" style="-fx-background-color: white; -fx-background-radius: 12; -fx-padding: 2;" VBox.vgrow="ALWAYS">
|
|
||||||
<columns>
|
|
||||||
<TableColumn fx:id="colSaleId" minWidth="50.0" prefWidth="60.0" text="ID" />
|
|
||||||
<TableColumn fx:id="colSaleDate" minWidth="150.0" prefWidth="170.0" text="Date" />
|
|
||||||
<TableColumn fx:id="colEmployeeName" minWidth="150.0" prefWidth="160.0" text="Employee" />
|
|
||||||
<TableColumn fx:id="colServiceProduct" minWidth="260.0" prefWidth="320.0" text="Product" />
|
|
||||||
<TableColumn fx:id="colSaleQuantity" minWidth="55.0" prefWidth="70.0" text="Qty" />
|
|
||||||
<TableColumn fx:id="colSaleUnitPrice" minWidth="100.0" prefWidth="115.0" text="Unit Price" />
|
|
||||||
<TableColumn fx:id="colSaleTotal" minWidth="100.0" prefWidth="120.0" text="Total" />
|
|
||||||
<TableColumn fx:id="colSalePaymentType" minWidth="95.0" prefWidth="110.0" text="Payment" />
|
|
||||||
</columns>
|
|
||||||
</TableView>
|
|
||||||
</children>
|
|
||||||
</VBox>
|
</VBox>
|
||||||
</content>
|
</content>
|
||||||
</ScrollPane>
|
</ScrollPane>
|
||||||
|
|||||||
562
web/app/ai-chat/page.js
Normal file
562
web/app/ai-chat/page.js
Normal file
@@ -0,0 +1,562 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
|
||||||
|
const API_BASE = "";
|
||||||
|
|
||||||
|
function AiChatPage() {
|
||||||
|
const { user, token, loading: authLoading } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [messages, setMessages] = useState([]);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [switchingToHuman, setSwitchingToHuman] = useState(false);
|
||||||
|
const [humanRequested, setHumanRequested] = useState(false);
|
||||||
|
|
||||||
|
const messagesEndRef = useRef(null);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading && !user) {
|
||||||
|
router.push("/login?next=" + encodeURIComponent("/ai-chat"));
|
||||||
|
}
|
||||||
|
}, [authLoading, user, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const buildHistory = useCallback(() => {
|
||||||
|
|
||||||
|
return messages.map((m) => ({
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
}));
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
async function handleSend(e) {
|
||||||
|
e?.preventDefault();
|
||||||
|
const text = input.trim();
|
||||||
|
if (!text || sending) return;
|
||||||
|
|
||||||
|
setInput("");
|
||||||
|
setError(null);
|
||||||
|
setSending(true);
|
||||||
|
|
||||||
|
const userMsg = { role: "user", content: text, id: Date.now() };
|
||||||
|
setMessages((prev) => [...prev, userMsg]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/ai-chat/message`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: text,
|
||||||
|
history: buildHistory(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
router.push("/login?next=" + encodeURIComponent("/ai-chat"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok || !data.success) {
|
||||||
|
setError(data.error || "Failed to get a response. Please try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aiMsg = { role: "assistant", content: data.message, id: Date.now() + 1 };
|
||||||
|
setMessages((prev) => [...prev, aiMsg]);
|
||||||
|
} catch {
|
||||||
|
setError("Network error. Please check your connection and try again.");
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleHumanChat() {
|
||||||
|
if (switchingToHuman || !token) return;
|
||||||
|
setSwitchingToHuman(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/chat/conversations`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: "Hi, I'd like to speak with a real support agent.",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
router.push("/login?next=" + encodeURIComponent("/ai-chat"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data?.message || "Could not connect to live support. Please try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conv = data;
|
||||||
|
|
||||||
|
//Flag conversation staff, also should notify them
|
||||||
|
await fetch(`${API_BASE}/api/v1/chat/conversations/${conv.id}/request-human`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
setHumanRequested(true);
|
||||||
|
router.push(`/chat?id=${conv.id}`);
|
||||||
|
} catch {
|
||||||
|
setError("Network error. Could not connect to live support.");
|
||||||
|
} finally {
|
||||||
|
setSwitchingToHuman(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e) {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<main style={s.page}>
|
||||||
|
<p style={s.loading}>Loading...</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={s.page}>
|
||||||
|
<section style={s.hero}>
|
||||||
|
<h1 style={s.heroTitle}>AI Pet Assistant</h1>
|
||||||
|
<p style={s.heroSubtitle}>
|
||||||
|
Ask me anything about pet care, adoption advice, or your pets!
|
||||||
|
</p>
|
||||||
|
<div style={s.titleDecoration} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={s.chatSection}>
|
||||||
|
<div style={s.chatCard}>
|
||||||
|
<div style={s.chatHeader}>
|
||||||
|
<div style={s.chatHeaderLeft}>
|
||||||
|
<div style={s.aiAvatar}>🐾</div>
|
||||||
|
<div>
|
||||||
|
<div style={s.chatHeaderTitle}>Leon's Pet Assistant</div>
|
||||||
|
<div style={s.chatHeaderStatus}>
|
||||||
|
<span style={s.statusDot} /> Online
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
...s.humanBtn,
|
||||||
|
...(switchingToHuman ? s.humanBtnDisabled : {}),
|
||||||
|
}}
|
||||||
|
onClick={handleHumanChat}
|
||||||
|
disabled={switchingToHuman}
|
||||||
|
title="Connect with a human support agent"
|
||||||
|
>
|
||||||
|
{switchingToHuman ? "Connecting..." : "Chat with a Real Person"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={s.messagesArea}>
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div style={s.emptyState}>
|
||||||
|
<div style={s.emptyIcon}>🐾</div>
|
||||||
|
<p style={s.emptyText}>
|
||||||
|
Hello{user.fullName ? `, ${user.fullName.split(" ")[0]}` : ""}! I'm your pet care assistant.
|
||||||
|
Ask me about pet recommendations, care tips, supplies, or anything pet-related!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
style={{
|
||||||
|
...s.messageRow,
|
||||||
|
...(msg.role === "user" ? s.messageRowUser : s.messageRowAi),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{msg.role === "assistant" && (
|
||||||
|
<div style={s.aiAvatarSmall}>🐾</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...s.messageBubble,
|
||||||
|
...(msg.role === "user" ? s.bubbleUser : s.bubbleAi),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{msg.content.split("\n").map((line, i) => (
|
||||||
|
<span key={i}>
|
||||||
|
{line}
|
||||||
|
{i < msg.content.split("\n").length - 1 && <br />}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{msg.role === "user" && (
|
||||||
|
<div style={s.userAvatarSmall}>
|
||||||
|
{user.fullName ? user.fullName.charAt(0).toUpperCase() : "U"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{sending && (
|
||||||
|
<div style={{ ...s.messageRow, ...s.messageRowAi }}>
|
||||||
|
<div style={s.aiAvatarSmall}>🐾</div>
|
||||||
|
<div style={{ ...s.messageBubble, ...s.bubbleAi, ...s.typingBubble }}>
|
||||||
|
<span style={s.dot} />
|
||||||
|
<span style={{ ...s.dot, animationDelay: "0.2s" }} />
|
||||||
|
<span style={{ ...s.dot, animationDelay: "0.4s" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={s.errorBar}>
|
||||||
|
{error}
|
||||||
|
<button style={s.errorClose} onClick={() => setError(null)}>✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{humanRequested && (
|
||||||
|
<div style={s.successBar}>
|
||||||
|
Connected to live support! Redirecting to chat...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form style={s.inputArea} onSubmit={handleSend}>
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
style={s.textarea}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Ask me about pet care, adoption, supplies..."
|
||||||
|
rows={1}
|
||||||
|
disabled={sending}
|
||||||
|
maxLength={2000}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
style={{
|
||||||
|
...s.sendBtn,
|
||||||
|
...((!input.trim() || sending) ? s.sendBtnDisabled : {}),
|
||||||
|
}}
|
||||||
|
disabled={!input.trim() || sending}
|
||||||
|
title="Send message"
|
||||||
|
>
|
||||||
|
{sending ? "..." : "Send"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = {
|
||||||
|
page: {
|
||||||
|
minHeight: "100vh",
|
||||||
|
background: "#fafaf8",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
textAlign: "center",
|
||||||
|
padding: "4rem",
|
||||||
|
color: "#888",
|
||||||
|
fontSize: "1rem",
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
background: "linear-gradient(135deg, #ff8c00 0%, #ffa500 100%)",
|
||||||
|
padding: "2.5rem 1.5rem 2rem",
|
||||||
|
textAlign: "center",
|
||||||
|
color: "white",
|
||||||
|
},
|
||||||
|
heroTitle: {
|
||||||
|
fontSize: "clamp(1.6rem, 4vw, 2.4rem)",
|
||||||
|
fontWeight: 800,
|
||||||
|
margin: 0,
|
||||||
|
letterSpacing: "-0.5px",
|
||||||
|
},
|
||||||
|
heroSubtitle: {
|
||||||
|
fontSize: "clamp(0.9rem, 2vw, 1.1rem)",
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
opacity: 0.9,
|
||||||
|
},
|
||||||
|
titleDecoration: {
|
||||||
|
width: 60,
|
||||||
|
height: 4,
|
||||||
|
background: "rgba(255,255,255,0.6)",
|
||||||
|
borderRadius: 2,
|
||||||
|
margin: "1rem auto 0",
|
||||||
|
},
|
||||||
|
chatSection: {
|
||||||
|
maxWidth: 800,
|
||||||
|
margin: "0 auto",
|
||||||
|
padding: "1.5rem 1rem 2rem",
|
||||||
|
},
|
||||||
|
chatCard: {
|
||||||
|
background: "white",
|
||||||
|
borderRadius: 16,
|
||||||
|
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
|
||||||
|
overflow: "hidden",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
height: "calc(100vh - 220px)",
|
||||||
|
minHeight: 450,
|
||||||
|
},
|
||||||
|
chatHeader: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "1rem 1.25rem",
|
||||||
|
borderBottom: "1px solid #f0f0f0",
|
||||||
|
background: "#fff",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
chatHeaderLeft: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
},
|
||||||
|
aiAvatar: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "linear-gradient(135deg, #ff8c00, #ffa500)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "1.3rem",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
chatHeaderTitle: {
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: "1rem",
|
||||||
|
color: "#1a1a1a",
|
||||||
|
},
|
||||||
|
chatHeaderStatus: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.35rem",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "#4CAF50",
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
statusDot: {
|
||||||
|
display: "inline-block",
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "#4CAF50",
|
||||||
|
},
|
||||||
|
humanBtn: {
|
||||||
|
background: "white",
|
||||||
|
border: "2px solid #ff8c00",
|
||||||
|
color: "#ff8c00",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "0.45rem 0.9rem",
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.2s",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
},
|
||||||
|
humanBtnDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
cursor: "not-allowed",
|
||||||
|
},
|
||||||
|
messagesArea: {
|
||||||
|
flex: 1,
|
||||||
|
overflowY: "auto",
|
||||||
|
padding: "1.25rem",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "0.75rem",
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
textAlign: "center",
|
||||||
|
padding: "2rem",
|
||||||
|
margin: "auto",
|
||||||
|
},
|
||||||
|
emptyIcon: {
|
||||||
|
fontSize: "3rem",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: "#666",
|
||||||
|
fontSize: "0.95rem",
|
||||||
|
maxWidth: 400,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
},
|
||||||
|
messageRow: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
gap: "0.5rem",
|
||||||
|
},
|
||||||
|
messageRowUser: {
|
||||||
|
flexDirection: "row-reverse",
|
||||||
|
},
|
||||||
|
messageRowAi: {
|
||||||
|
flexDirection: "row",
|
||||||
|
},
|
||||||
|
aiAvatarSmall: {
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "linear-gradient(135deg, #ff8c00, #ffa500)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
userAvatarSmall: {
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "#ff8c00",
|
||||||
|
color: "white",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
messageBubble: {
|
||||||
|
maxWidth: "72%",
|
||||||
|
padding: "0.65rem 0.9rem",
|
||||||
|
borderRadius: 14,
|
||||||
|
fontSize: "0.92rem",
|
||||||
|
lineHeight: 1.55,
|
||||||
|
wordBreak: "break-word",
|
||||||
|
},
|
||||||
|
bubbleUser: {
|
||||||
|
background: "#ff8c00",
|
||||||
|
color: "white",
|
||||||
|
borderBottomRightRadius: 4,
|
||||||
|
},
|
||||||
|
bubbleAi: {
|
||||||
|
background: "#f4f4f4",
|
||||||
|
color: "#1a1a1a",
|
||||||
|
borderBottomLeftRadius: 4,
|
||||||
|
},
|
||||||
|
typingBubble: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.3rem",
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
},
|
||||||
|
dot: {
|
||||||
|
display: "inline-block",
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "#aaa",
|
||||||
|
animation: "bounce 1s infinite",
|
||||||
|
},
|
||||||
|
errorBar: {
|
||||||
|
background: "#fff0f0",
|
||||||
|
borderTop: "1px solid #ffd0d0",
|
||||||
|
color: "#c0392b",
|
||||||
|
padding: "0.65rem 1.25rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
errorClose: {
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: "#c0392b",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
padding: "0 0.25rem",
|
||||||
|
},
|
||||||
|
successBar: {
|
||||||
|
background: "#f0fff4",
|
||||||
|
borderTop: "1px solid #c3f0c3",
|
||||||
|
color: "#27ae60",
|
||||||
|
padding: "0.65rem 1.25rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
inputArea: {
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.6rem",
|
||||||
|
padding: "0.85rem 1.25rem",
|
||||||
|
borderTop: "1px solid #f0f0f0",
|
||||||
|
background: "#fff",
|
||||||
|
flexShrink: 0,
|
||||||
|
alignItems: "flex-end",
|
||||||
|
},
|
||||||
|
textarea: {
|
||||||
|
flex: 1,
|
||||||
|
border: "1.5px solid #e0e0e0",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "0.6rem 0.85rem",
|
||||||
|
fontSize: "0.92rem",
|
||||||
|
resize: "none",
|
||||||
|
outline: "none",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
maxHeight: 120,
|
||||||
|
overflowY: "auto",
|
||||||
|
transition: "border-color 0.2s",
|
||||||
|
},
|
||||||
|
sendBtn: {
|
||||||
|
background: "#ff8c00",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "0.6rem 1.2rem",
|
||||||
|
fontSize: "0.92rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: "background 0.2s",
|
||||||
|
},
|
||||||
|
sendBtnDisabled: {
|
||||||
|
background: "#ffd0a0",
|
||||||
|
cursor: "not-allowed",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default dynamic(() => Promise.resolve(AiChatPage), { ssr: false });
|
||||||
770
web/app/chat/page.js
Normal file
770
web/app/chat/page.js
Normal file
@@ -0,0 +1,770 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
|
||||||
|
const API_BASE = "";
|
||||||
|
const POLL_INTERVAL = 2500;
|
||||||
|
|
||||||
|
function ChatPage() {
|
||||||
|
const { user, token, loading: authLoading } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const conversationIdParam = searchParams.get("id");
|
||||||
|
|
||||||
|
const [conversation, setConversation] = useState(null);
|
||||||
|
const [messages, setMessages] = useState([]);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [loadingConv, setLoadingConv] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const messagesEndRef = useRef(null);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const pollRef = useRef(null);
|
||||||
|
const lastMessageIdRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading && !user) {
|
||||||
|
router.push("/login?next=" + encodeURIComponent("/chat" + (conversationIdParam ? `?id=${conversationIdParam}` : "")));
|
||||||
|
}
|
||||||
|
}, [authLoading, user, router, conversationIdParam]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const fetchMessages = useCallback(async (convId) => {
|
||||||
|
if (!token || !convId) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) return;
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
setMessages(data);
|
||||||
|
|
||||||
|
if (data.length > 0) {
|
||||||
|
lastMessageIdRef.current = data[data.length - 1].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
catch {
|
||||||
|
//Silent fail
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const fetchConversation = useCallback(async (convId) => {
|
||||||
|
if (!token || !convId) return null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
setConversation(data);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
catch {
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const startPolling = useCallback((convId) => {
|
||||||
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
|
pollRef.current = setInterval(async () => {
|
||||||
|
if (!token || !convId) return;
|
||||||
|
try {
|
||||||
|
const [msgsRes, convRes] = await Promise.all([
|
||||||
|
fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
fetch(`${API_BASE}/api/v1/chat/conversations/${convId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (msgsRes.ok) {
|
||||||
|
const data = await msgsRes.json();
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
const lastId = data.length > 0 ? data[data.length - 1].id : null;
|
||||||
|
if (lastId !== lastMessageIdRef.current) {
|
||||||
|
lastMessageIdRef.current = lastId;
|
||||||
|
setMessages(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (convRes.ok) {
|
||||||
|
const convData = await convRes.json();
|
||||||
|
setConversation(convData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
catch {
|
||||||
|
//Silent
|
||||||
|
}
|
||||||
|
}, POLL_INTERVAL);
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || authLoading) return;
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
setLoadingConv(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
let convId = conversationIdParam ? Number(conversationIdParam) : null;
|
||||||
|
|
||||||
|
if (!convId) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/chat/conversations`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const list = await res.json();
|
||||||
|
const open = Array.isArray(list)
|
||||||
|
? list.find((c) => c.status === "OPEN")
|
||||||
|
: null;
|
||||||
|
if (open) convId = open.id;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!convId) {
|
||||||
|
setLoadingConv(false);
|
||||||
|
setConversation(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchConversation(convId);
|
||||||
|
await fetchMessages(convId);
|
||||||
|
setLoadingConv(false);
|
||||||
|
startPolling(convId);
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
|
};
|
||||||
|
}, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, startPolling]);
|
||||||
|
|
||||||
|
async function handleSend(e) {
|
||||||
|
e?.preventDefault();
|
||||||
|
const text = input.trim();
|
||||||
|
if (!text || sending || !conversation) return;
|
||||||
|
|
||||||
|
setInput("");
|
||||||
|
setSending(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${conversation.id}/messages`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ content: text }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
router.push("/login?next=" + encodeURIComponent("/chat"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.message || "Failed to send message.");
|
||||||
|
setInput(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = await res.json();
|
||||||
|
setMessages((prev) => {
|
||||||
|
if (prev.some((m) => m.id === msg.id)) return prev;
|
||||||
|
return [...prev, msg];
|
||||||
|
});
|
||||||
|
lastMessageIdRef.current = msg.id;
|
||||||
|
} catch {
|
||||||
|
setError("Network error. Please try again.");
|
||||||
|
setInput(text);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e) {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNewConversation() {
|
||||||
|
setError(null);
|
||||||
|
setLoadingConv(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/chat/conversations`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ message: "Hi, I'd like to speak with a support agent." }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.message || "Failed to start a conversation.");
|
||||||
|
setLoadingConv(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const conv = await res.json();
|
||||||
|
|
||||||
|
// Mark as human-requested
|
||||||
|
await fetch(`${API_BASE}/api/v1/chat/conversations/${conv.id}/request-human`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
setConversation(conv);
|
||||||
|
await fetchMessages(conv.id);
|
||||||
|
setLoadingConv(false);
|
||||||
|
startPolling(conv.id);
|
||||||
|
router.replace(`/chat?id=${conv.id}`, { scroll: false });
|
||||||
|
} catch {
|
||||||
|
setError("Network error. Please try again.");
|
||||||
|
setLoadingConv(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authLoading || loadingConv) {
|
||||||
|
return (
|
||||||
|
<main style={s.page}>
|
||||||
|
<p style={s.loading}>Loading...</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const isHuman = conversation?.mode === "HUMAN";
|
||||||
|
const hasStaff = !!conversation?.staffId;
|
||||||
|
const isClosed = conversation?.status === "CLOSED";
|
||||||
|
|
||||||
|
const staffStatusLabel = isClosed
|
||||||
|
? "Conversation closed"
|
||||||
|
: hasStaff
|
||||||
|
? "Support agent connected"
|
||||||
|
: isHuman
|
||||||
|
? "Waiting for a support agent..."
|
||||||
|
: "Support";
|
||||||
|
|
||||||
|
const staffStatusColor = isClosed ? "#999" : hasStaff ? "#4CAF50" : "#ff8c00";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={s.page}>
|
||||||
|
<section style={s.hero}>
|
||||||
|
<h1 style={s.heroTitle}>Live Support Chat</h1>
|
||||||
|
<p style={s.heroSubtitle}>Chat with our support team in real time</p>
|
||||||
|
<div style={s.titleDecoration} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={s.chatSection}>
|
||||||
|
{!conversation ? (
|
||||||
|
<div style={s.noConvCard}>
|
||||||
|
<div style={s.noConvIcon}>💬</div>
|
||||||
|
<h2 style={s.noConvTitle}>No active conversation</h2>
|
||||||
|
<p style={s.noConvText}>Start a new conversation to chat with our support team.</p>
|
||||||
|
{error && <div style={s.errorInline}>{error}</div>}
|
||||||
|
<button style={s.startBtn} onClick={handleNewConversation}>
|
||||||
|
Start a Conversation
|
||||||
|
</button>
|
||||||
|
<button style={s.backBtn} onClick={() => router.push("/ai-chat")}>
|
||||||
|
Back to AI Assistant
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={s.chatCard}>
|
||||||
|
<div style={s.chatHeader}>
|
||||||
|
<div style={s.chatHeaderLeft}>
|
||||||
|
<div style={s.agentAvatar}>👤</div>
|
||||||
|
<div>
|
||||||
|
<div style={s.chatHeaderTitle}>
|
||||||
|
{hasStaff ? "Support Agent" : "Leon's Pet Store Support"}
|
||||||
|
</div>
|
||||||
|
<div style={{ ...s.chatHeaderStatus, color: staffStatusColor }}>
|
||||||
|
<span style={{ ...s.statusDot, background: staffStatusColor }} />
|
||||||
|
{staffStatusLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
style={s.aiBtn}
|
||||||
|
onClick={() => router.push("/ai-chat")}
|
||||||
|
title="Back to AI Assistant"
|
||||||
|
>
|
||||||
|
AI Assistant
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hasStaff && !isClosed && (
|
||||||
|
<div style={s.waitingBanner}>
|
||||||
|
<span style={s.waitingSpinner} />
|
||||||
|
A support agent will be with you shortly. You can send messages while you wait.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={s.messagesArea}>
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div style={s.emptyState}>
|
||||||
|
<p style={s.emptyText}>
|
||||||
|
Your conversation has started. A support agent will join soon.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{messages.map((msg) => {
|
||||||
|
const isOwn = msg.senderId === user.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
style={{
|
||||||
|
...s.messageRow,
|
||||||
|
...(isOwn ? s.messageRowUser : s.messageRowAgent),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!isOwn && (
|
||||||
|
<div style={s.agentAvatarSmall}>👤</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...s.messageBubble,
|
||||||
|
...(isOwn ? s.bubbleUser : s.bubbleAgent),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{msg.content && msg.content.split("\n").map((line, i, arr) => (
|
||||||
|
<span key={i}>
|
||||||
|
{line}
|
||||||
|
{i < arr.length - 1 && <br />}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{msg.attachmentUrl && (
|
||||||
|
<div style={s.attachment}>
|
||||||
|
<a
|
||||||
|
href={msg.attachmentUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={s.attachmentLink}
|
||||||
|
>
|
||||||
|
📎 {msg.attachmentName || "Attachment"}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ ...s.timestamp, ...(isOwn ? s.timestampUser : {}) }}>
|
||||||
|
{msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isOwn && (
|
||||||
|
<div style={s.userAvatarSmall}>
|
||||||
|
{user.fullName ? user.fullName.charAt(0).toUpperCase() : "U"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={s.errorBar}>
|
||||||
|
{error}
|
||||||
|
<button style={s.errorClose} onClick={() => setError(null)}>✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isClosed ? (
|
||||||
|
<div style={s.closedBanner}>
|
||||||
|
This conversation has been closed.
|
||||||
|
<button style={s.newConvBtn} onClick={handleNewConversation}>
|
||||||
|
Start New Conversation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form style={s.inputArea} onSubmit={handleSend}>
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
style={s.textarea}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Type a message..."
|
||||||
|
rows={1}
|
||||||
|
disabled={sending}
|
||||||
|
maxLength={2000}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
style={{
|
||||||
|
...s.sendBtn,
|
||||||
|
...((!input.trim() || sending) ? s.sendBtnDisabled : {}),
|
||||||
|
}}
|
||||||
|
disabled={!input.trim() || sending}
|
||||||
|
>
|
||||||
|
{sending ? "..." : "Send"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = {
|
||||||
|
page: {
|
||||||
|
minHeight: "100vh",
|
||||||
|
background: "#fafaf8",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
textAlign: "center",
|
||||||
|
padding: "4rem",
|
||||||
|
color: "#888",
|
||||||
|
fontSize: "1rem",
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
background: "linear-gradient(135deg, #333 0%, #555 100%)",
|
||||||
|
padding: "2.5rem 1.5rem 2rem",
|
||||||
|
textAlign: "center",
|
||||||
|
color: "white",
|
||||||
|
},
|
||||||
|
heroTitle: {
|
||||||
|
fontSize: "clamp(1.6rem, 4vw, 2.4rem)",
|
||||||
|
fontWeight: 800,
|
||||||
|
margin: 0,
|
||||||
|
letterSpacing: "-0.5px",
|
||||||
|
},
|
||||||
|
heroSubtitle: {
|
||||||
|
fontSize: "clamp(0.9rem, 2vw, 1.1rem)",
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
opacity: 0.85,
|
||||||
|
},
|
||||||
|
titleDecoration: {
|
||||||
|
width: 60,
|
||||||
|
height: 4,
|
||||||
|
background: "rgba(255,255,255,0.4)",
|
||||||
|
borderRadius: 2,
|
||||||
|
margin: "1rem auto 0",
|
||||||
|
},
|
||||||
|
chatSection: {
|
||||||
|
maxWidth: 800,
|
||||||
|
margin: "0 auto",
|
||||||
|
padding: "1.5rem 1rem 2rem",
|
||||||
|
},
|
||||||
|
noConvCard: {
|
||||||
|
background: "white",
|
||||||
|
borderRadius: 16,
|
||||||
|
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
|
||||||
|
padding: "3rem 2rem",
|
||||||
|
textAlign: "center",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "1rem",
|
||||||
|
},
|
||||||
|
noConvIcon: { fontSize: "3rem" },
|
||||||
|
noConvTitle: { fontSize: "1.4rem", fontWeight: 700, color: "#1a1a1a", margin: 0 },
|
||||||
|
noConvText: { color: "#666", fontSize: "0.95rem", maxWidth: 360 },
|
||||||
|
errorInline: {
|
||||||
|
background: "#fff0f0",
|
||||||
|
color: "#c0392b",
|
||||||
|
border: "1px solid #ffd0d0",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "0.6rem 1rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 360,
|
||||||
|
},
|
||||||
|
startBtn: {
|
||||||
|
background: "#333",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "0.7rem 2rem",
|
||||||
|
fontSize: "0.95rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
},
|
||||||
|
backBtn: {
|
||||||
|
background: "none",
|
||||||
|
border: "1.5px solid #ff8c00",
|
||||||
|
color: "#ff8c00",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "0.6rem 1.5rem",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
},
|
||||||
|
chatCard: {
|
||||||
|
background: "white",
|
||||||
|
borderRadius: 16,
|
||||||
|
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
|
||||||
|
overflow: "hidden",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
height: "calc(100vh - 220px)",
|
||||||
|
minHeight: 450,
|
||||||
|
},
|
||||||
|
chatHeader: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "1rem 1.25rem",
|
||||||
|
borderBottom: "1px solid #f0f0f0",
|
||||||
|
background: "#fff",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
chatHeaderLeft: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
},
|
||||||
|
agentAvatar: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "linear-gradient(135deg, #444, #666)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "1.2rem",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
chatHeaderTitle: {
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: "1rem",
|
||||||
|
color: "#1a1a1a",
|
||||||
|
},
|
||||||
|
chatHeaderStatus: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.35rem",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
statusDot: {
|
||||||
|
display: "inline-block",
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
},
|
||||||
|
aiBtn: {
|
||||||
|
background: "white",
|
||||||
|
border: "2px solid #ff8c00",
|
||||||
|
color: "#ff8c00",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "0.45rem 0.9rem",
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
},
|
||||||
|
waitingBanner: {
|
||||||
|
background: "#fff8f0",
|
||||||
|
borderBottom: "1px solid #ffe0b2",
|
||||||
|
color: "#e65100",
|
||||||
|
padding: "0.6rem 1.25rem",
|
||||||
|
fontSize: "0.83rem",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.6rem",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
waitingSpinner: {
|
||||||
|
display: "inline-block",
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
border: "2px solid #ff8c00",
|
||||||
|
borderTopColor: "transparent",
|
||||||
|
borderRadius: "50%",
|
||||||
|
animation: "spin 0.8s linear infinite",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
messagesArea: {
|
||||||
|
flex: 1,
|
||||||
|
overflowY: "auto",
|
||||||
|
padding: "1.25rem",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "0.75rem",
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
margin: "auto",
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: "#888",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
messageRow: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
gap: "0.5rem",
|
||||||
|
},
|
||||||
|
messageRowUser: { flexDirection: "row-reverse" },
|
||||||
|
messageRowAgent: { flexDirection: "row" },
|
||||||
|
agentAvatarSmall: {
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "linear-gradient(135deg, #444, #666)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
userAvatarSmall: {
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "#ff8c00",
|
||||||
|
color: "white",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
messageBubble: {
|
||||||
|
maxWidth: "72%",
|
||||||
|
padding: "0.65rem 0.9rem",
|
||||||
|
borderRadius: 14,
|
||||||
|
fontSize: "0.92rem",
|
||||||
|
lineHeight: 1.55,
|
||||||
|
wordBreak: "break-word",
|
||||||
|
},
|
||||||
|
bubbleUser: {
|
||||||
|
background: "#ff8c00",
|
||||||
|
color: "white",
|
||||||
|
borderBottomRightRadius: 4,
|
||||||
|
},
|
||||||
|
bubbleAgent: {
|
||||||
|
background: "#f4f4f4",
|
||||||
|
color: "#1a1a1a",
|
||||||
|
borderBottomLeftRadius: 4,
|
||||||
|
},
|
||||||
|
timestamp: {
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
color: "#aaa",
|
||||||
|
marginTop: "0.3rem",
|
||||||
|
textAlign: "left",
|
||||||
|
},
|
||||||
|
timestampUser: { textAlign: "right", color: "rgba(255,255,255,0.7)" },
|
||||||
|
attachment: { marginTop: "0.4rem" },
|
||||||
|
attachmentLink: {
|
||||||
|
color: "inherit",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
opacity: 0.85,
|
||||||
|
},
|
||||||
|
errorBar: {
|
||||||
|
background: "#fff0f0",
|
||||||
|
borderTop: "1px solid #ffd0d0",
|
||||||
|
color: "#c0392b",
|
||||||
|
padding: "0.65rem 1.25rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
errorClose: {
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: "#c0392b",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
padding: "0 0.25rem",
|
||||||
|
},
|
||||||
|
closedBanner: {
|
||||||
|
background: "#f5f5f5",
|
||||||
|
borderTop: "1px solid #e0e0e0",
|
||||||
|
color: "#666",
|
||||||
|
padding: "0.85rem 1.25rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
newConvBtn: {
|
||||||
|
background: "#333",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "0.4rem 1rem",
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
},
|
||||||
|
inputArea: {
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.6rem",
|
||||||
|
padding: "0.85rem 1.25rem",
|
||||||
|
borderTop: "1px solid #f0f0f0",
|
||||||
|
background: "#fff",
|
||||||
|
flexShrink: 0,
|
||||||
|
alignItems: "flex-end",
|
||||||
|
},
|
||||||
|
textarea: {
|
||||||
|
flex: 1,
|
||||||
|
border: "1.5px solid #e0e0e0",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "0.6rem 0.85rem",
|
||||||
|
fontSize: "0.92rem",
|
||||||
|
resize: "none",
|
||||||
|
outline: "none",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
maxHeight: 120,
|
||||||
|
overflowY: "auto",
|
||||||
|
},
|
||||||
|
sendBtn: {
|
||||||
|
background: "#333",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "0.6rem 1.2rem",
|
||||||
|
fontSize: "0.92rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
sendBtnDisabled: {
|
||||||
|
background: "#aaa",
|
||||||
|
cursor: "not-allowed",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default dynamic(() => Promise.resolve(ChatPage), { ssr: false });
|
||||||
@@ -2367,3 +2367,6 @@ body {
|
|||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes bounce { 0%, 80%, 100% { transform: translateY(0); } 40% { transform: translateY(-6px); } }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export default function DisplayNav() {
|
|||||||
<Link href="/adopt" className="nav-link">Adopt a Pet</Link>
|
<Link href="/adopt" className="nav-link">Adopt a Pet</Link>
|
||||||
<Link href="/products" className="nav-link">Online Store</Link>
|
<Link href="/products" className="nav-link">Online Store</Link>
|
||||||
<Link href="/appointments" className="nav-link">Schedule an Appointment</Link>
|
<Link href="/appointments" className="nav-link">Schedule an Appointment</Link>
|
||||||
|
<Link href="/ai-chat" className="nav-link">AI Assistant</Link>
|
||||||
<Link href="/contact" className="nav-link">Contact Us</Link>
|
<Link href="/contact" className="nav-link">Contact Us</Link>
|
||||||
<Link href="/about" className="nav-link">About Us</Link>
|
<Link href="/about" className="nav-link">About Us</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user