diff --git a/package.json b/package.json index add4f32..b0c9bac 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "posthog-js": "^1.376.4", "react": "^18.2.0", "react-dom": "^18.2.0", + "resend": "^6.12.4", "satori": "^0.26.0", "sharp": "^0.34.5", "split-text-js": "^1.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 723a9ee..886c931 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + resend: + specifier: ^6.12.4 + version: 6.12.4 satori: specifier: ^0.26.0 version: 0.26.0 @@ -1887,6 +1890,9 @@ packages: '@so-ric/colorspace@1.1.6': resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@stripe/react-stripe-js@2.4.0': resolution: {integrity: sha512-1jVQEL3OuhuzNlf4OdfqovHt+MkWh8Uh8xpLxx/xUFUDdF+7/kDOrGKy+xJO3WLCfZUL7NAy+/ypwXbbYZi0tg==} peerDependencies: @@ -3210,6 +3216,9 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} @@ -4675,6 +4684,9 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + postal-mime@2.7.4: + resolution: {integrity: sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==} + postcss-selector-parser@6.0.10: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} engines: {node: '>=4'} @@ -4971,6 +4983,15 @@ packages: require-package-name@2.0.1: resolution: {integrity: sha512-uuoJ1hU/k6M0779t3VMVIYpb2VMJk05cehCaABFhXaibcbvfgR8wKiozLjVFSzJPmQMRqIcO0HMyTFqfV09V6Q==} + resend@6.12.4: + resolution: {integrity: sha512-lRpJ2Hxd+ht+JPDm97juRcUp9HOMuZyxaRFRFmc9Tx8iNWiei94Dx9v6SWufgKk2667C/uCeKKspMotOHSpCSg==} + engines: {node: '>=20'} + peerDependencies: + '@react-email/render': '*' + peerDependenciesMeta: + '@react-email/render': + optional: true + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -5204,6 +5225,9 @@ packages: stack-trace@0.0.10: resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -7460,6 +7484,8 @@ snapshots: color: 5.0.3 text-hex: 1.0.0 + '@stablelib/base64@1.0.1': {} + '@stripe/react-stripe-js@2.4.0(@stripe/stripe-js@2.4.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@stripe/stripe-js': 2.4.0 @@ -8962,6 +8988,8 @@ snapshots: fast-safe-stringify@2.1.1: {} + fast-sha256@1.3.0: {} + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: @@ -10775,6 +10803,8 @@ snapshots: possible-typed-array-names@1.1.0: {} + postal-mime@2.7.4: {} + postcss-selector-parser@6.0.10: dependencies: cssesc: 3.0.0 @@ -11142,6 +11172,11 @@ snapshots: require-package-name@2.0.1: {} + resend@6.12.4: + dependencies: + postal-mime: 2.7.4 + standardwebhooks: 1.0.0 + resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -11474,6 +11509,11 @@ snapshots: stack-trace@0.0.10: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + statuses@2.0.2: {} std-env@4.1.0: {} diff --git a/src/env-secrets.ts b/src/env-secrets.ts index 2867661..780b1f9 100644 --- a/src/env-secrets.ts +++ b/src/env-secrets.ts @@ -42,6 +42,12 @@ export const env = createEnv({ EGON_ITEM_ONE_PRICE: z.coerce.number(), EGON_ITEM_TWELVE_ID: z.coerce.number(), EGON_ITEM_TWELVE_PRICE: z.coerce.number(), + // Resend (AI Check-In lead-magnet email). Optional so builds/dev don't break + // before the key is configured; the API route degrades gracefully without it. + RESEND_API_KEY: z.string().optional(), + // Must be a Resend-verified sender. Defaults to the WhoCards domain — verify + // whocards.cc in Resend (or override this env) before it can email real users. + RESEND_FROM_EMAIL: z.string().default('WhoCards '), }, clientPrefix: 'PUBLIC_', client: { diff --git a/src/pages/ai-at-work.astro b/src/pages/ai-at-work.astro index 9d96220..3d5ab19 100644 --- a/src/pages/ai-at-work.astro +++ b/src/pages/ai-at-work.astro @@ -6,37 +6,49 @@ import WhatCard from '~components/Purchase/WhatCard.astro' import Layout from '~layouts/Layout.astro' // AI Check-In marketing landing page (Tier 0 "lead magnet" wedge). -// See docs/strategy/ai-at-work.md §5 (Tier 0) and §7 (positioning). -// -// COPY: all visible text below is PLACEHOLDER. Final marketing words come from -// the separate copy ticket — search this file for `TODO(copy)` and replace. -// -// TODO(#55/#57): The primary + footer CTAs point at the AI Check-In deck route. -// The final route is being decided in #55 (likely `/play/ai-at-work`). Update -// `deckHref` once #55/#57 land. Until then this links to a not-yet-built route. +// See docs/strategy/ai-at-work.md §5 (Tier 0) and §7 (positioning), and the +// finalized copy in docs/strategy/ai-checkin-landing-copy.md (#58). + +// The AI Check-In deck is live at /play/ai-at-work (#57). const deckHref = '/play/ai-at-work' // TODO(OG): give this page its own dedicated OG card. For now it falls back to // the site default social.png (handled by Head.astro when `image` is omitted). // e.g. pass image='og/ai-at-work.png' to . -// TODO(copy / strategy §2): replace placeholder stats with the real figures from -// docs/strategy/ai-at-work.md §2 ("Why now"). Candidates: -// 89% workers concerned about AI's impact on job security -// 38% of employees feel fully supported adapting to AI -// 62% of orgs stuck "experimenting/piloting" -// ~38% of adoption difficulty is people, not tech +// Proof stats — independently sourced public research (kept evergreen via live +// source links; refresh annually). See docs/strategy/ai-checkin-landing-copy.md §3. const stats = [ - {value: '89%', label: 'TODO(copy): stat caption — strategy §2'}, - {value: '38%', label: 'TODO(copy): stat caption — strategy §2'}, - {value: '62%', label: 'TODO(copy): stat caption — strategy §2'}, - {value: '20 min', label: 'TODO(copy): stat caption — strategy §2'}, + { + value: '52%', + label: 'of US workers are worried about AI at work — only 36% feel hopeful', + source: 'Pew, 2025', + href: 'https://www.pewresearch.org/social-trends/2025/02/25/u-s-workers-are-more-worried-than-hopeful-about-future-ai-use-in-the-workplace/', + }, + { + value: '~30%', + label: 'of employees use AI in secret, fearing it could cost them their job', + source: 'Ivanti, 2025', + href: 'https://www.emarketer.com/content/employees-fear-disclosing-ai-use-would-threaten-their-job', + }, + { + value: '3×', + label: 'more staff use AI daily than leadership believes', + source: 'McKinsey, 2025', + href: 'https://www.mckinsey.com/capabilities/tech-and-ai/our-insights/superagency-in-the-workplace-empowering-people-to-unlock-ais-full-potential-at-work', + }, + { + value: '40%', + label: 'of workers fear AI will make their job obsolete — up from 28%', + source: 'Mercer, 2026', + href: 'https://www.cfodive.com/news/workers-fear-ai-diminish-replace-role-grantthornton/731972/', + }, ] --- @@ -46,24 +58,24 @@ const stats = [

- {/* TODO(copy): eyebrow */} The AI Check-In

- {/* TODO(copy): hero headline */} - Your AI rollout is stalling because of - people, not tech. + Your AI rollout isn't stalling on the tech. It's stalling on the + people.

- {/* TODO(copy): hero subhead */} - Run a 20-minute team check-in that turns quiet anxiety about AI into an honest, - out-loud conversation — using the questions WhoCards is known for. + A free, ~20-minute team check-in that surfaces what your people actually think about AI — + before it quietly decides for them.

+

+ Free. ~20 minutes. No sign-up — just open the deck and start the conversation. +

- + - {/* - TODO(capture endpoint): this form does NOT submit anywhere yet. There is no - backend wired up. A later ticket must point the submit handler at a real - email-capture endpoint (e.g. a Netlify function or form provider) and add - validation + success/error states. For now the inline handler in the script - below prevents submission and is a harmless placeholder. - */}
-

+

@@ -156,18 +174,15 @@ const stats = [

- {/* TODO(copy): footer CTA headline */} Start the conversation today.

- {/* TODO(copy): footer CTA supporting line */} - It takes 20 minutes and costs nothing. Draw a card, ask the question, and find out + It takes about 20 minutes and costs nothing. Draw a card, ask the question, and find out what your team is really thinking about AI.

- {/* TODO(#55/#57): deck route — see deckHref above */} @@ -175,14 +190,42 @@ const stats = [
diff --git a/src/pages/api/ai-checkin-subscribe.ts b/src/pages/api/ai-checkin-subscribe.ts new file mode 100644 index 0000000..7c9134a --- /dev/null +++ b/src/pages/api/ai-checkin-subscribe.ts @@ -0,0 +1,113 @@ +import type {APIRoute} from 'astro' +import {Resend} from 'resend' +import {z} from 'zod' +import questions from '~data/decks/ai-at-work.questions.json' +import {env} from '~env-secrets' +import {insertUser} from '~server/db' + +// SSR endpoint for the AI Check-In lead magnet (/ai-at-work). Captures the email +// as a newsletter user (reusing the existing users table — no migration) and +// emails the 10-question starter via Resend. +export const prerender = false + +const schema = z.object({ + email: z.string().email(), + // optional — the landing form is email-only; we derive a name when absent. + name: z.string().trim().optional(), +}) + +// 10 starter questions, spread across the deck's four acts (Name the fear / +// Map the work / Redesign the role / Team norms). +const STARTER_IDS = [ + 'ai-1', + 'ai-4', + 'ai-7', + 'ai-10', + 'ai-11', + 'ai-16', + 'ai-19', + 'ai-24', + 'ai-28', + 'ai-37', +] as const + +const starterQuestions = STARTER_IDS.map((id) => (questions as Record)[id]?.en) + .filter((q): q is string => Boolean(q)) + +const DECK_URL = 'https://whocards.cc/play/ai-at-work' + +const json = (body: unknown, status: number) => + new Response(JSON.stringify(body), {status, headers: {'Content-Type': 'application/json'}}) + +const buildEmailHtml = () => { + const items = starterQuestions + .map( + (q) => + `
  • ${q}
  • ` + ) + .join('') + + return ` +
    +

    Your AI Check-In starter

    +

    + Ten questions to get your team talking honestly about AI — the fear, what stays human, + and the norms you want to live by. Pick one, ask it in your next team meeting, and listen. +

    +
      ${items}
    +

    + When you're ready for the full deck, run the whole check-in here: +

    +

    + Run the check-in +

    +

    + You're getting this because you asked for the starter at whocards.cc. Not what you expected? Just ignore this email. +

    +
    ` +} + +export const POST: APIRoute = async ({request}) => { + const body = await request.json().catch(() => null) + const parsed = schema.safeParse(body) + + if (!parsed.success) { + return json({message: 'Please enter a valid email address.'}, 400) + } + + const {email, name} = parsed.data + const displayName = name && name.length > 0 ? name : email.split('@')[0] + + // Capture the lead (newsletter opt-in). Upserts on email, so resubmits are safe. + // Don't fail the request if the DB write hiccups — sending the starter matters more. + try { + await insertUser({email, name: displayName, newsletter: true}) + } catch (error) { + console.error('ai-checkin-subscribe: failed to store lead', error) + } + + if (!env.RESEND_API_KEY) { + console.error('ai-checkin-subscribe: RESEND_API_KEY not configured') + return json({message: 'Email is not configured yet. Please try again later.'}, 503) + } + + try { + const resend = new Resend(env.RESEND_API_KEY) + const {error} = await resend.emails.send({ + from: env.RESEND_FROM_EMAIL, + to: email, + subject: 'Your AI Check-In starter — 10 questions to get your team talking', + html: buildEmailHtml(), + }) + + if (error) { + console.error('ai-checkin-subscribe: resend error', error) + return json({message: "We couldn't send the email just now. Please try again."}, 502) + } + } catch (error) { + console.error('ai-checkin-subscribe: send threw', error) + return json({message: "We couldn't send the email just now. Please try again."}, 502) + } + + return json({message: 'Check your inbox — your 10 questions are on the way.'}, 200) +}