Initial commit: site monorepo with API, web, and infra.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
влад
2026-07-16 10:11:54 +03:00
co-authored by Cursor
commit 016910ffb7
447 changed files with 73972 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
import { AdminPanel } from "@modules/admin/components/AdminPanel";
export default function AdminPage(): JSX.Element {
return <AdminPanel />;
}
+44
View File
@@ -0,0 +1,44 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ContentPage } from "./ContentPage";
import * as contentApi from "@modules/content/api/contentApi";
function renderPage(slug = "about") {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[`/pages/${slug}`]}>
<Routes>
<Route path="/pages/:slug" element={<ContentPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
);
}
describe("ContentPage", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("renders fetched page title and body", async () => {
vi.spyOn(contentApi, "getPageBySlug").mockResolvedValue({
id: "1",
slug: "about",
title: "About Us",
body: "<p>About content</p>",
status: "published"
});
renderPage("about");
await waitFor(() => {
expect(screen.getByRole("heading", { name: "About Us" })).toBeInTheDocument();
});
expect(screen.getByText("About content")).toBeInTheDocument();
});
});
+40
View File
@@ -0,0 +1,40 @@
import DOMPurify from "dompurify";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import { getPageBySlug } from "@modules/content/api/contentApi";
const ALLOWED_TAGS = ["p", "h1", "h2", "h3", "h4", "ul", "ol", "li", "a", "strong", "em", "br", "img"];
export function ContentPage(): JSX.Element {
const { slug } = useParams();
const { data, isLoading, isError } = useQuery({
queryKey: ["content-page", slug],
queryFn: () => getPageBySlug(slug ?? ""),
enabled: Boolean(slug)
});
if (isLoading) {
return (
<main>
<p>Loading...</p>
</main>
);
}
if (isError || !data) {
return (
<main>
<h1>Page not found</h1>
</main>
);
}
const safeHtml = DOMPurify.sanitize(data.body, { ALLOWED_TAGS });
return (
<main>
<h1>{data.title}</h1>
<article dangerouslySetInnerHTML={{ __html: safeHtml }} />
</main>
);
}
@@ -0,0 +1,163 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { apiClient } from "@shared/api/client";
type Farm = {
id: string;
name: string;
hub_site_id: string;
status: string;
last_seen: string | null;
};
type Member = {
user_id: string;
role: string;
email: string | null;
farm_ids: string[];
};
type SyncStatus = {
outbox_pending: number;
conflicts_pending: number;
hubs: Array<Farm & { sync_lag?: number }>;
};
export default function EnterpriseCabinetPage(): JSX.Element {
const { slug } = useParams();
const [farms, setFarms] = useState<Farm[]>([]);
const [members, setMembers] = useState<Member[]>([]);
const [enterpriseId, setEnterpriseId] = useState<string>("");
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
const [pairCode, setPairCode] = useState<string>("");
const [pairingBusy, setPairingBusy] = useState(false);
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState("viewer");
useEffect(() => {
void (async () => {
const ents = await apiClient.get<Array<{ id: string; slug: string }>>("/api/v1/enterprise/enterprises");
const ent = ents.find((e) => e.slug === slug);
if (!ent) return;
setEnterpriseId(ent.id);
const [list, status, mems] = await Promise.all([
apiClient.get<Farm[]>(`/api/v1/enterprise/enterprises/${ent.id}/farms`),
apiClient.get<SyncStatus>(`/api/v1/enterprise/enterprises/${ent.id}/sync-status`),
apiClient.get<Member[]>(`/api/v1/enterprise/enterprises/${ent.id}/members`),
]);
setFarms(list);
setSyncStatus(status);
setMembers(mems);
})();
}, [slug]);
async function startPairing(): Promise<void> {
if (!enterpriseId) return;
setPairingBusy(true);
try {
const resp = await apiClient.post<{ code: string }>(
`/api/v1/enterprise/pair/start`,
{ enterprise_id: enterpriseId, farm_name: "New hub" },
);
setPairCode(resp.code);
} finally {
setPairingBusy(false);
}
}
async function inviteMember(): Promise<void> {
if (!enterpriseId || !inviteEmail.trim()) return;
await apiClient.post(`/api/v1/enterprise/enterprises/${enterpriseId}/members`, {
email: inviteEmail.trim(),
role: inviteRole,
});
const mems = await apiClient.get<Member[]>(`/api/v1/enterprise/enterprises/${enterpriseId}/members`);
setMembers(mems);
setInviteEmail("");
}
return (
<div style={{ padding: 24, maxWidth: 960 }}>
<h1>Enterprise: {slug}</h1>
<p>Pairing, members, farms, sync status. Conflicts в K-hub «Мультисервер».</p>
<section style={{ marginTop: 24 }}>
<h2>Pair hub</h2>
<button type="button" onClick={() => void startPairing()} disabled={pairingBusy || !enterpriseId}>
{pairingBusy ? "Generating…" : "Generate pairing code"}
</button>
{pairCode ? (
<p>
Pairing code: <strong>{pairCode}</strong> (enter on hub admin within 15 min)
</p>
) : null}
</section>
<section style={{ marginTop: 24 }}>
<h2>Members</h2>
<ul>
{members.map((m) => (
<li key={m.user_id}>
{m.email ?? m.user_id} {m.role}
{m.farm_ids.length ? ` · farms: ${m.farm_ids.length}` : ""}
</li>
))}
</ul>
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
<input
type="email"
placeholder="user@example.com"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
/>
<select value={inviteRole} onChange={(e) => setInviteRole(e.target.value)}>
<option value="viewer">viewer</option>
<option value="zootech">zootech</option>
<option value="admin">admin</option>
</select>
<button type="button" onClick={() => void inviteMember()} disabled={!inviteEmail.trim()}>
Add member
</button>
</div>
</section>
<section style={{ marginTop: 24 }}>
<h2>Sync status</h2>
{syncStatus ? (
<ul>
<li>Outbox pending: {syncStatus.outbox_pending}</li>
<li>Conflicts pending: {syncStatus.conflicts_pending}</li>
{syncStatus.hubs.map((h) => (
<li key={h.id}>
{h.name}: lag {h.sync_lag ?? 0}
{h.last_seen ? ` · ${h.last_seen}` : " · offline"}
</li>
))}
</ul>
) : (
<p>Loading</p>
)}
</section>
<section style={{ marginTop: 24 }}>
<h2>Farms</h2>
<ul>
{farms.map((f) => (
<li key={f.id}>
{f.name} ({f.hub_site_id}) {f.status}
{f.last_seen ? ` · last seen ${f.last_seen}` : " · offline"}
</li>
))}
</ul>
</section>
{enterpriseId ? (
<p style={{ marginTop: 24 }}>
<a href={`/recipes?enterprise_id=${encodeURIComponent(enterpriseId)}`}>
Zootech UI (WESP copy)
</a>
</p>
) : null}
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { forgotPassword } from "@modules/auth/api/authApi";
import { formatAuthError } from "@modules/auth/utils/formatAuthError";
import { AuthLayout } from "@modules/auth/components/AuthLayout";
import { AuthField } from "@modules/auth/components/AuthField";
import { AuthMessage } from "@modules/auth/components/AuthMessage";
import { AuthSubmit } from "@modules/auth/components/AuthSubmit";
function UserIcon(): JSX.Element {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none">
<path
d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function ForgotPasswordPage(): JSX.Element {
const [email, setEmail] = useState("");
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
return (
<AuthLayout title="Восстановление пароля">
<form
className="z-login-form"
onSubmit={async (event) => {
event.preventDefault();
setMessage("");
setError("");
setIsSubmitting(true);
try {
const data = await forgotPassword(email);
setMessage(data.message ?? "If email is registered, reset instructions have been sent.");
} catch (forgotError) {
setError(formatAuthError(forgotError, "Request failed"));
} finally {
setIsSubmitting(false);
}
}}
>
<AuthField
icon={<UserIcon />}
placeholder="Email"
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
<AuthSubmit loading={isSubmitting} loadingText="Sending..." idleText="Send reset link" />
{message ? <AuthMessage type="success" text={message} /> : null}
{error ? <AuthMessage type="error" text={error} /> : null}
<p className="z-login-meta">
<Link className="z-login-link" to="/login">
Back to login
</Link>
</p>
</form>
</AuthLayout>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it } from "vitest";
import { HomePage } from "./HomePage";
describe("HomePage", () => {
it("renders landing sections", () => {
render(
<MemoryRouter>
<HomePage />
</MemoryRouter>
);
expect(screen.getByRole("heading", { name: "Organic Tech / Compton" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "About Compton" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Contacts" })).toBeInTheDocument();
});
});
+12
View File
@@ -0,0 +1,12 @@
import { HeroSection, MarqueeSection, BrandSection, ContactsSection } from "@modules/landing";
export function HomePage(): JSX.Element {
return (
<main>
<HeroSection />
<MarqueeSection />
<BrandSection />
<ContactsSection />
</main>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it } from "vitest";
import { LoginPage } from "./LoginPage";
describe("LoginPage", () => {
it("renders login heading", () => {
render(
<MemoryRouter>
<LoginPage />
</MemoryRouter>
);
expect(screen.getByRole("heading", { name: "Вход" })).toBeInTheDocument();
});
});
+10
View File
@@ -0,0 +1,10 @@
import { LoginForm } from "@modules/auth";
import { AuthLayout } from "@modules/auth/components/AuthLayout";
export function LoginPage(): JSX.Element {
return (
<AuthLayout title="Вход">
<LoginForm />
</AuthLayout>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { useQuery } from "@tanstack/react-query";
import { ProfileCard, ProfileEditor } from "@modules/profile";
import { getProfile } from "@modules/profile/api/profileApi";
export default function ProfilePage(): JSX.Element {
const { data, isLoading } = useQuery({
queryKey: ["profile-me"],
queryFn: getProfile
});
if (isLoading || !data) {
return (
<main>
<p>Loading profile...</p>
</main>
);
}
return (
<main style={{ display: "grid", gap: "2rem", padding: "1.5rem" }}>
<ProfileCard
email={data.user.email}
displayName={data.profile.display_name}
avatarUrl={data.profile.avatar_url}
/>
<ProfileEditor profile={data} />
</main>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it } from "vitest";
import { RegisterPage } from "./RegisterPage";
describe("RegisterPage", () => {
it("renders register heading", () => {
render(
<MemoryRouter>
<RegisterPage />
</MemoryRouter>
);
expect(screen.getByRole("heading", { name: "Регистрация" })).toBeInTheDocument();
});
});
+10
View File
@@ -0,0 +1,10 @@
import { RegisterForm } from "@modules/auth";
import { AuthLayout } from "@modules/auth/components/AuthLayout";
export function RegisterPage(): JSX.Element {
return (
<AuthLayout title="Регистрация">
<RegisterForm />
</AuthLayout>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { resetPassword } from "@modules/auth/api/authApi";
import { formatAuthError } from "@modules/auth/utils/formatAuthError";
import { AuthField } from "@modules/auth/components/AuthField";
import { AuthLayout } from "@modules/auth/components/AuthLayout";
import { AuthMessage } from "@modules/auth/components/AuthMessage";
import { AuthSubmit } from "@modules/auth/components/AuthSubmit";
function LockIcon(): JSX.Element {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none">
<path
d="M7 11V7a5 5 0 0 1 10 0v4M5 11h14a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function ResetPasswordPage(): JSX.Element {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [password, setPassword] = useState("");
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const token = searchParams.get("token") ?? "";
return (
<AuthLayout title="Сброс пароля">
<form
className="z-login-form"
onSubmit={async (event) => {
event.preventDefault();
setMessage("");
setError("");
setIsSubmitting(true);
try {
await resetPassword(token, password);
setMessage("Password updated. You can log in now.");
setTimeout(() => navigate("/login"), 1200);
} catch (resetError) {
setError(formatAuthError(resetError, "Password reset failed"));
} finally {
setIsSubmitting(false);
}
}}
>
{!token ? <AuthMessage type="error" text="Reset token is missing." /> : null}
<AuthField
icon={<LockIcon />}
placeholder="New password"
type="password"
autoComplete="new-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
minLength={8}
disabled={!token}
/>
<p className="z-login-meta">
Password: at least 8 characters with uppercase, lowercase, and a digit.
</p>
<AuthSubmit loading={isSubmitting} idleText="Reset password" loadingText="Saving..." disabled={!token} />
{message ? <AuthMessage type="success" text={message} /> : null}
{error ? <AuthMessage type="error" text={error} /> : null}
<p className="z-login-meta">
<Link className="z-login-link" to="/login">
Back to login
</Link>
</p>
</form>
</AuthLayout>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { useEffect, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { verifyEmail } from "@modules/auth/api/authApi";
import { formatAuthError } from "@modules/auth/utils/formatAuthError";
import { AuthLayout } from "@modules/auth/components/AuthLayout";
import { AuthMessage } from "@modules/auth/components/AuthMessage";
export function VerifyPage(): JSX.Element {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [message, setMessage] = useState("Verifying your email...");
const [error, setError] = useState("");
useEffect(() => {
const token = searchParams.get("token");
if (!token) {
setError("Verification token is missing.");
setMessage("");
return;
}
void verifyEmail(token)
.then(() => {
setMessage("Email verified. You can log in now.");
setError("");
})
.catch((verifyError) => {
setMessage("");
setError(formatAuthError(verifyError, "Verification failed"));
});
}, [searchParams]);
return (
<AuthLayout title="Подтверждение email">
<div className="z-login-form">
{message ? <AuthMessage type="success" text={message} /> : null}
{error ? <AuthMessage type="error" text={error} /> : null}
<button className="z-login-submit" type="button" onClick={() => navigate("/login")}>
Go to login
</button>
<p className="z-login-meta">
<Link className="z-login-link" to="/">
Back home
</Link>
</p>
</div>
</AuthLayout>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { useEffect } from "react";
/** Full-page redirect to WESP static login (orchestrator 1:1). */
export default function WespLoginRedirect(): null {
useEffect(() => {
window.location.replace("/login");
}, []);
return null;
}