Skip to content

Commit 95faa73

Browse files
authored
Merge pull request #16031 from ethereum/staging
Back merge `staging` into `dev`
2 parents 6ccf7ac + 151a276 commit 95faa73

File tree

29 files changed

+1624
-379
lines changed

29 files changed

+1624
-379
lines changed

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,8 @@ ANALYZE=false
4848
# Use mock data for development. Set to "false" to use live data but you must have the
4949
# environment variables set to make api requests
5050
USE_MOCK_DATA=true
51+
52+
# AWS SES Configuration for Enterprise Contact Form
53+
# SES_ACCESS_KEY_ID=your_iam_access_key_id
54+
# SES_SECRET_ACCESS_KEY=your_iam_secret_access_key
55+
# SES_REGION=us-east-2
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
"use client"
2+
3+
import React, { useState } from "react"
4+
import { HeartHandshake, TriangleAlert } from "lucide-react"
5+
6+
import { Button } from "@/components/ui/buttons/Button"
7+
import Input from "@/components/ui/input"
8+
import { Spinner } from "@/components/ui/spinner"
9+
import { Textarea } from "@/components/ui/textarea"
10+
11+
import { cn } from "@/lib/utils/cn"
12+
import { sanitizeInput } from "@/lib/utils/sanitize"
13+
14+
import { MAX_EMAIL_LENGTH, MAX_MESSAGE_LENGTH } from "../../constants"
15+
16+
type EnterpriseContactFormProps = {
17+
strings: {
18+
error: {
19+
domain: React.ReactNode // Link injected
20+
emailInvalid: string
21+
emailTooLong: string
22+
general: React.ReactNode // Link injected
23+
messageTooLong: string
24+
required: string
25+
}
26+
placeholder: {
27+
input: string
28+
textarea: string
29+
}
30+
button: {
31+
label: string
32+
loading: string
33+
}
34+
success: {
35+
heading: string
36+
message: string
37+
}
38+
}
39+
}
40+
41+
type FormState = {
42+
email: string
43+
message: string
44+
}
45+
46+
type FormErrors = {
47+
email?: React.ReactNode
48+
message?: React.ReactNode
49+
general?: React.ReactNode
50+
}
51+
52+
type SubmissionState = "idle" | "submitting" | "success" | "error"
53+
54+
// Consumer email domains to block
55+
const CONSUMER_DOMAINS = [
56+
"gmail.com",
57+
"yahoo.com",
58+
"hotmail.com",
59+
"outlook.com",
60+
"icloud.com",
61+
"protonmail.com",
62+
"proton.me",
63+
"pm.me",
64+
"aol.com",
65+
"mail.com",
66+
"yandex.com",
67+
"tutanota.com",
68+
"fastmail.com",
69+
"zoho.com",
70+
"gmx.com",
71+
"live.com",
72+
"msn.com",
73+
"me.com",
74+
"mac.com",
75+
"rocketmail.com",
76+
"yahoo.co.uk",
77+
"googlemail.com",
78+
"mailinator.com",
79+
"10minutemail.com",
80+
"guerrillamail.com",
81+
]
82+
83+
const EnterpriseContactForm = ({ strings }: EnterpriseContactFormProps) => {
84+
const getCharacterCountClasses = (currentLength: number, maxLength: number) =>
85+
cn(
86+
currentLength >= Math.floor(maxLength * 0.9) && "flex", // Show char count when within 10% remaining to limit
87+
currentLength > maxLength - 64 && "text-warning-border", // Warning color within 64 chars (border version for proper contrast ratio),
88+
currentLength > maxLength && "text-error [&_svg]:inline" // Error color over limit
89+
)
90+
91+
const [formData, setFormData] = useState<FormState>({
92+
email: "",
93+
message: "",
94+
})
95+
const [errors, setErrors] = useState<FormErrors>({})
96+
const [submissionState, setSubmissionState] =
97+
useState<SubmissionState>("idle")
98+
99+
const handleInputChange =
100+
(field: keyof FormState) =>
101+
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
102+
const value = e.target.value
103+
setFormData((prev) => ({ ...prev, [field]: value }))
104+
105+
// Clear error when user starts typing
106+
if (errors[field]) {
107+
setErrors((prev) => ({ ...prev, [field]: undefined }))
108+
}
109+
}
110+
111+
const handleBlur =
112+
(field: keyof FormState) =>
113+
(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
114+
const value = e.target.value
115+
116+
if (field === "email") {
117+
const emailError = validateEmail(value)
118+
if (emailError) setErrors((prev) => ({ ...prev, email: emailError }))
119+
return
120+
}
121+
if (field === "message") {
122+
const messageError = validateMessage(value)
123+
if (messageError)
124+
setErrors((prev) => ({ ...prev, message: messageError }))
125+
return
126+
}
127+
}
128+
129+
const validateEmail = (email: string): React.ReactNode | undefined => {
130+
const sanitized = sanitizeInput(email)
131+
132+
if (!sanitized) return strings.error.required
133+
134+
if (sanitized.length > MAX_EMAIL_LENGTH) return strings.error.emailTooLong
135+
136+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
137+
if (!emailRegex.test(sanitized)) return strings.error.emailInvalid
138+
139+
const domain = sanitized.toLowerCase().split("@")[1]
140+
if (CONSUMER_DOMAINS.includes(domain)) return strings.error.domain
141+
142+
return undefined
143+
}
144+
145+
const validateMessage = (
146+
message: string
147+
): React.ReactNode | string | undefined => {
148+
const sanitized = sanitizeInput(message)
149+
150+
if (!sanitized) return strings.error.required
151+
152+
if (sanitized.length > MAX_MESSAGE_LENGTH)
153+
return strings.error.messageTooLong
154+
155+
return undefined
156+
}
157+
158+
const validateForm = (): boolean => {
159+
const newErrors: FormErrors = {}
160+
161+
const emailError = validateEmail(formData.email)
162+
if (emailError) newErrors.email = emailError
163+
164+
const messageError = validateMessage(formData.message)
165+
if (messageError) newErrors.message = messageError
166+
167+
setErrors(newErrors)
168+
return Object.keys(newErrors).length === 0
169+
}
170+
171+
const handleSubmit = async () => {
172+
if (!validateForm()) return
173+
174+
setSubmissionState("submitting")
175+
setErrors({})
176+
177+
try {
178+
const sanitizedData = {
179+
email: sanitizeInput(formData.email),
180+
message: sanitizeInput(formData.message),
181+
}
182+
183+
const response = await fetch("/api/enterprise-contact", {
184+
method: "POST",
185+
headers: {
186+
"Content-Type": "application/json",
187+
},
188+
body: JSON.stringify(sanitizedData),
189+
})
190+
191+
if (!response.ok) throw new Error(`Server error: ${response.status}`)
192+
193+
setSubmissionState("success")
194+
} catch (error) {
195+
console.error("Form submission error:", error)
196+
setSubmissionState("error")
197+
setErrors({ general: strings.error.general })
198+
}
199+
}
200+
201+
if (submissionState === "success")
202+
return (
203+
<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">
204+
<div className="mb-2 flex items-center gap-4">
205+
<HeartHandshake className="size-8 text-primary" />
206+
<h3 className="text-2xl font-semibold">{strings.success.heading}</h3>
207+
</div>
208+
<p className="text-body-medium">{strings.success.message}</p>
209+
</div>
210+
)
211+
212+
return (
213+
<div className="w-full max-w-[440px] space-y-6">
214+
<div className="space-y-2">
215+
<Input
216+
type="email"
217+
className="w-full"
218+
placeholder={strings.placeholder.input}
219+
value={formData.email}
220+
onChange={handleInputChange("email")}
221+
onBlur={handleBlur("email")}
222+
hasError={!!errors.email}
223+
disabled={submissionState === "submitting"}
224+
/>
225+
{errors.email && (
226+
<p className="text-sm text-error" role="alert">
227+
{errors.email}
228+
</p>
229+
)}
230+
</div>
231+
232+
<div className="space-y-2">
233+
<div className="relative">
234+
<Textarea
235+
placeholder={strings.placeholder.textarea}
236+
value={formData.message}
237+
onChange={handleInputChange("message")}
238+
onBlur={handleBlur("message")}
239+
hasError={!!errors.message}
240+
disabled={submissionState === "submitting"}
241+
className="min-h-[120px]"
242+
/>
243+
<div
244+
className={cn(
245+
"absolute bottom-1 end-3 hidden items-center rounded bg-background px-1 py-0.5 text-xs shadow",
246+
getCharacterCountClasses(
247+
formData.message.length,
248+
MAX_MESSAGE_LENGTH
249+
)
250+
)}
251+
>
252+
<TriangleAlert className="mb-px me-1 hidden size-3" />
253+
{formData.message.length}/{MAX_MESSAGE_LENGTH}
254+
</div>
255+
</div>
256+
{errors.message && (
257+
<p className="text-sm text-error" role="alert">
258+
{errors.message}
259+
</p>
260+
)}
261+
</div>
262+
263+
{errors.general && (
264+
<div className="rounded-lg bg-error-light p-4">
265+
<p className="text-sm text-error" role="alert">
266+
{errors.general}
267+
</p>
268+
</div>
269+
)}
270+
271+
<Button
272+
onClick={handleSubmit}
273+
size="lg"
274+
disabled={submissionState === "submitting"}
275+
customEventOptions={{
276+
eventCategory: "enterprise",
277+
eventAction: "CTA",
278+
eventName: "bottom_mail",
279+
}}
280+
className="flex items-center justify-center gap-2 max-sm:w-full"
281+
>
282+
{submissionState === "submitting" ? (
283+
<>
284+
<Spinner className="text-lg" />
285+
{strings.button.loading}
286+
</>
287+
) : (
288+
strings.button.label
289+
)}
290+
</Button>
291+
</div>
292+
)
293+
}
294+
295+
export default EnterpriseContactForm
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import dynamic from "next/dynamic"
2+
3+
import Loading from "./loading"
4+
5+
export default dynamic(() => import("."), { ssr: false, loading: Loading })
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Skeleton } from "@/components/ui/skeleton"
2+
3+
const Loading = () => (
4+
<div className="w-full max-w-[440px] space-y-6">
5+
<Skeleton className="h-[42px] w-full" />
6+
<Skeleton className="h-[200px] w-full" />
7+
</div>
8+
)
9+
10+
export default Loading

app/[locale]/enterprise/constants.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1-
// TODO: Confirm
2-
export const ENTERPRISE_MAILTO =
3-
"mailto:[email protected]?subject=Enterprise%20inquiry"
1+
export const ENTERPRISE_EMAIL = "[email protected]"
2+
export const ENTERPRISE_MAILTO = `mailto:${ENTERPRISE_EMAIL}?subject=Enterprise%20inquiry`
3+
export const MAX_EMAIL_LENGTH = 2 ** 6 // 64
4+
export const MAX_MESSAGE_LENGTH = 2 ** 12 // 4,096

0 commit comments

Comments
 (0)