mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 13:32:11 +00:00
Compare commits
6 Commits
glary/remo
...
glary/impl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
294d90f36e | ||
|
|
5563f0b096 | ||
|
|
56e4e79ac5 | ||
|
|
968d9971f5 | ||
|
|
f00d8f1cc0 | ||
|
|
bd555a8732 |
14
.env_example
14
.env_example
@@ -52,3 +52,17 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
|
||||
# When unset, the committed snapshot at apps/website/src/data/ashby-roles.snapshot.json is used.
|
||||
# WEBSITE_ASHBY_API_KEY=
|
||||
# WEBSITE_ASHBY_JOB_BOARD_NAME=comfy-org
|
||||
|
||||
# Sales contact form (apps/website /contact page) → HubSpot Forms API.
|
||||
# REQUIRED to enable form submissions in any environment (production, preview,
|
||||
# local). When unset the submit button is disabled — preview/staging deploys
|
||||
# fail-safe rather than silently posting test submissions to the live HubSpot
|
||||
# CRM. Both IDs are inlined into the static client bundle and are NOT secrets;
|
||||
# they appear in HubSpot's own embed code on every site that uses the form.
|
||||
# Production values (use only on the live comfy.org deploy):
|
||||
# PUBLIC_HUBSPOT_PORTAL_ID=244637579
|
||||
# PUBLIC_HUBSPOT_FORM_ID_CONTACT_SALES=94e05eab-1373-47f7-ab5e-d84f9e6aa262
|
||||
# PUBLIC_HUBSPOT_PORTAL_ID=
|
||||
# PUBLIC_HUBSPOT_FORM_ID_CONTACT_SALES=
|
||||
# Set to "eu1" only if the HubSpot account is hosted in the EU region.
|
||||
# PUBLIC_HUBSPOT_REGION=na1
|
||||
|
||||
242
apps/website/src/components/contact/FormSection.test.ts
Normal file
242
apps/website/src/components/contact/FormSection.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
// @vitest-environment happy-dom
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { cleanup, render, screen } from '@testing-library/vue'
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type * as SubmitModule from '../../utils/submitHubspotForm'
|
||||
|
||||
import { HubspotSubmissionError } from '../../utils/submitHubspotForm'
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubEnv('PUBLIC_HUBSPOT_PORTAL_ID', '244637579')
|
||||
vi.stubEnv(
|
||||
'PUBLIC_HUBSPOT_FORM_ID_CONTACT_SALES',
|
||||
'94e05eab-1373-47f7-ab5e-d84f9e6aa262'
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
vi.mock('../../composables/useHeroAnimation', () => ({
|
||||
useHeroAnimation: () => undefined
|
||||
}))
|
||||
|
||||
const submitMock = vi.fn()
|
||||
|
||||
vi.mock('../../utils/submitHubspotForm', async () => {
|
||||
const actual = await vi.importActual<typeof SubmitModule>(
|
||||
'../../utils/submitHubspotForm'
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
submitHubspotForm: (...args: unknown[]) => submitMock(...args),
|
||||
readHubspotTrackingCookie: () => 'test-hutk'
|
||||
}
|
||||
})
|
||||
|
||||
type SubmitArgs = Parameters<typeof SubmitModule.submitHubspotForm>[0]
|
||||
|
||||
import FormSection from './FormSection.vue'
|
||||
|
||||
async function fillRequiredFields(
|
||||
options: { selectBuildsWorkflows?: boolean } = {}
|
||||
) {
|
||||
const { selectBuildsWorkflows = true } = options
|
||||
const user = userEvent.setup()
|
||||
await user.type(screen.getByLabelText(/first name/i), 'Jane')
|
||||
await user.type(screen.getByLabelText(/last name/i), 'Doe')
|
||||
await user.type(screen.getByLabelText(/work email/i), 'jane@acme.example')
|
||||
await user.click(screen.getByRole('radio', { name: /enterprise/i }))
|
||||
await user.click(screen.getByRole('radio', { name: /yes, in production/i }))
|
||||
if (selectBuildsWorkflows) {
|
||||
await user.click(
|
||||
screen.getByRole('checkbox', { name: /one dedicated technical owner/i })
|
||||
)
|
||||
}
|
||||
await user.type(
|
||||
screen.getByLabelText(/what are you looking for/i),
|
||||
'Need 50 seats'
|
||||
)
|
||||
return user
|
||||
}
|
||||
|
||||
describe('FormSection', () => {
|
||||
it('submits the HubSpot payload with mapped field names and objectTypeId', async () => {
|
||||
submitMock.mockReset()
|
||||
submitMock.mockResolvedValue({})
|
||||
render(FormSection)
|
||||
|
||||
const user = await fillRequiredFields()
|
||||
await user.click(screen.getByRole('button', { name: /submit/i }))
|
||||
|
||||
expect(submitMock).toHaveBeenCalledTimes(1)
|
||||
const args = submitMock.mock.calls[0][0] as SubmitArgs
|
||||
|
||||
expect(args.config.portalId).toBe('244637579')
|
||||
expect(args.config.formGuid).toBe('94e05eab-1373-47f7-ab5e-d84f9e6aa262')
|
||||
expect(args.context?.hutk).toBe('test-hutk')
|
||||
|
||||
const fieldByName = Object.fromEntries(args.fields.map((f) => [f.name, f]))
|
||||
expect(fieldByName.firstname).toEqual({
|
||||
objectTypeId: '0-1',
|
||||
name: 'firstname',
|
||||
value: 'Jane'
|
||||
})
|
||||
expect(fieldByName.email).toEqual({
|
||||
objectTypeId: '0-1',
|
||||
name: 'email',
|
||||
value: 'jane@acme.example'
|
||||
})
|
||||
expect(fieldByName.are_youyour_team_currently_using_comfy.value).toBe(
|
||||
'Yes, in production'
|
||||
)
|
||||
expect(
|
||||
fieldByName
|
||||
.to_give_you_ann_idea_of_pricing_upfront__while_comfyui_does_work_with_companies_of_all_sizes__our_mi
|
||||
.value
|
||||
).toBe('Yes')
|
||||
expect(fieldByName.who_primarily_builds_workflows.value).toBe(
|
||||
'One dedicated technical owner'
|
||||
)
|
||||
expect(fieldByName.comfy_intake_notes.value).toBe('Need 50 seats')
|
||||
})
|
||||
|
||||
it('shows the success message and resets the form on success', async () => {
|
||||
submitMock.mockReset()
|
||||
submitMock.mockResolvedValue({})
|
||||
render(FormSection)
|
||||
|
||||
const user = await fillRequiredFields()
|
||||
await user.click(screen.getByRole('button', { name: /submit/i }))
|
||||
|
||||
await screen.findByRole('status')
|
||||
expect(screen.getByRole('status').textContent).toContain(
|
||||
'your message is in'
|
||||
)
|
||||
expect(
|
||||
(screen.getByLabelText(/first name/i) as HTMLInputElement).value
|
||||
).toBe('')
|
||||
})
|
||||
|
||||
it('blocks submission with a localized error when no workflow-builder option is selected', async () => {
|
||||
submitMock.mockReset()
|
||||
render(FormSection)
|
||||
|
||||
const user = await fillRequiredFields({ selectBuildsWorkflows: false })
|
||||
await user.click(screen.getByRole('button', { name: /submit/i }))
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert.textContent).toMatch(/who primarily builds workflows/i)
|
||||
expect(submitMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('joins multiple workflow-builder selections with ";" for HubSpot', async () => {
|
||||
submitMock.mockReset()
|
||||
submitMock.mockResolvedValue({})
|
||||
render(FormSection)
|
||||
|
||||
const user = await fillRequiredFields()
|
||||
await user.click(
|
||||
screen.getByRole('checkbox', { name: /small group of power users/i })
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: /submit/i }))
|
||||
|
||||
expect(submitMock).toHaveBeenCalledTimes(1)
|
||||
const args = submitMock.mock.calls[0][0] as SubmitArgs
|
||||
const builds = args.fields.find(
|
||||
(f) => f.name === 'who_primarily_builds_workflows'
|
||||
)
|
||||
expect(builds?.value).toBe(
|
||||
'One dedicated technical owner;Small group of power users'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects whitespace-only required fields without calling HubSpot', async () => {
|
||||
submitMock.mockReset()
|
||||
render(FormSection)
|
||||
|
||||
const firstName = screen.getByLabelText(/first name/i) as HTMLInputElement
|
||||
const lastName = screen.getByLabelText(/last name/i) as HTMLInputElement
|
||||
const emailEl = screen.getByLabelText(/work email/i) as HTMLInputElement
|
||||
const lookingFor = screen.getByLabelText(
|
||||
/what are you looking for/i
|
||||
) as HTMLTextAreaElement
|
||||
firstName.value = ' '
|
||||
firstName.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
lastName.value = 'Doe'
|
||||
lastName.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
emailEl.value = 'jane@acme.example'
|
||||
emailEl.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
lookingFor.value = 'Need seats'
|
||||
lookingFor.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('radio', { name: /enterprise/i }))
|
||||
await user.click(screen.getByRole('radio', { name: /yes, in production/i }))
|
||||
await user.click(
|
||||
screen.getByRole('checkbox', { name: /one dedicated technical owner/i })
|
||||
)
|
||||
|
||||
const submitButton = screen.getByRole('button', {
|
||||
name: /submit/i
|
||||
}) as HTMLButtonElement
|
||||
const form = submitButton.form
|
||||
if (!form) throw new Error('submit button is not associated with a form')
|
||||
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert.textContent).toMatch(/required/i)
|
||||
expect(submitMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders the locale-aware privacy-policy link for non-default locales', () => {
|
||||
render(FormSection, { props: { locale: 'zh-CN' } })
|
||||
const link = screen.getByRole('link', { name: /隐私政策/i })
|
||||
expect(link.getAttribute('href')).toBe('/zh-CN/privacy-policy')
|
||||
})
|
||||
|
||||
it('renders the default privacy-policy link for English', () => {
|
||||
render(FormSection)
|
||||
const link = screen.getByRole('link', { name: /privacy policy/i })
|
||||
expect(link.getAttribute('href')).toBe('/privacy-policy')
|
||||
})
|
||||
|
||||
it('surfaces HubSpot validation messages on failure', async () => {
|
||||
submitMock.mockReset()
|
||||
submitMock.mockRejectedValue(
|
||||
new HubspotSubmissionError('boom', 400, [
|
||||
{
|
||||
message: 'Email is required.',
|
||||
errorType: 'REQUIRED_FIELD',
|
||||
in: 'email'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
render(FormSection)
|
||||
const user = await fillRequiredFields()
|
||||
await user.click(screen.getByRole('button', { name: /submit/i }))
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert.textContent).toContain('Email is required.')
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FormSection without HubSpot env vars (fail-safe)', () => {
|
||||
beforeAll(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('disables the submit button when the HubSpot env vars are unset', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
render(FormSection)
|
||||
const button = screen.getByRole('button', {
|
||||
name: /submit/i
|
||||
}) as HTMLButtonElement
|
||||
expect(button.disabled).toBe(true)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { useHeroAnimation } from '../../composables/useHeroAnimation'
|
||||
import { getRoutes } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
import {
|
||||
HubspotSubmissionError,
|
||||
readHubspotTrackingCookie,
|
||||
resolveHubspotRegion,
|
||||
submitHubspotForm
|
||||
} from '../../utils/submitHubspotForm'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import SectionLabel from '../common/SectionLabel.vue'
|
||||
|
||||
@@ -13,30 +20,90 @@ const { locale = 'en' } = defineProps<{
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const privacyPolicyHref = getRoutes(locale).privacyPolicy
|
||||
|
||||
function tk(suffix: string): TranslationKey {
|
||||
return `contact.form.${suffix}` as TranslationKey
|
||||
}
|
||||
|
||||
const hubspotConfig = {
|
||||
portalId: import.meta.env.PUBLIC_HUBSPOT_PORTAL_ID ?? '',
|
||||
formGuid: import.meta.env.PUBLIC_HUBSPOT_FORM_ID_CONTACT_SALES ?? '',
|
||||
region: resolveHubspotRegion(import.meta.env.PUBLIC_HUBSPOT_REGION)
|
||||
}
|
||||
const isFormConfigured = Boolean(
|
||||
hubspotConfig.portalId && hubspotConfig.formGuid
|
||||
)
|
||||
if (typeof window !== 'undefined' && !isFormConfigured) {
|
||||
console.warn(
|
||||
'[contact-form] HubSpot form is not configured. Set PUBLIC_HUBSPOT_PORTAL_ID and PUBLIC_HUBSPOT_FORM_ID_CONTACT_SALES to enable submissions.'
|
||||
)
|
||||
}
|
||||
|
||||
const HUBSPOT_FIELD_NAMES = {
|
||||
firstName: 'firstname',
|
||||
lastName: 'lastname',
|
||||
email: 'email',
|
||||
phone: 'phone',
|
||||
package:
|
||||
'to_give_you_ann_idea_of_pricing_upfront__while_comfyui_does_work_with_companies_of_all_sizes__our_mi',
|
||||
comfyUsage: 'are_youyour_team_currently_using_comfy',
|
||||
buildsWorkflows: 'who_primarily_builds_workflows',
|
||||
lookingFor: 'comfy_intake_notes'
|
||||
} as const
|
||||
|
||||
interface Option {
|
||||
key: string
|
||||
hubspotValue: string
|
||||
}
|
||||
|
||||
const packageOptions: readonly Option[] = [
|
||||
{ key: 'packageIndividual', hubspotValue: 'No' },
|
||||
{ key: 'packageTeams', hubspotValue: 'Teams' },
|
||||
{ key: 'packageEnterprise', hubspotValue: 'Yes' }
|
||||
]
|
||||
|
||||
const usageOptions: readonly Option[] = [
|
||||
{ key: 'usingYesProduction', hubspotValue: 'Yes, in production' },
|
||||
{ key: 'usingYesTesting', hubspotValue: 'Yes, testing / experimenting' },
|
||||
{ key: 'usingNotYet', hubspotValue: 'Not yet, evaluating' },
|
||||
{
|
||||
key: 'usingOtherTools',
|
||||
hubspotValue: 'Not using Comfy yet, but using other GenAI tools'
|
||||
}
|
||||
]
|
||||
|
||||
const buildsOptions: readonly Option[] = [
|
||||
{
|
||||
key: 'buildsDedicatedOwner',
|
||||
hubspotValue: 'One dedicated technical owner'
|
||||
},
|
||||
{ key: 'buildsPowerUsers', hubspotValue: 'Small group of power users' },
|
||||
{ key: 'buildsEveryone', hubspotValue: 'Everyone builds their own' },
|
||||
{
|
||||
key: 'buildsExternal',
|
||||
hubspotValue: 'External consultant / partner'
|
||||
}
|
||||
]
|
||||
|
||||
const firstName = ref('')
|
||||
const lastName = ref('')
|
||||
const company = ref('')
|
||||
const email = ref('')
|
||||
const phone = ref('')
|
||||
const selectedPackage = ref('')
|
||||
const comfyUsage = ref('')
|
||||
const buildsWorkflows = ref<string[]>([])
|
||||
const lookingFor = ref('')
|
||||
|
||||
const packageOptions = [
|
||||
'packageIndividual',
|
||||
'packageTeams',
|
||||
'packageEnterprise'
|
||||
] as const
|
||||
type SubmitStatus = 'idle' | 'submitting' | 'success' | 'error'
|
||||
const status = ref<SubmitStatus>('idle')
|
||||
const errorDetail = ref<string>('')
|
||||
|
||||
const usageOptions = [
|
||||
'usingYesProduction',
|
||||
'usingYesTesting',
|
||||
'usingNotYet',
|
||||
'usingOtherTools'
|
||||
] as const
|
||||
const submitButtonLabel = computed(() =>
|
||||
status.value === 'submitting'
|
||||
? t(tk('submitting'), locale)
|
||||
: t(tk('submit'), locale)
|
||||
)
|
||||
|
||||
const inputClass =
|
||||
'text-primary-comfy-canvas placeholder:text-primary-comfy-canvas/30 border-primary-warm-gray/20 focus:border-primary-comfy-yellow mt-2 w-full rounded-2xl border bg-transparency-white-t4 p-4 text-sm transition-colors outline-none'
|
||||
@@ -56,8 +123,82 @@ useHeroAnimation({
|
||||
parallax: false
|
||||
})
|
||||
|
||||
function handleSubmit() {
|
||||
// TODO: implement form submission
|
||||
function resetForm() {
|
||||
firstName.value = ''
|
||||
lastName.value = ''
|
||||
email.value = ''
|
||||
phone.value = ''
|
||||
selectedPackage.value = ''
|
||||
comfyUsage.value = ''
|
||||
buildsWorkflows.value = []
|
||||
lookingFor.value = ''
|
||||
}
|
||||
|
||||
function field(name: string, value: string) {
|
||||
return { objectTypeId: '0-1', name, value }
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (status.value === 'submitting') return
|
||||
if (!isFormConfigured) {
|
||||
status.value = 'error'
|
||||
errorDetail.value = ''
|
||||
return
|
||||
}
|
||||
const trimmedFirstName = firstName.value.trim()
|
||||
const trimmedLastName = lastName.value.trim()
|
||||
const trimmedEmail = email.value.trim()
|
||||
const trimmedPhone = phone.value.trim()
|
||||
const trimmedLookingFor = lookingFor.value.trim()
|
||||
if (
|
||||
!trimmedFirstName ||
|
||||
!trimmedLastName ||
|
||||
!trimmedEmail ||
|
||||
!trimmedLookingFor
|
||||
) {
|
||||
status.value = 'error'
|
||||
errorDetail.value = t(tk('requiredFieldsMissing'), locale)
|
||||
return
|
||||
}
|
||||
if (buildsWorkflows.value.length === 0) {
|
||||
status.value = 'error'
|
||||
errorDetail.value = t(tk('buildsWorkflowsRequired'), locale)
|
||||
return
|
||||
}
|
||||
status.value = 'submitting'
|
||||
errorDetail.value = ''
|
||||
try {
|
||||
await submitHubspotForm({
|
||||
config: hubspotConfig,
|
||||
fields: [
|
||||
field(HUBSPOT_FIELD_NAMES.firstName, trimmedFirstName),
|
||||
field(HUBSPOT_FIELD_NAMES.lastName, trimmedLastName),
|
||||
field(HUBSPOT_FIELD_NAMES.email, trimmedEmail),
|
||||
field(HUBSPOT_FIELD_NAMES.phone, trimmedPhone),
|
||||
field(HUBSPOT_FIELD_NAMES.package, selectedPackage.value),
|
||||
field(HUBSPOT_FIELD_NAMES.comfyUsage, comfyUsage.value),
|
||||
field(
|
||||
HUBSPOT_FIELD_NAMES.buildsWorkflows,
|
||||
buildsWorkflows.value.join(';')
|
||||
),
|
||||
field(HUBSPOT_FIELD_NAMES.lookingFor, trimmedLookingFor)
|
||||
],
|
||||
context: {
|
||||
hutk: readHubspotTrackingCookie(),
|
||||
pageUri:
|
||||
typeof window === 'undefined' ? undefined : window.location.href,
|
||||
pageName: typeof document === 'undefined' ? undefined : document.title
|
||||
}
|
||||
})
|
||||
status.value = 'success'
|
||||
resetForm()
|
||||
} catch (error) {
|
||||
console.error('Sales form submission failed', error)
|
||||
if (error instanceof HubspotSubmissionError && error.errors.length > 0) {
|
||||
errorDetail.value = error.errors.map((e) => e.message).join(' ')
|
||||
}
|
||||
status.value = 'error'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -108,52 +249,44 @@ function handleSubmit() {
|
||||
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||
<!-- First Name + Last Name -->
|
||||
<div class="lg:grid lg:grid-cols-2 lg:gap-4">
|
||||
<div>
|
||||
<label class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(tk('firstName'), locale) }}*
|
||||
</label>
|
||||
<label class="text-primary-comfy-canvas block text-xs">
|
||||
<span>{{ t(tk('firstName'), locale) }}*</span>
|
||||
<input
|
||||
v-model="firstName"
|
||||
v-model.trim="firstName"
|
||||
type="text"
|
||||
required
|
||||
:placeholder="t(tk('firstNamePlaceholder'), locale)"
|
||||
:class="inputClass"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-6 lg:mt-0">
|
||||
<label class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(tk('lastName'), locale) }}*
|
||||
</label>
|
||||
</label>
|
||||
<label class="text-primary-comfy-canvas mt-6 block text-xs lg:mt-0">
|
||||
<span>{{ t(tk('lastName'), locale) }}*</span>
|
||||
<input
|
||||
v-model="lastName"
|
||||
v-model.trim="lastName"
|
||||
type="text"
|
||||
required
|
||||
:placeholder="t(tk('lastNamePlaceholder'), locale)"
|
||||
:class="inputClass"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Company + Phone -->
|
||||
<!-- Email + Phone -->
|
||||
<div class="lg:grid lg:grid-cols-2 lg:gap-4">
|
||||
<div>
|
||||
<label class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(tk('company'), locale) }}*
|
||||
</label>
|
||||
<label class="text-primary-comfy-canvas block text-xs">
|
||||
<span>{{ t(tk('email'), locale) }}*</span>
|
||||
<input
|
||||
v-model="company"
|
||||
type="text"
|
||||
v-model.trim="email"
|
||||
type="email"
|
||||
required
|
||||
:placeholder="t(tk('companyPlaceholder'), locale)"
|
||||
:placeholder="t(tk('emailPlaceholder'), locale)"
|
||||
:class="inputClass"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-6 lg:mt-0">
|
||||
<label class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(tk('phone'), locale) }}
|
||||
</label>
|
||||
<input v-model="phone" type="tel" :class="inputClass" />
|
||||
</div>
|
||||
</label>
|
||||
<label class="text-primary-comfy-canvas mt-6 block text-xs lg:mt-0">
|
||||
<span>{{ t(tk('phone'), locale) }}</span>
|
||||
<input v-model.trim="phone" type="tel" :class="inputClass" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Package selection -->
|
||||
@@ -164,11 +297,11 @@ function handleSubmit() {
|
||||
<div class="mt-3 flex flex-wrap gap-3">
|
||||
<label
|
||||
v-for="opt in packageOptions"
|
||||
:key="opt"
|
||||
:key="opt.key"
|
||||
:class="
|
||||
cn(
|
||||
'bg-transparency-white-t4 flex cursor-pointer items-center gap-2 rounded-lg border px-6 py-2 text-xs font-bold tracking-wider transition-colors',
|
||||
selectedPackage === opt
|
||||
selectedPackage === opt.hubspotValue
|
||||
? 'border-primary-comfy-yellow text-primary-comfy-yellow'
|
||||
: 'text-primary-comfy-canvas border-(--site-border-subtle)'
|
||||
)
|
||||
@@ -178,25 +311,26 @@ function handleSubmit() {
|
||||
v-model="selectedPackage"
|
||||
type="radio"
|
||||
name="package"
|
||||
:value="opt"
|
||||
:value="opt.hubspotValue"
|
||||
required
|
||||
class="sr-only"
|
||||
/>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'flex size-4 shrink-0 items-center justify-center rounded-full border',
|
||||
selectedPackage === opt
|
||||
selectedPackage === opt.hubspotValue
|
||||
? 'border-primary-comfy-yellow'
|
||||
: 'border-primary-warm-gray/40'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
v-if="selectedPackage === opt"
|
||||
v-if="selectedPackage === opt.hubspotValue"
|
||||
class="bg-primary-comfy-yellow size-2 rounded-full"
|
||||
/>
|
||||
</span>
|
||||
{{ t(tk(opt), locale) }}
|
||||
{{ t(tk(opt.key), locale) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -209,54 +343,129 @@ function handleSubmit() {
|
||||
<div class="mt-3 space-y-3">
|
||||
<label
|
||||
v-for="opt in usageOptions"
|
||||
:key="opt"
|
||||
:key="opt.key"
|
||||
class="flex cursor-pointer items-center gap-3"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'flex size-4 shrink-0 items-center justify-center rounded-full border',
|
||||
comfyUsage === opt
|
||||
comfyUsage === opt.hubspotValue
|
||||
? 'border-primary-comfy-yellow'
|
||||
: 'border-(--site-border-subtle)'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
v-if="comfyUsage === opt"
|
||||
v-if="comfyUsage === opt.hubspotValue"
|
||||
class="bg-primary-comfy-yellow size-2 rounded-full"
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
v-model="comfyUsage"
|
||||
type="radio"
|
||||
:value="opt"
|
||||
name="comfyUsage"
|
||||
:value="opt.hubspotValue"
|
||||
required
|
||||
class="sr-only"
|
||||
/>
|
||||
<span class="text-primary-comfy-canvas text-sm">
|
||||
{{ t(tk(opt), locale) }}
|
||||
{{ t(tk(opt.key), locale) }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Who primarily builds workflows (multi-select) -->
|
||||
<div>
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(tk('buildsWorkflows'), locale) }}*
|
||||
</p>
|
||||
<div class="mt-3 space-y-3">
|
||||
<label
|
||||
v-for="opt in buildsOptions"
|
||||
:key="opt.key"
|
||||
class="flex cursor-pointer items-center gap-3"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'flex size-4 shrink-0 items-center justify-center rounded-sm border',
|
||||
buildsWorkflows.includes(opt.hubspotValue)
|
||||
? 'border-primary-comfy-yellow bg-primary-comfy-yellow'
|
||||
: 'border-(--site-border-subtle)'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
v-if="buildsWorkflows.includes(opt.hubspotValue)"
|
||||
class="size-2 rounded-sm bg-(--site-bg)"
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
v-model="buildsWorkflows"
|
||||
type="checkbox"
|
||||
name="buildsWorkflows"
|
||||
:value="opt.hubspotValue"
|
||||
class="sr-only"
|
||||
/>
|
||||
<span class="text-primary-comfy-canvas text-sm">
|
||||
{{ t(tk(opt.key), locale) }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Looking for -->
|
||||
<div>
|
||||
<label class="text-primary-comfy-canvas text-xs">
|
||||
{{ t(tk('lookingFor'), locale) }}
|
||||
</label>
|
||||
<label class="text-primary-comfy-canvas block text-xs">
|
||||
<span>{{ t(tk('lookingFor'), locale) }}*</span>
|
||||
<textarea
|
||||
v-model="lookingFor"
|
||||
v-model.trim="lookingFor"
|
||||
required
|
||||
:placeholder="t(tk('lookingForPlaceholder'), locale)"
|
||||
:class="cn(inputClass, 'min-h-24 resize-y')"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<!-- Submit -->
|
||||
<div>
|
||||
<BrandButton type="submit" variant="outline" size="sm">
|
||||
{{ t(tk('submit'), locale) }}
|
||||
<BrandButton
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="status === 'submitting' || !isFormConfigured"
|
||||
:class="
|
||||
cn(
|
||||
(status === 'submitting' || !isFormConfigured) &&
|
||||
'cursor-not-allowed opacity-60'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ submitButtonLabel }}
|
||||
</BrandButton>
|
||||
<p class="text-primary-comfy-canvas/60 mt-4 text-xs">
|
||||
{{ t(tk('privacyDisclosureBefore'), locale)
|
||||
}}<a :href="privacyPolicyHref" class="underline">{{
|
||||
t(tk('privacyDisclosureLinkLabel'), locale)
|
||||
}}</a
|
||||
>{{ t(tk('privacyDisclosureAfter'), locale) }}
|
||||
</p>
|
||||
<p
|
||||
v-if="status === 'success'"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="text-primary-comfy-yellow mt-4 text-sm"
|
||||
>
|
||||
{{ t(tk('successMessage'), locale) }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="status === 'error'"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
class="mt-4 text-sm text-red-400"
|
||||
>
|
||||
{{ errorDetail || t(tk('errorMessage'), locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -3306,9 +3306,9 @@ const translations = {
|
||||
en: 'Last Name',
|
||||
'zh-CN': '姓'
|
||||
},
|
||||
'contact.form.company': {
|
||||
en: 'Company',
|
||||
'zh-CN': '公司'
|
||||
'contact.form.email': {
|
||||
en: 'Work Email',
|
||||
'zh-CN': '工作邮箱'
|
||||
},
|
||||
'contact.form.phone': {
|
||||
en: 'Phone Number (optional)',
|
||||
@@ -3351,6 +3351,26 @@ const translations = {
|
||||
en: 'Not using Comfy yet, but using other GenAI tools',
|
||||
'zh-CN': '尚未使用 Comfy,但在使用其他 GenAI 工具'
|
||||
},
|
||||
'contact.form.buildsWorkflows': {
|
||||
en: 'Who primarily builds workflows?',
|
||||
'zh-CN': '主要由谁构建工作流?'
|
||||
},
|
||||
'contact.form.buildsDedicatedOwner': {
|
||||
en: 'One dedicated technical owner',
|
||||
'zh-CN': '一位专属技术负责人'
|
||||
},
|
||||
'contact.form.buildsPowerUsers': {
|
||||
en: 'Small group of power users',
|
||||
'zh-CN': '少数核心用户'
|
||||
},
|
||||
'contact.form.buildsEveryone': {
|
||||
en: 'Everyone builds their own',
|
||||
'zh-CN': '每个人各自构建'
|
||||
},
|
||||
'contact.form.buildsExternal': {
|
||||
en: 'External consultant / partner',
|
||||
'zh-CN': '外部顾问或合作伙伴'
|
||||
},
|
||||
'contact.form.lookingFor': {
|
||||
en: 'What are you looking for?',
|
||||
'zh-CN': '您在寻找什么?'
|
||||
@@ -3363,6 +3383,39 @@ const translations = {
|
||||
en: 'SUBMIT',
|
||||
'zh-CN': '提交'
|
||||
},
|
||||
'contact.form.submitting': {
|
||||
en: 'SUBMITTING…',
|
||||
'zh-CN': '正在提交…'
|
||||
},
|
||||
'contact.form.successMessage': {
|
||||
en: 'Thanks — your message is in. Our team will be in touch shortly.',
|
||||
'zh-CN': '感谢您 — 我们已收到您的信息,团队会尽快与您联系。'
|
||||
},
|
||||
'contact.form.errorMessage': {
|
||||
en: 'Something went wrong. Please try again or email sales@comfy.org.',
|
||||
'zh-CN': '提交失败。请重试,或发送邮件至 sales@comfy.org。'
|
||||
},
|
||||
'contact.form.buildsWorkflowsRequired': {
|
||||
en: 'Please select at least one option for who primarily builds workflows.',
|
||||
'zh-CN': '请至少选择一个主要负责构建工作流的人员选项。'
|
||||
},
|
||||
'contact.form.requiredFieldsMissing': {
|
||||
en: 'Please fill in all required fields.',
|
||||
'zh-CN': '请填写所有必填字段。'
|
||||
},
|
||||
'contact.form.privacyDisclosureBefore': {
|
||||
en: 'By submitting this form, your information is sent to our CRM provider, HubSpot, so our team can follow up. See our ',
|
||||
'zh-CN':
|
||||
'提交此表单即表示您的信息将发送至我们的 CRM 服务商 HubSpot,以便团队与您联系。详情请参阅我们的'
|
||||
},
|
||||
'contact.form.privacyDisclosureLinkLabel': {
|
||||
en: 'Privacy Policy',
|
||||
'zh-CN': '隐私政策'
|
||||
},
|
||||
'contact.form.privacyDisclosureAfter': {
|
||||
en: ' for details.',
|
||||
'zh-CN': '。'
|
||||
},
|
||||
'contact.form.firstNamePlaceholder': {
|
||||
en: 'Jane',
|
||||
'zh-CN': 'Jane'
|
||||
@@ -3371,9 +3424,9 @@ const translations = {
|
||||
en: 'Smith',
|
||||
'zh-CN': 'Smith'
|
||||
},
|
||||
'contact.form.companyPlaceholder': {
|
||||
en: 'jane@acme.org',
|
||||
'zh-CN': 'jane@acme.org'
|
||||
'contact.form.emailPlaceholder': {
|
||||
en: 'jane@company.com',
|
||||
'zh-CN': 'jane@company.com'
|
||||
},
|
||||
|
||||
'customers.story.whatsNext': {
|
||||
|
||||
302
apps/website/src/utils/submitHubspotForm.test.ts
Normal file
302
apps/website/src/utils/submitHubspotForm.test.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
HubspotSubmissionError,
|
||||
buildHubspotEndpoint,
|
||||
readHubspotTrackingCookie,
|
||||
resolveHubspotRegion,
|
||||
submitHubspotForm
|
||||
} from './submitHubspotForm'
|
||||
|
||||
const PORTAL = '12345'
|
||||
const FORM = 'abcd-form-guid'
|
||||
|
||||
function field(name: string, value: string) {
|
||||
return { objectTypeId: '0-1', name, value }
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
describe('buildHubspotEndpoint', () => {
|
||||
it('builds the NA1 endpoint by default', () => {
|
||||
expect(buildHubspotEndpoint({ portalId: PORTAL, formGuid: FORM })).toBe(
|
||||
`https://api.hsforms.com/submissions/v3/integration/submit/${PORTAL}/${FORM}`
|
||||
)
|
||||
})
|
||||
|
||||
it('switches host for the EU region', () => {
|
||||
expect(
|
||||
buildHubspotEndpoint({ portalId: PORTAL, formGuid: FORM, region: 'eu1' })
|
||||
).toBe(
|
||||
`https://api-eu1.hsforms.com/submissions/v3/integration/submit/${PORTAL}/${FORM}`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('submitHubspotForm', () => {
|
||||
it('POSTs JSON with fields (including objectTypeId) and context to the unauthenticated endpoint', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({}))
|
||||
|
||||
await submitHubspotForm({
|
||||
config: { portalId: PORTAL, formGuid: FORM },
|
||||
fields: [field('firstname', 'Jane'), field('email', 'jane@acme.org')],
|
||||
context: {
|
||||
hutk: 'tracking-cookie-value',
|
||||
pageUri: 'https://comfy.org/contact',
|
||||
pageName: 'Contact Sales'
|
||||
},
|
||||
fetchImpl
|
||||
})
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
const [url, init] = fetchImpl.mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe(
|
||||
`https://api.hsforms.com/submissions/v3/integration/submit/${PORTAL}/${FORM}`
|
||||
)
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.headers).toEqual({ 'Content-Type': 'application/json' })
|
||||
|
||||
const body = JSON.parse(init.body as string)
|
||||
expect(body.fields).toEqual([
|
||||
{ objectTypeId: '0-1', name: 'firstname', value: 'Jane' },
|
||||
{ objectTypeId: '0-1', name: 'email', value: 'jane@acme.org' }
|
||||
])
|
||||
expect(body.context).toEqual({
|
||||
hutk: 'tracking-cookie-value',
|
||||
pageUri: 'https://comfy.org/contact',
|
||||
pageName: 'Contact Sales'
|
||||
})
|
||||
expect(typeof body.submittedAt).toBe('number')
|
||||
})
|
||||
|
||||
it('drops fields with empty string values so HubSpot does not reject them', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({}))
|
||||
|
||||
await submitHubspotForm({
|
||||
config: { portalId: PORTAL, formGuid: FORM },
|
||||
fields: [
|
||||
field('firstname', 'Jane'),
|
||||
field('phone', ''),
|
||||
field('lastname', 'Doe')
|
||||
],
|
||||
fetchImpl
|
||||
})
|
||||
|
||||
const [, init] = fetchImpl.mock.calls[0] as [string, RequestInit]
|
||||
const body = JSON.parse(init.body as string)
|
||||
expect(body.fields).toEqual([
|
||||
{ objectTypeId: '0-1', name: 'firstname', value: 'Jane' },
|
||||
{ objectTypeId: '0-1', name: 'lastname', value: 'Doe' }
|
||||
])
|
||||
})
|
||||
|
||||
it('omits the context key entirely when no context is provided', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({}))
|
||||
|
||||
await submitHubspotForm({
|
||||
config: { portalId: PORTAL, formGuid: FORM },
|
||||
fields: [field('firstname', 'Jane')],
|
||||
fetchImpl
|
||||
})
|
||||
|
||||
const [, init] = fetchImpl.mock.calls[0] as [string, RequestInit]
|
||||
const body = JSON.parse(init.body as string)
|
||||
expect(body).not.toHaveProperty('context')
|
||||
})
|
||||
|
||||
it('strips undefined entries from the context but keeps null hutk', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({}))
|
||||
|
||||
await submitHubspotForm({
|
||||
config: { portalId: PORTAL, formGuid: FORM },
|
||||
fields: [field('firstname', 'Jane')],
|
||||
context: { hutk: null, pageUri: undefined, pageName: 'Contact' },
|
||||
fetchImpl
|
||||
})
|
||||
|
||||
const [, init] = fetchImpl.mock.calls[0] as [string, RequestInit]
|
||||
const body = JSON.parse(init.body as string)
|
||||
expect(body.context).toEqual({ hutk: null, pageName: 'Contact' })
|
||||
})
|
||||
|
||||
it('returns inlineMessage and redirectUri from a 200 response', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
inlineMessage: '<p>Thanks!</p>',
|
||||
redirectUri: 'https://comfy.org/thanks'
|
||||
})
|
||||
)
|
||||
|
||||
const result = await submitHubspotForm({
|
||||
config: { portalId: PORTAL, formGuid: FORM },
|
||||
fields: [field('firstname', 'Jane')],
|
||||
fetchImpl
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
inlineMessage: '<p>Thanks!</p>',
|
||||
redirectUri: 'https://comfy.org/thanks'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an empty object when a 200 response body is an array', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(jsonResponse([1, 2, 3]))
|
||||
|
||||
const result = await submitHubspotForm({
|
||||
config: { portalId: PORTAL, formGuid: FORM },
|
||||
fields: [field('firstname', 'Jane')],
|
||||
fetchImpl
|
||||
})
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('throws a HubspotSubmissionError carrying the API errors on 400', async () => {
|
||||
const errorBody = {
|
||||
errors: [
|
||||
{
|
||||
message: 'Required field missing',
|
||||
errorType: 'REQUIRED_FIELD',
|
||||
in: 'email'
|
||||
}
|
||||
]
|
||||
}
|
||||
const fetchImpl = vi.fn().mockResolvedValue(jsonResponse(errorBody, 400))
|
||||
|
||||
await expect(
|
||||
submitHubspotForm({
|
||||
config: { portalId: PORTAL, formGuid: FORM },
|
||||
fields: [field('firstname', 'Jane')],
|
||||
fetchImpl
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: 'HubspotSubmissionError',
|
||||
status: 400,
|
||||
errors: errorBody.errors
|
||||
})
|
||||
})
|
||||
|
||||
it('still throws on non-ok responses with unparseable bodies', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(
|
||||
new Response('upstream blew up', {
|
||||
status: 502,
|
||||
headers: { 'content-type': 'text/plain' }
|
||||
})
|
||||
)
|
||||
|
||||
const promise = submitHubspotForm({
|
||||
config: { portalId: PORTAL, formGuid: FORM },
|
||||
fields: [field('firstname', 'Jane')],
|
||||
fetchImpl
|
||||
})
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(HubspotSubmissionError)
|
||||
await expect(promise).rejects.toMatchObject({ status: 502, errors: [] })
|
||||
})
|
||||
|
||||
it('throws when the form is not configured', async () => {
|
||||
const fetchImpl = vi.fn()
|
||||
|
||||
await expect(
|
||||
submitHubspotForm({
|
||||
config: { portalId: '', formGuid: FORM },
|
||||
fields: [field('firstname', 'Jane')],
|
||||
fetchImpl
|
||||
})
|
||||
).rejects.toThrow(/not configured/)
|
||||
|
||||
expect(fetchImpl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('aborts when the request exceeds the timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const fetchImpl: typeof fetch = vi.fn(
|
||||
(_input, init) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
reject(new DOMException('aborted', 'AbortError'))
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const promise = submitHubspotForm({
|
||||
config: { portalId: PORTAL, formGuid: FORM },
|
||||
fields: [field('firstname', 'Jane')],
|
||||
fetchImpl,
|
||||
timeoutMs: 50
|
||||
})
|
||||
|
||||
vi.advanceTimersByTime(60)
|
||||
|
||||
await expect(promise).rejects.toThrow(/aborted/i)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('readHubspotTrackingCookie', () => {
|
||||
it.each([
|
||||
'foo=bar; hubspotutk=abc123; baz=qux',
|
||||
'foo=bar;hubspotutk=abc123;baz=qux',
|
||||
'foo=bar; hubspotutk=abc123;baz=qux'
|
||||
])(
|
||||
'reads the hubspotutk cookie value across separator variants: %s',
|
||||
(cookie) => {
|
||||
expect(readHubspotTrackingCookie(cookie)).toBe('abc123')
|
||||
}
|
||||
)
|
||||
|
||||
it('returns null when the cookie is missing', () => {
|
||||
expect(readHubspotTrackingCookie('foo=bar; baz=qux')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when no cookie string is available', () => {
|
||||
expect(readHubspotTrackingCookie(undefined)).toBeNull()
|
||||
expect(readHubspotTrackingCookie('')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for an empty hubspotutk value', () => {
|
||||
expect(readHubspotTrackingCookie('hubspotutk=')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveHubspotRegion', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockClear()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('falls back to "na1" when the value is undefined or empty', () => {
|
||||
expect(resolveHubspotRegion(undefined)).toBe('na1')
|
||||
expect(resolveHubspotRegion('')).toBe('na1')
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts the documented region values', () => {
|
||||
expect(resolveHubspotRegion('na1')).toBe('na1')
|
||||
expect(resolveHubspotRegion('eu1')).toBe('eu1')
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('warns and falls back to "na1" for unknown values', () => {
|
||||
expect(resolveHubspotRegion('eu')).toBe('na1')
|
||||
expect(resolveHubspotRegion('EU1')).toBe('na1')
|
||||
expect(resolveHubspotRegion('us-east-1')).toBe('na1')
|
||||
expect(warnSpy).toHaveBeenCalledTimes(3)
|
||||
expect(warnSpy).toHaveBeenLastCalledWith(
|
||||
expect.stringContaining('Unsupported PUBLIC_HUBSPOT_REGION')
|
||||
)
|
||||
})
|
||||
})
|
||||
183
apps/website/src/utils/submitHubspotForm.ts
Normal file
183
apps/website/src/utils/submitHubspotForm.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Submit a payload to HubSpot's Forms Submissions API v3.
|
||||
*
|
||||
* Uses the unauthenticated endpoint (CORS-enabled). The `/secure/submit/`
|
||||
* variant does NOT support CORS and must not be called from the browser.
|
||||
*
|
||||
* Field names must match the HubSpot form's internal property names
|
||||
* (e.g. `firstname`, not `firstName`). Submitting unknown fields fails with
|
||||
* `FIELD_NOT_IN_FORM_DEFINITION`.
|
||||
*
|
||||
* Docs: https://developers.hubspot.com/docs/api-reference/legacy/marketing/forms/v3-legacy/submit-data-unauthenticated
|
||||
*/
|
||||
|
||||
type HubspotRegion = 'na1' | 'eu1'
|
||||
|
||||
const HUBSPOT_REGIONS: readonly HubspotRegion[] = ['na1', 'eu1']
|
||||
|
||||
export function resolveHubspotRegion(value: string | undefined): HubspotRegion {
|
||||
if (value === undefined || value === '') return 'na1'
|
||||
if ((HUBSPOT_REGIONS as readonly string[]).includes(value)) {
|
||||
return value as HubspotRegion
|
||||
}
|
||||
console.warn(
|
||||
`[hubspot] Unsupported PUBLIC_HUBSPOT_REGION ${JSON.stringify(value)}; falling back to "na1". Expected one of: ${HUBSPOT_REGIONS.join(', ')}`
|
||||
)
|
||||
return 'na1'
|
||||
}
|
||||
|
||||
interface HubspotFormConfig {
|
||||
portalId: string
|
||||
formGuid: string
|
||||
region?: HubspotRegion
|
||||
}
|
||||
|
||||
interface HubspotField {
|
||||
objectTypeId: string
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface HubspotFormContext {
|
||||
hutk?: string | null
|
||||
pageUri?: string
|
||||
pageName?: string
|
||||
}
|
||||
|
||||
interface SubmitHubspotFormOptions {
|
||||
config: HubspotFormConfig
|
||||
fields: HubspotField[]
|
||||
context?: HubspotFormContext
|
||||
fetchImpl?: typeof fetch
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
interface HubspotFormError {
|
||||
message: string
|
||||
errorType: string
|
||||
in?: string
|
||||
}
|
||||
|
||||
interface HubspotSubmissionResult {
|
||||
inlineMessage?: string
|
||||
redirectUri?: string
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 15_000
|
||||
|
||||
export class HubspotSubmissionError extends Error {
|
||||
readonly status: number
|
||||
readonly errors: HubspotFormError[]
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
status: number,
|
||||
errors: HubspotFormError[] = []
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'HubspotSubmissionError'
|
||||
this.status = status
|
||||
this.errors = errors
|
||||
}
|
||||
}
|
||||
|
||||
function getApiHost(region: HubspotRegion): string {
|
||||
return region === 'eu1' ? 'api-eu1.hsforms.com' : 'api.hsforms.com'
|
||||
}
|
||||
|
||||
export function buildHubspotEndpoint(config: HubspotFormConfig): string {
|
||||
const host = getApiHost(config.region ?? 'na1')
|
||||
return `https://${host}/submissions/v3/integration/submit/${config.portalId}/${config.formGuid}`
|
||||
}
|
||||
|
||||
export async function submitHubspotForm({
|
||||
config,
|
||||
fields,
|
||||
context,
|
||||
fetchImpl = fetch,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS
|
||||
}: SubmitHubspotFormOptions): Promise<HubspotSubmissionResult> {
|
||||
if (!config.portalId || !config.formGuid) {
|
||||
throw new Error(
|
||||
'HubSpot form submission is not configured (missing portalId or formGuid)'
|
||||
)
|
||||
}
|
||||
|
||||
const url = buildHubspotEndpoint(config)
|
||||
const body = {
|
||||
submittedAt: Date.now(),
|
||||
fields: fields.filter((f) => f.value !== ''),
|
||||
...(context && { context: stripUndefined(context) })
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = await response.json()
|
||||
} catch {
|
||||
parsed = undefined
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errors = isHubspotErrorBody(parsed) ? parsed.errors : []
|
||||
throw new HubspotSubmissionError(
|
||||
`HubSpot submission failed with status ${response.status}`,
|
||||
response.status,
|
||||
errors
|
||||
)
|
||||
}
|
||||
|
||||
return isHubspotSuccessBody(parsed) ? parsed : {}
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
export function readHubspotTrackingCookie(
|
||||
cookieString: string | undefined = typeof document === 'undefined'
|
||||
? undefined
|
||||
: document.cookie
|
||||
): string | null {
|
||||
if (!cookieString) return null
|
||||
const match = cookieString
|
||||
.split(';')
|
||||
.map((entry) => entry.trim())
|
||||
.find((entry) => entry.startsWith('hubspotutk='))
|
||||
if (!match) return null
|
||||
const value = match.slice('hubspotutk='.length).trim()
|
||||
return value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
function stripUndefined<T extends object>(input: T): T {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (value !== undefined) result[key] = value
|
||||
}
|
||||
return result as T
|
||||
}
|
||||
|
||||
function isHubspotErrorBody(
|
||||
value: unknown
|
||||
): value is { errors: HubspotFormError[] } {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
Array.isArray((value as { errors?: unknown }).errors)
|
||||
)
|
||||
}
|
||||
|
||||
function isHubspotSuccessBody(
|
||||
value: unknown
|
||||
): value is HubspotSubmissionResult {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.{test,spec}.ts'],
|
||||
|
||||
Reference in New Issue
Block a user