Compare commits

...

6 Commits

Author SHA1 Message Date
Glary-Bot
294d90f36e fix(website): require explicit HubSpot env vars; drop production fallback
import.meta.env.PROD is true for any production-mode build, including
Vercel preview deploys. The previous fallback meant preview/staging
deploys could submit test entries to the live HubSpot CRM whenever the
env vars weren't set. Drop the hardcoded fallback entirely so every
environment (production included) must set PUBLIC_HUBSPOT_PORTAL_ID and
PUBLIC_HUBSPOT_FORM_ID_CONTACT_SALES explicitly. When unset the submit
button is disabled and a console.warn flags the misconfiguration.

.env_example documents the production values in a comment for ops to
copy into Vercel project settings.

Renamed the fail-safe describe block to drop the now-misleading
"preview/dev" qualifier, since the fail-safe applies in every
environment.
2026-04-28 02:29:59 +00:00
Glary-Bot
5563f0b096 fix(website): locale-aware privacy link, restore warn spy, dedupe cookie tests
- Render the privacy-policy link in the disclosure with the locale-aware
  path from getRoutes(locale).privacyPolicy instead of a hardcoded
  /privacy-policy. The i18n string is split into a before/link-label/
  after triple so zh-CN visitors land on /zh-CN/privacy-policy.
- Restore the global console.warn implementation in afterAll for the
  resolveHubspotRegion describe block so the warn spy doesn't leak
  across files.
- Collapse the three cookie-separator-variant tests into a single
  it.each block per the project's "no redundant tests" guideline.
- Add component tests covering both the en and zh-CN privacy-policy
  link hrefs to lock the locale routing in place.
2026-04-28 02:21:45 +00:00
Glary-Bot
56e4e79ac5 fix(website): gate HubSpot defaults to prod, add disclosure, harden response parser
- Production HubSpot Portal ID and Form GUID are now used only when
  import.meta.env.PROD is true. Local dev and Vercel preview deploys
  fail-safe with the submit button disabled unless the env vars are
  explicitly set, so non-prod runs cannot pollute the live CRM.
- Add a privacy disclosure under the submit button (en + zh-CN) noting
  that submissions are sent to HubSpot, with a link to /privacy-policy.
- Tighten isHubspotSuccessBody to reject arrays so a malformed 200
  response cannot be returned as a HubspotSubmissionResult.
- Replace inline payload type in the component test with the real
  Parameters<typeof submitHubspotForm>[0] so the test stays in sync
  with the production signature.
- Add tests for the prod-gating fail-safe and the array-response guard.
2026-04-28 02:06:19 +00:00
Glary-Bot
968d9971f5 fix(website): trim free-text inputs and validate HubSpot region
- Trim free-text v-models with the .trim modifier and reject
  whitespace-only required fields explicitly in handleSubmit so
  whitespace-only submissions cannot pass validation.
- Validate PUBLIC_HUBSPOT_REGION at runtime via resolveHubspotRegion:
  unknown values now log a console.warn and fall back to 'na1' instead
  of silently passing through a typo to the wrong host.
- Add tests: resolveHubspotRegion (3 cases), multi-select join for
  who_primarily_builds_workflows, and whitespace-only rejection of
  required fields.
2026-04-28 01:51:21 +00:00
Glary-Bot
f00d8f1cc0 fix(website): address contact-form review feedback
- Validate the workflow-builder checkbox group client-side: handleSubmit
  now blocks empty submissions with a localized error and never posts an
  empty value to HubSpot's required field.
- Mark the workflow-builder section as required in the UI (asterisk).
- Fix readHubspotTrackingCookie to handle cookie strings without spaces
  after semicolons (split on ';' + trim).
- Wrap form labels around their inputs so the form is reachable via
  getByLabelText / accessible-name queries; switch component-test
  helpers from placeholder/text queries to label/role queries; add
  coverage for the new empty-builds validation and the no-space cookie
  case.
2026-04-28 01:38:50 +00:00
Glary-Bot
bd555a8732 feat(website): implement sales contact form submission via HubSpot
The /contact form's handleSubmit was a stub, silently dropping enterprise
leads. Submit form data to HubSpot's Forms Submissions API v3
(unauthenticated, CORS-enabled) so leads land in the configured HubSpot
contact-sales form.

Form structure now matches the HubSpot form definition exactly:
- Drop the 'Company' field (not present in HubSpot definition)
- Add a required 'Work Email' field (HubSpot rejects without it)
- Add a required 'Who primarily builds workflows?' multi-checkbox group
- Mark the 'What are you looking for?' textarea as required
- Map package values to HubSpot's enumeration (Individual=No,
  Teams=Teams, Enterprise=Yes) and submit them under the form's actual
  internal property name
- Submit each field with objectTypeId='0-1' per HubSpot's schema

Submission utility:
- Add submitHubspotForm with fetch DI, abort/timeout, and a typed
  HubspotSubmissionError that carries HubSpot's per-field error array
- Read the visitor's hubspotutk tracking cookie so submissions tie back
  to HubSpot's session tracking
- Surface HubSpot's per-field validation messages to the user instead
  of a generic 'something went wrong'

Tests:
- Vitest coverage for the utility (15 cases): payload shape including
  objectTypeId, region switching, empty-value pruning, context handling,
  success body parse, HubspotSubmissionError on 400, unparseable error
  bodies, unconfigured guard, timeout/abort, and the hubspotutk cookie
  reader
- Component tests (3 cases) using @testing-library/vue + user-event:
  payload shape end-to-end, success state + form reset, and HubSpot
  error surfacing

Configuration:
- Default Portal ID and Form GUID baked into the component (and
  documented in .env_example) — these are public IDs that appear in
  HubSpot's own embed code, not secrets
- PUBLIC_HUBSPOT_PORTAL_ID, PUBLIC_HUBSPOT_FORM_ID_CONTACT_SALES, and
  PUBLIC_HUBSPOT_REGION env vars override the defaults per environment
- Wire @vitejs/plugin-vue into vitest.config so .vue components can be
  tested with happy-dom
2026-04-28 01:22:40 +00:00
7 changed files with 1072 additions and 67 deletions

View File

@@ -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

View 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()
})
})

View File

@@ -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>

View File

@@ -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': {

View 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')
)
})
})

View 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)
}

View File

@@ -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'],