Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
BASE64_SPAM_DETECTOR_PROMPT=
LINKEDIN_PARTNER_ID=
LINKEDIN_ACCESS_TOKEN=
63 changes: 63 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ====
Expand Down
10 changes: 10 additions & 0 deletions astro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
1 change: 1 addition & 0 deletions cubo.json5
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
Expand Down
32 changes: 20 additions & 12 deletions src/actions/forms/submitPartnerProgramRequest.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -12,6 +14,8 @@ import {
findOrCreatePerson,
} from '../pipedrive/utils';

const LINKEDIN_CONVERSION_RULE_PARTNER_LEAD = '27784882';

export default defineAction({
accept: 'form',
input: z.object({
Expand All @@ -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))) {
Expand All @@ -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,
Expand All @@ -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 = `<p>Team size: ${input.teamSize}</p><p>Product familiarity: ${input.productFamiliarity}</p><p>Message: ${input.body}</p>`;
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;
Expand Down
28 changes: 20 additions & 8 deletions src/actions/forms/submitSalesRequest.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -12,6 +14,8 @@ import {
findOrCreatePerson,
} from '../pipedrive/utils';

const LINKEDIN_CONVERSION_RULE_SALES_LEAD = '27784874';

export default defineAction({
accept: 'form',
input: z.object({
Expand All @@ -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))) {
Expand All @@ -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,
Expand All @@ -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;
Expand Down
14 changes: 14 additions & 0 deletions src/layouts/BaseLayout/Component.astro
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const finalSeo = suffix ? overrideSeo(seo, replaceSeoPageTitleSuffix(suffix)) :

<script>
import { getCookie, setCookie } from '~/lib/cookies';
import { GOOGLE_ADS_CONVERSIONS } from '~/lib/googleAds';

const urlParams = new URLSearchParams(window.location.search);
const source = urlParams.get('utm_source');
Expand Down Expand Up @@ -128,4 +129,17 @@ const finalSeo = suffix ? overrideSeo(seo, replaceSeoPageTitleSuffix(suffix)) :
(window as any).posthog.capture('$pageleave');
}
});

document.addEventListener('click', (e) => {
const link = (e.target as Element).closest('a');
if (!link?.href?.includes('dashboard.datocms.com') || !('gtag' in window)) return;

const label = (link.textContent ?? '').toLowerCase();
if (/log.?in|sign.?in|enter dashboard/i.test(label)) return;

(window as any).gtag('event', 'conversion', {
send_to: GOOGLE_ADS_CONVERSIONS.dashboardClick,
transport_type: 'beacon',
});
});
</script>
50 changes: 50 additions & 0 deletions src/lib/consent.ts
Original file line number Diff line number Diff line change
@@ -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));
}
7 changes: 7 additions & 0 deletions src/lib/googleAds.ts
Original file line number Diff line number Diff line change
@@ -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;
30 changes: 30 additions & 0 deletions src/lib/linkedin.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
if (!LINKEDIN_ACCESS_TOKEN) return;

const emailHash = createHash('sha256').update(email.toLowerCase().trim()).digest('hex');

const response = await fetch('https://api.linkedin.com/rest/conversionEvents', {
method: 'POST',
headers: {
Authorization: `Bearer ${LINKEDIN_ACCESS_TOKEN}`,
'Content-Type': 'application/json',
'LinkedIn-Version': '202506',
'X-RestLi-Protocol-Version': '2.0.0',
},
body: JSON.stringify({
conversion: `urn:lla:llaPartnerConversion:${conversionRuleId}`,
conversionHappenedAt: Date.now(),
user: {
userIds: [{ idType: 'SHA256_EMAIL', idValue: emailHash }],
},
}),
signal: AbortSignal.timeout(5000),
});

if (!response.ok) {
throw new Error(`LinkedIn CAPI ${response.status}: ${await response.text()}`);
}
}
8 changes: 8 additions & 0 deletions src/pages/contact/thanks.astro
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
---
import { FormResultLayout } from '~/layouts/FormResultLayout';
import { GOOGLE_ADS_CONVERSIONS } from '~/lib/googleAds';
---

<FormResultLayout type="success" kicker="Contact our Sales team">
<script is:inline define:vars={{ GADS_CONVERSION: GOOGLE_ADS_CONVERSIONS.contactSubmit }}>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag('event', 'conversion', { send_to: GADS_CONVERSION });
</script>
<p>
We have successfully received your request, and <strong
>you will shortly receive an automated confirmation via email</strong
Expand Down
Loading
Loading