From d299f1f856c407af0074227f3259f9cfdf69ec06 Mon Sep 17 00:00:00 2001 From: Jenny Date: Sat, 20 Jun 2026 08:25:29 +0800 Subject: [PATCH 1/6] feat(newsletter): add Resend capture with Upstash rate limiting --- .env.example | 12 ++ bun.lock | 35 +++- package.json | 8 +- src/components/layout/newsletter-form.tsx | 110 ++++++++++ src/components/layout/site-footer.tsx | 34 +--- src/lib/integrations/resend.ts | 200 +++++++++++++++++++ src/lib/newsletter/actions.ts | 23 +++ src/lib/newsletter/rate-limit.test.ts | 30 +++ src/lib/newsletter/rate-limit.ts | 110 ++++++++++ src/lib/newsletter/submit.test.ts | 233 ++++++++++++++++++++++ src/lib/newsletter/submit.ts | 76 +++++++ src/lib/newsletter/types.ts | 11 + src/lib/schemas/newsletter.ts | 20 ++ 13 files changed, 868 insertions(+), 34 deletions(-) create mode 100644 src/components/layout/newsletter-form.tsx create mode 100644 src/lib/integrations/resend.ts create mode 100644 src/lib/newsletter/actions.ts create mode 100644 src/lib/newsletter/rate-limit.test.ts create mode 100644 src/lib/newsletter/rate-limit.ts create mode 100644 src/lib/newsletter/submit.test.ts create mode 100644 src/lib/newsletter/submit.ts create mode 100644 src/lib/newsletter/types.ts create mode 100644 src/lib/schemas/newsletter.ts diff --git a/.env.example b/.env.example index ae7f9f2..6c0db38 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,15 @@ SENTRY_AUTH_TOKEN= # Optional. Defaults to VERCEL_GIT_COMMIT_SHA on Vercel. SENTRY_RELEASE= + +# Resend (newsletter capture) +RESEND_API_KEY= +RESEND_SEGMENT_ID= + +# Optional. Added when Cloudflare Turnstile is wired into public forms. +NEXT_PUBLIC_TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= + +# Upstash Redis (newsletter rate limiting) +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= diff --git a/bun.lock b/bun.lock index 5308735..4dc7a99 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,8 @@ "@react-three/drei": "10.7.7", "@react-three/fiber": "9.6.1", "@sentry/nextjs": "^10.58.0", + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.38.0", "@vercel/analytics": "^2.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -18,13 +20,16 @@ "radix-ui": "^1.4.3", "react": "19.2.4", "react-dom": "19.2.4", + "resend": "^6.14.0", "shadcn": "^4.7.0", "tailwind-merge": "^3.6.0", "three": "0.182.0", "tw-animate-css": "^1.4.0", + "zod": "^4.4.3", }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/bun": "^1.3.14", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -523,6 +528,8 @@ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], "@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="], @@ -561,6 +568,8 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/draco3d": ["@types/draco3d@1.4.10", "", {}, "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw=="], "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], @@ -655,6 +664,12 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.12.2", "", { "os": "win32", "cpu": "x64" }, "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA=="], + "@upstash/core-analytics": ["@upstash/core-analytics@0.0.10", "", { "dependencies": { "@upstash/redis": "^1.28.3" } }, "sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ=="], + + "@upstash/ratelimit": ["@upstash/ratelimit@2.0.8", "", { "dependencies": { "@upstash/core-analytics": "^0.0.10" }, "peerDependencies": { "@upstash/redis": "^1.34.3" } }, "sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w=="], + + "@upstash/redis": ["@upstash/redis@1.38.0", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg=="], + "@use-gesture/core": ["@use-gesture/core@10.3.1", "", {}, "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw=="], "@use-gesture/react": ["@use-gesture/react@10.3.1", "", { "dependencies": { "@use-gesture/core": "10.3.1" }, "peerDependencies": { "react": ">= 16.8.0" } }, "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g=="], @@ -773,6 +788,8 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -987,6 +1004,8 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], @@ -1431,6 +1450,8 @@ "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + "postal-mime": ["postal-mime@2.7.4", "", {}, "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g=="], + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], "postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], @@ -1493,6 +1514,8 @@ "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], + "resend": ["resend@6.14.0", "", { "dependencies": { "postal-mime": "2.7.4", "standardwebhooks": "1.0.0" }, "peerDependencies": { "@react-email/render": "*" }, "optionalPeers": ["@react-email/render"] }, "sha512-jVdpUgOoWGLjaP64lo8KwzHT9gY4w6Dl8c36CIb2F+ayYOMLr3khqs8xrNjXM2k19b+lPoj0VWQFhVNLiToBjA=="], + "resolve": ["resolve@2.0.0-next.7", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.2", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ=="], "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], @@ -1571,6 +1594,8 @@ "stacktrace-parser": ["stacktrace-parser@0.1.11", "", { "dependencies": { "type-fest": "^0.7.1" } }, "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg=="], + "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], + "stats-gl": ["stats-gl@2.4.2", "", { "dependencies": { "@types/three": "*", "three": "^0.170.0" } }, "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ=="], "stats.js": ["stats.js@0.17.0", "", {}, "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw=="], @@ -1687,6 +1712,8 @@ "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], @@ -1765,7 +1792,7 @@ "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], @@ -1787,8 +1814,6 @@ "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - "@modelcontextprotocol/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "@mswjs/interceptors/@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="], "@next/eslint-plugin-next/fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], @@ -1835,8 +1860,6 @@ "eslint-plugin-import/tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], - "eslint-plugin-react-hooks/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -1875,6 +1898,8 @@ "schema-utils/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], + "shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "sharp/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], "stats-gl/@types/three": ["@types/three@0.184.1", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA=="], diff --git a/package.json b/package.json index 35560da..02f8a64 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "next build", "start": "next start", "lint": "eslint .", + "test": "bun test", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -14,6 +15,8 @@ "@react-three/drei": "10.7.7", "@react-three/fiber": "9.6.1", "@sentry/nextjs": "^10.58.0", + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.38.0", "@vercel/analytics": "^2.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -23,13 +26,16 @@ "radix-ui": "^1.4.3", "react": "19.2.4", "react-dom": "19.2.4", + "resend": "^6.14.0", "shadcn": "^4.7.0", "tailwind-merge": "^3.6.0", "three": "0.182.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "zod": "^4.4.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/bun": "^1.3.14", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/src/components/layout/newsletter-form.tsx b/src/components/layout/newsletter-form.tsx new file mode 100644 index 0000000..1335147 --- /dev/null +++ b/src/components/layout/newsletter-form.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useActionState, useEffect, useRef, useState } from "react"; +import { CortexButton } from "@/components/cortex-button"; +import { submitNewsletterSignup } from "@/lib/newsletter/actions"; +import { newsletterInitialState } from "@/lib/newsletter/types"; + +type NewsletterFormProps = { + emailInputId: string; + placeholder: string; + submitLabel: string; +}; + +export default function NewsletterForm({ + emailInputId, + placeholder, + submitLabel, +}: NewsletterFormProps) { + const [state, formAction, pending] = useActionState( + submitNewsletterSignup, + newsletterInitialState, + ); + const emailInputRef = useRef(null); + const [emailValue, setEmailValue] = useState(""); + const [messageDismissed, setMessageDismissed] = useState(false); + const messageId = `${emailInputId}-message`; + const hasMessage = state.message.length > 0; + const showConfirmedMessage = + state.status === "success" && + state.subscribedEmail === emailValue.trim().toLowerCase(); + const showMessage = + !messageDismissed && + hasMessage && + (state.status === "error" || + (state.status === "success" && showConfirmedMessage)); + const showEmailError = !messageDismissed && Boolean(state.emailError); + + useEffect(() => { + if (state.status === "success" && state.subscribedEmail) { + emailInputRef.current?.focus(); + } + }, [state.message, state.status, state.subscribedEmail]); + + function handleEmailChange(value: string) { + setMessageDismissed(true); + setEmailValue(value); + } + + function handleSubmit() { + setMessageDismissed(false); + } + + return ( +
+
+ + handleEmailChange(event.target.value)} + aria-invalid={showEmailError ? "true" : undefined} + aria-describedby={showMessage ? messageId : undefined} + suppressHydrationWarning + className="site-footer-email-input" + /> + + {showMessage && ( +

+ {state.message} +

+ )} +
+ + {pending ? "Subscribing" : submitLabel} + +
+ ); +} diff --git a/src/components/layout/site-footer.tsx b/src/components/layout/site-footer.tsx index e77954b..b5131b3 100644 --- a/src/components/layout/site-footer.tsx +++ b/src/components/layout/site-footer.tsx @@ -1,10 +1,9 @@ -import Link from "next/link"; import { HashLink } from "@/components/hash-link"; import { HomeLink } from "@/components/home-link"; -import { CortexButton } from "@/components/cortex-button"; import { CortexMark } from "@/components/logos/cortex-mark"; import { CortexWordmark } from "@/components/logos/cortex-wordmark"; import { SOCIAL_GLYPHS } from "@/components/icons/social-glyphs"; +import NewsletterForm from "@/components/layout/newsletter-form"; import { footerContent } from "@/lib/content/footer"; import type { ExternalLinkChannel } from "@/lib/content/links"; import { splitTrailingAccent } from "@/lib/split-trailing-accent"; @@ -88,33 +87,12 @@ export function SiteFooter() { )} - {/* Newsletter — input UI only, no submit behavior. Mobile: input above button. Desktop: input and button inline. */}
-
-
- - {/* TODO: add validation */} - -
- - {footerContent.newsletterCta.label} - -
+
diff --git a/src/lib/integrations/resend.ts b/src/lib/integrations/resend.ts new file mode 100644 index 0000000..8b37da3 --- /dev/null +++ b/src/lib/integrations/resend.ts @@ -0,0 +1,200 @@ +import { Resend } from "resend"; + +type ResendNewsletterConfig = + | { + ok: true; + apiKey: string; + segmentId: string; + } + | { + ok: false; + message: string; + }; + +export type NewsletterSubscribeResult = + | { + ok: true; + alreadySubscribed: boolean; + } + | { + ok: false; + code: "configuration" | "resend"; + message: string; + }; + +function getNewsletterConfig(): ResendNewsletterConfig { + const apiKey = process.env.RESEND_API_KEY; + const segmentId = process.env.RESEND_SEGMENT_ID; + + if (!apiKey || !segmentId) { + return { + ok: false, + message: "Newsletter capture is missing Resend configuration.", + }; + } + + return { + ok: true, + apiKey, + segmentId, + }; +} + +function isMissingContactError(errorName: string | undefined): boolean { + return errorName === "not_found"; +} + +function getResendErrorMessage(error: { message: string } | null): string { + return error?.message || "Resend rejected the newsletter subscription."; +} + +async function verifyNewsletterSubscription( + resend: Resend, + email: string, + segmentId: string, +): Promise { + const contact = await resend.contacts.get({ email }); + + if (contact.error || !contact.data) { + return { + ok: false, + code: "resend", + message: getResendErrorMessage(contact.error), + }; + } + + if (contact.data.unsubscribed) { + return { + ok: false, + code: "resend", + message: "Resend did not confirm the newsletter subscription.", + }; + } + + const contactSegments = await resend.contacts.segments.list({ + email, + limit: 100, + }); + + if (contactSegments.error) { + return { + ok: false, + code: "resend", + message: getResendErrorMessage(contactSegments.error), + }; + } + + const inSegment = contactSegments.data.data.some( + (segment) => segment.id === segmentId, + ); + + if (!inSegment) { + return { + ok: false, + code: "resend", + message: "Resend did not confirm the newsletter subscription.", + }; + } + + return null; +} + +export async function subscribeNewsletterContact( + email: string, +): Promise { + const config = getNewsletterConfig(); + + if (!config.ok) { + return { + ok: false, + code: "configuration", + message: config.message, + }; + } + + const resend = new Resend(config.apiKey); + const existingContact = await resend.contacts.get({ email }); + + if (existingContact.error && !isMissingContactError(existingContact.error.name)) { + return { + ok: false, + code: "resend", + message: getResendErrorMessage(existingContact.error), + }; + } + + if (existingContact.data) { + const updateContact = await resend.contacts.update({ + email, + unsubscribed: false, + }); + + if (updateContact.error) { + return { + ok: false, + code: "resend", + message: getResendErrorMessage(updateContact.error), + }; + } + } else { + const createContact = await resend.contacts.create({ + email, + unsubscribed: false, + }); + + if (createContact.error) { + return { + ok: false, + code: "resend", + message: getResendErrorMessage(createContact.error), + }; + } + } + + const contactSegments = await resend.contacts.segments.list({ + email, + limit: 100, + }); + + if (contactSegments.error) { + return { + ok: false, + code: "resend", + message: getResendErrorMessage(contactSegments.error), + }; + } + + const alreadyInSegment = contactSegments.data.data.some( + (segment) => segment.id === config.segmentId, + ); + + if (!alreadyInSegment) { + const addSegment = await resend.contacts.segments.add({ + email, + segmentId: config.segmentId, + }); + + if (addSegment.error) { + return { + ok: false, + code: "resend", + message: getResendErrorMessage(addSegment.error), + }; + } + } + + const verificationError = await verifyNewsletterSubscription( + resend, + email, + config.segmentId, + ); + + if (verificationError) { + return verificationError; + } + + return { + ok: true, + alreadySubscribed: alreadyInSegment, + }; +} diff --git a/src/lib/newsletter/actions.ts b/src/lib/newsletter/actions.ts new file mode 100644 index 0000000..42ce9f4 --- /dev/null +++ b/src/lib/newsletter/actions.ts @@ -0,0 +1,23 @@ +"use server"; + +import { headers } from "next/headers"; +import { subscribeNewsletterContact } from "@/lib/integrations/resend"; +import { + checkNewsletterRateLimit, + getNewsletterRateLimitIdentifier, +} from "@/lib/newsletter/rate-limit"; +import { handleNewsletterSignup } from "@/lib/newsletter/submit"; +import type { NewsletterFormState } from "@/lib/newsletter/types"; + +export async function submitNewsletterSignup( + _prevState: NewsletterFormState, + formData: FormData, +): Promise { + const headersList = await headers(); + const rateLimitIdentifier = getNewsletterRateLimitIdentifier(headersList); + + return handleNewsletterSignup(formData, { + rateLimit: () => checkNewsletterRateLimit(rateLimitIdentifier), + subscribe: subscribeNewsletterContact, + }); +} diff --git a/src/lib/newsletter/rate-limit.test.ts b/src/lib/newsletter/rate-limit.test.ts new file mode 100644 index 0000000..496ce80 --- /dev/null +++ b/src/lib/newsletter/rate-limit.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; +import { getNewsletterRateLimitIdentifier } from "@/lib/newsletter/rate-limit"; + +function headersFrom(values: Record): Headers { + const headers = new Headers(); + + for (const [key, value] of Object.entries(values)) { + headers.set(key, value); + } + + return headers; +} + +describe("getNewsletterRateLimitIdentifier", () => { + test("uses the first x-forwarded-for IP", () => { + const identifier = getNewsletterRateLimitIdentifier( + headersFrom({ + "x-forwarded-for": "203.0.113.10, 198.51.100.20", + }), + ); + + expect(identifier).toBe("203.0.113.10"); + }); + + test("falls back to local when proxy headers are absent", () => { + const identifier = getNewsletterRateLimitIdentifier(headersFrom({})); + + expect(identifier).toBe("local"); + }); +}); diff --git a/src/lib/newsletter/rate-limit.ts b/src/lib/newsletter/rate-limit.ts new file mode 100644 index 0000000..51ccfe3 --- /dev/null +++ b/src/lib/newsletter/rate-limit.ts @@ -0,0 +1,110 @@ +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; + +const NEWSLETTER_RATE_LIMIT_REQUESTS = 5; +const NEWSLETTER_RATE_LIMIT_WINDOW = "1 m"; +const NEWSLETTER_RATE_LIMIT_PREFIX = "cortex:newsletter"; +const LOCAL_RATE_LIMIT_IDENTIFIER = "local"; + +type HeaderReader = { + get(name: string): string | null; +}; + +export type NewsletterRateLimitResult = + | { + ok: true; + skipped?: boolean; + } + | { + ok: false; + code: "configuration" | "limited" | "storage"; + message: string; + reset?: number; + }; + +let newsletterRateLimiter: Ratelimit | null = null; + +function isProductionLikeRuntime(): boolean { + return ( + process.env.NODE_ENV === "production" || + process.env.VERCEL_ENV === "preview" || + process.env.VERCEL_ENV === "production" + ); +} + +function hasUpstashConfig(): boolean { + return Boolean( + process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN, + ); +} + +function getNewsletterRateLimiter(): Ratelimit { + newsletterRateLimiter ??= new Ratelimit({ + redis: Redis.fromEnv({ enableTelemetry: false }), + limiter: Ratelimit.slidingWindow( + NEWSLETTER_RATE_LIMIT_REQUESTS, + NEWSLETTER_RATE_LIMIT_WINDOW, + ), + analytics: false, + prefix: NEWSLETTER_RATE_LIMIT_PREFIX, + }); + + return newsletterRateLimiter; +} + +function firstForwardedIp(value: string | null): string | null { + if (!value) return null; + + return value + .split(",") + .map((item) => item.trim()) + .find(Boolean) ?? null; +} + +export function getNewsletterRateLimitIdentifier(headersList: HeaderReader): string { + return ( + firstForwardedIp(headersList.get("x-forwarded-for")) ?? + firstForwardedIp(headersList.get("x-vercel-forwarded-for")) ?? + headersList.get("cf-connecting-ip") ?? + headersList.get("x-real-ip") ?? + LOCAL_RATE_LIMIT_IDENTIFIER + ); +} + +export async function checkNewsletterRateLimit( + identifier: string, +): Promise { + if (!hasUpstashConfig()) { + if (!isProductionLikeRuntime()) { + return { ok: true, skipped: true }; + } + + return { + ok: false, + code: "configuration", + message: + "Newsletter rate limiting is missing required Upstash environment variables.", + }; + } + + try { + const result = await getNewsletterRateLimiter().limit(identifier); + + if (!result.success) { + return { + ok: false, + code: "limited", + message: "Too many attempts. Please try again in a few minutes.", + reset: result.reset, + }; + } + + return { ok: true }; + } catch { + return { + ok: false, + code: "storage", + message: "We could not accept that request right now. Please try again.", + }; + } +} diff --git a/src/lib/newsletter/submit.test.ts b/src/lib/newsletter/submit.test.ts new file mode 100644 index 0000000..0a50b8d --- /dev/null +++ b/src/lib/newsletter/submit.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, test } from "bun:test"; +import type { NewsletterSubscribeResult } from "@/lib/integrations/resend"; +import type { NewsletterRateLimitResult } from "@/lib/newsletter/rate-limit"; +import { handleNewsletterSignup } from "@/lib/newsletter/submit"; + +function createFormData(fields: Record): FormData { + const formData = new FormData(); + + for (const [key, value] of Object.entries(fields)) { + formData.set(key, value); + } + + return formData; +} + +function createSubscribeSpy(result: NewsletterSubscribeResult) { + const calls: string[] = []; + + return { + calls, + subscribe: async (email: string) => { + calls.push(email); + return result; + }, + }; +} + +function createRateLimitSpy(result: NewsletterRateLimitResult) { + let calls = 0; + + return { + get calls() { + return calls; + }, + rateLimit: async () => { + calls += 1; + return result; + }, + }; +} + +const subscribedResult: NewsletterSubscribeResult = { + ok: true, + alreadySubscribed: false, +}; + +describe("handleNewsletterSignup", () => { + test("rejects invalid email before subscribing", async () => { + const subscribeSpy = createSubscribeSpy(subscribedResult); + const result = await handleNewsletterSignup( + createFormData({ + email: "not-an-email", + company: "", + }), + { + subscribe: subscribeSpy.subscribe, + }, + ); + + expect(result).toEqual({ + status: "error", + message: "Enter a valid email address.", + emailError: "Enter a valid email address.", + }); + expect(subscribeSpy.calls).toEqual([]); + }); + + test("silently accepts honeypot submissions without subscribing", async () => { + const subscribeSpy = createSubscribeSpy(subscribedResult); + const result = await handleNewsletterSignup( + createFormData({ + email: "person@example.com", + company: "Bot Corp", + }), + { + subscribe: subscribeSpy.subscribe, + }, + ); + + expect(result).toEqual({ + status: "success", + message: "You have successfully subscribed to the Cortex update list.", + }); + expect(subscribeSpy.calls).toEqual([]); + }); + + test("normalizes valid email and subscribes it", async () => { + const subscribeSpy = createSubscribeSpy(subscribedResult); + const rateLimitSpy = createRateLimitSpy({ ok: true }); + const result = await handleNewsletterSignup( + createFormData({ + email: " Person@Example.COM ", + company: "", + }), + { + rateLimit: rateLimitSpy.rateLimit, + subscribe: subscribeSpy.subscribe, + }, + ); + + expect(result).toEqual({ + status: "success", + message: "You have successfully subscribed to the Cortex update list.", + subscribedEmail: "person@example.com", + }); + expect(rateLimitSpy.calls).toBe(1); + expect(subscribeSpy.calls).toEqual(["person@example.com"]); + }); + + test("does not subscribe when the rate limit blocks the request", async () => { + const subscribeSpy = createSubscribeSpy(subscribedResult); + const rateLimitSpy = createRateLimitSpy({ + ok: false, + code: "limited", + message: "Too many attempts. Please try again in a few minutes.", + reset: 20_000, + }); + const result = await handleNewsletterSignup( + createFormData({ + email: "person@example.com", + company: "", + }), + { + rateLimit: rateLimitSpy.rateLimit, + subscribe: subscribeSpy.subscribe, + }, + ); + + expect(result).toEqual({ + status: "error", + message: "Too many attempts. Please try again in a few minutes.", + }); + expect(rateLimitSpy.calls).toBe(1); + expect(subscribeSpy.calls).toEqual([]); + }); + + test("does not subscribe when production rate-limit configuration is missing", async () => { + const subscribeSpy = createSubscribeSpy(subscribedResult); + const rateLimitSpy = createRateLimitSpy({ + ok: false, + code: "configuration", + message: + "Newsletter rate limiting is missing required Upstash environment variables.", + }); + const result = await handleNewsletterSignup( + createFormData({ + email: "person@example.com", + company: "", + }), + { + rateLimit: rateLimitSpy.rateLimit, + subscribe: subscribeSpy.subscribe, + }, + ); + + expect(result).toEqual({ + status: "error", + message: + "Newsletter rate limiting is missing required Upstash environment variables.", + }); + expect(rateLimitSpy.calls).toBe(1); + expect(subscribeSpy.calls).toEqual([]); + }); + + test("returns already-submitted message when Resend confirms an existing segment member", async () => { + const subscribeSpy = createSubscribeSpy({ + ok: true, + alreadySubscribed: true, + }); + const result = await handleNewsletterSignup( + createFormData({ + email: "person@example.com", + company: "", + }), + { + subscribe: subscribeSpy.subscribe, + }, + ); + + expect(result).toEqual({ + status: "success", + message: "Your email has already been submitted.", + subscribedEmail: "person@example.com", + }); + expect(subscribeSpy.calls).toEqual(["person@example.com"]); + }); + + test("returns configuration errors without exposing Resend internals", async () => { + const subscribeSpy = createSubscribeSpy({ + ok: false, + code: "configuration", + message: "missing env", + }); + const result = await handleNewsletterSignup( + createFormData({ + email: "person@example.com", + company: "", + }), + { + subscribe: subscribeSpy.subscribe, + }, + ); + + expect(result).toEqual({ + status: "error", + message: "Newsletter setup is missing required Resend environment variables.", + }); + expect(subscribeSpy.calls).toEqual(["person@example.com"]); + }); + + test("returns a generic message for Resend API failures", async () => { + const subscribeSpy = createSubscribeSpy({ + ok: false, + code: "resend", + message: "upstream detail", + }); + const result = await handleNewsletterSignup( + createFormData({ + email: "person@example.com", + company: "", + }), + { + subscribe: subscribeSpy.subscribe, + }, + ); + + expect(result).toEqual({ + status: "error", + message: "We could not subscribe that email right now. Please try again.", + }); + expect(subscribeSpy.calls).toEqual(["person@example.com"]); + }); +}); diff --git a/src/lib/newsletter/submit.ts b/src/lib/newsletter/submit.ts new file mode 100644 index 0000000..7f152be --- /dev/null +++ b/src/lib/newsletter/submit.ts @@ -0,0 +1,76 @@ +import { newsletterSignupSchema } from "@/lib/schemas/newsletter"; +import type { NewsletterSubscribeResult } from "@/lib/integrations/resend"; +import type { NewsletterRateLimitResult } from "@/lib/newsletter/rate-limit"; +import type { NewsletterFormState } from "@/lib/newsletter/types"; + +type NewsletterSubscribe = (email: string) => Promise; +type NewsletterRateLimit = () => Promise; + +type HandleNewsletterSignupOptions = { + rateLimit?: NewsletterRateLimit; + subscribe: NewsletterSubscribe; +}; + +const SUBSCRIBED_MESSAGE = + "You have successfully subscribed to the Cortex update list."; +const ALREADY_SUBSCRIBED_MESSAGE = "Your email has already been submitted."; +const GENERIC_ERROR_MESSAGE = + "We could not subscribe that email right now. Please try again."; + +export async function handleNewsletterSignup( + formData: FormData, + { rateLimit, subscribe }: HandleNewsletterSignupOptions, +): Promise { + const parsed = newsletterSignupSchema.safeParse({ + email: formData.get("email"), + company: formData.get("company"), + }); + + if (!parsed.success) { + const errors = parsed.error.flatten().fieldErrors; + + return { + status: "error", + message: errors.email?.[0] || GENERIC_ERROR_MESSAGE, + emailError: errors.email?.[0], + }; + } + + if (parsed.data.company) { + return { + status: "success", + message: SUBSCRIBED_MESSAGE, + }; + } + + const rateLimitResult = await rateLimit?.(); + + if (rateLimitResult && !rateLimitResult.ok) { + return { + status: "error", + message: rateLimitResult.message, + }; + } + + const result = await subscribe(parsed.data.email); + + if (!result.ok) { + const configurationMessage = + result.code === "configuration" + ? "Newsletter setup is missing required Resend environment variables." + : GENERIC_ERROR_MESSAGE; + + return { + status: "error", + message: configurationMessage, + }; + } + + return { + status: "success", + message: result.alreadySubscribed + ? ALREADY_SUBSCRIBED_MESSAGE + : SUBSCRIBED_MESSAGE, + subscribedEmail: parsed.data.email, + }; +} diff --git a/src/lib/newsletter/types.ts b/src/lib/newsletter/types.ts new file mode 100644 index 0000000..9063b2f --- /dev/null +++ b/src/lib/newsletter/types.ts @@ -0,0 +1,11 @@ +export type NewsletterFormState = { + status: "idle" | "success" | "error"; + message: string; + emailError?: string; + subscribedEmail?: string; +}; + +export const newsletterInitialState = { + status: "idle", + message: "", +} satisfies NewsletterFormState; diff --git a/src/lib/schemas/newsletter.ts b/src/lib/schemas/newsletter.ts new file mode 100644 index 0000000..92964fa --- /dev/null +++ b/src/lib/schemas/newsletter.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +export const newsletterSignupSchema = z.object({ + email: z.preprocess( + (value) => (typeof value === "string" ? value : ""), + z + .string() + .trim() + .toLowerCase() + .min(1, "Enter an email address.") + .max(254, "Email address is too long.") + .pipe(z.email("Enter a valid email address.")), + ), + company: z.preprocess( + (value) => (typeof value === "string" ? value : ""), + z.string().trim(), + ), +}); + +export type NewsletterSignupInput = z.infer; From cf4580f86641a3a6e15cbea1d29d02a5f8d94cd2 Mon Sep 17 00:00:00 2001 From: Jenny Date: Sat, 20 Jun 2026 08:25:48 +0800 Subject: [PATCH 2/6] fix(newsletter): polish submit feedback --- skills/context/progress-tracker.md | 9 ++++++--- src/app/globals.css | 22 +++++++++++++++++++--- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/skills/context/progress-tracker.md b/skills/context/progress-tracker.md index d4a73e9..549cbbb 100644 --- a/skills/context/progress-tracker.md +++ b/skills/context/progress-tracker.md @@ -15,6 +15,7 @@ All seven homepage sections are built, wired, and animated (scroll-driven entran - Shared hooks: `use-reduced-motion`, `use-is-desktop`, `use-on-mount`. - Five Pattern Studio animated SVG starters in `skills/examples/` (algorithmic generation, no static SVG deps). - CI workflow: Bun setup, frozen lockfile, lint, typecheck, build. +- Local unit tests use Bun's built-in runner via `bun run test`. - Typed content modules in `src/lib/content/` for all sections + `nav.ts`. - Vercel Web Analytics wired through `@vercel/analytics` in the root app layout. - Sentry wired through `@sentry/nextjs` using Next 16 instrumentation entrypoints. @@ -39,13 +40,15 @@ All seven homepage sections are built, wired, and animated (scroll-driven entran - ScrollTrigger-triggered automatic line drawing for History and Team: History draws the desktop timeline line and mobile stems after their triggers enter; Team draws the desktop SVG rules and mobile divider separately from the content reveal. Both respect reduced-motion by setting lines directly to their resting drawn state. - Slightly slowed the Hero SplitText word reveal and Team line-draw timing after visual tuning feedback. - Aligned the History timeline line with milestone reveal timing: desktop line advances one segment per milestone trigger; mobile stems use the same trigger start/duration as milestone reveals. +- History ScrollTrigger setup now uses one trigger per milestone; each trigger owns milestone reveal, desktop line advance, mobile stem draw, and the final summary reveal. This avoids the previous batch-plus-separate-summary-trigger mix that could leave GSAP refresh with an undefined internal trigger. ### Layout - `SiteHeader` refactored from monolithic client component into server component + client islands (`SiteHeaderShell`, `HeaderMegaNav`, `SiteHeaderMobileMenu`). Reduces client JS bundle. - Header upcoming-event promo: `HeaderUpcomingEvent` / `HeaderUpcomingEventLink` render the next event (from `src/lib/events/upcoming.ts`, shared with the Events section; hourly ISR via `EVENTS_DATE_REVALIDATE_SECONDS`) in the desktop header and mobile hero. Header scroll state lives in `src/lib/layout/site-header-scroll.ts`. - Same-page hash navigation: `HashLink` + `HashScrollSync` (`src/lib/hash-navigation.ts`) fix `/#section` links when already on `/`; wired through hero CTAs, header, mobile nav, footer, and services CTA. Root layout wraps `{children}` in `Suspense` with `RouteLoadingShell`. -- `SiteFooter` server component: CTA quote block, newsletter input (UI only), contact mailto, About/Programs/Legal columns, social glyphs (X/Instagram/TikTok/LinkedIn/YouTube), copyright. Module-scope hoisted constants (`QUOTE_LINES`, `TAGLINE_LINES`, `SOCIAL_LINKS`). +- `SiteFooter` server component: CTA quote block, newsletter form island, contact mailto, About/Programs/Legal columns, social glyphs (X/Instagram/TikTok/LinkedIn/YouTube), copyright. Module-scope hoisted constants (`QUOTE_LINES`, `TAGLINE_LINES`, `SOCIAL_LINKS`). +- Footer newsletter capture basic setup: `NewsletterForm` client island calls a thin server action with Zod email validation, honeypot, Upstash Redis-backed per-IP rate limiting (`UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN`; local dev skips if absent, production-like runtimes fail closed), and Resend global Contact + Segment assignment via `RESEND_API_KEY` / `RESEND_SEGMENT_ID`; submit decision and rate-limit logic are covered by Bun unit tests. A real submit has been verified to create the user email in Resend. - `(site)` route group: `page.tsx` and site-specific layout (with `SiteFooter` + `flex-1` wrapper) live under `src/app/(site)/`. Root `layout.tsx` has header only — `not-found.tsx` stays at root so the 404 page renders without footer. - `/privacy` and `/terms` ship as Server Component routes in the `(site)` group with copy in `src/lib/content/legal.ts`; footer legal links point to the live routes. - `SectionDivider` shared component replaces per-section divider markup in team, monad, and services sections. @@ -82,7 +85,7 @@ All seven homepage sections are built, wired, and animated (scroll-driven entran ## Next Steps -1. Integrate Resend for newsletter form. Cloudflare Turnstile for prevent spam. +1. Finish newsletter capture hardening: Cloudflare Turnstile. 2. Preformance review and optimization. 3. og image and description. 4. SEO, tracking validation, and launch metadata. @@ -90,4 +93,4 @@ All seven homepage sections are built, wired, and animated (scroll-driven entran ## Latest Handoff -- Mission graph placement tuning: moved expanded-card illustrations into the Mission card content grid so each graph centers in the remaining space between the wrapped title and body instead of relying on fixed vertical offsets. Added equal vertical inset and max sizing for the illustration row to preserve breathing room at short desktop heights such as 1440x670. Touched `src/components/sections/mission/mission-cards-client.tsx` and `src/app/globals.css`. Verification: `bun run typecheck`, `bun run lint` (passes with existing warnings in `site-footer.tsx` and `monad-topic-enter.ts`), `bun run build`, browser geometry check at 1440x670 showing equal title/graph and graph/body gaps. Open: existing unrelated lint warnings remain. Next: continue Mission card-by-card visual tuning if requested. +- Newsletter implementation status: Resend is configured and a real submit has been confirmed to create the user email in Resend. Current protection is Zod validation, honeypot, and Upstash Redis-backed rate limiting (5 attempts / 1 minute per proxy IP identifier); the earlier 2-second interaction trap has been removed. `NewsletterForm` hides stale submit feedback as soon as the email input changes and resets feedback visibility on the next submit. Verification previously passed with `bun run test`, `bun run typecheck`, `bun run lint` (existing Monad warning only), and `bun run build`. Remaining newsletter issue #10 item is Cloudflare Turnstile. diff --git a/src/app/globals.css b/src/app/globals.css index 84e69c6..34e9b38 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1955,15 +1955,31 @@ } .site-footer-email-shell { - @apply flex flex-1 flex-col lg:w-83.5 lg:flex-none; + @apply relative flex flex-1 flex-col lg:w-83.5 lg:flex-none; } .site-footer-email-input { - @apply h-6.75 w-full border-0 border-b border-action-primary bg-transparent pb-1 font-open text-[16px] leading-normal text-brand-cortex-orange placeholder:text-neutral-neural-gray/40 focus:outline-none focus-visible:border-action-primary-hover sm:text-body lg:h-9.75 lg:pb-3.5 lg:font-mona lg:text-[25px] lg:leading-none; + @apply h-6.75 w-full border-0 border-b border-neutral-silver-gray bg-transparent pb-1 font-open text-[16px] leading-normal text-neutral-silver-gray placeholder:text-neutral-neural-gray/40 focus:outline-none focus-visible:border-neutral-silver-gray sm:text-body lg:h-9.75 lg:pb-3.5 lg:font-mona lg:text-[25px] lg:leading-none; + } + + .site-footer-honeypot { + @apply absolute top-auto left-[-10000px] h-px w-px overflow-hidden; + } + + .site-footer-newsletter-message { + @apply mt-2 font-open text-[0.75rem] leading-normal text-text-muted md:absolute md:top-full; + } + + .site-footer-newsletter-message[data-status="success"] { + @apply text-action-primary; + } + + .site-footer-newsletter-message[data-status="error"] { + @apply text-destructive; } .site-footer-newsletter-button { - @apply w-fit shrink-0 px-6 text-brand-cortex-carbon hover:text-brand-cortex-carbon; + @apply w-fit min-w-44 shrink-0 px-6 text-brand-cortex-carbon hover:text-brand-cortex-carbon; } .site-footer-body { From e0843cbf78b9b6f66a2b5792cf22966a1bfd8b9c Mon Sep 17 00:00:00 2001 From: Jenny Date: Sat, 20 Jun 2026 11:41:58 +0800 Subject: [PATCH 3/6] fix(newsletter): optimize Resend calls and skip rate limits for bots/invalid email --- skills/context/architecture-context.md | 1 + skills/context/progress-tracker.md | 4 +- src/components/layout/newsletter-form.tsx | 4 +- src/lib/integrations/resend.test.ts | 224 ++++++++++++++++++++++ src/lib/integrations/resend.ts | 176 +++++++++-------- src/lib/newsletter/rate-limit.ts | 5 +- src/lib/newsletter/submit.test.ts | 31 +++ src/lib/newsletter/submit.ts | 16 +- tsconfig.json | 3 +- 9 files changed, 370 insertions(+), 94 deletions(-) create mode 100644 src/lib/integrations/resend.test.ts diff --git a/skills/context/architecture-context.md b/skills/context/architecture-context.md index 47f4e26..c2dc94c 100644 --- a/skills/context/architecture-context.md +++ b/skills/context/architecture-context.md @@ -18,6 +18,7 @@ Use this stack when the production website is scaffolded or consolidated: - Zod for all input validation (form schemas, server action parsing, future env-var validation) - Resend (`resend` package) for newsletter capture — create a Contact and assign to the newsletter Segment/Topic (Audiences API is being migrated; see https://resend.com/docs/dashboard/segments/migrating-from-audiences-to-segments). Sending deferred to post-launch. - Cloudflare Turnstile for invisible bot prevention on public forms +- Upstash Redis (`@upstash/ratelimit`, `@upstash/redis`) for server-side rate limiting on public form endpoints (newsletter). Sliding window, 5 requests per minute per IP. Skipped in local dev when env vars are absent; required in production-like runtimes. If the selected app uses a newer Next.js version, read the relevant docs in `node_modules/next/dist/docs/` before implementation. Do not rely only on older Next.js memory. diff --git a/skills/context/progress-tracker.md b/skills/context/progress-tracker.md index 549cbbb..9e16faa 100644 --- a/skills/context/progress-tracker.md +++ b/skills/context/progress-tracker.md @@ -48,7 +48,7 @@ All seven homepage sections are built, wired, and animated (scroll-driven entran - Header upcoming-event promo: `HeaderUpcomingEvent` / `HeaderUpcomingEventLink` render the next event (from `src/lib/events/upcoming.ts`, shared with the Events section; hourly ISR via `EVENTS_DATE_REVALIDATE_SECONDS`) in the desktop header and mobile hero. Header scroll state lives in `src/lib/layout/site-header-scroll.ts`. - Same-page hash navigation: `HashLink` + `HashScrollSync` (`src/lib/hash-navigation.ts`) fix `/#section` links when already on `/`; wired through hero CTAs, header, mobile nav, footer, and services CTA. Root layout wraps `{children}` in `Suspense` with `RouteLoadingShell`. - `SiteFooter` server component: CTA quote block, newsletter form island, contact mailto, About/Programs/Legal columns, social glyphs (X/Instagram/TikTok/LinkedIn/YouTube), copyright. Module-scope hoisted constants (`QUOTE_LINES`, `TAGLINE_LINES`, `SOCIAL_LINKS`). -- Footer newsletter capture basic setup: `NewsletterForm` client island calls a thin server action with Zod email validation, honeypot, Upstash Redis-backed per-IP rate limiting (`UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN`; local dev skips if absent, production-like runtimes fail closed), and Resend global Contact + Segment assignment via `RESEND_API_KEY` / `RESEND_SEGMENT_ID`; submit decision and rate-limit logic are covered by Bun unit tests. A real submit has been verified to create the user email in Resend. +- Footer newsletter capture basic setup: `NewsletterForm` client island calls a thin server action with Zod email validation, honeypot (checked before rate limiting so bot traffic stays silent and does not consume Upstash quota), Upstash Redis-backed per-IP rate limiting (`UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN`; local dev skips if absent, production-like runtimes fail closed), and Resend global Contact + Segment assignment via `RESEND_API_KEY` / `RESEND_SEGMENT_ID`; new contacts are created with the newsletter segment in one Resend call (`contacts.create` + `segments`), existing contacts are re-subscribed via update + segment list/add with one inline retry on transient failures; submit decision, rate-limit, and Resend subscribe logic are covered by Bun unit tests. A real submit has been verified to create the user email in Resend. - `(site)` route group: `page.tsx` and site-specific layout (with `SiteFooter` + `flex-1` wrapper) live under `src/app/(site)/`. Root `layout.tsx` has header only — `not-found.tsx` stays at root so the 404 page renders without footer. - `/privacy` and `/terms` ship as Server Component routes in the `(site)` group with copy in `src/lib/content/legal.ts`; footer legal links point to the live routes. - `SectionDivider` shared component replaces per-section divider markup in team, monad, and services sections. @@ -93,4 +93,4 @@ All seven homepage sections are built, wired, and animated (scroll-driven entran ## Latest Handoff -- Newsletter implementation status: Resend is configured and a real submit has been confirmed to create the user email in Resend. Current protection is Zod validation, honeypot, and Upstash Redis-backed rate limiting (5 attempts / 1 minute per proxy IP identifier); the earlier 2-second interaction trap has been removed. `NewsletterForm` hides stale submit feedback as soon as the email input changes and resets feedback visibility on the next submit. Verification previously passed with `bun run test`, `bun run typecheck`, `bun run lint` (existing Monad warning only), and `bun run build`. Remaining newsletter issue #10 item is Cloudflare Turnstile. +- Newsletter implementation status: Resend is configured and a real submit has been confirmed to create the user email in Resend. Current protection is Zod validation, honeypot, and Upstash Redis-backed rate limiting (5 attempts / 1 minute per proxy IP identifier); the earlier 2-second interaction trap has been removed. New signups use Resend's create-with-segments API (2 calls: get + create); returning contacts use update + list + add with one inline retry before surfacing the generic subscribe error. `NewsletterForm` hides stale submit feedback as soon as the email input changes and resets feedback visibility on the next submit. Verification previously passed with `bun run test`, `bun run typecheck`, `bun run lint` (existing Monad warning only), and `bun run build`. Remaining newsletter issue #10 item is Cloudflare Turnstile. diff --git a/src/components/layout/newsletter-form.tsx b/src/components/layout/newsletter-form.tsx index 1335147..5807896 100644 --- a/src/components/layout/newsletter-form.tsx +++ b/src/components/layout/newsletter-form.tsx @@ -22,12 +22,13 @@ export default function NewsletterForm({ ); const emailInputRef = useRef(null); const [emailValue, setEmailValue] = useState(""); + const [submittedEmail, setSubmittedEmail] = useState(""); const [messageDismissed, setMessageDismissed] = useState(false); const messageId = `${emailInputId}-message`; const hasMessage = state.message.length > 0; const showConfirmedMessage = state.status === "success" && - state.subscribedEmail === emailValue.trim().toLowerCase(); + state.subscribedEmail === submittedEmail; const showMessage = !messageDismissed && hasMessage && @@ -47,6 +48,7 @@ export default function NewsletterForm({ } function handleSubmit() { + setSubmittedEmail(emailValue.trim().toLowerCase()); setMessageDismissed(false); } diff --git a/src/lib/integrations/resend.test.ts b/src/lib/integrations/resend.test.ts new file mode 100644 index 0000000..d565574 --- /dev/null +++ b/src/lib/integrations/resend.test.ts @@ -0,0 +1,224 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { handleNewsletterSignup } from "@/lib/newsletter/submit"; + +type ResendApiError = { + message: string; + name?: string; +}; + +type ResendContact = { + id: string; + unsubscribed?: boolean; +}; + +type ResendSegmentAddResult = { + data: Record | null; + error: ResendApiError | null; +}; + +type ResendSegmentListResult = { + data: { data: Array<{ id: string }> }; + error: ResendApiError | null; +}; + +type ResendContactResult = { + data: ResendContact | null; + error: ResendApiError | null; +}; + +const segmentsAddMock = mock(async (): Promise => ({ + data: {}, + error: null, +})); + +const segmentsListMock = mock(async (): Promise => ({ + data: { data: [] }, + error: null, +})); + +const contactsGetMock = mock(async (): Promise => ({ + data: null, + error: { name: "not_found", message: "not found" }, +})); + +const contactsCreateMock = mock(async (): Promise => ({ + data: { id: "contact_1", unsubscribed: false }, + error: null, +})); + +const contactsUpdateMock = mock(async (): Promise => ({ + data: { id: "contact_1", unsubscribed: false }, + error: null, +})); + +mock.module("resend", () => ({ + Resend: class MockResend { + contacts = { + get: contactsGetMock, + create: contactsCreateMock, + update: contactsUpdateMock, + segments: { + list: segmentsListMock, + add: segmentsAddMock, + }, + }; + }, +})); + +const { subscribeNewsletterContact } = await import("@/lib/integrations/resend"); + +const SEGMENT_ID = "seg_test"; +const EMAIL = "person@example.com"; + +function createFormData(fields: Record): FormData { + const formData = new FormData(); + + for (const [key, value] of Object.entries(fields)) { + formData.set(key, value); + } + + return formData; +} + +function mockExistingContactNotInSegmentFlow() { + contactsGetMock.mockImplementation(async () => ({ + data: { id: "contact_1", unsubscribed: false }, + error: null, + })); + segmentsListMock.mockImplementation(async () => ({ + data: { data: [] }, + error: null, + })); +} + +function resetResendMocks() { + segmentsAddMock.mockReset(); + segmentsListMock.mockReset(); + contactsGetMock.mockReset(); + contactsCreateMock.mockReset(); + contactsUpdateMock.mockReset(); + + segmentsAddMock.mockImplementation(async () => ({ data: {}, error: null })); + segmentsListMock.mockImplementation(async () => ({ + data: { data: [] }, + error: null, + })); + contactsGetMock.mockImplementation(async () => ({ + data: null, + error: { name: "not_found", message: "not found" }, + })); + contactsCreateMock.mockImplementation(async () => ({ + data: { id: "contact_1", unsubscribed: false }, + error: null, + })); + contactsUpdateMock.mockImplementation(async () => ({ + data: { id: "contact_1", unsubscribed: false }, + error: null, + })); +} + +describe("subscribeNewsletterContact", () => { + beforeEach(() => { + process.env.RESEND_API_KEY = "re_test"; + process.env.RESEND_SEGMENT_ID = SEGMENT_ID; + resetResendMocks(); + }); + + test("existing contact already in segment returns alreadySubscribed: true", async () => { + contactsGetMock.mockImplementation(async () => ({ + data: { id: "contact_1", unsubscribed: false }, + error: null, + })); + segmentsListMock.mockImplementation(async () => ({ + data: { data: [{ id: SEGMENT_ID }] }, + error: null, + })); + + const result = await subscribeNewsletterContact(EMAIL); + + expect(result).toEqual({ ok: true, alreadySubscribed: true }); + expect(contactsCreateMock).not.toHaveBeenCalled(); + expect(segmentsAddMock).not.toHaveBeenCalled(); + }); + + test("new contact is created with the newsletter segment in one call", async () => { + const result = await subscribeNewsletterContact(EMAIL); + + expect(result).toEqual({ ok: true, alreadySubscribed: false }); + expect(contactsCreateMock).toHaveBeenCalledWith({ + email: EMAIL, + unsubscribed: false, + segments: [{ id: SEGMENT_ID }], + }); + expect(segmentsListMock).not.toHaveBeenCalled(); + expect(segmentsAddMock).not.toHaveBeenCalled(); + }); + + test("existing contact not in segment succeeds when first segment add succeeds", async () => { + mockExistingContactNotInSegmentFlow(); + + const result = await subscribeNewsletterContact(EMAIL); + + expect(result).toEqual({ ok: true, alreadySubscribed: false }); + expect(contactsUpdateMock).toHaveBeenCalledTimes(1); + expect(contactsCreateMock).not.toHaveBeenCalled(); + expect(segmentsListMock).toHaveBeenCalledTimes(1); + expect(segmentsAddMock).toHaveBeenCalledTimes(1); + }); + + test("segment add fails once, retry succeeds, result is success", async () => { + mockExistingContactNotInSegmentFlow(); + segmentsAddMock + .mockImplementationOnce(async () => ({ + data: null, + error: { message: "transient failure" }, + })) + .mockImplementationOnce(async () => ({ data: {}, error: null })); + + const result = await subscribeNewsletterContact(EMAIL); + + expect(result).toEqual({ ok: true, alreadySubscribed: false }); + expect(segmentsAddMock).toHaveBeenCalledTimes(2); + }); + + test("segment add fails twice, result is ok: false", async () => { + mockExistingContactNotInSegmentFlow(); + segmentsAddMock.mockImplementation(async () => ({ + data: null, + error: { message: "persistent failure" }, + })); + + const result = await subscribeNewsletterContact(EMAIL); + + expect(result).toEqual({ + ok: false, + code: "resend", + message: "persistent failure", + }); + expect(segmentsAddMock).toHaveBeenCalledTimes(2); + }); + + test("segment add fails twice and signup returns the generic retry message", async () => { + mockExistingContactNotInSegmentFlow(); + segmentsAddMock.mockImplementation(async () => ({ + data: null, + error: { message: "persistent failure" }, + })); + + const result = await handleNewsletterSignup( + createFormData({ + email: EMAIL, + company: "", + }), + { + subscribe: subscribeNewsletterContact, + }, + ); + + expect(result).toEqual({ + status: "error", + message: "We could not subscribe that email right now. Please try again.", + }); + expect(segmentsAddMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/lib/integrations/resend.ts b/src/lib/integrations/resend.ts index 8b37da3..3648ff0 100644 --- a/src/lib/integrations/resend.ts +++ b/src/lib/integrations/resend.ts @@ -48,26 +48,87 @@ function getResendErrorMessage(error: { message: string } | null): string { return error?.message || "Resend rejected the newsletter subscription."; } -async function verifyNewsletterSubscription( - resend: Resend, +type SegmentAddClient = { + contacts: { + segments: { + add: (params: { + email: string; + segmentId: string; + }) => Promise<{ + error: { message: string } | null; + }>; + }; + }; +}; + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function addContactToNewsletterSegment( + client: SegmentAddClient, email: string, segmentId: string, -): Promise { - const contact = await resend.contacts.get({ email }); +): Promise<{ ok: true } | { ok: false; message: string }> { + const firstAttempt = await client.contacts.segments.add({ email, segmentId }); + + if (!firstAttempt.error) { + return { ok: true }; + } + + await wait(1_000); + + const secondAttempt = await client.contacts.segments.add({ email, segmentId }); - if (contact.error || !contact.data) { + if (!secondAttempt.error) { + return { ok: true }; + } + + return { + ok: false, + message: getResendErrorMessage(secondAttempt.error), + }; +} + +export async function subscribeNewsletterContact( + email: string, +): Promise { + const config = getNewsletterConfig(); + + if (!config.ok) { + return { + ok: false, + code: "configuration", + message: config.message, + }; + } + + try { + return await subscribeWithResend(email, config); + } catch { return { ok: false, code: "resend", - message: getResendErrorMessage(contact.error), + message: "Resend is not reachable right now.", }; } +} + +async function subscribeExistingContact( + resend: Resend, + email: string, + segmentId: string, +): Promise { + const updateContact = await resend.contacts.update({ + email, + unsubscribed: false, + }); - if (contact.data.unsubscribed) { + if (updateContact.error) { return { ok: false, code: "resend", - message: "Resend did not confirm the newsletter subscription.", + message: getResendErrorMessage(updateContact.error), }; } @@ -84,34 +145,37 @@ async function verifyNewsletterSubscription( }; } - const inSegment = contactSegments.data.data.some( + const alreadyInSegment = contactSegments.data.data.some( (segment) => segment.id === segmentId, ); - if (!inSegment) { + if (alreadyInSegment) { return { - ok: false, - code: "resend", - message: "Resend did not confirm the newsletter subscription.", + ok: true, + alreadySubscribed: true, }; } - return null; -} - -export async function subscribeNewsletterContact( - email: string, -): Promise { - const config = getNewsletterConfig(); + const addSegment = await addContactToNewsletterSegment(resend, email, segmentId); - if (!config.ok) { + if (!addSegment.ok) { return { ok: false, - code: "configuration", - message: config.message, + code: "resend", + message: addSegment.message, }; } + return { + ok: true, + alreadySubscribed: false, + }; +} + +async function subscribeWithResend( + email: string, + config: { apiKey: string; segmentId: string }, +): Promise { const resend = new Resend(config.apiKey); const existingContact = await resend.contacts.get({ email }); @@ -124,77 +188,25 @@ export async function subscribeNewsletterContact( } if (existingContact.data) { - const updateContact = await resend.contacts.update({ - email, - unsubscribed: false, - }); - - if (updateContact.error) { - return { - ok: false, - code: "resend", - message: getResendErrorMessage(updateContact.error), - }; - } - } else { - const createContact = await resend.contacts.create({ - email, - unsubscribed: false, - }); - - if (createContact.error) { - return { - ok: false, - code: "resend", - message: getResendErrorMessage(createContact.error), - }; - } + return subscribeExistingContact(resend, email, config.segmentId); } - const contactSegments = await resend.contacts.segments.list({ + const createContact = await resend.contacts.create({ email, - limit: 100, + unsubscribed: false, + segments: [{ id: config.segmentId }], }); - if (contactSegments.error) { + if (createContact.error) { return { ok: false, code: "resend", - message: getResendErrorMessage(contactSegments.error), + message: getResendErrorMessage(createContact.error), }; } - const alreadyInSegment = contactSegments.data.data.some( - (segment) => segment.id === config.segmentId, - ); - - if (!alreadyInSegment) { - const addSegment = await resend.contacts.segments.add({ - email, - segmentId: config.segmentId, - }); - - if (addSegment.error) { - return { - ok: false, - code: "resend", - message: getResendErrorMessage(addSegment.error), - }; - } - } - - const verificationError = await verifyNewsletterSubscription( - resend, - email, - config.segmentId, - ); - - if (verificationError) { - return verificationError; - } - return { ok: true, - alreadySubscribed: alreadyInSegment, + alreadySubscribed: false, }; } diff --git a/src/lib/newsletter/rate-limit.ts b/src/lib/newsletter/rate-limit.ts index 51ccfe3..820ddcd 100644 --- a/src/lib/newsletter/rate-limit.ts +++ b/src/lib/newsletter/rate-limit.ts @@ -1,3 +1,4 @@ +import * as Sentry from "@sentry/nextjs"; import { Ratelimit } from "@upstash/ratelimit"; import { Redis } from "@upstash/redis"; @@ -100,7 +101,9 @@ export async function checkNewsletterRateLimit( } return { ok: true }; - } catch { + } catch (error) { + Sentry.captureException(error); + return { ok: false, code: "storage", diff --git a/src/lib/newsletter/submit.test.ts b/src/lib/newsletter/submit.test.ts index 0a50b8d..9d5dd61 100644 --- a/src/lib/newsletter/submit.test.ts +++ b/src/lib/newsletter/submit.test.ts @@ -67,12 +67,19 @@ describe("handleNewsletterSignup", () => { test("silently accepts honeypot submissions without subscribing", async () => { const subscribeSpy = createSubscribeSpy(subscribedResult); + const rateLimitSpy = createRateLimitSpy({ + ok: false, + code: "limited", + message: "Too many attempts. Please try again in a few minutes.", + reset: 20_000, + }); const result = await handleNewsletterSignup( createFormData({ email: "person@example.com", company: "Bot Corp", }), { + rateLimit: rateLimitSpy.rateLimit, subscribe: subscribeSpy.subscribe, }, ); @@ -81,6 +88,7 @@ describe("handleNewsletterSignup", () => { status: "success", message: "You have successfully subscribed to the Cortex update list.", }); + expect(rateLimitSpy.calls).toBe(0); expect(subscribeSpy.calls).toEqual([]); }); @@ -134,6 +142,29 @@ describe("handleNewsletterSignup", () => { expect(subscribeSpy.calls).toEqual([]); }); + test("does not consume a rate-limit token when the email is invalid", async () => { + const subscribeSpy = createSubscribeSpy(subscribedResult); + const rateLimitSpy = createRateLimitSpy({ ok: true }); + const result = await handleNewsletterSignup( + createFormData({ + email: "not-an-email", + company: "", + }), + { + rateLimit: rateLimitSpy.rateLimit, + subscribe: subscribeSpy.subscribe, + }, + ); + + expect(result).toEqual({ + status: "error", + message: "Enter a valid email address.", + emailError: "Enter a valid email address.", + }); + expect(rateLimitSpy.calls).toBe(0); + expect(subscribeSpy.calls).toEqual([]); + }); + test("does not subscribe when production rate-limit configuration is missing", async () => { const subscribeSpy = createSubscribeSpy(subscribedResult); const rateLimitSpy = createRateLimitSpy({ diff --git a/src/lib/newsletter/submit.ts b/src/lib/newsletter/submit.ts index 7f152be..9deeda2 100644 --- a/src/lib/newsletter/submit.ts +++ b/src/lib/newsletter/submit.ts @@ -21,6 +21,15 @@ export async function handleNewsletterSignup( formData: FormData, { rateLimit, subscribe }: HandleNewsletterSignupOptions, ): Promise { + const company = formData.get("company"); + + if (typeof company === "string" && company.trim()) { + return { + status: "success", + message: SUBSCRIBED_MESSAGE, + }; + } + const parsed = newsletterSignupSchema.safeParse({ email: formData.get("email"), company: formData.get("company"), @@ -36,13 +45,6 @@ export async function handleNewsletterSignup( }; } - if (parsed.data.company) { - return { - status: "success", - message: SUBSCRIBED_MESSAGE, - }; - } - const rateLimitResult = await rateLimit?.(); if (rateLimitResult && !rateLimitResult.ok) { diff --git a/tsconfig.json b/tsconfig.json index cf9c65d..8c5628d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,7 +20,8 @@ ], "paths": { "@/*": ["./src/*"] - } + }, + "types": ["bun-types"] }, "include": [ "next-env.d.ts", From 8aa90df8174df94940f5fd822f4b3949368afe1c Mon Sep 17 00:00:00 2001 From: Jenny Date: Sat, 20 Jun 2026 16:13:03 +0800 Subject: [PATCH 4/6] fix(newsletter): harden Turnstile verification and signup pipeline Validate Siteverify action and hostname (including Vercel preview URLs), run rate limiting before Turnstile, fail closed on unknown client IPs, fix Resend re-subscribe behavior, and clear the email field after success. --- .env.example | 20 +- skills/context/progress-tracker.md | 19 +- src/components/layout/newsletter-form.tsx | 533 +++++++++++++++++++--- src/lib/integrations/resend.test.ts | 50 +- src/lib/integrations/resend.ts | 34 +- src/lib/integrations/turnstile.test.ts | 210 +++++++++ src/lib/integrations/turnstile.ts | 230 ++++++++++ src/lib/newsletter/actions.ts | 4 + src/lib/newsletter/rate-limit.test.ts | 51 ++- src/lib/newsletter/rate-limit.ts | 29 +- src/lib/newsletter/submit.test.ts | 79 ++++ src/lib/newsletter/submit.ts | 19 +- src/lib/newsletter/turnstile.ts | 4 + 13 files changed, 1189 insertions(+), 93 deletions(-) create mode 100644 src/lib/integrations/turnstile.test.ts create mode 100644 src/lib/integrations/turnstile.ts create mode 100644 src/lib/newsletter/turnstile.ts diff --git a/.env.example b/.env.example index 6c0db38..0c127a1 100644 --- a/.env.example +++ b/.env.example @@ -16,10 +16,22 @@ SENTRY_RELEASE= RESEND_API_KEY= RESEND_SEGMENT_ID= -# Optional. Added when Cloudflare Turnstile is wired into public forms. -NEXT_PUBLIC_TURNSTILE_SITE_KEY= -TURNSTILE_SECRET_KEY= - # Upstash Redis (newsletter rate limiting) UPSTASH_REDIS_REST_URL= UPSTASH_REDIS_REST_TOKEN= + +# Cloudflare Turnstile (newsletter verification) +# Local development default: Cloudflare test keys that always pass. +NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA +TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA +TURNSTILE_ALLOWED_HOSTNAMES=localhost,127.0.0.1,0.0.0.0 + +# Production: replace the local test pair with real widget keys and hostnames. +# Vercel preview deployments also accept the current deployment hostname from VERCEL_URL. +# NEXT_PUBLIC_TURNSTILE_SITE_KEY= +# TURNSTILE_SECRET_KEY= +# TURNSTILE_ALLOWED_HOSTNAMES=cortexglobal.xyz,www.cortexglobal.xyz,*.vercel.app + +# Failure testing: temporarily replace the local test pair with this always-fail pair. +# NEXT_PUBLIC_TURNSTILE_SITE_KEY=2x00000000000000000000AB +# TURNSTILE_SECRET_KEY=2x0000000000000000000000000000000AA diff --git a/skills/context/progress-tracker.md b/skills/context/progress-tracker.md index 9e16faa..177db28 100644 --- a/skills/context/progress-tracker.md +++ b/skills/context/progress-tracker.md @@ -48,7 +48,7 @@ All seven homepage sections are built, wired, and animated (scroll-driven entran - Header upcoming-event promo: `HeaderUpcomingEvent` / `HeaderUpcomingEventLink` render the next event (from `src/lib/events/upcoming.ts`, shared with the Events section; hourly ISR via `EVENTS_DATE_REVALIDATE_SECONDS`) in the desktop header and mobile hero. Header scroll state lives in `src/lib/layout/site-header-scroll.ts`. - Same-page hash navigation: `HashLink` + `HashScrollSync` (`src/lib/hash-navigation.ts`) fix `/#section` links when already on `/`; wired through hero CTAs, header, mobile nav, footer, and services CTA. Root layout wraps `{children}` in `Suspense` with `RouteLoadingShell`. - `SiteFooter` server component: CTA quote block, newsletter form island, contact mailto, About/Programs/Legal columns, social glyphs (X/Instagram/TikTok/LinkedIn/YouTube), copyright. Module-scope hoisted constants (`QUOTE_LINES`, `TAGLINE_LINES`, `SOCIAL_LINKS`). -- Footer newsletter capture basic setup: `NewsletterForm` client island calls a thin server action with Zod email validation, honeypot (checked before rate limiting so bot traffic stays silent and does not consume Upstash quota), Upstash Redis-backed per-IP rate limiting (`UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN`; local dev skips if absent, production-like runtimes fail closed), and Resend global Contact + Segment assignment via `RESEND_API_KEY` / `RESEND_SEGMENT_ID`; new contacts are created with the newsletter segment in one Resend call (`contacts.create` + `segments`), existing contacts are re-subscribed via update + segment list/add with one inline retry on transient failures; submit decision, rate-limit, and Resend subscribe logic are covered by Bun unit tests. A real submit has been verified to create the user email in Resend. +- Footer newsletter capture: `NewsletterForm` client island runs an invisible Cloudflare Turnstile challenge when `NEXT_PUBLIC_TURNSTILE_SITE_KEY` is present, loads `api.js` with the existing page script nonce when a CSP nonce is present, then calls a thin server action with Zod email validation, honeypot (checked before rate limiting/Turnstile so bot traffic stays silent and does not consume Upstash quota), Upstash Redis-backed per-IP rate limiting (`UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN`; local dev skips if absent, production-like runtimes fail closed; unknown client IPs fail closed in production-like runtimes instead of sharing a global fallback bucket), server-side Turnstile Siteverify via `TURNSTILE_SECRET_KEY` after rate limiting, and Resend global Contact + Segment assignment via `RESEND_API_KEY` / `RESEND_SEGMENT_ID`; new contacts are created with the newsletter segment in one Resend call (`contacts.create` + `segments`), existing contacts are re-subscribed via update + segment list/add with one inline retry on transient failures. Submit decisions, missing/invalid Turnstile tokens, rate-limit behavior, and Resend subscribe logic are covered by Bun unit tests. A localhost Turnstile submit has been verified to create the test email in Resend. - `(site)` route group: `page.tsx` and site-specific layout (with `SiteFooter` + `flex-1` wrapper) live under `src/app/(site)/`. Root `layout.tsx` has header only — `not-found.tsx` stays at root so the 404 page renders without footer. - `/privacy` and `/terms` ship as Server Component routes in the `(site)` group with copy in `src/lib/content/legal.ts`; footer legal links point to the live routes. - `SectionDivider` shared component replaces per-section divider markup in team, monad, and services sections. @@ -85,12 +85,17 @@ All seven homepage sections are built, wired, and animated (scroll-driven entran ## Next Steps -1. Finish newsletter capture hardening: Cloudflare Turnstile. -2. Preformance review and optimization. -3. og image and description. -4. SEO, tracking validation, and launch metadata. -5. Staking page (remove `TEMP(staking-page)` comments when shipping). +1. Preformance review and optimization. +2. og image and description. +3. SEO, tracking validation, and launch metadata. +4. Staking page (remove `TEMP(staking-page)` comments when shipping). ## Latest Handoff -- Newsletter implementation status: Resend is configured and a real submit has been confirmed to create the user email in Resend. Current protection is Zod validation, honeypot, and Upstash Redis-backed rate limiting (5 attempts / 1 minute per proxy IP identifier); the earlier 2-second interaction trap has been removed. New signups use Resend's create-with-segments API (2 calls: get + create); returning contacts use update + list + add with one inline retry before surfacing the generic subscribe error. `NewsletterForm` hides stale submit feedback as soon as the email input changes and resets feedback visibility on the next submit. Verification previously passed with `bun run test`, `bun run typecheck`, `bun run lint` (existing Monad warning only), and `bun run build`. Remaining newsletter issue #10 item is Cloudflare Turnstile. +- Newsletter form clears the email input after a successful signup so the success message shows above an empty field ready for another address. +- Upstash unknown-IP fallback fixed: `getNewsletterRateLimitIdentifier` now returns `null` when no client IP can be derived, and `checkNewsletterRateLimit` fails closed in production-like runtimes when the identifier is missing instead of putting all such requests into the shared `local` bucket. Local/dev behavior still skips rate limiting when Upstash env vars are absent and uses the `local` bucket only outside production-like runtimes when a limiter is explicitly configured. Regression coverage added in `src/lib/newsletter/rate-limit.test.ts`. Verification passed with `bun test src/lib/newsletter/rate-limit.test.ts`, `bun run test`, `bun run typecheck`, `bun run lint` (existing Monad unused-argument warning only), and `bun run build`. +- Turnstile hostname validation hardened for deployment: allowed hostnames now support `*.vercel.app` wildcard entries, and the current Vercel deployment hostname from `VERCEL_URL` is merged automatically so preview signups pass Siteverify without listing every preview URL. `.env.example` production guidance updated accordingly. Verification passed with `bun test src/lib/integrations/turnstile.test.ts`, `bun test src/lib/newsletter/submit.test.ts`, `bun test src/lib/integrations/resend.test.ts`, and `bun run typecheck`. Siteverify success responses must now match the newsletter action and an allowed hostname from `TURNSTILE_ALLOWED_HOSTNAMES` (Cloudflare dummy test-secret responses may use the documented `test` action for local testing), `.env.example` includes localhost defaults plus production hostname guidance, and newsletter rate limiting now runs before Siteverify to reduce forged-token validation abuse. Regression coverage added for missing hostname configuration, valid action/hostname responses, action mismatch, hostname mismatch, dummy test action, and rate-limit-before-Turnstile ordering. Verification passed with `bun test src/lib/integrations/turnstile.test.ts`, `bun test src/lib/newsletter/submit.test.ts`, `bun run test`, `bun run typecheck`, `bun run lint` (existing Monad unused-argument warning only), and `bun run build`. +- Turnstile script loader reuse fixed: `NewsletterForm` now stores `loading` / `loaded` / `error` status on the shared Cloudflare script element, uses `window.turnstile.ready()` before setting the client ready state, immediately handles already-loaded scripts on remount, and removes/retries a previously failed script instead of waiting on load/error events that already fired. Verification passed with `bun run typecheck`, `bun run lint` (existing Monad unused-argument warning only), `bun run test`, and `bun run build`. +- Resend re-subscribe behavior fixed: existing contacts are now reactivated with `contacts.update({ unsubscribed: false })` only when Resend reports `unsubscribed: true`; active existing contacts skip the update call. Existing segment members that were just reactivated now return the normal subscription success state instead of the already-submitted state. Regression coverage added for reactivation and the user-facing success message in `src/lib/integrations/resend.test.ts`. Verification passed with `bun test src/lib/integrations/resend.test.ts`, `bun run test`, and `bun run typecheck`. +- Turnstile env sample fixed: `.env.example` now leaves only the localhost always-pass Cloudflare Turnstile test pair active, with production placeholders and the always-fail error-UI pair commented out. This prevents dotenv last-assignment behavior from making copied local env files fail Siteverify by default. Verification: local Next environment-variable docs reviewed; active Turnstile assignments checked with `rg`. +- Newsletter Turnstile hardening complete: invisible Cloudflare Turnstile is wired into the footer newsletter form, Siteverify runs server-side before Upstash rate limiting and Resend, missing/invalid tokens return the short generic verification error without consuming rate-limit quota, and tokens are reset after each server response because Turnstile tokens are single-use. The Turnstile client loader copies an existing page script nonce onto Cloudflare `api.js` so nonce-based CSP can allow Turnstile without making site routes dynamic. Verification passed with `bun run test`, `bun run typecheck`, `bun run lint` (existing Monad warning only), and `bun run build`. Manual localhost test on `http://localhost:3000/#footer` submitted `turnstile-test-1781928453889@example.com` and returned the success message. diff --git a/src/components/layout/newsletter-form.tsx b/src/components/layout/newsletter-form.tsx index 5807896..737b0a3 100644 --- a/src/components/layout/newsletter-form.tsx +++ b/src/components/layout/newsletter-form.tsx @@ -1,16 +1,85 @@ "use client"; -import { useActionState, useEffect, useRef, useState } from "react"; +import { + useActionState, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import type { SubmitEvent } from "react"; import { CortexButton } from "@/components/cortex-button"; import { submitNewsletterSignup } from "@/lib/newsletter/actions"; +import { + NEWSLETTER_TURNSTILE_ACTION, + NEWSLETTER_TURNSTILE_ERROR_MESSAGE, + NEWSLETTER_TURNSTILE_FIELD, +} from "@/lib/newsletter/turnstile"; import { newsletterInitialState } from "@/lib/newsletter/types"; +const TURNSTILE_ONLOAD_CALLBACK = "cortexTurnstileOnLoad"; +const TURNSTILE_SCRIPT_SRC = `https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=${TURNSTILE_ONLOAD_CALLBACK}`; +const TURNSTILE_SCRIPT_ID = "turnstile-script"; +const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY?.trim(); +const TURNSTILE_CHALLENGE_TIMEOUT_MS = 15_000; +const TURNSTILE_SCRIPT_STATUS_DATA_KEY = "turnstileStatus"; + +type TurnstileScriptStatus = "error" | "loaded" | "loading"; + type NewsletterFormProps = { emailInputId: string; placeholder: string; submitLabel: string; }; +type TurnstileWidgetId = string; + +type TurnstileChallenge = { + reject: (reason?: unknown) => void; + resolve: (token: string) => void; + timeoutId: number; +}; + +type TurnstileApi = { + execute(container: HTMLElement | string): void; + isExpired(widgetId: TurnstileWidgetId): boolean; + remove(widgetId: TurnstileWidgetId): void; + render( + container: HTMLElement, + options: { + action: string; + appearance: "always" | "execute" | "interaction-only"; + callback: (token: string) => void; + "error-callback": () => void; + "expired-callback": () => void; + execution: "render" | "execute"; + sitekey: string; + "timeout-callback": () => void; + }, + ): TurnstileWidgetId | undefined; + reset(widgetId: TurnstileWidgetId): void; +}; + +declare global { + interface Window { + cortexTurnstileOnLoad?: () => void; + turnstile?: TurnstileApi; + } +} + +function isCompatibleTurnstileScript(script: HTMLScriptElement): boolean { + return ( + !script.async && + !script.defer && + script.src.includes(TURNSTILE_ONLOAD_CALLBACK) + ); +} + +function removeTurnstileScript(): void { + document.getElementById(TURNSTILE_SCRIPT_ID)?.remove(); + delete window.turnstile; +} + export default function NewsletterForm({ emailInputId, placeholder, @@ -20,21 +89,89 @@ export default function NewsletterForm({ submitNewsletterSignup, newsletterInitialState, ); + const formRef = useRef(null); const emailInputRef = useRef(null); + const turnstileContainerRef = useRef(null); + const turnstileTokenInputRef = useRef(null); + const turnstileWidgetIdRef = useRef(null); + const turnstileChallengeRef = useRef(null); const [emailValue, setEmailValue] = useState(""); const [submittedEmail, setSubmittedEmail] = useState(""); + const [clearedSuccessEmail, setClearedSuccessEmail] = useState< + string | undefined + >(); const [messageDismissed, setMessageDismissed] = useState(false); + const [turnstileScriptReady, setTurnstileScriptReady] = useState(false); + const [turnstilePending, setTurnstilePending] = useState(false); + const [clientMessage, setClientMessage] = useState(""); const messageId = `${emailInputId}-message`; - const hasMessage = state.message.length > 0; + const activeMessage = clientMessage || state.message; + const activeStatus = clientMessage ? "error" : state.status; + const hasMessage = activeMessage.length > 0; const showConfirmedMessage = - state.status === "success" && - state.subscribedEmail === submittedEmail; + state.status === "success" && state.subscribedEmail === submittedEmail; const showMessage = !messageDismissed && hasMessage && - (state.status === "error" || + (Boolean(clientMessage) || + state.status === "error" || (state.status === "success" && showConfirmedMessage)); const showEmailError = !messageDismissed && Boolean(state.emailError); + const submitPending = pending || turnstilePending; + + if ( + state.status === "success" && + state.subscribedEmail && + state.subscribedEmail !== clearedSuccessEmail + ) { + setClearedSuccessEmail(state.subscribedEmail); + setEmailValue(""); + } + + const clearTurnstileToken = useCallback(() => { + if (turnstileTokenInputRef.current) { + turnstileTokenInputRef.current.value = ""; + } + }, []); + + const resolveTurnstileChallenge = useCallback((token: string) => { + const challenge = turnstileChallengeRef.current; + + if (!challenge) return; + + window.clearTimeout(challenge.timeoutId); + turnstileChallengeRef.current = null; + setTurnstilePending(false); + + const normalizedToken = token.trim(); + + if (turnstileTokenInputRef.current) { + turnstileTokenInputRef.current.value = normalizedToken; + } + + challenge.resolve(normalizedToken); + }, []); + + const rejectTurnstileChallenge = useCallback(() => { + const challenge = turnstileChallengeRef.current; + + if (challenge) { + window.clearTimeout(challenge.timeoutId); + challenge.reject(new Error(NEWSLETTER_TURNSTILE_ERROR_MESSAGE)); + turnstileChallengeRef.current = null; + } + + clearTurnstileToken(); + setTurnstilePending(false); + }, [clearTurnstileToken]); + + const resetTurnstileWidget = useCallback(() => { + const widgetId = turnstileWidgetIdRef.current; + + if (widgetId && window.turnstile) { + window.turnstile.reset(widgetId); + } + }, []); useEffect(() => { if (state.status === "success" && state.subscribedEmail) { @@ -42,71 +179,351 @@ export default function NewsletterForm({ } }, [state.message, state.status, state.subscribedEmail]); + useEffect(() => { + if (!TURNSTILE_SITE_KEY) return; + + let cancelled = false; + + function setScriptStatus( + script: HTMLScriptElement | null, + status: TurnstileScriptStatus, + ) { + if (script) { + script.dataset[TURNSTILE_SCRIPT_STATUS_DATA_KEY] = status; + } + } + + function markTurnstileReady(script: HTMLScriptElement | null) { + if (cancelled) return; + + if (!window.turnstile) { + setScriptStatus(script, "error"); + handleError(script); + return; + } + + setScriptStatus(script, "loaded"); + + if (!cancelled) { + setTurnstileScriptReady(true); + } + } + + function handleLoad(event: Event) { + markTurnstileReady(event.currentTarget as HTMLScriptElement | null); + } + + function handleError(script: HTMLScriptElement | null = null) { + if (cancelled) return; + setScriptStatus(script, "error"); + setTurnstileScriptReady(false); + setClientMessage(NEWSLETTER_TURNSTILE_ERROR_MESSAGE); + setMessageDismissed(false); + } + + function handleScriptError(event: Event) { + handleError(event.currentTarget as HTMLScriptElement | null); + } + + window[TURNSTILE_ONLOAD_CALLBACK] = () => { + const script = document.getElementById( + TURNSTILE_SCRIPT_ID, + ) as HTMLScriptElement | null; + + markTurnstileReady(script); + }; + + const existingScript = document.getElementById( + TURNSTILE_SCRIPT_ID, + ) as HTMLScriptElement | null; + + if (existingScript && !isCompatibleTurnstileScript(existingScript)) { + removeTurnstileScript(); + } + + const compatibleScript = document.getElementById( + TURNSTILE_SCRIPT_ID, + ) as HTMLScriptElement | null; + + if (window.turnstile && compatibleScript) { + const loadedScript = compatibleScript; + + window.queueMicrotask(() => markTurnstileReady(loadedScript)); + + return () => { + cancelled = true; + delete window[TURNSTILE_ONLOAD_CALLBACK]; + }; + } + + if (compatibleScript) { + const existingStatus = compatibleScript.dataset[ + TURNSTILE_SCRIPT_STATUS_DATA_KEY + ] as TurnstileScriptStatus | undefined; + + if (existingStatus === "loaded") { + const loadedScript = compatibleScript; + + window.queueMicrotask(() => markTurnstileReady(loadedScript)); + + return () => { + cancelled = true; + delete window[TURNSTILE_ONLOAD_CALLBACK]; + }; + } + + if (existingStatus === "error") { + removeTurnstileScript(); + } + } + + const loadingScript = document.getElementById( + TURNSTILE_SCRIPT_ID, + ) as HTMLScriptElement | null; + + if (loadingScript) { + loadingScript.addEventListener("load", handleLoad, { once: true }); + loadingScript.addEventListener("error", handleScriptError, { + once: true, + }); + + return () => { + cancelled = true; + delete window[TURNSTILE_ONLOAD_CALLBACK]; + loadingScript.removeEventListener("load", handleLoad); + loadingScript.removeEventListener("error", handleScriptError); + }; + } + + const script = document.createElement("script"); + const nonce = + document.querySelector("script[nonce]")?.nonce; + + script.id = TURNSTILE_SCRIPT_ID; + script.src = TURNSTILE_SCRIPT_SRC; + // Dynamically inserted scripts default to async; Turnstile rejects that. + script.async = false; + script.defer = false; + setScriptStatus(script, "loading"); + + if (nonce) { + script.nonce = nonce; + } + + script.addEventListener("load", handleLoad, { once: true }); + script.addEventListener("error", handleScriptError, { once: true }); + document.head.append(script); + + return () => { + cancelled = true; + delete window[TURNSTILE_ONLOAD_CALLBACK]; + script.removeEventListener("load", handleLoad); + script.removeEventListener("error", handleScriptError); + }; + }, []); + + useEffect(() => { + if (!TURNSTILE_SITE_KEY || state.status === "idle" || pending) return; + + clearTurnstileToken(); + resetTurnstileWidget(); + }, [clearTurnstileToken, pending, resetTurnstileWidget, state.status]); + + useEffect(() => { + if ( + !TURNSTILE_SITE_KEY || + !turnstileScriptReady || + !turnstileContainerRef.current || + turnstileWidgetIdRef.current || + !window.turnstile + ) { + return; + } + + const widgetId = window.turnstile.render(turnstileContainerRef.current, { + action: NEWSLETTER_TURNSTILE_ACTION, + appearance: "execute", + callback: resolveTurnstileChallenge, + "error-callback": rejectTurnstileChallenge, + "expired-callback": rejectTurnstileChallenge, + execution: "execute", + sitekey: TURNSTILE_SITE_KEY, + "timeout-callback": rejectTurnstileChallenge, + }); + + if (widgetId) { + turnstileWidgetIdRef.current = widgetId; + } + + return () => { + const renderedWidgetId = turnstileWidgetIdRef.current; + + if (renderedWidgetId && window.turnstile) { + window.turnstile.remove(renderedWidgetId); + turnstileWidgetIdRef.current = null; + } + }; + }, [ + clearTurnstileToken, + rejectTurnstileChallenge, + resolveTurnstileChallenge, + turnstileScriptReady, + ]); + function handleEmailChange(value: string) { setMessageDismissed(true); + setClientMessage(""); setEmailValue(value); } - function handleSubmit() { + const executeTurnstile = useCallback(() => { + const turnstile = window.turnstile; + const widgetId = turnstileWidgetIdRef.current; + const container = turnstileContainerRef.current; + + if (!turnstile || !widgetId || !container) { + return Promise.reject(new Error(NEWSLETTER_TURNSTILE_ERROR_MESSAGE)); + } + + clearTurnstileToken(); + setTurnstilePending(true); + + return new Promise((resolve, reject) => { + const timeoutId = window.setTimeout(() => { + turnstileChallengeRef.current = null; + clearTurnstileToken(); + setTurnstilePending(false); + reject(new Error(NEWSLETTER_TURNSTILE_ERROR_MESSAGE)); + }, TURNSTILE_CHALLENGE_TIMEOUT_MS); + + turnstileChallengeRef.current = { + reject, + resolve, + timeoutId, + }; + + try { + turnstile.reset(widgetId); + turnstile.execute(container); + } catch (error) { + window.clearTimeout(timeoutId); + turnstileChallengeRef.current = null; + clearTurnstileToken(); + setTurnstilePending(false); + reject(error); + } + }); + }, [clearTurnstileToken]); + + function handleSubmit(event: SubmitEvent) { setSubmittedEmail(emailValue.trim().toLowerCase()); setMessageDismissed(false); + setClientMessage(""); + + if (!TURNSTILE_SITE_KEY) return; + + const widgetId = turnstileWidgetIdRef.current; + const token = turnstileTokenInputRef.current?.value.trim(); + const tokenExpired = + widgetId && window.turnstile + ? window.turnstile.isExpired(widgetId) + : true; + + if (token && !tokenExpired) { + if (pending) { + event.preventDefault(); + } + + return; + } + + if (submitPending) { + event.preventDefault(); + return; + } + + event.preventDefault(); + + void executeTurnstile() + .then(() => { + formRef.current?.requestSubmit(); + }) + .catch(() => { + setClientMessage(NEWSLETTER_TURNSTILE_ERROR_MESSAGE); + setMessageDismissed(false); + emailInputRef.current?.focus(); + }); } return ( -
-
- - handleEmailChange(event.target.value)} - aria-invalid={showEmailError ? "true" : undefined} - aria-describedby={showMessage ? messageId : undefined} - suppressHydrationWarning - className="site-footer-email-input" - /> - {showMessage && (

- {turnstilePending - ? "Verifying" - : pending - ? "Subscribing" - : submitLabel} + {submitPending ? ( + + ) : ( + submitLabel + )} diff --git a/src/components/layout/site-footer.tsx b/src/components/layout/site-footer.tsx index b5131b3..007ed76 100644 --- a/src/components/layout/site-footer.tsx +++ b/src/components/layout/site-footer.tsx @@ -90,8 +90,8 @@ export function SiteFooter() {

diff --git a/src/hooks/use-turnstile.ts b/src/hooks/use-turnstile.ts new file mode 100644 index 0000000..27ec46f --- /dev/null +++ b/src/hooks/use-turnstile.ts @@ -0,0 +1,364 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +const ONLOAD_CALLBACK = "cortexTurnstileOnLoad"; +const SCRIPT_SRC = `https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=${ONLOAD_CALLBACK}`; +const SCRIPT_ID = "turnstile-script"; +const CHALLENGE_TIMEOUT_MS = 15_000; +const SCRIPT_STATUS_KEY = "turnstileStatus"; + +type ScriptStatus = "error" | "loaded" | "loading"; +type WidgetId = string; + +type Challenge = { + reject: (reason?: unknown) => void; + resolve: (token: string) => void; + timeoutId: number; +}; + +type TurnstileApi = { + execute(container: HTMLElement | string): void; + remove(widgetId: WidgetId): void; + render( + container: HTMLElement, + options: { + action: string; + appearance: "always" | "execute" | "interaction-only"; + callback: (token: string) => void; + "error-callback": () => void; + "expired-callback": () => void; + execution: "render" | "execute"; + sitekey: string; + "timeout-callback": () => void; + }, + ): WidgetId | undefined; + reset(widgetId: WidgetId): void; +}; + +declare global { + interface Window { + cortexTurnstileOnLoad?: () => void; + turnstile?: TurnstileApi; + } +} + +function isCompatibleScript(script: HTMLScriptElement): boolean { + return ( + !script.async && !script.defer && script.src.includes(ONLOAD_CALLBACK) + ); +} + +function removeScript(): void { + document.getElementById(SCRIPT_ID)?.remove(); + delete window.turnstile; +} + +export type UseTurnstileOptions = { + action: string; + errorMessage: string; + siteKey: string | undefined; +}; + +export type UseTurnstileReturn = { + containerRef: React.RefObject; + enabled: boolean; + error: string | null; + execute: () => Promise; + pending: boolean; + reset: () => void; + tokenRef: React.RefObject; +}; + +export function useTurnstile({ + action, + errorMessage, + siteKey, +}: UseTurnstileOptions): UseTurnstileReturn { + const containerRef = useRef(null); + const tokenRef = useRef(null); + const widgetIdRef = useRef(null); + const challengeRef = useRef(null); + const [scriptReady, setScriptReady] = useState(false); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + const clearToken = useCallback(() => { + if (tokenRef.current) { + tokenRef.current.value = ""; + } + }, []); + + const resolveChallenge = useCallback((token: string) => { + const challenge = challengeRef.current; + + if (!challenge) return; + + window.clearTimeout(challenge.timeoutId); + challengeRef.current = null; + setPending(false); + setError(null); + + const normalizedToken = token.trim(); + + if (tokenRef.current) { + tokenRef.current.value = normalizedToken; + } + + challenge.resolve(normalizedToken); + }, []); + + const rejectChallenge = useCallback(() => { + const challenge = challengeRef.current; + + if (!challenge) return; + + window.clearTimeout(challenge.timeoutId); + challenge.reject(new Error(errorMessage)); + challengeRef.current = null; + clearToken(); + setPending(false); + setError(errorMessage); + }, [clearToken, errorMessage]); + + const resetWidget = useCallback(() => { + const widgetId = widgetIdRef.current; + + if (widgetId && window.turnstile) { + window.turnstile.reset(widgetId); + } + }, []); + + const reset = useCallback(() => { + clearToken(); + resetWidget(); + setError(null); + }, [clearToken, resetWidget]); + + const execute = useCallback(() => { + const turnstile = window.turnstile; + const widgetId = widgetIdRef.current; + const container = containerRef.current; + + if (!turnstile || !widgetId || !container) { + setError(errorMessage); + return Promise.reject(new Error(errorMessage)); + } + + clearToken(); + setPending(true); + setError(null); + + return new Promise((resolve, reject) => { + const timeoutId = window.setTimeout(() => { + challengeRef.current = null; + clearToken(); + setPending(false); + setError(errorMessage); + reject(new Error(errorMessage)); + }, CHALLENGE_TIMEOUT_MS); + + challengeRef.current = { reject, resolve, timeoutId }; + + try { + turnstile.reset(widgetId); + turnstile.execute(container); + } catch (err) { + window.clearTimeout(timeoutId); + challengeRef.current = null; + clearToken(); + setPending(false); + setError(errorMessage); + reject(err); + } + }); + }, [clearToken, errorMessage]); + + useEffect(() => { + if (!siteKey) return; + + let cancelled = false; + + function setScriptStatus( + script: HTMLScriptElement | null, + status: ScriptStatus, + ) { + if (script) { + script.dataset[SCRIPT_STATUS_KEY] = status; + } + } + + function markReady(script: HTMLScriptElement | null) { + if (cancelled) return; + + if (!window.turnstile) { + setScriptStatus(script, "error"); + handleError(script); + return; + } + + setScriptStatus(script, "loaded"); + + if (!cancelled) { + setScriptReady(true); + setError(null); + } + } + + function handleLoad(event: Event) { + markReady(event.currentTarget as HTMLScriptElement | null); + } + + function handleError(script: HTMLScriptElement | null = null) { + if (cancelled) return; + setScriptStatus(script, "error"); + setScriptReady(false); + setError(errorMessage); + } + + function handleScriptError(event: Event) { + handleError(event.currentTarget as HTMLScriptElement | null); + } + + window[ONLOAD_CALLBACK] = () => { + const script = document.getElementById( + SCRIPT_ID, + ) as HTMLScriptElement | null; + + markReady(script); + }; + + const existingScript = document.getElementById( + SCRIPT_ID, + ) as HTMLScriptElement | null; + + if (existingScript && !isCompatibleScript(existingScript)) { + removeScript(); + } + + const compatibleScript = document.getElementById( + SCRIPT_ID, + ) as HTMLScriptElement | null; + + if (window.turnstile && compatibleScript) { + const loadedScript = compatibleScript; + + window.queueMicrotask(() => markReady(loadedScript)); + + return () => { + cancelled = true; + delete window[ONLOAD_CALLBACK]; + }; + } + + if (compatibleScript) { + const existingStatus = compatibleScript.dataset[ + SCRIPT_STATUS_KEY + ] as ScriptStatus | undefined; + + if (existingStatus === "loaded") { + const loadedScript = compatibleScript; + + window.queueMicrotask(() => markReady(loadedScript)); + + return () => { + cancelled = true; + delete window[ONLOAD_CALLBACK]; + }; + } + + if (existingStatus === "error") { + removeScript(); + } + } + + const loadingScript = document.getElementById( + SCRIPT_ID, + ) as HTMLScriptElement | null; + + if (loadingScript) { + loadingScript.addEventListener("load", handleLoad, { once: true }); + loadingScript.addEventListener("error", handleScriptError, { + once: true, + }); + + return () => { + cancelled = true; + delete window[ONLOAD_CALLBACK]; + loadingScript.removeEventListener("load", handleLoad); + loadingScript.removeEventListener("error", handleScriptError); + }; + } + + const script = document.createElement("script"); + const nonce = + document.querySelector("script[nonce]")?.nonce; + + script.id = SCRIPT_ID; + script.src = SCRIPT_SRC; + script.async = false; + script.defer = false; + setScriptStatus(script, "loading"); + + if (nonce) { + script.nonce = nonce; + } + + script.addEventListener("load", handleLoad, { once: true }); + script.addEventListener("error", handleScriptError, { once: true }); + document.head.append(script); + + return () => { + cancelled = true; + delete window[ONLOAD_CALLBACK]; + script.removeEventListener("load", handleLoad); + script.removeEventListener("error", handleScriptError); + }; + }, [siteKey, errorMessage]); + + useEffect(() => { + if ( + !siteKey || + !scriptReady || + !containerRef.current || + widgetIdRef.current || + !window.turnstile + ) { + return; + } + + const widgetId = window.turnstile.render(containerRef.current, { + action, + appearance: "execute", + callback: resolveChallenge, + "error-callback": rejectChallenge, + "expired-callback": rejectChallenge, + execution: "execute", + sitekey: siteKey, + "timeout-callback": rejectChallenge, + }); + + if (widgetId) { + widgetIdRef.current = widgetId; + } + + return () => { + const renderedWidgetId = widgetIdRef.current; + + if (renderedWidgetId && window.turnstile) { + window.turnstile.remove(renderedWidgetId); + widgetIdRef.current = null; + } + }; + }, [action, siteKey, scriptReady, resolveChallenge, rejectChallenge]); + + return { + containerRef, + enabled: Boolean(siteKey), + error, + execute, + pending, + reset, + tokenRef, + }; +} diff --git a/src/lib/content/footer.ts b/src/lib/content/footer.ts index 0e8b59f..bcc33a7 100644 --- a/src/lib/content/footer.ts +++ b/src/lib/content/footer.ts @@ -1,11 +1,10 @@ import { - contactEmail, cortexSocialLinkKeys, externalLinks, siteLinks, type ExternalLinkKey, } from "./links"; -import type { ContentCta, InternalHref, SectionContent } from "./types"; +import type { InternalHref, SectionContent } from "./types"; export type FooterColumnLink = { label: string; @@ -24,11 +23,8 @@ export const footerContent = { description: "Get Cortex updates on local hubs, education tracks, events, services, and ways to support the network.", tagline: "Open innovation, made local. Open to all.", - email: contactEmail, - newsletterCta: { - label: "Subscribe", - href: contactEmail.href, - } satisfies ContentCta, + newsletterPlaceholder: "youremail@example.com", + newsletterSubmitLabel: "Subscribe", columns: [ { title: "About", @@ -62,8 +58,8 @@ export const footerContent = { } as const satisfies SectionContent & { tagline: string; emailInputId: string; - email: typeof contactEmail; - newsletterCta: ContentCta; + newsletterPlaceholder: string; + newsletterSubmitLabel: string; columns: readonly FooterColumn[]; socialLinks: readonly ((typeof externalLinks)[ExternalLinkKey] & { key: ExternalLinkKey; diff --git a/src/lib/content/links.ts b/src/lib/content/links.ts index 0c20c1f..b76ecc9 100644 --- a/src/lib/content/links.ts +++ b/src/lib/content/links.ts @@ -2,7 +2,7 @@ import type { ExternalHref, InternalHref } from "./types"; export type SiteLink = { label: string; - href: InternalHref; + href?: InternalHref; }; export const siteLinks = { @@ -45,7 +45,7 @@ export type ExternalLinkChannel = | "tiktok" | "linkedin" | "youtube" - | "github" + | "github"; export type ExternalLink = { label: string; @@ -182,9 +182,3 @@ export const monadLinkKeys = [ "mipland", "categoryLabs", ] as const satisfies readonly ExternalLinkKey[]; - -// TODO: Need to verify this -export const contactEmail = { - label: "becool@cortexglobal.xyz", - href: "mailto:becool@cortexglobal.xyz", -} as const satisfies SiteLink; diff --git a/src/lib/integrations/turnstile.test.ts b/src/lib/integrations/turnstile.test.ts index 010b4bf..6fd7c3b 100644 --- a/src/lib/integrations/turnstile.test.ts +++ b/src/lib/integrations/turnstile.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, mock, test } from "bun:test"; +import { afterEach, describe, expect, mock, test } from "bun:test"; import { + isNewsletterTurnstileEnabled, resolveTurnstileAllowedHostnames, verifyTurnstileToken, } from "@/lib/integrations/turnstile"; @@ -41,6 +42,48 @@ describe("verifyTurnstileToken", () => { expect(fetcher).not.toHaveBeenCalled(); }); + test("rejects whitespace-only secret keys before calling Siteverify", async () => { + const fetcher = mock(async () => Response.json({ success: true })); + + const result = await verifyTurnstileToken({ + fetcher, + secretKey: " ", + token: "token_test", + }); + + expect(result).toEqual({ + ok: false, + code: "configuration", + message: "Verification failed. Please try again.", + }); + expect(fetcher).not.toHaveBeenCalled(); + }); + + test("trims secret keys before calling Siteverify", async () => { + const fetcher = mock(async (_input, init) => { + const body = init?.body; + + expect(body).toBeInstanceOf(URLSearchParams); + expect((body as URLSearchParams).get("secret")).toBe("secret_test"); + + return Response.json({ + action: "newsletter", + hostname: "www.cortexglobal.xyz", + success: true, + }); + }); + + const result = await verifyTurnstileToken({ + allowedHostnames: ["www.cortexglobal.xyz"], + fetcher, + secretKey: " secret_test ", + token: "token_test", + }); + + expect(result).toEqual({ ok: true }); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + test("accepts a valid Siteverify response for the newsletter action and hostname", async () => { const fetcher = mock(async () => Response.json({ @@ -208,3 +251,50 @@ describe("resolveTurnstileAllowedHostnames", () => { ]); }); }); + +describe("isNewsletterTurnstileEnabled", () => { + const originalSiteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY; + const originalSecretKey = process.env.TURNSTILE_SECRET_KEY; + + afterEach(() => { + if (originalSiteKey === undefined) { + delete process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY; + } else { + process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY = originalSiteKey; + } + + if (originalSecretKey === undefined) { + delete process.env.TURNSTILE_SECRET_KEY; + } else { + process.env.TURNSTILE_SECRET_KEY = originalSecretKey; + } + }); + + test("requires both the public site key and secret key", () => { + process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY = "site-key"; + process.env.TURNSTILE_SECRET_KEY = "secret-key"; + + expect(isNewsletterTurnstileEnabled()).toBe(true); + }); + + test("rejects a secret-only configuration", () => { + delete process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY; + process.env.TURNSTILE_SECRET_KEY = "secret-key"; + + expect(isNewsletterTurnstileEnabled()).toBe(false); + }); + + test("rejects a site-key-only configuration", () => { + process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY = "site-key"; + delete process.env.TURNSTILE_SECRET_KEY; + + expect(isNewsletterTurnstileEnabled()).toBe(false); + }); + + test("ignores whitespace-only env values", () => { + process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY = " "; + process.env.TURNSTILE_SECRET_KEY = "secret-key"; + + expect(isNewsletterTurnstileEnabled()).toBe(false); + }); +}); diff --git a/src/lib/integrations/turnstile.ts b/src/lib/integrations/turnstile.ts index 39e854f..84541f9 100644 --- a/src/lib/integrations/turnstile.ts +++ b/src/lib/integrations/turnstile.ts @@ -6,6 +6,13 @@ import { const TURNSTILE_SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; + +export function isNewsletterTurnstileEnabled(): boolean { + return Boolean( + process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY?.trim() && + process.env.TURNSTILE_SECRET_KEY?.trim(), + ); +} const TURNSTILE_TEST_ACTION = "test"; const TURNSTILE_TEST_SECRET_KEYS = new Set([ "1x0000000000000000000000000000000AA", @@ -136,12 +143,13 @@ export async function verifyTurnstileToken({ token, }: VerifyTurnstileTokenOptions): Promise { const responseToken = typeof token === "string" ? token.trim() : ""; + const normalizedSecretKey = secretKey?.trim() ?? ""; const normalizedAllowedHostnames = resolveTurnstileAllowedHostnames( allowedHostnames, deploymentHostname, ); - if (!secretKey) { + if (!normalizedSecretKey) { return { ok: false, code: "configuration", @@ -167,7 +175,7 @@ export async function verifyTurnstileToken({ const body = new URLSearchParams({ response: responseToken, - secret: secretKey, + secret: normalizedSecretKey, }); if (remoteIp) { @@ -190,7 +198,10 @@ export async function verifyTurnstileToken({ }; } - const expectedActions = getExpectedActions(secretKey, expectedAction); + const expectedActions = getExpectedActions( + normalizedSecretKey, + expectedAction, + ); if (!result.action || !expectedActions.has(result.action)) { return { diff --git a/src/lib/newsletter/actions.ts b/src/lib/newsletter/actions.ts index d3bfbb3..a3d9f7e 100644 --- a/src/lib/newsletter/actions.ts +++ b/src/lib/newsletter/actions.ts @@ -2,7 +2,10 @@ import { headers } from "next/headers"; import { subscribeNewsletterContact } from "@/lib/integrations/resend"; -import { verifyTurnstileToken } from "@/lib/integrations/turnstile"; +import { + isNewsletterTurnstileEnabled, + verifyTurnstileToken, +} from "@/lib/integrations/turnstile"; import { checkNewsletterRateLimit, getNewsletterClientIp, @@ -22,6 +25,8 @@ export async function submitNewsletterSignup( return handleNewsletterSignup(formData, { rateLimit: () => checkNewsletterRateLimit(rateLimitIdentifier), subscribe: subscribeNewsletterContact, - turnstile: (token) => verifyTurnstileToken({ remoteIp: clientIp, token }), + turnstile: isNewsletterTurnstileEnabled() + ? (token) => verifyTurnstileToken({ remoteIp: clientIp, token }) + : undefined, }); } diff --git a/src/lib/newsletter/submit.test.ts b/src/lib/newsletter/submit.test.ts index c3d5824..1733190 100644 --- a/src/lib/newsletter/submit.test.ts +++ b/src/lib/newsletter/submit.test.ts @@ -4,6 +4,7 @@ import type { TurnstileVerificationResult } from "@/lib/integrations/turnstile"; import type { NewsletterRateLimitResult } from "@/lib/newsletter/rate-limit"; import { handleNewsletterSignup } from "@/lib/newsletter/submit"; import { NEWSLETTER_TURNSTILE_FIELD } from "@/lib/newsletter/turnstile"; +import { NEWSLETTER_SUBMISSION_ID_FIELD } from "@/lib/newsletter/types"; function createFormData(fields: Record): FormData { const formData = new FormData(); @@ -129,6 +130,27 @@ describe("handleNewsletterSignup", () => { expect(subscribeSpy.calls).toEqual(["person@example.com"]); }); + test("preserves the client submission id in the returned state", async () => { + const subscribeSpy = createSubscribeSpy(subscribedResult); + const result = await handleNewsletterSignup( + createFormData({ + email: "person@example.com", + company: "", + [NEWSLETTER_SUBMISSION_ID_FIELD]: "submission-123", + }), + { + subscribe: subscribeSpy.subscribe, + }, + ); + + expect(result).toEqual({ + status: "success", + message: "You have successfully subscribed to the Cortex update list.", + subscribedEmail: "person@example.com", + submissionId: "submission-123", + }); + }); + test("rate limits before rejecting a missing Turnstile token", async () => { const subscribeSpy = createSubscribeSpy(subscribedResult); const rateLimitSpy = createRateLimitSpy({ ok: true }); diff --git a/src/lib/newsletter/submit.ts b/src/lib/newsletter/submit.ts index 9b0f361..ec49fd4 100644 --- a/src/lib/newsletter/submit.ts +++ b/src/lib/newsletter/submit.ts @@ -3,7 +3,10 @@ import type { TurnstileVerificationResult } from "@/lib/integrations/turnstile"; import type { NewsletterSubscribeResult } from "@/lib/integrations/resend"; import type { NewsletterRateLimitResult } from "@/lib/newsletter/rate-limit"; import { NEWSLETTER_TURNSTILE_FIELD } from "@/lib/newsletter/turnstile"; -import type { NewsletterFormState } from "@/lib/newsletter/types"; +import { + NEWSLETTER_SUBMISSION_ID_FIELD, + type NewsletterFormState, +} from "@/lib/newsletter/types"; type NewsletterSubscribe = (email: string) => Promise; type NewsletterRateLimit = () => Promise; @@ -23,17 +26,30 @@ const ALREADY_SUBSCRIBED_MESSAGE = "Your email has already been submitted."; const GENERIC_ERROR_MESSAGE = "We could not subscribe that email right now. Please try again."; +function getSubmissionId(formData: FormData): string | undefined { + const submissionId = formData.get(NEWSLETTER_SUBMISSION_ID_FIELD); + + return typeof submissionId === "string" && submissionId.trim() + ? submissionId.trim() + : undefined; +} + export async function handleNewsletterSignup( formData: FormData, { rateLimit, subscribe, turnstile }: HandleNewsletterSignupOptions, ): Promise { + const submissionId = getSubmissionId(formData); + const withSubmissionId = ( + state: Omit, + ): NewsletterFormState => + submissionId ? { ...state, submissionId } : state; const company = formData.get("company"); if (typeof company === "string" && company.trim()) { - return { + return withSubmissionId({ status: "success", message: SUBSCRIBED_MESSAGE, - }; + }); } const parsed = newsletterSignupSchema.safeParse({ @@ -44,20 +60,20 @@ export async function handleNewsletterSignup( if (!parsed.success) { const errors = parsed.error.flatten().fieldErrors; - return { + return withSubmissionId({ status: "error", message: errors.email?.[0] || GENERIC_ERROR_MESSAGE, emailError: errors.email?.[0], - }; + }); } const rateLimitResult = await rateLimit?.(); if (rateLimitResult && !rateLimitResult.ok) { - return { + return withSubmissionId({ status: "error", message: rateLimitResult.message, - }; + }); } const turnstileResult = await turnstile?.( @@ -65,10 +81,10 @@ export async function handleNewsletterSignup( ); if (turnstileResult && !turnstileResult.ok) { - return { + return withSubmissionId({ status: "error", message: turnstileResult.message, - }; + }); } const result = await subscribe(parsed.data.email); @@ -79,17 +95,17 @@ export async function handleNewsletterSignup( ? "Newsletter setup is missing required Resend environment variables." : GENERIC_ERROR_MESSAGE; - return { + return withSubmissionId({ status: "error", message: configurationMessage, - }; + }); } - return { + return withSubmissionId({ status: "success", message: result.alreadySubscribed ? ALREADY_SUBSCRIBED_MESSAGE : SUBSCRIBED_MESSAGE, subscribedEmail: parsed.data.email, - }; + }); } diff --git a/src/lib/newsletter/types.ts b/src/lib/newsletter/types.ts index 9063b2f..1cfdb54 100644 --- a/src/lib/newsletter/types.ts +++ b/src/lib/newsletter/types.ts @@ -2,9 +2,12 @@ export type NewsletterFormState = { status: "idle" | "success" | "error"; message: string; emailError?: string; + submissionId?: string; subscribedEmail?: string; }; +export const NEWSLETTER_SUBMISSION_ID_FIELD = "newsletterSubmissionId"; + export const newsletterInitialState = { status: "idle", message: "",