diff --git a/.all-contributorsrc b/.all-contributorsrc index d1350b252be..161933bc867 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -12675,7 +12675,9 @@ "avatar_url": "https://avatars.githubusercontent.com/u/171761102?v=4", "profile": "https://github.com/JoeChenJ", "contributions": [ - "content" + "content", + "bug", + "code" ] }, { @@ -13059,6 +13061,60 @@ "contributions": [ "maintenance" ] + }, + { + "login": "Co1nB3e", + "name": "Co1nB3e", + "avatar_url": "https://avatars.githubusercontent.com/u/91367832?v=4", + "profile": "https://github.com/Co1nB3e", + "contributions": [ + "maintenance" + ] + }, + { + "login": "Verestra", + "name": "Revo Arya", + "avatar_url": "https://avatars.githubusercontent.com/u/44845508?v=4", + "profile": "https://github.com/Verestra", + "contributions": [ + "maintenance" + ] + }, + { + "login": "teniolafatunmbi", + "name": "Teniola Fatunmbi", + "avatar_url": "https://avatars.githubusercontent.com/u/70762806?v=4", + "profile": "http://teniolafatunmbi.com", + "contributions": [ + "maintenance" + ] + }, + { + "login": "dinitheth", + "name": "Dinith", + "avatar_url": "https://avatars.githubusercontent.com/u/170238361?v=4", + "profile": "https://github.com/dinitheth", + "contributions": [ + "maintenance" + ] + }, + { + "login": "julio4", + "name": "Julio", + "avatar_url": "https://avatars.githubusercontent.com/u/30329843?v=4", + "profile": "https://github.com/julio4", + "contributions": [ + "maintenance" + ] + }, + { + "login": "abeldotam", + "name": "Abel Derderian", + "avatar_url": "https://avatars.githubusercontent.com/u/5216201?v=4", + "profile": "http://abel.fr", + "contributions": [ + "maintenance" + ] } ], "contributorsPerLine": 7, diff --git a/.env.example b/.env.example index eec7688be81..30e881faf9b 100644 --- a/.env.example +++ b/.env.example @@ -48,3 +48,8 @@ ANALYZE=false # Use mock data for development. Set to "false" to use live data but you must have the # environment variables set to make api requests USE_MOCK_DATA=true + +# AWS SES Configuration for Enterprise Contact Form +# SES_ACCESS_KEY_ID=your_iam_access_key_id +# SES_SECRET_ACCESS_KEY=your_iam_secret_access_key +# SES_REGION=us-east-2 diff --git a/README.md b/README.md index 11dd7441637..4e4d7936c20 100644 --- a/README.md +++ b/README.md @@ -1976,7 +1976,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Matthias Seidl
Matthias Seidl

💻 - JoeChenJ
JoeChenJ

🖋 + JoeChenJ
JoeChenJ

🖋 🐛 💻 Varshitha
Varshitha

🚧 Alexandria Roberts
Alexandria Roberts

💻 colin
colin

🖋 @@ -2031,6 +2031,14 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d 0xumarkhatab
0xumarkhatab

🚧 Alberto Cuesta Cañada
Alberto Cuesta Cañada

🚧 + Co1nB3e
Co1nB3e

🚧 + Revo Arya
Revo Arya

🚧 + Teniola Fatunmbi
Teniola Fatunmbi

🚧 + Dinith
Dinith

🚧 + Julio
Julio

🚧 + + + Abel Derderian
Abel Derderian

🚧 diff --git a/app/[locale]/enterprise/_components/ContactForm/index.tsx b/app/[locale]/enterprise/_components/ContactForm/index.tsx new file mode 100644 index 00000000000..37fb3932595 --- /dev/null +++ b/app/[locale]/enterprise/_components/ContactForm/index.tsx @@ -0,0 +1,295 @@ +"use client" + +import React, { useState } from "react" +import { HeartHandshake, TriangleAlert } from "lucide-react" + +import { Button } from "@/components/ui/buttons/Button" +import Input from "@/components/ui/input" +import { Spinner } from "@/components/ui/spinner" +import { Textarea } from "@/components/ui/textarea" + +import { cn } from "@/lib/utils/cn" +import { sanitizeInput } from "@/lib/utils/sanitize" + +import { MAX_EMAIL_LENGTH, MAX_MESSAGE_LENGTH } from "../../constants" + +type EnterpriseContactFormProps = { + strings: { + error: { + domain: React.ReactNode // Link injected + emailInvalid: string + emailTooLong: string + general: React.ReactNode // Link injected + messageTooLong: string + required: string + } + placeholder: { + input: string + textarea: string + } + button: { + label: string + loading: string + } + success: { + heading: string + message: string + } + } +} + +type FormState = { + email: string + message: string +} + +type FormErrors = { + email?: React.ReactNode + message?: React.ReactNode + general?: React.ReactNode +} + +type SubmissionState = "idle" | "submitting" | "success" | "error" + +// Consumer email domains to block +const CONSUMER_DOMAINS = [ + "gmail.com", + "yahoo.com", + "hotmail.com", + "outlook.com", + "icloud.com", + "protonmail.com", + "proton.me", + "pm.me", + "aol.com", + "mail.com", + "yandex.com", + "tutanota.com", + "fastmail.com", + "zoho.com", + "gmx.com", + "live.com", + "msn.com", + "me.com", + "mac.com", + "rocketmail.com", + "yahoo.co.uk", + "googlemail.com", + "mailinator.com", + "10minutemail.com", + "guerrillamail.com", +] + +const EnterpriseContactForm = ({ strings }: EnterpriseContactFormProps) => { + const getCharacterCountClasses = (currentLength: number, maxLength: number) => + cn( + currentLength >= Math.floor(maxLength * 0.9) && "flex", // Show char count when within 10% remaining to limit + currentLength > maxLength - 64 && "text-warning-border", // Warning color within 64 chars (border version for proper contrast ratio), + currentLength > maxLength && "text-error [&_svg]:inline" // Error color over limit + ) + + const [formData, setFormData] = useState({ + email: "", + message: "", + }) + const [errors, setErrors] = useState({}) + const [submissionState, setSubmissionState] = + useState("idle") + + const handleInputChange = + (field: keyof FormState) => + (e: React.ChangeEvent) => { + const value = e.target.value + setFormData((prev) => ({ ...prev, [field]: value })) + + // Clear error when user starts typing + if (errors[field]) { + setErrors((prev) => ({ ...prev, [field]: undefined })) + } + } + + const handleBlur = + (field: keyof FormState) => + (e: React.FocusEvent) => { + const value = e.target.value + + if (field === "email") { + const emailError = validateEmail(value) + if (emailError) setErrors((prev) => ({ ...prev, email: emailError })) + return + } + if (field === "message") { + const messageError = validateMessage(value) + if (messageError) + setErrors((prev) => ({ ...prev, message: messageError })) + return + } + } + + const validateEmail = (email: string): React.ReactNode | undefined => { + const sanitized = sanitizeInput(email) + + if (!sanitized) return strings.error.required + + if (sanitized.length > MAX_EMAIL_LENGTH) return strings.error.emailTooLong + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!emailRegex.test(sanitized)) return strings.error.emailInvalid + + const domain = sanitized.toLowerCase().split("@")[1] + if (CONSUMER_DOMAINS.includes(domain)) return strings.error.domain + + return undefined + } + + const validateMessage = ( + message: string + ): React.ReactNode | string | undefined => { + const sanitized = sanitizeInput(message) + + if (!sanitized) return strings.error.required + + if (sanitized.length > MAX_MESSAGE_LENGTH) + return strings.error.messageTooLong + + return undefined + } + + const validateForm = (): boolean => { + const newErrors: FormErrors = {} + + const emailError = validateEmail(formData.email) + if (emailError) newErrors.email = emailError + + const messageError = validateMessage(formData.message) + if (messageError) newErrors.message = messageError + + setErrors(newErrors) + return Object.keys(newErrors).length === 0 + } + + const handleSubmit = async () => { + if (!validateForm()) return + + setSubmissionState("submitting") + setErrors({}) + + try { + const sanitizedData = { + email: sanitizeInput(formData.email), + message: sanitizeInput(formData.message), + } + + const response = await fetch("/api/enterprise-contact", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(sanitizedData), + }) + + if (!response.ok) throw new Error(`Server error: ${response.status}`) + + setSubmissionState("success") + } catch (error) { + console.error("Form submission error:", error) + setSubmissionState("error") + setErrors({ general: strings.error.general }) + } + } + + if (submissionState === "success") + return ( +
+
+ +

{strings.success.heading}

+
+

{strings.success.message}

+
+ ) + + return ( +
+
+ + {errors.email && ( +

+ {errors.email} +

+ )} +
+ +
+
+