diff --git a/next.config.ts b/next.config.ts index cb651cd..9bd2d38 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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; diff --git a/src/app/api/contact/route.ts b/src/app/api/contact/route.ts index 3ecf88e..7060e13 100644 --- a/src/app/api/contact/route.ts +++ b/src/app/api/contact/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { formPost } from "@/lib/api"; type ContactPayload = { name: string; @@ -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 } ); } } diff --git a/src/app/globals.css b/src/app/globals.css index e961d6b..0442867 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; @@ -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; @@ -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"] { @@ -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 { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4866ef6..ba5590e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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: @@ -52,7 +80,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - +