Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,7 @@ describe('ExpertConsultationBanner', () => {
{ name: 'email', value: 'user@example.com' },
{ name: 'company', value: 'Acme Inc' },
{ name: 'message', value: 'Need help with auth' },
{ name: 'instance_id', value: 'test-instance-id' },
],
context: {
pageName: 'ToolHive Desktop - Expert Consultation',
Expand Down
126 changes: 126 additions & 0 deletions renderer/src/common/components/__tests__/hubspot-form-parts.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { PRIVACY_POLICY_URL } from '@/common/lib/hubspot'
import { Dialog } from '../ui/dialog'
import {
SuccessDialogContent,
ConsentCheckbox,
PrivacyFooter,
} from '../hubspot-form-parts'

describe('SuccessDialogContent', () => {
it('renders the success title and message', () => {
render(
<Dialog open>
<SuccessDialogContent message="Thanks for reaching out!" />
</Dialog>
)

expect(screen.getByText('Success!')).toBeInTheDocument()
expect(screen.getByText('Thanks for reaching out!')).toBeInTheDocument()
})
})

describe('ConsentCheckbox', () => {
it('renders the consent label text', () => {
render(
<ConsentCheckbox
checked={false}
onCheckedChange={() => {}}
disabled={false}
/>
)

expect(
screen.getByText(
/I agree to allow Stacklok to store and process my personal data/
)
).toBeInTheDocument()
expect(screen.getByText('(required)')).toBeInTheDocument()
})

it('calls onCheckedChange when clicked', async () => {
const onChange = vi.fn()
render(
<ConsentCheckbox
checked={false}
onCheckedChange={onChange}
disabled={false}
/>
)

await userEvent.click(
screen.getByRole('checkbox', {
name: /store and process my personal data/i,
})
)

expect(onChange).toHaveBeenCalledWith(true)
})

it('renders as disabled when disabled prop is true', () => {
render(
<ConsentCheckbox
checked={false}
onCheckedChange={() => {}}
disabled={true}
/>
)

expect(
screen.getByRole('checkbox', {
name: /store and process my personal data/i,
})
).toBeDisabled()
})

it('reflects checked state', () => {
render(
<ConsentCheckbox
checked={true}
onCheckedChange={() => {}}
disabled={false}
/>
)

expect(
screen.getByRole('checkbox', {
name: /store and process my personal data/i,
})
).toBeChecked()
})
})

describe('PrivacyFooter', () => {
it('renders children text and privacy policy link', () => {
render(
<PrivacyFooter>By submitting this form, you agree to our</PrivacyFooter>
)

expect(
screen.getByText(/By submitting this form, you agree to our/)
).toBeInTheDocument()

const link = screen.getByRole('link', { name: /privacy policy/i })
expect(link).toHaveAttribute('href', PRIVACY_POLICY_URL)
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noreferrer')
})

it('renders different children text (newsletter variant)', () => {
render(
<PrivacyFooter>
You can unsubscribe at any time. For more information on how to
unsubscribe and our privacy practices, please review our
</PrivacyFooter>
)

expect(
screen.getByText(/You can unsubscribe at any time/)
).toBeInTheDocument()
expect(
screen.getByRole('link', { name: /privacy policy/i })
).toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,10 @@ describe('NewsletterModal', () => {
)
expect(hubspotRequest).toBeDefined()
expect(hubspotRequest?.payload).toEqual({
fields: [{ name: 'email', value: 'user@example.com' }],
fields: [
{ name: 'email', value: 'user@example.com' },
{ name: 'instance_id', value: 'test-instance-id' },
],
context: {
pageName: 'ToolHive Desktop - Newsletter Signup',
},
Expand Down
158 changes: 37 additions & 121 deletions renderer/src/common/components/expert-consultation-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,37 +8,27 @@ import log from 'electron-log/renderer'
import { getApiV1BetaWorkloadsOptions } from '@common/api/generated/@tanstack/react-query.gen'
import type { GithubComStacklokToolhivePkgCoreWorkload } from '@common/api/generated/types.gen'
import { trackEvent } from '../lib/analytics'
import { shouldShowAfterDismissal } from '../lib/hubspot'
import { useHubSpotForm } from '../hooks/use-hubspot-form'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from './ui/dialog'
import { Checkbox } from './ui/checkbox'
import { Input } from './ui/input'
import { Textarea } from './ui/textarea'
import { Button } from './ui/button'
import {
HubSpotDialogContent,
SuccessDialogContent,
ConsentCheckbox,
PrivacyFooter,
} from './hubspot-form-parts'

const HUBSPOT_PORTAL_ID = '42544743'
const HUBSPOT_FORM_ID = '5f1a7a2c-5069-44b7-9444-d952c55ce89c'
const DISMISS_DAYS = 30
const MIN_SERVERS_IN_GROUP = 3
const PRIVACY_POLICY_URL = 'https://www.iubenda.com/privacy-policy/29074746'

const CONSENT_PROCESSING_TEXT =
'In order to provide you the content requested, we need to store and process your personal data. If you consent to us storing your personal data for this purpose, please tick the checkbox below.'

function shouldShowBanner(submitted: boolean, dismissedAt: string): boolean {
if (submitted) return false
if (!dismissedAt) return true

const dismissed = new Date(dismissedAt).getTime()
if (Number.isNaN(dismissed)) return true

const daysSinceDismissal = (Date.now() - dismissed) / (1000 * 60 * 60 * 24)
return daysSinceDismissal >= DISMISS_DAYS
}

function hasGroupWithEnoughServers(
workloads: GithubComStacklokToolhivePkgCoreWorkload[]
Expand All @@ -54,57 +44,6 @@ function hasGroupWithEnoughServers(
return false
}

async function submitToHubSpot(
fields: {
firstname: string
lastname: string
email: string
company: string
message: string
},
consentToProcess: boolean
): Promise<string | undefined> {
const response = await fetch(
`https://api.hsforms.com/submissions/v3/integration/submit/${HUBSPOT_PORTAL_ID}/${HUBSPOT_FORM_ID}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fields: [
{ name: 'firstname', value: fields.firstname },
{ name: 'lastname', value: fields.lastname },
{ name: 'email', value: fields.email },
{ name: 'company', value: fields.company },
{ name: 'message', value: fields.message },
],
context: {
pageName: 'ToolHive Desktop - Expert Consultation',
},
legalConsentOptions: {
consent: {
consentToProcess,
text: CONSENT_PROCESSING_TEXT,
},
},
}),
}
)

if (!response.ok) {
const text = await response.text()
throw new Error(`HubSpot submission failed (${response.status}): ${text}`)
}

try {
const data = await response.json()
if (typeof data?.inlineMessage !== 'string') return undefined
const doc = new DOMParser().parseFromString(data.inlineMessage, 'text/html')
return doc.body.textContent?.trim() || undefined
} catch {
return undefined
}
}

const formSchema = z.object({
firstname: z.string().min(1, 'First name is required'),
lastname: z.string().min(1, 'Last name is required'),
Expand All @@ -130,21 +69,29 @@ function ExpertConsultationDialog({
const [company, setCompany] = useState('')
const [message, setMessage] = useState('')
const [errors, setErrors] = useState<Record<string, string>>({})
const [consentToProcess, setConsentToProcess] = useState(false)

const { consentToProcess, setConsentToProcess, isReady, submit } =
useHubSpotForm(HUBSPOT_FORM_ID, 'ToolHive Desktop - Expert Consultation')

useEffect(() => {
trackEvent('Expert consultation modal shown')
}, [])

const { mutate: submit, isPending: isSubmitting } = useMutation({
const { mutate: submitForm, isPending: isSubmitting } = useMutation({
mutationFn: async (fields: {
firstname: string
lastname: string
email: string
company: string
message: string
}) => {
const inlineMessage = await submitToHubSpot(fields, consentToProcess)
const inlineMessage = await submit([
{ name: 'firstname', value: fields.firstname },
{ name: 'lastname', value: fields.lastname },
{ name: 'email', value: fields.email },
{ name: 'company', value: fields.company },
{ name: 'message', value: fields.message },
])
await window.electronAPI.setExpertConsultationSubmitted(true)
return inlineMessage
},
Expand Down Expand Up @@ -198,7 +145,7 @@ function ExpertConsultationDialog({
return
}
setErrors({})
submit(result.data)
submitForm(result.data)
}

return (
Expand All @@ -214,25 +161,9 @@ function ExpertConsultationDialog({
}
}}
>
<DialogContent
onInteractOutside={(e) => e.preventDefault()}
className="bg-brand-blue-light text-brand-blue-dark
dark:bg-brand-blue-light dark:text-brand-blue-dark
**:data-[slot=dialog-close]:text-brand-blue-dark
border-brand-blue-mid/20 p-8 **:data-[slot=dialog-close]:opacity-70
sm:max-w-md"
>
<HubSpotDialogContent>
{successMessage ? (
<DialogHeader>
<DialogTitle
className="text-brand-blue-mid font-serif text-3xl font-light"
>
Success!
</DialogTitle>
<DialogDescription className="text-primary">
{successMessage}
</DialogDescription>
</DialogHeader>
<SuccessDialogContent message={successMessage} />
) : (
<>
<DialogHeader>
Expand Down Expand Up @@ -348,26 +279,16 @@ function ExpertConsultationDialog({
/>
</div>

<label className="flex cursor-pointer items-start gap-2.5">
<Checkbox
checked={consentToProcess}
onCheckedChange={(checked) =>
setConsentToProcess(checked === true)
}
disabled={isSubmitting}
required
className="border-brand-blue-dark/40 mt-0.5 shrink-0"
/>
<span className="text-xs leading-relaxed">
I agree to allow Stacklok to store and process my personal
data.{' '}
<span className="text-brand-blue-dark/60">(required)</span>
</span>
</label>
<ConsentCheckbox
checked={consentToProcess}
onCheckedChange={setConsentToProcess}
disabled={isSubmitting}
/>

<Button
type="submit"
disabled={
!isReady ||
isSubmitting ||
!firstname.trim() ||
!lastname.trim() ||
Expand All @@ -380,22 +301,13 @@ function ExpertConsultationDialog({
{isSubmitting ? <Loader2 className="animate-spin" /> : 'Submit'}
</Button>

<p className="text-brand-blue-dark/50 text-xs leading-relaxed">
By submitting this form, you agree to our{' '}
<a
href={PRIVACY_POLICY_URL}
target="_blank"
rel="noreferrer"
className="underline underline-offset-2"
>
Privacy Policy
</a>
.
</p>
<PrivacyFooter>
By submitting this form, you agree to our
</PrivacyFooter>
</form>
</>
)}
</DialogContent>
</HubSpotDialogContent>
</Dialog>
)
}
Expand Down Expand Up @@ -470,7 +382,11 @@ export function ExpertConsultationBanner() {

const isEligible =
isDataReady &&
shouldShowBanner(consultationState.submitted, consultationState.dismissedAt)
shouldShowAfterDismissal(
consultationState.submitted,
consultationState.dismissedAt,
DISMISS_DAYS
)

const isNewsletterModalVisible =
isDataReady && !newsletterState.subscribed && !newsletterState.dismissedAt
Expand Down
Loading
Loading