# @se-studio/hubspot

HubSpot tracking and API-driven form rendering for Next.js marketing sites.

## Features

- **`HubSpotAnalyticsAdapter`** — `AnalyticsAdapter` for HubSpot tracking code (SPA Mode B page views, custom behavioural events)
- **`HubspotDynamicForm`** — React renderer from HubSpot Marketing Forms API definitions
- **UTM / marketing hidden fields** — auto-fills empty hidden form fields from URL query params and first-touch `sessionStorage` (`utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`, plus any query key that matches a hidden field name)
- **`createHubspotFormExternalRenderer`** — CMS external component factory (`externalComponentType: "Hubspot form"`)
- **Per-form fetch** — `GET /marketing/v3/forms/{formId}` with Next.js `unstable_cache` (no bulk form download)

## Installation

```bash
pnpm add @se-studio/hubspot
```

## Environment variables

| Variable | Purpose |
|----------|---------|
| `HUBSPOT_PORTAL_ID` | Portal ID for tracking script and form submissions |
| `HUBSPOT_PAT` | Private app token with `forms` scope (server-only, form definition fetch) |

## Analytics (with GTM)

```tsx
import { AnalyticsPageTracker, AnalyticsProvider, CompositeAnalyticsAdapter, ConsentAwareAdapter, GoogleTagManagerAdapter } from '@se-studio/core-ui';
import { AbTestReporter } from '@se-studio/ab-testing/components';
import { HubSpotAnalyticsAdapter, createHubSpotBootstrapScript } from '@se-studio/hubspot';
import Script from 'next/script';

const adapter = new ConsentAwareAdapter(
  new CompositeAnalyticsAdapter([
    new GoogleTagManagerAdapter({ containerId: process.env.GTM_TAG! }),
    new HubSpotAnalyticsAdapter({ portalId: process.env.HUBSPOT_PORTAL_ID! }),
  ]),
  'analytics',
  hasConsent,
);

// layout.tsx — before HubSpot script loads (no args = SSG-safe; uses window.location.pathname)
<Script id="hubspot-bootstrap" strategy="beforeInteractive"
  dangerouslySetInnerHTML={{ __html: createHubSpotBootstrapScript() }} />

<AnalyticsProvider adapter={adapter}>
  <AnalyticsPageTracker />
  <AbTestReporter />
  {children}
</AnalyticsProvider>
```

## CMS external component

Add `"Hubspot form"` to your `externalComponent` enum. `data` JSON:

```json
{
  "formId": "hubspot-form-guid",
  "portalId": "optional-override",
  "submitButtonText": "Optional label",
  "hiddenFields": { "campaign": "value" }
}
```

```tsx
import { defineExternalComponent } from '@se-studio/core-ui';
import { createHubspotFormExternalRenderer } from '@se-studio/hubspot/external';
import { Section } from '@/framework/Section';

export const HubspotFormRegistration = defineExternalComponent({
  name: 'Hubspot form',
  renderer: createHubspotFormExternalRenderer({
    wrap: ({ information, children, componentName }) => (
      <Section information={information} componentName={componentName}>
        {children}
      </Section>
    ),
  }),
});
```

### Hidden UTM / campaign fields

If the HubSpot form defines **hidden** fields whose internal names match query params (e.g. `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`), `HubspotDynamicForm` fills them on mount from:

1. HubSpot form defaults (if any)
2. First-touch `sessionStorage` + current URL (URL wins for keys present on the form page)
3. CMS `data.hiddenFields` / `data.initialValues` (**highest** — static overrides win)

First-touch is stored under `sessionStorage` key `se_studio_marketing_params` so a visitor who lands with UTMs on `/` and submits on `/contact-us/` still attributes correctly.

Sites that fork the form renderer should call the same helpers (do not reimplement):

```tsx
import {
  applyMarketingParamsToHiddenFields,
  buildDefaultValues,
  captureMarketingParams,
} from '@se-studio/hubspot';

// Optional: call once in a root client provider so multi-page capture runs before the form mounts
captureMarketingParams();

// When seeding form state (same order as HubspotDynamicForm):
setFormData(buildDefaultValues(formDefinition, hiddenFields, initialValues));
// or applyMarketingParamsToHiddenFields(formDefinition, baseDefaults)
```

Submissions already send Forms API `context` (`hutk`, `pageUri`, `pageName`) via `useHubspotSubmit`.

## Post-submit behaviour

These forms are **not** HubSpot’s native embed script — the package rebuilds fields from the Marketing Forms API and posts via the Forms Submit API. Redirect / thank-you must be handled in our client:

| Priority | Source | Behaviour |
|----------|--------|-----------|
| 1 | `redirectUrlOverride` prop | CMS override (e.g. Contentful `externalUrl` or `data.successRedirectUrl`) |
| 2 | Submit API `redirectUri` | Includes HubSpot conditional redirects when returned |
| 3 | Form definition `postSubmitAction` type `redirect_url` | Configured in HubSpot form editor |
| 4 | `inlineMessage` / `thank_you` | Inline message on the form |

**Navigation:** external absolute URLs use `window.location.assign`; same-origin paths use Next.js `router.push`. Full redirect URLs are preserved (hosts are not stripped).

**`onSuccess`:** side-effect only (analytics, swap to CMS `extraCopy`). It does **not** suppress thank-you or redirect. When a redirect is configured, navigation wins after the callback.

```tsx
<HubspotDynamicForm
  portalId={portalId}
  formDefinition={formDefinition}
  redirectUrlOverride={cmsExternalUrl} // optional CMS override
  onSuccess={() => setShowExtraCopy(true)} // optional; redirect still runs if set
/>
```

## Cache revalidation

When a form changes in HubSpot, revalidate:

```ts
import { revalidateTag } from 'next/cache';
import { hubspotFormTag } from '@se-studio/hubspot/server';

revalidateTag(hubspotFormTag(formId), { expire: 0 });
```