diff --git a/.env.example b/.env.example index 6ea15fca..bc764993 100644 --- a/.env.example +++ b/.env.example @@ -12,4 +12,6 @@ BASECAMP_ACCESS_TOKEN_PROVIDER_URL= FRONT_CHANNEL_URL_SALES= FRONT_CHANNEL_URL_PARTNER_PROGRAM= FRONT_CHANNEL_URL_SUPPORT= -BASE64_SPAM_DETECTOR_PROMPT= \ No newline at end of file +BASE64_SPAM_DETECTOR_PROMPT= +LINKEDIN_PARTNER_ID= +LINKEDIN_ACCESS_TOKEN= \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..86bdaf49 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,63 @@ +# astro-website + +Astro standalone Node server, deployed via `cubo` to Kubernetes on AWS. Three environments: `production`, `staging1`, `staging2`. + +## Environment variables + +Three distinct mechanisms — pick the right one by asking: **when is the value needed, and is it secret?** + +### 1. Build-time public — `cubo.json5` `build.args` + Dockerfile `ARG` + +Use for values that need to be baked into the build (client-side bundles, server modules that read `astro:env/client`). The value is committed in cleartext in `cubo.json5` per environment. + +Wiring (all four steps are required): + +1. **`astro.config.ts`** — declare with `context: 'client', access: 'public'`: + ```ts + MY_VAR: envField.string({ context: 'client', access: 'public' }), + ``` +2. **`Dockerfile`** — `ARG MY_VAR` in the base stage, `ENV MY_VAR=$MY_VAR` in the build stage before `RUN npm run build`. +3. **`cubo.json5`** — add `MY_VAR: 'literal-value'` under `build.args` in each environment where it should be set. +4. **`.env.example`** — add `MY_VAR=` for local dev discoverability. + +Examples in repo: `PUBLIC_HOSTNAME`, `RECAPTCHA_KEY`, `KNOWLEDGE_BASE_URL`, `LINKEDIN_PARTNER_ID`. + +### 2. Build-time secret — `cubo.json5` `envVarSecrets` + Dockerfile `--mount=type=secret` + +Use for secrets needed during `docker build` (e.g. fetching a schema). Cubo passes these via BuildKit secret mounts so they never land in image layers. + +Wiring: + +1. **`Dockerfile`** — consume with `RUN --mount=type=secret,id=MY_SECRET,env=MY_SECRET ...`. +2. **`cubo.json5`** — list the name in `envVarSecrets` (an array of strings, not values). +3. The actual value comes from the build host's environment / CI secret store. + +Example in repo: `DATOCMS_API_TOKEN` (used by the `gql.tada` step). + +### 3. Runtime secret — `cubo config:set`, nothing in `cubo.json5` + +Use for secrets read at request time by the running app (server actions, API routes, anything imported from `astro:env/server`). Values are stored in AWS Secrets Manager and injected into the pod at startup. They are **not** declared in `cubo.json5` and **not** in the Dockerfile. + +Wiring: + +1. **`astro.config.ts`** — declare with `context: 'server', access: 'secret'`. +2. **Set the value:** + ``` + cubo config:set MY_SECRET=value --env production + ``` + This writes to AWS Secrets Manager and triggers a rolling restart (override with `--strategy recreate`). +3. **`.env.example`** — add `MY_SECRET=` for local dev. + +Examples in repo: `DATOCMS_API_TOKEN` (also build-time, see above), `ROLLBAR_TOKEN`, `PIPEDRIVE_TOKEN`, `MAILERLITE_TOKEN`, `RECAPTCHA_SECRET_KEY`, `LINKEDIN_ACCESS_TOKEN`, etc. + +## Common pitfalls + +- **`cubo config:set` cannot set build-time vars.** Values are injected at pod startup, after `docker build` has already baked client bundles. If a var needs to appear in client JS or `astro:env/client`, it must go through mechanism 1. +- **`envVarSecrets` is not for runtime secrets.** It only declares build-time secret mounts. Listing a var there without a corresponding `--mount=type=secret` in the Dockerfile does nothing. +- **Production-only vars:** declare them `optional: true` in `astro.config.ts` and guard their use in code, so staging/dev don't crash when the value is absent. Only populate them in the `production` block of `cubo.json5` / via `config:set --env production`. +- **`context: 'client'` does not mean "client-only".** It means "can be read from both client and server, baked at build time". `context: 'server'` is the runtime-only one. + +## Deployment + +- Edit `cubo.json5` for declarative changes (build args, secret declarations, resources, scaling, cron jobs). Commit and deploy via the normal pipeline. +- Use `cubo config:set` for ad-hoc runtime secret rotation; no code change needed. diff --git a/Dockerfile b/Dockerfile index 793db757..9f831c20 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,7 @@ FROM node:22-alpine AS base ARG PUBLIC_HOSTNAME ARG RECAPTCHA_KEY ARG KNOWLEDGE_BASE_URL +ARG LINKEDIN_PARTNER_ID # Set the working directory for all subsequent operations WORKDIR /app @@ -59,6 +60,7 @@ RUN --mount=type=secret,id=DATOCMS_API_TOKEN,env=DATOCMS_API_TOKEN \ ENV RECAPTCHA_KEY=$RECAPTCHA_KEY ENV PUBLIC_HOSTNAME=$PUBLIC_HOSTNAME ENV KNOWLEDGE_BASE_URL=$KNOWLEDGE_BASE_URL +ENV LINKEDIN_PARTNER_ID=$LINKEDIN_PARTNER_ID RUN npm run build # ==== Runtime stage ==== diff --git a/astro.config.ts b/astro.config.ts index b7604b14..4d8b2b23 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -118,6 +118,16 @@ export default defineConfig({ context: 'server', access: 'secret', }), + LINKEDIN_ACCESS_TOKEN: envField.string({ + context: 'server', + access: 'secret', + optional: true, + }), + LINKEDIN_PARTNER_ID: envField.string({ + context: 'client', + access: 'public', + optional: true, + }), }, validateSecrets: false, }, diff --git a/cubo.json5 b/cubo.json5 index 2778b5a1..93033c17 100644 --- a/cubo.json5 +++ b/cubo.json5 @@ -8,6 +8,7 @@ PUBLIC_HOSTNAME: 'www.datocms.com', RECAPTCHA_KEY: '6Lda4UkgAAAAAE14pGAmMkbHKJCKOzmRoVc9_DE5', KNOWLEDGE_BASE_URL: 'https://knowledge.datocms.com', + LINKEDIN_PARTNER_ID: '6497930', }, envVarSecrets: ['DATOCMS_API_TOKEN', 'NODE_ENV'], }, diff --git a/src/actions/forms/submitPartnerProgramRequest.ts b/src/actions/forms/submitPartnerProgramRequest.ts index 3e1052a2..9fd993d6 100644 --- a/src/actions/forms/submitPartnerProgramRequest.ts +++ b/src/actions/forms/submitPartnerProgramRequest.ts @@ -1,7 +1,9 @@ import { ActionError, defineAction } from 'astro:actions'; import { FRONT_CHANNEL_URL_PARTNER_PROGRAM } from 'astro:env/server'; import { z } from 'astro:schema'; +import { hasMarketingConsent } from '~/lib/consent'; import { sendToFrontChannel } from '~/lib/front'; +import { trackConversion } from '~/lib/linkedin'; import { logErrorToRollbar } from '~/lib/logToRollbar'; import { isRecaptchaTokenValid } from '~/lib/recaptcha'; import { isSpam } from '~/lib/spam'; @@ -12,6 +14,8 @@ import { findOrCreatePerson, } from '../pipedrive/utils'; +const LINKEDIN_CONVERSION_RULE_PARTNER_LEAD = '27784882'; + export default defineAction({ accept: 'form', input: z.object({ @@ -26,7 +30,7 @@ export default defineAction({ body: z.string(), token: z.string(), }), - handler: async ({ token, ...input }) => { + handler: async ({ token, ...input }, { request, cookies }) => { try { // Step 1: Validate reCAPTCHA token if (!(await isRecaptchaTokenValid(token))) { @@ -43,14 +47,24 @@ export default defineAction({ }); } - // Step 2: Find or create organization in Pipedrive + // Side-effects with no Pipedrive dependency — kick off in parallel with the Pipedrive chain + const linkedinPromise = hasMarketingConsent(request, cookies) + ? trackConversion(LINKEDIN_CONVERSION_RULE_PARTNER_LEAD, input.email).catch((e) => + logErrorToRollbar(e, { context: { action: 'linkedin.trackConversion.partner' } }), + ) + : Promise.resolve(); + + const frontPromise = sendToFrontChannel( + FRONT_CHANNEL_URL_PARTNER_PROGRAM, + input, + 'https://www.datocms.com/partner-program', + ); + const organization = await findOrCreateOrgByName( input.agencyName, 'Agency / Freelancer', input.agencyUrl, ); - - // Step 3: Find or create person contact in Pipedrive const person = await findOrCreatePerson( input.email, input.firstName, @@ -60,20 +74,14 @@ export default defineAction({ '', organization, ); - - // Step 4: Create lead in Pipedrive with partnership label const partnershipLabel = '87a60c60-6a8e-11ed-92ec-410445a67487'; const lead = await createLead(person, organization, '', [partnershipLabel]); - // Step 5 & 6: Add note to Pipedrive and send to Front channel in parallel const noteText = `
Team size: ${input.teamSize}
Product familiarity: ${input.productFamiliarity}
Message: ${input.body}
`; const [, redirectUrl] = await Promise.all([ createNote(lead, noteText), - sendToFrontChannel( - FRONT_CHANNEL_URL_PARTNER_PROGRAM, - input, - 'https://www.datocms.com/partner-program', - ), + frontPromise, + linkedinPromise, ]); return redirectUrl; diff --git a/src/actions/forms/submitSalesRequest.ts b/src/actions/forms/submitSalesRequest.ts index 03ce722c..bfa3d9fc 100644 --- a/src/actions/forms/submitSalesRequest.ts +++ b/src/actions/forms/submitSalesRequest.ts @@ -1,7 +1,9 @@ import { ActionError, defineAction } from 'astro:actions'; import { FRONT_CHANNEL_URL_SALES } from 'astro:env/server'; import { z } from 'astro:schema'; +import { hasMarketingConsent } from '~/lib/consent'; import { sendToFrontChannel } from '~/lib/front'; +import { trackConversion } from '~/lib/linkedin'; import { logErrorToRollbar } from '~/lib/logToRollbar'; import { isRecaptchaTokenValid } from '~/lib/recaptcha'; import { isSpam } from '~/lib/spam'; @@ -12,6 +14,8 @@ import { findOrCreatePerson, } from '../pipedrive/utils'; +const LINKEDIN_CONVERSION_RULE_SALES_LEAD = '27784874'; + export default defineAction({ accept: 'form', input: z.object({ @@ -28,7 +32,7 @@ export default defineAction({ issueType: z.enum(['sales', 'enterprise']).optional(), token: z.string(), }), - handler: async ({ token, ...input }) => { + handler: async ({ token, ...input }, { request, cookies }) => { try { // Step 1: Validate reCAPTCHA token if (!(await isRecaptchaTokenValid(token))) { @@ -45,10 +49,20 @@ export default defineAction({ }); } - // Step 2: Find or create organization in Pipedrive - const organization = await findOrCreateOrgByName(input.companyName, input.industry); + // Side-effects with no Pipedrive dependency — kick off in parallel with the Pipedrive chain + const linkedinPromise = hasMarketingConsent(request, cookies) + ? trackConversion(LINKEDIN_CONVERSION_RULE_SALES_LEAD, input.email).catch((e) => + logErrorToRollbar(e, { context: { action: 'linkedin.trackConversion.sales' } }), + ) + : Promise.resolve(); + + const frontPromise = sendToFrontChannel( + FRONT_CHANNEL_URL_SALES, + input, + 'https://www.datocms.com/contact', + ); - // Step 3: Find or create person contact in Pipedrive + const organization = await findOrCreateOrgByName(input.companyName, input.industry); const person = await findOrCreatePerson( input.email, input.firstName, @@ -58,14 +72,12 @@ export default defineAction({ input.referral, organization, ); - - // Step 4: Create lead in Pipedrive const lead = await createLead(person, organization, input.useCase); - // Step 5 & 6: Add note to Pipedrive and send to Front channel in parallel const [, redirectUrl] = await Promise.all([ createNote(lead, input.body), - sendToFrontChannel(FRONT_CHANNEL_URL_SALES, input, 'https://www.datocms.com/contact'), + frontPromise, + linkedinPromise, ]); return redirectUrl; diff --git a/src/layouts/BaseLayout/Component.astro b/src/layouts/BaseLayout/Component.astro index 98c1085a..1048ee9b 100644 --- a/src/layouts/BaseLayout/Component.astro +++ b/src/layouts/BaseLayout/Component.astro @@ -71,6 +71,7 @@ const finalSeo = suffix ? overrideSeo(seo, replaceSeoPageTitleSuffix(suffix)) : diff --git a/src/lib/consent.ts b/src/lib/consent.ts new file mode 100644 index 00000000..d59f4c21 --- /dev/null +++ b/src/lib/consent.ts @@ -0,0 +1,50 @@ +import type { AstroCookies } from 'astro'; + +const gdprCountries = [ + 'AT', + 'BE', + 'BG', + 'CY', + 'CH', + 'CZ', + 'DE', + 'DK', + 'EE', + 'ES', + 'FI', + 'FR', + 'GB', + 'GR', + 'HR', + 'HU', + 'IE', + 'IS', + 'IT', + 'LI', + 'LT', + 'LU', + 'LV', + 'MT', + 'NL', + 'NO', + 'PL', + 'PT', + 'RO', + 'SE', + 'SI', + 'SK', +]; + +export const CONSENT_COOKIE_NAME = 'cookies-accepted'; + +// Mirrors the client-side cookie banner logic in cookieConsent.js.ts: +// non-GDPR visitors are auto-accepted (no banner shown); GDPR visitors must +// have explicitly accepted via the banner. +export function hasMarketingConsent(request: Request, cookies: AstroCookies): boolean { + const country = request.headers.get('client-geo-country'); + + // No country or non-GDPR → no banner shown, so the visitor is auto-accepted. + if (!country || !gdprCountries.includes(country)) return true; + + return Boolean(cookies.get(CONSENT_COOKIE_NAME)); +} diff --git a/src/lib/googleAds.ts b/src/lib/googleAds.ts new file mode 100644 index 00000000..62589585 --- /dev/null +++ b/src/lib/googleAds.ts @@ -0,0 +1,7 @@ +export const GOOGLE_ADS_ID = 'AW-11079629404'; + +export const GOOGLE_ADS_CONVERSIONS = { + dashboardClick: `${GOOGLE_ADS_ID}/jWVKCJSzqrAcENz0lqMp`, + contactSubmit: `${GOOGLE_ADS_ID}/Ozd_CKeAqrAcENz0lqMp`, + partnerSubmit: `${GOOGLE_ADS_ID}/vEIuCKqAqrAcENz0lqMp`, +} as const; diff --git a/src/lib/linkedin.ts b/src/lib/linkedin.ts new file mode 100644 index 00000000..630fbb48 --- /dev/null +++ b/src/lib/linkedin.ts @@ -0,0 +1,30 @@ +import { createHash } from 'node:crypto'; +import { LINKEDIN_ACCESS_TOKEN } from 'astro:env/server'; + +export async function trackConversion(conversionRuleId: string, email: string): Promise
We have successfully received your request, and you will shortly receive an automated confirmation via email
-const gdprCountries = [
- 'AT',
- 'BE',
- 'BG',
- 'CY',
- 'CH',
- 'CZ',
- 'DE',
- 'DK',
- 'EE',
- 'ES',
- 'FI',
- 'FR',
- 'GB',
- 'GR',
- 'HR',
- 'HU',
- 'IE',
- 'IS',
- 'IT',
- 'LI',
- 'LT',
- 'LU',
- 'LV',
- 'MT',
- 'NL',
- 'NO',
- 'PL',
- 'PT',
- 'RO',
- 'SE',
- 'SI',
- 'SK',
-];
+const linkedinSnippet = LINKEDIN_PARTNER_ID
+ ? `
+window._linkedin_partner_id="${LINKEDIN_PARTNER_ID}";
+window._linkedin_data_partner_ids=window._linkedin_data_partner_ids||[];
+window._linkedin_data_partner_ids.push(window._linkedin_partner_id);
+(function(l){if(!l){window.lintrk=function(a,b){window.lintrk.q.push([a,b])};window.lintrk.q=[]}var s=document.getElementsByTagName("script")[0];var b=document.createElement("script");b.async=true;b.src="https://snap.licdn.com/li.lms-analytics/insight.min.js";s.parentNode.insertBefore(b,s)})(window.lintrk);
+`
+ : '';
const scriptsToAddOnCookieConsent = `
+var _gtag=document.createElement("script");_gtag.async=true;_gtag.src="https://www.googletagmanager.com/gtag/js?id=${GOOGLE_ADS_ID}";document.head.appendChild(_gtag);
+window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag("js",new Date());gtag("config","${GOOGLE_ADS_ID}");
+${linkedinSnippet}
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init capture register register_once register_for_session unregister unregister_for_session getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey getNextSurveyStep identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty createPersonProfile opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing debug getPageViewId".split(" "),n=0;n
We have successfully received your request, and you will soon receive an automated confirmation via email