From a3e523414a8bee56b54c20c8cf47f73d0d36c6e8 Mon Sep 17 00:00:00 2001 From: Ultraivanov <33052194+Ultraivanov@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:36:40 -0400 Subject: [PATCH 01/10] Refactor: cleanup duplicated requests and simplify code --- src/app/api/contact/route.ts | 158 ++++++++++++++----------- src/app/layout.tsx | 1 - src/app/work/[slug]/page.tsx | 119 ++++++++++--------- src/components/ClientProviders.tsx | 73 +++++++----- src/components/contact/ContactForm.tsx | 60 +++++----- src/lib/api.ts | 48 ++++++++ src/lib/content.ts | 77 ++++++++++++ src/lib/cookies.ts | 26 ++++ 8 files changed, 373 insertions(+), 189 deletions(-) create mode 100644 src/lib/api.ts create mode 100644 src/lib/content.ts create mode 100644 src/lib/cookies.ts 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/layout.tsx b/src/app/layout.tsx index 4866ef6..c2994c6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,4 @@ import type { Metadata } from "next"; -import { cookies } from "next/headers"; import ClientProviders from "@/components/ClientProviders"; import Layout from "@/components/layout/Layout"; import "./globals.css"; diff --git a/src/app/work/[slug]/page.tsx b/src/app/work/[slug]/page.tsx index de41c2b..cf0677e 100644 --- a/src/app/work/[slug]/page.tsx +++ b/src/app/work/[slug]/page.tsx @@ -4,11 +4,70 @@ import CaseFacts from "@/components/case/CaseFacts"; import CaseMedia from "@/components/case/CaseMedia"; import CaseSection from "@/components/case/CaseSection"; import { cases, getCaseBySlug } from "@/content/cases"; +import { + normalizeBlockContent, + isParagraphBlock, + isListBlock, + isLinkBlock, + isMediaBlock, + NormalizedCaseSectionContent, +} from "@/lib/content"; type CasePageProps = { params: { slug: string }; }; +/** + * Render a single block based on its type + */ +const renderBlock = (block: NormalizedCaseSectionContent, index: number) => { + const blockKey = `block-${index}`; + + if (isParagraphBlock(block)) { + return ( +

{block.text}

+ ); + } + + if (isListBlock(block)) { + return ( + + ); + } + + if (isLinkBlock(block)) { + return ( + + {block.label} + + ); + } + + if (isMediaBlock(block)) { + return ( + + ); + } + + return null; +}; + export default function CasePage({ params }: CasePageProps) { const { slug } = params; const caseStudy = getCaseBySlug(slug) ?? cases[0]; @@ -21,65 +80,7 @@ export default function CasePage({ params }: CasePageProps) { {caseStudy.sections.map((section) => ( {section.blocks?.length - ? section.blocks.map((block, index) => { - const normalized = - "discriminant" in block - ? ({ type: block.discriminant, ...block.value } as const) - : block; - if (normalized.type === "paragraph" && "text" in normalized) { - return ( -

- {normalized.text} -

- ); - } - if (normalized.type === "list" && "items" in normalized) { - return ( - - ); - } - if ( - normalized.type === "link" && - "href" in normalized && - "label" in normalized - ) { - return ( - - {normalized.label} - - ); - } - if ( - normalized.type === "media" && - "src" in normalized && - "alt" in normalized - ) { - return ( - - ); - } - return null; - }) + ? section.blocks.map((block, index) => renderBlock(normalizeBlockContent(block), index)) : null}
))} diff --git a/src/components/ClientProviders.tsx b/src/components/ClientProviders.tsx index 25819ea..ebe2c41 100644 --- a/src/components/ClientProviders.tsx +++ b/src/components/ClientProviders.tsx @@ -4,6 +4,8 @@ import type { ReactNode } from "react"; import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useState } from "react"; import { ThemeProvider } from "@gravity-ui/uikit"; import { usePathname } from "next/navigation"; +import { jsonPost } from "@/lib/api"; +import { getThemeFromCookies } from "@/lib/cookies"; type ThemeMode = "dark" | "light"; type ThemeContextValue = { @@ -27,45 +29,54 @@ type ClientProvidersProps = { initialTheme?: ThemeMode; }; +/** + * Get the initial theme from available sources + */ +const getInitialTheme = (initialTheme: ThemeMode): ThemeMode => { + if (typeof window === "undefined") return initialTheme; + + // Check cookie first + const themeFromCookie = getThemeFromCookies(document.cookie); + if (themeFromCookie) return themeFromCookie; + + // Then localStorage + const stored = window.localStorage.getItem("theme"); + if (stored === "light" || stored === "dark") return stored; + + // Finally, check system preference + const media = window.matchMedia?.("(prefers-color-scheme: light)"); + return media?.matches ? "light" : "dark"; +}; + +/** + * Check if theme is set to auto (not explicitly configured) + */ +const isThemeAuto = (): boolean => { + if (typeof window === "undefined") return true; + + const themeFromCookie = getThemeFromCookies(document.cookie); + if (themeFromCookie) return false; + + const stored = window.localStorage.getItem("theme"); + return !(stored === "light" || stored === "dark"); +}; + export default function ClientProviders({ children, initialTheme = "dark" }: ClientProvidersProps) { const pathname = usePathname(); const isKeystatic = pathname?.startsWith("/keystatic"); - const [theme, setTheme] = useState(() => { - if (typeof window === "undefined") return initialTheme; - // First check cookie - const cookies = document.cookie.split("; ").reduce((acc, cookie) => { - const [key, value] = cookie.split("="); - acc[key] = value; - return acc; - }, {} as Record); - if (cookies.theme === "light" || cookies.theme === "dark") return cookies.theme; - // Then localStorage - const stored = window.localStorage.getItem("theme"); - if (stored === "light" || stored === "dark") return stored; - // Then media - const media = window.matchMedia?.("(prefers-color-scheme: light)"); - return media?.matches ? "light" : "dark"; - }); + const [theme, setTheme] = useState(() => getInitialTheme(initialTheme)); const [isAuto, setIsAuto] = useState(() => { if (isKeystatic) return true; - if (typeof window === "undefined") return true; - const cookies = document.cookie.split("; ").reduce((acc, cookie) => { - const [key, value] = cookie.split("="); - acc[key] = value; - return acc; - }, {} as Record); - if (cookies.theme === "light" || cookies.theme === "dark") return false; - const stored = window.localStorage.getItem("theme"); - return !(stored === "light" || stored === "dark"); + return isThemeAuto(); }); const saveTheme = async (newTheme: ThemeMode) => { window.localStorage.setItem("theme", newTheme); - await fetch("/api/theme", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ theme: newTheme }), - }); + try { + await jsonPost("/api/theme", { theme: newTheme }); + } catch (error) { + console.error("Failed to save theme:", error); + } }; useLayoutEffect(() => { @@ -120,7 +131,7 @@ export default function ClientProviders({ children, initialTheme = "dark" }: Cli await saveTheme(next); }, }), - [theme], + [theme] ); return ( diff --git a/src/components/contact/ContactForm.tsx b/src/components/contact/ContactForm.tsx index 41b89f5..a043294 100644 --- a/src/components/contact/ContactForm.tsx +++ b/src/components/contact/ContactForm.tsx @@ -3,6 +3,7 @@ import { useCallback, useState } from "react"; import { Button, TextArea, TextInput } from "@gravity-ui/uikit"; import { trackEvent } from "@/lib/analytics"; +import { jsonPost } from "@/lib/api"; import TurnstileWidget from "./TurnstileWidget"; import styles from "./contact-form.module.css"; @@ -12,6 +13,15 @@ type ContactFormProps = { type FormStatus = "idle" | "sending" | "success" | "error"; +type ContactPayload = { + name: string; + company: string; + email: string; + message: string; + page: string; + captchaToken: string; +}; + export default function ContactForm({ email }: ContactFormProps) { const [name, setName] = useState(""); const [company, setCompany] = useState(""); @@ -24,44 +34,42 @@ export default function ContactForm({ email }: ContactFormProps) { const [status, setStatus] = useState("idle"); const [error, setError] = useState(""); const turnstileSiteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || ""; - const handleVerify = useCallback((token: string) => { - setCaptchaToken(token); - }, []); - const handleExpire = useCallback(() => { - setCaptchaToken(""); - }, []); - - const handleError = useCallback(() => { - setCaptchaToken(""); + const handleCaptchaStateChange = useCallback((token: string | null) => { + setCaptchaToken(token || ""); }, []); const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); setError(""); + // Honeypot check if (honeypot) { return; } + // Validate required fields if (!name || !contactEmail || !message) { setStatus("error"); setError("Please fill in name, email, and message."); return; } + // Validate captcha configuration if (!turnstileSiteKey) { setStatus("error"); setError("Captcha is not configured."); return; } + // Validate captcha token if (!captchaToken) { setStatus("error"); setError("Please complete the captcha."); return; } + // Validate consent if (!consent) { setStatus("error"); setError("Please confirm you agree to the Privacy Policy."); @@ -70,25 +78,21 @@ export default function ContactForm({ email }: ContactFormProps) { setStatus("sending"); try { - const response = await fetch("/api/contact", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name, - company, - email: contactEmail, - message, - page: window.location.href, - captchaToken, - }), - }); - - if (!response.ok) { - throw new Error("Request failed"); - } + const payload: ContactPayload = { + name, + company, + email: contactEmail, + message, + page: window.location.href, + captchaToken, + }; + + await jsonPost("/api/contact", payload); setStatus("success"); trackEvent("contact_submit", { status: "success" }); + + // Reset form setName(""); setCompany(""); setMessage(""); @@ -201,9 +205,9 @@ export default function ContactForm({ email }: ContactFormProps) { handleCaptchaStateChange(token)} + onExpire={() => handleCaptchaStateChange(null)} + onError={() => handleCaptchaStateChange(null)} /> ) : null} diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..6fa25bd --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,48 @@ +/** + * Standard fetch options for JSON requests + */ +const JSON_HEADERS = { + "Content-Type": "application/json", +} as const; + +/** + * Make a JSON POST request + */ +export const jsonPost = async ( + url: string, + body: unknown +): Promise => { + const response = await fetch(url, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`API request failed: ${response.statusText}`); + } + + return response.json() as Promise; +}; + +/** + * Make a JSON POST request with form-encoded body + */ +export const formPost = async ( + url: string, + body: URLSearchParams +): Promise => { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: body.toString(), + }); + + if (!response.ok) { + throw new Error(`API request failed: ${response.statusText}`); + } + + return response.json() as Promise; +}; diff --git a/src/lib/content.ts b/src/lib/content.ts new file mode 100644 index 0000000..f5ba6a6 --- /dev/null +++ b/src/lib/content.ts @@ -0,0 +1,77 @@ +import type { CaseSectionContent } from "@/content/cases"; + +/** + * Normalized content structure + */ +export type NormalizedCaseSectionContent = + | { type: "paragraph"; text: string } + | { type: "list"; items: string[] } + | { type: "link"; label: string; href: string } + | { + type: "media"; + src: string; + alt: string; + caption?: string; + variant?: "phone" | "desktop" | "diagram"; + }; + +/** + * Normalize legacy discriminant format to modern type format + */ +export const normalizeBlockContent = ( + block: CaseSectionContent +): NormalizedCaseSectionContent => { + if ("discriminant" in block) { + return { + type: block.discriminant as "paragraph" | "list" | "link" | "media", + ...block.value, + } as NormalizedCaseSectionContent; + } + return block as NormalizedCaseSectionContent; +}; + +/** + * Type guard for paragraph block + */ +export const isParagraphBlock = ( + block: NormalizedCaseSectionContent +): block is { type: "paragraph"; text: string } => { + return block.type === "paragraph" && "text" in block; +}; + +/** + * Type guard for list block + */ +export const isListBlock = ( + block: NormalizedCaseSectionContent +): block is { type: "list"; items: string[] } => { + return block.type === "list" && "items" in block; +}; + +/** + * Type guard for link block + */ +export const isLinkBlock = ( + block: NormalizedCaseSectionContent +): block is { type: "link"; label: string; href: string } => { + return ( + block.type === "link" && + "href" in block && + "label" in block + ); +}; + +/** + * Type guard for media block + */ +export const isMediaBlock = ( + block: NormalizedCaseSectionContent +): block is { + type: "media"; + src: string; + alt: string; + caption?: string; + variant?: "phone" | "desktop" | "diagram"; +} => { + return block.type === "media" && "src" in block && "alt" in block; +}; diff --git a/src/lib/cookies.ts b/src/lib/cookies.ts new file mode 100644 index 0000000..413ed0a --- /dev/null +++ b/src/lib/cookies.ts @@ -0,0 +1,26 @@ +/** + * Parse cookie string into key-value pairs + */ +export const parseCookies = (cookieString: string): Record => { + return cookieString.split("; ").reduce( + (acc, cookie) => { + const [key, value] = cookie.split("="); + if (key && value) { + acc[key] = value; + } + return acc; + }, + {} as Record + ); +}; + +/** + * Extract theme from cookies + */ +export const getThemeFromCookies = ( + cookieString: string +): "light" | "dark" | null => { + const cookies = parseCookies(cookieString); + const theme = cookies.theme; + return theme === "light" || theme === "dark" ? theme : null; +}; From 3850c98a79e56800233b8d217761a5b7f3bca4c7 Mon Sep 17 00:00:00 2001 From: Ultraivanov <33052194+Ultraivanov@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:42:28 -0400 Subject: [PATCH 02/10] Content: sync home project images with case covers --- src/content/home.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content/home.json b/src/content/home.json index 3e4e954..7291e8e 100644 --- a/src/content/home.json +++ b/src/content/home.json @@ -120,7 +120,7 @@ { "title": "Russian Railways", "subtitle": "Resort booking case", - "imageSrc": "/home/project-beta.png", + "imageSrc": "/cases/rzd/cover.png", "imageAlt": "Project preview", "href": "/work/railway-booking-flow" }, From 531cb730865203e02b250d75932ed61b611dd599 Mon Sep 17 00:00:00 2001 From: Ultraivanov <33052194+Ultraivanov@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:46:06 -0400 Subject: [PATCH 03/10] Performance: optimize images, fonts and next config --- next.config.ts | 13 +++++++++- src/app/globals.css | 8 ++---- src/app/layout.tsx | 31 ++++++++++++++++++++++- src/components/case/CaseMedia.tsx | 13 +++++++++- src/components/case/case-media.module.css | 5 ++++ 5 files changed, 61 insertions(+), 9 deletions(-) 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/globals.css b/src/app/globals.css index e961d6b..a8fc11d 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; @@ -43,8 +39,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"] { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c2994c6..ba5590e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,8 +1,37 @@ import type { Metadata } from "next"; +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: @@ -51,7 +80,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - +