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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,8 @@ NEXT_PUBLIC_LANDING_GET_CHATS_WEBHOOK=
NEXT_PUBLIC_LANDING_GET_FOLLOWUP_WEBHOOK=
NEXT_PUBLIC_INFERENCEAI_POST_CHATS_WEBHOOK=
NEXT_PUBLIC_INFERENCEAI_GET_CHATS_WEBHOOK=
NEXT_PUBLIC_GA_TRACKING_ID=
NEXT_PUBLIC_GA_TRACKING_ID=
FRONTIERNOTES_API_URL=
SUBSCRIBE_API_KEY=
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
"use client";

import { Turnstile, type TurnstileInstance } from "@marsidev/react-turnstile";
import { useRef, useState } from "react";

import type { SupportedLanguage } from "@/locales/.generated/types";

type NewsletterSectionProps = {
lang: SupportedLanguage;
};

type DigestLang = "en" | "id" | "su" | "de";

type NewsletterCopy = {
heading: string;
subheading: string;
placeholder: string;
cta: string;
disclaimer: string;
success: string;
error: string;
captchaError: string;
languageLabel: string;
languageEn: string;
languageId: string;
languageSu: string;
languageDe: string;
};

const newsletterCopyByDigestLang: Record<
"en" | "id" | "de" | "su",
NewsletterCopy
> = {
en: {
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:
"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",
languageDe: "Deutsch"
},
id: {
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: "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",
languageDe: "Deutsch"
},
de: {
heading: "Bleiben Sie immer einen Schritt voraus",
subheading:
"Erhalte praxisnahe KI-Einblicke, detaillierte Agent-Architekturen und Berichte aus echten Implementierungen — direkt in dein Postfach.",
placeholder: "Gib deine E-Mail-Adresse ein",
cta: "Abonnieren",
disclaimer: "Kein Spam. Jederzeit abbestellbar.",
success:
"Fast geschafft! Bestätige dein Abonnement über den Link in deinem Postfach.",
error: "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
captchaError: "Bitte schließe die CAPTCHA-Verifizierung ab.",
languageLabel: "Digest-Sprache:",
languageEn: "English",
languageId: "Bahasa Indonesia",
languageSu: "Basa Sunda",
languageDe: "Deutsch"
},
su: {
heading: "Salangkung Leuwih Maju",
subheading:
"Kenging insight AI anu praktis, bedah arsitektur agent, sareng carita implementasi nyata — langsung ka kotak surel anjeun.",
placeholder: "Lebetkeun email anjeun",
cta: "Langganan",
disclaimer: "Tanpa spam. Tiasa eureun langganan iraha wae.",
success: "Ampir rengse! Pariksa email anjeun kanggo konfirmasi langganan.",
error: "Aya kasalahan. Mangga cobian deui.",
captchaError: "Mangga lengkepan verifikasi CAPTCHA heula.",
languageLabel: "Basa digest:",
languageEn: "English",
languageId: "Bahasa Indonesia",
languageSu: "Basa Sunda",
languageDe: "Deutsch"
}
};

export function NewsletterSection({ lang }: NewsletterSectionProps) {
const [email, setEmail] = useState("");
const [digestLang, setDigestLang] = useState<DigestLang>(
lang === "id" ? "id" : "en"
);
const [status, setStatus] = useState<
"idle" | "loading" | "success" | "error" | "captcha-error"
>("idle");
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const turnstileRef = useRef<TurnstileInstance>(null);

const copy = newsletterCopyByDigestLang[digestLang] ??
newsletterCopyByDigestLang.en;

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const trimmed = email.trim();
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!trimmed || !emailRegex.test(trimmed)) {
setStatus("error");
return;
}

if (!captchaToken) {
setStatus("captcha-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,
turnstileToken: captchaToken
})
});
if (res.ok) {
setStatus("success");
} else {
setStatus("error");
}
} catch {
setStatus("error");
} finally {
setCaptchaToken(null);
turnstileRef.current?.reset();
}
};

return (
<section className="bg-[#F6F8F9] py-8 md:py-16">
<div className="text-hyperjump-black mx-auto flex w-full max-w-5xl flex-col items-center px-4 text-center md:px-20 xl:px-0">
<h2 className="mb-3 text-[34px] font-medium md:text-4xl">
{copy.heading}
</h2>
<p className="mb-6 max-w-xl text-lg leading-relaxed text-gray-600">
{copy.subheading}
</p>

{/* Language dropdown */}
<div className="mb-4 flex items-center gap-2 text-sm text-gray-500">
<label htmlFor="digest-lang">{copy.languageLabel}</label>
<select
id="digest-lang"
value={digestLang}
onChange={(e) =>
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">
<option value="en">{copy.languageEn}</option>
<option value="id">{copy.languageId}</option>
<option value="de">{copy.languageDe}</option>
<option value="su">{copy.languageSu}</option>
</select>
</div>

<form
onSubmit={handleSubmit}
className="flex w-full max-w-xl flex-col gap-3">
<div className="flex w-full flex-col gap-3 sm:flex-row">
<input
type="email"
value={email}
onChange={(e) => 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"
/>
<button
type="submit"
disabled={
status === "loading" || status === "success" || !captchaToken
}
className="rounded-lg bg-blue-600 px-6 py-3 text-base font-semibold text-white shadow-sm transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60">
{status === "loading" ? "..." : copy.cta}
</button>
</div>

<div className="flex justify-center">
<Turnstile
ref={turnstileRef}
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? ""}
onSuccess={(token) => setCaptchaToken(token)}
onExpire={() => setCaptchaToken(null)}
onError={() => setCaptchaToken(null)}
/>
</div>
</form>

{status === "success" && (
<p className="mt-4 text-sm text-green-700">{copy.success}</p>
)}
{status === "error" && (
<p className="mt-4 text-sm text-red-400">{copy.error}</p>
)}
{status === "captcha-error" && (
<p className="mt-4 text-sm text-red-400">
{copy.captchaError}
</p>
)}

<p className="mt-4 text-sm text-gray-400">{copy.disclaimer}</p>
</div>
</section>
);
}
2 changes: 2 additions & 0 deletions app/[lang]/(hyperjump)/services/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -107,6 +108,7 @@ export default async function ServiceDetail({ params }: ServiceDetailProps) {
<HowItWorks lang={lang} service={service} />
<WhatYouGet lang={lang} service={service} />
<WhyUs lang={lang} service={service} />
{slug === ServiceSlug.InferenceAI && <NewsletterSection lang={lang} />}
<Faqs lang={lang} service={service} />
<CaseStudies caseStudies={service.caseStudies} lang={lang} />
<CallToAction lang={lang} service={service} />
Expand Down
82 changes: 82 additions & 0 deletions app/api/subscribe/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from "next/server";

export async function POST(req: NextRequest) {
const body = await req.json();
const { email, language, turnstileToken } = body;

// Basic validation server-side
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!email || !emailRegex.test(email.trim())) {
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";

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 });
}
4 changes: 3 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions locales/en/ai.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,21 @@
"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": "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",
"languageDe": "Deutsch"
},
"faq": {
"heading": "Frequently asked questions",
"desc": "Everything you need to go from concept to fully deployed Al agent-done for you, end to end.",
Expand Down
Loading
Loading