Skip to content

Commit 0ea5775

Browse files
committed
feat: build out contact form
1 parent ccf2661 commit 0ea5775

File tree

5 files changed

+282
-35
lines changed

5 files changed

+282
-35
lines changed
Lines changed: 243 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,254 @@
11
"use client"
22

3+
import React, { useState } from "react"
4+
import { HeartHandshake } from "lucide-react"
5+
36
import { Button } from "@/components/ui/buttons/Button"
47
import Input from "@/components/ui/input"
8+
import { Spinner } from "@/components/ui/spinner"
59
import { Textarea } from "@/components/ui/textarea"
610

11+
import { CONTACT_FORM_CHAR_MIN } from "../../constants"
12+
713
type EnterpriseContactFormProps = {
8-
emailPlaceholder: string
9-
bodyPlaceholder: string
10-
buttonLabel: string
14+
strings: {
15+
error: {
16+
domain: string
17+
emailInvalid: string
18+
general: string
19+
minLength: React.ReactNode // constant injected into span
20+
required: string
21+
}
22+
placeholder: {
23+
input: string
24+
textarea: string
25+
}
26+
button: {
27+
label: string
28+
loading: string
29+
}
30+
success: {
31+
heading: string
32+
message: string
33+
}
34+
}
1135
}
1236

13-
const EnterpriseContactForm = ({
14-
emailPlaceholder,
15-
bodyPlaceholder,
16-
buttonLabel,
17-
}: EnterpriseContactFormProps) => (
18-
<div className="w-full max-w-[440px] space-y-6">
19-
<Input type="email" className="w-full" placeholder={emailPlaceholder} />
20-
<Textarea className="" placeholder={bodyPlaceholder} />
21-
<Button
22-
onClick={() => {
23-
console.log("Submit form!")
24-
}}
25-
size="lg"
26-
customEventOptions={{
27-
eventCategory: "enterprise",
28-
eventAction: "CTA",
29-
eventName: "bottom_mail",
30-
}}
31-
>
32-
{buttonLabel}
33-
</Button>
34-
</div>
35-
)
37+
type FormState = {
38+
email: string
39+
message: string
40+
}
41+
42+
type FormErrors = {
43+
email?: string
44+
message?: React.ReactNode
45+
general?: string
46+
}
47+
48+
type SubmissionState = "idle" | "submitting" | "success" | "error"
49+
50+
// Consumer email domains to block
51+
const CONSUMER_DOMAINS = [
52+
"gmail.com",
53+
"yahoo.com",
54+
"hotmail.com",
55+
"outlook.com",
56+
"icloud.com",
57+
"protonmail.com",
58+
"proton.me",
59+
"pm.me",
60+
"aol.com",
61+
"mail.com",
62+
"yandex.com",
63+
"tutanota.com",
64+
"fastmail.com",
65+
"zoho.com",
66+
"gmx.com",
67+
"live.com",
68+
"msn.com",
69+
"me.com",
70+
"mac.com",
71+
"rocketmail.com",
72+
"yahoo.co.uk",
73+
"googlemail.com",
74+
"mailinator.com",
75+
"10minutemail.com",
76+
"guerrillamail.com",
77+
]
78+
79+
const sanitizeInput = (input: string): string =>
80+
input
81+
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
82+
.replace(/javascript:/gi, "")
83+
.replace(/on\w+\s*=/gi, "")
84+
.replace(/&lt;script/gi, "")
85+
.replace(/&lt;\/script/gi, "")
86+
.trim()
87+
88+
const EnterpriseContactForm = ({ strings }: EnterpriseContactFormProps) => {
89+
const [formData, setFormData] = useState<FormState>({
90+
email: "",
91+
message: "",
92+
})
93+
const [errors, setErrors] = useState<FormErrors>({})
94+
const [submissionState, setSubmissionState] =
95+
useState<SubmissionState>("idle")
96+
97+
const handleInputChange =
98+
(field: keyof FormState) =>
99+
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
100+
const value = e.target.value
101+
setFormData((prev) => ({ ...prev, [field]: value }))
102+
103+
// Clear error when user starts typing
104+
if (errors[field]) {
105+
setErrors((prev) => ({ ...prev, [field]: undefined }))
106+
}
107+
}
108+
109+
const validateEmail = (email: string): string | undefined => {
110+
const sanitized = sanitizeInput(email)
111+
112+
if (!sanitized) return strings.error.required
113+
114+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
115+
if (!emailRegex.test(sanitized)) return strings.error.emailInvalid
116+
117+
const domain = sanitized.toLowerCase().split("@")[1]
118+
if (CONSUMER_DOMAINS.includes(domain)) return strings.error.domain
119+
120+
return undefined
121+
}
122+
123+
const validateMessage = (
124+
message: string
125+
): React.ReactNode | string | undefined => {
126+
const sanitized = sanitizeInput(message)
127+
128+
if (!sanitized) return strings.error.required
129+
130+
if (sanitized.length < CONTACT_FORM_CHAR_MIN) return strings.error.minLength
131+
132+
return undefined
133+
}
134+
135+
const validateForm = (): boolean => {
136+
const newErrors: FormErrors = {}
137+
138+
const emailError = validateEmail(formData.email)
139+
if (emailError) newErrors.email = emailError
140+
141+
const messageError = validateMessage(formData.message)
142+
if (messageError) newErrors.message = messageError
143+
144+
setErrors(newErrors)
145+
return Object.keys(newErrors).length === 0
146+
}
147+
148+
const handleSubmit = async () => {
149+
if (!validateForm()) return
150+
151+
setSubmissionState("submitting")
152+
setErrors({})
153+
154+
try {
155+
const sanitizedData = {
156+
email: sanitizeInput(formData.email),
157+
message: sanitizeInput(formData.message),
158+
}
159+
160+
const response = await fetch("/api/enterprise-contact", {
161+
method: "POST",
162+
headers: {
163+
"Content-Type": "application/json",
164+
},
165+
body: JSON.stringify(sanitizedData),
166+
})
167+
168+
if (!response.ok) throw new Error(`Server error: ${response.status}`)
169+
170+
setSubmissionState("success")
171+
} catch (error) {
172+
console.error("Form submission error:", error)
173+
setSubmissionState("error")
174+
setErrors({ general: strings.error.general })
175+
}
176+
}
177+
178+
if (submissionState === "success")
179+
return (
180+
<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">
181+
<div className="mb-2 flex items-center gap-4">
182+
<HeartHandshake className="size-8 text-primary" />
183+
<h3 className="text-2xl font-semibold">{strings.success.heading}</h3>
184+
</div>
185+
<p className="text-body-medium">{strings.success.message}</p>
186+
</div>
187+
)
188+
189+
return (
190+
<div className="w-full max-w-[440px] space-y-6">
191+
<div className="space-y-2">
192+
<Input
193+
type="email"
194+
className="w-full"
195+
placeholder={strings.placeholder.input}
196+
value={formData.email}
197+
onChange={handleInputChange("email")}
198+
disabled={submissionState === "submitting"}
199+
/>
200+
{errors.email && (
201+
<p className="text-sm text-error" role="alert">
202+
{errors.email}
203+
</p>
204+
)}
205+
</div>
206+
207+
<div className="space-y-2">
208+
<Textarea
209+
placeholder={strings.placeholder.textarea}
210+
value={formData.message}
211+
onChange={handleInputChange("message")}
212+
disabled={submissionState === "submitting"}
213+
className="min-h-[120px]"
214+
/>
215+
{errors.message && (
216+
<p className="text-sm text-error" role="alert">
217+
{errors.message}
218+
</p>
219+
)}
220+
</div>
221+
222+
{errors.general && (
223+
<div className="rounded-lg bg-error-light p-4">
224+
<p className="text-sm text-error" role="alert">
225+
{errors.general}
226+
</p>
227+
</div>
228+
)}
229+
230+
<Button
231+
onClick={handleSubmit}
232+
size="lg"
233+
disabled={submissionState === "submitting"}
234+
customEventOptions={{
235+
eventCategory: "enterprise",
236+
eventAction: "CTA",
237+
eventName: "bottom_mail",
238+
}}
239+
className="flex items-center justify-center gap-2 max-sm:w-full"
240+
>
241+
{submissionState === "submitting" ? (
242+
<>
243+
<Spinner className="text-lg" />
244+
{strings.button.loading}
245+
</>
246+
) : (
247+
strings.button.label
248+
)}
249+
</Button>
250+
</div>
251+
)
252+
}
36253

37254
export default EnterpriseContactForm

app/[locale]/enterprise/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
// TODO: Confirm
22
export const ENTERPRISE_MAILTO =
33
"mailto:[email protected]?subject=Enterprise%20inquiry"
4+
5+
export const CONTACT_FORM_CHAR_MIN = 40

app/[locale]/enterprise/page.tsx

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ import { BASE_TIME_UNIT } from "@/lib/constants"
5050
import CasesColumn from "./_components/CasesColumn"
5151
import EnterpriseContactForm from "./_components/ContactForm/lazy"
5252
import FeatureCard from "./_components/FeatureCard"
53-
import { ENTERPRISE_MAILTO } from "./constants"
53+
import { CONTACT_FORM_CHAR_MIN, ENTERPRISE_MAILTO } from "./constants"
5454
import type { Case, EcosystemPlayer, Feature } from "./types"
5555
import { parseActivity } from "./utils"
5656

@@ -96,7 +96,6 @@ const Page = async ({ params }: { params: { locale: Lang } }) => {
9696
const { locale } = params
9797

9898
const t = await getTranslations({ locale, namespace: "page-enterprise" })
99-
const tCommon = await getTranslations({ locale, namespace: "common" })
10099

101100
const [
102101
{ txCount, txCostsMedianUsd },
@@ -483,7 +482,7 @@ const Page = async ({ params }: { params: { locale: Lang } }) => {
483482

484483
<section
485484
id="team"
486-
className="flex w-full flex-col items-center gap-y-12 rounded-4xl border border-accent-a/20 bg-gradient-to-b from-accent-a/5 to-accent-a/10 py-10 md:py-12"
485+
className="flex w-full flex-col items-center gap-y-12 rounded-4xl border border-accent-a/20 bg-gradient-to-b from-accent-a/5 to-accent-a/10 px-4 py-10 md:py-12"
487486
>
488487
<div className="flex flex-col items-center gap-2">
489488
<EthGlyph className="size-14" />
@@ -495,9 +494,31 @@ const Page = async ({ params }: { params: { locale: Lang } }) => {
495494
{t("page-enterprise-team-description")}
496495
</p>
497496
<EnterpriseContactForm
498-
buttonLabel={tCommon("set-up-a-call")}
499-
emailPlaceholder={tCommon("your-email")}
500-
bodyPlaceholder={t("page-enterprise-team-form-placeholder")}
497+
strings={{
498+
error: {
499+
domain: t("page-enterprise-team-form-error-domain"),
500+
emailInvalid: t(
501+
"page-enterprise-team-form-error-email-invalid"
502+
),
503+
general: t("page-enterprise-team-form-error-general"),
504+
minLength: t.rich("page-enterprise-team-form-error-short", {
505+
span: () => CONTACT_FORM_CHAR_MIN,
506+
}),
507+
required: t("page-enterprise-team-form-error-required"),
508+
},
509+
placeholder: {
510+
input: t("page-enterprise-team-form-placeholder-input"),
511+
textarea: t("page-enterprise-team-form-placeholder-textarea"),
512+
},
513+
button: {
514+
label: t("page-enterprise-hero-cta"),
515+
loading: t("page-enterprise-team-form-button-loading"),
516+
},
517+
success: {
518+
heading: t("page-enterprise-team-form-success-heading"),
519+
message: t("page-enterprise-team-form-success-message"),
520+
},
521+
}}
501522
/>
502523
</section>
503524
</MainArticle>

src/intl/en/common.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,6 @@
415415
"secret-leader-election": "Secret leader election",
416416
"security": "Security",
417417
"see-contributors": "See contributors",
418-
"set-up-a-call": "Set up a call",
419418
"set-up-local-env": "Set up local environment",
420419
"sharding": "Sharding",
421420
"show-all": "Show all",
@@ -465,6 +464,5 @@
465464
"withdrawals": "Staking withdrawals",
466465
"wrapped-ether": "Wrapped Ether",
467466
"yes": "Yes",
468-
"your-email": "Your e-mail",
469467
"zero-knowledge-proofs": "Zero-knowledge proofs"
470468
}

src/intl/en/page-enterprise.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,16 @@
4242
"page-enterprise-reason-4-header": "Battle-tested",
4343
"page-enterprise-team-description": "We will answer your questions, help identify potential paths forward, provide technical support and connect you with relevant industry leaders.",
4444
"page-enterprise-team-header": "Ethereum Enterprise Team",
45-
"page-enterprise-team-form-placeholder": "Tell us about your project",
45+
"page-enterprise-team-form-button-loading": "Beaming request",
46+
"page-enterprise-team-form-error-domain": "Please use a business, institutional, or organizational email address",
47+
"page-enterprise-team-form-error-email-invalid": "Please enter a valid email address",
48+
"page-enterprise-team-form-error-general": "Unable to send your message. Please try again or contact us directly at [email protected]",
49+
"page-enterprise-team-form-error-required": "Required",
50+
"page-enterprise-team-form-error-short": "Please provide at least <span>#</span> characters describing your inquiry",
51+
"page-enterprise-team-form-placeholder-input": "Your e-mail",
52+
"page-enterprise-team-form-placeholder-textarea": "Tell us about your project",
53+
"page-enterprise-team-form-success-heading": "Thanks for reaching out!",
54+
"page-enterprise-team-form-success-message": "We've received your message and someone from our enterprise team will get back to you within a few business days.",
4655
"page-enterprise-why-description": "Ethereum supports enterprise compliance with transparent, auditable infrastructure that aligns with GDPR and KYC while protecting sensitive data in private or consortium environments.",
4756
"page-enterprise-why-header": "Why Ethereum"
4857
}

0 commit comments

Comments
 (0)