This commit is contained in:
Oscar
2026-06-22 09:31:44 +03:00
commit 3902d0dec4
48 changed files with 19218 additions and 0 deletions

5
.claude/settings.json Normal file
View File

@@ -0,0 +1,5 @@
{
"permissions": {
"defaultMode": "bypassPermissions"
}
}

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(pnpm dev *)"
]
}
}

View File

@@ -0,0 +1,85 @@
---
name: minimalist-ui
description: Clean editorial-style interfaces. Warm monochrome palette, typographic contrast, flat bento grids, muted pastels. No gradients, no heavy shadows.
---
# Protocol: Premium Utilitarian Minimalism UI Architect
## 1. Protocol Overview
Name: Premium Utilitarian Minimalism & Editorial UI
Description: An advanced frontend engineering directive for generating highly refined, ultra-minimalist, "document-style" web interfaces analogous to top-tier workspace platforms. This protocol strictly enforces a high-contrast warm monochrome palette, bespoke typographic hierarchies, meticulous structural macro-whitespace, bento-grid layouts, and an ultra-flat component architecture with deliberate muted pastel accents. It actively rejects standard generic SaaS design trends.
## 2. Absolute Negative Constraints (Banned Elements)
The AI must strictly avoid the following generic web development defaults:
- DO NOT use the "Inter", "Roboto", or "Open Sans" typefaces.
- DO NOT use generic, thin-line icon libraries like "Lucide", "Feather", or standard "Heroicons".
- DO NOT use Tailwind's default heavy drop shadows (e.g., `shadow-md`, `shadow-lg`, `shadow-xl`). Shadows must be practically non-existent or heavily customized to be ultra-diffuse and low opacity (< 0.05).
- DO NOT use primary colored backgrounds for large elements or sections (e.g., no bright blue, green, or red hero sections).
- DO NOT use gradients, neon colors, or 3D glassmorphism (beyond subtle navbar blurs).
- DO NOT use `rounded-full` (pill shapes) for large containers, cards, or primary buttons.
- DO NOT use emojis anywhere in code, markup, text content, headings, or alt text. Replace with proper icons or clean SVG primitives.
- DO NOT use generic placeholder names like "John Doe", "Acme Corp", or "Lorem Ipsum". Use realistic, contextual content.
- DO NOT use AI copywriting clichés: "Elevate", "Seamless", "Unleash", "Next-Gen", "Game-changer", "Delve". Write plain, specific language.
## 3. Typographic Architecture
The interface must rely on extreme typographic contrast and premium font selection to establish an editorial feel.
- Primary Sans-Serif (Body, UI, Buttons): Use clean, geometric, or system-native fonts with character. Target: `font-family: 'SF Pro Display', 'Geist Sans', 'Helvetica Neue', 'Switzer', sans-serif`.
- Editorial Serif (Hero Headings & Quotes): Target: `font-family: 'Lyon Text', 'Newsreader', 'Playfair Display', 'Instrument Serif', serif`. Apply tight tracking (`letter-spacing: -0.02em` to `-0.04em`) and tight line-height (`1.1`).
- Monospace (Code, Keystrokes, Meta-data): Target: `font-family: 'Geist Mono', 'SF Mono', 'JetBrains Mono', monospace`.
- Text Colors: Body text must never be absolute black (`#000000`). Use off-black/charcoal (`#111111` or `#2F3437`) with a generous `line-height` of `1.6` for legibility. Secondary text should be muted gray (`#787774`).
## 4. Color Palette (Warm Monochrome + Spot Pastels)
Color is a scarce resource, utilized only for semantic meaning or subtle accents.
- Canvas / Background: Pure White `#FFFFFF` or Warm Bone/Off-White `#F7F6F3` / `#FBFBFA`.
- Primary Surface (Cards): `#FFFFFF` or `#F9F9F8`.
- Structural Borders / Dividers: Ultra-light gray `#EAEAEA` or `rgba(0,0,0,0.06)`.
- Accent Colors: Exclusively use highly desaturated, washed-out pastels for tags, inline code backgrounds, or subtle icon backgrounds.
- Pale Red: `#FDEBEC` (Text: `#9F2F2D`)
- Pale Blue: `#E1F3FE` (Text: `#1F6C9F`)
- Pale Green: `#EDF3EC` (Text: `#346538`)
- Pale Yellow: `#FBF3DB` (Text: `#956400`)
## 5. Component Specifications
- Bento Box Feature Grids:
- Utilize asymmetrical CSS Grid layouts.
- Cards must have exactly `border: 1px solid #EAEAEA`.
- Border-radius must be crisp: `8px` or `12px` maximum.
- Internal padding must be generous (e.g., `24px` to `40px`).
- Primary Call-To-Action (Buttons):
- Solid background `#111111`, text `#FFFFFF`.
- Slight border-radius (`4px` to `6px`). No box-shadow.
- Hover state should be a subtle color shift to `#333333` or a micro-scale `transform: scale(0.98)`.
- Tags & Status Badges:
- Pill-shaped (`border-radius: 9999px`), very small typography (`text-xs`), uppercase with wide tracking (`letter-spacing: 0.05em`).
- Background must use the defined Muted Pastels.
- Accordions (FAQ):
- Strip all container boxes. Separate items only with a `border-bottom: 1px solid #EAEAEA`.
- Use a clean, sharp `+` and `-` icon for the toggle state.
- Keystroke Micro-UIs:
- Render shortcuts as physical keys using `<kbd>` tags: `border: 1px solid #EAEAEA`, `border-radius: 4px`, `background: #F7F6F3`, using the Monospace font.
- Faux-OS Window Chrome:
- When mocking up software, wrap it in a minimalist container with a white top bar containing three small, light gray circles (replicating macOS window controls).
## 6. Iconography & Imagery Directives
- System Icons: Use "Phosphor Icons (Bold or Fill weights)" or "Radix UI Icons" for a technical, slightly thicker-stroke aesthetic. Standardize stroke width across all icons.
- Illustrations: Monochromatic, rough continuous-line ink sketches on a white background, featuring a single offset geometric shape filled with a muted pastel color.
- Photography: Use high-quality, desaturated images with a warm tone. Apply subtle overlays (`opacity: 0.04` warm grain) to blend photos into the monochrome palette. Never use oversaturated stock photos. Use reliable placeholders like `https://picsum.photos/seed/{context}/1200/800` when real assets are unavailable.
- Hero & Section Backgrounds: Sections should not feel empty and flat. Use subtle full-width background imagery at very low opacity, soft radial light spots (`radial-gradient` with warm tones at `opacity: 0.03`), or minimal geometric line patterns to add depth without breaking the clean aesthetic.
## 7. Subtle Motion & Micro-Animations
Motion should feel invisible — present but never distracting. The goal is quiet sophistication, not spectacle.
- Scroll Entry: Elements fade in gently as they enter the viewport. Use `translateY(12px)` + `opacity: 0` resolving over `600ms` with `cubic-bezier(0.16, 1, 0.3, 1)`. Use `IntersectionObserver`, never `window.addEventListener('scroll')`.
- Hover States: Cards lift with an ultra-subtle shadow shift (`box-shadow` transitioning from `0 0 0` to `0 2px 8px rgba(0,0,0,0.04)` over `200ms`). Buttons respond with `scale(0.98)` on `:active`.
- Staggered Reveals: Lists and grid items enter with a cascade delay (`animation-delay: calc(var(--index) * 80ms)`). Never mount everything at once.
- Background Ambient Motion: Optional. A single, very slow-moving radial gradient blob (`animation-duration: 20s+`, `opacity: 0.02-0.04`) drifting behind hero sections. Must be applied to a `position: fixed; pointer-events: none` layer. Never on scrolling containers.
- Performance: Animate exclusively via `transform` and `opacity`. No layout-triggering properties (`top`, `left`, `width`, `height`). Use `will-change: transform` sparingly and only on actively animating elements.
## 8. Execution Protocol
When tasked with writing frontend code (HTML, React, Tailwind, Vue) or designing a layout:
1. Establish the macro-whitespace first. Use massive vertical padding between sections (e.g., `py-24` or `py-32` in Tailwind).
2. Constrain the main typography content width to `max-w-4xl` or `max-w-5xl`.
3. Apply the custom typographic hierarchy and monochromatic color variables immediately.
4. Ensure every card, divider, and border adheres strictly to the `1px solid #EAEAEA` rule.
5. Add scroll-entry animations to all major content blocks.
6. Ensure sections have visual depth through imagery, ambient gradients, or subtle textures — no empty flat backgrounds.
7. Provide code that reflects this high-end, uncluttered, editorial aesthetic natively without requiring manual adjustments.

48
.gitignore vendored Normal file
View File

@@ -0,0 +1,48 @@
# Dependencies
node_modules/
# Environment variables
.env
.env.local
.env.*.local
# Build outputs
dist/
.output/
.nuxt/
.nitro/
# pnpm
.pnpm-store/
.pnpm-debug.log
# Logs
*.log
logs/
npm-debug.log*
pnpm-debug.log*
# IDE
.idea/
.vscode/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS
.DS_Store
Thumbs.db
# Runtime data
pids/
*.pid
*.seed
# Test coverage
coverage/
.nyc_output/
# TypeScript cache
*.tsbuildinfo

456
PROMPT.md Normal file
View File

@@ -0,0 +1,456 @@
# TMC Test Task — Claude Code Prompt
> Запусти этот промт из корня проекта (например `~/projects/dev-landing`)
---
## Цель
Создать fullstack-приложение: **backend API на NestJS** + **лендинг на Nuxt 3**.
Монорепозиторий с двумя папками: `backend/` и `frontend/`.
Работай итеративно: сначала scaffold → потом реализация по фичам. Не переходи к следующему шагу, пока текущий не работает.
---
## Структура репозитория
```
internetlab-test-task/
├── backend/
│ ├── src/
│ │ ├── main.ts
│ │ ├── app.module.ts
│ │ ├── contact/
│ │ │ ├── contact.module.ts
│ │ │ ├── contact.controller.ts
│ │ │ ├── contact.service.ts
│ │ │ ├── dto/
│ │ │ │ └── create-contact.dto.ts
│ │ │ └── interfaces/
│ │ │ └── contact.interface.ts
│ │ ├── ai/
│ │ │ ├── ai.module.ts
│ │ │ └── ai.service.ts
│ │ ├── mail/
│ │ │ ├── mail.module.ts
│ │ │ └── mail.service.ts
│ │ ├── health/
│ │ │ └── health.controller.ts
│ │ ├── metrics/
│ │ │ ├── metrics.module.ts
│ │ │ ├── metrics.controller.ts
│ │ │ └── metrics.service.ts
│ │ ├── logger/
│ │ │ └── logger.service.ts
│ │ └── storage/
│ │ └── file-storage.service.ts
│ ├── data/
│ │ ├── logs/ ← логи запросов (создаётся автоматически)
│ │ ├── metrics.json ← статистика обращений
│ │ └── rate-limit/ ← данные rate limiting
│ ├── .env.example
│ ├── tsconfig.json
│ └── package.json
└── frontend/
├── app/
│ ├── pages/
│ │ └── index.vue
│ ├── components/
│ │ ├── HeroSection.vue
│ │ ├── AboutSection.vue
│ │ ├── SkillsSection.vue
│ │ ├── ProjectsSection.vue
│ │ └── ContactForm.vue
│ ├── composables/
│ │ └── useContact.ts
│ └── assets/
│ └── styles/
│ └── main.scss
├── .env.example
├── nuxt.config.ts
└── package.json
```
---
## BACKEND
### Технологии
- **Runtime**: Node.js + TypeScript
- **Framework**: NestJS 10 + Fastify adapter (`@nestjs/platform-fastify`)
- **Package manager**: pnpm
- **Validation**: `class-validator` + `class-transformer`
- **Docs**: `@nestjs/swagger`
- **Rate limiting**: `@nestjs/throttler`
- **Mail**: `nodemailer`
- **Logging**: `winston`
- **AI**: `openai` (OpenAI-compatible SDK, используем Groq baseURL)
- **Config**: `@nestjs/config` + `dotenv`
### package.json (backend)
```json
{
"name": "dev-landing-backend",
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
"start": "node dist/main.js",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\""
}
}
```
Зависимости: `@nestjs/common`, `@nestjs/core`, `@nestjs/platform-fastify`, `@nestjs/config`, `@nestjs/swagger`, `@nestjs/throttler`, `class-validator`, `class-transformer`, `reflect-metadata`, `rxjs`, `nodemailer`, `winston`, `openai`, `uuid`
Dev: `@nestjs/cli`, `@nestjs/schematics`, `@types/node`, `@types/nodemailer`, `@types/uuid`, `typescript`, `ts-node`, `eslint`, `@antfu/eslint-config`
### Переменные окружения (`.env.example`)
```env
# Server
PORT=3001
NODE_ENV=development
# CORS
FRONTEND_URL=http://localhost:3000
# Mail (SMTP)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your@gmail.com
SMTP_PASS=your-app-password
MAIL_FROM=your@gmail.com
MAIL_TO=owner@gmail.com
# AI (Groq)
GROQ_API_KEY=your-groq-api-key
GROQ_MODEL=llama-3.3-70b-versatile
# Rate limiting
THROTTLE_TTL=60000
THROTTLE_LIMIT=5
```
### Архитектура и реализация
#### `main.ts`
- NestJS + Fastify adapter
- Включить `ValidationPipe` глобально (с `transform: true`, `whitelist: true`)
- Настроить CORS: разрешить `FRONTEND_URL` из env
- Настроить Swagger: `DocumentBuilder` → title, description, version → `SwaggerModule.setup('api/docs', app, document)`
- Порт из `process.env.PORT ?? 3001`
#### `app.module.ts`
Импортировать: `ConfigModule.forRoot({ isGlobal: true })`, `ThrottlerModule.forRootAsync(...)`, `ContactModule`, `HealthModule`, `MetricsModule`
#### DTO (`create-contact.dto.ts`)
```typescript
export class CreateContactDto {
@IsString()
@IsNotEmpty()
@MaxLength(100)
@ApiProperty({ example: "Иван Иванов" })
name: string;
@IsString()
@IsNotEmpty()
@Matches(/^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4,6}$/)
@ApiProperty({ example: "+7 999 123-45-67" })
phone: string;
@IsEmail()
@ApiProperty({ example: "ivan@example.com" })
email: string;
@IsString()
@IsOptional()
@MaxLength(1000)
@ApiProperty({ example: "Хочу обсудить проект", required: false })
comment?: string;
}
```
#### `ContactController` (`POST /api/contact`)
- Декоратор `@UseGuards(ThrottlerGuard)`
- Принимает `CreateContactDto`
- Вызывает `contactService.handleContact(dto)`
- Возвращает `{ success: true, message: string, ai: { sentiment, category, autoReply } }`
- Обработка ошибок: если mail упал — вернуть 500 с понятным сообщением; если AI упал — продолжить без AI (graceful fallback)
- Все ошибки логировать через `LoggerService`
#### `ContactService`
Метод `handleContact(dto: CreateContactDto)`:
1. Логировать входящий запрос через `LoggerService` (в файл)
2. Вызвать `aiService.analyzeContact(dto)` — получить `{ sentiment, category, autoReply }`. Если упал — `null`, продолжить
3. Вызвать `mailService.sendOwnerNotification(dto, aiResult)`
4. Вызвать `mailService.sendUserConfirmation(dto, aiResult)`
5. Обновить метрики через `metricsService.increment(category)`
6. Вернуть результат
#### `AiService`
Используй `openai` SDK с Groq baseURL:
```typescript
import OpenAI from "openai";
const groq = new OpenAI({
apiKey: process.env.GROQ_API_KEY,
baseURL: "https://api.groq.com/openai/v1",
});
```
Метод `analyzeContact(dto)`:
- Промпт (системный): "Ты — аналитик обращений. Отвечай ТОЛЬКО валидным JSON без markdown и пояснений."
- Промпт (пользователь): передать имя, email, комментарий
- Ожидаемый JSON-ответ от модели:
```json
{
"sentiment": "positive" | "neutral" | "negative",
"category": "question" | "partnership" | "job" | "spam" | "other",
"autoReply": "текст автоответа на русском языке (2-3 предложения)"
}
```
- Graceful fallback: обернуть в `try/catch`, при ошибке логировать и вернуть `null`
- Таймаут запроса: 10 секунд
#### `MailService`
`sendOwnerNotification(dto, aiResult)`:
- Тема: `Новое обращение от ${dto.name}`
- HTML-письмо: имя, телефон, email, комментарий, блок AI-анализа (если есть)
`sendUserConfirmation(dto, aiResult)`:
- Тема: `Спасибо за обращение, ${dto.name}!`
- HTML-письмо: подтверждение получения + `autoReply` от AI (если есть) или дефолтный текст
#### `LoggerService`
Использовать `winston`:
- Транспорты: Console + File (`data/logs/requests-YYYY-MM-DD.log`)
- Формат файла: JSON с `timestamp`, `level`, `message`, `meta`
- Метод `logRequest(method, path, body, response, duration)` — логировать каждый запрос
- Метод `logError(error, context)` — логировать ошибки
#### `FileStorageService`
Простой сервис для работы с JSON-файлами:
- `read<T>(filepath): T | null`
- `write<T>(filepath, data: T): void`
- Все операции синхронные (`fs.readFileSync` / `writeFileSync`)
- Автосоздание директории если не существует
#### `MetricsService`
Хранить данные в `data/metrics.json`:
```json
{
"total": 0,
"byCategory": {
"question": 0,
"partnership": 0,
"job": 0,
"spam": 0,
"other": 0
},
"bySentiment": { "positive": 0, "neutral": 0, "negative": 0 },
"lastUpdated": "ISO timestamp"
}
```
`MetricsController` → `GET /api/metrics` → вернуть текущие метрики
#### `HealthController` → `GET /api/health`
```json
{
"status": "ok",
"timestamp": "ISO",
"uptime": 12345,
"version": "1.0.0"
}
```
#### Rate Limiting
`ThrottlerModule` с настройками из env: `ttl: THROTTLE_TTL (60000ms)`, `limit: THROTTLE_LIMIT (5)`.
Применять `ThrottlerGuard` к `POST /api/contact`.
При превышении лимита — NestJS вернёт 429 автоматически.
#### Глобальный error handler
Создать `AllExceptionsFilter` (`@Catch()`):
- Логировать все необработанные ошибки через `LoggerService`
- Возвращать единообразный JSON: `{ statusCode, message, timestamp, path }`
- Зарегистрировать глобально в `main.ts`
#### Swagger
- Доступен по `GET /api/docs`
- Все эндпоинты задокументированы с `@ApiOperation`, `@ApiResponse`, `@ApiBody`
- DTO с `@ApiProperty` декораторами
---
## FRONTEND
### Технологии
- **Framework**: Nuxt 3
- **UI**: `@nuxt/ui` (последняя версия)
- **Styling**: Tailwind CSS (через `@nuxt/ui`) + SASS
- **Package manager**: pnpm
- **Lint**: `@antfu/eslint-config`
- **HTTP**: встроенный `$fetch` / `useFetch` Nuxt
### `nuxt.config.ts`
```typescript
export default defineNuxtConfig({
modules: ["@nuxt/ui"],
compatibilityDate: "2024-11-01",
devtools: { enabled: true },
css: ["~/assets/styles/main.scss"],
runtimeConfig: {
public: {
apiBase: process.env.NUXT_PUBLIC_API_BASE ?? "http://localhost:3001/api",
},
},
});
```
### Переменные окружения (`.env.example`)
```env
NUXT_PUBLIC_API_BASE=http://localhost:3001/api
```
### Страница (`pages/index.vue`)
Лендинг разработчика. Секции по порядку:
1. **HeroSection** — имя, должность, краткое описание, кнопка "Связаться"
2. **AboutSection** — несколько предложений о себе
3. **SkillsSection** — карточки со скиллами (NestJS, Nuxt, TypeScript, etc.)
4. **ProjectsSection** — карточки проектов (можно placeholder данные)
5. **ContactForm** — форма обратной связи
Навбар: фиксированный вверху, ссылки-якоря на секции. Плавный скролл через CSS `scroll-behavior: smooth`.
### `ContactForm.vue`
Поля формы: Имя (required), Телефон (required), Email (required), Комментарий (textarea, optional).
Логика:
- Валидация на клиенте перед отправкой (не пустые обязательные поля, формат email)
- `POST` на `${apiBase}/contact`
- Состояния: `idle → loading → success | error`
- При `success`: показать сообщение + `autoReply` от AI если есть
- При `error`: показать текст ошибки из ответа
- При 429 (rate limit): показать "Слишком много запросов, попробуйте позже"
### `composables/useContact.ts`
```typescript
export function useContact() {
const config = useRuntimeConfig();
const loading = ref(false);
const error = ref<string | null>(null);
const success = ref(false);
const aiReply = ref<string | null>(null);
async function submit(data: ContactFormData) {
loading.value = true;
error.value = null;
try {
const res = await $fetch(`${config.public.apiBase}/contact`, {
method: "POST",
body: data,
});
success.value = true;
aiReply.value = res.ai?.autoReply ?? null;
} catch (e: any) {
if (e.status === 429)
error.value = "Слишком много запросов. Попробуйте через минуту.";
else error.value = e.data?.message ?? "Что-то пошло не так";
} finally {
loading.value = false;
}
}
return { loading, error, success, aiReply, submit };
}
```
### Дизайн
Тёмная тема (dark mode по умолчанию). Современный минималистичный стиль разработчика.
Использовать компоненты `@nuxt/ui`: `UButton`, `UInput`, `UTextarea`, `UCard`, `UBadge`, `UAlert`.
Анимации появления секций при скролле через CSS `@keyframes` + `IntersectionObserver`.
---
## Порядок выполнения
### Backend
1. `mkdir backend && cd backend` → `pnpm init` → установить зависимости → scaffold через `nest new . --package-manager pnpm` или вручную
2. Настроить `tsconfig.json`, `.env` из `.env.example`
3. Создать `FileStorageService`, `LoggerService` (базовые утилиты)
4. Создать `AiService` → протестировать вызов Groq
5. Создать `MailService` → протестировать отправку (можно с `SMTP_HOST=smtp.ethereal.email` для теста)
6. Создать `ContactModule`: DTO → Controller → Service (с AI + mail + logging)
7. Создать `MetricsModule` и `HealthController`
8. Настроить `ThrottlerModule`, `AllExceptionsFilter`, Swagger
9. Проверить: `pnpm dev` → `http://localhost:3001/api/docs` открывается
### Frontend
1. `pnpm dlx nuxi@latest init frontend` → выбрать нужные модули
2. Установить `@nuxt/ui`, `sass`
3. Настроить `nuxt.config.ts`, `.env`
4. Создать `useContact` composable
5. Сверстать секции лендинга (Hero → About → Skills → Projects → Contact)
6. Реализовать `ContactForm.vue` с полной логикой
7. Проверить: форма отправляет запрос, показывает AI-ответ
---
## Важные детали
- `data/` директория в `backend/` — создавать автоматически при старте если не существует
- Метрики и rate-limit данные — хранить в файлах, не в памяти (чтобы переживали рестарт)
- Логи — один файл в день, имя формата `requests-2025-01-15.log`
- AI fallback — если `GROQ_API_KEY` не задан или запрос упал, ContactService продолжает работу (отправляет письма без AI-анализа), в ответе `ai: null`
- Mail fallback — если SMTP не настроен (пустой `SMTP_HOST`), MailService логирует письмо в консоль вместо отправки (для удобства разработки)
- CORS — только `FRONTEND_URL` из env, не `*`
- Все секреты только через env, никаких хардкодов
---
## Что должно работать в итоге
- `http://localhost:3001/api/docs` — Swagger UI со всеми эндпоинтами
- `POST http://localhost:3001/api/contact` — принимает форму, анализирует AI, отправляет письма, логирует
- `GET http://localhost:3001/api/health` — статус сервиса
- `GET http://localhost:3001/api/metrics` — статистика обращений
- `http://localhost:3000` — лендинг с формой, которая работает с бэком
- Форма показывает AI-автоответ после успешной отправки
- Rate limiting: больше 5 запросов в минуту → 429
- Логи пишутся в `backend/data/logs/`

22
backend/.env.example Normal file
View File

@@ -0,0 +1,22 @@
# Server
PORT=3001
NODE_ENV=development
# CORS
FRONTEND_URL=http://localhost:3000
# Mail (SMTP)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your@gmail.com
SMTP_PASS=your-app-password
MAIL_FROM=your@gmail.com
MAIL_TO=owner@gmail.com
# AI (Groq)
GROQ_API_KEY=your-groq-api-key
GROQ_MODEL=llama-3.3-70b-versatile
# Rate limiting
THROTTLE_TTL=60000
THROTTLE_LIMIT=5

16
backend/data/metrics.json Normal file
View File

@@ -0,0 +1,16 @@
{
"total": 2,
"byCategory": {
"question": 0,
"partnership": 0,
"job": 0,
"spam": 0,
"other": 2
},
"bySentiment": {
"positive": 0,
"neutral": 1,
"negative": 0
},
"lastUpdated": "2026-06-19T14:26:09.000Z"
}

39
backend/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "dev-landing-backend",
"version": "1.0.0",
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
"start": "node dist/main.js",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\""
},
"dependencies": {
"@fastify/static": "^9.1.3",
"@nestjs/common": "^11.1.27",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.27",
"@nestjs/platform-fastify": "^11.1.27",
"@nestjs/swagger": "^11.4.4",
"@nestjs/throttler": "^6.5.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"fastify": "^5.8.5",
"nodemailer": "^9.0.1",
"openai": "^6.44.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"uuid": "^11.0.5",
"winston": "^3.17.0"
},
"devDependencies": {
"@antfu/eslint-config": "^3.14.0",
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@types/node": "^22.10.0",
"@types/nodemailer": "^6.4.17",
"@types/uuid": "^10.0.0",
"eslint": "^9.18.0",
"ts-node": "^10.9.2",
"typescript": "^5.7.2"
}
}

6118
backend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
allowBuilds:
'@nestjs/core': true
'@scarf/scarf': true
unrs-resolver: true

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AiService } from './ai.service';
import { LoggerModule } from '../logger/logger.module';
@Module({
imports: [LoggerModule],
providers: [AiService],
exports: [AiService],
})
export class AiModule {}

View File

@@ -0,0 +1,56 @@
import { Injectable } from '@nestjs/common';
import OpenAI from 'openai';
import { LoggerService } from '../logger/logger.service';
export interface AiAnalysisResult {
sentiment: 'positive' | 'neutral' | 'negative';
category: 'question' | 'partnership' | 'job' | 'spam' | 'other';
autoReply: string;
}
@Injectable()
export class AiService {
private groq: OpenAI | null = null;
constructor(private readonly logger: LoggerService) {
const apiKey = process.env.GROQ_API_KEY;
if (apiKey) {
this.groq = new OpenAI({
apiKey,
baseURL: 'https://api.groq.com/openai/v1',
timeout: 10000,
});
}
}
async analyzeContact(dto: { name: string; email: string; comment?: string }): Promise<AiAnalysisResult | null> {
if (!this.groq) {
this.logger.warn('AiService: GROQ_API_KEY not set, skipping AI analysis');
return null;
}
try {
const model = process.env.GROQ_MODEL ?? 'llama-3.3-70b-versatile';
const completion = await this.groq.chat.completions.create({
model,
messages: [
{
role: 'system',
content: 'Ты — аналитик обращений. Отвечай ТОЛЬКО валидным JSON без markdown и пояснений.',
},
{
role: 'user',
content: `Проанализируй обращение:\nИмя: ${dto.name}\nEmail: ${dto.email}\nКомментарий: ${dto.comment ?? '(не указан)'}\n\nВерни JSON:\n{"sentiment":"positive"|"neutral"|"negative","category":"question"|"partnership"|"job"|"spam"|"other","autoReply":"текст автоответа на русском языке (2-3 предложения)"}`,
},
],
});
const text = completion.choices[0]?.message?.content ?? '';
const result = JSON.parse(text) as AiAnalysisResult;
return result;
} catch (e) {
this.logger.logError(e, 'AiService.analyzeContact');
return null;
}
}
}

30
backend/src/app.module.ts Normal file
View File

@@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ThrottlerModule } from '@nestjs/throttler';
import { ContactModule } from './contact/contact.module';
import { HealthModule } from './health/health.module';
import { MetricsModule } from './metrics/metrics.module';
import { LoggerModule } from './logger/logger.module';
import { StorageModule } from './storage/storage.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ThrottlerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => [
{
ttl: parseInt(config.get('THROTTLE_TTL', '60000')),
limit: parseInt(config.get('THROTTLE_LIMIT', '5')),
},
],
}),
LoggerModule,
StorageModule,
ContactModule,
HealthModule,
MetricsModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,23 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { ThrottlerGuard } from '@nestjs/throttler';
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ContactService } from './contact.service';
import { CreateContactDto } from './dto/create-contact.dto';
@ApiTags('Contact')
@Controller('api/contact')
export class ContactController {
constructor(private readonly contactService: ContactService) {}
@Post()
@UseGuards(ThrottlerGuard)
@ApiOperation({ summary: 'Submit contact form' })
@ApiBody({ type: CreateContactDto })
@ApiResponse({ status: 201, description: 'Contact form submitted successfully' })
@ApiResponse({ status: 400, description: 'Validation error' })
@ApiResponse({ status: 429, description: 'Too many requests' })
@ApiResponse({ status: 500, description: 'Internal server error' })
async submit(@Body() dto: CreateContactDto) {
return this.contactService.handleContact(dto);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ContactController } from './contact.controller';
import { ContactService } from './contact.service';
import { AiModule } from '../ai/ai.module';
import { MailModule } from '../mail/mail.module';
import { MetricsModule } from '../metrics/metrics.module';
@Module({
imports: [AiModule, MailModule, MetricsModule],
controllers: [ContactController],
providers: [ContactService],
})
export class ContactModule {}

View File

@@ -0,0 +1,42 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { CreateContactDto } from './dto/create-contact.dto';
import { AiService } from '../ai/ai.service';
import { MailService } from '../mail/mail.service';
import { MetricsService } from '../metrics/metrics.service';
import { LoggerService } from '../logger/logger.service';
import { ContactResponse } from './interfaces/contact.interface';
@Injectable()
export class ContactService {
constructor(
private readonly aiService: AiService,
private readonly mailService: MailService,
private readonly metricsService: MetricsService,
private readonly logger: LoggerService,
) {}
async handleContact(dto: CreateContactDto): Promise<ContactResponse> {
this.logger.logRequest('POST', '/api/contact', dto, null, 0);
const aiResult = await this.aiService.analyzeContact(dto).catch((e) => {
this.logger.logError(e, 'ContactService.aiService.analyzeContact');
return null;
});
try {
await this.mailService.sendOwnerNotification(dto, aiResult);
await this.mailService.sendUserConfirmation(dto, aiResult);
} catch (e) {
this.logger.logError(e, 'ContactService.mailService');
throw new InternalServerErrorException('Не удалось отправить письмо. Попробуйте позже.');
}
this.metricsService.increment(aiResult?.category ?? null, aiResult?.sentiment ?? null);
return {
success: true,
message: 'Ваше обращение успешно отправлено!',
ai: aiResult,
};
}
}

View File

@@ -0,0 +1,26 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
export class CreateContactDto {
@IsString()
@IsNotEmpty()
@MaxLength(100)
@ApiProperty({ example: 'Иван Иванов' })
name: string;
@IsString()
@IsNotEmpty()
@Matches(/^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4,6}$/)
@ApiProperty({ example: '+7 999 123-45-67' })
phone: string;
@IsEmail()
@ApiProperty({ example: 'ivan@example.com' })
email: string;
@IsString()
@IsOptional()
@MaxLength(1000)
@ApiProperty({ example: 'Хочу обсудить проект', required: false })
comment?: string;
}

View File

@@ -0,0 +1,18 @@
export interface Contact {
id: string;
name: string;
phone: string;
email: string;
comment?: string;
createdAt: string;
}
export interface ContactResponse {
success: boolean;
message: string;
ai: {
sentiment: string;
category: string;
autoReply: string;
} | null;
}

View File

@@ -0,0 +1,33 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common';
import { FastifyReply, FastifyRequest } from 'fastify';
import { LoggerService } from '../logger/logger.service';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
constructor(private readonly logger: LoggerService) {}
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const reply = ctx.getResponse<FastifyReply>();
const request = ctx.getRequest<FastifyRequest>();
const statusCode =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message =
exception instanceof HttpException
? (exception.getResponse() as any)?.message ?? exception.message
: 'Internal server error';
this.logger.logError(exception, `${request.method} ${request.url}`);
reply.status(statusCode).send({
statusCode,
message,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}

View File

@@ -0,0 +1,18 @@
import { Controller, Get } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
@ApiTags('Health')
@Controller('api/health')
export class HealthController {
@Get()
@ApiOperation({ summary: 'Health check' })
@ApiResponse({ status: 200, description: 'Service is healthy' })
check() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
uptime: Math.floor(process.uptime()),
version: '1.0.0',
};
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View File

@@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { LoggerService } from './logger.service';
@Global()
@Module({
providers: [LoggerService],
exports: [LoggerService],
})
export class LoggerModule {}

View File

@@ -0,0 +1,61 @@
import { Injectable } from '@nestjs/common';
import * as winston from 'winston';
import * as path from 'path';
import * as fs from 'fs';
@Injectable()
export class LoggerService {
private logger: winston.Logger;
constructor() {
const logsDir = path.join(process.cwd(), 'data', 'logs');
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
const today = new Date().toISOString().split('T')[0];
const logFile = path.join(logsDir, `requests-${today}.log`);
this.logger = winston.createLogger({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json(),
),
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple(),
),
}),
new winston.transports.File({
filename: logFile,
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json(),
),
}),
],
});
}
logRequest(method: string, path: string, body: any, response: any, duration: number) {
this.logger.info('Request', {
meta: { method, path, body, response, duration },
});
}
logError(error: any, context?: string) {
this.logger.error('Error', {
meta: { error: error?.message ?? String(error), stack: error?.stack, context },
});
}
log(message: string, meta?: any) {
this.logger.info(message, { meta });
}
warn(message: string, meta?: any) {
this.logger.warn(message, { meta });
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { MailService } from './mail.service';
import { LoggerModule } from '../logger/logger.module';
@Module({
imports: [LoggerModule],
providers: [MailService],
exports: [MailService],
})
export class MailModule {}

View File

@@ -0,0 +1,76 @@
import { Injectable } from '@nestjs/common';
import * as nodemailer from 'nodemailer';
import { LoggerService } from '../logger/logger.service';
import { AiAnalysisResult } from '../ai/ai.service';
@Injectable()
export class MailService {
private transporter: nodemailer.Transporter | null = null;
private devMode: boolean;
constructor(private readonly logger: LoggerService) {
const smtpHost = process.env.SMTP_HOST;
this.devMode = !smtpHost;
if (!this.devMode) {
this.transporter = nodemailer.createTransport({
host: smtpHost,
port: parseInt(process.env.SMTP_PORT ?? '587'),
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
}
}
async sendOwnerNotification(dto: { name: string; phone: string; email: string; comment?: string }, aiResult: AiAnalysisResult | null): Promise<void> {
const subject = `Новое обращение от ${dto.name}`;
const aiBlock = aiResult
? `<hr><h3>AI-анализ</h3><p><b>Тональность:</b> ${aiResult.sentiment}</p><p><b>Категория:</b> ${aiResult.category}</p><p><b>Автоответ:</b> ${aiResult.autoReply}</p>`
: '<p><i>AI-анализ недоступен</i></p>';
const html = `
<h2>Новое обращение с сайта</h2>
<p><b>Имя:</b> ${dto.name}</p>
<p><b>Телефон:</b> ${dto.phone}</p>
<p><b>Email:</b> ${dto.email}</p>
<p><b>Комментарий:</b> ${dto.comment ?? '—'}</p>
${aiBlock}
`;
await this.send(process.env.MAIL_TO ?? '', subject, html);
}
async sendUserConfirmation(dto: { name: string; email: string }, aiResult: AiAnalysisResult | null): Promise<void> {
const subject = `Спасибо за обращение, ${dto.name}!`;
const replyText = aiResult?.autoReply
? `<p>${aiResult.autoReply}</p>`
: '<p>Мы получили ваше сообщение и свяжемся с вами в ближайшее время.</p>';
const html = `
<h2>Спасибо за обращение!</h2>
<p>Здравствуйте, ${dto.name}!</p>
<p>Ваше сообщение успешно получено.</p>
${replyText}
<p>С уважением,<br>Команда разработки</p>
`;
await this.send(dto.email, subject, html);
}
private async send(to: string, subject: string, html: string): Promise<void> {
if (this.devMode || !this.transporter) {
this.logger.log(`[DEV MAIL] To: ${to} | Subject: ${subject}`, { html });
return;
}
await this.transporter.sendMail({
from: process.env.MAIL_FROM,
to,
subject,
html,
});
}
}

47
backend/src/main.ts Normal file
View File

@@ -0,0 +1,47 @@
import {NestFactory} from '@nestjs/core';
import {FastifyAdapter, NestFastifyApplication} from '@nestjs/platform-fastify';
import {ValidationPipe} from '@nestjs/common';
import {DocumentBuilder, SwaggerModule} from '@nestjs/swagger';
import {AppModule} from './app.module';
import {AllExceptionsFilter} from './filters/all-exceptions.filter';
import {LoggerService} from './logger/logger.service';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
);
app.enableCors({
origin: '*',//process.env.FRONTEND_URL ?? 'http://localhost:3000',
methods: ['GET', 'POST', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
});
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
}),
);
const logger = app.get(LoggerService);
app.useGlobalFilters(new AllExceptionsFilter(logger));
const config = new DocumentBuilder()
.setTitle('Dev Landing API')
.setDescription('Backend API for developer landing page with contact form, AI analysis, and metrics')
.setVersion('1.0.0')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
const port = process.env.PORT ?? 3001;
await app.listen(port, '0.0.0.0');
console.log(`Application running on http://localhost:${port}`);
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
}
bootstrap();

View File

@@ -0,0 +1,16 @@
import { Controller, Get } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { MetricsService } from './metrics.service';
@ApiTags('Metrics')
@Controller('api/metrics')
export class MetricsController {
constructor(private readonly metricsService: MetricsService) {}
@Get()
@ApiOperation({ summary: 'Get contact form metrics' })
@ApiResponse({ status: 200, description: 'Current metrics data' })
getMetrics() {
return this.metricsService.getMetrics();
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { MetricsService } from './metrics.service';
import { MetricsController } from './metrics.controller';
import { StorageModule } from '../storage/storage.module';
@Module({
imports: [StorageModule],
providers: [MetricsService],
controllers: [MetricsController],
exports: [MetricsService],
})
export class MetricsModule {}

View File

@@ -0,0 +1,50 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import * as path from 'path';
import { FileStorageService } from '../storage/file-storage.service';
export interface MetricsData {
total: number;
byCategory: Record<string, number>;
bySentiment: Record<string, number>;
lastUpdated: string;
}
const DEFAULT_METRICS: MetricsData = {
total: 0,
byCategory: { question: 0, partnership: 0, job: 0, spam: 0, other: 0 },
bySentiment: { positive: 0, neutral: 0, negative: 0 },
lastUpdated: new Date().toISOString(),
};
@Injectable()
export class MetricsService implements OnModuleInit {
private readonly filePath = path.join(process.cwd(), 'data', 'metrics.json');
constructor(private readonly storage: FileStorageService) {}
onModuleInit() {
const existing = this.storage.read<MetricsData>(this.filePath);
if (!existing) {
this.storage.write(this.filePath, DEFAULT_METRICS);
}
}
increment(category: string | null, sentiment: string | null) {
const metrics = this.storage.read<MetricsData>(this.filePath) ?? { ...DEFAULT_METRICS };
metrics.total += 1;
if (category && category in metrics.byCategory) {
metrics.byCategory[category] += 1;
} else {
metrics.byCategory['other'] = (metrics.byCategory['other'] ?? 0) + 1;
}
if (sentiment && sentiment in metrics.bySentiment) {
metrics.bySentiment[sentiment] += 1;
}
metrics.lastUpdated = new Date().toISOString();
this.storage.write(this.filePath, metrics);
}
getMetrics(): MetricsData {
return this.storage.read<MetricsData>(this.filePath) ?? { ...DEFAULT_METRICS };
}
}

View File

@@ -0,0 +1,24 @@
import { Injectable } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
@Injectable()
export class FileStorageService {
read<T>(filepath: string): T | null {
try {
if (!fs.existsSync(filepath)) return null;
const content = fs.readFileSync(filepath, 'utf-8');
return JSON.parse(content) as T;
} catch {
return null;
}
}
write<T>(filepath: string, data: T): void {
const dir = path.dirname(filepath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(filepath, JSON.stringify(data, null, 2), 'utf-8');
}
}

View File

@@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { FileStorageService } from './file-storage.service';
@Global()
@Module({
providers: [FileStorageService],
exports: [FileStorageService],
})
export class StorageModule {}

21
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
}

1
frontend/.env.example Normal file
View File

@@ -0,0 +1 @@
NUXT_PUBLIC_API_BASE=http://localhost:3001/api

8
frontend/app.config.ts Normal file
View File

@@ -0,0 +1,8 @@
export default defineAppConfig({
ui: {
colors: {
primary: 'indigo',
neutral: 'zinc',
},
},
});

View File

@@ -0,0 +1,41 @@
html {
scroll-behavior: smooth;
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(32px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.animate-fade-in-up {
animation: fadeInUp 0.6s ease forwards;
}
.section-hidden {
opacity: 0;
transform: translateY(32px);
}
.section-visible {
animation: fadeInUp 0.7s ease forwards;
}
.hero-gradient {
background: radial-gradient(ellipse at 60% 40%, rgba(99, 102, 241, 0.15) 0%, transparent 60%),
radial-gradient(ellipse at 20% 80%, rgba(139, 92, 246, 0.1) 0%, transparent 50%);
}

View File

@@ -0,0 +1,72 @@
<template>
<section id="about" class="py-24 px-4">
<div class="max-w-4xl mx-auto">
<div class="section-hidden" ref="sectionRef">
<h2 class="text-3xl md:text-4xl font-bold text-white mb-4">
О себе
</h2>
<div class="w-16 h-1 bg-indigo-500 mb-10 rounded-full" />
<div class="grid md:grid-cols-2 gap-8">
<div>
<p class="text-gray-400 text-lg leading-relaxed mb-6">
Привет! Я fullstack-разработчик с более чем 5 годами опыта создания
масштабируемых веб-приложений. Специализируюсь на экосистеме Node.js
и современных JavaScript-фреймворках.
</p>
<p class="text-gray-400 text-lg leading-relaxed">
Люблю чистую архитектуру, хорошо задокументированный код и всё, что
связано с DevOps-практиками. Открыт к интересным проектам и
долгосрочному сотрудничеству.
</p>
</div>
<div class="space-y-4">
<UCard class="bg-gray-900 border-gray-800">
<div class="flex items-center gap-3">
<UIcon name="i-heroicons-map-pin" class="text-indigo-400 w-5 h-5" />
<span class="text-gray-300">Москва, Россия</span>
</div>
</UCard>
<UCard class="bg-gray-900 border-gray-800">
<div class="flex items-center gap-3">
<UIcon name="i-heroicons-briefcase" class="text-indigo-400 w-5 h-5" />
<span class="text-gray-300">5+ лет опыта</span>
</div>
</UCard>
<UCard class="bg-gray-900 border-gray-800">
<div class="flex items-center gap-3">
<UIcon name="i-heroicons-language" class="text-indigo-400 w-5 h-5" />
<span class="text-gray-300">Русский, English (B2)</span>
</div>
</UCard>
<UCard class="bg-gray-900 border-gray-800">
<div class="flex items-center gap-3">
<UIcon name="i-heroicons-check-circle" class="text-indigo-400 w-5 h-5" />
<span class="text-gray-300">Доступен для фриланса</span>
</div>
</UCard>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
const sectionRef = ref<HTMLElement | null>(null);
onMounted(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.remove('section-hidden');
entry.target.classList.add('section-visible');
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.15 },
);
if (sectionRef.value) observer.observe(sectionRef.value);
});
</script>

View File

@@ -0,0 +1,200 @@
<template>
<section id="contact" class="py-24 px-4 bg-gray-950">
<div class="max-w-2xl mx-auto">
<div class="section-hidden" ref="sectionRef">
<h2 class="text-3xl md:text-4xl font-bold text-white mb-4">
Связаться
</h2>
<div class="w-16 h-1 bg-indigo-500 mb-10 rounded-full" />
<UCard class="bg-gray-900 border-gray-800">
<div v-if="success" class="space-y-4">
<UAlert
color="success"
variant="soft"
icon="i-heroicons-check-circle"
title="Сообщение отправлено!"
description="Мы получили ваше обращение и свяжемся с вами в ближайшее время."
/>
<UAlert
v-if="aiReply"
color="primary"
variant="soft"
icon="i-heroicons-sparkles"
title="Автоответ от AI"
:description="aiReply"
/>
<UButton
color="neutral"
variant="ghost"
icon="i-heroicons-arrow-left"
@click="handleReset"
>
Отправить ещё
</UButton>
</div>
<form v-else @submit.prevent="handleSubmit" class="space-y-5">
<UAlert
v-if="error"
color="error"
variant="soft"
icon="i-heroicons-exclamation-triangle"
:title="error"
/>
<UFormField
label="Имя"
required
:error="validationErrors.name"
>
<UInput
v-model="form.name"
placeholder="Иван Иванов"
:disabled="loading"
size="lg"
class="w-full"
/>
</UFormField>
<UFormField
label="Телефон"
required
:error="validationErrors.phone"
>
<UInput
v-model="form.phone"
placeholder="+7 999 123-45-67"
type="tel"
:disabled="loading"
size="lg"
class="w-full"
/>
</UFormField>
<UFormField
label="Email"
required
:error="validationErrors.email"
>
<UInput
v-model="form.email"
placeholder="ivan@example.com"
type="email"
:disabled="loading"
size="lg"
class="w-full"
/>
</UFormField>
<UFormField label="Комментарий">
<UTextarea
v-model="form.comment"
placeholder="Расскажите о вашем проекте..."
:disabled="loading"
:rows="4"
class="w-full"
/>
</UFormField>
<UButton
type="submit"
color="primary"
size="lg"
:loading="loading"
:disabled="loading"
icon="i-heroicons-paper-airplane"
class="w-full justify-center"
>
{{ loading ? 'Отправляем...' : 'Отправить' }}
</UButton>
</form>
</UCard>
</div>
</div>
</section>
</template>
<script setup lang="ts">
const sectionRef = ref<HTMLElement | null>(null);
const { loading, error, success, aiReply, submit, reset } = useContact();
const form = reactive({
name: '',
phone: '',
email: '',
comment: '',
});
const validationErrors = reactive({
name: '',
phone: '',
email: '',
});
function validate(): boolean {
validationErrors.name = '';
validationErrors.phone = '';
validationErrors.email = '';
let valid = true;
if (!form.name.trim()) {
validationErrors.name = 'Введите имя';
valid = false;
}
if (!form.phone.trim()) {
validationErrors.phone = 'Введите телефон';
valid = false;
}
else if (!/^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4,6}$/.test(form.phone.replace(/\s/g, ''))) {
validationErrors.phone = 'Некорректный формат телефона';
valid = false;
}
if (!form.email.trim()) {
validationErrors.email = 'Введите email';
valid = false;
}
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
validationErrors.email = 'Некорректный email';
valid = false;
}
return valid;
}
async function handleSubmit() {
if (!validate()) return;
await submit({
name: form.name.trim(),
phone: form.phone.trim(),
email: form.email.trim(),
comment: form.comment.trim() || undefined,
});
}
function handleReset() {
reset();
form.name = '';
form.phone = '';
form.email = '';
form.comment = '';
}
onMounted(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.remove('section-hidden');
entry.target.classList.add('section-visible');
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.1 },
);
if (sectionRef.value) observer.observe(sectionRef.value);
});
</script>

View File

@@ -0,0 +1,56 @@
<template>
<section id="hero" class="hero-gradient min-h-screen flex items-center justify-center px-4 py-24">
<div class="max-w-4xl mx-auto text-center section-hidden" ref="sectionRef">
<div class="mb-6">
<UBadge color="primary" variant="soft" size="lg" class="mb-4">
Доступен для новых проектов
</UBadge>
</div>
<h1 class="text-5xl md:text-7xl font-bold text-white mb-6 leading-tight">
Алексей<br>
<span class="text-indigo-400">Разработчик</span>
</h1>
<p class="text-xl md:text-2xl text-gray-400 mb-4 font-medium">
Fullstack Developer · NestJS · Nuxt 3 · TypeScript
</p>
<p class="text-lg text-gray-500 max-w-2xl mx-auto mb-10">
Создаю современные веб-приложения с чистым кодом и отличным UX.
От MVP до production-ready решений.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<UButton
size="xl"
color="primary"
icon="i-heroicons-paper-airplane"
@click="scrollTo('#contact')"
>
Связаться
</UButton>
<UButton
size="xl"
variant="ghost"
color="neutral"
icon="i-heroicons-code-bracket"
@click="scrollTo('#projects')"
>
Проекты
</UButton>
</div>
</div>
</section>
</template>
<script setup lang="ts">
const sectionRef = ref<HTMLElement | null>(null);
function scrollTo(selector: string) {
document.querySelector(selector)?.scrollIntoView({ behavior: 'smooth' });
}
onMounted(() => {
if (sectionRef.value) {
sectionRef.value.classList.remove('section-hidden');
sectionRef.value.classList.add('section-visible');
}
});
</script>

View File

@@ -0,0 +1,91 @@
<template>
<section id="projects" class="py-24 px-4">
<div class="max-w-4xl mx-auto">
<div class="section-hidden" ref="sectionRef">
<h2 class="text-3xl md:text-4xl font-bold text-white mb-4">
Проекты
</h2>
<div class="w-16 h-1 bg-indigo-500 mb-10 rounded-full" />
<div class="grid md:grid-cols-2 gap-6">
<UCard
v-for="project in projects"
:key="project.title"
class="bg-gray-900 border-gray-800 hover:border-indigo-500 transition-colors duration-300"
>
<div class="space-y-3">
<div class="flex items-start justify-between">
<h3 class="text-white font-semibold text-lg">{{ project.title }}</h3>
<UBadge :color="project.statusColor" variant="soft" size="sm">
{{ project.status }}
</UBadge>
</div>
<p class="text-gray-400 text-sm leading-relaxed">{{ project.description }}</p>
<div class="flex flex-wrap gap-2 pt-1">
<UBadge
v-for="tech in project.stack"
:key="tech"
color="neutral"
variant="outline"
size="xs"
>
{{ tech }}
</UBadge>
</div>
</div>
</UCard>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
const sectionRef = ref<HTMLElement | null>(null);
const projects = [
{
title: 'E-Commerce Platform',
description: 'Полноценная платформа электронной коммерции с микросервисной архитектурой, real-time уведомлениями и AI-рекомендациями.',
stack: ['NestJS', 'Nuxt 3', 'PostgreSQL', 'Redis', 'Docker'],
status: 'Production',
statusColor: 'success' as const,
},
{
title: 'DevOps Dashboard',
description: 'Мониторинговый дашборд для CI/CD пайплайнов с метриками, алертами и историей деплоев.',
stack: ['Vue 3', 'Node.js', 'InfluxDB', 'Grafana', 'K8s'],
status: 'Production',
statusColor: 'success' as const,
},
{
title: 'AI Content Generator',
description: 'Инструмент генерации контента на основе OpenAI API с кастомными промптами и экспортом.',
stack: ['Nuxt 3', 'NestJS', 'OpenAI', 'TypeScript'],
status: 'Beta',
statusColor: 'warning' as const,
},
{
title: 'Real-Time Chat',
description: 'Мессенджер с WebSocket, поддержкой медиафайлов, сквозным шифрованием и мобильным приложением.',
stack: ['NestJS', 'Socket.io', 'React Native', 'MongoDB'],
status: 'WIP',
statusColor: 'info' as const,
},
];
onMounted(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.remove('section-hidden');
entry.target.classList.add('section-visible');
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.1 },
);
if (sectionRef.value) observer.observe(sectionRef.value);
});
</script>

View File

@@ -0,0 +1,62 @@
<template>
<section id="skills" class="py-24 px-4 bg-gray-950">
<div class="max-w-4xl mx-auto">
<div class="section-hidden" ref="sectionRef">
<h2 class="text-3xl md:text-4xl font-bold text-white mb-4">
Технологии
</h2>
<div class="w-16 h-1 bg-indigo-500 mb-10 rounded-full" />
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
<UCard
v-for="skill in skills"
:key="skill.name"
class="bg-gray-900 border-gray-800 hover:border-indigo-500 transition-colors duration-300 cursor-default"
>
<div class="flex flex-col items-center gap-2 py-2">
<span class="text-2xl">{{ skill.icon }}</span>
<span class="text-white font-medium text-sm">{{ skill.name }}</span>
<UBadge :color="skill.color" variant="soft" size="xs">
{{ skill.level }}
</UBadge>
</div>
</UCard>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
const sectionRef = ref<HTMLElement | null>(null);
const skills = [
{ name: 'NestJS', icon: '🏗️', level: 'Expert', color: 'error' as const },
{ name: 'Nuxt 3', icon: '💚', level: 'Expert', color: 'success' as const },
{ name: 'TypeScript', icon: '🔷', level: 'Expert', color: 'info' as const },
{ name: 'Node.js', icon: '🟩', level: 'Expert', color: 'success' as const },
{ name: 'PostgreSQL', icon: '🐘', level: 'Advanced', color: 'info' as const },
{ name: 'Redis', icon: '🔴', level: 'Advanced', color: 'error' as const },
{ name: 'Docker', icon: '🐳', level: 'Advanced', color: 'sky' as const },
{ name: 'Vue 3', icon: '💡', level: 'Expert', color: 'success' as const },
{ name: 'React', icon: '⚛️', level: 'Advanced', color: 'info' as const },
{ name: 'GraphQL', icon: '🔗', level: 'Intermediate', color: 'warning' as const },
{ name: 'Kubernetes', icon: '☸️', level: 'Intermediate', color: 'secondary' as const },
{ name: 'AWS', icon: '☁️', level: 'Intermediate', color: 'warning' as const },
];
onMounted(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.remove('section-hidden');
entry.target.classList.add('section-visible');
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.1 },
);
if (sectionRef.value) observer.observe(sectionRef.value);
});
</script>

View File

@@ -0,0 +1,45 @@
export interface ContactFormData {
name: string;
phone: string;
email: string;
comment?: string;
}
export function useContact() {
const config = useRuntimeConfig();
const loading = ref(false);
const error = ref<string | null>(null);
const success = ref(false);
const aiReply = ref<string | null>(null);
async function submit(data: ContactFormData) {
loading.value = true;
error.value = null;
try {
const res = await $fetch<{ success: boolean; message: string; ai: { sentiment: string; category: string; autoReply: string } | null }>(
`${config.public.apiBase}/contact`,
{
method: 'POST',
body: data,
},
);
success.value = true;
aiReply.value = res.ai?.autoReply ?? null;
} catch (e: any) {
if (e.status === 429)
error.value = 'Слишком много запросов. Попробуйте через минуту.';
else
error.value = e.data?.message ?? 'Что-то пошло не так';
} finally {
loading.value = false;
}
}
function reset() {
success.value = false;
error.value = null;
aiReply.value = null;
}
return { loading, error, success, aiReply, submit, reset };
}

View File

@@ -0,0 +1,61 @@
<template>
<div class="min-h-screen bg-gray-950">
<main>
<nav class="fixed top-0 left-0 right-0 z-50 bg-gray-950/80 backdrop-blur-md border-b border-gray-800">
<div class="max-w-4xl mx-auto px-4 h-16 flex items-center justify-between">
<span class="text-white font-bold text-lg tracking-tight">
<span class="text-indigo-400">&lt;</span>dev<span class="text-indigo-400">/&gt;</span>
</span>
<div class="hidden md:flex items-center gap-6">
<a
v-for="link in navLinks"
:key="link.href"
:href="link.href"
class="text-gray-400 hover:text-white transition-colors duration-200 text-sm font-medium"
>
{{ link.label }}
</a>
</div>
<UButton
size="sm"
color="primary"
variant="soft"
icon="i-heroicons-envelope"
@click="scrollToContact"
>
Контакт
</UButton>
</div>
</nav>
</main>
<footer class="py-8 px-4 border-t border-gray-800 text-center text-gray-600 text-sm">
<p>© {{ new Date().getFullYear() }} Алексей Разработчик. Все права защищены.</p>
</footer>
</div>
</template>
<script setup lang="ts">
const navLinks = [
{ href: '#about', label: 'О себе' },
{ href: '#skills', label: 'Навыки' },
{ href: '#projects', label: 'Проекты' },
{ href: '#contact', label: 'Контакт' },
];
function scrollToContact() {
document.querySelector('#contact')?.scrollIntoView({ behavior: 'smooth' });
}
useHead({
title: 'Алексей — Fullstack Developer',
meta: [
{
name: 'description',
content: 'Fullstack Developer специализирующийся на NestJS, Nuxt 3 и TypeScript. Открыт для новых проектов.',
},
],
});
</script>

View File

@@ -0,0 +1,41 @@
html {
scroll-behavior: smooth;
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(32px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.animate-fade-in-up {
animation: fadeInUp 0.6s ease forwards;
}
.section-hidden {
opacity: 0;
transform: translateY(32px);
}
.section-visible {
animation: fadeInUp 0.7s ease forwards;
}
.hero-gradient {
background: radial-gradient(ellipse at 60% 40%, rgba(99, 102, 241, 0.15) 0%, transparent 60%),
radial-gradient(ellipse at 20% 80%, rgba(139, 92, 246, 0.1) 0%, transparent 50%);
}

23
frontend/nuxt.config.ts Normal file
View File

@@ -0,0 +1,23 @@
export default defineNuxtConfig({
modules: ['@nuxt/ui'],
devServer: {
host: '127.0.0.1',
port: 3000,
},
compatibilityDate: '2024-11-01',
devtools: { enabled: true },
css: ['~/assets/styles/main.scss'],
ui: {
theme: {
colors: ['primary', 'secondary', 'success', 'info', 'warning', 'error', 'indigo', 'sky', 'green', 'amber', 'red', 'cyan', 'pink', 'blue'],
},
},
runtimeConfig: {
public: {
apiBase: process.env.NUXT_PUBLIC_API_BASE ?? 'http://localhost:3001/api',
},
},
colorMode: {
preference: 'dark',
},
});

20
frontend/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "dev-landing-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "nuxt dev",
"build": "nuxt build",
"preview": "nuxt preview",
"lint": "eslint ."
},
"dependencies": {
"@nuxt/ui": "^4.9.0",
"nuxt": "^4.4.8"
},
"devDependencies": {
"@antfu/eslint-config": "^9.0.0",
"eslint": "^10.5.0",
"sass": "^1.101.0"
}
}

11080
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
allowBuilds:
'@parcel/watcher': true
esbuild: true
unrs-resolver: true
vue-demi: true

1
package.json Normal file
View File

@@ -0,0 +1 @@
{"name":"dev-landing","private":true,"scripts":{"backend":"cd backend && pnpm dev","frontend":"cd frontend && pnpm dev"}}