diff --git a/actions/contact.ts b/actions/contact.ts new file mode 100644 index 0000000..2bea664 --- /dev/null +++ b/actions/contact.ts @@ -0,0 +1,148 @@ +"use server"; + +import { z } from "zod"; +import nodemailer from "nodemailer"; +import { GoogleGenerativeAI } from "@google/generative-ai"; + +// Define the schema for contact form validation +const ContactSchema = z.object({ + name: z.string().min(2, "Name must be at least 2 characters"), + email: z.string().email("Invalid email address"), + subject: z.string().min(5, "Subject must be at least 5 characters"), + message: z.string().min(10, "Message must be at least 10 characters"), +}); + +// Define the state structure for the form action +export type ContactFormState = { + errors?: { + name?: string[]; + email?: string[]; + subject?: string[]; + message?: string[]; + _form?: string[]; // General form error + }; + message?: string | null; // Success or general error message + success?: boolean; // Flag for successful submission +}; + +// --- Initialize Gemini --- +const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY || ""); +const geminiModel = genAI.getGenerativeModel({ model: "gemini-2.0-flash" }); // Or other suitable model + +// --- Initialize Nodemailer Transporter --- +const transporter = nodemailer.createTransport({ + host: process.env.EMAIL_SERVER_HOST, + port: parseInt(process.env.EMAIL_SERVER_PORT || "587"), // Default to 587 + secure: parseInt(process.env.EMAIL_SERVER_PORT || "587") === 465, // true for 465, false for other ports + auth: { + user: process.env.EMAIL_SERVER_USER, + pass: process.env.EMAIL_SERVER_PASSWORD, + }, +}); + +// --- Helper function for Spam Check --- +async function isSpamOrAdvertisement(content: { + subject: string; + message: string; + email: string; +}): Promise { + if (!process.env.GEMINI_API_KEY) { + console.warn("GEMINI_API_KEY not set. Skipping spam check."); + return false; // Skip check if API key is missing + } + + const prompt = `Analyze the following email content and classify it as "spam", "advertisement", or "legitimate inquiry". Consider the subject, message body, and sender email. Provide only the classification word as the response. + +Subject: ${content.subject} +Sender Email: ${content.email} +Message: +${content.message}`; + + try { + const result = await geminiModel.generateContent(prompt); + const response = await result.response; + const text = response.text().trim().toLowerCase(); + console.log("Gemini Classification:", text); // Log classification for debugging + // Consider "spam" or "advertisement" as unwanted + return text === "spam" || text === "advertisement"; + } catch (error) { + console.error("Error checking content with Gemini:", error); + // Fail open (treat as not spam) if Gemini check fails + return false; + } +} + +// Server action to process the contact form +export async function submitContactForm( + prevState: ContactFormState, + formData: FormData +): Promise { + // Validate form data + const validatedFields = ContactSchema.safeParse({ + name: formData.get("name"), + email: formData.get("email"), + subject: formData.get("subject"), + message: formData.get("message"), + }); + + // If validation fails, return errors + if (!validatedFields.success) { + console.error( + "Contact Form Validation Errors:", + validatedFields.error.flatten().fieldErrors + ); + return { + errors: validatedFields.error.flatten().fieldErrors, + message: "Please correct the errors above.", + success: false, + }; + } + + const { name, email, subject, message } = validatedFields.data; + + // --- Spam/Advertisement Check --- + try { + const isUnwanted = await isSpamOrAdvertisement({ subject, message, email }); + if (isUnwanted) { + console.log(`Message from ${email} flagged as spam/advertisement.`); + // Return generic error as requested + return { message: "Message could not be sent.", success: false }; + } + } catch (error) { + // Log error but proceed if check fails unexpectedly + console.error("Error during spam check:", error); + } + // --- End Spam Check --- + + // --- Send Email using Nodemailer --- + const mailOptions = { + from: process.env.EMAIL_FROM, // Sender address (configured in .env) + to: process.env.EMAIL_TO, // List of receivers (configured in .env) + replyTo: email, // Set reply-to to the user's email + subject: `Website Contact: ${subject}`, // Subject line + text: `Name: ${name}\nEmail: ${email}\n\nMessage:\n${message}`, // Plain text body + html: `

Name: ${name}

+

Email: ${email}

+
+

Message:

+

${message.replace(/\n/g, "
")}

`, // HTML body + }; + + try { + await transporter.sendMail(mailOptions); + console.log("Contact email sent successfully from:", email); + return { + message: "Thank you for your message! We'll get back to you soon.", + success: true, + }; + } catch (error) { + console.error("Failed to send contact email:", error); + return { + message: + "Failed to send message due to a server error. Please try again later.", + success: false, + errors: { _form: ["Email sending failed."] }, + }; + } + // --- End Send Email --- +} diff --git a/actions/posts.ts b/actions/posts.ts index 70883e5..5a0d8cd 100644 --- a/actions/posts.ts +++ b/actions/posts.ts @@ -48,16 +48,16 @@ const PostSchema = z.object({ }); export type CreatePostState = { - errors?: { + message: string | null; + errors: { title?: string[]; slug?: string[]; content?: string[]; - image?: string[]; // Changed from imageUrl + image?: string[]; tags?: string[]; + excerpt?: string[]; _form?: string[]; }; - message?: string | null; - // Add a field to hold previous input values previousInput?: { title?: string; slug?: string; @@ -65,7 +65,6 @@ export type CreatePostState = { excerpt?: string; tags?: string; published?: boolean; - // We don't repopulate the file input for security reasons }; }; diff --git a/app/(website)/contact/page.tsx b/app/(website)/contact/page.tsx new file mode 100644 index 0000000..3469adf --- /dev/null +++ b/app/(website)/contact/page.tsx @@ -0,0 +1,151 @@ +import React from "react"; +import type { Metadata } from "next"; +import ContactForm from "@/components/ContactForm"; +import { FiMapPin, FiPhone, FiMail, FiClock } from "react-icons/fi"; // Import icons + +// SEO Metadata for the Contact page +export const metadata: Metadata = { + title: "Contact Us | Owethu Managed Services (OMS)", + description: + "Get in touch with Owethu Managed Services. Contact us for IT solutions, resource augmentation, project management, and custom software development inquiries.", + alternates: { + canonical: "/contact", + }, + openGraph: { + title: "Contact OMS", + description: "Reach out to OMS for expert IT services and solutions.", + url: "https://oms.africa/contact", // Replace with your actual domain + images: [ + { + url: "/og-image-contact.jpg", // Create a specific OG image for contact + width: 1200, + height: 630, + alt: "Contact Owethu Managed Services", + }, + ], + }, + twitter: { + card: "summary_large_image", + title: "Contact OMS", + description: "Get in touch with Owethu Managed Services.", + images: ["/og-image-contact.jpg"], + }, +}; + +// Contact Page Component +export default function ContactPage() { + return ( +
+ {/* Hero Section */} +
+
+

+ Get In Touch +

+

+ We're here to help! Whether you have a question about our + services, need assistance, or want to discuss a project, reach out + and let us know. +

+
+
+ + {/* Main Content Area (Form + Info) */} +
+
+
+ {/* Contact Information Section */} +
+

+ Contact Information +

+ +
+ +
+

+ Our Office +

+
+ Unit 10 B Centuria Park +
+ 265 Von Willich Avenue +
+ Die Hoewes, Centurion, 0159 +
+ South Africa +
+ {/* Optional: Link to Google Maps */} + + View on Google Maps + +
+
+ +
+ + +
+ +
+ + +
+ +
+ +
+

+ Business Hours +

+

+ Monday - Friday: 8:00 AM - 5:00 PM (SAST) +

+

+ Weekends & Public Holidays: Closed +

+
+
+
+ + {/* Contact Form Section */} +
+

+ Send Us a Message +

+ +
+
+
+
+ + {/* Optional: Map Section Placeholder */} + {/*
+
+

[Embedded Map Placeholder - e.g., Google Maps iframe]

+
+
*/} +
+ ); +} diff --git a/components/ContactForm.tsx b/components/ContactForm.tsx new file mode 100644 index 0000000..2705599 --- /dev/null +++ b/components/ContactForm.tsx @@ -0,0 +1,160 @@ +"use client"; + +import React, { useActionState, useEffect, useRef } from "react"; +import { useFormStatus } from "react-dom"; +import { submitContactForm, ContactFormState } from "@/actions/contact"; +import Button from "@/components/ui/Button"; // Use your existing Button component + +// Submit button component with pending state +function SubmitButton() { + const { pending } = useFormStatus(); + return ( + + ); +} + +// The main contact form component +export default function ContactForm() { + const initialState: ContactFormState = { + message: null, + errors: {}, + success: false, + }; + const [state, dispatch] = useActionState(submitContactForm, initialState); + const formRef = useRef(null); // Ref to reset the form + + // Reset form on successful submission + useEffect(() => { + if (state.success) { + formRef.current?.reset(); + } + }, [state.success]); + + return ( +
+ {/* Name Input */} +
+ + +
+ {state.errors?.name && + state.errors.name.map((error: string) => ( +

+ {error} +

+ ))} +
+
+ + {/* Email Input */} +
+ + +
+ {state.errors?.email && + state.errors.email.map((error: string) => ( +

+ {error} +

+ ))} +
+
+ + {/* Subject Input */} +
+ + +
+ {state.errors?.subject && + state.errors.subject.map((error: string) => ( +

+ {error} +

+ ))} +
+
+ + {/* Message Textarea */} +
+ + +
+ {state.errors?.message && + state.errors.message.map((error: string) => ( +

+ {error} +

+ ))} +
+
+ + {/* General Form Message (Success or Error) */} +
+ {state.message && ( +

+ {state.message} +

+ )} +
+ + {/* Submit Button */} +
+ +
+
+ ); +} diff --git a/components/CreatePostForm.tsx b/components/CreatePostForm.tsx index 4f40df1..2fa3d09 100644 --- a/components/CreatePostForm.tsx +++ b/components/CreatePostForm.tsx @@ -20,10 +20,18 @@ export default function CreatePostForm() { // Use useActionState from React const [state, dispatch] = useActionState(createPost, initialState); - // Helper to get default value or empty string + // Helper to get default value or empty string, ensuring string return type const getPreviousInput = ( key: keyof NonNullable - ) => state.previousInput?.[key] ?? ""; + ): string => { + const value = state.previousInput?.[key]; + // Ensure the return value is always a string for text/textarea defaultValue + if (typeof value === "string") { + return value; + } + // Return empty string for non-string types (like boolean) or null/undefined + return ""; + }; return ( // Remove encType="multipart/form-data" diff --git a/next.config.ts b/next.config.ts index 96c854d..05c512b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,14 +2,20 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { images: { - domains: ["avatars.githubusercontent.com", "storage.cvevolve.com"], - }, - serverActions: { - bodySizeLimit: "10mb", // Increase limit (e.g., to 10MB) - adjust as needed + remotePatterns: [ + { + protocol: "https", + hostname: "avatars.githubusercontent.com", + }, + { + protocol: "https", + hostname: "storage.cvevolve.com", + }, + ], }, experimental: { serverActions: { - bodySizeLimit: "2mb", + bodySizeLimit: "5mb", }, }, }; diff --git a/package-lock.json b/package-lock.json index 7db3786..f87c7d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,13 @@ "version": "0.1.0", "dependencies": { "@auth/prisma-adapter": "^2.9.0", + "@google/generative-ai": "^0.24.0", "@prisma/client": "^6.6.0", "minio": "^8.0.5", "next": "15.3.1", "next-auth": "^5.0.0-beta.26", "next-themes": "^0.4.6", + "nodemailer": "^6.10.1", "react": "^19.0.0", "react-dom": "^19.0.0", "react-icons": "^5.5.0", @@ -26,6 +28,7 @@ "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/nodemailer": "^6.4.17", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", @@ -1239,6 +1242,15 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@google/generative-ai": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.0.tgz", + "integrity": "sha512-fnEITCGEB7NdX0BhoYZ/cq/7WPZ1QS5IzJJfC3Tg/OwkvBetMiVJciyaan297OvE4B9Jg1xvo0zIazX/9sGu1Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2970,6 +2982,16 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/nodemailer": { + "version": "6.4.17", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz", + "integrity": "sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.1.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.2.tgz", @@ -8133,6 +8155,15 @@ "license": "MIT", "peer": true }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/package.json b/package.json index c828a03..e8e55bc 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,13 @@ }, "dependencies": { "@auth/prisma-adapter": "^2.9.0", + "@google/generative-ai": "^0.24.0", "@prisma/client": "^6.6.0", "minio": "^8.0.5", "next": "15.3.1", "next-auth": "^5.0.0-beta.26", "next-themes": "^0.4.6", + "nodemailer": "^6.10.1", "react": "^19.0.0", "react-dom": "^19.0.0", "react-icons": "^5.5.0", @@ -27,6 +29,7 @@ "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/nodemailer": "^6.4.17", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9",