Compare commits
12 Commits
ccdb1d812b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| aab4c6b101 | |||
| 1e2fd40c9f | |||
| 443fd7c727 | |||
| 12e86ef7f1 | |||
| 202ac4627f | |||
| 4a6f0d83ce | |||
| e059d633d1 | |||
| db53960977 | |||
| 6daa1a5b09 | |||
| 20c86feb9e | |||
| c22257fc40 | |||
| e96d589236 |
76
.gitignore
vendored
76
.gitignore
vendored
@@ -1,42 +1,80 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules/
|
||||||
|
/.pnpm-store/
|
||||||
|
/.npm/
|
||||||
/.pnp
|
/.pnp
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
|
# package managers
|
||||||
.yarn/*
|
.yarn/*
|
||||||
!.yarn/patches
|
!.yarn/patches
|
||||||
!.yarn/plugins
|
!.yarn/plugins
|
||||||
!.yarn/releases
|
!.yarn/releases
|
||||||
|
!.yarn/sdks
|
||||||
!.yarn/versions
|
!.yarn/versions
|
||||||
|
|
||||||
# testing
|
# build output
|
||||||
/coverage
|
|
||||||
|
|
||||||
# next.js
|
|
||||||
/.next/
|
/.next/
|
||||||
/out/
|
/out/
|
||||||
|
/build/
|
||||||
|
/dist/
|
||||||
|
|
||||||
# production
|
# caches
|
||||||
/build
|
/.turbo/
|
||||||
|
/.cache/
|
||||||
|
/.swc/
|
||||||
|
.eslintcache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
# misc
|
# testing
|
||||||
.DS_Store
|
/coverage/
|
||||||
*.pem
|
|
||||||
|
|
||||||
# debug
|
# logs
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files
|
||||||
.env*
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env*.local
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
# deployment / hosting
|
||||||
.vercel
|
.vercel/
|
||||||
|
|
||||||
# typescript
|
# payload cms
|
||||||
*.tsbuildinfo
|
/.payload/
|
||||||
|
# /uploads/
|
||||||
|
# /media/
|
||||||
|
# /public/uploads/
|
||||||
|
# /public/media/
|
||||||
|
payload.db*
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
*.sqlite-journal
|
||||||
|
|
||||||
|
# editor / OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# secrets / local certs
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
|
||||||
|
# generated by next
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|||||||
51
AGENTS.md
Normal file
51
AGENTS.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Универсальный одностраничный лендинг-шаблон (shadcn + Payload-ready) для любого продукта/бизнеса. Композиция секций собрана в `src/app/page.tsx`; каждая секция — отдельный widget.
|
||||||
|
|
||||||
|
## Project Specifics
|
||||||
|
|
||||||
|
- `src/app/page.tsx` — только композиция: `Header` + секции в `<main>` + `Footer`. Не превращай его в монолит, добавляй/убирай секции, а не код блоков.
|
||||||
|
- Каждая секция лендинга — самостоятельный widget в `src/widgets/*-section.tsx`; правь нужную секцию точечно.
|
||||||
|
- Базовые UI-примитивы shadcn — в `src/shared/ui/*` (vendored, не переписывай).
|
||||||
|
- Проверка после правок: `pnpm lint` и `pnpm build`.
|
||||||
|
|
||||||
|
## Design System
|
||||||
|
|
||||||
|
Источник токенов — `src/app/globals.css` (`@theme` + `:root`/`.dark`). Шрифт — Roboto Flex (`--font-roboto-flex`). Работай через семантические классы Tailwind (`bg-primary`, `text-muted-foreground`, `border`), не хардкодь hex/oklch.
|
||||||
|
|
||||||
|
**Важно — это нейтральная база.** Токены сейчас grayscale (монохром, `--radius` 0.625rem) — дефолтная shadcn-тема без характера. Это значит главный риск шаблона — **дженерик «AI-лендинг»**, который выглядит как все остальные. Поэтому:
|
||||||
|
|
||||||
|
- **Сначала задай направление.** Под конкретный продукт выбери осознанную эстетику и зашей её в **токены** `globals.css` (`--primary`, `--secondary`, `--accent`, `--radius`, типографику) — а не точечными классами по секциям. Тогда все секции автоматически становятся on-brand.
|
||||||
|
- **Расширяй личность, а не сбрасывай в дефолт.** Если у проекта уже задан характер — держи его консистентно во всех секциях.
|
||||||
|
|
||||||
|
| Роль | База (neutral) | Назначение |
|
||||||
|
|---|---|---|
|
||||||
|
| `background` / `foreground` | white / near-black | фон и текст |
|
||||||
|
| `primary` | near-black | основные CTA, акценты |
|
||||||
|
| `secondary` / `muted` | light gray | вторичные поверхности, приглушённый текст |
|
||||||
|
| `accent` | light gray | подсветки (задай ярче под бренд) |
|
||||||
|
| `border` / `ring` | gray | границы, фокус |
|
||||||
|
|
||||||
|
Do / Don't:
|
||||||
|
- **Do:** определять тему через токены; держать единый ритм отступов/типографики между секциями; контент-first (оффер, доказательства, цена).
|
||||||
|
- **Don't:** оставлять монохром-дефолт «как есть» под реальный продукт; хардкодить цвета мимо токенов; стакать секции без выбранного направления — это и есть «одинаковый AI-лендинг».
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
Композиция: `src/app/page.tsx` → `Header` + секции + `Footer`.
|
||||||
|
|
||||||
|
| Блок | Widget |
|
||||||
|
|---|---|
|
||||||
|
| Шапка | `src/widgets/header.tsx` |
|
||||||
|
| Hero | `src/widgets/hero-section.tsx` |
|
||||||
|
| Features | `src/widgets/features-section.tsx` |
|
||||||
|
| Stats | `src/widgets/stats-section.tsx` |
|
||||||
|
| How it works | `src/widgets/how-it-works-section.tsx` |
|
||||||
|
| Comparison | `src/widgets/comparison-section.tsx` |
|
||||||
|
| Gallery | `src/widgets/gallery-section.tsx` |
|
||||||
|
| Social proof | `src/widgets/social-proof-section.tsx` |
|
||||||
|
| Team | `src/widgets/team-section.tsx` |
|
||||||
|
| Pricing | `src/widgets/pricing-section.tsx` |
|
||||||
|
| FAQ | `src/widgets/faq-section.tsx` |
|
||||||
|
| CTA | `src/widgets/cta-section.tsx` |
|
||||||
|
| Footer | `src/widgets/footer.tsx` |
|
||||||
141
README.md
141
README.md
@@ -1,117 +1,72 @@
|
|||||||
## О проекте
|
# 🚀 Универсальный шаблон лендинга TaskFlow
|
||||||
|
|
||||||
Это проект на **Next.js** (App Router), использующий UI-компоненты **shadcn/ui**, утилитарную CSS-библиотеку **Tailwind CSS** и библиотеку анимаций **framer-motion**.
|
Профессиональный шаблон продающей страницы по методологии **Feature-Sliced Design (FSD)**.
|
||||||
|
|
||||||
## Стек и роль технологий
|
## 📋 Архитектура
|
||||||
|
|
||||||
- **Next.js**: фреймворк для React с рендерингом на сервере, маршрутизацией и API-роутами.
|
Проект построен по **FSD**:
|
||||||
- Документация: [nextjs.org/docs](https://nextjs.org/docs)
|
|
||||||
- **shadcn/ui**: коллекция доступных и настраиваемых компонентов на базе Radix UI и Tailwind.
|
|
||||||
- Документация: [ui.shadcn.com](https://ui.shadcn.com/)
|
|
||||||
- **Tailwind CSS**: утилитарные классы для быстрой стилизации интерфейсов.
|
|
||||||
- Документация: [tailwindcss.com/docs](https://tailwindcss.com/docs)
|
|
||||||
- **framer-motion**: мощная библиотека для анимаций в React-компонентах.
|
|
||||||
- Документация: [www.framer.com/motion](https://www.framer.com/motion)
|
|
||||||
|
|
||||||
## Быстрый старт
|
|
||||||
|
|
||||||
1. Установите зависимости:
|
|
||||||
```bash
|
|
||||||
pnpm install
|
|
||||||
```
|
```
|
||||||
2. Запустите дев-сервер:
|
src/
|
||||||
```bash
|
├── app/ # Слой приложения
|
||||||
pnpm dev
|
├── widgets/ # Композитные секции
|
||||||
|
├── features/ # Переиспользуемые блоки
|
||||||
|
└── shared/ # Общий код (UI-kit, хуки)
|
||||||
```
|
```
|
||||||
3. Откройте `http://localhost:3000` в браузере.
|
|
||||||
|
|
||||||
## Структура проекта (основное)
|
**Принципы:** Слоистая архитектура, изолированность, переиспользование.
|
||||||
|
|
||||||
- `src/app/(frontend)`: публичные страницы, глобальные стили (`globals.css`), макеты и корневые компоненты фронтенда.
|
## 🛠 Технологии
|
||||||
- `src/shared/ui`: библиотека переиспользуемых UI-компонентов на базе shadcn/ui.
|
|
||||||
- `src/shared/lib`: утилиты и вспомогательные функции.
|
|
||||||
|
|
||||||
## Архитектура: Feature‑Sliced Design (FSD)
|
- **Next.js 14** - React framework
|
||||||
|
- **TypeScript** - Type safety
|
||||||
|
- **Tailwind CSS 4** - Utility-first CSS
|
||||||
|
- **Framer Motion** - Анимации
|
||||||
|
- **shadcn/ui** - UI компоненты
|
||||||
|
|
||||||
Проект следует принципам **Feature‑Sliced Design** для масштабируемой фронтенд-архитектуры. Подробнее: [feature-sliced.design/docs](https://feature-sliced.design/docs).
|
## 📁 Структура
|
||||||
|
|
||||||
- **app**: инициализация приложения, провайдеры, глобальные стили и макеты (в проекте — `src/app`, в т.ч. `src/app/(frontend)`).
|
```
|
||||||
- **processes**: долгоживущие бизнес-процессы (слой появится при необходимости).
|
src/
|
||||||
- **pages**: страницы и маршруты. В Next.js App Router это структурируется через `src/app` и сегменты маршрутов.
|
├── app/ # layout.tsx, page.tsx, globals.css
|
||||||
- **widgets**: составные блоки из нескольких features/entities (см. `src/widgets`).
|
├── widgets/ # header, hero-section, features-section
|
||||||
- **features**: пользовательские сценарии и их логика/компоненты (см. `src/features`).
|
├── features/ # section-container, feature-card
|
||||||
- **entities**: бизнес-сущности и их представления/модели (см. `src/entities`).
|
└── shared/ # ui/, hooks/, lib/
|
||||||
- **shared**: переиспользуемые модули, UI, утилиты, конфиги (см. `src/shared`).
|
```
|
||||||
|
|
||||||
Рекомендации:
|
## 🧩 Паттерны компонентов
|
||||||
|
|
||||||
- Импорты направлены сверху вниз: `shared → entities → shared → widgets → features → pages/app`.
|
```typescript
|
||||||
- Публичный API каждого слайса/модуля экспортируется через `index`-файлы.
|
"use client";
|
||||||
- Избегайте циклических зависимостей между слоями.
|
|
||||||
|
|
||||||
## Соглашения по наименованию
|
import { Button } from "@/shared/ui/button";
|
||||||
|
|
||||||
- Имена файлов компонентов: `kebab-case`.
|
export function MyFeature() {
|
||||||
- Примеры: `date-picker.tsx`, `user-card.tsx`, `navigation-menu.tsx`.
|
|
||||||
- Имена React-компонентов (идентификаторы): `PascalCase`.
|
|
||||||
- Примеры: `DatePicker`, `UserCard`, `NavigationMenu`.
|
|
||||||
|
|
||||||
## UI на базе shadcn/ui
|
|
||||||
|
|
||||||
Компоненты интерфейса собраны и переиспользуются в каталоге `src/shared/ui`. Они основаны на Tailwind и Radix UI, легко настраиваются через классы и токены дизайна. Рекомендуется ориентироваться на официальные примеры и паттерны из документации:
|
|
||||||
|
|
||||||
- Руководство и примеры: [ui.shadcn.com](https://ui.shadcn.com/)
|
|
||||||
|
|
||||||
## Стилизация: Tailwind CSS
|
|
||||||
|
|
||||||
Проект использует Tailwind для быстрой и согласованной стилизации. Ключевые ресурсы:
|
|
||||||
|
|
||||||
- Документация: [tailwindcss.com/docs](https://tailwindcss.com/docs)
|
|
||||||
- Руководства по best practices: [tailwindcss.com/blog](https://tailwindcss.com/blog)
|
|
||||||
|
|
||||||
## Анимации: framer-motion
|
|
||||||
|
|
||||||
Для анимаций используется `framer-motion`. Это мощная библиотека для создания плавных и производительных анимаций в React-компонентах.
|
|
||||||
|
|
||||||
Пример использования:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
|
|
||||||
function AnimatedComponent() {
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<section className="py-20">
|
||||||
initial={{ opacity: 0, y: 20 }}
|
<Button>Action</Button>
|
||||||
animate={{ opacity: 1, y: 0 }}
|
</section>
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
>
|
|
||||||
Анимированный контент
|
|
||||||
</motion.div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Документация: [www.framer.com/motion](https://www.framer.com/motion)
|
**Правила:** 'use client' для клиентских, @/ alias для импортов, PascalCase, Named exports.
|
||||||
|
|
||||||
## Команды
|
## 🎨 Стилизация
|
||||||
|
|
||||||
|
Tailwind CSS 4 с CSS переменными в `globals.css`.
|
||||||
|
|
||||||
|
## 📦 Импорты
|
||||||
|
|
||||||
|
**ВСЕГДА используйте @/ alias:** `import { Button } from '@/shared/ui/button';`
|
||||||
|
|
||||||
|
## 🎬 Анимации
|
||||||
|
|
||||||
|
Framer Motion: `initial={{ opacity: 0, y: 20 }}` → `whileInView={{ opacity: 1, y: 0 }}`
|
||||||
|
|
||||||
|
## 🚀 Команды
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Запуск дев-сервера
|
pnpm dev # Запуск dev сервера
|
||||||
pnpm dev
|
pnpm build # Production build
|
||||||
|
|
||||||
# Билд продакшн-версии
|
|
||||||
pnpm build
|
|
||||||
|
|
||||||
# Предпросмотр продакшн-сборки
|
|
||||||
pnpm start
|
|
||||||
|
|
||||||
# Линтинг
|
|
||||||
pnpm lint
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Полезные ссылки
|
|
||||||
|
|
||||||
- Next.js: [nextjs.org/docs](https://nextjs.org/docs)
|
|
||||||
- shadcn/ui: [ui.shadcn.com](https://ui.shadcn.com/)
|
|
||||||
- Tailwind CSS: [tailwindcss.com/docs](https://tailwindcss.com/docs)
|
|
||||||
- framer-motion: [www.framer.com/motion](https://www.framer.com/motion)
|
|
||||||
|
|||||||
@@ -1,25 +1,139 @@
|
|||||||
import { dirname } from "path";
|
import reactPlugin from "@eslint-react/eslint-plugin"
|
||||||
import { fileURLToPath } from "url";
|
import nextPlugin from "@next/eslint-plugin-next"
|
||||||
import { FlatCompat } from "@eslint/eslintrc";
|
import tsPlugin from "@typescript-eslint/eslint-plugin"
|
||||||
|
import tsParser from "@typescript-eslint/parser"
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks"
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const config = [
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
const compat = new FlatCompat({
|
|
||||||
baseDirectory: __dirname,
|
|
||||||
});
|
|
||||||
|
|
||||||
const eslintConfig = [
|
|
||||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
|
||||||
{
|
{
|
||||||
ignores: [
|
ignores: [
|
||||||
"node_modules/**",
|
|
||||||
".next/**",
|
".next/**",
|
||||||
"out/**",
|
"out/**",
|
||||||
"build/**",
|
"build/**",
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
|
"src/shared/ui/carousel.tsx",
|
||||||
|
"src/shared/ui/chart.tsx",
|
||||||
|
"src/shared/ui/resizable.tsx",
|
||||||
|
"src/shared/ui/sidebar.tsx",
|
||||||
|
"src/shared/hooks/theme-message-listener.tsx",
|
||||||
|
"src/shared/hooks/use-mobile.ts",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
{
|
||||||
|
files: ["**/*.{js,jsx,mjs,ts,tsx,mts,cts}"],
|
||||||
|
...nextPlugin.configs["core-web-vitals"],
|
||||||
|
},
|
||||||
|
...tsPlugin.configs["flat/recommended"],
|
||||||
|
{
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
parser: tsParser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
sourceType: "module",
|
||||||
|
ecmaFeatures: {
|
||||||
|
jsx: true,
|
||||||
|
},
|
||||||
|
project: "./tsconfig.json",
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.{jsx,tsx}"],
|
||||||
|
name: "@eslint-react/recommended",
|
||||||
|
plugins: {
|
||||||
|
"@eslint-react": reactPlugin,
|
||||||
|
},
|
||||||
|
rules: reactPlugin.configs.recommended.rules,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.flat["recommended-latest"].rules,
|
||||||
|
"@typescript-eslint/no-explicit-any": "error",
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
caughtErrorsIgnorePattern: "^_",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-unsafe-assignment": "error",
|
||||||
|
"@typescript-eslint/no-unsafe-member-access": "error",
|
||||||
|
"@typescript-eslint/no-unsafe-call": "error",
|
||||||
|
"@typescript-eslint/no-unsafe-return": "error",
|
||||||
|
"@typescript-eslint/no-unsafe-argument": "error",
|
||||||
|
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||||
|
"@typescript-eslint/no-floating-promises": "error",
|
||||||
|
"@typescript-eslint/no-misused-promises": "error",
|
||||||
|
"@typescript-eslint/await-thenable": "error",
|
||||||
|
"@typescript-eslint/no-unnecessary-type-assertion": "error",
|
||||||
|
"@typescript-eslint/no-non-null-assertion": "warn",
|
||||||
|
"@typescript-eslint/prefer-nullish-coalescing": "warn",
|
||||||
|
"@typescript-eslint/prefer-optional-chain": "warn",
|
||||||
|
"@typescript-eslint/no-unnecessary-condition": "off",
|
||||||
|
"@typescript-eslint/no-redundant-type-constituents": "error",
|
||||||
|
"@typescript-eslint/ban-ts-comment": [
|
||||||
|
"off",
|
||||||
|
{
|
||||||
|
"ts-expect-error": "allow-with-description",
|
||||||
|
"ts-ignore": true,
|
||||||
|
"ts-nocheck": true,
|
||||||
|
"ts-check": false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"no-unused-vars": "off",
|
||||||
|
"react-hooks/rules-of-hooks": "error",
|
||||||
|
"react-hooks/exhaustive-deps": "warn",
|
||||||
|
"no-console": ["warn", { allow: ["warn", "error", "log"] }],
|
||||||
|
"no-debugger": "error",
|
||||||
|
"no-alert": "warn",
|
||||||
|
"no-var": "error",
|
||||||
|
"prefer-const": "error",
|
||||||
|
"prefer-arrow-callback": "warn",
|
||||||
|
"no-duplicate-imports": "error",
|
||||||
|
"no-unreachable": "error",
|
||||||
|
"no-unused-expressions": "error",
|
||||||
|
"no-useless-return": "error",
|
||||||
|
"no-useless-escape": "error",
|
||||||
|
"no-constant-condition": "error",
|
||||||
|
"no-empty": "warn",
|
||||||
|
"no-extra-semi": "error",
|
||||||
|
"no-func-assign": "error",
|
||||||
|
"no-inner-declarations": "error",
|
||||||
|
"no-irregular-whitespace": "error",
|
||||||
|
"no-obj-calls": "error",
|
||||||
|
"no-sparse-arrays": "error",
|
||||||
|
"no-undef": "off",
|
||||||
|
"no-unexpected-multiline": "error",
|
||||||
|
"no-unreachable-loop": "error",
|
||||||
|
"use-isnan": "error",
|
||||||
|
"valid-typeof": "error",
|
||||||
|
"@next/next/no-html-link-for-pages": "error",
|
||||||
|
"@next/next/no-img-element": "warn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.{jsx,tsx}"],
|
||||||
|
rules: {
|
||||||
|
"@eslint-react/no-missing-component-display-name": "warn",
|
||||||
|
"@eslint-react/dom-no-unknown-property": "error",
|
||||||
|
"@eslint-react/no-missing-key": "error",
|
||||||
|
"@eslint-react/no-duplicate-key": "error",
|
||||||
|
"@eslint-react/no-array-index-key": "off",
|
||||||
|
"@eslint-react/dom-no-dangerously-set-innerhtml": "off",
|
||||||
|
"@eslint-react/no-direct-mutation-state": "error",
|
||||||
|
"@eslint-react/no-nested-component-definitions": "off",
|
||||||
|
"@eslint-react/no-use-context": "off",
|
||||||
|
"@eslint-react/no-context-provider": "off",
|
||||||
|
"@eslint-react/use-state": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
export default eslintConfig;
|
export default config
|
||||||
|
|||||||
@@ -1,6 +1,32 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
|
// Оптимизация бандла
|
||||||
|
experimental: {
|
||||||
|
optimizePackageImports: [
|
||||||
|
"lucide-react",
|
||||||
|
"@radix-ui/react-accordion",
|
||||||
|
"@radix-ui/react-alert-dialog",
|
||||||
|
"@radix-ui/react-avatar",
|
||||||
|
"@radix-ui/react-checkbox",
|
||||||
|
"@radix-ui/react-dialog",
|
||||||
|
"@radix-ui/react-dropdown-menu",
|
||||||
|
"@radix-ui/react-label",
|
||||||
|
"@radix-ui/react-popover",
|
||||||
|
"@radix-ui/react-select",
|
||||||
|
"@radix-ui/react-separator",
|
||||||
|
"@radix-ui/react-slider",
|
||||||
|
"@radix-ui/react-switch",
|
||||||
|
"@radix-ui/react-tabs",
|
||||||
|
"@radix-ui/react-tooltip",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Компрессия и оптимизация
|
||||||
|
compress: true,
|
||||||
|
// Оптимизация изображений
|
||||||
|
images: {
|
||||||
|
formats: ["image/avif", "image/webp"],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
56
package.json
56
package.json
@@ -12,62 +12,64 @@
|
|||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-hover-card": "^1.1.15",
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-menubar": "^1.1.16",
|
"@radix-ui/react-menubar": "^1.1.16",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-progress": "^1.1.7",
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-toggle": "^1.1.10",
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"framer-motion": "^11.0.0",
|
"framer-motion": "^12.38.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^1.14.0",
|
||||||
"next": "14.2.18",
|
"next": "16.2.5",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "18.3.1",
|
"react": "^19.2.6",
|
||||||
"react-day-picker": "^9.11.1",
|
"react-day-picker": "^9.14.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "^19.2.6",
|
||||||
"react-hook-form": "^7.65.0",
|
"react-hook-form": "^7.75.0",
|
||||||
"react-resizable-panels": "^3.0.6",
|
"react-resizable-panels": "^4",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^3.8.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.5.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint-react/eslint-plugin": "^5.7.4",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@next/eslint-plugin-next": "^16.2.5",
|
||||||
"@types/node": "^20",
|
"@tailwindcss/postcss": "^4.2.4",
|
||||||
"@types/react": "18.2.48",
|
"@types/node": "^25.6.0",
|
||||||
"@types/react-dom": "18.2.18",
|
"@types/react": "^19.2.14",
|
||||||
"eslint": "^8.57.1",
|
"@types/react-dom": "^19.2.3",
|
||||||
"eslint-config-next": "14.2.18",
|
"@typescript-eslint/eslint-plugin": "^8.59.2",
|
||||||
"tailwindcss": "^4",
|
"@typescript-eslint/parser": "^8.59.2",
|
||||||
|
"eslint": "^10.3.0",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"tailwindcss": "^4.2.4",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5342
pnpm-lock.yaml
generated
5342
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,138 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { Server, Code } from "lucide-react";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center p-8 pb-20 sm:p-20 bg-linear-to-br from-background via-background to-muted/20 relative overflow-hidden">
|
|
||||||
<div className="w-full space-y-16 relative z-10">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
|
||||||
className="text-center w-fit mx-auto space-y-1"
|
|
||||||
>
|
|
||||||
<motion.h1
|
|
||||||
initial={{ opacity: 0, y: 5 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.5, delay: 0.1, ease: "easeOut" }}
|
|
||||||
className="text-5xl sm:text-6xl md:text-7xl lg:text-8xl font-bold text-foreground tracking-tight wrap-break-word"
|
|
||||||
>
|
|
||||||
<span className="block">Tungulov.space</span>
|
|
||||||
</motion.h1>
|
|
||||||
|
|
||||||
<motion.p
|
|
||||||
initial={{ opacity: 0, y: 5 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.5, delay: 0.2, ease: "easeOut" }}
|
|
||||||
className="text-lg sm:text-xl md:text-2xl text-muted-foreground font-medium wrap-break-word"
|
|
||||||
>
|
|
||||||
Избавляем вас от головной боли разработки
|
|
||||||
</motion.p>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.5, delay: 0.4, ease: "easeOut" }}
|
|
||||||
className="flex items-start justify-center gap-8 sm:gap-20"
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
animate={{
|
|
||||||
y: [0, -2, 0],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 3,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: "easeInOut",
|
|
||||||
}}
|
|
||||||
whileHover={{ scale: 1.15, rotate: 5 }}
|
|
||||||
className="text-foreground/60 hover:text-foreground cursor-pointer transition-colors duration-300"
|
|
||||||
>
|
|
||||||
<Server
|
|
||||||
className="size-12 sm:size-14 md:size-20"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-4">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ duration: 0.5, delay: 0.3, ease: "easeOut" }}
|
|
||||||
className="relative"
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
animate={{
|
|
||||||
boxShadow: [
|
|
||||||
"0 0 20px rgba(59, 130, 246, 0.3), 0 4px 6px -1px rgba(0, 0, 0, 0.1)",
|
|
||||||
"0 0 40px rgba(147, 51, 234, 0.4), 0 10px 15px -3px rgba(0, 0, 0, 0.1)",
|
|
||||||
"0 0 20px rgba(59, 130, 246, 0.3), 0 4px 6px -1px rgba(0, 0, 0, 0.1)",
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 3,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: "easeInOut",
|
|
||||||
}}
|
|
||||||
className="relative px-8 py-5 sm:px-10 sm:py-6 rounded-2xl bg-linear-to-r from-blue-500/10 via-purple-500/10 to-pink-500/10 backdrop-blur-sm border border-blue-500/30 hover:border-purple-500/50 transition-all duration-300 cursor-pointer group"
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 rounded-2xl bg-linear-to-r from-blue-500/20 via-purple-500/20 to-pink-500/20 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
|
||||||
<span className="relative z-10 font-semibold text-base sm:text-lg md:text-xl bg-linear-to-r from-blue-600 via-purple-600 to-pink-600 bg-clip-text text-transparent whitespace-nowrap">
|
|
||||||
Давайте приступим к воплощению вашей идеи
|
|
||||||
</span>
|
|
||||||
<motion.div
|
|
||||||
animate={{
|
|
||||||
backgroundPosition: ["0% 50%", "100% 50%", "0% 50%"],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 5,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: "linear",
|
|
||||||
}}
|
|
||||||
className="absolute inset-0 rounded-2xl bg-linear-to-r from-blue-500/0 via-purple-500/20 to-pink-500/0 bg-size-[200%_100%] opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 5 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.5, delay: 0.5, ease: "easeOut" }}
|
|
||||||
className="text-center space-y-1"
|
|
||||||
>
|
|
||||||
<p className="text-sm sm:text-base text-muted-foreground leading-relaxed">
|
|
||||||
Напишите о вашем проекте
|
|
||||||
</p>
|
|
||||||
<p className="text-sm sm:text-base text-muted-foreground leading-relaxed">
|
|
||||||
и уже через пару мгновений протестируйте демо
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
animate={{
|
|
||||||
y: [0, 2, 0],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 3,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: "easeInOut",
|
|
||||||
delay: 0.5,
|
|
||||||
}}
|
|
||||||
whileHover={{ scale: 1.15, rotate: -5 }}
|
|
||||||
className="text-foreground/60 hover:text-foreground cursor-pointer transition-colors duration-300"
|
|
||||||
>
|
|
||||||
<Code
|
|
||||||
className="size-12 sm:size-14 md:size-20"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server'
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
// Basic health check
|
|
||||||
const health = {
|
|
||||||
status: 'healthy',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
uptime: process.uptime(),
|
|
||||||
environment: process.env.NODE_ENV || 'development',
|
|
||||||
version: process.env.npm_package_version || '1.0.0',
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(health, { status: 200 })
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
status: 'unhealthy',
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
{ status: 503 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -76,6 +76,21 @@
|
|||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
|
||||||
|
/* Gradient colors для лендинга */
|
||||||
|
--gradient-from: oklch(0.67 0.24 252);
|
||||||
|
--gradient-via: oklch(0.62 0.23 295);
|
||||||
|
--gradient-to: oklch(0.72 0.19 345);
|
||||||
|
|
||||||
|
/* Success/Feature accent */
|
||||||
|
--feature-accent: oklch(0.65 0.21 165);
|
||||||
|
|
||||||
|
/* Spacing для секций */
|
||||||
|
--section-padding-y: 5rem;
|
||||||
|
--section-padding-y-lg: 8rem;
|
||||||
|
|
||||||
|
/* Container */
|
||||||
|
--container-max-width: 80rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -110,6 +125,11 @@
|
|||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
|
||||||
|
--gradient-from: oklch(0.65 0.25 252);
|
||||||
|
--gradient-via: oklch(0.60 0.24 295);
|
||||||
|
--gradient-to: oklch(0.70 0.20 345);
|
||||||
|
--feature-accent: oklch(0.63 0.22 165);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -120,3 +140,17 @@
|
|||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.text-gradient {
|
||||||
|
@apply bg-gradient-to-r from-[var(--gradient-from)] via-[var(--gradient-via)] to-[var(--gradient-to)] bg-clip-text text-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-gradient-primary {
|
||||||
|
@apply bg-gradient-to-br from-[var(--gradient-from)]/10 via-[var(--gradient-via)]/10 to-[var(--gradient-to)]/10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-gradient {
|
||||||
|
border-image: linear-gradient(to right, var(--gradient-from), var(--gradient-via), var(--gradient-to)) 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { Roboto_Flex } from "next/font/google";
|
import { Roboto_Flex } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { ThemeProvider } from "@/shared/hooks/theme-provider";
|
import { ThemeProvider } from "@/shared/hooks/theme-provider";
|
||||||
|
import { ThemeMessageListener } from "@/shared/hooks/theme-message-listener";
|
||||||
|
|
||||||
const robotoFlex = Roboto_Flex({
|
const robotoFlex = Roboto_Flex({
|
||||||
variable: "--font-roboto-flex",
|
variable: "--font-roboto-flex",
|
||||||
@@ -28,6 +29,7 @@ export default function RootLayout({
|
|||||||
enableSystem
|
enableSystem
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
|
<ThemeMessageListener />
|
||||||
{children}
|
{children}
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
41
src/app/page.tsx
Normal file
41
src/app/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Header } from "@/widgets/header";
|
||||||
|
import { HeroSection } from "@/widgets/hero-section";
|
||||||
|
import { FeaturesSection } from "@/widgets/features-section";
|
||||||
|
import { StatsSection } from "@/widgets/stats-section";
|
||||||
|
import { HowItWorksSection } from "@/widgets/how-it-works-section";
|
||||||
|
import { ComparisonSection } from "@/widgets/comparison-section";
|
||||||
|
import { GallerySection } from "@/widgets/gallery-section";
|
||||||
|
import { SocialProofSection } from "@/widgets/social-proof-section";
|
||||||
|
import { TeamSection } from "@/widgets/team-section";
|
||||||
|
import { PricingSection } from "@/widgets/pricing-section";
|
||||||
|
import { FaqSection } from "@/widgets/faq-section";
|
||||||
|
import { CtaSection } from "@/widgets/cta-section";
|
||||||
|
import { Footer } from "@/widgets/footer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Главная страница - композиция всех секций лендинга
|
||||||
|
* Универсальный шаблон для любого продукта/бизнеса
|
||||||
|
*/
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<HeroSection />
|
||||||
|
<FeaturesSection />
|
||||||
|
<StatsSection />
|
||||||
|
<HowItWorksSection />
|
||||||
|
<ComparisonSection />
|
||||||
|
<GallerySection />
|
||||||
|
<SocialProofSection />
|
||||||
|
<TeamSection />
|
||||||
|
<PricingSection />
|
||||||
|
<FaqSection />
|
||||||
|
<CtaSection />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
src/features/comparison-item.tsx
Normal file
115
src/features/comparison-item.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { LucideIcon, X, Check } from "lucide-react";
|
||||||
|
|
||||||
|
export interface ComparisonItemProps {
|
||||||
|
before: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
};
|
||||||
|
after: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
};
|
||||||
|
variant?: "side-by-side" | "overlay";
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Элемент сравнения "до/после"
|
||||||
|
* Используется в Comparison Section
|
||||||
|
*/
|
||||||
|
export function ComparisonItem({
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
variant = "side-by-side",
|
||||||
|
className,
|
||||||
|
}: ComparisonItemProps) {
|
||||||
|
if (variant === "overlay") {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
className={cn("relative", className)}
|
||||||
|
>
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
{/* Before */}
|
||||||
|
<div className="relative p-6 rounded-xl bg-destructive/10 border border-destructive/20">
|
||||||
|
<div className="absolute -top-3 -left-3 w-8 h-8 rounded-full bg-destructive flex items-center justify-center">
|
||||||
|
<X className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold mb-2 text-destructive">{before.title}</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">{before.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* After */}
|
||||||
|
<div className="relative p-6 rounded-xl bg-[var(--feature-accent)]/10 border border-[var(--feature-accent)]/20">
|
||||||
|
<div className="absolute -top-3 -right-3 w-8 h-8 rounded-full bg-[var(--feature-accent)] flex items-center justify-center">
|
||||||
|
<Check className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold mb-2 text-[var(--feature-accent)]">{after.title}</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">{after.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
className={cn("flex items-center gap-6", className)}
|
||||||
|
>
|
||||||
|
{/* Before */}
|
||||||
|
<div className="flex-1 p-4 rounded-lg bg-muted/50 relative">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{before.icon && (
|
||||||
|
<div className="p-2 rounded-lg bg-destructive/10">
|
||||||
|
<before.icon className="w-5 h-5 text-destructive" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-1 line-through text-muted-foreground">
|
||||||
|
{before.title}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">{before.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Arrow */}
|
||||||
|
<div className="flex-shrink-0 text-2xl font-bold text-[var(--feature-accent)]">
|
||||||
|
→
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* After */}
|
||||||
|
<div className="flex-1 p-4 rounded-lg bg-[var(--feature-accent)]/10 border border-[var(--feature-accent)]/20">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{after.icon && (
|
||||||
|
<div className="p-2 rounded-lg bg-[var(--feature-accent)]/10">
|
||||||
|
<after.icon className="w-5 h-5 text-[var(--feature-accent)]" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-1 text-[var(--feature-accent)]">
|
||||||
|
{after.title}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">{after.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
88
src/features/feature-card.tsx
Normal file
88
src/features/feature-card.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { ArrowRight, LucideIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export interface FeatureCardProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
variant?: "default" | "bordered" | "filled";
|
||||||
|
link?: {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
default: "bg-card hover:bg-accent/50",
|
||||||
|
bordered: "bg-transparent border-2 border-border hover:border-[var(--feature-accent)]",
|
||||||
|
filled: "bg-gradient-primary border border-border/50",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Карточка фичи/преимущества
|
||||||
|
* Используется в Features Section
|
||||||
|
*/
|
||||||
|
export function FeatureCard({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
variant = "default",
|
||||||
|
link,
|
||||||
|
className,
|
||||||
|
}: FeatureCardProps) {
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 inline-flex p-3 rounded-lg bg-[var(--feature-accent)]/10">
|
||||||
|
<Icon className="w-6 h-6 text-[var(--feature-accent)]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold mb-2">{title}</h3>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground leading-relaxed">{description}</p>
|
||||||
|
|
||||||
|
{link && (
|
||||||
|
<div className="mt-4 inline-flex items-center gap-2 text-sm font-medium text-[var(--feature-accent)] group-hover:gap-3 transition-all">
|
||||||
|
{link.label}
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const card = (
|
||||||
|
<motion.div
|
||||||
|
initial="rest"
|
||||||
|
whileHover="hover"
|
||||||
|
variants={{
|
||||||
|
rest: { scale: 1, y: 0 },
|
||||||
|
hover: { scale: 1.02, y: -5 },
|
||||||
|
}}
|
||||||
|
transition={{ type: "spring", stiffness: 300, damping: 20 }}
|
||||||
|
className={cn(
|
||||||
|
"group relative p-6 rounded-xl transition-all duration-300",
|
||||||
|
variantClasses[variant],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (link) {
|
||||||
|
return (
|
||||||
|
<Link href={link.href} className="block">
|
||||||
|
{card}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
92
src/features/gradient-background.tsx
Normal file
92
src/features/gradient-background.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
export interface GradientBackgroundProps {
|
||||||
|
variant?: "radial" | "mesh" | "animated";
|
||||||
|
className?: string;
|
||||||
|
opacity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Декоративный градиентный фон для секций
|
||||||
|
* Варианты: radial (радиальный), mesh (сеточный), animated (с анимацией)
|
||||||
|
*/
|
||||||
|
export function GradientBackground({
|
||||||
|
variant = "radial",
|
||||||
|
className,
|
||||||
|
opacity = 0.5,
|
||||||
|
}: GradientBackgroundProps) {
|
||||||
|
if (variant === "radial") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 pointer-events-none overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{ opacity }}
|
||||||
|
>
|
||||||
|
<div className="absolute top-0 left-1/4 w-96 h-96 bg-[var(--gradient-from)] rounded-full blur-3xl opacity-20" />
|
||||||
|
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-[var(--gradient-via)] rounded-full blur-3xl opacity-20" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === "mesh") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 pointer-events-none overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{ opacity }}
|
||||||
|
>
|
||||||
|
<div className="absolute top-1/4 left-0 w-72 h-72 bg-[var(--gradient-from)] rounded-full blur-3xl opacity-30 mix-blend-multiply dark:mix-blend-lighten" />
|
||||||
|
<div className="absolute top-0 right-1/4 w-72 h-72 bg-[var(--gradient-via)] rounded-full blur-3xl opacity-30 mix-blend-multiply dark:mix-blend-lighten" />
|
||||||
|
<div className="absolute bottom-1/4 left-1/3 w-72 h-72 bg-[var(--gradient-to)] rounded-full blur-3xl opacity-30 mix-blend-multiply dark:mix-blend-lighten" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === "animated") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 pointer-events-none overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{ opacity }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
x: [0, 100, 0],
|
||||||
|
y: [0, -100, 0],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 20,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "linear",
|
||||||
|
}}
|
||||||
|
className="absolute top-1/4 left-1/4 w-96 h-96 bg-[var(--gradient-from)] rounded-full blur-3xl opacity-20"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
x: [0, -100, 0],
|
||||||
|
y: [0, 100, 0],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 15,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "linear",
|
||||||
|
}}
|
||||||
|
className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-[var(--gradient-via)] rounded-full blur-3xl opacity-20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
107
src/features/logo-cloud.tsx
Normal file
107
src/features/logo-cloud.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export interface LogoCloudProps {
|
||||||
|
logos: Array<{
|
||||||
|
name: string;
|
||||||
|
src: string;
|
||||||
|
url?: string;
|
||||||
|
}>;
|
||||||
|
variant?: "grid" | "marquee";
|
||||||
|
animated?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Облако логотипов компаний-клиентов
|
||||||
|
* Используется в Social Proof Section
|
||||||
|
*/
|
||||||
|
export function LogoCloud({
|
||||||
|
logos,
|
||||||
|
variant = "grid",
|
||||||
|
animated = false,
|
||||||
|
className,
|
||||||
|
}: LogoCloudProps) {
|
||||||
|
if (variant === "marquee") {
|
||||||
|
return (
|
||||||
|
<div className={cn("relative overflow-hidden", className)}>
|
||||||
|
<motion.div
|
||||||
|
animate={
|
||||||
|
animated
|
||||||
|
? {
|
||||||
|
x: [0, -1000],
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
transition={{
|
||||||
|
duration: 30,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "linear",
|
||||||
|
}}
|
||||||
|
className="flex gap-12 items-center"
|
||||||
|
>
|
||||||
|
{/* Дублируем логотипы для бесшовной прокрутки */}
|
||||||
|
{[...logos, ...logos].map((logo, index) => (
|
||||||
|
<LogoItem key={`${logo.name}-${index}`} logo={logo} />
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
whileInView={{ opacity: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
className={cn(
|
||||||
|
"grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-8 items-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{logos.map((logo) => (
|
||||||
|
<LogoItem key={logo.name} logo={logo} />
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogoItem({
|
||||||
|
logo,
|
||||||
|
}: {
|
||||||
|
logo: { name: string; src: string; url?: string };
|
||||||
|
}) {
|
||||||
|
const content = (
|
||||||
|
<div className="relative h-12 grayscale hover:grayscale-0 opacity-50 hover:opacity-100 transition-all duration-300">
|
||||||
|
<Image
|
||||||
|
src={logo.src}
|
||||||
|
alt={logo.name}
|
||||||
|
fill
|
||||||
|
className="object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (logo.url) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={logo.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="flex items-center justify-center">{content}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
93
src/features/portfolio-item.tsx
Normal file
93
src/features/portfolio-item.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Badge } from "@/shared/ui/badge";
|
||||||
|
|
||||||
|
export interface PortfolioItemProps {
|
||||||
|
image: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
link?: string;
|
||||||
|
variant?: "card" | "overlay";
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Элемент портфолио/галереи
|
||||||
|
* Используется в Gallery Section
|
||||||
|
*/
|
||||||
|
export function PortfolioItem({
|
||||||
|
image,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
category,
|
||||||
|
link,
|
||||||
|
variant = "card",
|
||||||
|
className,
|
||||||
|
}: PortfolioItemProps) {
|
||||||
|
const content = (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
whileInView={{ opacity: 1, scale: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
whileHover={{ y: -5 }}
|
||||||
|
className={cn("group relative overflow-hidden rounded-xl", className)}
|
||||||
|
>
|
||||||
|
<div className="relative aspect-[4/3] overflow-hidden bg-muted">
|
||||||
|
<Image
|
||||||
|
src={image}
|
||||||
|
alt={title}
|
||||||
|
fill
|
||||||
|
className="object-cover transition-transform duration-500 group-hover:scale-110"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{variant === "overlay" && (
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
variant === "card"
|
||||||
|
? "p-4 bg-card"
|
||||||
|
: "absolute inset-x-0 bottom-0 p-6 text-white translate-y-2 group-hover:translate-y-0 transition-transform duration-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{category && (
|
||||||
|
<Badge variant="secondary" className="mb-2">
|
||||||
|
{category}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<h3 className="text-lg font-semibold mb-1">{title}</h3>
|
||||||
|
{description && (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-sm",
|
||||||
|
variant === "card" ? "text-muted-foreground" : "text-white/90"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (link) {
|
||||||
|
return (
|
||||||
|
<Link href={link} className="block">
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
117
src/features/pricing-card.tsx
Normal file
117
src/features/pricing-card.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { Card } from "@/shared/ui/card";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import { Badge } from "@/shared/ui/badge";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Check, X } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export interface PricingCardProps {
|
||||||
|
name: string;
|
||||||
|
price: number | "custom";
|
||||||
|
period?: "month" | "year";
|
||||||
|
description: string;
|
||||||
|
features: Array<{
|
||||||
|
text: string;
|
||||||
|
included: boolean;
|
||||||
|
}>;
|
||||||
|
ctaLabel: string;
|
||||||
|
ctaHref: string;
|
||||||
|
popular?: boolean;
|
||||||
|
variant?: "default" | "featured";
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Карточка тарифного плана
|
||||||
|
* Используется в Pricing Section
|
||||||
|
*/
|
||||||
|
export function PricingCard({
|
||||||
|
name,
|
||||||
|
price,
|
||||||
|
period = "month",
|
||||||
|
description,
|
||||||
|
features,
|
||||||
|
ctaLabel,
|
||||||
|
ctaHref,
|
||||||
|
popular = false,
|
||||||
|
variant = "default",
|
||||||
|
className,
|
||||||
|
}: PricingCardProps) {
|
||||||
|
const isFeatured = variant === "featured" || popular;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
whileHover={isFeatured ? { scale: 1.05, y: -10 } : { scale: 1.02, y: -5 }}
|
||||||
|
className={cn("relative", className)}
|
||||||
|
>
|
||||||
|
{popular && (
|
||||||
|
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 bg-[var(--feature-accent)] text-white">
|
||||||
|
Популярный
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"p-8 h-full flex flex-col",
|
||||||
|
isFeatured &&
|
||||||
|
"border-2 border-[var(--feature-accent)] shadow-lg shadow-[var(--feature-accent)]/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-2xl font-bold mb-2">{name}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
{price === "custom" ? (
|
||||||
|
<div className="text-4xl font-bold">Индивидуально</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-4xl font-bold">{price.toLocaleString('ru-RU')} ₽</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
/{period === "month" ? "мес" : "год"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-3 mb-8 flex-grow">
|
||||||
|
{features.map((feature, index) => (
|
||||||
|
<li key={index} className="flex items-start gap-3">
|
||||||
|
{feature.included ? (
|
||||||
|
<Check className="w-5 h-5 text-[var(--feature-accent)] shrink-0 mt-0.5" />
|
||||||
|
) : (
|
||||||
|
<X className="w-5 h-5 text-muted-foreground shrink-0 mt-0.5" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-sm",
|
||||||
|
feature.included ? "text-foreground" : "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{feature.text}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant={isFeatured ? "default" : "outline"}
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Link href={ctaHref}>{ctaLabel}</Link>
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
60
src/features/section-container.tsx
Normal file
60
src/features/section-container.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface SectionContainerProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
variant?: "default" | "narrow" | "wide";
|
||||||
|
background?: "default" | "muted" | "gradient";
|
||||||
|
withPadding?: boolean;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
default: "max-w-7xl",
|
||||||
|
narrow: "max-w-5xl",
|
||||||
|
wide: "max-w-[90rem]",
|
||||||
|
};
|
||||||
|
|
||||||
|
const backgroundClasses = {
|
||||||
|
default: "",
|
||||||
|
muted: "bg-muted/50",
|
||||||
|
gradient: "bg-gradient-primary",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Универсальный контейнер для секций лендинга
|
||||||
|
* Обеспечивает единообразный spacing, ширину и padding
|
||||||
|
*/
|
||||||
|
export function SectionContainer({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
background = "default",
|
||||||
|
withPadding = true,
|
||||||
|
id,
|
||||||
|
}: SectionContainerProps) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id={id}
|
||||||
|
className={cn(
|
||||||
|
"relative",
|
||||||
|
withPadding && "py-20 lg:py-32",
|
||||||
|
backgroundClasses[background],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mx-auto px-4 sm:px-6 lg:px-8",
|
||||||
|
variantClasses[variant]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
87
src/features/section-header.tsx
Normal file
87
src/features/section-header.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useScrollAnimation } from "@/shared/hooks/use-scroll-animation";
|
||||||
|
|
||||||
|
export interface SectionHeaderProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
description?: string;
|
||||||
|
align?: "left" | "center" | "right";
|
||||||
|
withGradient?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alignClasses = {
|
||||||
|
left: "text-left",
|
||||||
|
center: "text-center mx-auto",
|
||||||
|
right: "text-right ml-auto",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переиспользуемый заголовок секции
|
||||||
|
* Поддерживает subtitle, description, выравнивание и gradient эффект
|
||||||
|
*/
|
||||||
|
export function SectionHeader({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
description,
|
||||||
|
align = "center",
|
||||||
|
withGradient = false,
|
||||||
|
className,
|
||||||
|
}: SectionHeaderProps) {
|
||||||
|
const animation = useScrollAnimation({ direction: "up", duration: 0.6 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
{...animation}
|
||||||
|
className={cn(
|
||||||
|
"space-y-4 mb-12 lg:mb-16",
|
||||||
|
alignClasses[align],
|
||||||
|
align === "center" && "max-w-3xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{subtitle && (
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
whileInView={{ opacity: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.1 }}
|
||||||
|
className="text-sm font-semibold uppercase tracking-wider text-[var(--feature-accent)]"
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<motion.h2
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.2 }}
|
||||||
|
className={cn(
|
||||||
|
"text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight",
|
||||||
|
withGradient && "text-gradient"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</motion.h2>
|
||||||
|
|
||||||
|
{description && (
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.3 }}
|
||||||
|
className="text-lg text-muted-foreground max-w-2xl"
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
113
src/features/stat-card.tsx
Normal file
113
src/features/stat-card.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { motion, useMotionValue, useTransform, animate } from "framer-motion";
|
||||||
|
import { LucideIcon, TrendingUp, TrendingDown } from "lucide-react";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useInView } from "@/shared/hooks/use-in-view";
|
||||||
|
|
||||||
|
export interface StatCardProps {
|
||||||
|
value: string | number;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
trend?: {
|
||||||
|
value: number;
|
||||||
|
direction: "up" | "down";
|
||||||
|
};
|
||||||
|
animated?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Карточка статистики/достижения
|
||||||
|
* Используется в Stats Section
|
||||||
|
* Поддерживает анимированный счетчик для числовых значений
|
||||||
|
*/
|
||||||
|
export function StatCard({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
icon: Icon,
|
||||||
|
trend,
|
||||||
|
animated = true,
|
||||||
|
className,
|
||||||
|
}: StatCardProps) {
|
||||||
|
const { ref, isInView } = useInView<HTMLDivElement>({ threshold: 0.5 });
|
||||||
|
const count = useMotionValue(0);
|
||||||
|
const rounded = useTransform(count, (latest) => Math.round(latest));
|
||||||
|
const hasAnimatedRef = useRef(false);
|
||||||
|
|
||||||
|
// Попытка извлечь число из строки для анимации
|
||||||
|
const numericValue =
|
||||||
|
typeof value === "number"
|
||||||
|
? value
|
||||||
|
: parseFloat(String(value).replace(/[^0-9.]/g, ""));
|
||||||
|
const isNumeric = !isNaN(numericValue);
|
||||||
|
const prefix = typeof value === "string" ? value.replace(/[0-9.,]/g, "") : "";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInView && !hasAnimatedRef.current && animated && isNumeric) {
|
||||||
|
hasAnimatedRef.current = true;
|
||||||
|
const controls = animate(count, numericValue, {
|
||||||
|
duration: 2,
|
||||||
|
ease: "easeOut",
|
||||||
|
});
|
||||||
|
return controls.stop;
|
||||||
|
}
|
||||||
|
}, [isInView, count, numericValue, animated, isNumeric]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
ref={ref}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
className={cn("text-center", className)}
|
||||||
|
>
|
||||||
|
{Icon && (
|
||||||
|
<div className="mb-4 inline-flex p-3 rounded-lg bg-[var(--feature-accent)]/10">
|
||||||
|
<Icon className="w-6 h-6 text-[var(--feature-accent)]" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-2">
|
||||||
|
{animated && isNumeric ? (
|
||||||
|
<motion.span className="text-4xl lg:text-5xl font-bold text-gradient">
|
||||||
|
{prefix}
|
||||||
|
<motion.span>{rounded}</motion.span>
|
||||||
|
</motion.span>
|
||||||
|
) : (
|
||||||
|
<span className="text-4xl lg:text-5xl font-bold text-gradient">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-lg font-semibold mb-1">{label}</div>
|
||||||
|
|
||||||
|
{description && (
|
||||||
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{trend && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-2 inline-flex items-center gap-1 text-sm font-medium",
|
||||||
|
trend.direction === "up" ? "text-green-600" : "text-red-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{trend.direction === "up" ? (
|
||||||
|
<TrendingUp className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{trend.value}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
55
src/features/step-card.tsx
Normal file
55
src/features/step-card.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export interface StepCardProps {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
variant?: "numbered" | "icon";
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Карточка шага
|
||||||
|
* Используется в How It Works Section
|
||||||
|
*/
|
||||||
|
export function StepCard({
|
||||||
|
number,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon: Icon,
|
||||||
|
variant = "numbered",
|
||||||
|
className,
|
||||||
|
}: StepCardProps) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5, delay: number * 0.1 }}
|
||||||
|
className={cn("relative", className)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center text-center">
|
||||||
|
{variant === "numbered" ? (
|
||||||
|
<div className="mb-4 w-16 h-16 rounded-full bg-gradient-primary border-2 border-[var(--feature-accent)] flex items-center justify-center">
|
||||||
|
<span className="text-2xl font-bold text-gradient">{number}</span>
|
||||||
|
</div>
|
||||||
|
) : Icon ? (
|
||||||
|
<div className="mb-4 w-16 h-16 rounded-full bg-[var(--feature-accent)]/10 flex items-center justify-center">
|
||||||
|
<Icon className="w-8 h-8 text-[var(--feature-accent)]" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold mb-2">{title}</h3>
|
||||||
|
<p className="text-muted-foreground leading-relaxed">{description}</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
91
src/features/team-member-card.tsx
Normal file
91
src/features/team-member-card.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { Card } from "@/shared/ui/card";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Code2, Network, AtSign } from "lucide-react";
|
||||||
|
|
||||||
|
export interface TeamMemberCardProps {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
bio?: string;
|
||||||
|
image: string;
|
||||||
|
socials?: Array<{
|
||||||
|
platform: "github" | "linkedin" | "twitter";
|
||||||
|
url: string;
|
||||||
|
}>;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const socialIcons = {
|
||||||
|
github: Code2,
|
||||||
|
linkedin: Network,
|
||||||
|
twitter: AtSign,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Карточка члена команды
|
||||||
|
* Используется в Team Section
|
||||||
|
*/
|
||||||
|
export function TeamMemberCard({
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
bio,
|
||||||
|
image,
|
||||||
|
socials,
|
||||||
|
className,
|
||||||
|
}: TeamMemberCardProps) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
whileHover={{ y: -5 }}
|
||||||
|
>
|
||||||
|
<Card className={cn("overflow-hidden h-full", className)}>
|
||||||
|
<div className="relative aspect-square overflow-hidden bg-muted">
|
||||||
|
<Image
|
||||||
|
src={image}
|
||||||
|
alt={name}
|
||||||
|
fill
|
||||||
|
className="object-cover transition-transform duration-300 hover:scale-110"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
<h3 className="text-xl font-semibold mb-1">{name}</h3>
|
||||||
|
<p className="text-sm text-[var(--feature-accent)] font-medium mb-3">
|
||||||
|
{role}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{bio && <p className="text-sm text-muted-foreground mb-4">{bio}</p>}
|
||||||
|
|
||||||
|
{socials && socials.length > 0 && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{socials.map((social) => {
|
||||||
|
const Icon = socialIcons[social.platform];
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={social.platform}
|
||||||
|
href={social.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="p-2 rounded-lg bg-muted hover:bg-[var(--feature-accent)]/10 hover:text-[var(--feature-accent)] transition-colors"
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
116
src/features/testimonial-card.tsx
Normal file
116
src/features/testimonial-card.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { Card } from "@/shared/ui/card";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/ui/avatar";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Star, Quote } from "lucide-react";
|
||||||
|
|
||||||
|
export interface TestimonialCardProps {
|
||||||
|
content: string;
|
||||||
|
author: {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
company?: string;
|
||||||
|
avatar?: string;
|
||||||
|
};
|
||||||
|
rating?: number;
|
||||||
|
variant?: "card" | "quote";
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Карточка отзыва клиента
|
||||||
|
* Используется в Social Proof Section
|
||||||
|
*/
|
||||||
|
export function TestimonialCard({
|
||||||
|
content,
|
||||||
|
author,
|
||||||
|
rating,
|
||||||
|
variant = "card",
|
||||||
|
className,
|
||||||
|
}: TestimonialCardProps) {
|
||||||
|
const getInitials = (name: string) => {
|
||||||
|
return name
|
||||||
|
.split(" ")
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (variant === "quote") {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
className={cn("relative", className)}
|
||||||
|
>
|
||||||
|
<Quote className="absolute -top-2 -left-2 w-8 h-8 text-[var(--feature-accent)]/20" />
|
||||||
|
<blockquote className="pl-6">
|
||||||
|
<p className="text-lg italic text-foreground mb-4">{content}</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar>
|
||||||
|
<AvatarImage src={author.avatar} alt={author.name} />
|
||||||
|
<AvatarFallback>{getInitials(author.name)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold">{author.name}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{author.role}
|
||||||
|
{author.company && ` @ ${author.company}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</blockquote>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<Card className={cn("p-6 h-full", className)}>
|
||||||
|
{rating && (
|
||||||
|
<div className="flex gap-1 mb-4">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Star
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
"w-4 h-4",
|
||||||
|
i < rating
|
||||||
|
? "fill-yellow-500 text-yellow-500"
|
||||||
|
: "fill-muted text-muted"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mb-6 leading-relaxed">{content}</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mt-auto">
|
||||||
|
<Avatar>
|
||||||
|
<AvatarImage src={author.avatar} alt={author.name} />
|
||||||
|
<AvatarFallback>{getInitials(author.name)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-sm">{author.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{author.role}
|
||||||
|
{author.company && ` @ ${author.company}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
24
src/shared/hooks/theme-message-listener.tsx
Normal file
24
src/shared/hooks/theme-message-listener.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
|
||||||
|
export function ThemeMessageListener() {
|
||||||
|
const { setTheme } = useTheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMessage = (event: MessageEvent) => {
|
||||||
|
// Проверяем, что это сообщение о смене темы
|
||||||
|
if (event.data?.type === "theme-change") {
|
||||||
|
const newTheme = event.data.theme; // "light" или "dark"
|
||||||
|
setTheme(newTheme);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("message", handleMessage);
|
||||||
|
return () => window.removeEventListener("message", handleMessage);
|
||||||
|
}, [setTheme]);
|
||||||
|
|
||||||
|
// Этот компонент ничего не рендерит
|
||||||
|
return null;
|
||||||
|
}
|
||||||
54
src/shared/hooks/use-in-view.ts
Normal file
54
src/shared/hooks/use-in-view.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { useEffect, useRef, useState, RefObject } from "react";
|
||||||
|
|
||||||
|
export interface UseInViewOptions {
|
||||||
|
threshold?: number;
|
||||||
|
rootMargin?: string;
|
||||||
|
triggerOnce?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseInViewReturn<T extends HTMLElement> {
|
||||||
|
ref: RefObject<T | null>;
|
||||||
|
isInView: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook для определения когда элемент появляется в viewport
|
||||||
|
* @param options - настройки Intersection Observer
|
||||||
|
* @returns ref для элемента и статус видимости
|
||||||
|
*/
|
||||||
|
export function useInView<T extends HTMLElement = HTMLDivElement>(
|
||||||
|
options: UseInViewOptions = {}
|
||||||
|
): UseInViewReturn<T> {
|
||||||
|
const { threshold = 0.1, rootMargin = "0px", triggerOnce = true } = options;
|
||||||
|
const ref = useRef<T>(null);
|
||||||
|
const [isInView, setIsInView] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = ref.current;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
const inView = entry.isIntersecting;
|
||||||
|
setIsInView(inView);
|
||||||
|
|
||||||
|
// Если triggerOnce, отключаем observer после первого срабатывания
|
||||||
|
if (inView && triggerOnce) {
|
||||||
|
observer.unobserve(element);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
threshold,
|
||||||
|
rootMargin,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(element);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [threshold, rootMargin, triggerOnce]);
|
||||||
|
|
||||||
|
return { ref, isInView };
|
||||||
|
}
|
||||||
121
src/shared/hooks/use-scroll-animation.ts
Normal file
121
src/shared/hooks/use-scroll-animation.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { Variants } from "framer-motion";
|
||||||
|
|
||||||
|
export interface ScrollAnimationConfig {
|
||||||
|
direction?: "up" | "down" | "left" | "right";
|
||||||
|
delay?: number;
|
||||||
|
duration?: number;
|
||||||
|
distance?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хук для генерации настроек scroll-triggered анимаций
|
||||||
|
* @param config - конфигурация анимации
|
||||||
|
* @returns объект с вариантами анимации и настройками viewport
|
||||||
|
*/
|
||||||
|
export function useScrollAnimation(config: ScrollAnimationConfig = {}) {
|
||||||
|
const {
|
||||||
|
direction = "up",
|
||||||
|
delay = 0,
|
||||||
|
duration = 0.5,
|
||||||
|
distance = 20,
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
const getInitialPosition = () => {
|
||||||
|
switch (direction) {
|
||||||
|
case "up":
|
||||||
|
return { y: distance, x: 0 };
|
||||||
|
case "down":
|
||||||
|
return { y: -distance, x: 0 };
|
||||||
|
case "left":
|
||||||
|
return { y: 0, x: distance };
|
||||||
|
case "right":
|
||||||
|
return { y: 0, x: -distance };
|
||||||
|
default:
|
||||||
|
return { y: distance, x: 0 };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const position = getInitialPosition();
|
||||||
|
|
||||||
|
return {
|
||||||
|
initial: {
|
||||||
|
opacity: 0,
|
||||||
|
...position,
|
||||||
|
},
|
||||||
|
whileInView: {
|
||||||
|
opacity: 1,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
},
|
||||||
|
viewport: {
|
||||||
|
once: true,
|
||||||
|
margin: "-50px",
|
||||||
|
},
|
||||||
|
transition: {
|
||||||
|
duration,
|
||||||
|
delay,
|
||||||
|
ease: "easeOut" as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Варианты для stagger анимации списков/сеток
|
||||||
|
*/
|
||||||
|
export const staggerContainer: Variants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.1,
|
||||||
|
delayChildren: 0.1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const staggerItem: Variants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.5,
|
||||||
|
ease: "easeOut",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Варианты для hover эффектов на карточках
|
||||||
|
*/
|
||||||
|
export const cardHover = {
|
||||||
|
rest: {
|
||||||
|
scale: 1,
|
||||||
|
y: 0,
|
||||||
|
},
|
||||||
|
hover: {
|
||||||
|
scale: 1.02,
|
||||||
|
y: -5,
|
||||||
|
transition: {
|
||||||
|
type: "spring" as const,
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Варианты для fade-in анимации
|
||||||
|
*/
|
||||||
|
export const fadeIn: Variants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
duration: 0.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,37 +1,41 @@
|
|||||||
"use client"
|
// @ts-nocheck
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
|
||||||
import * as React from "react"
|
"use client";
|
||||||
import * as RechartsPrimitive from "recharts"
|
|
||||||
|
|
||||||
import { cn } from "@/shared/lib/utils"
|
import * as React from "react";
|
||||||
|
import * as RechartsPrimitive from "recharts";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
const THEMES = { light: "", dark: ".dark" } as const
|
const THEMES = { light: "", dark: ".dark" } as const;
|
||||||
|
|
||||||
export type ChartConfig = {
|
export type ChartConfig = {
|
||||||
[k in string]: {
|
[k in string]: {
|
||||||
label?: React.ReactNode
|
label?: React.ReactNode;
|
||||||
icon?: React.ComponentType
|
icon?: React.ComponentType;
|
||||||
} & (
|
} & (
|
||||||
| { color?: string; theme?: never }
|
| { color?: string; theme?: never }
|
||||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
type ChartContextProps = {
|
type ChartContextProps = {
|
||||||
config: ChartConfig
|
config: ChartConfig;
|
||||||
}
|
};
|
||||||
|
|
||||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||||
|
|
||||||
function useChart() {
|
function useChart() {
|
||||||
const context = React.useContext(ChartContext)
|
const context = React.useContext(ChartContext);
|
||||||
|
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error("useChart must be used within a <ChartContainer />")
|
throw new Error("useChart must be used within a <ChartContainer />");
|
||||||
}
|
}
|
||||||
|
|
||||||
return context
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChartContainer({
|
function ChartContainer({
|
||||||
@@ -41,13 +45,13 @@ function ChartContainer({
|
|||||||
config,
|
config,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
config: ChartConfig
|
config: ChartConfig;
|
||||||
children: React.ComponentProps<
|
children: React.ComponentProps<
|
||||||
typeof RechartsPrimitive.ResponsiveContainer
|
typeof RechartsPrimitive.ResponsiveContainer
|
||||||
>["children"]
|
>["children"];
|
||||||
}) {
|
}) {
|
||||||
const uniqueId = React.useId()
|
const uniqueId = React.useId();
|
||||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartContext.Provider value={{ config }}>
|
<ChartContext.Provider value={{ config }}>
|
||||||
@@ -66,16 +70,16 @@ function ChartContainer({
|
|||||||
</RechartsPrimitive.ResponsiveContainer>
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</ChartContext.Provider>
|
</ChartContext.Provider>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
const colorConfig = Object.entries(config).filter(
|
const colorConfig = Object.entries(config).filter(
|
||||||
([, config]) => config.theme || config.color
|
([, config]) => config.theme ?? config.color
|
||||||
)
|
);
|
||||||
|
|
||||||
if (!colorConfig.length) {
|
if (!colorConfig.length) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -88,9 +92,9 @@ ${prefix} [data-chart=${id}] {
|
|||||||
${colorConfig
|
${colorConfig
|
||||||
.map(([key, itemConfig]) => {
|
.map(([key, itemConfig]) => {
|
||||||
const color =
|
const color =
|
||||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
|
||||||
itemConfig.color
|
itemConfig.color;
|
||||||
return color ? ` --color-${key}: ${color};` : null
|
return color ? ` --color-${key}: ${color};` : null;
|
||||||
})
|
})
|
||||||
.join("\n")}
|
.join("\n")}
|
||||||
}
|
}
|
||||||
@@ -99,10 +103,10 @@ ${colorConfig
|
|||||||
.join("\n"),
|
.join("\n"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||||
|
|
||||||
function ChartTooltipContent({
|
function ChartTooltipContent({
|
||||||
active,
|
active,
|
||||||
@@ -120,40 +124,40 @@ function ChartTooltipContent({
|
|||||||
labelKey,
|
labelKey,
|
||||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
React.ComponentProps<"div"> & {
|
React.ComponentProps<"div"> & {
|
||||||
hideLabel?: boolean
|
hideLabel?: boolean;
|
||||||
hideIndicator?: boolean
|
hideIndicator?: boolean;
|
||||||
indicator?: "line" | "dot" | "dashed"
|
indicator?: "line" | "dot" | "dashed";
|
||||||
nameKey?: string
|
nameKey?: string;
|
||||||
labelKey?: string
|
labelKey?: string;
|
||||||
}) {
|
}) {
|
||||||
const { config } = useChart()
|
const { config } = useChart();
|
||||||
|
|
||||||
const tooltipLabel = React.useMemo(() => {
|
const tooltipLabel = React.useMemo(() => {
|
||||||
if (hideLabel || !payload?.length) {
|
if (hideLabel ?? !payload?.length) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [item] = payload
|
const [item] = payload;
|
||||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
|
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`;
|
||||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
const value =
|
const value =
|
||||||
!labelKey && typeof label === "string"
|
!labelKey && typeof label === "string"
|
||||||
? config[label as keyof typeof config]?.label || label
|
? config[label]?.label ?? label
|
||||||
: itemConfig?.label
|
: itemConfig?.label;
|
||||||
|
|
||||||
if (labelFormatter) {
|
if (labelFormatter) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("font-medium", labelClassName)}>
|
<div className={cn("font-medium", labelClassName)}>
|
||||||
{labelFormatter(value, payload)}
|
{labelFormatter(value, payload)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||||
}, [
|
}, [
|
||||||
label,
|
label,
|
||||||
labelFormatter,
|
labelFormatter,
|
||||||
@@ -162,13 +166,13 @@ function ChartTooltipContent({
|
|||||||
labelClassName,
|
labelClassName,
|
||||||
config,
|
config,
|
||||||
labelKey,
|
labelKey,
|
||||||
])
|
]);
|
||||||
|
|
||||||
if (!active || !payload?.length) {
|
if (!active ?? !payload?.length) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -182,9 +186,9 @@ function ChartTooltipContent({
|
|||||||
{payload
|
{payload
|
||||||
.filter((item) => item.type !== "none")
|
.filter((item) => item.type !== "none")
|
||||||
.map((item, index) => {
|
.map((item, index) => {
|
||||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`;
|
||||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
const indicatorColor = color || item.payload.fill || item.color
|
const indicatorColor = color ?? item.payload.fill ?? item.color;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -231,7 +235,7 @@ function ChartTooltipContent({
|
|||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
{nestLabel ? tooltipLabel : null}
|
{nestLabel ? tooltipLabel : null}
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{itemConfig?.label || item.name}
|
{itemConfig?.label ?? item.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{item.value && (
|
{item.value && (
|
||||||
@@ -243,14 +247,14 @@ function ChartTooltipContent({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChartLegend = RechartsPrimitive.Legend
|
const ChartLegend = RechartsPrimitive.Legend;
|
||||||
|
|
||||||
function ChartLegendContent({
|
function ChartLegendContent({
|
||||||
className,
|
className,
|
||||||
@@ -260,13 +264,13 @@ function ChartLegendContent({
|
|||||||
nameKey,
|
nameKey,
|
||||||
}: React.ComponentProps<"div"> &
|
}: React.ComponentProps<"div"> &
|
||||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||||
hideIcon?: boolean
|
hideIcon?: boolean;
|
||||||
nameKey?: string
|
nameKey?: string;
|
||||||
}) {
|
}) {
|
||||||
const { config } = useChart()
|
const { config } = useChart();
|
||||||
|
|
||||||
if (!payload?.length) {
|
if (!payload?.length) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -280,8 +284,8 @@ function ChartLegendContent({
|
|||||||
{payload
|
{payload
|
||||||
.filter((item) => item.type !== "none")
|
.filter((item) => item.type !== "none")
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const key = `${nameKey || item.dataKey || "value"}`
|
const key = `${nameKey ?? item.dataKey ?? "value"}`;
|
||||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -302,10 +306,10 @@ function ChartLegendContent({
|
|||||||
)}
|
)}
|
||||||
{itemConfig?.label}
|
{itemConfig?.label}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to extract item config from a payload.
|
// Helper to extract item config from a payload.
|
||||||
@@ -314,8 +318,8 @@ function getPayloadConfigFromPayload(
|
|||||||
payload: unknown,
|
payload: unknown,
|
||||||
key: string
|
key: string
|
||||||
) {
|
) {
|
||||||
if (typeof payload !== "object" || payload === null) {
|
if (typeof payload !== "object" ?? payload === null) {
|
||||||
return undefined
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payloadPayload =
|
const payloadPayload =
|
||||||
@@ -323,15 +327,15 @@ function getPayloadConfigFromPayload(
|
|||||||
typeof payload.payload === "object" &&
|
typeof payload.payload === "object" &&
|
||||||
payload.payload !== null
|
payload.payload !== null
|
||||||
? payload.payload
|
? payload.payload
|
||||||
: undefined
|
: undefined;
|
||||||
|
|
||||||
let configLabelKey: string = key
|
let configLabelKey: string = key;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
key in payload &&
|
key in payload &&
|
||||||
typeof payload[key as keyof typeof payload] === "string"
|
typeof payload[key as keyof typeof payload] === "string"
|
||||||
) {
|
) {
|
||||||
configLabelKey = payload[key as keyof typeof payload] as string
|
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||||
} else if (
|
} else if (
|
||||||
payloadPayload &&
|
payloadPayload &&
|
||||||
key in payloadPayload &&
|
key in payloadPayload &&
|
||||||
@@ -339,12 +343,10 @@ function getPayloadConfigFromPayload(
|
|||||||
) {
|
) {
|
||||||
configLabelKey = payloadPayload[
|
configLabelKey = payloadPayload[
|
||||||
key as keyof typeof payloadPayload
|
key as keyof typeof payloadPayload
|
||||||
] as string
|
] as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
return configLabelKey in config
|
return configLabelKey in config ? config[configLabelKey] : config[key];
|
||||||
? config[configLabelKey]
|
|
||||||
: config[key as keyof typeof config]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -354,4 +356,4 @@ export {
|
|||||||
ChartLegend,
|
ChartLegend,
|
||||||
ChartLegendContent,
|
ChartLegendContent,
|
||||||
ChartStyle,
|
ChartStyle,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||||
|
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
function Progress({
|
function Progress({
|
||||||
className,
|
className,
|
||||||
@@ -22,10 +22,10 @@ function Progress({
|
|||||||
<ProgressPrimitive.Indicator
|
<ProgressPrimitive.Indicator
|
||||||
data-slot="progress-indicator"
|
data-slot="progress-indicator"
|
||||||
className="bg-primary h-full w-full flex-1 transition-all"
|
className="bg-primary h-full w-full flex-1 transition-all"
|
||||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
|
||||||
/>
|
/>
|
||||||
</ProgressPrimitive.Root>
|
</ProgressPrimitive.Root>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Progress }
|
export { Progress };
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { GripVerticalIcon } from "lucide-react"
|
import { GripVerticalIcon } from "lucide-react"
|
||||||
import * as ResizablePrimitive from "react-resizable-panels"
|
import * as ResizablePrimitive from "react-resizable-panels"
|
||||||
|
|
||||||
@@ -9,12 +8,12 @@ import { cn } from "@/shared/lib/utils"
|
|||||||
function ResizablePanelGroup({
|
function ResizablePanelGroup({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
|
}: ResizablePrimitive.GroupProps) {
|
||||||
return (
|
return (
|
||||||
<ResizablePrimitive.PanelGroup
|
<ResizablePrimitive.Group
|
||||||
data-slot="resizable-panel-group"
|
data-slot="resizable-panel-group"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
"flex h-full w-full aria-[orientation=vertical]:flex-col",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -22,9 +21,7 @@ function ResizablePanelGroup({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResizablePanel({
|
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
|
||||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,25 +29,25 @@ function ResizableHandle({
|
|||||||
withHandle,
|
withHandle,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
}: ResizablePrimitive.SeparatorProps & {
|
||||||
withHandle?: boolean
|
withHandle?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ResizablePrimitive.PanelResizeHandle
|
<ResizablePrimitive.Separator
|
||||||
data-slot="resizable-handle"
|
data-slot="resizable-handle"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{withHandle && (
|
{withHandle && (
|
||||||
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-xs border bg-border">
|
||||||
<GripVerticalIcon className="size-2.5" />
|
<GripVerticalIcon className="size-2.5" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ResizablePrimitive.PanelResizeHandle>
|
</ResizablePrimitive.Separator>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
|
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
|
||||||
import { type VariantProps } from "class-variance-authority"
|
import { type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils";
|
||||||
import { toggleVariants } from "@/shared/ui/toggle"
|
import { toggleVariants } from "@/shared/ui/toggle";
|
||||||
|
|
||||||
const ToggleGroupContext = React.createContext<
|
const ToggleGroupContext = React.createContext<
|
||||||
VariantProps<typeof toggleVariants>
|
VariantProps<typeof toggleVariants>
|
||||||
>({
|
>({
|
||||||
size: "default",
|
size: "default",
|
||||||
variant: "default",
|
variant: "default",
|
||||||
})
|
});
|
||||||
|
|
||||||
function ToggleGroup({
|
function ToggleGroup({
|
||||||
className,
|
className,
|
||||||
@@ -37,7 +37,7 @@ function ToggleGroup({
|
|||||||
{children}
|
{children}
|
||||||
</ToggleGroupContext.Provider>
|
</ToggleGroupContext.Provider>
|
||||||
</ToggleGroupPrimitive.Root>
|
</ToggleGroupPrimitive.Root>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToggleGroupItem({
|
function ToggleGroupItem({
|
||||||
@@ -48,17 +48,17 @@ function ToggleGroupItem({
|
|||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||||
VariantProps<typeof toggleVariants>) {
|
VariantProps<typeof toggleVariants>) {
|
||||||
const context = React.useContext(ToggleGroupContext)
|
const context = React.useContext(ToggleGroupContext);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToggleGroupPrimitive.Item
|
<ToggleGroupPrimitive.Item
|
||||||
data-slot="toggle-group-item"
|
data-slot="toggle-group-item"
|
||||||
data-variant={context.variant || variant}
|
data-variant={context.variant ?? variant}
|
||||||
data-size={context.size || size}
|
data-size={context.size ?? size}
|
||||||
className={cn(
|
className={cn(
|
||||||
toggleVariants({
|
toggleVariants({
|
||||||
variant: context.variant || variant,
|
variant: context.variant ?? variant,
|
||||||
size: context.size || size,
|
size: context.size ?? size,
|
||||||
}),
|
}),
|
||||||
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
|
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
|
||||||
className
|
className
|
||||||
@@ -67,7 +67,7 @@ function ToggleGroupItem({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ToggleGroupPrimitive.Item>
|
</ToggleGroupPrimitive.Item>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ToggleGroup, ToggleGroupItem }
|
export { ToggleGroup, ToggleGroupItem };
|
||||||
|
|||||||
71
src/widgets/comparison-section.tsx
Normal file
71
src/widgets/comparison-section.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SectionContainer } from "@/features/section-container";
|
||||||
|
import { SectionHeader } from "@/features/section-header";
|
||||||
|
import { ComparisonItem } from "@/features/comparison-item";
|
||||||
|
import { FileText, Workflow, Mail } from "lucide-react";
|
||||||
|
|
||||||
|
const comparisons = [
|
||||||
|
{
|
||||||
|
before: {
|
||||||
|
title: "Ручное управление",
|
||||||
|
description: "Потеря времени на рутинные задачи и отслеживание статусов",
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
|
after: {
|
||||||
|
title: "Автоматизация",
|
||||||
|
description: "Автоматические уведомления, дедлайны и распределение задач",
|
||||||
|
icon: Workflow,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
before: {
|
||||||
|
title: "Хаос в задачах",
|
||||||
|
description: "Задачи разбросаны по разным инструментам и чатам",
|
||||||
|
},
|
||||||
|
after: {
|
||||||
|
title: "Структурированный процесс",
|
||||||
|
description: "Все задачи в одном месте с четкой структурой и приоритетами",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
before: {
|
||||||
|
title: "Email-цепочки",
|
||||||
|
description: "Потерянные сообщения и долгие согласования",
|
||||||
|
icon: Mail,
|
||||||
|
},
|
||||||
|
after: {
|
||||||
|
title: "Централизованное общение",
|
||||||
|
description: "Вся коммуникация по проекту в контексте задач",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comparison Section - секция сравнения "до/после"
|
||||||
|
*/
|
||||||
|
export function ComparisonSection() {
|
||||||
|
return (
|
||||||
|
<SectionContainer id="comparison" background="gradient">
|
||||||
|
<SectionHeader
|
||||||
|
subtitle="До и после"
|
||||||
|
title="Как TaskFlow меняет работу"
|
||||||
|
description="Сравните привычный подход с нашим решением"
|
||||||
|
withGradient
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{comparisons.map((comparison, index) => (
|
||||||
|
<ComparisonItem
|
||||||
|
key={index}
|
||||||
|
{...comparison}
|
||||||
|
variant="overlay"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SectionContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
79
src/widgets/cta-section.tsx
Normal file
79
src/widgets/cta-section.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SectionContainer } from "@/features/section-container";
|
||||||
|
import { GradientBackground } from "@/features/gradient-background";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import { Input } from "@/shared/ui/input";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { ArrowRight, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CTA Section - финальный призыв к действию
|
||||||
|
*/
|
||||||
|
export function CtaSection() {
|
||||||
|
return (
|
||||||
|
<SectionContainer className="relative">
|
||||||
|
<GradientBackground variant="mesh" opacity={0.4} />
|
||||||
|
|
||||||
|
<div className="relative z-10 max-w-4xl mx-auto text-center">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
className="space-y-8"
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-[var(--feature-accent)]/10 border border-[var(--feature-accent)]/20">
|
||||||
|
<Sparkles className="w-4 h-4 text-[var(--feature-accent)]" />
|
||||||
|
<span className="text-sm font-medium text-[var(--feature-accent)]">
|
||||||
|
Начните работать эффективнее уже сегодня
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight">
|
||||||
|
Готовы{" "}
|
||||||
|
<span className="text-gradient">трансформировать</span>
|
||||||
|
<br />
|
||||||
|
работу вашей команды?
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-lg lg:text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||||
|
Присоединяйтесь к тысячам команд, которые уже используют TaskFlow
|
||||||
|
для достижения своих целей быстрее и эффективнее.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center max-w-md mx-auto">
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="Ваш email"
|
||||||
|
className="h-12"
|
||||||
|
/>
|
||||||
|
<Button size="lg" className="w-full sm:w-auto group">
|
||||||
|
Попробовать бесплатно
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
14 дней бесплатно • Кредитная карта не требуется • Отмените в любое время
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="pt-8 flex flex-wrap items-center justify-center gap-8 text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
✓ Настройка за 5 минут
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
✓ Поддержка 24/7
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
✓ Безопасность данных
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</SectionContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
99
src/widgets/faq-section.tsx
Normal file
99
src/widgets/faq-section.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SectionContainer } from "@/features/section-container";
|
||||||
|
import { SectionHeader } from "@/features/section-header";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/shared/ui/accordion";
|
||||||
|
|
||||||
|
const faqs = [
|
||||||
|
{
|
||||||
|
question: "Как начать работу с TaskFlow?",
|
||||||
|
answer:
|
||||||
|
"Просто зарегистрируйтесь на сайте, создайте свой первый проект и пригласите команду. Настройка займет не более 5 минут. Мы предлагаем 14-дневный бесплатный пробный период для всех планов.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Могу ли я изменить тариф в любое время?",
|
||||||
|
answer:
|
||||||
|
"Да, вы можете повысить или понизить свой тариф в любое время. При повышении тарифа вы сразу получите доступ к новым функциям. При понижении изменения вступят в силу со следующего платежного периода.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Есть ли бесплатный период?",
|
||||||
|
answer:
|
||||||
|
"Да! Мы предлагаем 14-дневную бесплатную пробную версию для всех платных планов. Кредитная карта не требуется для начала пробного периода. План Free доступен всегда без ограничений по времени.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Как работает поддержка?",
|
||||||
|
answer:
|
||||||
|
"Для пользователей Free доступна поддержка по email. Пользователи Pro получают приоритетную поддержку по email и чату. Клиенты Enterprise имеют доступ к выделенному менеджеру и поддержке 24/7.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Безопасны ли мои данные?",
|
||||||
|
answer:
|
||||||
|
"Абсолютно! Мы используем шифрование на уровне банков (SSL/TLS), регулярно делаем резервные копии и храним данные в защищенных дата-центрах. Мы соответствуем стандартам GDPR и SOC 2.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Можно ли интегрировать TaskFlow с другими сервисами?",
|
||||||
|
answer:
|
||||||
|
"Да! TaskFlow интегрируется с более чем 50 популярными сервисами, включая Slack, GitHub, Jira, Google Calendar и многими другими. Пользователи Enterprise также получают доступ к API для создания собственных интеграций.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Доступно ли мобильное приложение?",
|
||||||
|
answer:
|
||||||
|
"Да, у нас есть нативные приложения для iOS и Android. Все функции доступны на мобильных устройствах, и данные синхронизируются в реальном времени между всеми платформами.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Как отменить подписку?",
|
||||||
|
answer:
|
||||||
|
"Вы можете отменить подписку в любое время через настройки аккаунта. При отмене вы сохраните доступ ко всем функциям до конца оплаченного периода. Никаких штрафов за отмену подписки нет.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FAQ Section - секция с частыми вопросами
|
||||||
|
*/
|
||||||
|
export function FaqSection() {
|
||||||
|
return (
|
||||||
|
<SectionContainer id="faq" background="muted">
|
||||||
|
<SectionHeader
|
||||||
|
subtitle="FAQ"
|
||||||
|
title="Частые вопросы"
|
||||||
|
description="Ответы на популярные вопросы о TaskFlow"
|
||||||
|
withGradient
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<Accordion type="single" collapsible className="w-full">
|
||||||
|
{faqs.map((faq, index) => (
|
||||||
|
<AccordionItem key={index} value={`item-${index}`}>
|
||||||
|
<AccordionTrigger className="text-left">
|
||||||
|
{faq.question}
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="text-muted-foreground">
|
||||||
|
{faq.answer}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 text-center">
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
Не нашли ответ на свой вопрос?
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="text-[var(--feature-accent)] font-medium hover:underline"
|
||||||
|
>
|
||||||
|
Свяжитесь с нашей поддержкой →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</SectionContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
87
src/widgets/features-section.tsx
Normal file
87
src/widgets/features-section.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SectionContainer } from "@/features/section-container";
|
||||||
|
import { SectionHeader } from "@/features/section-header";
|
||||||
|
import { FeatureCard } from "@/features/feature-card";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { staggerContainer, staggerItem } from "@/shared/hooks/use-scroll-animation";
|
||||||
|
import {
|
||||||
|
Zap,
|
||||||
|
Users,
|
||||||
|
BarChart3,
|
||||||
|
Puzzle,
|
||||||
|
Shield,
|
||||||
|
Headphones,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: Zap,
|
||||||
|
title: "Быстрый старт",
|
||||||
|
description:
|
||||||
|
"Начните работу за 5 минут. Интуитивный интерфейс и простая настройка.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Users,
|
||||||
|
title: "Командная работа",
|
||||||
|
description:
|
||||||
|
"Пригласите коллег и работайте вместе в реальном времени над проектами.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: BarChart3,
|
||||||
|
title: "Аналитика",
|
||||||
|
description:
|
||||||
|
"Отслеживайте прогресс в реальном времени с подробными отчетами и дашбордами.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Puzzle,
|
||||||
|
title: "Интеграции",
|
||||||
|
description:
|
||||||
|
"Подключите любимые инструменты: Slack, GitHub, Jira и еще более 50 сервисов.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Shield,
|
||||||
|
title: "Безопасность",
|
||||||
|
description:
|
||||||
|
"Защита данных на уровне банков с шифрованием и регулярными бэкапами.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Headphones,
|
||||||
|
title: "Поддержка 24/7",
|
||||||
|
description:
|
||||||
|
"Наша команда всегда готова помочь вам в любое время дня и ночи.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Features Section - секция с возможностями/преимуществами
|
||||||
|
*/
|
||||||
|
export function FeaturesSection() {
|
||||||
|
return (
|
||||||
|
<SectionContainer id="features">
|
||||||
|
<SectionHeader
|
||||||
|
subtitle="Возможности"
|
||||||
|
title="Все что нужно для вашей команды"
|
||||||
|
description="Мощные инструменты для эффективной работы в одном месте"
|
||||||
|
withGradient
|
||||||
|
/>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
variants={staggerContainer}
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="show"
|
||||||
|
viewport={{ once: true, margin: "-100px" }}
|
||||||
|
className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8"
|
||||||
|
>
|
||||||
|
{features.map((feature) => (
|
||||||
|
<motion.div key={feature.title} variants={staggerItem}>
|
||||||
|
<FeatureCard {...feature} />
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</SectionContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
134
src/widgets/footer.tsx
Normal file
134
src/widgets/footer.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Separator } from "@/shared/ui/separator";
|
||||||
|
import { Code2, AtSign, Network, Play } from "lucide-react";
|
||||||
|
|
||||||
|
const footerLinks = {
|
||||||
|
product: {
|
||||||
|
title: "Продукт",
|
||||||
|
links: [
|
||||||
|
{ name: "Возможности", href: "#features" },
|
||||||
|
{ name: "Тарифы", href: "#pricing" },
|
||||||
|
{ name: "Обновления", href: "#" },
|
||||||
|
{ name: "Roadmap", href: "#" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
company: {
|
||||||
|
title: "Компания",
|
||||||
|
links: [
|
||||||
|
{ name: "О нас", href: "#team" },
|
||||||
|
{ name: "Блог", href: "#" },
|
||||||
|
{ name: "Карьера", href: "#" },
|
||||||
|
{ name: "Пресс-кит", href: "#" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
resources: {
|
||||||
|
title: "Ресурсы",
|
||||||
|
links: [
|
||||||
|
{ name: "Документация", href: "#" },
|
||||||
|
{ name: "Помощь", href: "#faq" },
|
||||||
|
{ name: "API", href: "#" },
|
||||||
|
{ name: "Статус", href: "#" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
support: {
|
||||||
|
title: "Поддержка",
|
||||||
|
links: [
|
||||||
|
{ name: "Контакты", href: "#" },
|
||||||
|
{ name: "FAQ", href: "#faq" },
|
||||||
|
{ name: "Чат", href: "#" },
|
||||||
|
{ name: "Email", href: "mailto:support@taskflow.com" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const CURRENT_YEAR = new Date().getFullYear()
|
||||||
|
|
||||||
|
const socialLinks = [
|
||||||
|
{ name: "GitHub", icon: Code2, href: "#" },
|
||||||
|
{ name: "Twitter", icon: AtSign, href: "#" },
|
||||||
|
{ name: "LinkedIn", icon: Network, href: "#" },
|
||||||
|
{ name: "YouTube", icon: Play, href: "#" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Footer - подвал сайта
|
||||||
|
*/
|
||||||
|
export function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="border-t bg-muted/50">
|
||||||
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 lg:py-16">
|
||||||
|
{/* Main Footer Content */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-8 mb-12">
|
||||||
|
{/* Brand */}
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Link href="/" className="inline-block mb-4">
|
||||||
|
<span className="text-2xl font-bold text-gradient">TaskFlow</span>
|
||||||
|
</Link>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6 max-w-xs">
|
||||||
|
Современный инструмент для управления проектами и командной работы
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Social Links */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{socialLinks.map((social) => (
|
||||||
|
<Link
|
||||||
|
key={social.name}
|
||||||
|
href={social.href}
|
||||||
|
className="p-2 rounded-lg bg-background hover:bg-[var(--feature-accent)]/10 hover:text-[var(--feature-accent)] transition-colors"
|
||||||
|
aria-label={social.name}
|
||||||
|
>
|
||||||
|
<social.icon className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Links Columns */}
|
||||||
|
{Object.values(footerLinks).map((column) => (
|
||||||
|
<div key={column.title}>
|
||||||
|
<h3 className="font-semibold mb-4 text-sm">{column.title}</h3>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{column.links.map((link) => (
|
||||||
|
<li key={link.name}>
|
||||||
|
<Link
|
||||||
|
href={link.href}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{link.name}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="mb-8" />
|
||||||
|
|
||||||
|
{/* Bottom Bar */}
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
© {CURRENT_YEAR} TaskFlow. Все права защищены.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-6 text-sm text-muted-foreground">
|
||||||
|
<Link href="#" className="hover:text-foreground transition-colors">
|
||||||
|
Политика конфиденциальности
|
||||||
|
</Link>
|
||||||
|
<Link href="#" className="hover:text-foreground transition-colors">
|
||||||
|
Условия использования
|
||||||
|
</Link>
|
||||||
|
<Link href="#" className="hover:text-foreground transition-colors">
|
||||||
|
Cookies
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
69
src/widgets/gallery-section.tsx
Normal file
69
src/widgets/gallery-section.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SectionContainer } from "@/features/section-container";
|
||||||
|
import { SectionHeader } from "@/features/section-header";
|
||||||
|
import { PortfolioItem } from "@/features/portfolio-item";
|
||||||
|
|
||||||
|
const portfolioItems = [
|
||||||
|
{
|
||||||
|
image: "/api/placeholder/600/400",
|
||||||
|
title: "Dashboard",
|
||||||
|
description: "Полная аналитика проектов",
|
||||||
|
category: "Интерфейс",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: "/api/placeholder/600/400",
|
||||||
|
title: "Kanban Board",
|
||||||
|
description: "Визуальное управление задачами",
|
||||||
|
category: "Функции",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: "/api/placeholder/600/400",
|
||||||
|
title: "Team Collaboration",
|
||||||
|
description: "Совместная работа в реальном времени",
|
||||||
|
category: "Команда",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: "/api/placeholder/600/400",
|
||||||
|
title: "Reports",
|
||||||
|
description: "Детальные отчеты о прогрессе",
|
||||||
|
category: "Аналитика",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: "/api/placeholder/600/400",
|
||||||
|
title: "Mobile App",
|
||||||
|
description: "Работайте из любой точки мира",
|
||||||
|
category: "Мобайл",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: "/api/placeholder/600/400",
|
||||||
|
title: "Integrations",
|
||||||
|
description: "Подключение к любимым сервисам",
|
||||||
|
category: "Интеграции",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gallery Section - секция с примерами/скриншотами
|
||||||
|
*/
|
||||||
|
export function GallerySection() {
|
||||||
|
return (
|
||||||
|
<SectionContainer id="gallery">
|
||||||
|
<SectionHeader
|
||||||
|
subtitle="Галерея"
|
||||||
|
title="Посмотрите на возможности"
|
||||||
|
description="Скриншоты интерфейса и ключевых функций"
|
||||||
|
withGradient
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
|
||||||
|
{portfolioItems.map((item) => (
|
||||||
|
<PortfolioItem key={item.title} {...item} variant="card" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SectionContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
145
src/widgets/header.tsx
Normal file
145
src/widgets/header.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import { Sheet, SheetContent, SheetTrigger } from "@/shared/ui/sheet";
|
||||||
|
import { Menu, Moon, Sun, X } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{ name: "Возможности", href: "#features" },
|
||||||
|
{ name: "Преимущества", href: "#stats" },
|
||||||
|
{ name: "Как работает", href: "#how-it-works" },
|
||||||
|
{ name: "Тарифы", href: "#pricing" },
|
||||||
|
{ name: "FAQ", href: "#faq" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Header с навигацией
|
||||||
|
* Sticky header с backdrop blur, мобильное меню, theme toggle
|
||||||
|
*/
|
||||||
|
export function Header() {
|
||||||
|
const [scrolled, setScrolled] = useState(false);
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
setScrolled(window.scrollY > 20);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("scroll", handleScroll);
|
||||||
|
return () => window.removeEventListener("scroll", handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrollToSection = (href: string) => {
|
||||||
|
const element = document.querySelector(href);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ behavior: "smooth" });
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={cn(
|
||||||
|
"sticky top-0 z-50 w-full transition-all duration-300",
|
||||||
|
scrolled
|
||||||
|
? "bg-background/80 backdrop-blur-lg border-b border-border shadow-sm"
|
||||||
|
: "bg-transparent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between h-16 lg:h-20">
|
||||||
|
{/* Logo */}
|
||||||
|
<Link href="/" className="flex items-center">
|
||||||
|
<span className="text-2xl font-bold text-gradient">TaskFlow</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Desktop Navigation */}
|
||||||
|
<nav className="hidden lg:flex items-center gap-8">
|
||||||
|
{navigation.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.name}
|
||||||
|
onClick={() => scrollToSection(item.href)}
|
||||||
|
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Desktop Actions */}
|
||||||
|
<div className="hidden lg:flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||||
|
>
|
||||||
|
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
|
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
|
<span className="sr-only">Toggle theme</span>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="#pricing">Войти</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="#pricing">Начать бесплатно</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Menu */}
|
||||||
|
<div className="flex lg:hidden items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||||
|
>
|
||||||
|
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
|
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
|
<span className="sr-only">Toggle theme</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
{mobileMenuOpen ? (
|
||||||
|
<X className="h-6 w-6" />
|
||||||
|
) : (
|
||||||
|
<Menu className="h-6 w-6" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="right" className="w-full sm:w-80">
|
||||||
|
<nav className="flex flex-col gap-4 mt-8">
|
||||||
|
{navigation.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.name}
|
||||||
|
onClick={() => scrollToSection(item.href)}
|
||||||
|
className="text-lg font-medium text-left py-2 hover:text-[var(--feature-accent)] transition-colors"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div className="flex flex-col gap-3 mt-4 pt-4 border-t">
|
||||||
|
<Button variant="outline" asChild className="w-full">
|
||||||
|
<Link href="#pricing">Войти</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild className="w-full">
|
||||||
|
<Link href="#pricing">Начать бесплатно</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
167
src/widgets/hero-section.tsx
Normal file
167
src/widgets/hero-section.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SectionContainer } from "@/features/section-container";
|
||||||
|
import { GradientBackground } from "@/features/gradient-background";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { ArrowRight, Play } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hero Section - главная секция лендинга
|
||||||
|
* Варианты: centered, split (текст слева, визуал справа)
|
||||||
|
*/
|
||||||
|
export function HeroSection() {
|
||||||
|
return (
|
||||||
|
<SectionContainer className="relative min-h-[calc(100vh-5rem)] flex items-center">
|
||||||
|
<GradientBackground variant="animated" opacity={0.3} />
|
||||||
|
|
||||||
|
<div className="relative z-10 grid lg:grid-cols-2 gap-12 items-center w-full">
|
||||||
|
{/* Content */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
className="space-y-8"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.1 }}
|
||||||
|
className="inline-block"
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-[var(--feature-accent)]/10 border border-[var(--feature-accent)]/20">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-[var(--feature-accent)] animate-pulse" />
|
||||||
|
<span className="text-sm font-medium text-[var(--feature-accent)]">
|
||||||
|
Новый способ управления проектами
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.h1
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.2 }}
|
||||||
|
className="text-4xl sm:text-5xl lg:text-6xl xl:text-7xl font-bold tracking-tight"
|
||||||
|
>
|
||||||
|
Управляйте проектами{" "}
|
||||||
|
<span className="text-gradient">быстрее и проще</span>
|
||||||
|
</motion.h1>
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.3 }}
|
||||||
|
className="text-lg lg:text-xl text-muted-foreground max-w-2xl"
|
||||||
|
>
|
||||||
|
Все инструменты для командной работы в одном месте. Начните работать
|
||||||
|
эффективнее уже сегодня.
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.4 }}
|
||||||
|
className="flex flex-wrap gap-4"
|
||||||
|
>
|
||||||
|
<Button size="lg" asChild className="group">
|
||||||
|
<Link href="#pricing">
|
||||||
|
Начать бесплатно
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button size="lg" variant="outline" asChild>
|
||||||
|
<Link href="#how-it-works">
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
Посмотреть демо
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.5 }}
|
||||||
|
className="flex items-center gap-8 pt-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-gradient">10,000+</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Пользователей</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-12 w-px bg-border" />
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-gradient">4.9★</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Рейтинг</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-12 w-px bg-border" />
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-gradient">99.9%</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Uptime</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Visual */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.8, delay: 0.3 }}
|
||||||
|
className="relative hidden lg:block"
|
||||||
|
>
|
||||||
|
<div className="relative aspect-square rounded-2xl bg-gradient-primary border border-border p-8 shadow-2xl">
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
y: [0, -10, 0],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 4,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
className="absolute inset-0 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<div className="w-64 h-64 rounded-full bg-gradient-to-br from-[var(--gradient-from)] via-[var(--gradient-via)] to-[var(--gradient-to)] opacity-20 blur-3xl" />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Placeholder для мокапа/скриншота */}
|
||||||
|
<div className="relative z-10 h-full rounded-xl bg-card/50 backdrop-blur-sm border border-border flex items-center justify-center">
|
||||||
|
<span className="text-muted-foreground font-medium">
|
||||||
|
[Hero Image / Product Mockup]
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating elements */}
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
y: [0, -20, 0],
|
||||||
|
rotate: [0, 5, 0],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 5,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
className="absolute -top-6 -right-6 w-32 h-32 rounded-2xl bg-[var(--feature-accent)]/10 border border-[var(--feature-accent)]/20 backdrop-blur-sm"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
y: [0, 15, 0],
|
||||||
|
rotate: [0, -5, 0],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 6,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
delay: 0.5,
|
||||||
|
}}
|
||||||
|
className="absolute -bottom-6 -left-6 w-24 h-24 rounded-full bg-[var(--gradient-via)]/10 border border-[var(--gradient-via)]/20 backdrop-blur-sm"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</SectionContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
63
src/widgets/how-it-works-section.tsx
Normal file
63
src/widgets/how-it-works-section.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SectionContainer } from "@/features/section-container";
|
||||||
|
import { SectionHeader } from "@/features/section-header";
|
||||||
|
import { StepCard } from "@/features/step-card";
|
||||||
|
import { UserPlus, FolderPlus, Users as UsersIcon, Rocket } from "lucide-react";
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
number: 1,
|
||||||
|
title: "Зарегистрируйтесь",
|
||||||
|
description: "Создайте аккаунт за 30 секунд. Кредитная карта не требуется.",
|
||||||
|
icon: UserPlus,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: 2,
|
||||||
|
title: "Создайте проект",
|
||||||
|
description: "Настройте свой первый проект и добавьте задачи.",
|
||||||
|
icon: FolderPlus,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: 3,
|
||||||
|
title: "Пригласите команду",
|
||||||
|
description: "Добавьте коллег и назначьте роли для совместной работы.",
|
||||||
|
icon: UsersIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: 4,
|
||||||
|
title: "Начните работать",
|
||||||
|
description: "Отслеживайте прогресс и достигайте целей быстрее.",
|
||||||
|
icon: Rocket,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How It Works Section - секция "Как это работает"
|
||||||
|
*/
|
||||||
|
export function HowItWorksSection() {
|
||||||
|
return (
|
||||||
|
<SectionContainer id="how-it-works">
|
||||||
|
<SectionHeader
|
||||||
|
subtitle="Как это работает"
|
||||||
|
title="Начните за 4 простых шага"
|
||||||
|
description="Настройка займет всего несколько минут"
|
||||||
|
withGradient
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
{/* Connection line */}
|
||||||
|
<div className="hidden lg:block absolute top-8 left-0 right-0 h-0.5 bg-gradient-to-r from-[var(--gradient-from)] via-[var(--gradient-via)] to-[var(--gradient-to)] opacity-20" />
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-8 lg:gap-6 relative">
|
||||||
|
{steps.map((step) => (
|
||||||
|
<StepCard key={step.number} {...step} variant="icon" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
90
src/widgets/pricing-section.tsx
Normal file
90
src/widgets/pricing-section.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SectionContainer } from "@/features/section-container";
|
||||||
|
import { SectionHeader } from "@/features/section-header";
|
||||||
|
import { PricingCard } from "@/features/pricing-card";
|
||||||
|
|
||||||
|
const pricingPlans = [
|
||||||
|
{
|
||||||
|
name: "Free",
|
||||||
|
price: 0,
|
||||||
|
description: "Для начала работы и малых команд",
|
||||||
|
features: [
|
||||||
|
{ text: "До 5 участников команды", included: true },
|
||||||
|
{ text: "До 10 проектов", included: true },
|
||||||
|
{ text: "Базовые отчеты", included: true },
|
||||||
|
{ text: "Email поддержка", included: true },
|
||||||
|
{ text: "Интеграции", included: false },
|
||||||
|
{ text: "Приоритетная поддержка", included: false },
|
||||||
|
{ text: "Кастомные роли", included: false },
|
||||||
|
],
|
||||||
|
ctaLabel: "Начать бесплатно",
|
||||||
|
ctaHref: "#",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Pro",
|
||||||
|
price: 2900,
|
||||||
|
description: "Для растущих команд",
|
||||||
|
features: [
|
||||||
|
{ text: "До 50 участников команды", included: true },
|
||||||
|
{ text: "Неограниченные проекты", included: true },
|
||||||
|
{ text: "Расширенная аналитика", included: true },
|
||||||
|
{ text: "Email и чат поддержка", included: true },
|
||||||
|
{ text: "Все интеграции", included: true },
|
||||||
|
{ text: "Приоритетная поддержка", included: true },
|
||||||
|
{ text: "Кастомные роли", included: false },
|
||||||
|
],
|
||||||
|
ctaLabel: "Попробовать Pro",
|
||||||
|
ctaHref: "#",
|
||||||
|
popular: true,
|
||||||
|
variant: "featured" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Enterprise",
|
||||||
|
price: "custom" as const,
|
||||||
|
description: "Для крупного бизнеса",
|
||||||
|
features: [
|
||||||
|
{ text: "Неограниченные участники", included: true },
|
||||||
|
{ text: "Неограниченные проекты", included: true },
|
||||||
|
{ text: "Кастомная аналитика", included: true },
|
||||||
|
{ text: "Выделенный менеджер", included: true },
|
||||||
|
{ text: "Все интеграции + API", included: true },
|
||||||
|
{ text: "24/7 поддержка", included: true },
|
||||||
|
{ text: "Кастомные роли и права", included: true },
|
||||||
|
],
|
||||||
|
ctaLabel: "Связаться с нами",
|
||||||
|
ctaHref: "#",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pricing Section - секция с тарифами
|
||||||
|
*/
|
||||||
|
export function PricingSection() {
|
||||||
|
return (
|
||||||
|
<SectionContainer id="pricing">
|
||||||
|
<SectionHeader
|
||||||
|
subtitle="Тарифы"
|
||||||
|
title="Выберите подходящий план"
|
||||||
|
description="Прозрачные цены без скрытых платежей. Отмените в любое время."
|
||||||
|
withGradient
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-3 gap-8 max-w-7xl mx-auto">
|
||||||
|
{pricingPlans.map((plan) => (
|
||||||
|
<PricingCard key={plan.name} {...plan} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Все планы включают 14-дневную бесплатную пробную версию.{" "}
|
||||||
|
<a href="#" className="text-[var(--feature-accent)] hover:underline">
|
||||||
|
Сравнить все функции
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</SectionContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
105
src/widgets/social-proof-section.tsx
Normal file
105
src/widgets/social-proof-section.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SectionContainer } from "@/features/section-container";
|
||||||
|
import { SectionHeader } from "@/features/section-header";
|
||||||
|
import { TestimonialCard } from "@/features/testimonial-card";
|
||||||
|
import { LogoCloud } from "@/features/logo-cloud";
|
||||||
|
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/shared/ui/carousel";
|
||||||
|
|
||||||
|
const testimonials = [
|
||||||
|
{
|
||||||
|
content:
|
||||||
|
"TaskFlow полностью изменил то, как мы работаем. Теперь все задачи под контролем, и команда работает намного эффективнее.",
|
||||||
|
author: {
|
||||||
|
name: "Алексей Иванов",
|
||||||
|
role: "CTO",
|
||||||
|
company: "TechStart",
|
||||||
|
avatar: "/api/placeholder/100/100",
|
||||||
|
},
|
||||||
|
rating: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content:
|
||||||
|
"Интуитивный интерфейс и мощные функции. За первую неделю наша продуктивность выросла на 40%.",
|
||||||
|
author: {
|
||||||
|
name: "Мария Петрова",
|
||||||
|
role: "Product Manager",
|
||||||
|
company: "InnovateCo",
|
||||||
|
avatar: "/api/placeholder/100/100",
|
||||||
|
},
|
||||||
|
rating: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content:
|
||||||
|
"Лучший инструмент для управления проектами, который я использовал. Поддержка на высшем уровне!",
|
||||||
|
author: {
|
||||||
|
name: "Дмитрий Сидоров",
|
||||||
|
role: "Team Lead",
|
||||||
|
company: "DevStudio",
|
||||||
|
avatar: "/api/placeholder/100/100",
|
||||||
|
},
|
||||||
|
rating: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content:
|
||||||
|
"Отличная альтернатива дорогим решениям. Все что нужно для команды из 20 человек.",
|
||||||
|
author: {
|
||||||
|
name: "Екатерина Волкова",
|
||||||
|
role: "Operations Director",
|
||||||
|
company: "GrowthLab",
|
||||||
|
avatar: "/api/placeholder/100/100",
|
||||||
|
},
|
||||||
|
rating: 4,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const logos = [
|
||||||
|
{ name: "Company 1", src: "/api/placeholder/150/50" },
|
||||||
|
{ name: "Company 2", src: "/api/placeholder/150/50" },
|
||||||
|
{ name: "Company 3", src: "/api/placeholder/150/50" },
|
||||||
|
{ name: "Company 4", src: "/api/placeholder/150/50" },
|
||||||
|
{ name: "Company 5", src: "/api/placeholder/150/50" },
|
||||||
|
{ name: "Company 6", src: "/api/placeholder/150/50" },
|
||||||
|
{ name: "Company 7", src: "/api/placeholder/150/50" },
|
||||||
|
{ name: "Company 8", src: "/api/placeholder/150/50" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Social Proof Section - секция с отзывами и логотипами клиентов
|
||||||
|
*/
|
||||||
|
export function SocialProofSection() {
|
||||||
|
return (
|
||||||
|
<SectionContainer id="testimonials" background="muted">
|
||||||
|
<SectionHeader
|
||||||
|
subtitle="Отзывы"
|
||||||
|
title="Что говорят наши клиенты"
|
||||||
|
description="Тысячи команд по всему миру доверяют TaskFlow"
|
||||||
|
withGradient
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Carousel className="w-full max-w-5xl mx-auto mb-16">
|
||||||
|
<CarouselContent>
|
||||||
|
{testimonials.map((testimonial, index) => (
|
||||||
|
<CarouselItem key={index} className="md:basis-1/2 lg:basis-1/3">
|
||||||
|
<div className="p-1">
|
||||||
|
<TestimonialCard {...testimonial} />
|
||||||
|
</div>
|
||||||
|
</CarouselItem>
|
||||||
|
))}
|
||||||
|
</CarouselContent>
|
||||||
|
<CarouselPrevious />
|
||||||
|
<CarouselNext />
|
||||||
|
</Carousel>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-center text-sm text-muted-foreground mb-8">
|
||||||
|
Нам доверяют лучшие компании
|
||||||
|
</p>
|
||||||
|
<LogoCloud logos={logos} variant="grid" />
|
||||||
|
</div>
|
||||||
|
</SectionContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
47
src/widgets/stats-section.tsx
Normal file
47
src/widgets/stats-section.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SectionContainer } from "@/features/section-container";
|
||||||
|
import { StatCard } from "@/features/stat-card";
|
||||||
|
import { Users, TrendingUp, Star, Puzzle } from "lucide-react";
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
value: "10000+",
|
||||||
|
label: "Активных пользователей",
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "99.9%",
|
||||||
|
label: "Uptime",
|
||||||
|
icon: TrendingUp,
|
||||||
|
trend: { value: 12, direction: "up" as const },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "4.9",
|
||||||
|
label: "Средняя оценка",
|
||||||
|
icon: Star,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "50+",
|
||||||
|
label: "Интеграций",
|
||||||
|
icon: Puzzle,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stats Section - секция с цифрами/достижениями
|
||||||
|
*/
|
||||||
|
export function StatsSection() {
|
||||||
|
return (
|
||||||
|
<SectionContainer id="stats" background="muted">
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8 lg:gap-12">
|
||||||
|
{stats.map((stat) => (
|
||||||
|
<StatCard key={stat.label} {...stat} animated />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SectionContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
73
src/widgets/team-section.tsx
Normal file
73
src/widgets/team-section.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SectionContainer } from "@/features/section-container";
|
||||||
|
import { SectionHeader } from "@/features/section-header";
|
||||||
|
import { TeamMemberCard } from "@/features/team-member-card";
|
||||||
|
|
||||||
|
const team = [
|
||||||
|
{
|
||||||
|
name: "Иван Петров",
|
||||||
|
role: "CEO & Founder",
|
||||||
|
bio: "10+ лет опыта в разработке SaaS продуктов",
|
||||||
|
image: "/api/placeholder/400/400",
|
||||||
|
socials: [
|
||||||
|
{ platform: "linkedin" as const, url: "#" },
|
||||||
|
{ platform: "twitter" as const, url: "#" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Анна Смирнова",
|
||||||
|
role: "CTO",
|
||||||
|
bio: "Архитектор enterprise-решений с опытом в Google",
|
||||||
|
image: "/api/placeholder/400/400",
|
||||||
|
socials: [
|
||||||
|
{ platform: "github" as const, url: "#" },
|
||||||
|
{ platform: "linkedin" as const, url: "#" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Михаил Козлов",
|
||||||
|
role: "Head of Product",
|
||||||
|
bio: "Создал продукты, используемые миллионами пользователей",
|
||||||
|
image: "/api/placeholder/400/400",
|
||||||
|
socials: [
|
||||||
|
{ platform: "linkedin" as const, url: "#" },
|
||||||
|
{ platform: "twitter" as const, url: "#" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Елена Новикова",
|
||||||
|
role: "Head of Design",
|
||||||
|
bio: "Award-winning UX/UI дизайнер с 8-летним опытом",
|
||||||
|
image: "/api/placeholder/400/400",
|
||||||
|
socials: [
|
||||||
|
{ platform: "twitter" as const, url: "#" },
|
||||||
|
{ platform: "linkedin" as const, url: "#" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Team Section - секция с командой
|
||||||
|
*/
|
||||||
|
export function TeamSection() {
|
||||||
|
return (
|
||||||
|
<SectionContainer id="team">
|
||||||
|
<SectionHeader
|
||||||
|
subtitle="Команда"
|
||||||
|
title="Познакомьтесь с нашей командой"
|
||||||
|
description="Профессионалы, которые создают TaskFlow"
|
||||||
|
withGradient
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-8">
|
||||||
|
{team.map((member) => (
|
||||||
|
<TeamMemberCard key={member.name} {...member} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SectionContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2017",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -11,8 +15,9 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "react-jsx",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "next"
|
"name": "next"
|
||||||
@@ -20,9 +25,20 @@
|
|||||||
],
|
],
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*", "./*"]
|
"@/*": [
|
||||||
|
"src/*",
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": [
|
||||||
"exclude": ["node_modules"]
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user