first commit

This commit is contained in:
grigorij
2026-08-03 21:02:34 +04:00
commit 2dd4f32646
39 changed files with 2326 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
# Скопируйте этот файл в .env.local и заполните реальными значениями.
# .env.local никогда не должен попадать в git (уже добавлен в .gitignore).
# Токен вашего Telegram-бота, полученный у @BotFather
TELEGRAM_BOT_TOKEN=8539727902:AAGVuTFMrE6TOGV4PUEzCY2-MMraWqjgyvk
# ID чата/канала, куда бот должен присылать заявки с сайта.
# Узнать свой chat_id можно, написав боту @userinfobot,
# либо получив его через метод getUpdates вашего бота.
TELEGRAM_CHAT_ID=991309145
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}
+34
View File
@@ -0,0 +1,34 @@
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+5
View File
@@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
+13
View File
@@ -0,0 +1,13 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredPackages">
<list>
<option value="aiogram" />
<option value="python-dotenv" />
</list>
</option>
</inspection_tool>
</profile>
</component>
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="jdk" jdkName="Python 3.14 (TG_BOT)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.14 (TG_BOT)" project-jdk-type="Python SDK" />
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/labnethub.iml" filepath="$PROJECT_DIR$/.idea/labnethub.iml" />
</modules>
</component>
</project>
+109
View File
@@ -0,0 +1,109 @@
# Запуск сайта LabNetHub на macOS
## 1. Проверьте, установлен ли Node.js
Откройте Terminal (⌘+Space → введите «Terminal» → Enter) и выполните:
```bash
node -v
npm -v
```
- Если видите номер версии Node **18.17 и выше** (например, `v20.x`) — переходите к шагу 3.
- Если команда не найдена или версия ниже — переходите к шагу 2.
## 2. Установите Node.js
**Вариант А — официальный установщик (самый простой):**
1. Откройте https://nodejs.org
2. Скачайте версию **LTS**
3. Запустите скачанный `.pkg`-файл и пройдите установку по шагам
**Вариант Б — через Homebrew** (если уже пользуетесь brew):
```bash
brew install node@20
brew link node@20
```
После установки закройте и снова откройте Terminal, затем проверьте:
```bash
node -v
npm -v
```
## 3. Распакуйте архив проекта
Дважды кликните `labnethub-website.zip` в Finder — рядом появится папка `labnethub`.
Либо через терминал:
```bash
cd ~/Downloads
unzip labnethub-website.zip
cd labnethub
```
## 4. Установите зависимости проекта
Находясь внутри папки `labnethub`, выполните:
```bash
npm install
```
Это скачает Next.js, React, Tailwind и остальные пакеты. При первом запуске занимает пару минут.
## 5. Настройте переменные окружения
```bash
cp .env.local.example .env.local
open -e .env.local
```
В открывшемся файле впишите свои значения:
```
TELEGRAM_BOT_TOKEN=токен_от_BotFather
TELEGRAM_CHAT_ID=ваш_chat_id
```
Сохраните (⌘+S) и закройте окно.
## 6. Запустите сайт локально
```bash
npm run dev
```
В терминале появится адрес — откройте в браузере:
```
http://localhost:3000
```
Чтобы остановить сервер — нажмите `Control + C` в терминале.
## 7. Проверка продакшен-сборки (по желанию)
```bash
npm run build
npm run start
```
Так сайт запустится в том же режиме, в котором будет работать на реальном сервере.
## Частые проблемы
| Проблема | Решение |
|---|---|
| `command not found: npm` | Node не установлен, либо терминал открыт до установки — закройте и откройте Terminal заново |
| Порт 3000 занят | Next.js сам предложит другой порт, либо укажите вручную: `npm run dev -- -p 3001` |
| Ошибки при `npm install` | Проверьте версию Node (`node -v`) — нужна 18.17 или выше |
| `EACCES` / ошибки прав доступа | Не используйте `sudo npm install` — переустановите Node через официальный установщик или Homebrew |
## Что дальше
Инструкции по заполнению реальных контактов, деплою на сервер и настройке безопасности — в `README.md` в корне проекта.
+101
View File
@@ -0,0 +1,101 @@
# LabNetHub — сайт компании
Сайт на Next.js 14 (App Router) + TypeScript + Tailwind CSS для компании
LabNetHub: разработка сайтов, ботов для соцсетей, настройка CRM, хостинг
и IT-поддержка.
## Быстрый старт
```bash
npm install
cp .env.local.example .env.local # заполните реальными значениями
npm run dev
```
Сайт откроется на http://localhost:3000
Сборка для продакшена:
```bash
npm run build
npm run start
```
## Структура проекта
```
app/ — страницы (App Router)
page.tsx — Главная
websites/ — Сайты
bots/ — Боты
crm/ — CRM
hosting/ — Хостинг и серверы
support/ — IT-поддержка
contacts/ — Контакты
api/contact/ — API-роут формы обратной связи (→ Telegram)
components/ — переиспользуемые компоненты (Header, Footer, карточки и т.д.)
config/
contacts.ts — все контакты и ссылки на соцсети в одном месте
services.ts — данные для карточек услуг и навигации
```
## Что нужно заполнить перед публикацией
1. **Контакты** — откройте `config/contacts.ts` и замените placeholder-ссылки
на реальные: Telegram, VK, WhatsApp, email, город.
2. **Telegram-уведомления формы** — в `.env.local` укажите:
- `TELEGRAM_BOT_TOKEN` — токен бота, полученный у [@BotFather](https://t.me/BotFather)
- `TELEGRAM_CHAT_ID` — ID чата/канала для приёма заявок.
Узнать свой ID можно, написав боту [@userinfobot](https://t.me/userinfobot),
либо через метод `getUpdates` вашего бота.
3. **Домен** — в `app/layout.tsx` поле `metadataBase` укажите на ваш реальный домен.
## Форма обратной связи
Форма на странице `/contacts` отправляет данные на `POST /api/contact`.
Роут проверяет обязательные поля, убирает HTML-теги (защита от инъекций),
экранирует спецсимволы Markdown и пересылает сообщение в Telegram через
Bot API. В форме также есть скрытое honeypot-поле для защиты от простых ботов.
## Безопасность
В `next.config.mjs` включены базовые защитные HTTP-заголовки:
`Strict-Transport-Security`, `X-Frame-Options`, `X-Content-Type-Options`,
`Referrer-Policy`, `Permissions-Policy`.
Рекомендации для сервера при деплое:
- Раздавайте сайт только по HTTPS (например, сертификат Let's Encrypt через certbot).
- На уровне nginx настройте `limit_req` для `/api/contact`, чтобы ограничить
количество запросов от одного адреса в минуту.
- Храните `.env.local` вне репозитория — файл уже добавлен в `.gitignore`.
- Регулярно обновляйте зависимости (`npm outdated` / `npm audit`).
## Деплой на свой сервер (LabNetHub VDS/VPS)
Пример запуска через PM2 за nginx как обратным прокси:
```bash
npm run build
pm2 start npm --name labnethub -- start
```
В конфиге nginx проксируйте запросы на `http://127.0.0.1:3000` и подключите
SSL-сертификат. При желании сайт также можно собрать в статику
(`output: "export"` в `next.config.mjs`) — тогда `/api/contact` работать не будет,
и форму нужно будет переключить на внешний сервис отправки писем.
## Цветовая тема
Токены дизайна заданы в `tailwind.config.ts`:
- `base` — тёмно-синий фон (`base.950` — основной фон страницы)
- `blue` — основной акцент бренда
- `green` — акцент раздела «Хостинг и серверы»
- `ink` — оттенки текста
Шрифты (`Unbounded`, `Manrope`, `JetBrains Mono`) подключены через
`next/font/google` в `app/layout.tsx` — при сборке скачиваются один раз
и дальше работают без обращений к Google Fonts во время выполнения.
+98
View File
@@ -0,0 +1,98 @@
import { NextRequest, NextResponse } from "next/server";
export const runtime = "nodejs";
const MAX_LENGTHS = {
name: 100,
contact: 150,
message: 2000,
};
// Убираем HTML-теги и обрезаем длину, чтобы нельзя было сломать
// форматирование сообщения в Telegram или протащить лишний код.
function sanitize(value: unknown, maxLength: number): string {
if (typeof value !== "string") return "";
return value.replace(/<[^>]*>/g, "").trim().slice(0, maxLength);
}
// Экранируем спецсимволы Telegram MarkdownV2.
function escapeMarkdown(text: string): string {
return text.replace(/[_*[\]()~`>#+\-=|{}.!\\]/g, (m) => `\\${m}`);
}
export async function POST(req: NextRequest) {
const token = process.env.TELEGRAM_BOT_TOKEN;
const chatId = process.env.TELEGRAM_CHAT_ID;
if (!token || !chatId) {
console.error("TELEGRAM_BOT_TOKEN или TELEGRAM_CHAT_ID не заданы");
return NextResponse.json(
{ ok: false, error: "Сервис временно недоступен, попробуйте позже" },
{ status: 500 }
);
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json(
{ ok: false, error: "Некорректный запрос" },
{ status: 400 }
);
}
const { name, contact, message } = (body ?? {}) as Record<string, unknown>;
const cleanName = sanitize(name, MAX_LENGTHS.name);
const cleanContact = sanitize(contact, MAX_LENGTHS.contact);
const cleanMessage = sanitize(message, MAX_LENGTHS.message);
if (!cleanName || !cleanContact || !cleanMessage) {
return NextResponse.json(
{ ok: false, error: "Заполните имя, контакт и сообщение" },
{ status: 400 }
);
}
const text = [
"*Новая заявка с сайта LabNetHub*",
"",
`*Имя:* ${escapeMarkdown(cleanName)}`,
`*Контакт:* ${escapeMarkdown(cleanContact)}`,
`*Сообщение:*`,
escapeMarkdown(cleanMessage),
].join("\n");
try {
const telegramRes = await fetch(
`https://api.telegram.org/bot${token}/sendMessage`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: chatId,
text,
parse_mode: "MarkdownV2",
}),
}
);
if (!telegramRes.ok) {
const details = await telegramRes.text();
console.error("Telegram API error:", details);
return NextResponse.json(
{ ok: false, error: "Не удалось отправить сообщение" },
{ status: 502 }
);
}
return NextResponse.json({ ok: true });
} catch (err) {
console.error("Ошибка отправки в Telegram:", err);
return NextResponse.json(
{ ok: false, error: "Не удалось отправить сообщение" },
{ status: 500 }
);
}
}
+150
View File
@@ -0,0 +1,150 @@
import type { Metadata } from "next";
import Link from "next/link";
import {
Send,
MessageCircle,
ArrowUpRight,
Check,
Bell,
ShoppingBag,
Headphones,
Workflow,
Mail,
FileText,
} from "lucide-react";
import PageHero from "@/components/PageHero";
import SectionEyebrow from "@/components/SectionEyebrow";
import CTASection from "@/components/CTASection";
export const metadata: Metadata = {
title: "Боты для Telegram и VK",
description:
"Разработка Telegram- и VK-ботов любой сложности: автоматизация заявок, уведомления, интеграция с CRM.",
};
const platforms = [
{
icon: Send,
title: "Telegram-боты",
description:
"Боты любой сложности: приём заявок, уведомления, интеграция с CRM и сервисами.",
points: ["Диалоговые сценарии и кнопки", "Оплата и интеграции", "Уведомления в реальном времени"],
},
{
icon: MessageCircle,
title: "Боты для ВКонтакте",
description:
"Боты для сообществ VK: рассылки, магазины в чате, поддержка подписчиков.",
points: ["Автоответы и сценарии диалога", "Магазин прямо в сообществе", "Рассылки по базе подписчиков"],
},
];
const useCases = [
{
icon: FileText,
title: "Приём заявок",
description: "Клиент оставляет заявку в чате — она сразу попадает в CRM или менеджеру.",
},
{
icon: Bell,
title: "Уведомления",
description: "Автоматические напоминания клиентам и сотрудникам о статусе заказа.",
},
{
icon: ShoppingBag,
title: "Магазин в чате",
description: "Каталог, корзина и оформление заказа без перехода на сайт.",
},
{
icon: Headphones,
title: "Поддержка клиентов",
description: "Ответы на частые вопросы и передача сложных случаев менеджеру.",
},
{
icon: Workflow,
title: "Интеграция с CRM",
description: "Бот пишет и читает данные напрямую из вашей CRM-системы.",
},
{
icon: Mail,
title: "Рассылки",
description: "Массовые и сегментированные рассылки по базе клиентов.",
},
];
export default function BotsPage() {
return (
<>
<PageHero
eyebrow="Боты для соцсетей"
title="Боты, которые"
highlight="работают без выходных"
subtitle="Создаём Telegram- и VK-ботов любой сложности: автоматизация заявок, уведомления, интеграция с CRM и сервисами вашего бизнеса."
>
<Link
href="/contacts"
className="inline-flex items-center gap-1.5 rounded-full bg-blue-500 px-6 py-3.5 font-medium text-white transition-transform hover:scale-[1.03]"
>
Обсудить бота
<ArrowUpRight size={18} />
</Link>
</PageHero>
<section className="px-6 py-20">
<div className="mx-auto grid max-w-6xl gap-5 lg:grid-cols-2">
{platforms.map((platform) => (
<div
key={platform.title}
className="rounded-2xl border border-white/[0.06] bg-base-900/50 p-8"
>
<div className="mb-5 flex h-11 w-11 items-center justify-center rounded-xl border border-blue-500/25 bg-blue-500/10 text-blue-400">
<platform.icon size={20} strokeWidth={1.75} />
</div>
<h3 className="font-display text-xl font-medium">{platform.title}</h3>
<p className="mt-3 text-ink-500">{platform.description}</p>
<ul className="mt-6 space-y-3">
{platform.points.map((point) => (
<li key={point} className="flex items-start gap-2.5 text-sm text-ink-300">
<Check size={17} className="mt-0.5 shrink-0 text-blue-400" />
{point}
</li>
))}
</ul>
</div>
))}
</div>
</section>
<section className="px-6 py-20">
<div className="mx-auto max-w-6xl">
<SectionEyebrow>Сценарии использования</SectionEyebrow>
<h2 className="font-display text-3xl font-medium sm:text-4xl">
Что умеют наши боты
</h2>
<div className="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{useCases.map((item) => (
<div
key={item.title}
className="rounded-2xl border border-white/[0.06] bg-base-900/40 p-6"
>
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg border border-white/[0.08] bg-white/[0.03] text-blue-400">
<item.icon size={18} strokeWidth={1.75} />
</div>
<h3 className="font-medium text-ink-100">{item.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-ink-500">
{item.description}
</p>
</div>
))}
</div>
</div>
</section>
<CTASection
title="Готовы автоматизировать общение с клиентами?"
subtitle="Опишите задачу — предложим сценарий бота и сроки разработки."
/>
</>
);
}
+107
View File
@@ -0,0 +1,107 @@
import type { Metadata } from "next";
import { Send, MessageCircle, Phone, Mail, MapPin, Clock } from "lucide-react";
import SectionEyebrow from "@/components/SectionEyebrow";
import NetworkGraphic from "@/components/NetworkGraphic";
import ContactForm from "@/components/ContactForm";
import { contacts } from "@/config/contacts";
export const metadata: Metadata = {
title: "Контакты",
description: "Свяжитесь с командой LabNetHub через форму, Telegram, WhatsApp или email.",
};
const directContacts = [
{
icon: Send,
label: "Telegram",
value: "Написать в чат",
href: contacts.telegramBot,
},
{
icon: Phone,
label: "WhatsApp",
value: "Написать в WhatsApp",
href: contacts.whatsapp,
},
{
icon: Mail,
label: "Email",
value: contacts.email,
href: `mailto:${contacts.email}`,
},
{
icon: MessageCircle,
label: "ВКонтакте",
value: "Наше сообщество",
href: contacts.vk,
},
];
export default function ContactsPage() {
return (
<>
<section className="relative overflow-hidden border-b border-white/[0.06] px-6 pb-16 pt-32 sm:pb-20 sm:pt-40">
<div className="pointer-events-none absolute right-0 top-0 w-[420px] max-w-[60%] opacity-70 sm:opacity-100">
<NetworkGraphic className="w-full" />
</div>
<div className="mx-auto max-w-3xl">
<SectionEyebrow>Контакты</SectionEyebrow>
<h1 className="text-balance font-display text-4xl font-medium leading-[1.1] sm:text-5xl">
Свяжитесь <span className="text-gradient-blue">с нами</span>
</h1>
<p className="mt-5 max-w-xl text-lg leading-relaxed text-ink-500">
Расскажите о задаче предложим решение и сроки. Форма ниже
приходит нам напрямую в Telegram.
</p>
</div>
</section>
<section className="px-6 py-20">
<div className="mx-auto grid max-w-6xl gap-12 lg:grid-cols-[1.1fr_0.9fr]">
<div>
<h2 className="mb-6 font-display text-xl font-medium">
Напишите нам
</h2>
<ContactForm />
</div>
<div>
<h2 className="mb-6 font-display text-xl font-medium">
Прямые контакты
</h2>
<div className="space-y-3">
{directContacts.map((c) => (
<a
key={c.label}
href={c.href}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 rounded-xl border border-white/[0.06] bg-base-900/50 p-4 transition-colors hover:border-blue-500/30"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-blue-500/25 bg-blue-500/10 text-blue-400">
<c.icon size={18} strokeWidth={1.75} />
</div>
<div>
<p className="text-sm text-ink-500">{c.label}</p>
<p className="font-medium text-ink-100">{c.value}</p>
</div>
</a>
))}
</div>
<div className="mt-6 space-y-3 border-t border-white/[0.06] pt-6">
<div className="flex items-center gap-3 text-sm text-ink-500">
<MapPin size={16} className="text-ink-700" />
{contacts.city}
</div>
<div className="flex items-center gap-3 text-sm text-ink-500">
<Clock size={16} className="text-ink-700" />
{contacts.workingHours}
</div>
</div>
</div>
</div>
</section>
</>
);
}
+143
View File
@@ -0,0 +1,143 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ArrowUpRight, Check, Lock, Wallet, Settings2, Database } from "lucide-react";
import PageHero from "@/components/PageHero";
import SectionEyebrow from "@/components/SectionEyebrow";
import CTASection from "@/components/CTASection";
export const metadata: Metadata = {
title: "Настройка и интеграция CRM",
description:
"Установка, настройка и интеграция CRM-систем с открытым исходным кодом под процессы вашего бизнеса.",
};
const benefits = [
{
icon: Wallet,
title: "Без лицензионных платежей",
description: "Открытый исходный код — платите за настройку, а не за подписку на каждого пользователя.",
},
{
icon: Lock,
title: "Данные остаются у вас",
description: "CRM разворачивается на вашем сервере — полный контроль над данными клиентов.",
},
{
icon: Settings2,
title: "Гибкая настройка",
description: "Дорабатываем систему под ваши процессы, а не подстраиваем бизнес под шаблон.",
},
{
icon: Database,
title: "Свобода интеграций",
description: "Подключаем сайт, боты, почту и другие сервисы без ограничений вендора.",
},
];
const systems = [
{
name: "EspoCRM",
description: "Гибкая CRM с удобным конструктором сущностей и процессов под любые отрасли.",
},
{
name: "SuiteCRM",
description: "Развитая система с модулями продаж, поддержки и маркетинга из коробки.",
},
{
name: "Odoo CRM",
description: "Часть экосистемы Odoo — легко расширяется складом, документами и бухгалтерией.",
},
];
const included = [
"Установка и настройка CRM на сервере",
"Настройка воронок продаж и полей под ваши процессы",
"Интеграция с сайтом, ботами и почтой",
"Перенос текущей базы клиентов",
"Обучение сотрудников работе с системой",
"Техническая поддержка после запуска",
];
export default function CrmPage() {
return (
<>
<PageHero
eyebrow="CRM-системы"
title="Настройка и"
highlight="интеграция CRM"
subtitle="Устанавливаем, настраиваем и интегрируем CRM с открытым исходным кодом — под процессы именно вашего бизнеса."
>
<Link
href="/contacts"
className="inline-flex items-center gap-1.5 rounded-full bg-blue-500 px-6 py-3.5 font-medium text-white transition-transform hover:scale-[1.03]"
>
Обсудить внедрение
<ArrowUpRight size={18} />
</Link>
</PageHero>
<section className="px-6 py-20">
<div className="mx-auto max-w-6xl">
<SectionEyebrow>Почему open source</SectionEyebrow>
<div className="mt-8 grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
{benefits.map((b) => (
<div key={b.title}>
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg border border-white/[0.08] bg-white/[0.03] text-blue-400">
<b.icon size={18} strokeWidth={1.75} />
</div>
<h3 className="font-medium text-ink-100">{b.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-ink-500">
{b.description}
</p>
</div>
))}
</div>
</div>
</section>
<section className="px-6 py-20">
<div className="mx-auto max-w-6xl">
<SectionEyebrow>С какими системами работаем</SectionEyebrow>
<h2 className="font-display text-3xl font-medium sm:text-4xl">
Примеры CRM с открытым кодом
</h2>
<div className="mt-12 grid gap-5 lg:grid-cols-3">
{systems.map((s) => (
<div
key={s.name}
className="rounded-2xl border border-white/[0.06] bg-base-900/50 p-7"
>
<h3 className="font-display text-lg font-medium">{s.name}</h3>
<p className="mt-3 text-sm leading-relaxed text-ink-500">
{s.description}
</p>
</div>
))}
</div>
<p className="mt-6 text-sm text-ink-700">
Также работаем с другими open-source CRM подберём систему под ваши задачи и бюджет.
</p>
</div>
</section>
<section className="px-6 py-20">
<div className="mx-auto max-w-6xl rounded-3xl border border-white/[0.06] bg-base-900/40 p-10">
<SectionEyebrow>Что входит в услугу</SectionEyebrow>
<div className="mt-6 grid gap-4 sm:grid-cols-2">
{included.map((item) => (
<div key={item} className="flex items-start gap-2.5">
<Check size={17} className="mt-0.5 shrink-0 text-blue-400" />
<span className="text-ink-300">{item}</span>
</div>
))}
</div>
</div>
</section>
<CTASection
title="Хотите навести порядок в клиентской базе?"
subtitle="Расскажем, какая CRM подойдёт именно вашим процессам."
/>
</>
);
}
+68
View File
@@ -0,0 +1,68 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
scroll-behavior: smooth;
}
body {
@apply bg-base-950 text-ink-100 font-body antialiased;
background-image: theme("backgroundImage.grid-fade");
background-attachment: fixed;
}
h1,
h2,
h3,
h4 {
@apply font-display text-ink-100;
}
::selection {
@apply bg-blue-600/40 text-ink-100;
}
:focus-visible {
outline: 2px solid theme("colors.blue.400");
outline-offset: 3px;
border-radius: 2px;
}
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-track {
@apply bg-base-950;
}
::-webkit-scrollbar-thumb {
@apply bg-base-700 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-blue-700;
}
}
@layer utilities {
.text-gradient-blue {
@apply bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-blue-600;
}
.text-gradient-green {
@apply bg-clip-text text-transparent bg-gradient-to-r from-green-300 to-green-500;
}
.clip-corner {
clip-path: polygon(0 0, 100% 0, 100% calc(100% - 22px), calc(100% - 22px) 100%, 0 100%);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
}
+134
View File
@@ -0,0 +1,134 @@
import type { Metadata } from "next";
import Link from "next/link";
import {
ArrowUpRight,
Check,
MapPin,
Globe,
Gauge,
Settings2,
Cpu,
} from "lucide-react";
import PageHero from "@/components/PageHero";
import SectionEyebrow from "@/components/SectionEyebrow";
import CTASection from "@/components/CTASection";
export const metadata: Metadata = {
title: "Хостинг и серверы",
description:
"VDS/VPS на собственных серверах в Астрахани и у партнёров по всему миру. NVMe, SLA 99.9%, защита от DDoS.",
};
const features = [
{
icon: MapPin,
title: "Собственный дата-центр",
description: "Современное оборудование в нашем дата-центре в Астрахани.",
},
{
icon: Globe,
title: "Локации по всему миру",
description: "Европа, Азия, США и другие регионы у партнёров.",
},
{
icon: Gauge,
title: "Высокая производительность",
description: "NVMe-диски и мощные процессоры для стабильной нагрузки.",
},
{
icon: Settings2,
title: "Гибкие конфигурации",
description: "Соберём сервер под ваши задачи, а не по шаблону.",
},
];
const specs = [
"От 1 до 32 vCPU",
"От 1 до 128 GB RAM",
"NVMe SSD",
"Linux / Windows",
"SLA 99.9%",
"Защита от DDoS",
"Резервное копирование",
"Панель управления и API",
];
export default function HostingPage() {
return (
<>
<PageHero
accent="green"
eyebrow="Хостинг и серверы"
title="Надёжные серверы"
highlight="по всему миру"
subtitle="Предоставляем мощные и стабильные VDS/VPS — на собственных серверах в Астрахани и в других локациях у проверенных партнёров."
>
<Link
href="/contacts"
className="inline-flex items-center gap-1.5 rounded-full bg-green-500 px-6 py-3.5 font-medium text-base-950 transition-transform hover:scale-[1.03]"
>
Подобрать сервер
<ArrowUpRight size={18} />
</Link>
</PageHero>
<section className="px-6 py-20">
<div className="mx-auto max-w-6xl grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
{features.map((f) => (
<div key={f.title}>
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg border border-green-500/25 bg-green-500/10 text-green-400">
<f.icon size={18} strokeWidth={1.75} />
</div>
<h3 className="font-medium text-ink-100">{f.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-ink-500">
{f.description}
</p>
</div>
))}
</div>
</section>
<section className="px-6 py-20">
<div className="mx-auto max-w-4xl rounded-3xl border border-green-500/20 bg-green-500/[0.04] p-10">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-green-500/25 bg-green-500/10 text-green-400">
<Cpu size={20} strokeWidth={1.75} />
</div>
<h2 className="font-display text-xl font-medium">
VDS/VPS от LabNetHub
</h2>
</div>
<div className="mt-8 grid gap-4 sm:grid-cols-2">
{specs.map((spec) => (
<div key={spec} className="flex items-start gap-2.5">
<Check size={17} className="mt-0.5 shrink-0 text-green-400" />
<span className="text-ink-300">{spec}</span>
</div>
))}
</div>
<p className="mt-8 text-sm text-ink-500">
Стоимость зависит от конфигурации и локации рассчитаем тариф под
вашу задачу.
</p>
</div>
</section>
<section className="px-6 pb-4">
<div className="mx-auto max-w-6xl">
<SectionEyebrow accent="green">Размещение проектов</SectionEyebrow>
<p className="max-w-2xl text-ink-500">
Размещаем сайты и боты, разработанные нашей командой, на
собственных серверах или у партнёров без необходимости искать
хостинг отдельно и разбираться в настройке сервера самостоятельно.
</p>
</div>
</section>
<CTASection
title="Нужен сервер под проект?"
subtitle="Подберём конфигурацию и локацию, поможем с переносом и настройкой."
accent="green"
/>
</>
);
}
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#6FA8FF" />
<stop offset="100%" stop-color="#2F63EB" />
</linearGradient>
</defs>
<rect width="40" height="40" rx="9" fill="#0A1428" />
<polygon points="10,7 18,7 15,32 7,32" fill="#F5F7FB" />
<polygon points="14,32 33,32 28,24 18,24" fill="url(#g)" />
</svg>

After

Width:  |  Height:  |  Size: 440 B

+59
View File
@@ -0,0 +1,59 @@
import type { Metadata } from "next";
import { Unbounded, Manrope, JetBrains_Mono } from "next/font/google";
import "./globals.css";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
const unbounded = Unbounded({
subsets: ["latin", "cyrillic"],
weight: ["400", "500", "600"],
variable: "--font-unbounded",
display: "swap",
});
const manrope = Manrope({
subsets: ["latin", "cyrillic"],
weight: ["400", "500", "600", "700"],
variable: "--font-manrope",
display: "swap",
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin", "cyrillic"],
weight: ["400", "500"],
variable: "--font-jetbrains",
display: "swap",
});
export const metadata: Metadata = {
metadataBase: new URL("https://labnethub.ru"),
title: {
default: "LabNetHub — комплексные IT-решения для бизнеса",
template: "%s — LabNetHub",
},
description:
"Сайты, боты для соцсетей, настройка CRM, хостинг и IT-поддержка. От идеи до стабильной работы вашей инфраструктуры.",
openGraph: {
title: "LabNetHub — комплексные IT-решения для бизнеса",
description:
"Сайты, боты для соцсетей, настройка CRM, хостинг и IT-поддержка.",
locale: "ru_RU",
type: "website",
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ru" className={`${unbounded.variable} ${manrope.variable} ${jetbrainsMono.variable}`}>
<body>
<Header />
<main className="min-h-screen pt-[73px]">{children}</main>
<Footer />
</body>
</html>
);
}
+113
View File
@@ -0,0 +1,113 @@
import Link from "next/link";
import { ArrowUpRight, ShieldCheck, Rocket, SlidersHorizontal, Headphones } from "lucide-react";
import SectionEyebrow from "@/components/SectionEyebrow";
import ServiceCard from "@/components/ServiceCard";
import FeatureBadge from "@/components/FeatureBadge";
import NetworkGraphic from "@/components/NetworkGraphic";
import CTASection from "@/components/CTASection";
import { services } from "@/config/services";
export default function HomePage() {
return (
<>
<section className="relative overflow-hidden px-6 pb-20 pt-32 sm:pb-28 sm:pt-40">
<div className="pointer-events-none absolute right-[-40px] top-10 w-[480px] max-w-[65%] opacity-60 sm:opacity-100">
<NetworkGraphic accent="blue" className="w-full" />
</div>
<div className="mx-auto max-w-3xl">
<SectionEyebrow>Технологии · Решения · Поддержка</SectionEyebrow>
<h1 className="text-balance font-display text-4xl font-medium leading-[1.08] sm:text-6xl">
Комплексные IT-решения{" "}
<span className="text-gradient-blue">для вашего бизнеса</span>
</h1>
<p className="mt-6 max-w-xl text-lg leading-relaxed text-ink-500">
От идеи до стабильной работы инфраструктуры: сайты, боты для
соцсетей, CRM, серверы и техническая поддержка в одной команде.
</p>
<div className="mt-9 flex flex-wrap gap-3">
<Link
href="/contacts"
className="inline-flex items-center gap-1.5 rounded-full bg-blue-500 px-6 py-3.5 font-medium text-white transition-transform hover:scale-[1.03]"
>
Обсудить проект
<ArrowUpRight size={18} />
</Link>
<Link
href="#services"
className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.12] px-6 py-3.5 font-medium text-ink-100 transition-colors hover:bg-white/[0.05]"
>
Смотреть услуги
</Link>
</div>
</div>
<div className="mx-auto mt-16 grid max-w-3xl grid-cols-2 gap-6 border-t border-white/[0.06] pt-8 sm:grid-cols-4">
{[
["5", "направлений услуг"],
["24/7", "техподдержка"],
["99.9%", "SLA на серверах"],
["1", "команда на весь проект"],
].map(([value, label]) => (
<div key={label}>
<p className="font-display text-2xl text-ink-100">{value}</p>
<p className="mt-1 text-sm text-ink-500">{label}</p>
</div>
))}
</div>
</section>
<section id="services" className="scroll-mt-24 px-6 py-20">
<div className="mx-auto max-w-6xl">
<div className="max-w-2xl">
<SectionEyebrow>Что мы делаем</SectionEyebrow>
<h2 className="font-display text-3xl font-medium sm:text-4xl">
Всё для роста вашего бизнеса в одном месте
</h2>
<p className="mt-4 text-ink-500">
Каждое направление можно заказать отдельно или как часть общего
решения от первого сайта до полноценной IT-инфраструктуры.
</p>
</div>
<div className="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{services.map((service) => (
<ServiceCard key={service.key} service={service} />
))}
</div>
</div>
</section>
<section className="px-6 py-20">
<div className="mx-auto max-w-6xl rounded-3xl border border-white/[0.06] bg-base-900/40 p-10">
<SectionEyebrow>Почему выбирают нас</SectionEyebrow>
<div className="mt-6 grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
<FeatureBadge
icon={ShieldCheck}
title="Надёжность"
description="Гарантия качества и стабильной работы решений"
/>
<FeatureBadge
icon={Rocket}
title="Скорость"
description="Быстрое выполнение задач и запуск проектов"
/>
<FeatureBadge
icon={SlidersHorizontal}
title="Индивидуальный подход"
description="Решения под конкретные задачи вашего бизнеса"
/>
<FeatureBadge
icon={Headphones}
title="Поддержка 24/7"
description="Мы всегда на связи и готовы помочь"
/>
</div>
</div>
</section>
<CTASection />
</>
);
}
+120
View File
@@ -0,0 +1,120 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ArrowUpRight, Server, Activity, Wrench, MessageSquare, Check } from "lucide-react";
import PageHero from "@/components/PageHero";
import SectionEyebrow from "@/components/SectionEyebrow";
import CTASection from "@/components/CTASection";
export const metadata: Metadata = {
title: "IT-поддержка и инфраструктура",
description:
"Обслуживание IT-инфраструктуры бизнеса: сборка серверов, мониторинг, устранение сбоев, консультации.",
};
const coverage = [
{
icon: Server,
title: "Обслуживание серверов",
description: "Настройка, обновление и сопровождение серверного оборудования.",
},
{
icon: Activity,
title: "Мониторинг и профилактика",
description: "Отслеживаем состояние систем, чтобы предупредить сбои заранее.",
},
{
icon: Wrench,
title: "Сборка и настройка оборудования",
description: "Подбираем и настраиваем технику под задачи вашей команды.",
},
{
icon: MessageSquare,
title: "Консультации",
description: "Помогаем принимать решения по развитию IT-инфраструктуры.",
},
];
const formats = [
{
title: "Разовая задача",
description:
"Точечная помощь: настройка сервера, устранение конкретной проблемы, консультация.",
points: ["Без долгосрочных обязательств", "Оценка сроков после диагностики задачи"],
},
{
title: "Постоянное обслуживание",
description:
"Регулярное сопровождение инфраструктуры: мониторинг, обновления, приоритетная поддержка.",
points: ["Согласованный формат и время реакции", "Профилактика вместо экстренных починок"],
},
];
export default function SupportPage() {
return (
<>
<PageHero
eyebrow="IT-поддержка"
title="Поддержка вашей"
highlight="IT-инфраструктуры"
subtitle="Обслуживаем существующую инфраструктуру бизнеса: от сборки и настройки оборудования до мониторинга и устранения сбоев."
>
<Link
href="/contacts"
className="inline-flex items-center gap-1.5 rounded-full bg-blue-500 px-6 py-3.5 font-medium text-white transition-transform hover:scale-[1.03]"
>
Обсудить задачу
<ArrowUpRight size={18} />
</Link>
</PageHero>
<section className="px-6 py-20">
<div className="mx-auto max-w-6xl grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
{coverage.map((c) => (
<div key={c.title}>
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg border border-white/[0.08] bg-white/[0.03] text-blue-400">
<c.icon size={18} strokeWidth={1.75} />
</div>
<h3 className="font-medium text-ink-100">{c.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-ink-500">
{c.description}
</p>
</div>
))}
</div>
</section>
<section className="px-6 py-20">
<div className="mx-auto max-w-6xl">
<SectionEyebrow>Формат работы</SectionEyebrow>
<h2 className="font-display text-3xl font-medium sm:text-4xl">
Как удобно вам
</h2>
<div className="mt-12 grid gap-5 lg:grid-cols-2">
{formats.map((f) => (
<div
key={f.title}
className="rounded-2xl border border-white/[0.06] bg-base-900/50 p-8"
>
<h3 className="font-display text-lg font-medium">{f.title}</h3>
<p className="mt-3 text-ink-500">{f.description}</p>
<ul className="mt-6 space-y-3">
{f.points.map((point) => (
<li key={point} className="flex items-start gap-2.5 text-sm text-ink-300">
<Check size={17} className="mt-0.5 shrink-0 text-blue-400" />
{point}
</li>
))}
</ul>
</div>
))}
</div>
</div>
</section>
<CTASection
title="Нужна помощь с инфраструктурой?"
subtitle="Опишите ситуацию — предложим формат работы и следующий шаг."
/>
</>
);
}
+160
View File
@@ -0,0 +1,160 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Check, ArrowUpRight } from "lucide-react";
import PageHero from "@/components/PageHero";
import SectionEyebrow from "@/components/SectionEyebrow";
import CTASection from "@/components/CTASection";
export const metadata: Metadata = {
title: "Сайты для бизнеса",
description:
"Разработка сайтов-визиток и полноценных корпоративных сайтов для малого и среднего бизнеса.",
};
const offers = [
{
title: "Сайт-визитка",
description:
"Компактный сайт на 1–5 страниц: рассказать о компании, показать услуги и получить первые заявки.",
points: [
"Адаптивная вёрстка под любые экраны",
"Форма заявки и контакты на видном месте",
"Быстрый запуск — от нескольких дней",
],
},
{
title: "Корпоративный сайт под ключ",
description:
"Полноценный сайт для компании: каталог, блог, личный кабинет, интеграции с CRM и сервисами.",
points: [
"Структура под задачи бизнеса, а не шаблон",
"SEO-оптимизация и подготовка к продвижению",
"Интеграция с CRM, ботами и аналитикой",
],
},
];
const steps = [
{
n: "01",
title: "Бриф",
description: "Обсуждаем задачи бизнеса, аудиторию и нужный результат.",
},
{
n: "02",
title: "Прототип и дизайн",
description: "Собираем структуру страниц и визуальный стиль сайта.",
},
{
n: "03",
title: "Разработка",
description: "Вёрстка и программирование с адаптацией под все устройства.",
},
{
n: "04",
title: "Тестирование",
description: "Проверяем работу форм, скорость и корректность на разных устройствах.",
},
{
n: "05",
title: "Запуск и поддержка",
description: "Публикуем сайт и остаёмся на связи для доработок.",
},
];
const included = [
"Адаптивная вёрстка под мобильные, планшеты и десктоп",
"SEO-оптимизация: метатеги, семантическая разметка, скорость загрузки",
"Интеграция форм обратной связи с Telegram, email или CRM",
"Подключение аналитики (Яндекс.Метрика, Google Analytics)",
"Настройка домена, SSL-сертификата и хостинга",
"Инструкция по управлению сайтом после запуска",
];
export default function WebsitesPage() {
return (
<>
<PageHero
eyebrow="Разработка сайтов"
title="Сайты, которые"
highlight="работают на бизнес"
subtitle="Разрабатываем современные и адаптивные сайты для вашего бизнеса — от простой визитки до полноценного корпоративного портала."
>
<Link
href="/contacts"
className="inline-flex items-center gap-1.5 rounded-full bg-blue-500 px-6 py-3.5 font-medium text-white transition-transform hover:scale-[1.03]"
>
Обсудить проект
<ArrowUpRight size={18} />
</Link>
<Link
href="#process"
className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.12] px-6 py-3.5 font-medium text-ink-100 transition-colors hover:bg-white/[0.05]"
>
Как мы работаем
</Link>
</PageHero>
<section className="px-6 py-20">
<div className="mx-auto grid max-w-6xl gap-5 lg:grid-cols-2">
{offers.map((offer) => (
<div
key={offer.title}
className="rounded-2xl border border-white/[0.06] bg-base-900/50 p-8"
>
<h3 className="font-display text-xl font-medium">{offer.title}</h3>
<p className="mt-3 text-ink-500">{offer.description}</p>
<ul className="mt-6 space-y-3">
{offer.points.map((point) => (
<li key={point} className="flex items-start gap-2.5 text-sm text-ink-300">
<Check size={17} className="mt-0.5 shrink-0 text-blue-400" />
{point}
</li>
))}
</ul>
</div>
))}
</div>
</section>
<section id="process" className="scroll-mt-24 px-6 py-20">
<div className="mx-auto max-w-6xl">
<SectionEyebrow>Как мы работаем</SectionEyebrow>
<h2 className="font-display text-3xl font-medium sm:text-4xl">
Пять шагов от идеи до запуска
</h2>
<div className="mt-12 grid gap-8 sm:grid-cols-2 lg:grid-cols-5">
{steps.map((step) => (
<div key={step.n}>
<p className="font-mono text-sm text-blue-400">{step.n}</p>
<h3 className="mt-2 font-display text-lg font-medium">
{step.title}
</h3>
<p className="mt-2 text-sm leading-relaxed text-ink-500">
{step.description}
</p>
</div>
))}
</div>
</div>
</section>
<section className="px-6 py-20">
<div className="mx-auto max-w-6xl rounded-3xl border border-white/[0.06] bg-base-900/40 p-10">
<SectionEyebrow>Что входит в разработку</SectionEyebrow>
<div className="mt-6 grid gap-4 sm:grid-cols-2">
{included.map((item) => (
<div key={item} className="flex items-start gap-2.5">
<Check size={17} className="mt-0.5 shrink-0 text-blue-400" />
<span className="text-ink-300">{item}</span>
</div>
))}
</div>
</div>
</section>
<CTASection />
</>
);
}
+43
View File
@@ -0,0 +1,43 @@
import Link from "next/link";
import { ArrowUpRight } from "lucide-react";
interface CTASectionProps {
title?: string;
subtitle?: string;
accent?: "blue" | "green";
}
export default function CTASection({
title = "Готовы обсудить проект?",
subtitle = "Расскажите, что нужно — предложим решение и сроки в течение рабочего дня.",
accent = "blue",
}: CTASectionProps) {
const isGreen = accent === "green";
return (
<section className="px-6 py-20">
<div
className={`mx-auto flex max-w-4xl flex-col items-start justify-between gap-6 rounded-3xl border p-10 sm:flex-row sm:items-center ${
isGreen
? "border-green-500/20 bg-green-500/[0.04]"
: "border-blue-500/20 bg-blue-500/[0.04]"
}`}
>
<div>
<h2 className="font-display text-2xl font-medium text-ink-100">
{title}
</h2>
<p className="mt-2 max-w-md text-ink-500">{subtitle}</p>
</div>
<Link
href="/contacts"
className={`inline-flex shrink-0 items-center gap-1.5 rounded-full px-6 py-3 font-medium text-base-950 transition-transform hover:scale-[1.03] ${
isGreen ? "bg-green-400" : "bg-blue-400"
}`}
>
Связаться с нами
<ArrowUpRight size={18} />
</Link>
</div>
</section>
);
}
+144
View File
@@ -0,0 +1,144 @@
"use client";
import { useState, FormEvent } from "react";
import { Loader2, CheckCircle2, AlertCircle } from "lucide-react";
type Status = "idle" | "loading" | "success" | "error";
export default function ContactForm() {
const [status, setStatus] = useState<Status>("idle");
const [errorMessage, setErrorMessage] = useState("");
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus("loading");
setErrorMessage("");
const form = e.currentTarget;
const data = new FormData(form);
// Honeypot: если поле заполнено — это бот, тихо считаем успехом.
if (data.get("website")) {
setStatus("success");
form.reset();
return;
}
const payload = {
name: String(data.get("name") || ""),
contact: String(data.get("contact") || ""),
message: String(data.get("message") || ""),
};
try {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await res.json();
if (!res.ok || !result.ok) {
throw new Error(result.error || "Не удалось отправить сообщение");
}
setStatus("success");
form.reset();
} catch (err) {
setStatus("error");
setErrorMessage(
err instanceof Error ? err.message : "Не удалось отправить сообщение"
);
}
}
if (status === "success") {
return (
<div className="flex items-start gap-3 rounded-2xl border border-green-500/25 bg-green-500/[0.06] p-6">
<CheckCircle2 className="mt-0.5 shrink-0 text-green-400" size={22} />
<div>
<p className="font-medium text-ink-100">Сообщение отправлено</p>
<p className="mt-1 text-sm text-ink-500">
Мы получили заявку и ответим в ближайшее время.
</p>
</div>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
{/* Honeypot — скрыто от людей, видно ботам */}
<input
type="text"
name="website"
tabIndex={-1}
autoComplete="off"
className="absolute left-[-9999px] h-0 w-0 opacity-0"
aria-hidden="true"
/>
<div>
<label htmlFor="name" className="mb-1.5 block text-sm text-ink-300">
Имя
</label>
<input
id="name"
name="name"
type="text"
required
maxLength={100}
placeholder="Как к вам обращаться"
className="w-full rounded-xl border border-white/[0.08] bg-base-900/60 px-4 py-3 text-ink-100 placeholder:text-ink-700 focus:border-blue-500/50"
/>
</div>
<div>
<label htmlFor="contact" className="mb-1.5 block text-sm text-ink-300">
Telegram, WhatsApp или email
</label>
<input
id="contact"
name="contact"
type="text"
required
maxLength={150}
placeholder="Как с вами связаться"
className="w-full rounded-xl border border-white/[0.08] bg-base-900/60 px-4 py-3 text-ink-100 placeholder:text-ink-700 focus:border-blue-500/50"
/>
</div>
<div>
<label htmlFor="message" className="mb-1.5 block text-sm text-ink-300">
Сообщение
</label>
<textarea
id="message"
name="message"
required
maxLength={2000}
rows={5}
placeholder="Расскажите о задаче — направление, сроки, пожелания"
className="w-full resize-none rounded-xl border border-white/[0.08] bg-base-900/60 px-4 py-3 text-ink-100 placeholder:text-ink-700 focus:border-blue-500/50"
/>
</div>
{status === "error" && (
<div className="flex items-start gap-2.5 rounded-xl border border-red-500/25 bg-red-500/[0.06] px-4 py-3 text-sm text-red-300">
<AlertCircle size={18} className="mt-0.5 shrink-0" />
{errorMessage}
</div>
)}
<button
type="submit"
disabled={status === "loading"}
className="inline-flex w-full items-center justify-center gap-2 rounded-full bg-blue-500 px-6 py-3.5 font-medium text-white transition-transform hover:scale-[1.01] disabled:opacity-60 sm:w-auto"
>
{status === "loading" && <Loader2 size={18} className="animate-spin" />}
{status === "loading" ? "Отправляем…" : "Отправить сообщение"}
</button>
</form>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { LucideIcon } from "lucide-react";
interface FeatureBadgeProps {
icon: LucideIcon;
title: string;
description: string;
}
export default function FeatureBadge({
icon: Icon,
title,
description,
}: FeatureBadgeProps) {
return (
<div className="flex items-start gap-3.5">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-white/[0.08] bg-white/[0.03] text-blue-400">
<Icon size={18} strokeWidth={1.75} />
</div>
<div>
<p className="font-medium text-ink-100">{title}</p>
<p className="text-sm text-ink-500">{description}</p>
</div>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
import Link from "next/link";
import { Send, Mail, MessageCircle } from "lucide-react";
import Logo from "./Logo";
import { navLinks } from "@/config/services";
import { contacts } from "@/config/contacts";
export default function Footer() {
return (
<footer className="border-t border-white/[0.06] px-6 py-14">
<div className="mx-auto grid max-w-6xl gap-10 sm:grid-cols-2 lg:grid-cols-4">
<div>
<Logo />
<p className="mt-4 max-w-xs text-sm leading-relaxed text-ink-500">
Комплексные IT-решения для бизнеса: сайты, боты, CRM, серверы и
техническая поддержка.
</p>
</div>
<div>
<p className="mb-4 font-mono text-xs uppercase tracking-[0.2em] text-ink-700">
Разделы
</p>
<ul className="space-y-2.5">
{navLinks.slice(1).map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="text-sm text-ink-500 transition-colors hover:text-ink-100"
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
<div>
<p className="mb-4 font-mono text-xs uppercase tracking-[0.2em] text-ink-700">
Контакты
</p>
<ul className="space-y-2.5 text-sm text-ink-500">
<li>{contacts.city}</li>
<li>
<a
href={`mailto:${contacts.email}`}
className="transition-colors hover:text-ink-100"
>
{contacts.email}
</a>
</li>
<li>{contacts.workingHours}</li>
</ul>
</div>
<div>
<p className="mb-4 font-mono text-xs uppercase tracking-[0.2em] text-ink-700">
Мы в сети
</p>
<div className="flex gap-3">
<a
href={contacts.telegramChannel}
target="_blank"
rel="noopener noreferrer"
aria-label="Telegram"
className="flex h-10 w-10 items-center justify-center rounded-full border border-white/[0.08] text-ink-300 transition-colors hover:border-blue-500/40 hover:text-blue-400"
>
<Send size={17} />
</a>
<a
href={contacts.vk}
target="_blank"
rel="noopener noreferrer"
aria-label="VKontakte"
className="flex h-10 w-10 items-center justify-center rounded-full border border-white/[0.08] text-ink-300 transition-colors hover:border-blue-500/40 hover:text-blue-400"
>
<MessageCircle size={17} />
</a>
<a
href={`mailto:${contacts.email}`}
aria-label="Email"
className="flex h-10 w-10 items-center justify-center rounded-full border border-white/[0.08] text-ink-300 transition-colors hover:border-blue-500/40 hover:text-blue-400"
>
<Mail size={17} />
</a>
</div>
</div>
</div>
<div className="mx-auto mt-12 max-w-6xl border-t border-white/[0.06] pt-6 text-xs text-ink-700">
© {new Date().getFullYear()} LabNetHub. Все права защищены.
</div>
</footer>
);
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Menu, X, ArrowUpRight } from "lucide-react";
import Logo from "./Logo";
import { navLinks } from "@/config/services";
export default function Header() {
const pathname = usePathname();
const [open, setOpen] = useState(false);
return (
<header className="fixed inset-x-0 top-0 z-50 border-b border-white/[0.06] bg-base-950/80 backdrop-blur-lg">
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
<Link href="/" onClick={() => setOpen(false)}>
<Logo />
</Link>
<nav className="hidden items-center gap-1 lg:flex">
{navLinks.map((link) => {
const active =
link.href === "/"
? pathname === "/"
: pathname.startsWith(link.href);
return (
<Link
key={link.href}
href={link.href}
className={`rounded-full px-4 py-2 text-sm transition-colors ${
active
? "bg-white/[0.06] text-ink-100"
: "text-ink-500 hover:text-ink-100"
}`}
>
{link.label}
</Link>
);
})}
</nav>
<Link
href="/contacts"
className="hidden shrink-0 items-center gap-1.5 rounded-full bg-blue-500 px-5 py-2.5 text-sm font-medium text-white transition-transform hover:scale-[1.03] lg:inline-flex"
>
Связаться
<ArrowUpRight size={16} />
</Link>
<button
onClick={() => setOpen((v) => !v)}
className="flex h-10 w-10 items-center justify-center rounded-full border border-white/[0.08] text-ink-100 lg:hidden"
aria-label={open ? "Закрыть меню" : "Открыть меню"}
aria-expanded={open}
>
{open ? <X size={20} /> : <Menu size={20} />}
</button>
</div>
{open && (
<nav className="border-t border-white/[0.06] bg-base-950 px-6 py-4 lg:hidden">
<ul className="flex flex-col gap-1">
{navLinks.map((link) => (
<li key={link.href}>
<Link
href={link.href}
onClick={() => setOpen(false)}
className="block rounded-lg px-3 py-2.5 text-ink-300 hover:bg-white/[0.05] hover:text-ink-100"
>
{link.label}
</Link>
</li>
))}
</ul>
</nav>
)}
</header>
);
}
+42
View File
@@ -0,0 +1,42 @@
interface LogoProps {
className?: string;
wordmarkClassName?: string;
showWordmark?: boolean;
}
export default function Logo({
className = "h-8 w-8",
wordmarkClassName = "text-lg",
showWordmark = true,
}: LogoProps) {
return (
<span className="inline-flex items-center gap-2.5 select-none">
<svg
viewBox="0 0 40 40"
className={className}
aria-hidden="true"
role="img"
>
<defs>
<linearGradient id="labnet-mark" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#6FA8FF" />
<stop offset="100%" stopColor="#2F63EB" />
</linearGradient>
</defs>
<polygon points="9,3 19,3 15,34 5,34" fill="#F5F7FB" />
<polygon
points="14,34 36,34 30,24 18,24"
fill="url(#labnet-mark)"
/>
</svg>
{showWordmark && (
<span
className={`font-display font-medium tracking-tight ${wordmarkClassName}`}
>
<span className="text-ink-100">LabNet</span>
<span className="text-gradient-blue">Hub</span>
</span>
)}
</span>
);
}
+71
View File
@@ -0,0 +1,71 @@
interface NetworkGraphicProps {
accent?: "blue" | "green";
className?: string;
}
const nodes = [
{ x: 40, y: 40, r: 3 },
{ x: 140, y: 20, r: 2.5 },
{ x: 220, y: 70, r: 4 },
{ x: 300, y: 30, r: 2.5 },
{ x: 90, y: 130, r: 2.5 },
{ x: 190, y: 150, r: 3.5 },
{ x: 280, y: 130, r: 2.5 },
{ x: 20, y: 190, r: 2 },
{ x: 340, y: 190, r: 3 },
];
const edges: [number, number][] = [
[0, 1],
[1, 2],
[2, 3],
[1, 4],
[4, 5],
[5, 6],
[2, 5],
[4, 7],
[6, 8],
[0, 4],
];
export default function NetworkGraphic({
accent = "blue",
className = "",
}: NetworkGraphicProps) {
const stroke = accent === "green" ? "#12B76A" : "#3B7BFF";
const dot = accent === "green" ? "#6EE7B7" : "#6FA8FF";
return (
<svg
viewBox="0 0 360 220"
className={className}
aria-hidden="true"
role="img"
>
<g opacity="0.55">
{edges.map(([a, b], i) => (
<line
key={i}
x1={nodes[a].x}
y1={nodes[a].y}
x2={nodes[b].x}
y2={nodes[b].y}
stroke={stroke}
strokeWidth="1"
/>
))}
</g>
{nodes.map((n, i) => (
<circle
key={i}
cx={n.x}
cy={n.y}
r={n.r}
fill={dot}
className="animate-pulse-slow"
style={{ animationDelay: `${(i % 5) * 0.6}s` }}
/>
))}
</svg>
);
}
+45
View File
@@ -0,0 +1,45 @@
import SectionEyebrow from "./SectionEyebrow";
import NetworkGraphic from "./NetworkGraphic";
interface PageHeroProps {
eyebrow: string;
title: string;
highlight?: string;
subtitle: string;
accent?: "blue" | "green";
children?: React.ReactNode;
}
export default function PageHero({
eyebrow,
title,
highlight,
subtitle,
accent = "blue",
children,
}: PageHeroProps) {
return (
<section className="relative overflow-hidden border-b border-white/[0.06] px-6 pb-16 pt-32 sm:pb-20 sm:pt-40">
<div className="pointer-events-none absolute right-0 top-0 w-[420px] max-w-[60%] opacity-70 sm:opacity-100">
<NetworkGraphic accent={accent} className="w-full" />
</div>
<div className="mx-auto max-w-3xl">
<SectionEyebrow accent={accent}>{eyebrow}</SectionEyebrow>
<h1 className="text-balance font-display text-4xl font-medium leading-[1.1] sm:text-5xl">
{title}{" "}
{highlight && (
<span
className={accent === "green" ? "text-gradient-green" : "text-gradient-blue"}
>
{highlight}
</span>
)}
</h1>
<p className="mt-5 max-w-xl text-lg leading-relaxed text-ink-500">
{subtitle}
</p>
{children && <div className="mt-8 flex flex-wrap gap-3">{children}</div>}
</div>
</section>
);
}
+17
View File
@@ -0,0 +1,17 @@
interface SectionEyebrowProps {
children: React.ReactNode;
accent?: "blue" | "green";
}
export default function SectionEyebrow({
children,
accent = "blue",
}: SectionEyebrowProps) {
const dot = accent === "green" ? "bg-green-400" : "bg-blue-400";
return (
<div className="mb-4 inline-flex items-center gap-2 font-mono text-xs uppercase tracking-[0.2em] text-ink-500">
<span className={`h-1.5 w-1.5 rounded-full ${dot}`} />
{children}
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import Link from "next/link";
import { Globe, Bot, Workflow, Server, LifeBuoy, ArrowUpRight } from "lucide-react";
import type { ServiceMeta } from "@/config/services";
const iconMap = {
Globe,
Bot,
Workflow,
Server,
LifeBuoy,
};
interface ServiceCardProps {
service: ServiceMeta;
}
export default function ServiceCard({ service }: ServiceCardProps) {
const Icon = iconMap[service.icon];
const isGreen = service.accent === "green";
return (
<Link
href={service.href}
className={`group relative flex flex-col justify-between rounded-2xl border border-white/[0.06] bg-base-900/60 p-6 transition-all hover:-translate-y-1 clip-corner ${
isGreen
? "hover:border-green-500/30 hover:shadow-glow-green"
: "hover:border-blue-500/30 hover:shadow-glow"
}`}
>
<div>
<div
className={`mb-5 flex h-11 w-11 items-center justify-center rounded-xl border ${
isGreen
? "border-green-500/25 bg-green-500/10 text-green-400"
: "border-blue-500/25 bg-blue-500/10 text-blue-400"
}`}
>
<Icon size={20} strokeWidth={1.75} />
</div>
<h3 className="mb-2 font-display text-lg font-medium text-ink-100">
{service.title}
</h3>
<p className="text-sm leading-relaxed text-ink-500">
{service.description}
</p>
</div>
<div className="mt-6 flex items-center gap-1.5 text-sm font-medium text-ink-300 group-hover:text-ink-100">
Подробнее
<ArrowUpRight
size={16}
className="transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5"
/>
</div>
</Link>
);
}
+12
View File
@@ -0,0 +1,12 @@
// Единое место для всех контактов и ссылок на соцсети.
// Замените placeholder-значения на реальные перед публикацией сайта.
export const contacts = {
telegramBot: "https://t.me/labnethub_bot",
telegramChannel: "https://t.me/labnethub",
vk: "https://vk.com/labnethub",
whatsapp: "https://wa.me/70000000000",
email: "info@labnethub.ru",
city: "Астрахань, Россия",
workingHours: "Отвечаем в течение рабочего дня",
};
+73
View File
@@ -0,0 +1,73 @@
export type ServiceKey =
| "websites"
| "bots"
| "crm"
| "hosting"
| "support";
export interface ServiceMeta {
key: ServiceKey;
href: string;
icon: "Globe" | "Bot" | "Workflow" | "Server" | "LifeBuoy";
title: string;
description: string;
accent: "blue" | "green";
}
export const services: ServiceMeta[] = [
{
key: "websites",
href: "/websites",
icon: "Globe",
title: "Сайты для бизнеса",
description:
"Сайты-визитки и полноценные корпоративные сайты — от идеи до запуска.",
accent: "blue",
},
{
key: "bots",
href: "/bots",
icon: "Bot",
title: "Боты для соцсетей",
description:
"Telegram- и VK-боты: автоматизация заявок, уведомлений и продаж.",
accent: "blue",
},
{
key: "crm",
href: "/crm",
icon: "Workflow",
title: "CRM-системы",
description:
"Установка, настройка и интеграция CRM с открытым исходным кодом.",
accent: "blue",
},
{
key: "hosting",
href: "/hosting",
icon: "Server",
title: "Хостинг и серверы",
description:
"Размещение сайтов и ботов на наших серверах или у партнёров по всему миру.",
accent: "green",
},
{
key: "support",
href: "/support",
icon: "LifeBuoy",
title: "IT-поддержка",
description:
"Обслуживание инфраструктуры, настройка оборудования, помощь 24/7.",
accent: "blue",
},
];
export const navLinks = [
{ href: "/", label: "Главная" },
{ href: "/websites", label: "Сайты" },
{ href: "/bots", label: "Боты" },
{ href: "/crm", label: "CRM" },
{ href: "/hosting", label: "Хостинг" },
{ href: "/support", label: "Поддержка" },
{ href: "/contacts", label: "Контакты" },
];
+30
View File
@@ -0,0 +1,30 @@
/** @type {import('next').NextConfig} */
const securityHeaders = [
{ key: "X-DNS-Prefetch-Control", value: "on" },
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(), interest-cohort=()",
},
];
const nextConfig = {
reactStrictMode: true,
poweredByHeader: false,
async headers() {
return [
{
source: "/:path*",
headers: securityHeaders,
},
];
},
};
export default nextConfig;
+28
View File
@@ -0,0 +1,28 @@
{
"name": "labnethub",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^14.2.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"lucide-react": "^0.427.0"
},
"devDependencies": {
"typescript": "^5.5.4",
"@types/node": "^20.14.15",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.40",
"tailwindcss": "^3.4.7",
"eslint": "^8.57.0",
"eslint-config-next": "^14.2.5"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+69
View File
@@ -0,0 +1,69 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./app/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
],
theme: {
extend: {
colors: {
base: {
950: "#050B18",
900: "#0A1428",
800: "#0F1B33",
700: "#152442",
600: "#1D2F52",
},
blue: {
400: "#6FA8FF",
500: "#3B7BFF",
600: "#2F63EB",
700: "#2450C4",
},
green: {
300: "#6EE7B7",
400: "#34D399",
500: "#12B76A",
600: "#0E9B5A",
},
ink: {
100: "#F5F7FB",
300: "#C4CDDD",
500: "#93A1BD",
700: "#5C6884",
},
},
fontFamily: {
display: ["var(--font-unbounded)", "sans-serif"],
body: ["var(--font-manrope)", "sans-serif"],
mono: ["var(--font-jetbrains)", "monospace"],
},
backgroundImage: {
"grid-fade":
"radial-gradient(circle at 20% 20%, rgba(59,123,255,0.14), transparent 40%), radial-gradient(circle at 80% 0%, rgba(18,183,106,0.10), transparent 35%)",
},
boxShadow: {
glow: "0 0 0 1px rgba(111,168,255,0.15), 0 20px 60px -20px rgba(47,99,235,0.35)",
"glow-green":
"0 0 0 1px rgba(110,231,183,0.15), 0 20px 60px -20px rgba(18,183,106,0.35)",
},
keyframes: {
"pulse-slow": {
"0%, 100%": { opacity: "0.35" },
"50%": { opacity: "0.8" },
},
float: {
"0%, 100%": { transform: "translateY(0px)" },
"50%": { transform: "translateY(-8px)" },
},
},
animation: {
"pulse-slow": "pulse-slow 4s ease-in-out infinite",
float: "float 6s ease-in-out infinite",
},
},
},
plugins: [],
};
export default config;
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}