Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
cb0ac7f
feat: initialize enterprise contact form
wackerow Jul 24, 2025
ccf2661
feat: add ui/textarea
wackerow Jul 25, 2025
0ea5775
feat: build out contact form
wackerow Jul 26, 2025
91ecdd7
feat: add netlify form integration
wackerow Jul 26, 2025
a53694d
revert: min char requirement - enterprise form body
wackerow Jul 28, 2025
034534f
feat: update ui/input ui/textarea with error state styling
wackerow Jul 28, 2025
698f0a0
feat: update error message with discord link
wackerow Jul 28, 2025
6582ff5
feat: validate form fields on blur
wackerow Jul 28, 2025
8093b79
patch: variable type
wackerow Jul 28, 2025
4c4b7c1
feat: remap hero link to #get-in-touch form
wackerow Jul 29, 2025
4e66f63
Update Translatathon for 2025: change event title and timeline, adjus…
corwintines Jul 31, 2025
75201e1
Remove prizes section from Translatathon details in the translation p…
corwintines Jul 31, 2025
d7577ae
remove prize pool amount
corwintines Jul 31, 2025
adb9842
Translatathon page updates
lukassim Aug 4, 2025
763f7d7
patch: expand link label
wackerow Aug 5, 2025
2e5a785
feat: revamp to use AWS SES
wackerow Aug 5, 2025
d0805ce
feat: add max char error states
wackerow Aug 5, 2025
968eba1
feat: add error icon to char count indicator over max
wackerow Aug 6, 2025
87694c7
fix: default ses aws region
wackerow Aug 6, 2025
af7e73c
Merge branch 'master' into enterprise-form
wackerow Aug 6, 2025
8f3f037
patch: remove comments
wackerow Aug 7, 2025
e7a3313
revert: custom rate-limit logic
wackerow Aug 7, 2025
3dd71ba
Update Translatathon participation section with consistent heading st…
corwintines Aug 7, 2025
d0406e9
refactor: extract reusable sanitizeInput util
wackerow Aug 7, 2025
d9921ba
Update Translatathon details for 2025: adjust application dates, refi…
corwintines Aug 7, 2025
f8b4747
feat: add link to fallback, add constants
wackerow Aug 7, 2025
b7b6ad9
Translatathon updates
lukassim Aug 7, 2025
19196c2
Merge pull request #16029 from ethereum/master
corwintines Aug 7, 2025
aa52a69
Merge pull request #15941 from ethereum/enterprise-form
corwintines Aug 7, 2025
151a276
Merge pull request #15978 from ethereum/translatathonUpdates
wackerow Aug 7, 2025
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", {
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