From 5c6cea8395253bb8bca21e83409179c11f403747 Mon Sep 17 00:00:00 2001 From: "muslim.ilmiawan" Date: Thu, 2 Jul 2026 07:27:31 +0700 Subject: [PATCH 1/3] [SPARKS] add email newsletter subscription form --- .../[slug]/components/newsletter-section.tsx | 116 ++++++++++++++++++ .../(hyperjump)/services/[slug]/page.tsx | 2 + app/api/subscribe/route.ts | 44 +++++++ locales/en/ai.json | 13 ++ locales/id/ai.json | 13 ++ 5 files changed, 188 insertions(+) create mode 100644 app/[lang]/(hyperjump)/services/[slug]/components/newsletter-section.tsx create mode 100644 app/api/subscribe/route.ts diff --git a/app/[lang]/(hyperjump)/services/[slug]/components/newsletter-section.tsx b/app/[lang]/(hyperjump)/services/[slug]/components/newsletter-section.tsx new file mode 100644 index 000000000..2a3a230b3 --- /dev/null +++ b/app/[lang]/(hyperjump)/services/[slug]/components/newsletter-section.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useState } from "react"; + +import type { SupportedLanguage } from "@/locales/.generated/types"; +import { + aiNewsletterCta, + aiNewsletterDisclaimer, + aiNewsletterError, + aiNewsletterHeading, + aiNewsletterLanguageEn, + aiNewsletterLanguageId, + aiNewsletterLanguageLabel, + aiNewsletterLanguageSu, + aiNewsletterPlaceholder, + aiNewsletterSubheading, + aiNewsletterSuccess, +} from "@/locales/.generated/strings"; + +type NewsletterSectionProps = { + lang: SupportedLanguage; +}; + +export function NewsletterSection({ lang }: NewsletterSectionProps) { + const [email, setEmail] = useState(""); + const [digestLang, setDigestLang] = useState<"en" | "id" | "su">( + lang === "id" ? "id" : "en" + ); + const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle"); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = email.trim(); + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!trimmed || !emailRegex.test(trimmed)) { + setStatus("error"); + return; + } + + setStatus("loading"); + try { + const res = await fetch("/api/subscribe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: trimmed, language: digestLang }), + }); + if (res.ok) { + setStatus("success"); + } else { + setStatus("error"); + } + } catch { + setStatus("error"); + } + }; + + return ( +
+
+

+ {aiNewsletterHeading(lang)} +

+

+ {aiNewsletterSubheading(lang)} +

+ + {/* Language dropdown */} +
+ + +
+ +
+ setEmail(e.target.value)} + placeholder={aiNewsletterPlaceholder(lang)} + disabled={status === "loading" || status === "success"} + className="flex-1 rounded-lg border border-gray-200 bg-white px-4 py-3 text-base text-hyperjump-black placeholder-gray-400 shadow-sm outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-200 disabled:opacity-50" + /> + +
+ + {status === "success" && ( +

+ {aiNewsletterSuccess(lang)} +

+ )} + {status === "error" && ( +

+ {aiNewsletterError(lang)} +

+ )} + +

{aiNewsletterDisclaimer(lang)}

+
+
+ ); +} diff --git a/app/[lang]/(hyperjump)/services/[slug]/page.tsx b/app/[lang]/(hyperjump)/services/[slug]/page.tsx index 878a6a5a0..2add8e21b 100644 --- a/app/[lang]/(hyperjump)/services/[slug]/page.tsx +++ b/app/[lang]/(hyperjump)/services/[slug]/page.tsx @@ -35,6 +35,7 @@ import { mainServicesLabel } from "@/locales/.generated/strings"; +import { NewsletterSection } from "./components/newsletter-section"; import { AnimatedLines } from "../../components/animated-lines"; import { CaseStudyCard } from "../../components/case-study-card"; import { SectionReveal } from "../../components/motion-wrappers"; @@ -107,6 +108,7 @@ export default async function ServiceDetail({ params }: ServiceDetailProps) { + {slug === ServiceSlug.InferenceAI && } diff --git a/app/api/subscribe/route.ts b/app/api/subscribe/route.ts new file mode 100644 index 000000000..1f1edf931 --- /dev/null +++ b/app/api/subscribe/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(req: NextRequest) { + const body = await req.json(); + const { email, language } = body; + + // Basic validation server-side + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!email || !emailRegex.test(email.trim())) { + return NextResponse.json({ error: "Invalid email" }, { status: 422 }); + } + + const apiKey = process.env.SUBSCRIBE_API_KEY; + const apiUrl = process.env.FRONTIERNOTES_API_URL ?? "https://tech-monitor.fly.dev"; + + if (!apiKey) { + return NextResponse.json({ error: "Service unavailable" }, { status: 503 }); + } + + const res = await fetch(`${apiUrl}/api/subscribe`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + email: email.trim().toLowerCase(), + language: language ?? "en", + }), + }); + + let data: { status?: string } = {}; + try { + data = await res.json(); + } catch { + // FrontierNotes returned non-JSON (e.g. plain text error) + } + + if (res.status === 201 || res.status === 200) { + return NextResponse.json({ status: data.status ?? "subscribed" }, { status: 200 }); + } + + return NextResponse.json({ error: "Subscription failed" }, { status: 500 }); +} diff --git a/locales/en/ai.json b/locales/en/ai.json index 2ac7d917b..50b50136d 100644 --- a/locales/en/ai.json +++ b/locales/en/ai.json @@ -103,6 +103,19 @@ "title": "Our Products", "description": "Innovative products tailored to support your goals and scale with your needs." }, + "newsletter": { + "heading": "Stay Ahead of the Curve", + "subheading": "Get practical AI insights, agent architecture breakdowns, and real-world implementation stories — straight to your inbox.", + "placeholder": "Enter your email", + "cta": "Subscribe", + "disclaimer": "No spam. Unsubscribe anytime.", + "success": "You're in! We'll be in touch soon.", + "error": "Something went wrong. Please try again.", + "languageLabel": "Digest language:", + "languageEn": "English", + "languageId": "Bahasa Indonesia", + "languageSu": "Basa Sunda" + }, "faq": { "heading": "Frequently asked questions", "desc": "Everything you need to go from concept to fully deployed Al agent-done for you, end to end.", diff --git a/locales/id/ai.json b/locales/id/ai.json index 266f81935..37a4c79fb 100644 --- a/locales/id/ai.json +++ b/locales/id/ai.json @@ -103,6 +103,19 @@ "title": "Produk Kami", "description": "Produk inovatif yang dirancang untuk mendukung tujuan Anda dan berkembang sesuai kebutuhan Anda." }, + "newsletter": { + "heading": "Selangkah Lebih Maju", + "subheading": "Dapatkan insight AI praktis, breakdown arsitektur agent, dan kisah implementasi nyata — langsung ke kotak masukmu.", + "placeholder": "Masukkan email kamu", + "cta": "Langganan", + "disclaimer": "Tanpa spam. Bisa berhenti berlangganan kapan saja.", + "success": "Berhasil! Kami akan segera menghubungi kamu.", + "error": "Terjadi kesalahan. Silakan coba lagi.", + "languageLabel": "Bahasa digest:", + "languageEn": "English", + "languageId": "Bahasa Indonesia", + "languageSu": "Basa Sunda" + }, "faq": { "heading": "Pertanyaan yang Sering Diajukan", "desc": "Segala hal yang Anda butuhkan untuk mewujudkan agen AI dari konsep hingga implementasi penuh dikerjakan secara menyeluruh oleh kami.", From 5a85bc80ed5dc5bc00782cf30efa710bc759272c Mon Sep 17 00:00:00 2001 From: "muslim.ilmiawan" Date: Thu, 2 Jul 2026 10:37:56 +0700 Subject: [PATCH 2/3] Fix formatting issue in newsletter --- .../[slug]/components/newsletter-section.tsx | 21 ++++++++++++------- app/api/subscribe/route.ts | 14 ++++++++----- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/app/[lang]/(hyperjump)/services/[slug]/components/newsletter-section.tsx b/app/[lang]/(hyperjump)/services/[slug]/components/newsletter-section.tsx index 2a3a230b3..5b0f1bd9a 100644 --- a/app/[lang]/(hyperjump)/services/[slug]/components/newsletter-section.tsx +++ b/app/[lang]/(hyperjump)/services/[slug]/components/newsletter-section.tsx @@ -14,7 +14,7 @@ import { aiNewsletterLanguageSu, aiNewsletterPlaceholder, aiNewsletterSubheading, - aiNewsletterSuccess, + aiNewsletterSuccess } from "@/locales/.generated/strings"; type NewsletterSectionProps = { @@ -26,7 +26,9 @@ export function NewsletterSection({ lang }: NewsletterSectionProps) { const [digestLang, setDigestLang] = useState<"en" | "id" | "su">( lang === "id" ? "id" : "en" ); - const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle"); + const [status, setStatus] = useState< + "idle" | "loading" | "success" | "error" + >("idle"); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -42,7 +44,7 @@ export function NewsletterSection({ lang }: NewsletterSectionProps) { const res = await fetch("/api/subscribe", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: trimmed, language: digestLang }), + body: JSON.stringify({ email: trimmed, language: digestLang }) }); if (res.ok) { setStatus("success"); @@ -70,9 +72,10 @@ export function NewsletterSection({ lang }: NewsletterSectionProps) { - setDigestLang(e.target.value as "en" | "id" | "su") + setDigestLang(e.target.value as "en" | "id" | "su" | "de") } className="rounded border border-gray-300 px-2 py-1 text-sm text-gray-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-200 focus:outline-none"> - - - + + + +
- setEmail(e.target.value)} - placeholder={aiNewsletterPlaceholder(lang)} - disabled={status === "loading" || status === "success"} - className="text-hyperjump-black flex-1 rounded-lg border border-gray-200 bg-white px-4 py-3 text-base placeholder-gray-400 shadow-sm outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-200 disabled:opacity-50" - /> - + className="flex w-full max-w-xl flex-col gap-3"> +
+ setEmail(e.target.value)} + placeholder={copy.placeholder} + disabled={status === "loading" || status === "success"} + className="text-hyperjump-black flex-1 rounded-lg border border-gray-200 bg-white px-4 py-3 text-base placeholder-gray-400 shadow-sm outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-200 disabled:opacity-50" + /> + +
+ +
+ setCaptchaToken(token)} + onExpire={() => setCaptchaToken(null)} + onError={() => setCaptchaToken(null)} + /> +
{status === "success" && ( -

- {aiNewsletterSuccess(lang)} -

+

{copy.success}

)} {status === "error" && ( -

- {aiNewsletterError(lang)} +

{copy.error}

+ )} + {status === "captcha-error" && ( +

+ {copy.captchaError}

)} -

- {aiNewsletterDisclaimer(lang)} -

+

{copy.disclaimer}

); diff --git a/app/api/subscribe/route.ts b/app/api/subscribe/route.ts index ad47b81e4..d693fa4b4 100644 --- a/app/api/subscribe/route.ts +++ b/app/api/subscribe/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; export async function POST(req: NextRequest) { const body = await req.json(); - const { email, language } = body; + const { email, language, turnstileToken } = body; // Basic validation server-side const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -10,6 +10,40 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Invalid email" }, { status: 422 }); } + if (!turnstileToken) { + return NextResponse.json( + { error: "Missing CAPTCHA verification" }, + { status: 422 } + ); + } + + const turnstileVerifyRes = await fetch( + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + secret: process.env.TURNSTILE_SECRET_KEY, + response: turnstileToken, + remoteip: req.headers.get("x-forwarded-for") ?? undefined + }) + } + ); + + let turnstileData: { success?: boolean } = {}; + try { + turnstileData = await turnstileVerifyRes.json(); + } catch { + // Cloudflare returned non-JSON, treat as verification failure below + } + + if (turnstileData.success !== true) { + return NextResponse.json( + { error: "CAPTCHA verification failed" }, + { status: 403 } + ); + } + const apiKey = process.env.SUBSCRIBE_API_KEY; const apiUrl = process.env.FRONTIERNOTES_API_URL ?? "https://tech-monitor.fly.dev"; diff --git a/bun.lock b/bun.lock index c7e1a12c1..75d68fea0 100644 --- a/bun.lock +++ b/bun.lock @@ -1,10 +1,10 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "hyperjump.tech", "dependencies": { + "@marsidev/react-turnstile": "^1.5.3", "@mdx-js/loader": "3.1.1", "@mdx-js/react": "3.1.1", "@n8n/chat": "0.61.0", @@ -213,6 +213,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@marsidev/react-turnstile": ["@marsidev/react-turnstile@1.5.3", "", { "peerDependencies": { "react": "^17.0.2 || ^18.0.0 || ^19.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0" } }, "sha512-8Dij2jiNGNczq1U4EKpO4do2XepcTPxSMc2ZzvHndO+gcp68tvMULm27z2P99rGkdB89hc3452NZeu2Rti4g6A=="], + "@mdx-js/loader": ["@mdx-js/loader@3.1.1", "", { "dependencies": { "@mdx-js/mdx": "^3.0.0", "source-map": "^0.7.0" }, "peerDependencies": { "webpack": ">=5" }, "optionalPeers": ["webpack"] }, "sha512-0TTacJyZ9mDmY+VefuthVshaNIyCGZHJG2fMnGaDttCt8HmjUF7SizlHJpaCDoGnN635nK1wpzfpx/Xx5S4WnQ=="], "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], diff --git a/locales/en/ai.json b/locales/en/ai.json index 50b50136d..32980f6f4 100644 --- a/locales/en/ai.json +++ b/locales/en/ai.json @@ -109,12 +109,14 @@ "placeholder": "Enter your email", "cta": "Subscribe", "disclaimer": "No spam. Unsubscribe anytime.", - "success": "You're in! We'll be in touch soon.", + "success": "Almost there! Check your inbox to confirm your subscription.", "error": "Something went wrong. Please try again.", + "captchaError": "Please complete the CAPTCHA verification.", "languageLabel": "Digest language:", "languageEn": "English", "languageId": "Bahasa Indonesia", - "languageSu": "Basa Sunda" + "languageSu": "Basa Sunda", + "languageDe": "Deutsch" }, "faq": { "heading": "Frequently asked questions", diff --git a/locales/id/ai.json b/locales/id/ai.json index 37a4c79fb..f67ddc47f 100644 --- a/locales/id/ai.json +++ b/locales/id/ai.json @@ -109,12 +109,14 @@ "placeholder": "Masukkan email kamu", "cta": "Langganan", "disclaimer": "Tanpa spam. Bisa berhenti berlangganan kapan saja.", - "success": "Berhasil! Kami akan segera menghubungi kamu.", + "success": "Hampir selesai! Cek email kamu untuk konfirmasi langganan.", "error": "Terjadi kesalahan. Silakan coba lagi.", + "captchaError": "Mohon selesaikan verifikasi CAPTCHA terlebih dahulu.", "languageLabel": "Bahasa digest:", "languageEn": "English", "languageId": "Bahasa Indonesia", - "languageSu": "Basa Sunda" + "languageSu": "Basa Sunda", + "languageDe": "Deutsch" }, "faq": { "heading": "Pertanyaan yang Sering Diajukan", diff --git a/package.json b/package.json index 07798ea6d..dbe3fd4fe 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test:e2e:report": "playwright show-report" }, "dependencies": { + "@marsidev/react-turnstile": "^1.5.3", "@mdx-js/loader": "3.1.1", "@mdx-js/react": "3.1.1", "@n8n/chat": "0.61.0",