feat(contact us): Send email function

Send email to admin and get the confirmation message from admin
This commit is contained in:
vergiLgood1 2025-02-19 02:08:45 +07:00
parent 8137c58cd8
commit 55f295cee3
8 changed files with 767 additions and 31 deletions

View File

@ -0,0 +1,123 @@
"use server";
import { Resend } from "resend";
import { TValidator } from "@/utils/validator";
import AdminNotification from "@/components/email-templates/admin-notification";
import { render } from "@react-email/components";
import UserConfirmation from "@/components/email-templates/user-confirmation";
import { createClient } from "@/utils/supabase/server";
import { useResend } from "@/hooks/use-resend";
const typeMessageMap: Record<string, string> = {
"1": "Request to become a user",
"2": "OTP problem",
"3": "Request for a feature",
"4": "Other",
};
export async function sendContactEmail(formData: {
name: string;
email: string;
phone: string;
typeMessage: string;
message: string;
}) {
try {
// Initialize Supabase
const supabase = await createClient();
const { resend } = useResend();
// Validate form data
const validation = TValidator.validateContactForm(formData);
if (!validation.success) {
return {
success: false,
errors: validation.errors,
};
}
// Get message type label
const messageTypeLabel = typeMessageMap[formData.typeMessage] || "Unknown";
// Save to Supabase
const { data: contactData, error: contactError } = await supabase
.from("contact_messages")
.insert([
{
name: formData.name,
email: formData.email,
phone: formData.phone,
message_type: formData.typeMessage,
message_type_label: messageTypeLabel,
message: formData.message,
status: "new",
},
])
.select();
if (contactError) {
console.error("Error saving contact message to Supabase:", contactError);
return {
success: false,
error: "Failed to save your message. Please try again later.",
};
}
// Render admin email template
const adminEmailHtml = await render(
AdminNotification({
name: formData.name,
email: formData.email,
phone: formData.phone,
messageType: messageTypeLabel,
message: formData.message,
})
);
// Send email to admin
const { data: emailData, error: emailError } = await resend.emails.send({
from: "Contact Form <noreply@backspacex.tech>",
to: ["xdamazon17@gmail.com"],
subject: `New Contact Form Submission: ${messageTypeLabel}`,
html: adminEmailHtml,
});
if (emailError) {
console.error("Error sending email via Resend:", emailError);
// Note: We don't return error here since the data is already saved to Supabase
}
const userEmailHtml = await render(
UserConfirmation({
name: formData.name,
messageType: messageTypeLabel,
message: formData.message,
})
);
// Send confirmation email to user
const { data: confirmationData, error: confirmationError } =
await resend.emails.send({
from: "Your Company <noreply@backspacex.tech>",
to: [formData.email],
subject: "Thank you for contacting us",
html: userEmailHtml,
});
if (confirmationError) {
console.error("Error sending confirmation email:", confirmationError);
// Note: We don't return error here either
}
return {
success: true,
message: "Your message has been sent successfully!",
};
} catch (error) {
console.error("Unexpected error in sendContactEmail:", error);
return {
success: false,
error: "An unexpected error occurred. Please try again later.",
};
}
}

View File

@ -1,6 +1,8 @@
import * as React from "react";
"use client";
import * as React from "react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
@ -21,6 +23,9 @@ import {
import { Textarea } from "../ui/textarea";
import { SubmitButton } from "../submit-button";
import Link from "next/link";
import { toast } from "@/hooks/use-toast";
import { sendContactEmail } from "@/actions/auth/contact-us";
import { TValidator } from "@/utils/validator";
export function ContactUsForm() {
const typeMessage = [
@ -30,6 +35,112 @@ export function ContactUsForm() {
{ value: "4", label: "Other" },
];
// State untuk form data
const [formData, setFormData] = useState({
name: "",
email: "",
phone: "",
typeMessage: "",
message: "",
});
// State untuk error messages
const [errors, setErrors] = useState<Record<string, string>>({});
// Loading state
const [isSubmitting, setIsSubmitting] = useState(false);
// Handle input change
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { id, value } = e.target;
setFormData((prev) => ({ ...prev, [id]: value }));
// Clear error when typing
if (errors[id]) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[id];
return newErrors;
});
}
};
// Handle select change
const handleSelectChange = (value: string) => {
setFormData((prev) => ({ ...prev, typeMessage: value }));
// Clear error when selecting
if (errors.typeMessage) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors.typeMessage;
return newErrors;
});
}
};
// Handle form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Client-side validation
const validation = TValidator.validateContactForm(formData);
if (!validation.success) {
setErrors(validation.errors);
return;
}
try {
setIsSubmitting(true);
// Call server action to send email
const result = await sendContactEmail(formData);
if (!result.success) {
// Handle server-side validation errors
if (result.errors) {
setErrors(result.errors);
} else {
toast({
title: "Error",
description:
result.error || "Failed to send message. Please try again.",
variant: "destructive",
});
}
return;
}
// Success!
toast({
title: "Success",
description:
result.message || "Your message has been sent successfully!",
});
// Reset form and errors after successful submission
setFormData({
name: "",
email: "",
phone: "",
typeMessage: "",
message: "",
});
setErrors({});
} catch (error) {
console.error("Error submitting form:", error);
toast({
title: "Error",
description: "An unexpected error occurred. Please try again later.",
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
return (
<Card className="w-[500px] bg-[#171717] border-none text-white">
<CardHeader>
@ -39,7 +150,7 @@ export function ContactUsForm() {
</CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-4">
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="name" className="text-sm text-gray-300">
Name
@ -47,8 +158,16 @@ export function ContactUsForm() {
<Input
id="name"
placeholder="John doe"
className="bg-[#1C1C1C] border-gray-800 text-white placeholder:text-gray-500 focus:border-emerald-600 focus:ring-emerald-600"
className={`bg-[#1C1C1C] border-gray-800 text-white placeholder:text-gray-500 focus:border-emerald-600 focus:ring-emerald-600 ${
errors.name ? "border-red-500" : ""
}`}
value={formData.name}
onChange={handleChange}
disabled={isSubmitting}
/>
{errors.name && (
<p className="text-red-500 text-xs mt-1">{errors.name}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email" className="text-sm text-gray-300">
@ -57,8 +176,16 @@ export function ContactUsForm() {
<Input
id="email"
placeholder="example@gmail.com"
className="bg-[#1C1C1C] border-gray-800 text-white placeholder:text-gray-500 focus:border-emerald-600 focus:ring-emerald-600"
className={`bg-[#1C1C1C] border-gray-800 text-white placeholder:text-gray-500 focus:border-emerald-600 focus:ring-emerald-600 ${
errors.email ? "border-red-500" : ""
}`}
value={formData.email}
onChange={handleChange}
disabled={isSubmitting}
/>
{errors.email && (
<p className="text-red-500 text-xs mt-1">{errors.email}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone" className="text-sm text-gray-300">
@ -66,18 +193,32 @@ export function ContactUsForm() {
</Label>
<Input
id="phone"
placeholder="085255xxx"
className="bg-[#1C1C1C] border-gray-800 text-white placeholder:text-gray-500 focus:border-emerald-600 focus:ring-emerald-600"
placeholder="08123456789"
className={`bg-[#1C1C1C] border-gray-800 text-white placeholder:text-gray-500 focus:border-emerald-600 focus:ring-emerald-600 ${
errors.phone ? "border-red-500" : ""
}`}
value={formData.phone}
onChange={handleChange}
disabled={isSubmitting}
/>
{errors.phone && (
<p className="text-red-500 text-xs mt-1">{errors.phone}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="typemessage" className="text-sm text-gray-300">
Type message
</Label>
<Select>
<Select
value={formData.typeMessage}
onValueChange={handleSelectChange}
disabled={isSubmitting}
>
<SelectTrigger
id="typemessage"
className="bg-[#1C1C1C] border-gray-800 text-white focus:border-emerald-600 focus:ring-emerald-600"
className={`bg-[#1C1C1C] border-gray-800 text-white focus:border-emerald-600 focus:ring-emerald-600 ${
errors.typeMessage ? "border-red-500" : ""
}`}
>
<SelectValue placeholder="Select" />
</SelectTrigger>
@ -93,6 +234,9 @@ export function ContactUsForm() {
))}
</SelectContent>
</Select>
{errors.typeMessage && (
<p className="text-red-500 text-xs mt-1">{errors.typeMessage}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="message" className="text-sm text-gray-300">
@ -100,25 +244,38 @@ export function ContactUsForm() {
</Label>
<Textarea
id="message"
name="message"
placeholder="Your message here..."
className="resize-none h-24 bg-[#1C1C1C] border-gray-800 text-white placeholder:text-gray-500 focus:border-emerald-600 focus:ring-emerald-600"
required
className={`resize-none h-24 bg-[#1C1C1C] border-gray-800 text-white placeholder:text-gray-500 focus:border-emerald-600 focus:ring-emerald-600 ${
errors.message ? "border-red-500" : ""
}`}
value={formData.message}
onChange={handleChange}
disabled={isSubmitting}
/>
{errors.message && (
<p className="text-red-500 text-xs mt-1">{errors.message}</p>
)}
</div>
<CardFooter className="flex flex-col items-center space-y-4 px-0">
<SubmitButton
type="submit"
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white"
disabled={isSubmitting}
>
{isSubmitting ? "Sending..." : "Send"}
</SubmitButton>
<div className="text-center text-lg space-x-2">
<span className="text-gray-400">Already have an account?</span>
<Link
href="/sign-in"
className="text-white hover:text-emerald-500"
>
Login
</Link>
</div>
</CardFooter>
</form>
</CardContent>
<CardFooter className="flex flex-col items-center space-y-4">
<SubmitButton className="w-full bg-emerald-600 hover:bg-emerald-700 text-white">
Send
</SubmitButton>
<div className="text-center text-lg space-x-2">
<span className="text-gray-400">Already have an account?</span>
<Link href="/sign-in" className="text-white hover:text-emerald-500">
Login
</Link>
</div>
</CardFooter>
</Card>
);
}

View File

@ -0,0 +1,137 @@
import {
Body,
Container,
Head,
Heading,
Hr,
Html,
Preview,
Section,
Text,
} from "@react-email/components";
import * as React from "react";
interface AdminNotificationProps {
name: string;
email: string;
phone: string;
messageType: string;
message: string;
}
export const AdminNotification = ({
name,
email,
phone,
messageType,
message,
}: AdminNotificationProps) => {
return (
<Html>
<Head />
<Preview>New Contact Form Submission: {messageType}</Preview>
<Body style={main}>
<Container style={container}>
<Heading style={h1}>New Contact Form Submission</Heading>
<Section style={section}>
<Text style={detailLabel}>Name:</Text>
<Text style={detailValue}>{name}</Text>
<Text style={detailLabel}>Email:</Text>
<Text style={detailValue}>{email}</Text>
<Text style={detailLabel}>Phone:</Text>
<Text style={detailValue}>{phone}</Text>
<Text style={detailLabel}>Type:</Text>
<Text style={detailValue}>{messageType}</Text>
</Section>
<Section style={section}>
<Heading as="h2" style={h2}>
Message:
</Heading>
<Text style={messageBox}>{message}</Text>
</Section>
<Hr style={hr} />
<Text style={footer}>
This message was submitted through the contact form.
</Text>
</Container>
</Body>
</Html>
);
};
const main = {
backgroundColor: "#1C1C1C",
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
};
const container = {
backgroundColor: "#2E2E2E",
margin: "0 auto",
padding: "20px 0 48px",
marginBottom: "64px",
borderRadius: "8px",
};
const section = {
padding: "0 48px",
};
const h1 = {
color: "#10B981", // Emerald-500
fontSize: "24px",
fontWeight: "bold",
margin: "40px 0",
padding: "0",
textAlign: "center" as const,
};
const h2 = {
color: "#10B981", // Emerald-500
fontSize: "20px",
fontWeight: "bold",
margin: "24px 0",
padding: "0",
};
const detailLabel = {
color: "#A1A1AA", // Zinc-400
fontSize: "14px",
margin: "8px 0 4px",
};
const detailValue = {
color: "#FFFFFF",
fontSize: "14px",
margin: "0 0 16px",
};
const messageBox = {
backgroundColor: "#3F3F46", // Zinc-700
padding: "16px",
borderRadius: "4px",
color: "#FFFFFF",
fontSize: "14px",
lineHeight: "24px",
margin: "0",
};
const hr = {
borderColor: "#3F3F46", // Zinc-700
margin: "20px 0",
};
const footer = {
color: "#71717A", // Zinc-500
fontSize: "12px",
marginTop: "12px",
textAlign: "center" as const,
};
export default AdminNotification;

View File

@ -0,0 +1,126 @@
import {
Body,
Container,
Head,
Heading,
Hr,
Html,
Preview,
Section,
Text,
} from "@react-email/components";
import * as React from "react";
interface UserConfirmationProps {
name: string;
messageType: string;
message: string;
}
export const UserConfirmation = ({
name,
messageType,
message,
}: UserConfirmationProps) => {
return (
<Html>
<Head />
<Preview>Thank you for contacting us</Preview>
<Body style={main}>
<Container style={container}>
<Heading style={h1}>Thank You for Contacting Us</Heading>
<Section style={section}>
<Text style={text}>Dear {name},</Text>
<Text style={text}>
We have received your message regarding "{messageType}" and will
get back to you as soon as possible.
</Text>
<Text style={messageLabel}>Here's a copy of your message:</Text>
<Text style={messageBox}>{message}</Text>
<Text style={text}>
If you have any further questions, please don't hesitate to
contact us.
</Text>
</Section>
<Hr style={hr} />
<Text style={signature}>
Best regards,
<br />
The Support Team
</Text>
</Container>
</Body>
</Html>
);
};
const main = {
backgroundColor: "#1C1C1C",
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
};
const container = {
backgroundColor: "#2E2E2E",
margin: "0 auto",
padding: "20px 0 48px",
marginBottom: "64px",
borderRadius: "8px",
};
const section = {
padding: "0 48px",
};
const h1 = {
color: "#10B981", // Emerald-500
fontSize: "24px",
fontWeight: "bold",
margin: "40px 0",
padding: "0",
textAlign: "center" as const,
};
const text = {
color: "#FFFFFF",
fontSize: "14px",
lineHeight: "24px",
margin: "16px 0",
};
const messageLabel = {
color: "#A1A1AA", // Zinc-400
fontSize: "14px",
margin: "24px 0 8px",
};
const messageBox = {
backgroundColor: "#3F3F46", // Zinc-700
padding: "16px",
borderRadius: "4px",
color: "#FFFFFF",
fontSize: "14px",
lineHeight: "24px",
margin: "0 0 24px",
};
const hr = {
borderColor: "#3F3F46", // Zinc-700
margin: "20px 0",
};
const signature = {
color: "#FFFFFF",
fontSize: "14px",
lineHeight: "24px",
margin: "16px 0",
textAlign: "center" as const,
};
export default UserConfirmation;

View File

@ -0,0 +1,9 @@
import { Resend } from "resend";
export const useResend = () => {
const resend = new Resend(process.env.RESEND_API_KEY);
return {
resend,
};
};

View File

@ -0,0 +1,18 @@
-- CreateEnum
CREATE TYPE "status_contact_messages" AS ENUM ('new', 'read', 'replied', 'resolved');
-- CreateTable
CREATE TABLE "contact_messages" (
"id" TEXT NOT NULL,
"name" TEXT,
"email" TEXT,
"phone" TEXT,
"message_type" TEXT,
"message_type_label" TEXT,
"message" TEXT,
"status" "status_contact_messages" NOT NULL DEFAULT 'new',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "contact_messages_pkey" PRIMARY KEY ("id")
);

View File

@ -14,14 +14,6 @@ datasource db {
directUrl = env("DIRECT_URL")
}
enum Role {
admin
staff
user
@@map("roles")
}
model User {
id String @id
email String @unique
@ -55,3 +47,35 @@ model Profile {
@@map("profiles") // Maps to Supabase's 'profiles' table
}
model ContactMessages {
id String @id @default(dbgenerated("gen_random_uuid()"))
name String?
email String?
phone String?
message_type String?
message_type_label String?
message String?
status StatusContactMessages @default(new)
createdAt DateTime @default(dbgenerated("now()")) @db.Timestamptz(6)
updatedAt DateTime @default(dbgenerated("now()")) @updatedAt @db.Timestamptz(6)
@@map("contact_messages") // Maps to Supabase's 'contact_messages' table
}
enum Role {
admin
staff
user
@@map("roles")
}
enum StatusContactMessages {
new
read
replied
resolved
@@map("status_contact_messages")
}

View File

@ -0,0 +1,142 @@
import { z } from "zod";
export class TValidator {
/**
* Validate an empty value
* @param {string} value - The value to validate
* @param {string} fieldName - The name of the field for error message
* @returns {Object} - Validation result object with success and optional error message
*/
static validateEmptyValue(value: string, fieldName: string) {
const schema = z.string().min(1, `${fieldName} cannot be empty`);
const result = schema.safeParse(value);
if (!result.success) {
return {
success: false,
error: `${fieldName} cannot be empty`,
};
}
return {
success: true,
};
}
/**
* Validate an email address
* @param {string} email - The email address to validate
* @returns {Object} - Validation result object with success and optional error message
*/
static validateEmail(email: string) {
const emptyCheck = this.validateEmptyValue(email, "Email");
if (!emptyCheck.success) return emptyCheck;
const schema = z.string().email("Invalid email address");
const result = schema.safeParse(email);
if (!result.success) {
return {
success: false,
error: "Invalid email address",
};
}
return {
success: true,
};
}
/**
* Validate a phone number for Indonesia
* @param {string} phone - The phone number to validate
* @returns {Object} - Validation result object with success and optional error message
*/
static validatePhone(phone: string) {
const emptyCheck = this.validateEmptyValue(phone, "Phone");
if (!emptyCheck.success) return emptyCheck;
// Regex for Indonesian phone numbers:
// - Allows format starting with +62 or 0
// - For +62 format: +62 followed by 8-12 digits
// - For 0 format: 0 followed by 9-12 digits (usually starts with 08)
// - Handles common mobile prefixes (8xx) and landline prefixes
const schema = z
.string()
.regex(
/^(?:\+62[0-9]{8,12}|0[0-9]{9,12})$/,
"Invalid Indonesian phone number format"
);
const result = schema.safeParse(phone);
if (!result.success) {
return {
success: false,
error:
"Invalid Indonesian phone number format. Use format +62xxxxxxxxxx or 08xxxxxxxxx",
};
}
return {
success: true,
};
}
/**
* Validate the contact form
* @param {Object} formData - The form data to validate
* @returns {Object} - Validation result with success flag and errors object
*/
static validateContactForm(formData: {
name: string;
email: string;
phone: string;
typeMessage: string;
message: string;
}) {
const errors: Record<string, string> = {};
let isValid = true;
// Validate name
const nameResult = this.validateEmptyValue(formData.name, "Name");
if (!nameResult.success) {
errors.name = nameResult.error!;
isValid = false;
}
// Validate email
const emailResult = this.validateEmail(formData.email);
if (!emailResult.success) {
errors.email = emailResult.error!;
isValid = false;
}
// Validate phone
const phoneResult = this.validatePhone(formData.phone);
if (!phoneResult.success) {
errors.phone = phoneResult.error!;
isValid = false;
}
// Validate type message
const typeMessageResult = this.validateEmptyValue(
formData.typeMessage,
"Type message"
);
if (!typeMessageResult.success) {
errors.typeMessage = typeMessageResult.error!;
isValid = false;
}
// Validate message
const messageResult = this.validateEmptyValue(formData.message, "Message");
if (!messageResult.success) {
errors.message = messageResult.error!;
isValid = false;
}
return {
success: isValid,
errors: isValid ? {} : errors,
};
}
}