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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions src/components/test-ppc-leads/HeroSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import axios from 'axios';
import Image from 'next/image';

import { getImage } from '@/lib/getImage';
import { HighlightText } from '@/components/components/HighlightText';
import { HeroSection as HeroSectionText } from '@/assets/types/test-ppc-leads';
import SignUpForm from '@/components/shared/SignUpForm';

interface HeroSectionProps {
textContent: HeroSectionText;
lang?: string;
}

const LANDING_NAME = 'test-ppc-leads';
const LEAD_REQUEST_TIMEOUT = 3000;

export const HeroSection = ({ textContent, lang }: HeroSectionProps): JSX.Element => {
const registerLead = async (email: string) => {
await axios.post(
'/api/ppc-leads',
{ email, locale: lang, landing: LANDING_NAME },
{ timeout: LEAD_REQUEST_TIMEOUT },
);
};

return (
<section
id="heroSection"
className="flex w-full flex-col items-center justify-center gap-8 overflow-hidden px-5 pb-10 pt-28 lg:mt-10 lg:flex-row lg:items-center lg:justify-between lg:gap-10 lg:px-10 lg:py-20 xl:px-32 3xl:px-80"
style={{ background: 'linear-gradient(180deg, #E5EFFF 0%, #FFFFFF 100%)' }}
>
<div className="flex w-full flex-col gap-8 lg:min-w-0 lg:max-w-[760px] lg:flex-1">
<div className="flex flex-col gap-3">
<h1 className="text-30 font-semibold leading-tight text-gray-100 lg:text-5xl">
<HighlightText text={textContent.title} />
</h1>
<HighlightText
text={textContent.subtitle}
className="text-base font-medium leading-tight text-gray-55 lg:text-xl"
/>
</div>

<div className="flex w-full flex-col rounded-16 border border-gray-10 bg-white p-6 shadow-soft lg:max-w-[480px] lg:p-8">
<SignUpForm textContent={textContent.SignUp} lang={lang} onSubmitEmail={registerLead} />
</div>
</div>

<div className="flex w-full min-w-0 flex-1 items-center justify-center lg:justify-end">
<Image
src={getImage('/images/home/NewDesign/mockup.png')}
alt="Internxt Drive web app"
width={580}
height={508}
quality={100}
priority
className="h-auto w-full max-w-[580px]"
/>
</div>
</section>
);
};

export default HeroSection;
112 changes: 112 additions & 0 deletions src/pages/api/ppc-leads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import axios, { AxiosError } from 'axios';
import { NextApiRequest, NextApiResponse } from 'next';

import { getClientIp } from '@/utils/get-client-ip';

const KLAVIYO_PRIVATE_API_KEY = process.env.KLAVIYO_PRIVATE_API_KEY;
const KLAVIYO_PPC_LEADS_LIST_ID = process.env.KLAVIYO_PPC_LEADS_LIST_ID;
const KLAVIYO_API_URL = 'https://a.klaviyo.com/api';
const KLAVIYO_API_REVISION = '2024-10-15';

const THROTTLE_TIME = 2 * 1000;
const MAX_EMAIL_LENGTH = 254;
const MAX_LANDING_LENGTH = 100;
const requestTimestamps = new Map<string, number>();

interface KlaviyoErrorResponse {
errors?: {
meta?: {
duplicate_profile_id?: string;
};
}[];
}

const klaviyoAxios = axios.create({
baseURL: KLAVIYO_API_URL,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: `Klaviyo-API-Key ${KLAVIYO_PRIVATE_API_KEY}`,
revision: KLAVIYO_API_REVISION,
},
});

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
return res.status(405).json({ status: 'Error' });
}

if (!KLAVIYO_PRIVATE_API_KEY || !KLAVIYO_PPC_LEADS_LIST_ID) {
return res.status(500).json({ status: 'Error', message: 'Klaviyo is not configured' });
}

const ip = getClientIp(req);
const now = Date.now();
const lastRequest = requestTimestamps.get(ip) || 0;

if (now - lastRequest < THROTTLE_TIME) {
return res.status(429).json({ status: 'Error' });
}

requestTimestamps.set(ip, now);

const { email, locale, landing } = req.body;

if (!email?.includes('@') || email.length > MAX_EMAIL_LENGTH) {
return res.status(400).json({ status: 'Error', message: 'Invalid email' });
}

if (landing && landing.length > MAX_LANDING_LENGTH) {
return res.status(400).json({ status: 'Error', message: 'Field too long' });
}

try {
await createPpcLead({ email, locale, landing: landing || '' });
res.status(200).json({ status: 'Success' });
} catch (error) {
const axiosError = error as AxiosError;
res.status(500).json({ status: 'Error', message: axiosError.message });
}
}

const profileAttributes = (email: string, locale: string, landing: string) => ({
email,
locale,
properties: { landing, origin_contact: 'PPC' },
});

async function getOrCreateProfile(email: string, locale: string, landing: string): Promise<string> {
try {
const response = await klaviyoAxios.post<{ data: { id: string } }>('/profiles/', {
data: {
type: 'profile',
attributes: profileAttributes(email, locale, landing),
},
});
return response.data.data.id;
} catch (error) {
const axiosError = error as AxiosError<KlaviyoErrorResponse>;
if (axiosError.response?.status === 409) {
const duplicateId = axiosError.response.data?.errors?.[0]?.meta?.duplicate_profile_id;
if (duplicateId) {
await klaviyoAxios.patch(`/profiles/${duplicateId}/`, {
data: {
type: 'profile',
id: duplicateId,
attributes: profileAttributes(email, locale, landing),
},
});
return duplicateId;
}
}
throw error;
}
}

async function createPpcLead({ email, locale, landing }: { email: string; locale: string; landing: string }) {
const profileId = await getOrCreateProfile(email, locale, landing);

await klaviyoAxios.post(`/lists/${KLAVIYO_PPC_LEADS_LIST_ID}/relationships/profiles/`, {
data: [{ type: 'profile', id: profileId }],
});
}
124 changes: 124 additions & 0 deletions src/pages/test-ppc-leads.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { GetStaticPropsContext } from 'next';

import Layout from '@/components/layout/Layout';
import Navbar from '@/components/layout/navbars/Navbar';
import { MinimalFooter } from '@/components/layout/footers/MinimalFooter';
import ReviewsSection from '@/components/home/ReviewsSection';
import TrustedSection from '@/components/home/TrustedSection';
import FAQSection from '@/components/shared/sections/FaqSection';
import CardsGridSection from '@/components/shared/sections/CardsGridSection';
import FloatingCtaSectionv2 from '@/components/shared/FloatingCtaSectionV2';
import HeroSection from '@/components/test-ppc-leads/HeroSection';
import ChoosePlanSection from '@/components/test-ppc-leads/ChoosePlanSection';
import usePricing from '@/hooks/usePricing';
import { getMinimumPrice } from '@/utils/priceHelper';
import { PromoCodeName } from '@/lib/types';
import { TestPpcLeadsText } from '@/assets/types/test-ppc-leads';
import { FooterText, MetatagsDescription, NavigationBarText } from '@/assets/types/layout/types';

interface TestPpcLeadsProps {
metatagsDescriptions: MetatagsDescription[];
navbarLang: NavigationBarText;
textContent: TestPpcLeadsText;
footerLang: FooterText;
lang: GetStaticPropsContext['locale'];
}

const TestPpcLeads = ({
metatagsDescriptions,
navbarLang,
textContent,
footerLang,
lang,
}: TestPpcLeadsProps): JSX.Element => {
const metatags = metatagsDescriptions.find((desc) => desc.id === 'test-ppc-leads');
const locale = lang as string;

const {
products,
loadingCards,
currency,
coupon: individualCoupon,
} = usePricing({
couponCode: PromoCodeName.META85,
});

const decimalDiscount = individualCoupon?.percentOff && 100 - individualCoupon.percentOff;
const minimumPrice = getMinimumPrice(products, decimalDiscount);

return (
<Layout
title={metatags?.title ?? ''}
description={metatags?.description ?? ''}
segmentName="Test PPC Leads"
lang={locale}
robots="noindex, follow"
>
<Navbar textContent={navbarLang} lang={locale} cta={['default']} fixed />

<HeroSection textContent={textContent.HeroSection} lang={locale} />

<ReviewsSection textContent={textContent.ReviewSection} />

<TrustedSection textContent={textContent.TrustedSection} bottomBar={false} />

<CardsGridSection textContent={textContent.FeaturesSection} bgColor="bg-neutral-17" />

<ChoosePlanSection
textContent={textContent.ChoosePlanSection}
currency={currency}
minimumPrice={minimumPrice}
loadingCards={loadingCards}
sectionDetails="bg-neutral-17 py-10 lg:py-20"
/>

<FAQSection
textContent={textContent.FaqSection}
needsH2
needsH3
bgGradient="linear-gradient(360deg, #FFFFFF 0%, #F4F8FF 100%)"
/>

<FloatingCtaSectionv2
textContent={textContent.CtaSection}
url="#heroSection"
customText={
<div className="flex flex-col gap-2 lg:w-auto">
<h2 className="text-2xl font-semibold leading-tight text-white lg:text-4xl">
{textContent.CtaSection.title}
</h2>
<p className="text-base font-normal leading-tight text-blue-20 lg:text-xl">
{textContent.CtaSection.description}
</p>
</div>
}
bgGradientContainerColor="linear-gradient(279.29deg, #0c4499 3.25%, #041733 78.35%)"
containerDetails="px-6 lg:flex-row lg:justify-between lg:px-10 lg:text-left"
bgPadding="py-10 lg:py-20"
/>

<MinimalFooter footerLang={footerLang.FooterSection} lang={locale} bgColor="bg-white" />
</Layout>
);
};

export function getStaticProps(ctx: GetStaticPropsContext) {
const lang = ctx.locale;

const metatagsDescriptions = require(`@/assets/lang/${lang}/metatags-descriptions.json`);
const textContent = require(`@/assets/lang/${lang}/test-ppc-leads.json`);
const navbarLang = require(`@/assets/lang/${lang}/navbar.json`);
const footerLang = require(`@/assets/lang/${lang}/footer.json`);

return {
props: {
lang,
metatagsDescriptions,
textContent,
navbarLang,
footerLang,
},
};
}

export default TestPpcLeads;
Loading