mirror of
https://github.com/Such-Software/such.software.git
synced 2026-10-17 05:32:05 +02:00
feat: add resend for email
fix: nebual minified react error
This commit is contained in:
@@ -3,5 +3,9 @@
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=your_site_key_here
|
||||
TURNSTILE_SECRET_KEY=your_secret_key_here
|
||||
|
||||
# Optional: Email for contact form notifications
|
||||
# CONTACT_EMAIL=your@email.com
|
||||
# Resend (email service for contact form)
|
||||
# Get API key from: https://resend.com/api-keys
|
||||
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# Email address to receive contact form submissions
|
||||
CONTACT_EMAIL=sales@such.software
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"resend": "^4.0.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
'use server';
|
||||
|
||||
import { z } from 'zod';
|
||||
import { verifyTurnstile } from '@/lib/turnstile'; // Helper to call Cloudflare API
|
||||
import { Resend } from 'resend';
|
||||
import { verifyTurnstile } from '@/lib/turnstile';
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
const ContactSchema = z.object({
|
||||
name: z.string().min(2, "Name is too short"),
|
||||
@@ -12,7 +15,7 @@ const ContactSchema = z.object({
|
||||
|
||||
export async function submitContactForm(prevState: any, formData: FormData) {
|
||||
const rawData = Object.fromEntries(formData.entries());
|
||||
|
||||
|
||||
// 1. Zod Validation
|
||||
const validated = ContactSchema.safeParse(rawData);
|
||||
if (!validated.success) {
|
||||
@@ -25,17 +28,34 @@ export async function submitContactForm(prevState: any, formData: FormData) {
|
||||
return { success: false, message: "Security check failed. Please refresh." };
|
||||
}
|
||||
|
||||
// 3. Send to Backend (or Email)
|
||||
// 3. Send email via Resend
|
||||
try {
|
||||
// TODO: Connect to email service or CMS
|
||||
console.log("Form submitted:", validated.data);
|
||||
|
||||
// Simulating success for now
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
const { name, email, message } = validated.data;
|
||||
const contactEmail = process.env.CONTACT_EMAIL || 'sales@such.software';
|
||||
|
||||
const { error } = await resend.emails.send({
|
||||
from: 'Such Software <noreply@such.software>',
|
||||
to: contactEmail,
|
||||
replyTo: email,
|
||||
subject: `New inquiry from ${name}`,
|
||||
text: `Name: ${name}\nEmail: ${email}\n\nMessage:\n${message}`,
|
||||
html: `
|
||||
<h2>New Project Inquiry</h2>
|
||||
<p><strong>Name:</strong> ${name}</p>
|
||||
<p><strong>Email:</strong> <a href="mailto:${email}">${email}</a></p>
|
||||
<h3>Message:</h3>
|
||||
<p>${message.replace(/\n/g, '<br>')}</p>
|
||||
`,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Resend error:', error);
|
||||
return { success: false, message: "Failed to send message. Please try again." };
|
||||
}
|
||||
|
||||
return { success: true, message: "Request received. We will be in touch shortly." };
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
console.error('Email error:', e);
|
||||
return { success: false, message: "System error. Please contact us via email." };
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,41 @@
|
||||
'use client';
|
||||
import { Turnstile } from '@marsidev/react-turnstile';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
const SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY;
|
||||
|
||||
export function TurnstileWidget({ onVerify }: { onVerify: (token: string) => void }) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!SITE_KEY) {
|
||||
console.warn('NEXT_PUBLIC_TURNSTILE_SITE_KEY is not set');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only render on client after mount to avoid hydration issues
|
||||
if (!mounted) {
|
||||
return <div className="h-[65px] w-[300px]" />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Security check unavailable
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Turnstile
|
||||
siteKey={SITE_KEY}
|
||||
onSuccess={onVerify}
|
||||
onError={() => setError(true)}
|
||||
onExpire={() => onVerify('')}
|
||||
options={{ theme: 'auto' }}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -80,10 +80,21 @@ const StreamLine = ({ d, themeMode, onHit, width, index }: StreamLineProps) => {
|
||||
};
|
||||
|
||||
export const NebulaField = ({ className, density = 8, themeMode = 'dark', position }: NebulaProps) => {
|
||||
// === ALL HOOKS MUST BE CALLED BEFORE ANY CONDITIONAL RETURNS ===
|
||||
const monitorControls = useAnimation();
|
||||
const [monitorColor, setMonitorColor] = useState(themeMode === 'light' ? "text-slate-400" : "text-emerald-500/60");
|
||||
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
|
||||
|
||||
// Parallax Scroll hooks (called unconditionally)
|
||||
const { scrollY } = useScroll();
|
||||
const yRange = useTransform(scrollY, [0, 1000], [0, 250]);
|
||||
|
||||
// Position calculations
|
||||
const computerX = position?.x ?? 18;
|
||||
const computerY = position?.y ?? 18;
|
||||
const targetX = computerX + 2.5;
|
||||
const targetY = computerY + 2.5;
|
||||
|
||||
// Check for reduced motion preference
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
@@ -94,47 +105,18 @@ export const NebulaField = ({ className, density = 8, themeMode = 'dark', positi
|
||||
return () => mediaQuery.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
// Return static background for reduced motion users
|
||||
if (prefersReducedMotion) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"absolute inset-0 overflow-hidden pointer-events-none z-0",
|
||||
themeMode === 'light'
|
||||
? "bg-gradient-to-br from-slate-100 via-slate-50 to-white"
|
||||
: "bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900",
|
||||
className
|
||||
)} />
|
||||
);
|
||||
}
|
||||
|
||||
// Sync color with theme if no impacts have happened yet
|
||||
// Sync color with theme
|
||||
useEffect(() => {
|
||||
setMonitorColor(themeMode === 'light' ? "text-slate-300" : "text-emerald-500/40");
|
||||
}, [themeMode]);
|
||||
|
||||
// --- POSITIONING & ANIMATION CONTROLS ---
|
||||
|
||||
// Parallax Scroll: Vertical movement of the computer icon as you scroll.
|
||||
// [0, 1000] is the scroll pixel range, [0, 250] is the movement in pixels.
|
||||
const { scrollY } = useScroll();
|
||||
const yRange = useTransform(scrollY, [0, 1000], [0, 250]);
|
||||
|
||||
// 1. Computer Position: Where the icon sits on the screen (%)
|
||||
// Use dynamic position if provided, otherwise fall back to defaults
|
||||
const computerX = position?.x ?? 18;
|
||||
const computerY = position?.y ?? 18;
|
||||
|
||||
// 2. Streamer Convergence: Where the streamers actually head (%)
|
||||
// Offset from computer position so streamers hit the center of the monitor
|
||||
const targetX = computerX + 2.5;
|
||||
const targetY = computerY + 2.5;
|
||||
if (!prefersReducedMotion) {
|
||||
setMonitorColor(themeMode === 'light' ? "text-slate-300" : "text-emerald-500/40");
|
||||
}
|
||||
}, [themeMode, prefersReducedMotion]);
|
||||
|
||||
const paths = useMemo(() => {
|
||||
return Array.from({ length: density }).map(() => {
|
||||
// Streamer start position (random edge)
|
||||
const edge = Math.floor(Math.random() * 4);
|
||||
let startX = 50, startY = 50;
|
||||
|
||||
|
||||
switch(edge) {
|
||||
case 0: startX = Math.random() * 100; startY = -20; break;
|
||||
case 1: startX = 120; startY = Math.random() * 100; break;
|
||||
@@ -142,11 +124,11 @@ export const NebulaField = ({ className, density = 8, themeMode = 'dark', positi
|
||||
case 3: startX = -20; startY = Math.random() * 100; break;
|
||||
}
|
||||
|
||||
const endX = targetX + (Math.random() - 0.5) * 1.5; // Even tighter
|
||||
const endY = targetY + (Math.random() - 0.5) * 1.5;
|
||||
const endX = targetX + (Math.random() - 0.5) * 1.5;
|
||||
const endY = targetY + (Math.random() - 0.5) * 1.5;
|
||||
const cpX = (startX + endX) / 2 + (Math.random() - 0.5) * 40;
|
||||
const cpY = (startY + endY) / 2 + (Math.random() - 0.5) * 40;
|
||||
|
||||
|
||||
return `M ${startX} ${startY} Q ${cpX} ${cpY} ${endX} ${endY}`;
|
||||
});
|
||||
}, [density, themeMode, targetX, targetY]);
|
||||
@@ -169,6 +151,21 @@ export const NebulaField = ({ className, density = 8, themeMode = 'dark', positi
|
||||
});
|
||||
}, [monitorControls, themeMode]);
|
||||
|
||||
// === CONDITIONAL RETURNS AFTER ALL HOOKS ===
|
||||
|
||||
// Return static background for reduced motion users
|
||||
if (prefersReducedMotion) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"absolute inset-0 overflow-hidden pointer-events-none z-0",
|
||||
themeMode === 'light'
|
||||
? "bg-gradient-to-br from-slate-100 via-slate-50 to-white"
|
||||
: "bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900",
|
||||
className
|
||||
)} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("absolute inset-0 overflow-hidden pointer-events-none z-0", className)}>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user