diff --git a/apps/web/.env.example b/apps/web/.env.example index 3f64003..1cf4b53 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -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 diff --git a/apps/web/package.json b/apps/web/package.json index 9cecab0..041f0ec 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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": { diff --git a/apps/web/src/actions/submit-contact.ts b/apps/web/src/actions/submit-contact.ts index f7a3543..c2158f4 100644 --- a/apps/web/src/actions/submit-contact.ts +++ b/apps/web/src/actions/submit-contact.ts @@ -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 ', + to: contactEmail, + replyTo: email, + subject: `New inquiry from ${name}`, + text: `Name: ${name}\nEmail: ${email}\n\nMessage:\n${message}`, + html: ` +

New Project Inquiry

+

Name: ${name}

+

Email: ${email}

+

Message:

+

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

+ `, + }); + + 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." }; } } \ No newline at end of file diff --git a/apps/web/src/components/contact/turnstile-widget.tsx b/apps/web/src/components/contact/turnstile-widget.tsx index 0a2ae9b..12f89b4 100644 --- a/apps/web/src/components/contact/turnstile-widget.tsx +++ b/apps/web/src/components/contact/turnstile-widget.tsx @@ -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
; + } + + if (error) { + return ( +
+ Security check unavailable +
+ ); + } + return ( setError(true)} + onExpire={() => onVerify('')} options={{ theme: 'auto' }} /> ); diff --git a/packages/ui/src/procedural/nebula-field.tsx b/packages/ui/src/procedural/nebula-field.tsx index e1bb057..04108e2 100644 --- a/packages/ui/src/procedural/nebula-field.tsx +++ b/packages/ui/src/procedural/nebula-field.tsx @@ -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 ( -
- ); - } - - // 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 ( +
+ ); + } + return (