init
This commit is contained in:
5
.claude/settings.json
Normal file
5
.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"defaultMode": "bypassPermissions"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(pnpm dev *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
85
.claude/skills/taste-skill/SKILL.md
Normal file
85
.claude/skills/taste-skill/SKILL.md
Normal 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
48
.gitignore
vendored
Normal 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
456
PROMPT.md
Normal 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
22
backend/.env.example
Normal 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
16
backend/data/metrics.json
Normal 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
39
backend/package.json
Normal 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
6118
backend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
4
backend/pnpm-workspace.yaml
Normal file
4
backend/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
allowBuilds:
|
||||||
|
'@nestjs/core': true
|
||||||
|
'@scarf/scarf': true
|
||||||
|
unrs-resolver: true
|
||||||
10
backend/src/ai/ai.module.ts
Normal file
10
backend/src/ai/ai.module.ts
Normal 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 {}
|
||||||
56
backend/src/ai/ai.service.ts
Normal file
56
backend/src/ai/ai.service.ts
Normal 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
30
backend/src/app.module.ts
Normal 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 {}
|
||||||
23
backend/src/contact/contact.controller.ts
Normal file
23
backend/src/contact/contact.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend/src/contact/contact.module.ts
Normal file
13
backend/src/contact/contact.module.ts
Normal 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 {}
|
||||||
42
backend/src/contact/contact.service.ts
Normal file
42
backend/src/contact/contact.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
26
backend/src/contact/dto/create-contact.dto.ts
Normal file
26
backend/src/contact/dto/create-contact.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
18
backend/src/contact/interfaces/contact.interface.ts
Normal file
18
backend/src/contact/interfaces/contact.interface.ts
Normal 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;
|
||||||
|
}
|
||||||
33
backend/src/filters/all-exceptions.filter.ts
Normal file
33
backend/src/filters/all-exceptions.filter.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backend/src/health/health.controller.ts
Normal file
18
backend/src/health/health.controller.ts
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
7
backend/src/health/health.module.ts
Normal file
7
backend/src/health/health.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
||||||
9
backend/src/logger/logger.module.ts
Normal file
9
backend/src/logger/logger.module.ts
Normal 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 {}
|
||||||
61
backend/src/logger/logger.service.ts
Normal file
61
backend/src/logger/logger.service.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend/src/mail/mail.module.ts
Normal file
10
backend/src/mail/mail.module.ts
Normal 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 {}
|
||||||
76
backend/src/mail/mail.service.ts
Normal file
76
backend/src/mail/mail.service.ts
Normal 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
47
backend/src/main.ts
Normal 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();
|
||||||
16
backend/src/metrics/metrics.controller.ts
Normal file
16
backend/src/metrics/metrics.controller.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/src/metrics/metrics.module.ts
Normal file
12
backend/src/metrics/metrics.module.ts
Normal 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 {}
|
||||||
50
backend/src/metrics/metrics.service.ts
Normal file
50
backend/src/metrics/metrics.service.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
24
backend/src/storage/file-storage.service.ts
Normal file
24
backend/src/storage/file-storage.service.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
9
backend/src/storage/storage.module.ts
Normal file
9
backend/src/storage/storage.module.ts
Normal 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
21
backend/tsconfig.json
Normal 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
1
frontend/.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
NUXT_PUBLIC_API_BASE=http://localhost:3001/api
|
||||||
8
frontend/app.config.ts
Normal file
8
frontend/app.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export default defineAppConfig({
|
||||||
|
ui: {
|
||||||
|
colors: {
|
||||||
|
primary: 'indigo',
|
||||||
|
neutral: 'zinc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
41
frontend/app/assets/styles/main.scss
Normal file
41
frontend/app/assets/styles/main.scss
Normal 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%);
|
||||||
|
}
|
||||||
72
frontend/app/components/AboutSection.vue
Normal file
72
frontend/app/components/AboutSection.vue
Normal 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>
|
||||||
200
frontend/app/components/ContactForm.vue
Normal file
200
frontend/app/components/ContactForm.vue
Normal 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>
|
||||||
56
frontend/app/components/HeroSection.vue
Normal file
56
frontend/app/components/HeroSection.vue
Normal 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>
|
||||||
91
frontend/app/components/ProjectsSection.vue
Normal file
91
frontend/app/components/ProjectsSection.vue
Normal 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>
|
||||||
62
frontend/app/components/SkillsSection.vue
Normal file
62
frontend/app/components/SkillsSection.vue
Normal 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>
|
||||||
45
frontend/app/composables/useContact.ts
Normal file
45
frontend/app/composables/useContact.ts
Normal 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 };
|
||||||
|
}
|
||||||
61
frontend/app/pages/index.vue
Normal file
61
frontend/app/pages/index.vue
Normal 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"><</span>dev<span class="text-indigo-400">/></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>
|
||||||
41
frontend/assets/styles/main.scss
Normal file
41
frontend/assets/styles/main.scss
Normal 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
23
frontend/nuxt.config.ts
Normal 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
20
frontend/package.json
Normal 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
11080
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
5
frontend/pnpm-workspace.yaml
Normal file
5
frontend/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
allowBuilds:
|
||||||
|
'@parcel/watcher': true
|
||||||
|
esbuild: true
|
||||||
|
unrs-resolver: true
|
||||||
|
vue-demi: true
|
||||||
1
package.json
Normal file
1
package.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"name":"dev-landing","private":true,"scripts":{"backend":"cd backend && pnpm dev","frontend":"cd frontend && pnpm dev"}}
|
||||||
Reference in New Issue
Block a user