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