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
13 changes: 12 additions & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import type { NextConfig } from "next";

const nextConfig: NextConfig = {};
const nextConfig: NextConfig = {
images: {
formats: ["image/avif", "image/webp"],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
minimumCacheTTL: 60,
},
// Optimize for performance
compress: true,
poweredByHeader: false,
reactStrictMode: true,
};

export default nextConfig;
158 changes: 88 additions & 70 deletions src/app/api/contact/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { formPost } from "@/lib/api";

type ContactPayload = {
name: string;
Expand All @@ -13,111 +14,128 @@ const AIRTABLE_BASE_ID = process.env.AIRTABLE_BASE_ID;
const AIRTABLE_TABLE_NAME = process.env.AIRTABLE_TABLE_NAME || "Contacts";
const TURNSTILE_SECRET_KEY = process.env.TURNSTILE_SECRET_KEY;

/**
* Verify Turnstile captcha token
*/
const verifyCaptcha = async (token: string, remoteIp: string) => {
const verifyPayload = new URLSearchParams({
secret: TURNSTILE_SECRET_KEY!,
response: token,
remoteip: remoteIp,
});

const result = await formPost<{
success: boolean;
"error-codes"?: string[];
}>(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
verifyPayload
);

return result.success;
};

/**
* Save contact message to Airtable
*/
const saveToAirtable = async (payload: ContactPayload) => {
const response = await fetch(
`https://api.airtable.com/v0/${AIRTABLE_BASE_ID}/${encodeURIComponent(
AIRTABLE_TABLE_NAME
)}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${AIRTABLE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
records: [
{
fields: {
Name: payload.name,
Email: payload.email,
Company: payload.company ?? "",
Message: payload.message,
Page: payload.page ?? "",
SubmittedAt: new Date().toISOString(),
},
},
],
}),
}
);

if (!response.ok) {
const details = await response.text();
throw new Error(`Airtable error: ${details}`);
}
};

export async function POST(request: Request) {
try {
// Validate configuration
if (!AIRTABLE_API_KEY || !AIRTABLE_BASE_ID || !AIRTABLE_TABLE_NAME) {
return NextResponse.json(
{ error: "Airtable is not configured." },
{ status: 500 },
{ status: 500 }
);
}

if (!TURNSTILE_SECRET_KEY) {
return NextResponse.json(
{ error: "Captcha is not configured." },
{ status: 500 }
);
}

// Parse request
const payload = (await request.json()) as ContactPayload & {
captchaToken?: string;
};
const { name, email, message, company, page } = payload;
const captchaToken = payload.captchaToken;
const { name, email, message, company, page, captchaToken } = payload;

// Validate required fields
if (!name || !email || !message) {
return NextResponse.json(
{ error: "Missing required fields." },
{ status: 400 },
);
}

if (!TURNSTILE_SECRET_KEY) {
return NextResponse.json(
{ error: "Captcha is not configured." },
{ status: 500 },
{ status: 400 }
);
}

if (!captchaToken) {
return NextResponse.json(
{ error: "Captcha token missing." },
{ status: 400 },
{ status: 400 }
);
}

const verifyPayload = new URLSearchParams({
secret: TURNSTILE_SECRET_KEY,
response: captchaToken,
remoteip: request.headers.get("x-forwarded-for") ?? "",
});

const verifyResponse = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: verifyPayload.toString(),
},
);

const verifyResult = (await verifyResponse.json()) as {
success: boolean;
"error-codes"?: string[];
};
// Verify captcha
const remoteIp = request.headers.get("x-forwarded-for") ?? "";
const isCaptchaValid = await verifyCaptcha(captchaToken, remoteIp);

if (!verifyResult.success) {
if (!isCaptchaValid) {
return NextResponse.json(
{ error: "Captcha verification failed." },
{ status: 400 },
{ status: 400 }
);
}

const airtableResponse = await fetch(
`https://api.airtable.com/v0/${AIRTABLE_BASE_ID}/${encodeURIComponent(
AIRTABLE_TABLE_NAME,
)}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${AIRTABLE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
records: [
{
fields: {
Name: name,
Email: email,
Company: company ?? "",
Message: message,
Page: page ?? "",
SubmittedAt: new Date().toISOString(),
},
},
],
}),
},
);

if (!airtableResponse.ok) {
const details = await airtableResponse.text();
return NextResponse.json(
{ error: "Failed to store message.", details },
{ status: 502 },
);
}
// Save to Airtable
await saveToAirtable({
name,
email,
company,
message,
page,
});

return NextResponse.json({ ok: true });
} catch (error) {
console.error("Contact form error:", error);
return NextResponse.json(
{ error: "Unexpected server error." },
{ status: 500 },
{ status: 500 }
);
}
}
10 changes: 4 additions & 6 deletions src/app/globals.css
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
@import "@gravity-ui/uikit/styles/styles.css";
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;700;800&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Geist+Mono:wght@300&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap");

:root {
--color-bg: #151414;
Expand All @@ -12,6 +8,7 @@
--color-border-primary: #6d6d6d;
--color-border-subtle: #2f2f2f;
--color-border-subtle-soft: rgba(47, 47, 47, 0.9);
--color-hero-title: rgba(255, 250, 236, 0.1);
--grid-columns: 12;
--grid-margin: 60px;
--grid-gutter: 24px;
Expand Down Expand Up @@ -43,8 +40,8 @@
--radius-1: 8px;
--radius-2: 16px;
--radius-3: 24px;
--font-body: "IBM Plex Sans";
--font-display: "IBM Plex Sans";
--font-body: var(--font-ibm-plex);
--font-display: var(--font-ibm-plex);
}

:root[data-theme="light"] {
Expand All @@ -54,6 +51,7 @@
--color-border-primary: #c7c1b4;
--color-border-subtle: #e1dbcf;
--color-border-subtle-soft: rgba(225, 219, 207, 0.9);
--color-hero-title: rgba(21, 20, 20, 0.18);
}

html {
Expand Down
32 changes: 30 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,37 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { IBM_Plex_Sans, Plus_Jakarta_Sans, Geist_Mono, Roboto } from "next/font/google";
import ClientProviders from "@/components/ClientProviders";
import Layout from "@/components/layout/Layout";
import "./globals.css";

const ibmPlexSans = IBM_Plex_Sans({
subsets: ["latin"],
weight: ["300", "400", "500", "600", "700"],
variable: "--font-ibm-plex",
display: "swap",
});

const plusJakartaSans = Plus_Jakarta_Sans({
subsets: ["latin"],
weight: ["400", "700", "800"],
variable: "--font-plus-jakarta",
display: "swap",
});

const geistMono = Geist_Mono({
subsets: ["latin"],
weight: ["300"],
variable: "--font-geist-mono",
display: "swap",
});

const roboto = Roboto({
subsets: ["latin"],
weight: ["400", "700"],
variable: "--font-roboto",
display: "swap",
});

export const metadata: Metadata = {
title: "Dima Ginzburg — Product Designer",
description:
Expand Down Expand Up @@ -52,7 +80,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" className={`${ibmPlexSans.variable} ${plusJakartaSans.variable} ${geistMono.variable} ${roboto.variable}`}>
<head>
<script
async
Expand Down
Loading