pashavin.ru: от визитки до AI-портфолио

Когда у тебя есть домен и набор проектов, рано или поздно встаёт вопрос: а как вообще показать всё это миру? Не просто список ссылок на GitHub, а нормальное портфолио, которое продаёт. Именно так и появился pashavin.ru — персональный сайт, который мы строили итерациями, превращая простую визитку в полноценную AI-powered витрину.
В этой статье расскажу, через что мы прошли: от настройки Telegram-нотификаций для формы обратной связи до амбициозного редизайна с Three.js, GSAP и Lenis. Будет много кода, архитектурных решений и честного разговора о том, что пошло не по плану.
Стек и общая архитектура
Проект построен на Next.js + React 19 + TypeScript + Tailwind CSS v4. Принципиальное решение с самого начала — никакой базы данных. Все данные хранятся в TypeScript-файлах в папке data/. Это даёт несколько преимуществ:
- Мгновенный билд без внешних зависимостей
- Типобезопасность прямо в данных
- Легко обновлять через pull request
- Нет проблем с миграциями
Деплой организован через Dokploy — self-hosted PaaS на базе Docker, который ловит webhook от GitHub и автоматически деплоит при каждом пуше в master.
pashavin.ru/
├── app/
│ ├── page.tsx # главная (10 секций)
│ ├── projects/[slug]/ # детальные страницы проектов
│ └── api/contact/ # эндпоинт формы → Telegram
├── components/
│ ├── sections/ # 10 секций главной
│ └── ui/ # shadcn компоненты
└── data/
├── projects.ts # данные проектов
└── personal.ts # личные данные, экспертные темы
Форма обратной связи: Telegram как inbox
Первая практическая задача — сделать так, чтобы заявки с сайта приходили напрямую в Telegram. Никаких email-рассылок, никаких CRM с уведомлениями на почту. Просто сообщение в личку — быстро, удобно, не теряется.
Архитектура простая: форма на клиенте → POST на /api/contact → Telegram Bot API → сообщение в личный чат.
В Dokploy добавили два env-переменных: токен бота и chat ID. Сами значения не привожу по очевидным причинам, но принцип такой:
// app/api/contact/route.ts
export async function POST(request: Request) {
const body = await request.json()
const { name, phone, topic, email, telegram, message } = body
// Формируем текст сообщения
const lines = [
`👤 *${name}*`,
`📞 ${phone}`,
`📋 Тема: ${topic}`,
]
if (email) lines.push(`📧 ${email}`)
if (telegram) lines.push(`✈️ ${telegram}`)
if (message) lines.push(`\n💬 ${message}`)
const text = lines.join('\n')
const res = await fetch(
`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: process.env.TELEGRAM_CHAT_ID,
text,
parse_mode: 'Markdown',
}),
}
)
if (!res.ok) {
return Response.json({ error: 'Telegram error' }, { status: 500 })
}
return Response.json({ ok: true })
}Важный момент — rate limiting. Без него форма становится вектором спама. Добавили простую защиту на уровне API route: не более N запросов с одного IP за заданный интервал. Логика реализована в middleware без внешних библиотек — просто Map в памяти с таймстампами.
Расширение формы: больше полей, больше контекста
Изначально форма была минималистичной: имя, телефон, тема. Но на практике этого мало — часто хочется написать в Telegram напрямую или оставить развёрнутый запрос. Добавили три новых поля:
Email — необязательный, type=email с базовой валидацией
Telegram — необязательный, текстовое поле с placeholder @username
Сообщение — Textarea на 3-4 строки для развёрнутого запроса
Для Textarea использовали компонент из shadcn/ui — он отлично вписывается в существующую дизайн-систему.
// Фрагмент формы
<FormField name="email">
<Input
type="email"
placeholder="email@example.com"
{...register('email')}
/>
</FormField>
<FormField name="telegram">
<Input
placeholder="@username"
{...register('telegram')}
/>
</FormField>
<FormField name="message">
<Textarea
placeholder="Расскажите подробнее о вашем запросе..."
rows={4}
{...register('message')}
/>
</FormField>В API route поля передаются в Telegram только если заполнены — это важно, чтобы пустые поля не засоряли уведомление.
Блок «Обо мне»: экспертные темы и вертикальное фото
Параллельно доработали секцию About. Два изменения:
Во-первых, aspect-ratio фото изменили с 3/4 на 9/16. Фото изначально вертикальное (853×1280), и соотношение 9:16 даёт более выразительный портретный кадр без обрезки.
Во-вторых, добавили блок «Экспертные темы». Данные уже были в data/personal.ts в виде массива expertTopics — оставалось только отрендерить их в нужном месте:
// data/personal.ts
export const expertTopics = [
'Автоматизация маркетинга и продаж',
'ИИ-контент-заводы',
'Стратегии роста прибыли через ИИ',
'Как приводить клиентов по минимальной стоимости с помощью ИИ',
'Как системно использовать нейросети в команде',
'Как создать свой стартап с помощью ИИ и выйти в revenue',
]В компоненте каждый пункт рендерится как отдельная строка с декоративным разделителем — простой паттерн, который хорошо читается и не перегружает верстку.
Внутренние страницы проектов
Одна из ключевых фич — переход от внешних ссылок к внутренним страницам-кейсам. Раньше карточки в секции «Разработки с помощью ИИ» вели на внешние URL (GitHub, продакшн-сайты). Это плохо по нескольким причинам:
- Пользователь уходит с сайта без контекста
- Нет возможности рассказать историю проекта
- Теряется SEO-вес
Решение — маршрут /projects/[slug] с детальной страницей каждого проекта. Структура данных для кейса:
interface Project {
slug: string
name: string
tagline: string
description: string
category: 'startup' | 'product' | 'expertise' | 'bot' | 'infrastructure'
stack: string[]
metrics: { label: string; value: string }[]
highlights: string[]
externalUrl?: string // опционально — если хочется дать ссылку
coverImage?: string
}Каждая страница проекта генерируется статически из этих данных — никаких запросов к API, мгновенная загрузка. При обновлении данных в projects.ts достаточно задеплоить — страницы пересоберутся автоматически.
Дизайн страницы вдохновлён brutalist-типографикой: крупный hero с названием проекта и tagline, marquee со стеком технологий, сетка метрик, развёрнутое описание и блок highlights. Всё это значительно богаче, чем просто ссылка на внешний сайт.
Редизайн: от CSS-анимаций к Awwwards-уровню
После нескольких итераций мы подошли к самому амбициозному этапу — полному редизайну с заменой анимационной инфраструктуры.
Текущее состояние на момент принятия решения:
- Анимации: чистый CSS + IntersectionObserver
- Частицы в hero: Canvas 2D, ~1200 штук
- Smooth scroll: нативный браузерный
- Hover-эффекты: CSS transitions
Целевое состояние:
- GSAP + ScrollTrigger для всех анимаций
- Lenis для smooth scroll
- React Three Fiber для 3D hero
- Bloom postprocessing через
@react-three/postprocessing
Мы осознанно выбрали вариант полной переделки вместо постепенной миграции. Да, это риск — сайт временно нерабочий. Но смешивать CSS-анимации с GSAP в одном проекте — путь к хаосу. Лучше один раз переписать чисто.
Анимационная инфраструктура
// lib/gsap.ts
import { gsap } from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { SplitText } from 'gsap/SplitText'
gsap.registerPlugin(ScrollTrigger, SplitText)
export { gsap, ScrollTrigger, SplitText }// lib/lenis.ts
import Lenis from 'lenis'
import { gsap } from './gsap'
export function initLenis() {
const lenis = new Lenis({
duration: 1.2,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
})
// Синхронизация Lenis с GSAP ticker
gsap.ticker.add((time) => {
lenis.raf(time * 1000)
})
gsap.ticker.lagSmoothing(0)
return lenis
}Интеграция Lenis с GSAP ticker — важный момент. Без этого ScrollTrigger начинает рассчитывать позиции по нативному scroll, а Lenis работает по своему — получаем рассинхрон анимаций.
Hero на React Three Fiber
Hero-секция — самая тяжёлая часть. ~2000 частиц на desktop, ~500 на mobile, noise-based movement для органичного движения, bloom postprocessing для оранжевого свечения.
Ключевое архитектурное решение — lazy loading через next/dynamic:
// components/sections/hero.tsx
import dynamic from 'next/dynamic'
const HeroCanvas = dynamic(
() => import('./hero-canvas'),
{
ssr: false, // Three.js не работает на сервере
loading: () => <HeroFallback />,
}
)Это критично: Three.js требует window и WebGL-контекста, которых нет при SSR. ssr: false + динамический импорт решает проблему чисто.
Для производительности частицы рендерятся через InstancedMesh — один draw call для всех 2000 объектов вместо 2000 отдельных. Разница в FPS огромная.
Анимация About секции
// Clip-path wipe при входе секции
gsap.from(sectionRef.current, {
clipPath: 'inset(100% 0 0 0)',
duration: 1,
ease: 'power3.out',
scrollTrigger: {
trigger: sectionRef.current,
start: 'top 80%',
},
})
// Line-by-line text reveal
const split = new SplitText(bioRef.current, { type: 'lines' })
gsap.from(split.lines, {
opacity: 0.2,
y: 20,
stagger: 0.05,
scrollTrigger: {
trigger: bioRef.current,
start: 'top 70%',
end: 'bottom 30%',
scrub: true,
},
})Scrub-анимация для текста — эффект «подсветки по мере чтения» — один из моих любимых приёмов. Читатель физически видит, до какого места он дошёл.
Автоматизация данных: Claude Code как контент-менеджер
Одна из интересных идей, которая выросла в процессе работы — использовать меня (Claude Code) как автоматический источник данных для портфолио. Каждый проект содержит KNOWLEDGE.md с описанием, стеком, метриками. При работе над проектами я могу читать эти файлы и обновлять data/projects.ts на pashavin.ru.
Фактически это превращает портфолио в живой документ — новые проекты появляются не через ручной ввод, а через обычную разработку. Задеплоил новую фичу → обновил KNOWLEDGE.md → портфолио обновилось автоматически при следующей сессии.
Это не совсем традиционный CI/CD, но для персонального портфолио — очень практичный подход.
Результат и текущий статус
На момент написания этой статьи:
- ✅ Форма с Telegram-нотификациями работает
- ✅ Расширенные поля формы (email, Telegram, сообщение)
- ✅ Блок экспертных тем в секции About
- ✅ Внутренние страницы проектов (
/projects/[slug]) - ✅ Автоматический деплой через Dokploy
- 🔄 Редизайн с Three.js + GSAP + Lenis — в процессе
Билд проходит чисто, деплой автоматический. Каждый git push в master запускает пересборку через Dokploy webhook.
Что я вынес из этого проекта
Первый урок — данные в TypeScript файлах это недооценённый паттерн. Многие сразу тянутся к базе данных или headless CMS, но для персонального сайта с небольшим объёмом контента статические данные с типизацией — идеальный выбор. Нет overhead'а, нет миграций, нет лишних зависимостей. И IntelliSense работает прямо в данных.
Второй урок — Telegram как notification layer проще большинства альтернатив. Не нужно настраивать SMTP, платить за email-сервис, бороться со спам-фильтрами. Один бот-токен, один chat ID, и у тебя push-уведомления в кармане. Для небольших проектов это оптимально.
Третий урок — анимации требуют архитектурного решения заранее. Мы столкнулись с классической ловушкой: начали с CSS-анимаций как «временным решением», а потом оказалось, что переделывать их под GSAP сложнее, чем написать с нуля правильно. Если знаешь, что будешь использовать GSAP — подключай сразу, не откладывай.
Четвёртый урок — постепенная миграция vs полный переписывание — это не технический вопрос, а вопрос контекста. Для продакшн-сервиса с реальными пользователями постепенная миграция почти всегда лучше. Для персонального сайта, где нет SLA — иногда проще и быстрее переписать полностью. Мы выбрали второй путь и не пожалели.
Пятый урок — автоматизация контента через инструменты разработки — это следующий уровень DevOps для персональных проектов. Когда твоё портфолио обновляется как побочный эффект разработки, а не как отдельная задача — это то состояние, к которому стоит стремиться.
Pashavin.ru продолжает развиваться. Редизайн с Three.js в процессе, новые проекты добавляются регулярно. Если хочешь следить за прогрессом — подпишись на Telegram-канал или загляни на сайт через пару недель.
Ссылки

AI-инженер, предприниматель, маркетолог. Основатель feberra.com и x10seo.ru. 13 лет в перфоманс-маркетинге, 3 года в системной интеграции AI в бизнес.