first commit

This commit is contained in:
grigorij
2026-08-03 21:02:34 +04:00
commit b0bafe740f
39 changed files with 2326 additions and 0 deletions
+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>
);
}