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 (
+
+ {block.items.map((item) => (
+ - {item}
+ ))}
+
+ );
+ }
+
+ 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 (
-
- {normalized.items.map((item) => (
- - {item}
- ))}
-
- );
- }
- 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 (
-
+
) : (
-
+
+
+
)}
{caption ? {caption} : null}
diff --git a/src/components/case/case-media.module.css b/src/components/case/case-media.module.css
index 3b608f6..19941f1 100644
--- a/src/components/case/case-media.module.css
+++ b/src/components/case/case-media.module.css
@@ -33,6 +33,11 @@
background: var(--color-surface);
}
+.imageWrapper {
+ position: relative;
+ width: 100%;
+}
+
.image {
width: 100%;
height: auto;
From a2c5ca51aa60daa5ddcf83dcdfb2d658a6932ec7 Mon Sep 17 00:00:00 2001
From: Ultraivanov <33052194+Ultraivanov@users.noreply.github.com>
Date: Fri, 10 Apr 2026 00:56:36 -0400
Subject: [PATCH 04/10] UI: remove phone media variant and enhance figma iframe
scaling
---
src/components/case/CaseMedia.tsx | 27 ++++++++++++++++++-----
src/components/case/case-media.module.css | 11 ---------
src/content/cases.ts | 4 ++--
src/lib/content.ts | 4 ++--
4 files changed, 25 insertions(+), 21 deletions(-)
diff --git a/src/components/case/CaseMedia.tsx b/src/components/case/CaseMedia.tsx
index efd3e0b..3a8d226 100644
--- a/src/components/case/CaseMedia.tsx
+++ b/src/components/case/CaseMedia.tsx
@@ -5,12 +5,30 @@ type CaseMediaProps = {
src: string;
alt: string;
caption?: string;
- variant?: "phone" | "desktop" | "diagram";
+ variant?: "desktop" | "diagram";
};
const isEmbedSource = (src: string) =>
src.startsWith("https://www.figma.com/embed");
+/**
+ * Enhance Figma embed URL with scaling and presentation parameters
+ */
+const getEnhancedFigmaUrl = (src: string) => {
+ if (!isEmbedSource(src)) return src;
+
+ try {
+ const url = new URL(src);
+ // Use 'scaling=scale-down-to-fit' for scaling content
+ url.searchParams.set("scaling", "scale-down-to-fit");
+ // 'hide-ui=1' for a cleaner presentation mode
+ url.searchParams.set("hide-ui", "1");
+ return url.toString();
+ } catch (e) {
+ return src;
+ }
+};
+
export default function CaseMedia({
src,
alt,
@@ -20,10 +38,6 @@ export default function CaseMedia({
const frameClasses = [styles.frame];
const embedClasses = [styles.embed];
- if (variant === "phone") {
- frameClasses.push(styles.framePhone);
- embedClasses.push(styles.embedPhone);
- }
if (variant === "desktop") {
frameClasses.push(styles.frameDesktop);
embedClasses.push(styles.embedDesktop);
@@ -34,6 +48,7 @@ export default function CaseMedia({
}
const isEmbed = isEmbedSource(src);
+ const finalSrc = isEmbed ? getEnhancedFigmaUrl(src) : src;
return (
@@ -42,7 +57,7 @@ export default function CaseMedia({
diff --git a/src/components/case/case-media.module.css b/src/components/case/case-media.module.css
index 19941f1..b1159c7 100644
--- a/src/components/case/case-media.module.css
+++ b/src/components/case/case-media.module.css
@@ -13,12 +13,6 @@
background: #0b0b0b;
}
-.framePhone {
- width: min(360px, 100%);
- border-radius: 28px;
- padding: var(--space-3);
-}
-
.frameDesktop {
width: 100%;
max-width: 960px;
@@ -59,11 +53,6 @@
display: block;
}
-.embedPhone {
- aspect-ratio: 9 / 19.5;
- border-radius: 24px;
-}
-
.embedDesktop {
aspect-ratio: 16 / 10;
}
diff --git a/src/content/cases.ts b/src/content/cases.ts
index e31b222..fdb7311 100644
--- a/src/content/cases.ts
+++ b/src/content/cases.ts
@@ -10,7 +10,7 @@ export type CaseSectionContent =
src: string;
alt: string;
caption?: string;
- variant?: "phone" | "desktop" | "diagram";
+ variant?: "desktop" | "diagram";
}
| { discriminant: "paragraph"; value: { text: string } }
| { discriminant: "list"; value: { items: string[] } }
@@ -21,7 +21,7 @@ export type CaseSectionContent =
src: string;
alt: string;
caption?: string;
- variant?: "phone" | "desktop" | "diagram";
+ variant?: "desktop" | "diagram";
};
};
diff --git a/src/lib/content.ts b/src/lib/content.ts
index f5ba6a6..79c94e7 100644
--- a/src/lib/content.ts
+++ b/src/lib/content.ts
@@ -12,7 +12,7 @@ export type NormalizedCaseSectionContent =
src: string;
alt: string;
caption?: string;
- variant?: "phone" | "desktop" | "diagram";
+ variant?: "desktop" | "diagram";
};
/**
@@ -71,7 +71,7 @@ export const isMediaBlock = (
src: string;
alt: string;
caption?: string;
- variant?: "phone" | "desktop" | "diagram";
+ variant?: "desktop" | "diagram";
} => {
return block.type === "media" && "src" in block && "alt" in block;
};
From 8a50d467382d3f88988af48428f409e0569af92f Mon Sep 17 00:00:00 2001
From: Ultraivanov <33052194+Ultraivanov@users.noreply.github.com>
Date: Fri, 10 Apr 2026 01:00:20 -0400
Subject: [PATCH 05/10] Content: replace phone variant with desktop for proper
scaling
---
src/content/cases/travel-booking-platform.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/content/cases/travel-booking-platform.json b/src/content/cases/travel-booking-platform.json
index 694a607..e50b295 100644
--- a/src/content/cases/travel-booking-platform.json
+++ b/src/content/cases/travel-booking-platform.json
@@ -334,7 +334,7 @@
"src": "https://www.figma.com/embed?embed_host=share&url=https%3A%2F%2Fwww.figma.com%2Fdesign%2FPZmx9cxqPNLG9btTp50gth%2FMy-Perfect-greek-vacation%3Fnode-id%3D2378-24635",
"alt": "Mobile booking UI mockup",
"caption": "Mobile layout with the same decision-first structure translated into a single-column flow.",
- "variant": "phone"
+ "variant": "desktop"
}
}
]
From f3341fb9c9aeca9bd732191ba45db3d6abbd2a06 Mon Sep 17 00:00:00 2001
From: Ultraivanov <33052194+Ultraivanov@users.noreply.github.com>
Date: Fri, 10 Apr 2026 01:01:42 -0400
Subject: [PATCH 06/10] UI: remove border and padding for desktop iFrames for
cleaner look
---
src/components/case/case-media.module.css | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/src/components/case/case-media.module.css b/src/components/case/case-media.module.css
index b1159c7..2f07286 100644
--- a/src/components/case/case-media.module.css
+++ b/src/components/case/case-media.module.css
@@ -16,8 +16,10 @@
.frameDesktop {
width: 100%;
max-width: 960px;
- border-radius: 20px;
- padding: var(--space-3);
+ border: none;
+ border-radius: 0;
+ padding: 0;
+ background: transparent;
}
.frameDiagram {
@@ -55,6 +57,9 @@
.embedDesktop {
aspect-ratio: 16 / 10;
+ border: none;
+ border-radius: 0;
+ background: transparent;
}
.embedDiagram {
From f8f20879ccf856b28f85bb56fd05892c14b366ba Mon Sep 17 00:00:00 2001
From: Ultraivanov <33052194+Ultraivanov@users.noreply.github.com>
Date: Fri, 10 Apr 2026 01:04:25 -0400
Subject: [PATCH 07/10] Fix: replace SVG hero title with live text using Plus
Jakarta Sans for theme adaptation
---
src/components/home/HomePage.tsx | 11 ++++------
src/components/home/home-page.module.css | 26 ++++++++++++++++--------
2 files changed, 21 insertions(+), 16 deletions(-)
diff --git a/src/components/home/HomePage.tsx b/src/components/home/HomePage.tsx
index e50e1ed..95e39b0 100644
--- a/src/components/home/HomePage.tsx
+++ b/src/components/home/HomePage.tsx
@@ -13,8 +13,6 @@ type HomePageProps = {
export default function HomePage({ data }: HomePageProps) {
const { theme } = useThemeMode();
- const titleSrc =
- theme === "light" ? "/home/hero-title-dark.svg" : data.hero.titleImageSrc;
const projects = data.pastProjects.items.slice(
0,
@@ -23,12 +21,11 @@ export default function HomePage({ data }: HomePageProps) {
return (
-
- Portfolio
-
-
-

+
+
+ PORTFOLIO
+
{data.hero.headline}
diff --git a/src/components/home/home-page.module.css b/src/components/home/home-page.module.css
index 6fd5039..932daed 100644
--- a/src/components/home/home-page.module.css
+++ b/src/components/home/home-page.module.css
@@ -24,17 +24,25 @@
padding: var(--space-4) 0;
}
-.heroTitle {
+.heroTitleContainer {
position: relative;
width: 100%;
- height: 157px;
+ padding: var(--space-2) 0;
}
-.heroTitle img {
- object-fit: contain;
- width: 100%;
- height: 100%;
- display: block;
+.heroTitleText {
+ font-family: var(--font-plus-jakarta), sans-serif;
+ font-weight: 800;
+ font-size: 157px;
+ line-height: 1;
+ letter-spacing: -0.04em;
+ color: var(--color-text-primary);
+ text-transform: uppercase;
+ margin: 0;
+ padding: 0;
+ opacity: 0.08;
+ pointer-events: none;
+ user-select: none;
}
.heroHeadline {
@@ -464,8 +472,8 @@
}
@media (max-width: 900px) {
- .heroTitle {
- height: 120px;
+ .heroTitleText {
+ font-size: 80px;
}
.skillsTools {
From 06a3a781119bd44cf454001a81514437e63f5517 Mon Sep 17 00:00:00 2001
From: Ultraivanov <33052194+Ultraivanov@users.noreply.github.com>
Date: Fri, 10 Apr 2026 01:10:48 -0400
Subject: [PATCH 08/10] Fix: resolve infinite theme update loop in
ClientProviders
---
src/components/ClientProviders.tsx | 22 ++++++++++++++++------
1 file changed, 16 insertions(+), 6 deletions(-)
diff --git a/src/components/ClientProviders.tsx b/src/components/ClientProviders.tsx
index ebe2c41..eb17fe5 100644
--- a/src/components/ClientProviders.tsx
+++ b/src/components/ClientProviders.tsx
@@ -1,7 +1,7 @@
"use client";
import type { ReactNode } from "react";
-import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useState } from "react";
+import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useState, useRef } from "react";
import { ThemeProvider } from "@gravity-ui/uikit";
import { usePathname } from "next/navigation";
import { jsonPost } from "@/lib/api";
@@ -69,10 +69,16 @@ export default function ClientProviders({ children, initialTheme = "dark" }: Cli
if (isKeystatic) return true;
return isThemeAuto();
});
+
+ // Use a ref to track the last saved theme to avoid infinite loops
+ const lastSavedTheme = useRef(null);
const saveTheme = async (newTheme: ThemeMode) => {
+ if (lastSavedTheme.current === newTheme) return;
+
window.localStorage.setItem("theme", newTheme);
try {
+ lastSavedTheme.current = newTheme;
await jsonPost("/api/theme", { theme: newTheme });
} catch (error) {
console.error("Failed to save theme:", error);
@@ -84,6 +90,7 @@ export default function ClientProviders({ children, initialTheme = "dark" }: Cli
}, [theme]);
useEffect(() => {
+ // Only save if it's different from what we started with
if (theme !== initialTheme) {
saveTheme(theme);
}
@@ -91,7 +98,10 @@ export default function ClientProviders({ children, initialTheme = "dark" }: Cli
const media = window.matchMedia?.("(prefers-color-scheme: light)");
const applyTheme = () => {
if (isAuto) {
- setTheme(media?.matches ? "light" : "dark");
+ const nextTheme = media?.matches ? "light" : "dark";
+ if (nextTheme !== theme) {
+ setTheme(nextTheme);
+ }
}
};
@@ -119,16 +129,16 @@ export default function ClientProviders({ children, initialTheme = "dark" }: Cli
const value = useMemo(
() => ({
theme,
- setTheme: async (next) => {
+ setTheme: (next) => {
setIsAuto(false);
setTheme(next);
- await saveTheme(next);
+ saveTheme(next);
},
- toggleTheme: async () => {
+ toggleTheme: () => {
const next = theme === "dark" ? "light" : "dark";
setIsAuto(false);
setTheme(next);
- await saveTheme(next);
+ saveTheme(next);
},
}),
[theme]
From 995a0fb4530ea45972193682e4ae78a071638ff1 Mon Sep 17 00:00:00 2001
From: Ultraivanov <33052194+Ultraivanov@users.noreply.github.com>
Date: Fri, 10 Apr 2026 01:12:24 -0400
Subject: [PATCH 09/10] Fix: ensure hero title visibility in both themes using
fixed RGBA colors
---
src/components/home/home-page.module.css | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/src/components/home/home-page.module.css b/src/components/home/home-page.module.css
index 932daed..e7f0dcf 100644
--- a/src/components/home/home-page.module.css
+++ b/src/components/home/home-page.module.css
@@ -36,15 +36,18 @@
font-size: 157px;
line-height: 1;
letter-spacing: -0.04em;
- color: var(--color-text-primary);
+ color: rgba(21, 20, 20, 0.12);
text-transform: uppercase;
margin: 0;
padding: 0;
- opacity: 0.08;
pointer-events: none;
user-select: none;
}
+:root[data-theme="light"] .heroTitleText {
+ color: rgba(21, 20, 20, 0.15);
+}
+
.heroHeadline {
font-size: 40px;
line-height: 48px;
From 186c2646f67ecb524f379d359e7695aa60cefb18 Mon Sep 17 00:00:00 2001
From: Ultraivanov <33052194+Ultraivanov@users.noreply.github.com>
Date: Fri, 10 Apr 2026 01:18:10 -0400
Subject: [PATCH 10/10] Fix: ensure hero title visibility via global CSS
variables for robust theme adaptation
---
src/app/globals.css | 2 ++
src/components/home/home-page.module.css | 6 +-----
2 files changed, 3 insertions(+), 5 deletions(-)
diff --git a/src/app/globals.css b/src/app/globals.css
index a8fc11d..0442867 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -8,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;
@@ -50,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/components/home/home-page.module.css b/src/components/home/home-page.module.css
index e7f0dcf..2a3a543 100644
--- a/src/components/home/home-page.module.css
+++ b/src/components/home/home-page.module.css
@@ -36,7 +36,7 @@
font-size: 157px;
line-height: 1;
letter-spacing: -0.04em;
- color: rgba(21, 20, 20, 0.12);
+ color: var(--color-hero-title);
text-transform: uppercase;
margin: 0;
padding: 0;
@@ -44,10 +44,6 @@
user-select: none;
}
-:root[data-theme="light"] .heroTitleText {
- color: rgba(21, 20, 20, 0.15);
-}
-
.heroHeadline {
font-size: 40px;
line-height: 48px;