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
6 changes: 6 additions & 0 deletions apps/ui/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,12 @@
"noFieldsTitle": "No form fields configured",
"noFieldsDescription": "This form has no visible fields. Please check the HubSpot form configuration.",
"reviewErrors": "Please review the highlighted fields above and correct the errors.",
"errors": {
"blockedEmail": "Please use your work email address. Free email providers are not accepted.",
"invalidEmail": "Please enter a valid email address.",
"requiredField": "This field is required.",
"invalidValue": "This value is not accepted. Please check and try again."
},
"demo": {
"settingUp": "Setting up your demo...",
"readyTitle": "Your demo is ready!",
Expand Down
46 changes: 45 additions & 1 deletion apps/ui/src/app/api/hubspot/submit/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,36 @@ import {
CONSENT_FIELD_PREFIX,
CONSENT_TO_PROCESS_FIELD,
} from "@/components/page-builder/components/forms/strapi-hubspot-form-ssr/helpers"
import { submitHubSpotForm } from "@/lib/hubspot"
import { HubSpotSubmissionError, submitHubSpotForm } from "@/lib/hubspot"
import { verifyRecaptcha } from "@/lib/recaptcha"

export const dynamic = "force-dynamic"

/** HubSpot error types that always concern the email field. */
const EMAIL_ERROR_TYPES = new Set(["BLOCKED_EMAIL", "INVALID_EMAIL"])

/**
* Best-effort mapping of a HubSpot error to the form field it concerns.
* HubSpot messages look like `fields.email.value: ...` or mention the field name.
*/
function fieldForHubSpotError(
errorType: string,
message: string,
fieldNames: string[]
): string | undefined {
if (EMAIL_ERROR_TYPES.has(errorType)) {
return "email"
}

const fromPath = /fields\.(\w+)/.exec(message)?.[1]

if (fromPath && fieldNames.includes(fromPath)) {
return fromPath
}

return fieldNames.find((name) => message.includes(`'${name}'`))
}

/** Proxy for HubSpot form submissions — verifies reCAPTCHA (opt-in), strips consent fields, and forwards to HubSpot API. */
async function handler(request: Request) {
const body = await request.json().catch(() => null)
Expand Down Expand Up @@ -61,6 +86,25 @@ async function handler(request: Request) {
} catch (error) {
console.error("Error submitting HubSpot form:", error)

// HubSpot rejected the data: pass the reasons through (with the field
// they concern) so the UI can show them inline instead of a generic error.
if (error instanceof HubSpotSubmissionError && error.errors.length > 0) {
const fieldNames = hubspotFields.map((f) => f.name)

return Response.json(
{
error:
"Form submission failed. Please review the highlighted fields.",
errors: error.errors.map((e) => ({
code: e.errorType,
message: e.message,
field: fieldForHubSpotError(e.errorType, e.message, fieldNames),
})),
},
{ status: 400 }
)
}

return Response.json(
{ error: "Form submission failed. Please try again." },
{ status: 500 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,23 @@ interface SubmitResult {
redirectUri?: string
}

interface SubmitFieldError {
code: string
message: string
field?: string
}

/** Error thrown by the submit call, carrying HubSpot's per-field reasons when available. */
class SubmitError extends Error {
readonly fieldErrors: SubmitFieldError[]

constructor(message: string, fieldErrors: SubmitFieldError[] = []) {
super(message)
this.name = "SubmitError"
this.fieldErrors = fieldErrors
}
}

/** Returns the trimmed string, or undefined when empty or not a string. */
function nonEmpty(value: unknown): string | undefined {
if (typeof value !== "string") {
Expand All @@ -73,10 +90,13 @@ async function submitHubSpotForm(
}),
})

const data = await res.json()
const data = await res.json().catch(() => ({}))

if (!res.ok) {
throw new Error(data.error || "Submission failed")
throw new SubmitError(
data.error || "Submission failed",
Array.isArray(data.errors) ? data.errors : []
)
}

return data
Expand Down Expand Up @@ -112,6 +132,20 @@ function HubSpotSsrFormInner({
}: HubSpotSsrFormProps) {
const t = useTranslations("forms")

const messageForErrorCode = (code: string): string => {
switch (code) {
case "BLOCKED_EMAIL":
return t("errors.blockedEmail")
case "INVALID_EMAIL":
return t("errors.invalidEmail")
case "REQUIRED_FIELD":
return t("errors.requiredField")

default:
return t("errors.invalidValue")
}
}

const zodSchema = useMemo(() => buildZodSchema(schema), [schema])
const defaultValues = useMemo(() => buildDefaultValues(schema), [schema])

Expand Down Expand Up @@ -157,9 +191,38 @@ function HubSpotSsrFormInner({
}
},
onError: (err) => {
form.setError("root", {
message: err instanceof Error ? err.message : t("submissionError"),
})
const fieldErrors = err instanceof SubmitError ? err.fieldErrors : []
let shownInline = false

// Attach HubSpot's reasons to the fields they concern, with a readable
// message per error code; anything unmapped falls back to the banner.
for (const fieldError of fieldErrors) {
const fieldName = fieldError.field

if (!fieldName || !visibleFields.some((f) => f.name === fieldName)) {
continue
}

form.setError(fieldName, {
type: "server",
message: messageForErrorCode(fieldError.code),
})
shownInline = true
}

if (shownInline) {
return
}

// No field to attach the error to: show the most useful message we have.
let rootMessage =
err instanceof Error ? err.message : t("submissionError")

if (fieldErrors[0]) {
rootMessage = messageForErrorCode(fieldErrors[0].code)
}

form.setError("root", { message: rootMessage })
},
})

Expand Down Expand Up @@ -254,7 +317,9 @@ function HubSpotSsrFormInner({
/>

{form.formState.submitCount > 0 &&
Object.keys(form.formState.errors).length > 0 && (
Object.keys(form.formState.errors).some(
(key) => key !== "root"
) && (
<div
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
role="alert"
Expand Down
45 changes: 45 additions & 0 deletions apps/ui/src/lib/hubspot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,44 @@ export async function fetchHubSpotFormSchema(
return res.json()
}

export interface HubSpotSubmissionErrorDetail {
readonly message: string
readonly errorType: string
}

/**
* Thrown when HubSpot rejects a submission (4xx). Carries HubSpot's own
* error list so callers can surface a precise message (e.g. blocked email).
*/
export class HubSpotSubmissionError extends Error {
readonly status: number
readonly errors: readonly HubSpotSubmissionErrorDetail[]

constructor(status: number, errors: readonly HubSpotSubmissionErrorDetail[]) {
super(`HubSpot rejected the submission (status=${status})`)
this.name = "HubSpotSubmissionError"
this.status = status
this.errors = errors
}
}

function parseHubSpotErrors(text: string): HubSpotSubmissionErrorDetail[] {
try {
const parsed = JSON.parse(text) as {
errors?: { message?: unknown; errorType?: unknown }[]
}

return (parsed.errors ?? [])
.filter((e) => typeof e.errorType === "string")
.map((e) => ({
errorType: e.errorType as string,
message: typeof e.message === "string" ? e.message : "",
}))
} catch {
return []
}
}

/**
* Submit a HubSpot form via the submissions API.
* Server-only — requires HUBSPOT_API_TOKEN env var.
Expand Down Expand Up @@ -132,6 +170,13 @@ export async function submitHubSpotForm({
`HubSpot form submission failed (status=${res.status}):`,
text
)

// 4xx = HubSpot validated and rejected the data (blocked email, missing
// field...). Surface the details so the UI can show a precise message.
if (res.status >= 400 && res.status < 500) {
throw new HubSpotSubmissionError(res.status, parseHubSpotErrors(text))
}

throw new Error("Form submission failed")
}

Expand Down
Loading