feat: split big file and update agents.md

This commit is contained in:
2026-06-18 23:17:32 +03:00
parent b902cf0fa4
commit cf0a279ed3
21 changed files with 584 additions and 504 deletions

View File

@@ -5,8 +5,59 @@ Common Ground — editorial impact/NKO шаблон: сохраняй newsroom-
## Project Specifics
- Кампании, метрики, ledger, истории, смены, документы, партнеры и бюджет описаны в `src/entities/site-content.ts`.
- `src/app` — только route wrappers; композиция страниц находится в `src/widgets/template-ui.tsx`.
- `src/app` — только route wrappers; композиция каждой страницы живёт в отдельном widget (`src/widgets/<page>-page.tsx`). См. File Map.
- Mock-пожертвования держи в `src/features/*/ui`; не добавляй реальные платежи, CRM, donor accounts или API без запроса.
- Не делай generic charity landing: каждая страница должна усиливать доверие через цель, прогресс, документы, ответственного, место или конкретную смену.
- Избегай мягкой декоративной благотворительности; стиль должен ощущаться как редакция, полевой desk и публичная отчетность.
- Проверка после правок: `pnpm lint` и `pnpm build`.
## Design System
Источник токенов — `src/app/globals.css` (`@theme` + `:root`/`.dark`). Шрифт — Noto Serif (`--font-commonground`), один serif-гарнитур на sans и mono, с включёнными `ss01`/`cv01`/`tnum` (табличные цифры для ledger). Работай через семантические классы Tailwind (`bg-card`, `text-foreground`, `text-primary`, `border-foreground`), не хардкодь hex/oklch.
Личность: **newsroom / полевой выпуск** — тёплый газетный кремовый фон с лёгкой бумажной зернистостью, тёмно-коричневый «ink» как текст и границы, глубокий teal primary как «редакционный» сигнал, мягкий зелёный secondary (надежда/поле) и тёплый терракотовый accent. Это не charity-пастель: ощущение печатной редакции и публичной отчётности.
| Роль | Light | Характер |
|---|---|---|
| `background` | газетный кремовый (oklch 0.96 0.018 83) | бумажный фон с зерном-сеткой |
| `foreground` | тёмно-коричневый ink (oklch 0.17 0.025 65) | текст + **границы** (`border-2 border-foreground`) |
| `primary` | глубокий teal (oklch 0.43 0.095 218) | акценты, цифры ledger, кнопки, иконки |
| `secondary` | мягкий зелёный (oklch 0.83 0.09 145) | бейджи региона/роли, hero-glow |
| `muted` / `muted-foreground` | тёплый серо-бежевый | вторичный текст |
| `accent` | терракота (oklch 0.73 0.105 50) | редкий тёплый сигнал |
| `card` | почти белый кремовый | карточки кампаний, ledger, документы |
Узнаваемые приёмы (держи их, это и есть «лицо» проекта):
- **Серифная типографика:** Noto Serif + `font-black` — газетные заголовки, очень плотный `leading` (`leading-[0.88]`/`leading-[0.96]`), крупные размеры (до `text-8xl`). Тело текста тоже serif.
- **Толстые границы:** `border-2 border-foreground`, `divide-y-2 divide-foreground` — карточки, таблицы ledger и доски смен очерчены, а не растворены.
- **Hard offset shadow:** `shadow-[8px_8px_0_var(--foreground)]` на ключевых aside-блоках (hero-выпуск, партнёрская проверка).
- **Double-rule плашки:** утилита `.impact-rule` — двойная типографская линейка сверху/снизу для рубрик-капителей (`text-sm font-black uppercase`).
- **Газетная подложка:** `.newsprint` — тонкая сетка-растр для hero/title-секций; глобальный фон body тоже несёт зелёный radial-glow + вертикальный растр.
- **Скромный radius:** `--radius` = 0.18rem; элементы почти прямоугольные, но не абсолютно острые.
- **Ledger-first доверие:** табличные цифры (`tnum`), прогресс-бары целей и процентов бюджета, проверяемые блоки документов.
Do / Don't:
- **Do:** держи serif-заголовки, толстые границы, double-rule рубрики, публичный ledger и прогресс-бары; цифры — через `text-primary`.
- **Don't:** sans-заголовки, мягкие тени, скруглённые «pill»-карточки, пастельные градиенты, generic-charity hero — это ломает newsroom-личность.
## File Map
| Route | Widget |
|---|---|
| `/` | `src/widgets/home-page.tsx` (`HomePage`) |
| `/campaigns` | `src/widgets/campaigns-page.tsx` (`CampaignsPage`) |
| `/campaigns/clean-water` | `src/widgets/campaign-detail-page.tsx` (`CampaignDetailPage`) |
| `/impact` | `src/widgets/impact-page.tsx` (`ImpactPage`) |
| `/stories` | `src/widgets/stories-page.tsx` (`StoriesPage`) |
| `/volunteer` | `src/widgets/volunteer-page.tsx` (`VolunteerPage`) |
| `/transparency` | `src/widgets/transparency-page.tsx` (`TransparencyPage`) |
Переиспользуемые блоки:
- `src/widgets/site-shell.tsx``SiteShell` (header + nav + footer, обёртка всех страниц).
- `src/widgets/campaign-card.tsx``CampaignCard` (Home featured + Campaigns grid).
- `src/widgets/ledger-table.tsx``LedgerTable` (Home + Impact).
- `src/shared/ui/editorial-title.tsx``EditorialTitle` (заголовочная секция внутренних страниц: Campaigns, Impact, Stories, Volunteer, Transparency).
- `src/shared/lib/campaign.ts``formatRub`, `campaignProgress` (CampaignCard + Campaign Detail).
- `src/features/donation-panel/ui/donation-panel.tsx` — mock-панель пожертвования.
Одноразовые блоки колоцированы со своей страницей: `MetricCard` в `impact-page.tsx`, `StoryCard` в `stories-page.tsx`, `DocumentCard` в `transparency-page.tsx`.

View File

@@ -1,4 +1,4 @@
import { CampaignDetailPage } from "@/widgets/template-ui";
import { CampaignDetailPage } from "@/widgets/campaign-detail-page";
export default function Page() {
return <CampaignDetailPage />;

View File

@@ -1,4 +1,4 @@
import { CampaignsPage } from "@/widgets/template-ui";
import { CampaignsPage } from "@/widgets/campaigns-page";
export default function Page() {
return <CampaignsPage />;

View File

@@ -1,4 +1,4 @@
import { ImpactPage } from "@/widgets/template-ui";
import { ImpactPage } from "@/widgets/impact-page";
export default function Page() {
return <ImpactPage />;

View File

@@ -1,4 +1,4 @@
import { HomePage } from "@/widgets/template-ui";
import { HomePage } from "@/widgets/home-page";
export default function Page() {
return <HomePage />;

View File

@@ -1,4 +1,4 @@
import { StoriesPage } from "@/widgets/template-ui";
import { StoriesPage } from "@/widgets/stories-page";
export default function Page() {
return <StoriesPage />;

View File

@@ -1,4 +1,4 @@
import { TransparencyPage } from "@/widgets/template-ui";
import { TransparencyPage } from "@/widgets/transparency-page";
export default function Page() {
return <TransparencyPage />;

View File

@@ -1,4 +1,4 @@
import { VolunteerPage } from "@/widgets/template-ui";
import { VolunteerPage } from "@/widgets/volunteer-page";
export default function Page() {
return <VolunteerPage />;

View File

@@ -0,0 +1,11 @@
import { campaigns } from "@/entities/site-content";
type Campaign = (typeof campaigns)[number];
export function formatRub(value: number) {
return new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 }).format(value);
}
export function campaignProgress(campaign: Campaign) {
return Math.round((campaign.raised / campaign.goal) * 100);
}

View File

@@ -0,0 +1,11 @@
export function EditorialTitle({ label, title, text }: { label: string; title: string; text: string }) {
return (
<section className="newsprint border-b-2 border-foreground px-4 py-12 md:px-6 md:py-20">
<div className="mx-auto max-w-7xl">
<div className="impact-rule mb-6 py-2 text-sm font-black uppercase">{label}</div>
<h1 className="max-w-5xl text-5xl font-black leading-[0.95] md:text-8xl">{title}</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-muted-foreground md:text-xl">{text}</p>
</div>
</section>
);
}

View File

@@ -0,0 +1,50 @@
import Image from "next/image";
import Link from "next/link";
import { campaigns } from "@/entities/site-content";
import { campaignProgress, formatRub } from "@/shared/lib/campaign";
import { Badge } from "@/shared/ui/badge";
import { Progress } from "@/shared/ui/progress";
type Campaign = (typeof campaigns)[number];
export function CampaignCard({ campaign, featured = false }: { campaign: Campaign; featured?: boolean }) {
const href = campaign.slug === "clean-water" ? "/campaigns/clean-water" : "/campaigns";
return (
<article className={`overflow-hidden border-2 border-foreground bg-card ${featured ? "md:grid md:grid-cols-[1.05fr_0.95fr]" : ""}`}>
<Link href={href} className={featured ? "contents" : "block"}>
<div className={`relative border-b-2 border-foreground ${featured ? "min-h-[360px] md:border-b-0 md:border-r-2" : "h-72"}`}>
<Image
src={campaign.image}
alt={campaign.title}
fill
className="object-cover"
sizes={featured ? "(min-width: 768px) 50vw, 100vw" : "(min-width: 768px) 33vw, 100vw"}
/>
<div className="absolute left-4 top-4 bg-background px-3 py-2 text-xs font-black uppercase">
{campaign.deadline}
</div>
</div>
<div className="p-5 md:p-6">
<div className="mb-5 flex flex-wrap items-center gap-2">
<Badge variant="outline" className="border-foreground bg-secondary font-black">
{campaign.region}
</Badge>
<span className="text-sm font-bold text-muted-foreground">{campaignProgress(campaign)}% цели</span>
</div>
<h2 className={`${featured ? "text-4xl md:text-6xl" : "text-3xl"} font-black leading-[0.96]`}>{campaign.title}</h2>
<p className="mt-4 text-lg font-bold leading-7">{campaign.lead}</p>
<p className="mt-3 leading-7 text-muted-foreground">{campaign.text}</p>
<div className="mt-6">
<Progress value={campaignProgress(campaign)} />
</div>
<div className="mt-3 flex justify-between gap-4 text-sm font-black">
<span>{formatRub(campaign.raised)} собрано</span>
<span>{formatRub(campaign.goal)} цель</span>
</div>
</div>
</Link>
</article>
);
}

View File

@@ -0,0 +1,63 @@
import { DonationPanel } from "@/features/donation-panel/ui/donation-panel";
import { campaigns } from "@/entities/site-content";
import { campaignProgress, formatRub } from "@/shared/lib/campaign";
import { Badge } from "@/shared/ui/badge";
import { Progress } from "@/shared/ui/progress";
import { SiteShell } from "@/widgets/site-shell";
export function CampaignDetailPage() {
const campaign = campaigns[0];
return (
<SiteShell>
<section className="grid border-b-2 border-foreground lg:grid-cols-[1fr_430px]">
<div className="newsprint p-4 md:p-6">
<div className="mb-5 flex flex-wrap gap-2">
<Badge className="font-black">{campaign.region}</Badge>
<Badge variant="outline" className="border-foreground bg-background font-black">{campaign.deadline}</Badge>
</div>
<h1 className="max-w-4xl text-5xl font-black leading-[0.96] md:text-8xl">{campaign.title}</h1>
<p className="mt-6 max-w-2xl text-xl font-bold leading-8">{campaign.lead}</p>
<p className="mt-4 max-w-2xl leading-8 text-muted-foreground">{campaign.text}</p>
<div className="mt-8 max-w-3xl">
<Progress value={campaignProgress(campaign)} />
<div className="mt-3 flex justify-between gap-4 font-black">
<span>{formatRub(campaign.raised)} собрано</span>
<span>{formatRub(campaign.goal)} цель</span>
</div>
</div>
<div className="mt-10 grid max-w-4xl gap-3 md:grid-cols-4">
{campaign.checkpoints.map((point, index) => (
<div key={point} className="border-2 border-foreground bg-card p-4">
<div className="text-3xl font-black text-primary">0{index + 1}</div>
<div className="mt-6 font-black">{point}</div>
</div>
))}
</div>
</div>
<div className="border-t-2 border-foreground p-4 md:p-6 lg:border-l-2 lg:border-t-0">
<DonationPanel />
</div>
</section>
<section className="px-4 py-12 md:px-6">
<div className="mx-auto grid max-w-7xl gap-6 lg:grid-cols-[0.75fr_1.25fr]">
<div>
<div className="impact-rule py-2 text-sm font-black uppercase">бюджет кампании</div>
<h2 className="mt-5 text-4xl font-black leading-none">Куда уйдет каждый рубль</h2>
</div>
<div className="grid gap-5">
{campaign.allocation.map((item) => (
<div key={item.label}>
<div className="mb-2 flex justify-between font-black">
<span>{item.label}</span>
<span>{item.value}%</span>
</div>
<Progress value={item.value} />
</div>
))}
</div>
</div>
</section>
</SiteShell>
);
}

View File

@@ -0,0 +1,23 @@
import { campaigns } from "@/entities/site-content";
import { EditorialTitle } from "@/shared/ui/editorial-title";
import { CampaignCard } from "@/widgets/campaign-card";
import { SiteShell } from "@/widgets/site-shell";
export function CampaignsPage() {
return (
<SiteShell>
<EditorialTitle
label="Кампании"
title="Каждый сбор объясняет цель, регион, срок и бюджет"
text="Страница помогает быстро понять, где нужна помощь, сколько уже закрыто и какие шаги будут проверены после сбора."
/>
<section className="px-4 py-12 md:px-6">
<div className="mx-auto grid max-w-7xl gap-6 lg:grid-cols-3">
{campaigns.map((campaign) => (
<CampaignCard key={campaign.title} campaign={campaign} />
))}
</div>
</section>
</SiteShell>
);
}

90
src/widgets/home-page.tsx Normal file
View File

@@ -0,0 +1,90 @@
import Link from "next/link";
import { ArrowRightIcon, NewspaperIcon } from "lucide-react";
import { campaigns, impactMetrics, site } from "@/entities/site-content";
import { Button } from "@/shared/ui/button";
import { CampaignCard } from "@/widgets/campaign-card";
import { LedgerTable } from "@/widgets/ledger-table";
import { SiteShell } from "@/widgets/site-shell";
export function HomePage() {
const featured = campaigns[0];
return (
<SiteShell>
<section className="newsprint border-b-2 border-foreground px-4 py-10 md:px-6 md:py-14">
<div className="mx-auto grid max-w-7xl gap-6 lg:grid-cols-[1fr_420px]">
<div>
<div className="impact-rule mb-5 py-2 text-sm font-black uppercase">{site.issue} / {site.tagline}</div>
<h1 className="max-w-5xl text-5xl font-black leading-[0.88] md:text-8xl">
Помощь, которую видно по документам и людям
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-muted-foreground">
Common Ground - шаблон для НКО и impact-платформы, где каждая кампания показывает
цель, бюджет, полевой прогресс, истории и открытый реестр расходов.
</p>
<div className="mt-7 flex flex-wrap gap-3">
<Button asChild size="lg">
<Link href="/campaigns">Смотреть кампании <ArrowRightIcon className="size-4" /></Link>
</Button>
<Button asChild variant="outline" size="lg">
<Link href="/transparency">Открыть отчетность</Link>
</Button>
</div>
</div>
<aside className="border-2 border-foreground bg-card p-5 shadow-[8px_8px_0_var(--foreground)]">
<NewspaperIcon className="mb-10 size-8 text-primary" />
<div className="impact-rule py-2 text-sm font-black uppercase">полевой выпуск</div>
<p className="mt-5 text-xl font-bold leading-8">
НКО продает доверие. Поэтому первый экран сразу дает цель, факт, ответственного и путь к документам.
</p>
<div className="mt-6 grid gap-3 text-sm">
<div className="flex items-center justify-between border-b border-foreground/25 pb-2">
<span className="text-muted-foreground">открытых кампаний</span>
<span className="font-black">3</span>
</div>
<div className="flex items-center justify-between border-b border-foreground/25 pb-2">
<span className="text-muted-foreground">обновление реестра</span>
<span className="font-black">еженедельно</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">полевой desk</span>
<span className="font-black">{site.email}</span>
</div>
</div>
</aside>
</div>
</section>
<section className="px-4 py-12 md:px-6">
<div className="mx-auto max-w-7xl">
<CampaignCard campaign={featured} featured />
</div>
</section>
<section className="border-y-2 border-foreground bg-primary px-4 py-10 text-primary-foreground md:px-6">
<div className="mx-auto grid max-w-7xl gap-4 md:grid-cols-4">
{impactMetrics.map((item) => (
<div key={item.value}>
<div className="text-5xl font-black">{item.value}</div>
<p className="mt-2 text-sm font-bold opacity-85">{item.label}</p>
</div>
))}
</div>
</section>
<section className="px-4 py-12 md:px-6">
<div className="mx-auto grid max-w-7xl gap-6 lg:grid-cols-[0.85fr_1.15fr]">
<div>
<div className="impact-rule py-2 text-sm font-black uppercase">живой реестр</div>
<h2 className="mt-5 text-4xl font-black leading-none md:text-6xl">Последние движения средств</h2>
<p className="mt-4 leading-7 text-muted-foreground">
Не прячьте доверие в PDF. Покажите свежие поступления, закупки и основание расходов прямо на сайте.
</p>
</div>
<LedgerTable />
</div>
</section>
</SiteShell>
);
}

View File

@@ -0,0 +1,47 @@
import { ClipboardCheckIcon } from "lucide-react";
import { impactMetrics } from "@/entities/site-content";
import { EditorialTitle } from "@/shared/ui/editorial-title";
import { LedgerTable } from "@/widgets/ledger-table";
import { SiteShell } from "@/widgets/site-shell";
function MetricCard({ metric }: { metric: (typeof impactMetrics)[number] }) {
return (
<article className="border-2 border-foreground bg-card p-5">
<div className="text-5xl font-black md:text-6xl">{metric.value}</div>
<p className="mt-4 font-black leading-6">{metric.label}</p>
<p className="mt-2 text-sm leading-6 text-muted-foreground">{metric.detail}</p>
</article>
);
}
export function ImpactPage() {
return (
<SiteShell>
<EditorialTitle
label="Отчет о влиянии"
title="Impact измеряется людьми, сменами и подтвержденными расходами"
text="Страница заменяет красивую декларацию на отчетные блоки, которые можно развивать в полноценный годовой отчет."
/>
<section className="px-4 py-12 md:px-6">
<div className="mx-auto grid max-w-7xl gap-5 md:grid-cols-2 lg:grid-cols-4">
{impactMetrics.map((item) => (
<MetricCard key={item.value} metric={item} />
))}
</div>
</section>
<section className="px-4 pb-12 md:px-6">
<div className="mx-auto grid max-w-7xl gap-6 lg:grid-cols-[1fr_1fr]">
<div className="border-2 border-foreground bg-card p-6">
<ClipboardCheckIcon className="mb-10 size-8 text-primary" />
<h2 className="text-4xl font-black leading-none">Методика подтверждения</h2>
<p className="mt-5 leading-7 text-muted-foreground">
Каждая цифра связывается с документом: актом, фото, письмом партнера, платежом или сменой координатора.
</p>
</div>
<LedgerTable />
</div>
</section>
</SiteShell>
);
}

View File

@@ -0,0 +1,16 @@
import { ledger } from "@/entities/site-content";
export function LedgerTable() {
return (
<div className="divide-y-2 divide-foreground border-2 border-foreground bg-card">
{ledger.map((row) => (
<div key={`${row.date}-${row.source}`} className="grid gap-2 p-4 text-sm md:grid-cols-[110px_1fr_130px_1.1fr] md:items-center">
<div className="font-black">{row.date}</div>
<div>{row.source}</div>
<div className="font-black text-primary">{row.amount}</div>
<div className="text-muted-foreground">{row.note}</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,46 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { NewspaperIcon } from "lucide-react";
import { navItems, site } from "@/entities/site-content";
import { Button } from "@/shared/ui/button";
export function SiteShell({ children }: { children: ReactNode }) {
return (
<main className="min-h-screen bg-background text-foreground">
<header className="sticky top-0 z-30 border-b-2 border-foreground bg-background/95 backdrop-blur">
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-4 md:px-6">
<Link href="/" className="flex items-center gap-3 text-xl font-black uppercase">
<span className="grid size-10 place-items-center bg-foreground text-background">
<NewspaperIcon className="size-5" />
</span>
{site.name}
</Link>
<nav className="hidden items-center gap-5 text-sm font-black md:flex">
{navItems.map((item) => (
<Link key={item.href} href={item.href} className="hover:underline">
{item.label}
</Link>
))}
</nav>
<Button asChild size="sm">
<Link href="/campaigns/clean-water">Помочь</Link>
</Button>
</div>
</header>
{children}
<footer className="border-t-2 border-foreground px-4 py-8 md:px-6">
<div className="mx-auto grid max-w-7xl gap-4 text-sm md:grid-cols-[1fr_auto] md:items-center">
<div>
<div className="font-black uppercase">{site.name}</div>
<div className="mt-1 text-muted-foreground">{site.tagline}</div>
</div>
<div className="flex flex-wrap gap-4 font-bold">
<span>{site.email}</span>
<span>{site.phone}</span>
</div>
</div>
</footer>
</main>
);
}

View File

@@ -0,0 +1,44 @@
import Image from "next/image";
import { MapPinIcon } from "lucide-react";
import { stories } from "@/entities/site-content";
import { EditorialTitle } from "@/shared/ui/editorial-title";
import { SiteShell } from "@/widgets/site-shell";
function StoryCard({ story }: { story: (typeof stories)[number] }) {
return (
<article className="overflow-hidden border-2 border-foreground bg-card">
<div className="relative h-64 border-b-2 border-foreground">
<Image src={story.image} alt={story.title} fill className="object-cover" sizes="(min-width: 768px) 33vw, 100vw" />
</div>
<div className="p-5">
<div className="mb-4 flex items-center gap-2 text-sm font-black uppercase text-primary">
<MapPinIcon className="size-4" />
{story.place}
</div>
<h2 className="text-3xl font-black leading-tight">{story.title}</h2>
<p className="mt-4 leading-7 text-muted-foreground">{story.text}</p>
<div className="mt-5 border-t-2 border-foreground pt-4 text-lg font-black">{story.result}</div>
</div>
</article>
);
}
export function StoriesPage() {
return (
<SiteShell>
<EditorialTitle
label="Истории"
title="Истории показывают, что изменилось после кампании"
text="Вместо generic отзывов здесь короткие заметки с местом, ситуацией, фотографией и проверяемым результатом."
/>
<section className="px-4 py-12 md:px-6">
<div className="mx-auto grid max-w-7xl gap-6 md:grid-cols-3">
{stories.map((story) => (
<StoryCard key={story.title} story={story} />
))}
</div>
</section>
</SiteShell>
);
}

View File

@@ -1,496 +0,0 @@
import type { ReactNode } from "react";
import Image from "next/image";
import Link from "next/link";
import {
ArrowRightIcon,
ClipboardCheckIcon,
FileTextIcon,
LandmarkIcon,
MapPinIcon,
NewspaperIcon,
ReceiptTextIcon,
ShieldCheckIcon,
UsersIcon,
} from "lucide-react";
import { DonationPanel } from "@/features/donation-panel/ui/donation-panel";
import {
campaigns,
documents,
impactMetrics,
ledger,
navItems,
partners,
site,
stories,
transparency,
volunteerShifts,
} from "@/entities/site-content";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Progress } from "@/shared/ui/progress";
type Campaign = (typeof campaigns)[number];
function formatRub(value: number) {
return new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 }).format(value);
}
function campaignProgress(campaign: Campaign) {
return Math.round((campaign.raised / campaign.goal) * 100);
}
function Shell({ children }: { children: ReactNode }) {
return (
<main className="min-h-screen bg-background text-foreground">
<header className="sticky top-0 z-30 border-b-2 border-foreground bg-background/95 backdrop-blur">
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-4 md:px-6">
<Link href="/" className="flex items-center gap-3 text-xl font-black uppercase">
<span className="grid size-10 place-items-center bg-foreground text-background">
<NewspaperIcon className="size-5" />
</span>
{site.name}
</Link>
<nav className="hidden items-center gap-5 text-sm font-black md:flex">
{navItems.map((item) => (
<Link key={item.href} href={item.href} className="hover:underline">
{item.label}
</Link>
))}
</nav>
<Button asChild size="sm">
<Link href="/campaigns/clean-water">Помочь</Link>
</Button>
</div>
</header>
{children}
<footer className="border-t-2 border-foreground px-4 py-8 md:px-6">
<div className="mx-auto grid max-w-7xl gap-4 text-sm md:grid-cols-[1fr_auto] md:items-center">
<div>
<div className="font-black uppercase">{site.name}</div>
<div className="mt-1 text-muted-foreground">{site.tagline}</div>
</div>
<div className="flex flex-wrap gap-4 font-bold">
<span>{site.email}</span>
<span>{site.phone}</span>
</div>
</div>
</footer>
</main>
);
}
function EditorialTitle({ label, title, text }: { label: string; title: string; text: string }) {
return (
<section className="newsprint border-b-2 border-foreground px-4 py-12 md:px-6 md:py-20">
<div className="mx-auto max-w-7xl">
<div className="impact-rule mb-6 py-2 text-sm font-black uppercase">{label}</div>
<h1 className="max-w-5xl text-5xl font-black leading-[0.95] md:text-8xl">{title}</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-muted-foreground md:text-xl">{text}</p>
</div>
</section>
);
}
function CampaignCard({ campaign, featured = false }: { campaign: Campaign; featured?: boolean }) {
const href = campaign.slug === "clean-water" ? "/campaigns/clean-water" : "/campaigns";
return (
<article className={`overflow-hidden border-2 border-foreground bg-card ${featured ? "md:grid md:grid-cols-[1.05fr_0.95fr]" : ""}`}>
<Link href={href} className={featured ? "contents" : "block"}>
<div className={`relative border-b-2 border-foreground ${featured ? "min-h-[360px] md:border-b-0 md:border-r-2" : "h-72"}`}>
<Image
src={campaign.image}
alt={campaign.title}
fill
className="object-cover"
sizes={featured ? "(min-width: 768px) 50vw, 100vw" : "(min-width: 768px) 33vw, 100vw"}
/>
<div className="absolute left-4 top-4 bg-background px-3 py-2 text-xs font-black uppercase">
{campaign.deadline}
</div>
</div>
<div className="p-5 md:p-6">
<div className="mb-5 flex flex-wrap items-center gap-2">
<Badge variant="outline" className="border-foreground bg-secondary font-black">
{campaign.region}
</Badge>
<span className="text-sm font-bold text-muted-foreground">{campaignProgress(campaign)}% цели</span>
</div>
<h2 className={`${featured ? "text-4xl md:text-6xl" : "text-3xl"} font-black leading-[0.96]`}>{campaign.title}</h2>
<p className="mt-4 text-lg font-bold leading-7">{campaign.lead}</p>
<p className="mt-3 leading-7 text-muted-foreground">{campaign.text}</p>
<div className="mt-6">
<Progress value={campaignProgress(campaign)} />
</div>
<div className="mt-3 flex justify-between gap-4 text-sm font-black">
<span>{formatRub(campaign.raised)} собрано</span>
<span>{formatRub(campaign.goal)} цель</span>
</div>
</div>
</Link>
</article>
);
}
function MetricCard({ metric }: { metric: (typeof impactMetrics)[number] }) {
return (
<article className="border-2 border-foreground bg-card p-5">
<div className="text-5xl font-black md:text-6xl">{metric.value}</div>
<p className="mt-4 font-black leading-6">{metric.label}</p>
<p className="mt-2 text-sm leading-6 text-muted-foreground">{metric.detail}</p>
</article>
);
}
function LedgerTable() {
return (
<div className="divide-y-2 divide-foreground border-2 border-foreground bg-card">
{ledger.map((row) => (
<div key={`${row.date}-${row.source}`} className="grid gap-2 p-4 text-sm md:grid-cols-[110px_1fr_130px_1.1fr] md:items-center">
<div className="font-black">{row.date}</div>
<div>{row.source}</div>
<div className="font-black text-primary">{row.amount}</div>
<div className="text-muted-foreground">{row.note}</div>
</div>
))}
</div>
);
}
function StoryCard({ story }: { story: (typeof stories)[number] }) {
return (
<article className="overflow-hidden border-2 border-foreground bg-card">
<div className="relative h-64 border-b-2 border-foreground">
<Image src={story.image} alt={story.title} fill className="object-cover" sizes="(min-width: 768px) 33vw, 100vw" />
</div>
<div className="p-5">
<div className="mb-4 flex items-center gap-2 text-sm font-black uppercase text-primary">
<MapPinIcon className="size-4" />
{story.place}
</div>
<h2 className="text-3xl font-black leading-tight">{story.title}</h2>
<p className="mt-4 leading-7 text-muted-foreground">{story.text}</p>
<div className="mt-5 border-t-2 border-foreground pt-4 text-lg font-black">{story.result}</div>
</div>
</article>
);
}
function DocumentCard({ item }: { item: (typeof documents)[number] }) {
return (
<article className="border-2 border-foreground bg-card p-4">
<ReceiptTextIcon className="mb-8 size-7 text-primary" />
<h3 className="text-2xl font-black leading-tight">{item.title}</h3>
<p className="mt-3 font-bold">{item.status}</p>
<p className="mt-2 text-sm text-muted-foreground">{item.owner}</p>
</article>
);
}
export function HomePage() {
const featured = campaigns[0];
return (
<Shell>
<section className="newsprint border-b-2 border-foreground px-4 py-10 md:px-6 md:py-14">
<div className="mx-auto grid max-w-7xl gap-6 lg:grid-cols-[1fr_420px]">
<div>
<div className="impact-rule mb-5 py-2 text-sm font-black uppercase">{site.issue} / {site.tagline}</div>
<h1 className="max-w-5xl text-5xl font-black leading-[0.88] md:text-8xl">
Помощь, которую видно по документам и людям
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-muted-foreground">
Common Ground - шаблон для НКО и impact-платформы, где каждая кампания показывает
цель, бюджет, полевой прогресс, истории и открытый реестр расходов.
</p>
<div className="mt-7 flex flex-wrap gap-3">
<Button asChild size="lg">
<Link href="/campaigns">Смотреть кампании <ArrowRightIcon className="size-4" /></Link>
</Button>
<Button asChild variant="outline" size="lg">
<Link href="/transparency">Открыть отчетность</Link>
</Button>
</div>
</div>
<aside className="border-2 border-foreground bg-card p-5 shadow-[8px_8px_0_var(--foreground)]">
<NewspaperIcon className="mb-10 size-8 text-primary" />
<div className="impact-rule py-2 text-sm font-black uppercase">полевой выпуск</div>
<p className="mt-5 text-xl font-bold leading-8">
НКО продает доверие. Поэтому первый экран сразу дает цель, факт, ответственного и путь к документам.
</p>
<div className="mt-6 grid gap-3 text-sm">
<div className="flex items-center justify-between border-b border-foreground/25 pb-2">
<span className="text-muted-foreground">открытых кампаний</span>
<span className="font-black">3</span>
</div>
<div className="flex items-center justify-between border-b border-foreground/25 pb-2">
<span className="text-muted-foreground">обновление реестра</span>
<span className="font-black">еженедельно</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">полевой desk</span>
<span className="font-black">{site.email}</span>
</div>
</div>
</aside>
</div>
</section>
<section className="px-4 py-12 md:px-6">
<div className="mx-auto max-w-7xl">
<CampaignCard campaign={featured} featured />
</div>
</section>
<section className="border-y-2 border-foreground bg-primary px-4 py-10 text-primary-foreground md:px-6">
<div className="mx-auto grid max-w-7xl gap-4 md:grid-cols-4">
{impactMetrics.map((item) => (
<div key={item.value}>
<div className="text-5xl font-black">{item.value}</div>
<p className="mt-2 text-sm font-bold opacity-85">{item.label}</p>
</div>
))}
</div>
</section>
<section className="px-4 py-12 md:px-6">
<div className="mx-auto grid max-w-7xl gap-6 lg:grid-cols-[0.85fr_1.15fr]">
<div>
<div className="impact-rule py-2 text-sm font-black uppercase">живой реестр</div>
<h2 className="mt-5 text-4xl font-black leading-none md:text-6xl">Последние движения средств</h2>
<p className="mt-4 leading-7 text-muted-foreground">
Не прячьте доверие в PDF. Покажите свежие поступления, закупки и основание расходов прямо на сайте.
</p>
</div>
<LedgerTable />
</div>
</section>
</Shell>
);
}
export function CampaignsPage() {
return (
<Shell>
<EditorialTitle
label="Кампании"
title="Каждый сбор объясняет цель, регион, срок и бюджет"
text="Страница помогает быстро понять, где нужна помощь, сколько уже закрыто и какие шаги будут проверены после сбора."
/>
<section className="px-4 py-12 md:px-6">
<div className="mx-auto grid max-w-7xl gap-6 lg:grid-cols-3">
{campaigns.map((campaign) => (
<CampaignCard key={campaign.title} campaign={campaign} />
))}
</div>
</section>
</Shell>
);
}
export function CampaignDetailPage() {
const campaign = campaigns[0];
return (
<Shell>
<section className="grid border-b-2 border-foreground lg:grid-cols-[1fr_430px]">
<div className="newsprint p-4 md:p-6">
<div className="mb-5 flex flex-wrap gap-2">
<Badge className="font-black">{campaign.region}</Badge>
<Badge variant="outline" className="border-foreground bg-background font-black">{campaign.deadline}</Badge>
</div>
<h1 className="max-w-4xl text-5xl font-black leading-[0.96] md:text-8xl">{campaign.title}</h1>
<p className="mt-6 max-w-2xl text-xl font-bold leading-8">{campaign.lead}</p>
<p className="mt-4 max-w-2xl leading-8 text-muted-foreground">{campaign.text}</p>
<div className="mt-8 max-w-3xl">
<Progress value={campaignProgress(campaign)} />
<div className="mt-3 flex justify-between gap-4 font-black">
<span>{formatRub(campaign.raised)} собрано</span>
<span>{formatRub(campaign.goal)} цель</span>
</div>
</div>
<div className="mt-10 grid max-w-4xl gap-3 md:grid-cols-4">
{campaign.checkpoints.map((point, index) => (
<div key={point} className="border-2 border-foreground bg-card p-4">
<div className="text-3xl font-black text-primary">0{index + 1}</div>
<div className="mt-6 font-black">{point}</div>
</div>
))}
</div>
</div>
<div className="border-t-2 border-foreground p-4 md:p-6 lg:border-l-2 lg:border-t-0">
<DonationPanel />
</div>
</section>
<section className="px-4 py-12 md:px-6">
<div className="mx-auto grid max-w-7xl gap-6 lg:grid-cols-[0.75fr_1.25fr]">
<div>
<div className="impact-rule py-2 text-sm font-black uppercase">бюджет кампании</div>
<h2 className="mt-5 text-4xl font-black leading-none">Куда уйдет каждый рубль</h2>
</div>
<div className="grid gap-5">
{campaign.allocation.map((item) => (
<div key={item.label}>
<div className="mb-2 flex justify-between font-black">
<span>{item.label}</span>
<span>{item.value}%</span>
</div>
<Progress value={item.value} />
</div>
))}
</div>
</div>
</section>
</Shell>
);
}
export function ImpactPage() {
return (
<Shell>
<EditorialTitle
label="Отчет о влиянии"
title="Impact измеряется людьми, сменами и подтвержденными расходами"
text="Страница заменяет красивую декларацию на отчетные блоки, которые можно развивать в полноценный годовой отчет."
/>
<section className="px-4 py-12 md:px-6">
<div className="mx-auto grid max-w-7xl gap-5 md:grid-cols-2 lg:grid-cols-4">
{impactMetrics.map((item) => (
<MetricCard key={item.value} metric={item} />
))}
</div>
</section>
<section className="px-4 pb-12 md:px-6">
<div className="mx-auto grid max-w-7xl gap-6 lg:grid-cols-[1fr_1fr]">
<div className="border-2 border-foreground bg-card p-6">
<ClipboardCheckIcon className="mb-10 size-8 text-primary" />
<h2 className="text-4xl font-black leading-none">Методика подтверждения</h2>
<p className="mt-5 leading-7 text-muted-foreground">
Каждая цифра связывается с документом: актом, фото, письмом партнера, платежом или сменой координатора.
</p>
</div>
<LedgerTable />
</div>
</section>
</Shell>
);
}
export function StoriesPage() {
return (
<Shell>
<EditorialTitle
label="Истории"
title="Истории показывают, что изменилось после кампании"
text="Вместо generic отзывов здесь короткие заметки с местом, ситуацией, фотографией и проверяемым результатом."
/>
<section className="px-4 py-12 md:px-6">
<div className="mx-auto grid max-w-7xl gap-6 md:grid-cols-3">
{stories.map((story) => (
<StoryCard key={story.title} story={story} />
))}
</div>
</section>
</Shell>
);
}
export function VolunteerPage() {
return (
<Shell>
<EditorialTitle
label="Волонтерские смены"
title="Помощь начинается с конкретного расписания"
text="Пользователь видит дату, роль, место, время и количество мест. Это операционная доска, а не расплывчатый призыв помочь."
/>
<section className="px-4 py-12 md:px-6">
<div className="mx-auto max-w-7xl divide-y-2 divide-foreground border-2 border-foreground bg-card">
{volunteerShifts.map((shift) => (
<article key={`${shift.date}-${shift.title}`} className="grid gap-3 p-5 md:grid-cols-[130px_1fr_150px_150px_140px] md:items-center">
<div>
<div className="font-black">{shift.date}</div>
<div className="text-sm text-muted-foreground">{shift.time}</div>
</div>
<h2 className="text-2xl font-black leading-tight">{shift.title}</h2>
<div className="flex items-center gap-2 text-sm font-bold">
<UsersIcon className="size-4 text-primary" />
{shift.spots}
</div>
<div className="flex items-center gap-2 text-sm font-bold">
<MapPinIcon className="size-4 text-primary" />
{shift.location}
</div>
<Badge variant="outline" className="w-fit border-foreground bg-secondary font-black">
{shift.role}
</Badge>
</article>
))}
</div>
</section>
</Shell>
);
}
export function TransparencyPage() {
return (
<Shell>
<EditorialTitle
label="Прозрачность"
title="Открытая структура расходов до доверия, а не после"
text="Шаблон дает НКО готовую страницу для бюджета, документов, партнеров, подтверждений и годового отчета."
/>
<section className="px-4 py-12 md:px-6">
<div className="mx-auto grid max-w-7xl gap-6 lg:grid-cols-[1fr_420px]">
<div className="border-2 border-foreground bg-card p-6">
<LandmarkIcon className="mb-10 size-8 text-primary" />
<h2 className="mb-6 text-4xl font-black leading-none">Структура расходов</h2>
<div className="grid gap-5">
{transparency.map((item) => (
<div key={item.label}>
<div className="mb-2 flex justify-between gap-4 font-black">
<span>{item.label}</span>
<span>{item.value}%</span>
</div>
<Progress value={item.value} />
<p className="mt-2 text-sm text-muted-foreground">{item.detail}</p>
</div>
))}
</div>
</div>
<aside className="border-2 border-foreground bg-card p-6 shadow-[8px_8px_0_var(--foreground)]">
<ShieldCheckIcon className="mb-10 size-8 text-primary" />
<h2 className="text-4xl font-black leading-none">Партнерская проверка</h2>
<p className="mt-5 leading-7 text-muted-foreground">
Отчеты, акты, фотофиксация и письма партнеров должны иметь отдельные блоки, а не прятаться в футере.
</p>
<Button className="mt-7 w-full" size="lg">
<FileTextIcon className="size-4" />
Скачать mock-отчет
</Button>
</aside>
</div>
</section>
<section className="px-4 pb-12 md:px-6">
<div className="mx-auto grid max-w-7xl gap-6 lg:grid-cols-[0.72fr_1.28fr]">
<div>
<div className="impact-rule py-2 text-sm font-black uppercase">документы</div>
<h2 className="mt-5 text-4xl font-black leading-none">Что можно проверить</h2>
<div className="mt-8 flex flex-wrap gap-2">
{partners.map((partner) => (
<Badge key={partner} variant="outline" className="border-foreground bg-secondary px-3 py-1 font-black">
{partner}
</Badge>
))}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
{documents.map((item) => (
<DocumentCard key={item.title} item={item} />
))}
</div>
</div>
</section>
</Shell>
);
}

View File

@@ -0,0 +1,82 @@
import { FileTextIcon, LandmarkIcon, ReceiptTextIcon, ShieldCheckIcon } from "lucide-react";
import { documents, partners, transparency } from "@/entities/site-content";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Progress } from "@/shared/ui/progress";
import { EditorialTitle } from "@/shared/ui/editorial-title";
import { SiteShell } from "@/widgets/site-shell";
function DocumentCard({ item }: { item: (typeof documents)[number] }) {
return (
<article className="border-2 border-foreground bg-card p-4">
<ReceiptTextIcon className="mb-8 size-7 text-primary" />
<h3 className="text-2xl font-black leading-tight">{item.title}</h3>
<p className="mt-3 font-bold">{item.status}</p>
<p className="mt-2 text-sm text-muted-foreground">{item.owner}</p>
</article>
);
}
export function TransparencyPage() {
return (
<SiteShell>
<EditorialTitle
label="Прозрачность"
title="Открытая структура расходов до доверия, а не после"
text="Шаблон дает НКО готовую страницу для бюджета, документов, партнеров, подтверждений и годового отчета."
/>
<section className="px-4 py-12 md:px-6">
<div className="mx-auto grid max-w-7xl gap-6 lg:grid-cols-[1fr_420px]">
<div className="border-2 border-foreground bg-card p-6">
<LandmarkIcon className="mb-10 size-8 text-primary" />
<h2 className="mb-6 text-4xl font-black leading-none">Структура расходов</h2>
<div className="grid gap-5">
{transparency.map((item) => (
<div key={item.label}>
<div className="mb-2 flex justify-between gap-4 font-black">
<span>{item.label}</span>
<span>{item.value}%</span>
</div>
<Progress value={item.value} />
<p className="mt-2 text-sm text-muted-foreground">{item.detail}</p>
</div>
))}
</div>
</div>
<aside className="border-2 border-foreground bg-card p-6 shadow-[8px_8px_0_var(--foreground)]">
<ShieldCheckIcon className="mb-10 size-8 text-primary" />
<h2 className="text-4xl font-black leading-none">Партнерская проверка</h2>
<p className="mt-5 leading-7 text-muted-foreground">
Отчеты, акты, фотофиксация и письма партнеров должны иметь отдельные блоки, а не прятаться в футере.
</p>
<Button className="mt-7 w-full" size="lg">
<FileTextIcon className="size-4" />
Скачать mock-отчет
</Button>
</aside>
</div>
</section>
<section className="px-4 pb-12 md:px-6">
<div className="mx-auto grid max-w-7xl gap-6 lg:grid-cols-[0.72fr_1.28fr]">
<div>
<div className="impact-rule py-2 text-sm font-black uppercase">документы</div>
<h2 className="mt-5 text-4xl font-black leading-none">Что можно проверить</h2>
<div className="mt-8 flex flex-wrap gap-2">
{partners.map((partner) => (
<Badge key={partner} variant="outline" className="border-foreground bg-secondary px-3 py-1 font-black">
{partner}
</Badge>
))}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
{documents.map((item) => (
<DocumentCard key={item.title} item={item} />
))}
</div>
</div>
</section>
</SiteShell>
);
}

View File

@@ -0,0 +1,42 @@
import { MapPinIcon, UsersIcon } from "lucide-react";
import { volunteerShifts } from "@/entities/site-content";
import { Badge } from "@/shared/ui/badge";
import { EditorialTitle } from "@/shared/ui/editorial-title";
import { SiteShell } from "@/widgets/site-shell";
export function VolunteerPage() {
return (
<SiteShell>
<EditorialTitle
label="Волонтерские смены"
title="Помощь начинается с конкретного расписания"
text="Пользователь видит дату, роль, место, время и количество мест. Это операционная доска, а не расплывчатый призыв помочь."
/>
<section className="px-4 py-12 md:px-6">
<div className="mx-auto max-w-7xl divide-y-2 divide-foreground border-2 border-foreground bg-card">
{volunteerShifts.map((shift) => (
<article key={`${shift.date}-${shift.title}`} className="grid gap-3 p-5 md:grid-cols-[130px_1fr_150px_150px_140px] md:items-center">
<div>
<div className="font-black">{shift.date}</div>
<div className="text-sm text-muted-foreground">{shift.time}</div>
</div>
<h2 className="text-2xl font-black leading-tight">{shift.title}</h2>
<div className="flex items-center gap-2 text-sm font-bold">
<UsersIcon className="size-4 text-primary" />
{shift.spots}
</div>
<div className="flex items-center gap-2 text-sm font-bold">
<MapPinIcon className="size-4 text-primary" />
{shift.location}
</div>
<Badge variant="outline" className="w-fit border-foreground bg-secondary font-black">
{shift.role}
</Badge>
</article>
))}
</div>
</section>
</SiteShell>
);
}