diff --git a/AGENTS.md b/AGENTS.md index e587469..a9b6c4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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.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`. diff --git a/src/app/campaigns/clean-water/page.tsx b/src/app/campaigns/clean-water/page.tsx index fc93de0..20b4d13 100644 --- a/src/app/campaigns/clean-water/page.tsx +++ b/src/app/campaigns/clean-water/page.tsx @@ -1,4 +1,4 @@ -import { CampaignDetailPage } from "@/widgets/template-ui"; +import { CampaignDetailPage } from "@/widgets/campaign-detail-page"; export default function Page() { return ; diff --git a/src/app/campaigns/page.tsx b/src/app/campaigns/page.tsx index fffb1b3..1f864c8 100644 --- a/src/app/campaigns/page.tsx +++ b/src/app/campaigns/page.tsx @@ -1,4 +1,4 @@ -import { CampaignsPage } from "@/widgets/template-ui"; +import { CampaignsPage } from "@/widgets/campaigns-page"; export default function Page() { return ; diff --git a/src/app/impact/page.tsx b/src/app/impact/page.tsx index ff2ca98..80bae7c 100644 --- a/src/app/impact/page.tsx +++ b/src/app/impact/page.tsx @@ -1,4 +1,4 @@ -import { ImpactPage } from "@/widgets/template-ui"; +import { ImpactPage } from "@/widgets/impact-page"; export default function Page() { return ; diff --git a/src/app/page.tsx b/src/app/page.tsx index 2794f3c..5b1660a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,4 +1,4 @@ -import { HomePage } from "@/widgets/template-ui"; +import { HomePage } from "@/widgets/home-page"; export default function Page() { return ; diff --git a/src/app/stories/page.tsx b/src/app/stories/page.tsx index 3a8e767..0bdc951 100644 --- a/src/app/stories/page.tsx +++ b/src/app/stories/page.tsx @@ -1,4 +1,4 @@ -import { StoriesPage } from "@/widgets/template-ui"; +import { StoriesPage } from "@/widgets/stories-page"; export default function Page() { return ; diff --git a/src/app/transparency/page.tsx b/src/app/transparency/page.tsx index e61c20b..bc92ba9 100644 --- a/src/app/transparency/page.tsx +++ b/src/app/transparency/page.tsx @@ -1,4 +1,4 @@ -import { TransparencyPage } from "@/widgets/template-ui"; +import { TransparencyPage } from "@/widgets/transparency-page"; export default function Page() { return ; diff --git a/src/app/volunteer/page.tsx b/src/app/volunteer/page.tsx index 6716f5d..aec42d7 100644 --- a/src/app/volunteer/page.tsx +++ b/src/app/volunteer/page.tsx @@ -1,4 +1,4 @@ -import { VolunteerPage } from "@/widgets/template-ui"; +import { VolunteerPage } from "@/widgets/volunteer-page"; export default function Page() { return ; diff --git a/src/shared/lib/campaign.ts b/src/shared/lib/campaign.ts new file mode 100644 index 0000000..1846b47 --- /dev/null +++ b/src/shared/lib/campaign.ts @@ -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); +} diff --git a/src/shared/ui/editorial-title.tsx b/src/shared/ui/editorial-title.tsx new file mode 100644 index 0000000..b34bd71 --- /dev/null +++ b/src/shared/ui/editorial-title.tsx @@ -0,0 +1,11 @@ +export function EditorialTitle({ label, title, text }: { label: string; title: string; text: string }) { + return ( +
+
+
{label}
+

{title}

+

{text}

+
+
+ ); +} diff --git a/src/widgets/campaign-card.tsx b/src/widgets/campaign-card.tsx new file mode 100644 index 0000000..c111c9c --- /dev/null +++ b/src/widgets/campaign-card.tsx @@ -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 ( +
+ +
+ {campaign.title} +
+ {campaign.deadline} +
+
+
+
+ + {campaign.region} + + {campaignProgress(campaign)}% цели +
+

{campaign.title}

+

{campaign.lead}

+

{campaign.text}

+
+ +
+
+ {formatRub(campaign.raised)} ₽ собрано + {formatRub(campaign.goal)} ₽ цель +
+
+ +
+ ); +} diff --git a/src/widgets/campaign-detail-page.tsx b/src/widgets/campaign-detail-page.tsx new file mode 100644 index 0000000..7a82f83 --- /dev/null +++ b/src/widgets/campaign-detail-page.tsx @@ -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 ( + +
+
+
+ {campaign.region} + {campaign.deadline} +
+

{campaign.title}

+

{campaign.lead}

+

{campaign.text}

+
+ +
+ {formatRub(campaign.raised)} ₽ собрано + {formatRub(campaign.goal)} ₽ цель +
+
+
+ {campaign.checkpoints.map((point, index) => ( +
+
0{index + 1}
+
{point}
+
+ ))} +
+
+
+ +
+
+
+
+
+
бюджет кампании
+

Куда уйдет каждый рубль

+
+
+ {campaign.allocation.map((item) => ( +
+
+ {item.label} + {item.value}% +
+ +
+ ))} +
+
+
+
+ ); +} diff --git a/src/widgets/campaigns-page.tsx b/src/widgets/campaigns-page.tsx new file mode 100644 index 0000000..6c91897 --- /dev/null +++ b/src/widgets/campaigns-page.tsx @@ -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 ( + + +
+
+ {campaigns.map((campaign) => ( + + ))} +
+
+
+ ); +} diff --git a/src/widgets/home-page.tsx b/src/widgets/home-page.tsx new file mode 100644 index 0000000..9894b26 --- /dev/null +++ b/src/widgets/home-page.tsx @@ -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 ( + +
+
+
+
{site.issue} / {site.tagline}
+

+ Помощь, которую видно по документам и людям +

+

+ Common Ground - шаблон для НКО и impact-платформы, где каждая кампания показывает + цель, бюджет, полевой прогресс, истории и открытый реестр расходов. +

+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ {impactMetrics.map((item) => ( +
+
{item.value}
+

{item.label}

+
+ ))} +
+
+ +
+
+
+
живой реестр
+

Последние движения средств

+

+ Не прячьте доверие в PDF. Покажите свежие поступления, закупки и основание расходов прямо на сайте. +

+
+ +
+
+
+ ); +} diff --git a/src/widgets/impact-page.tsx b/src/widgets/impact-page.tsx new file mode 100644 index 0000000..9935fac --- /dev/null +++ b/src/widgets/impact-page.tsx @@ -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 ( +
+
{metric.value}
+

{metric.label}

+

{metric.detail}

+
+ ); +} + +export function ImpactPage() { + return ( + + +
+
+ {impactMetrics.map((item) => ( + + ))} +
+
+
+
+
+ +

Методика подтверждения

+

+ Каждая цифра связывается с документом: актом, фото, письмом партнера, платежом или сменой координатора. +

+
+ +
+
+
+ ); +} diff --git a/src/widgets/ledger-table.tsx b/src/widgets/ledger-table.tsx new file mode 100644 index 0000000..03bf7b6 --- /dev/null +++ b/src/widgets/ledger-table.tsx @@ -0,0 +1,16 @@ +import { ledger } from "@/entities/site-content"; + +export function LedgerTable() { + return ( +
+ {ledger.map((row) => ( +
+
{row.date}
+
{row.source}
+
{row.amount}
+
{row.note}
+
+ ))} +
+ ); +} diff --git a/src/widgets/site-shell.tsx b/src/widgets/site-shell.tsx new file mode 100644 index 0000000..167c51a --- /dev/null +++ b/src/widgets/site-shell.tsx @@ -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 ( +
+
+
+ + + + + {site.name} + + + +
+
+ {children} +
+
+
+
{site.name}
+
{site.tagline}
+
+
+ {site.email} + {site.phone} +
+
+
+
+ ); +} diff --git a/src/widgets/stories-page.tsx b/src/widgets/stories-page.tsx new file mode 100644 index 0000000..9a486bd --- /dev/null +++ b/src/widgets/stories-page.tsx @@ -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 ( +
+
+ {story.title} +
+
+
+ + {story.place} +
+

{story.title}

+

{story.text}

+
{story.result}
+
+
+ ); +} + +export function StoriesPage() { + return ( + + +
+
+ {stories.map((story) => ( + + ))} +
+
+
+ ); +} diff --git a/src/widgets/template-ui.tsx b/src/widgets/template-ui.tsx deleted file mode 100644 index dcf317c..0000000 --- a/src/widgets/template-ui.tsx +++ /dev/null @@ -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 ( -
-
-
- - - - - {site.name} - - - -
-
- {children} -
-
-
-
{site.name}
-
{site.tagline}
-
-
- {site.email} - {site.phone} -
-
-
-
- ); -} - -function EditorialTitle({ label, title, text }: { label: string; title: string; text: string }) { - return ( -
-
-
{label}
-

{title}

-

{text}

-
-
- ); -} - -function CampaignCard({ campaign, featured = false }: { campaign: Campaign; featured?: boolean }) { - const href = campaign.slug === "clean-water" ? "/campaigns/clean-water" : "/campaigns"; - - return ( -
- -
- {campaign.title} -
- {campaign.deadline} -
-
-
-
- - {campaign.region} - - {campaignProgress(campaign)}% цели -
-

{campaign.title}

-

{campaign.lead}

-

{campaign.text}

-
- -
-
- {formatRub(campaign.raised)} ₽ собрано - {formatRub(campaign.goal)} ₽ цель -
-
- -
- ); -} - -function MetricCard({ metric }: { metric: (typeof impactMetrics)[number] }) { - return ( -
-
{metric.value}
-

{metric.label}

-

{metric.detail}

-
- ); -} - -function LedgerTable() { - return ( -
- {ledger.map((row) => ( -
-
{row.date}
-
{row.source}
-
{row.amount}
-
{row.note}
-
- ))} -
- ); -} - -function StoryCard({ story }: { story: (typeof stories)[number] }) { - return ( -
-
- {story.title} -
-
-
- - {story.place} -
-

{story.title}

-

{story.text}

-
{story.result}
-
-
- ); -} - -function DocumentCard({ item }: { item: (typeof documents)[number] }) { - return ( -
- -

{item.title}

-

{item.status}

-

{item.owner}

-
- ); -} - -export function HomePage() { - const featured = campaigns[0]; - - return ( - -
-
-
-
{site.issue} / {site.tagline}
-

- Помощь, которую видно по документам и людям -

-

- Common Ground - шаблон для НКО и impact-платформы, где каждая кампания показывает - цель, бюджет, полевой прогресс, истории и открытый реестр расходов. -

-
- - -
-
- -
-
- -
-
- -
-
- -
-
- {impactMetrics.map((item) => ( -
-
{item.value}
-

{item.label}

-
- ))} -
-
- -
-
-
-
живой реестр
-

Последние движения средств

-

- Не прячьте доверие в PDF. Покажите свежие поступления, закупки и основание расходов прямо на сайте. -

-
- -
-
-
- ); -} - -export function CampaignsPage() { - return ( - - -
-
- {campaigns.map((campaign) => ( - - ))} -
-
-
- ); -} - -export function CampaignDetailPage() { - const campaign = campaigns[0]; - - return ( - -
-
-
- {campaign.region} - {campaign.deadline} -
-

{campaign.title}

-

{campaign.lead}

-

{campaign.text}

-
- -
- {formatRub(campaign.raised)} ₽ собрано - {formatRub(campaign.goal)} ₽ цель -
-
-
- {campaign.checkpoints.map((point, index) => ( -
-
0{index + 1}
-
{point}
-
- ))} -
-
-
- -
-
-
-
-
-
бюджет кампании
-

Куда уйдет каждый рубль

-
-
- {campaign.allocation.map((item) => ( -
-
- {item.label} - {item.value}% -
- -
- ))} -
-
-
-
- ); -} - -export function ImpactPage() { - return ( - - -
-
- {impactMetrics.map((item) => ( - - ))} -
-
-
-
-
- -

Методика подтверждения

-

- Каждая цифра связывается с документом: актом, фото, письмом партнера, платежом или сменой координатора. -

-
- -
-
-
- ); -} - -export function StoriesPage() { - return ( - - -
-
- {stories.map((story) => ( - - ))} -
-
-
- ); -} - -export function VolunteerPage() { - return ( - - -
-
- {volunteerShifts.map((shift) => ( -
-
-
{shift.date}
-
{shift.time}
-
-

{shift.title}

-
- - {shift.spots} -
-
- - {shift.location} -
- - {shift.role} - -
- ))} -
-
-
- ); -} - -export function TransparencyPage() { - return ( - - -
-
-
- -

Структура расходов

-
- {transparency.map((item) => ( -
-
- {item.label} - {item.value}% -
- -

{item.detail}

-
- ))} -
-
- -
-
-
-
-
-
документы
-

Что можно проверить

-
- {partners.map((partner) => ( - - {partner} - - ))} -
-
-
- {documents.map((item) => ( - - ))} -
-
-
-
- ); -} diff --git a/src/widgets/transparency-page.tsx b/src/widgets/transparency-page.tsx new file mode 100644 index 0000000..e1e65ff --- /dev/null +++ b/src/widgets/transparency-page.tsx @@ -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 ( +
+ +

{item.title}

+

{item.status}

+

{item.owner}

+
+ ); +} + +export function TransparencyPage() { + return ( + + +
+
+
+ +

Структура расходов

+
+ {transparency.map((item) => ( +
+
+ {item.label} + {item.value}% +
+ +

{item.detail}

+
+ ))} +
+
+ +
+
+
+
+
+
документы
+

Что можно проверить

+
+ {partners.map((partner) => ( + + {partner} + + ))} +
+
+
+ {documents.map((item) => ( + + ))} +
+
+
+
+ ); +} diff --git a/src/widgets/volunteer-page.tsx b/src/widgets/volunteer-page.tsx new file mode 100644 index 0000000..d99b8f5 --- /dev/null +++ b/src/widgets/volunteer-page.tsx @@ -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 ( + + +
+
+ {volunteerShifts.map((shift) => ( +
+
+
{shift.date}
+
{shift.time}
+
+

{shift.title}

+
+ + {shift.spots} +
+
+ + {shift.location} +
+ + {shift.role} + +
+ ))} +
+
+
+ ); +}