contact form with email

This commit is contained in:
2026-04-15 00:46:32 -06:00
parent 2e13c0cea0
commit a3eff2e738
4 changed files with 130 additions and 0 deletions

View File

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

View File

@@ -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) {
if (customer == null || customer.getEmail() == null || customer.getEmail().isBlank()) return;
String subject = "Your PetShop support transcript";

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { useAuth } from "@/context/AuthContext";
function getStoreImage(store) {
if (store.imageUrl) return store.imageUrl;
@@ -12,10 +13,17 @@ function getStoreImage(store) {
}
export default function ContactPage() {
const { token } = useAuth();
const [locations, setLocations] = useState([]);
const [loading, setLoading] = useState(true);
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(() => {
const params = new URLSearchParams({ page: "0", size: "100", sort: "storeName,asc" });
fetch(`/api/v1/stores?${params}`)
@@ -28,6 +36,27 @@ export default function ContactPage() {
.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 (
<main className="info-page">
<section className="info-hero">
@@ -44,6 +73,44 @@ export default function ContactPage() {
<p>Hours: MonSat, 9:00 AM 6:00 PM</p>
</div>
{token && (
<div className="info-card">
<h2>Send Us a Message</h2>
{sendSuccess ? (
<p className="contact-success">Your message has been sent. We&apos;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">
<h2>Store Locations</h2>

View File

@@ -2921,3 +2921,13 @@ html, body {
img, video, iframe {
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; }