@@ -0,0 +1,40 @@
|
|||||||
|
package com.petshop.backend.controller;
|
||||||
|
|
||||||
|
import com.petshop.backend.entity.User;
|
||||||
|
import com.petshop.backend.repository.UserRepository;
|
||||||
|
import com.petshop.backend.service.EmailService;
|
||||||
|
import com.petshop.backend.util.AuthenticationHelper;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/contact")
|
||||||
|
public class ContactController {
|
||||||
|
|
||||||
|
private final EmailService emailService;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public ContactController(EmailService emailService, UserRepository userRepository) {
|
||||||
|
this.emailService = emailService;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ContactRequest(
|
||||||
|
@NotBlank @Size(max = 150) String subject,
|
||||||
|
@NotBlank @Size(max = 2000) String body
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<Void> sendContactEmail(@Valid @RequestBody ContactRequest req) {
|
||||||
|
Long userId = AuthenticationHelper.getAuthenticatedUserId();
|
||||||
|
User user = userRepository.findById(userId).orElseThrow();
|
||||||
|
emailService.sendContactMessage(user, req.subject(), req.body());
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -129,6 +129,19 @@ public class EmailService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void sendContactMessage(User user, String subject, String body) {
|
||||||
|
if (user.getEmail() == null || user.getEmail().isBlank()) return;
|
||||||
|
String html = """
|
||||||
|
<div style="font-family:sans-serif;max-width:600px;margin:auto">
|
||||||
|
<h2>Contact form message</h2>
|
||||||
|
<p><strong>From:</strong> %s (%s)</p>
|
||||||
|
<p><strong>Subject:</strong> %s</p>
|
||||||
|
<hr/>
|
||||||
|
<p style="white-space:pre-wrap">%s</p>
|
||||||
|
</div>""".formatted(esc(firstName(user)), esc(user.getEmail()), esc(subject), esc(body));
|
||||||
|
send(user.getId(), user.getEmail(), "Contact: " + subject, html);
|
||||||
|
}
|
||||||
|
|
||||||
public void sendChatTranscript(Conversation conversation, List<Message> messages, User customer) {
|
public void sendChatTranscript(Conversation conversation, List<Message> messages, User customer) {
|
||||||
if (customer == null || customer.getEmail() == null || customer.getEmail().isBlank()) return;
|
if (customer == null || customer.getEmail() == null || customer.getEmail().isBlank()) return;
|
||||||
String subject = "Your PetShop support transcript";
|
String subject = "Your PetShop support transcript";
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
|
||||||
@@ -339,6 +340,7 @@ function AppointmentsPage() {
|
|||||||
const adoptionStoreName = searchParams.get("storeName") || "";
|
const adoptionStoreName = searchParams.get("storeName") || "";
|
||||||
|
|
||||||
const didPreselectRef = useRef(false);
|
const didPreselectRef = useRef(false);
|
||||||
|
const errorRef = useRef(null);
|
||||||
|
|
||||||
// Adoption-mode URL verification
|
// Adoption-mode URL verification
|
||||||
const [adoptionVerified, setAdoptionVerified] = useState(!adoptionMode);
|
const [adoptionVerified, setAdoptionVerified] = useState(!adoptionMode);
|
||||||
@@ -364,6 +366,12 @@ function AppointmentsPage() {
|
|||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [success, setSuccess] = useState(null);
|
const [success, setSuccess] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (error && errorRef.current) {
|
||||||
|
errorRef.current.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
const [appointments, setAppointments] = useState([]);
|
const [appointments, setAppointments] = useState([]);
|
||||||
const [loadingAppointments, setLoadingAppointments] = useState(false);
|
const [loadingAppointments, setLoadingAppointments] = useState(false);
|
||||||
|
|
||||||
@@ -425,12 +433,12 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
|||||||
})
|
})
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then(setStores)
|
.then(setStores)
|
||||||
.catch(() => {});
|
.catch(() => setError("Failed to load stores."));
|
||||||
|
|
||||||
fetch(`${API_BASE}/api/v1/services?size=100`)
|
fetch(`${API_BASE}/api/v1/services?size=100`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data) => setServices(data.content ?? []))
|
.then((data) => setServices(data.content ?? []))
|
||||||
.catch(() => {});
|
.catch(() => setError("Failed to load services."));
|
||||||
|
|
||||||
fetch(`${API_BASE}/api/v1/pets?size=200&sort=id,asc&status=Available`)
|
fetch(`${API_BASE}/api/v1/pets?size=200&sort=id,asc&status=Available`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@@ -779,7 +787,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
|||||||
<form className="appt-form" onSubmit={handleSubmit}>
|
<form className="appt-form" onSubmit={handleSubmit}>
|
||||||
<h2 className="appt-form-title">{adoptionMode ? "New Adoption" : "New Appointment"}</h2>
|
<h2 className="appt-form-title">{adoptionMode ? "New Adoption" : "New Appointment"}</h2>
|
||||||
|
|
||||||
{error && <div className="appt-error">{error}</div>}
|
{error && <div className="appt-error" ref={errorRef}>{error}</div>}
|
||||||
|
|
||||||
{adoptionMode && adoptionVerifyLoading && (
|
{adoptionMode && adoptionVerifyLoading && (
|
||||||
<p className="appt-loading">Verifying pet details…</p>
|
<p className="appt-loading">Verifying pet details…</p>
|
||||||
@@ -805,7 +813,10 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
|||||||
<div className="appt-label">
|
<div className="appt-label">
|
||||||
<span>Select Your Pet</span>
|
<span>Select Your Pet</span>
|
||||||
{eligiblePets.length === 0 ? (
|
{eligiblePets.length === 0 ? (
|
||||||
<p className="appt-no-slots">You have no adopted pets available for appointments.</p>
|
<p className="appt-no-slots">
|
||||||
|
You have no adopted pets available.{" "}
|
||||||
|
<Link href="/profile">Add a pet on your profile page.</Link>
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="appt-pets-grid">
|
<div className="appt-pets-grid">
|
||||||
{eligiblePets.map((p) => (
|
{eligiblePets.map((p) => (
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ function PaymentForm({ clientSecret, totalAmount, onSuccess, onCancel }) {
|
|||||||
try {
|
try {
|
||||||
await apiCompleteCheckout(token, paymentIntentId);
|
await apiCompleteCheckout(token, paymentIntentId);
|
||||||
} catch {
|
} catch {
|
||||||
|
setPayError("Order confirmation failed. Please contact support.");
|
||||||
|
setPaying(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
onSuccess();
|
onSuccess();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ function ChatPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
catch {
|
catch {
|
||||||
//Silent fail
|
setError("Failed to load messages.");
|
||||||
}
|
}
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ function ChatPage() {
|
|||||||
if (open) convId = open.id;
|
if (open) convId = open.id;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
//
|
setError("Failed to load conversations.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +265,7 @@ function ChatPage() {
|
|||||||
const isHuman = conversation?.mode === "HUMAN";
|
const isHuman = conversation?.mode === "HUMAN";
|
||||||
const hasStaff = !!conversation?.staffId;
|
const hasStaff = !!conversation?.staffId;
|
||||||
const isClosed = conversation?.status === "CLOSED";
|
const isClosed = conversation?.status === "CLOSED";
|
||||||
|
const hasStaffMessage = messages.some((m) => m.senderId !== user?.id);
|
||||||
|
|
||||||
const staffStatusLabel = isClosed
|
const staffStatusLabel = isClosed
|
||||||
? "Conversation closed"
|
? "Conversation closed"
|
||||||
@@ -322,7 +323,7 @@ function ChatPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!hasStaff && !isClosed && (
|
{!hasStaff && !hasStaffMessage && !isClosed && (
|
||||||
<div style={s.waitingBanner}>
|
<div style={s.waitingBanner}>
|
||||||
<span style={s.waitingSpinner} />
|
<span style={s.waitingSpinner} />
|
||||||
A support agent will be with you shortly. You can send messages while you wait.
|
A support agent will be with you shortly. You can send messages while you wait.
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
|
||||||
|
function getStoreImage(store) {
|
||||||
|
if (store.imageUrl) return store.imageUrl;
|
||||||
|
const name = store.storeName?.toLowerCase() ?? "";
|
||||||
|
if (name.includes("downtown")) return "/stores/downtown.webp";
|
||||||
|
if (name.includes("north")) return "/stores/north.webp";
|
||||||
|
if (name.includes("west")) return "/stores/west.webp";
|
||||||
|
return "/images/pet-placeholder.png";
|
||||||
|
}
|
||||||
|
|
||||||
export default function ContactPage() {
|
export default function ContactPage() {
|
||||||
|
const { token } = useAuth();
|
||||||
const [locations, setLocations] = useState([]);
|
const [locations, setLocations] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const [subject, setSubject] = useState("");
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [sendError, setSendError] = useState(null);
|
||||||
|
const [sendSuccess, setSendSuccess] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams({ page: "0", size: "100", sort: "storeName,asc" });
|
const params = new URLSearchParams({ page: "0", size: "100", sort: "storeName,asc" });
|
||||||
fetch(`/api/v1/stores?${params}`)
|
fetch(`/api/v1/stores?${params}`)
|
||||||
@@ -19,6 +36,27 @@ export default function ContactPage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
async function handleSend(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSending(true);
|
||||||
|
setSendError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v1/contact", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ subject, body }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
setSendSuccess(true);
|
||||||
|
setSubject("");
|
||||||
|
setBody("");
|
||||||
|
} catch (err) {
|
||||||
|
setSendError("Failed to send message. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="info-page">
|
<main className="info-page">
|
||||||
<section className="info-hero">
|
<section className="info-hero">
|
||||||
@@ -30,11 +68,49 @@ export default function ContactPage() {
|
|||||||
<section className="info-content">
|
<section className="info-content">
|
||||||
<div className="info-card">
|
<div className="info-card">
|
||||||
<h2>General Contact</h2>
|
<h2>General Contact</h2>
|
||||||
<p>Email: support@petshop.com</p>
|
<p>Email: hello@leonspetstore.com.au</p>
|
||||||
<p>Phone: (000) 000-0000</p>
|
<p>Phone: (03) 9000 0000</p>
|
||||||
<p>Hours: Mon–Sat, 9:00 AM – 6:00 PM</p>
|
<p>Hours: Mon–Sat, 9:00 AM – 6:00 PM</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{token && (
|
||||||
|
<div className="info-card">
|
||||||
|
<h2>Send Us a Message</h2>
|
||||||
|
{sendSuccess ? (
|
||||||
|
<p className="contact-success">Your message has been sent. We'll be in touch soon.</p>
|
||||||
|
) : (
|
||||||
|
<form className="contact-form" onSubmit={handleSend}>
|
||||||
|
<label className="contact-label">
|
||||||
|
Subject
|
||||||
|
<input
|
||||||
|
className="contact-input"
|
||||||
|
type="text"
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
|
required
|
||||||
|
maxLength={150}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="contact-label">
|
||||||
|
Message
|
||||||
|
<textarea
|
||||||
|
className="contact-textarea"
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
required
|
||||||
|
maxLength={2000}
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{sendError && <p className="contact-error">{sendError}</p>}
|
||||||
|
<button className="contact-submit-btn" type="submit" disabled={sending}>
|
||||||
|
{sending ? "Sending…" : "Send Message"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="info-card">
|
<div className="info-card">
|
||||||
<h2>Store Locations</h2>
|
<h2>Store Locations</h2>
|
||||||
|
|
||||||
@@ -52,7 +128,7 @@ export default function ContactPage() {
|
|||||||
<article key={location.storeId} className="info-mini-card location-card">
|
<article key={location.storeId} className="info-mini-card location-card">
|
||||||
<div className="location-card-image-wrapper">
|
<div className="location-card-image-wrapper">
|
||||||
<img
|
<img
|
||||||
src={location.imageUrl || "/images/pet-placeholder.png"}
|
src={getStoreImage(location)}
|
||||||
alt={location.storeName}
|
alt={location.storeName}
|
||||||
className="location-card-image"
|
className="location-card-image"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
|
|||||||
@@ -12,12 +12,6 @@
|
|||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--background: #0a0a0a;
|
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
@@ -2927,3 +2921,13 @@ html, body {
|
|||||||
img, video, iframe {
|
img, video, iframe {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contact-form { display: flex; flex-direction: column; gap: 1rem; }
|
||||||
|
.contact-label { display: flex; flex-direction: column; gap: 0.4rem; font-weight: 500; color: #333; font-size: 0.95rem; }
|
||||||
|
.contact-input, .contact-textarea { border: 1px solid #ddd; border-radius: 8px; padding: 0.6rem 0.8rem; font-size: 0.95rem; font-family: inherit; resize: vertical; }
|
||||||
|
.contact-input:focus, .contact-textarea:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 2px rgba(37,99,235,0.15); }
|
||||||
|
.contact-submit-btn { align-self: flex-start; background: #2563eb; color: #fff; border: none; border-radius: 8px; padding: 0.65rem 1.4rem; font-size: 0.95rem; cursor: pointer; }
|
||||||
|
.contact-submit-btn:hover:not(:disabled) { background: #1d4ed8; }
|
||||||
|
.contact-submit-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
.contact-error { color: #c0392b; font-size: 0.9rem; }
|
||||||
|
.contact-success { color: #166534; background: #dcfce7; border: 1px solid #bbf7d0; border-radius: 8px; padding: 0.75rem 1rem; }
|
||||||
|
|||||||
@@ -135,6 +135,8 @@ function RegisterPage() {
|
|||||||
value={form.phone}
|
value={form.phone}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
|
pattern="[0-9\-\+\(\) ]{7,15}"
|
||||||
|
title="Enter a valid phone number"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -161,6 +163,7 @@ function RegisterPage() {
|
|||||||
value={form.confirmPassword}
|
value={form.confirmPassword}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
|
minLength={6}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -15,14 +15,11 @@ export default function DisplayNav() {
|
|||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return;
|
fetch("/api/v1/stores?size=100")
|
||||||
fetch("/api/v1/stores?size=100", {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.then((data) => { if (data) setStores(data.content ?? []); })
|
.then((data) => { if (data) setStores(data.content ?? []); })
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [token]);
|
}, []);
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
logout();
|
logout();
|
||||||
|
|||||||
Reference in New Issue
Block a user