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
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ CONTACT_FORM_EMAIL_FROM=''
CONTACT_FORM_EMAIL_TO=''
RESEND_API_KEY=''
VERCEL_URL='localhost:3000'
VERCEL_ENV='development'
VERCEL_ENV='development'
NEXT_PUBLIC_RECAPTCHA_SITE_KEY="GTM-XXXXXXX"
RECAPTCHA_SECRET_KEY=''
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@vercel/speed-insights": "^1.1.0",
"cookies-next": "^5.0.2",
"next": "^15.1.3",
"next-recaptcha-v3": "^1.5.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
Expand Down
37 changes: 20 additions & 17 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import PageContainer from '@/components/PageContainer';
import TrainProvider from '@/app/train-provider';
import ThemeStylesProviderComponent from '@/app/theme-styles-provider';
import baseUrl from '@/lib/urlHelpers';
import { ReCaptchaProvider } from 'next-recaptcha-v3';

export const metadata: Metadata = {
title: 'Brett Cimbalik',
Expand All @@ -33,26 +34,28 @@ export default async function RootLayout({ children }: { children: React.ReactNo

const cookies = await headerCookies();

const themeCookieValue = cookies?.get('theme-setting')?.value as 'dark' | 'light' ?? 'dark';
const themeCookieValue = (cookies?.get('theme-setting')?.value as 'dark' | 'light') ?? 'dark';

return (
<html lang="en">
<StyledComponentsRegistry>
<ThemeStylesProviderComponent themeFromCookie={themeCookieValue}>
<TrainProvider>
<ThemeProvider>
<PageContainer>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
{children}
<SpeedInsights />
</PageContainer>
</ThemeProvider>
</TrainProvider>
</ThemeStylesProviderComponent>
</StyledComponentsRegistry>
<ReCaptchaProvider>
<StyledComponentsRegistry>
<ThemeStylesProviderComponent themeFromCookie={themeCookieValue}>
<TrainProvider>
<ThemeProvider>
<PageContainer>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
{children}
<SpeedInsights />
</PageContainer>
</ThemeProvider>
</TrainProvider>
</ThemeStylesProviderComponent>
</StyledComponentsRegistry>
</ReCaptchaProvider>
</html>
);
}
9 changes: 8 additions & 1 deletion src/components/ContactForm/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import FormContent from './FormContent';
import { formSchema } from './validation';
import { ReCaptcha } from 'next-recaptcha-v3';

export interface FormValues {
subject: string;
Expand Down Expand Up @@ -53,7 +54,12 @@ const ContactForm = () => {
formState: { errors, isValid },
setError
} = useForm<ContactFormFields>({ mode: 'all', resolver: zodResolver(formSchema) });
const [state, formAction] = useActionState<State, FormData>(contactFormSubmit, null);
const [token, setToken] = useState<string>();
const submitContactWithCaptchaToken = contactFormSubmit.bind(null, {
captchaToken: token
});

const [state, formAction] = useActionState<State, FormData>(submitContactWithCaptchaToken, null);
const [isSubmitSuccessful, setIsSubmitSuccessful] = useState(false);

useEffect(() => {
Expand All @@ -80,6 +86,7 @@ const ContactForm = () => {
) : (
<Form action={formAction}>
<FormContent register={register} isValid={isValid} errors={errors} />
<ReCaptcha onValidate={setToken} action="contact_form_submit" />
<StateText>{state?.message}</StateText>
</Form>
)}
Expand Down
41 changes: 41 additions & 0 deletions src/lib/verifyReCapthcha.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
type CaptchaData = {
success: true;
challenge_ts: string;
hostname: string;
score: number;
action: string;
} | {
success: false;
"error-codes": string[];
};


const verifyCaptchaToken = async ({ token }: { token: string }) => {
const secretKey = process.env.RECAPTCHA_SECRET_KEY;

// Google's siteVerify URL - https://developers.google.com/recaptcha/docs/verify
const siteVerifyUrl = "https://www.google.com/recaptcha/api/siteverify";

if (!secretKey) {
throw new Error('No secret key found.');
}

// https://www.google.com/recaptcha/api/siteverify
const url = new URL(siteVerifyUrl);
url.searchParams.append('secret', secretKey);
url.searchParams.append('response', token);

const res = await fetch(url, {
method: 'POST'
});

const captchaData: CaptchaData = await res.json();

if (!res.ok) {
return null;
}

return captchaData;
};

export default verifyCaptchaToken;
26 changes: 25 additions & 1 deletion src/server_actions/contactFormSubmit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ZodError } from 'zod';
import { Resend } from 'resend';
import ContactFormEmail from '@/components/ContactFormEmail';
import React from 'react';
import verifyCaptchaToken from '@/lib/verifyReCapthcha';

const resend = new Resend(process.env.RESEND_API_KEY);

Expand All @@ -23,8 +24,30 @@ export type State =
}
| null;

const contactFormSubmit = async (prevState: State | null, data: FormData): Promise<State> => {
const contactFormSubmit = async (extras: { captchaToken?: string }, prevState: State | null, data: FormData): Promise<State> => {
console.log('In contactFormSubmit.ts, this is data: ', data);
console.log('In contactFormSubmit.ts, this is captchaToken: ', extras?.captchaToken);

if (!extras?.captchaToken) {
// If no token, throw error or return out:
throw new Error(`No Captcha token provided.`);
}

// Verify the token:
const captchaData = await verifyCaptchaToken({ token: extras?.captchaToken});
console.log('In contactFormSubmit.ts, this is captchaData: ', captchaData);

if (!captchaData) {
throw('No Captcha Data');
}

if (!captchaData.success || captchaData.score < 0.5) {
// let err = '';
// !captchaData.success ? err = captchaData['error-codes'].join(', ') : 'Captcha failed';

throw new Error('Captcha failed.')
}

// Outer try catch only works for errors
// thrown by the fetch function itself:
try {
Expand Down Expand Up @@ -59,6 +82,7 @@ const contactFormSubmit = async (prevState: State | null, data: FormData): Promi
};
} catch (e) {
// In case of a ZodError (caused by our validation) we're adding issues to our response:
console.error(e);
if (e instanceof ZodError) {
return {
status: 'error',
Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3418,6 +3418,11 @@ natural-compare@^1.4.0:
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==

next-recaptcha-v3@^1.5.2:
version "1.5.2"
resolved "https://registry.yarnpkg.com/next-recaptcha-v3/-/next-recaptcha-v3-1.5.2.tgz#bad67f1add6a4614d88496f4fc4d8449448763b1"
integrity sha512-CTFQvyWWQLrIuI++NhOFeQKdELUFvTDGfCty+bz6hxU8frS59ycl4TInvWhCn+9eKjAqV6LqQZZGHdwla8t8GQ==

next@^15.1.3:
version "15.1.6"
resolved "https://registry.yarnpkg.com/next/-/next-15.1.6.tgz#ce22fd0a8f36da1fc4aba86e3ec7e98eb248c555"
Expand Down