feature: create post

This commit is contained in:
libertyoms
2025-04-22 08:50:55 +02:00
parent a8c6b5297b
commit 43f867cfe4
8 changed files with 518 additions and 12 deletions

148
actions/contact.ts Normal file
View File

@ -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<boolean> {
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<ContactFormState> {
// 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: `<p><strong>Name:</strong> ${name}</p>
<p><strong>Email:</strong> <a href="mailto:${email}">${email}</a></p>
<hr>
<p><strong>Message:</strong></p>
<p>${message.replace(/\n/g, "<br>")}</p>`, // 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 ---
}

View File

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

View File

@ -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 (
<div className="bg-gradient-to-b from-background to-secondary/50 text-foreground">
{/* Hero Section */}
<section className="py-20 md:py-28 text-center bg-primary/10 dark:bg-primary/5 border-b border-border">
<div className="container mx-auto px-4">
<h1 className="text-4xl md:text-5xl font-bold mb-4 text-primary">
Get In Touch
</h1>
<p className="text-lg md:text-xl text-muted-foreground max-w-2xl mx-auto">
We&apos;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.
</p>
</div>
</section>
{/* Main Content Area (Form + Info) */}
<section className="py-16 md:py-24">
<div className="container mx-auto px-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 md:gap-16 lg:gap-20 items-start">
{/* Contact Information Section */}
<div className="space-y-8 bg-card p-8 rounded-lg border border-border shadow-sm">
<h2 className="text-3xl font-semibold mb-6 text-foreground">
Contact Information
</h2>
<div className="flex items-start space-x-4">
<FiMapPin className="w-6 h-6 text-primary mt-1 flex-shrink-0" />
<div>
<h3 className="text-lg font-medium text-foreground">
Our Office
</h3>
<address className="text-muted-foreground not-italic text-sm leading-relaxed">
Unit 10 B Centuria Park
<br />
265 Von Willich Avenue
<br />
Die Hoewes, Centurion, 0159
<br />
South Africa
</address>
{/* Optional: Link to Google Maps */}
<a
href="https://www.google.com/maps/place/Owethu+Managed+Services/@-25.863168,28.186075,17z/data=!3m1!4b1!4m6!3m5!1s0x1e9565b7018f5f9b:0x4d8a4ae3a2c0d9a1!8m2!3d-25.8631728!4d28.1886499!16s%2Fg%2F11h1_q_1_f?entry=ttu" // Replace with your actual Google Maps link
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline mt-2 inline-block"
>
View on Google Maps
</a>
</div>
</div>
<div className="flex items-start space-x-4">
<FiPhone className="w-6 h-6 text-primary mt-1 flex-shrink-0" />
<div>
<h3 className="text-lg font-medium text-foreground">Phone</h3>
<a
href="tel:+27120513282"
className="text-muted-foreground hover:text-primary transition text-sm"
>
(012) 051 3282
</a>
</div>
</div>
<div className="flex items-start space-x-4">
<FiMail className="w-6 h-6 text-primary mt-1 flex-shrink-0" />
<div>
<h3 className="text-lg font-medium text-foreground">Email</h3>
<a
href="mailto:hello@oms.africa"
className="text-muted-foreground hover:text-primary transition text-sm"
>
hello@oms.africa
</a>
</div>
</div>
<div className="flex items-start space-x-4">
<FiClock className="w-6 h-6 text-primary mt-1 flex-shrink-0" />
<div>
<h3 className="text-lg font-medium text-foreground">
Business Hours
</h3>
<p className="text-muted-foreground text-sm">
Monday - Friday: 8:00 AM - 5:00 PM (SAST)
</p>
<p className="text-muted-foreground text-sm">
Weekends & Public Holidays: Closed
</p>
</div>
</div>
</div>
{/* Contact Form Section */}
<div className="bg-card p-8 rounded-lg border border-border shadow-sm">
<h2 className="text-3xl font-semibold mb-6 text-foreground">
Send Us a Message
</h2>
<ContactForm />
</div>
</div>
</div>
</section>
{/* Optional: Map Section Placeholder */}
{/* <section className="h-96 bg-muted border-t border-border">
<div className="container mx-auto h-full flex items-center justify-center">
<p className="text-muted-foreground">[Embedded Map Placeholder - e.g., Google Maps iframe]</p>
</div>
</section> */}
</div>
);
}

160
components/ContactForm.tsx Normal file
View File

@ -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 (
<Button type="submit" variant="primary" size="lg" disabled={pending}>
{pending ? "Sending..." : "Send Message"}
</Button>
);
}
// 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<HTMLFormElement>(null); // Ref to reset the form
// Reset form on successful submission
useEffect(() => {
if (state.success) {
formRef.current?.reset();
}
}, [state.success]);
return (
<form ref={formRef} action={dispatch} className="space-y-6">
{/* Name Input */}
<div>
<label
htmlFor="name"
className="block text-sm font-medium text-foreground mb-1"
>
Full Name
</label>
<input
type="text"
id="name"
name="name"
required
className="block w-full px-4 py-2 border border-border rounded-lg shadow-sm focus:ring-primary focus:border-primary sm:text-sm bg-input text-foreground placeholder-muted-foreground"
aria-describedby="name-error"
/>
<div id="name-error" aria-live="polite" aria-atomic="true">
{state.errors?.name &&
state.errors.name.map((error: string) => (
<p className="mt-1 text-sm text-destructive" key={error}>
{error}
</p>
))}
</div>
</div>
{/* Email Input */}
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-foreground mb-1"
>
Email Address
</label>
<input
type="email"
id="email"
name="email"
required
className="block w-full px-4 py-2 border border-border rounded-lg shadow-sm focus:ring-primary focus:border-primary sm:text-sm bg-input text-foreground placeholder-muted-foreground"
aria-describedby="email-error"
/>
<div id="email-error" aria-live="polite" aria-atomic="true">
{state.errors?.email &&
state.errors.email.map((error: string) => (
<p className="mt-1 text-sm text-destructive" key={error}>
{error}
</p>
))}
</div>
</div>
{/* Subject Input */}
<div>
<label
htmlFor="subject"
className="block text-sm font-medium text-foreground mb-1"
>
Subject
</label>
<input
type="text"
id="subject"
name="subject"
required
className="block w-full px-4 py-2 border border-border rounded-lg shadow-sm focus:ring-primary focus:border-primary sm:text-sm bg-input text-foreground placeholder-muted-foreground"
aria-describedby="subject-error"
/>
<div id="subject-error" aria-live="polite" aria-atomic="true">
{state.errors?.subject &&
state.errors.subject.map((error: string) => (
<p className="mt-1 text-sm text-destructive" key={error}>
{error}
</p>
))}
</div>
</div>
{/* Message Textarea */}
<div>
<label
htmlFor="message"
className="block text-sm font-medium text-foreground mb-1"
>
Your Message
</label>
<textarea
id="message"
name="message"
rows={5}
required
className="block w-full px-4 py-2 border border-border rounded-lg shadow-sm focus:ring-primary focus:border-primary sm:text-sm bg-input text-foreground placeholder-muted-foreground"
aria-describedby="message-error"
></textarea>
<div id="message-error" aria-live="polite" aria-atomic="true">
{state.errors?.message &&
state.errors.message.map((error: string) => (
<p className="mt-1 text-sm text-destructive" key={error}>
{error}
</p>
))}
</div>
</div>
{/* General Form Message (Success or Error) */}
<div id="form-response" aria-live="polite" aria-atomic="true">
{state.message && (
<p
className={`text-sm ${
state.success ? "text-green-600" : "text-destructive"
}`}
>
{state.message}
</p>
)}
</div>
{/* Submit Button */}
<div className="pt-2">
<SubmitButton />
</div>
</form>
);
}

View File

@ -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<CreatePostState["previousInput"]>
) => 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"

View File

@ -2,14 +2,20 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
domains: ["avatars.githubusercontent.com", "storage.cvevolve.com"],
remotePatterns: [
{
protocol: "https",
hostname: "avatars.githubusercontent.com",
},
serverActions: {
bodySizeLimit: "10mb", // Increase limit (e.g., to 10MB) - adjust as needed
{
protocol: "https",
hostname: "storage.cvevolve.com",
},
],
},
experimental: {
serverActions: {
bodySizeLimit: "2mb",
bodySizeLimit: "5mb",
},
},
};

31
package-lock.json generated
View File

@ -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",

View File

@ -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",