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/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} +

+ )} +
+ +
+
+