Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
295 changes: 295 additions & 0 deletions app/[locale]/enterprise/_components/ContactForm/index.tsx
Original file line number Diff line number Diff line change
@@ -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<FormState>({
email: "",
message: "",
})
const [errors, setErrors] = useState<FormErrors>({})
const [submissionState, setSubmissionState] =
useState<SubmissionState>("idle")

const handleInputChange =
(field: keyof FormState) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
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<HTMLInputElement | HTMLTextAreaElement>) => {
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", {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good place to use react-query with useMutation

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 (
<div className="flex w-full max-w-prose flex-col items-center gap-y-6 rounded-2xl border border-accent-a/20 bg-background p-6 text-center">
<div className="mb-2 flex items-center gap-4">
<HeartHandshake className="size-8 text-primary" />
<h3 className="text-2xl font-semibold">{strings.success.heading}</h3>
</div>
<p className="text-body-medium">{strings.success.message}</p>
</div>
)

return (
<div className="w-full max-w-[440px] space-y-6">
<div className="space-y-2">
<Input
type="email"
className="w-full"
placeholder={strings.placeholder.input}
value={formData.email}
onChange={handleInputChange("email")}
onBlur={handleBlur("email")}
hasError={!!errors.email}
disabled={submissionState === "submitting"}
/>
{errors.email && (
<p className="text-sm text-error" role="alert">
{errors.email}
</p>
)}
</div>

<div className="space-y-2">
<div className="relative">
<Textarea
placeholder={strings.placeholder.textarea}
value={formData.message}
onChange={handleInputChange("message")}
onBlur={handleBlur("message")}
hasError={!!errors.message}
disabled={submissionState === "submitting"}
className="min-h-[120px]"
/>
<div
className={cn(
"absolute bottom-1 end-3 hidden items-center rounded bg-background px-1 py-0.5 text-xs shadow",
getCharacterCountClasses(
formData.message.length,
MAX_MESSAGE_LENGTH
)
)}
>
<TriangleAlert className="mb-px me-1 hidden size-3" />
{formData.message.length}/{MAX_MESSAGE_LENGTH}
</div>
</div>
{errors.message && (
<p className="text-sm text-error" role="alert">
{errors.message}
</p>
)}
</div>

{errors.general && (
<div className="rounded-lg bg-error-light p-4">
<p className="text-sm text-error" role="alert">
{errors.general}
</p>
</div>
)}

<Button
onClick={handleSubmit}
size="lg"
disabled={submissionState === "submitting"}
customEventOptions={{
eventCategory: "enterprise",
eventAction: "CTA",
eventName: "bottom_mail",
}}
className="flex items-center justify-center gap-2 max-sm:w-full"
>
{submissionState === "submitting" ? (
<>
<Spinner className="text-lg" />
{strings.button.loading}
</>
) : (
strings.button.label
)}
</Button>
</div>
)
}

export default EnterpriseContactForm
5 changes: 5 additions & 0 deletions app/[locale]/enterprise/_components/ContactForm/lazy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import dynamic from "next/dynamic"

import Loading from "./loading"

export default dynamic(() => import("."), { ssr: false, loading: Loading })
10 changes: 10 additions & 0 deletions app/[locale]/enterprise/_components/ContactForm/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Skeleton } from "@/components/ui/skeleton"

const Loading = () => (
<div className="w-full max-w-[440px] space-y-6">
<Skeleton className="h-[42px] w-full" />
<Skeleton className="h-[200px] w-full" />
</div>
)

export default Loading
7 changes: 4 additions & 3 deletions app/[locale]/enterprise/constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// TODO: Confirm
export const ENTERPRISE_MAILTO =
"mailto:enterprise@ethereum.org?subject=Enterprise%20inquiry"
export const ENTERPRISE_EMAIL = "enterprise@ethereum.org"
export const ENTERPRISE_MAILTO = `mailto:${ENTERPRISE_EMAIL}?subject=Enterprise%20inquiry`
export const MAX_EMAIL_LENGTH = 2 ** 6 // 64
export const MAX_MESSAGE_LENGTH = 2 ** 12 // 4,096
Loading
Loading