Web and AI chat

This commit is contained in:
augmentedpotato
2026-04-10 07:34:53 -06:00
committed by Harkamal Randhawa
parent b9635cae68
commit a50fa82a50
12 changed files with 1691 additions and 7 deletions

View File

@@ -28,6 +28,8 @@ services:
JWT_SECRET: change_me_please_this_secret_key_is_long_enough_for_jwt_hmac_sha256
STRIPE_SECRET_KEY: ${STRIPE_SECRET_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:
- "8080:8080"
depends_on:

View File

@@ -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."));
}
}
}

View File

@@ -55,7 +55,7 @@ public class ChatController {
}
@PostMapping("/conversations")
@PreAuthorize("hasRole('CUSTOMER')")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<ConversationResponse> createConversation(@Valid @RequestBody ConversationRequest request) {
User user = getCurrentUser();
ConversationResponse response = chatService.createConversation(user.getId(), request);
@@ -139,7 +139,7 @@ public class ChatController {
}
@PostMapping("/conversations/{id}/request-human")
@PreAuthorize("hasRole('CUSTOMER')")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<ConversationResponse> requestHumanTakeover(@PathVariable Long id) {
User user = getCurrentUser();
ConversationResponse conversation = chatService.requestHumanTakeover(id, user.getId(), user.getRole());

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -48,10 +48,6 @@ public class ChatService {
User user = userRepository.findById(userId)
.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.setCustomerId(userId);
conversation.setStatus(Conversation.ConversationStatus.OPEN);
@@ -210,7 +206,7 @@ public class ChatService {
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");
}

View File

@@ -0,0 +1,144 @@
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)
.header("HTTP-Referer", "https://petshop.local")
.header("X-Title", "Leon's Pet Store AI Assistant")
.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 (RuntimeException e) {
throw 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);
}
}

View File

@@ -2,6 +2,9 @@ spring:
application:
name: petshop-backend
config:
import: optional:file:.env[.properties]
servlet:
multipart:
enabled: true
@@ -53,6 +56,10 @@ jwt:
stripe:
secret-key: ${STRIPE_SECRET_KEY:}
openrouter:
api-key: ${OPENROUTER_API_KEY:}
model: ${OPENROUTER_MODEL:openai/gpt-oss-120b:free}
logging:
level:
com.petshop: ${LOG_LEVEL:INFO}

562
web/app/ai-chat/page.js Normal file
View 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
View 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 });

View File

@@ -2367,3 +2367,6 @@ body {
max-width: 400px;
margin: 0;
}
@keyframes bounce { 0%, 80%, 100% { transform: translateY(0); } 40% { transform: translateY(-6px); } }
@keyframes spin { to { transform: rotate(360deg); } }

View File

@@ -44,6 +44,7 @@ export default function DisplayNav() {
<Link href="/adopt" className="nav-link">Adopt a Pet</Link>
<Link href="/products" className="nav-link">Online Store</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="/about" className="nav-link">About Us</Link>
</div>