diff --git a/AGENTS.md b/AGENTS.md index 7c4e856..12073e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ SignalDesk AI — B2B SaaS для поддержки; сохраняй launch-pa ## Project Specifics - Source content lives in `src/entities/site-content.ts`; keep visible copy there or directly in route JSX. -- `src/app` is routing only; page composition belongs to `src/widgets/template-ui.tsx`. +- `src/app` is routing only; композиция каждой страницы живёт в отдельном widget (`src/widgets/-page.tsx`). См. File Map. - Keep cards at 8px radius or less and preserve the selected palette in `src/app/globals.css`. - This is a static frontend template: do not add real payments, auth, persistence, external API calls, or backend contracts without an explicit product request. - After changes run `pnpm lint` and `pnpm build`. @@ -14,3 +14,58 @@ SignalDesk AI — B2B SaaS для поддержки; сохраняй launch-pa - Кириллица обязательна для видимого текста и выбранных Google Fonts. - При AI-правках сохраняйте доменные блоки этого шаблона: они отличают проект от generic landing. + +## Design System + +Источник токенов — `src/app/globals.css` (`@theme inline` + `:root`/`.dark`). Шрифты: заголовки (`h1/h2/h3`, `.font-display`) — **Roboto Flex** (`--font-display`), текст/UI — **Inter** (`--font-sans`); mono в теме указывает на тот же display. Работай через семантические классы Tailwind (`bg-primary`, `text-foreground`, `border-border`, `text-muted-foreground`), никогда не хардкодь hex/oklch. + +Личность: **clean B2B SaaS launch-page** — почти белый фон (`oklch(0.985 0 0)`), глубокий cobalt/indigo primary как единственный фирменный цвет, бирюзовый teal accent для данных и диаграмм, мягкие нейтральные границы. Воздух, плотная сетка `max-w-[1280px]`, крупные семиболдовые заголовки. Не «громкий», а спокойный и доверительный. + +| Роль | Light | Характер | +|---|---|---| +| `background` | почти белый (`oklch 0.985`) | основной фон страниц | +| `foreground` | near-black indigo-ink | текст, тёмные CTA-плашки (`bg-foreground text-background`) | +| `primary` | глубокий cobalt/indigo | бренд-акцент: лого, ссылки, активные числа, primary CTA | +| `accent` | teal/бирюза | вторые столбцы диаграмм, точечные акценты данных | +| `secondary` | холодный светло-серый | мягкие бейджи, фоновые плашки | +| `muted` / `muted-foreground` | светло-серый / серо-синий | вторичный текст, подписи | +| `card` | белый (`oklch 1`) | карточки и секции на фоне | + +Узнаваемые приёмы (держи их — это «лицо» шаблона): +- **Мягкие, не острые углы:** `--radius` = 0.5rem; карточки и кнопки почти всегда `rounded-md` (8px и меньше). Не уходи в `rounded-none` или сильно скруглённые pill-формы. +- **Тонкие нейтральные границы:** `border border-border` (1px), без толстых брутализм-рамок. +- **Hero-фоны на CSS-утилитах:** `.signal-aurora` (анимированный radial-градиент cobalt/teal/lime) + `.signal-grid` (точечная сетка с mask-fade). Используй их для launch hero вместо плоского цвета. Есть также `.grainient-field`. +- **Глубокие «продуктовые» тени для dashboard-панели:** `shadow-[0_36px_100px_rgba(37,76,210,0.22)]` + `backdrop-blur-xl` на demo-панели. +- **Типографика:** `font-semibold`, крупные размеры (`text-4xl`…`text-7xl`), плотный `leading`, eyebrow-метки `text-xs font-semibold uppercase tracking-[0.16em] text-primary`. + +Do / Don't: +- **Do:** держи cobalt primary единственным брендовым цветом; data/charts подсвечивай `accent`; используй eyebrow + крупный заголовок + сетку карточек; demo dashboard оставляй mock без реальных API. +- **Don't:** не добавляй второй яркий бренд-цвет, неоновые градиенты на тексте, толстые рамки, острые `rounded-none` углы или тяжёлый брутализм — это ломает спокойный SaaS-тон. + +## File Map + +| Route | Widget | +|---|---| +| `/` | `src/widgets/home-page.tsx` (`HomePage`) | +| `/product` | `src/widgets/product-page.tsx` (`ProductPage`) | +| `/pricing` | `src/widgets/pricing-page.tsx` (`PricingPage`) | +| `/customers` | `src/widgets/customers-page.tsx` (`CustomersPage`) | +| `/resources` | `src/widgets/resources-page.tsx` (`ResourcesPage`) | +| `/dashboard` | `src/widgets/dashboard-page.tsx` (`DashboardPage`) | + +Переиспользуемые блоки: +- `src/widgets/site-shell.tsx` — `SiteHeader` + `SiteFooter` (обёртка из `src/app/layout.tsx`). +- `src/widgets/saas-dashboard.tsx` — `SaasDashboard` (Home hero, Dashboard). +- `src/widgets/issue-workflow.tsx` — `IssueWorkflow` (Home, Dashboard, Product). +- `src/widgets/integration-stack.tsx` — `IntegrationStack` (Home, Product). +- `src/widgets/metric-strip.tsx` — `MetricStrip` (Home; рендерит `highlights`). +- `src/widgets/testimonial-band.tsx` — `TestimonialBand` (Home, Customers; рендерит `testimonials`). +- `src/shared/ui/inner-hero.tsx` — `InnerHero` (хедер всех внутренних страниц). +- `src/shared/ui/featured-grid.tsx` — `FeaturedGrid` (Home, Product, Resources). +- `src/shared/ui/icon-cards.tsx` — `IconCards` (Dashboard, Product). +- `src/shared/ui/pricing-tiles.tsx` — `PricingTiles` (Pricing). +- `src/shared/ui/info-columns.tsx` — `InfoColumns` (Pricing, Resources). +- `src/shared/ui/split-story.tsx` — `SplitStory` (Home, Customers). +- `src/shared/ui/cta-panel.tsx` — `CtaPanel` (Product). + +Одноразовые блоки колоцированы со своей страницей: `GrainientHero` (launch hero) живёт внутри `src/widgets/home-page.tsx`. diff --git a/src/app/customers/page.tsx b/src/app/customers/page.tsx index a7f23fc..e45f375 100644 --- a/src/app/customers/page.tsx +++ b/src/app/customers/page.tsx @@ -1,15 +1,5 @@ -"use client"; - -import { InnerHero, SplitStory, TestimonialBand } from "@/widgets/template-ui"; -import { testimonials } from "@/entities/site-content"; - +import { CustomersPage } from "@/widgets/customers-page"; export default function Page() { - return ( - <> - - - - - ); + return ; } diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 13d6dea..1f34b6d 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,16 +1,5 @@ -"use client"; - -import { IconCards, InnerHero, IssueWorkflow, SaasDashboard } from "@/widgets/template-ui"; -import { contactCards } from "@/entities/site-content"; - +import { DashboardPage } from "@/widgets/dashboard-page"; export default function Page() { - return ( - <> - - - - - - ); + return ; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 16ba3d7..e5898b0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,7 +3,7 @@ import { Inter, Roboto_Flex } from "next/font/google"; import "./globals.css"; import { ThemeProvider } from "@/shared/hooks/theme-provider"; import { ThemeMessageListener } from "@/shared/hooks/theme-message-listener"; -import { SiteHeader, SiteFooter } from "@/widgets/template-ui"; +import { SiteHeader, SiteFooter } from "@/widgets/site-shell"; const display = Roboto_Flex({ variable: "--font-display", diff --git a/src/app/page.tsx b/src/app/page.tsx index 93066aa..5b1660a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,19 +1,5 @@ -"use client"; - -import { FeaturedGrid, GrainientHero, IntegrationStack, IssueWorkflow, MetricStrip, SplitStory, TestimonialBand } from "@/widgets/template-ui"; -import { highlights, products, site, testimonials } from "@/entities/site-content"; - +import { HomePage } from "@/widgets/home-page"; export default function Page() { - return ( - <> - - - - - - - - - ); + return ; } diff --git a/src/app/pricing/page.tsx b/src/app/pricing/page.tsx index 3bcd3df..6b152d7 100644 --- a/src/app/pricing/page.tsx +++ b/src/app/pricing/page.tsx @@ -1,15 +1,5 @@ -"use client"; - -import { InfoColumns, InnerHero, PricingTiles } from "@/widgets/template-ui"; -import { tastingSets } from "@/entities/site-content"; - +import { PricingPage } from "@/widgets/pricing-page"; export default function Page() { - return ( - <> - - - - - ); + return ; } diff --git a/src/app/product/page.tsx b/src/app/product/page.tsx index 8cd778a..b620240 100644 --- a/src/app/product/page.tsx +++ b/src/app/product/page.tsx @@ -1,18 +1,5 @@ -"use client"; - -import { CtaPanel, FeaturedGrid, IconCards, InnerHero, IntegrationStack, IssueWorkflow } from "@/widgets/template-ui"; -import { eventTypes, products } from "@/entities/site-content"; - +import { ProductPage } from "@/widgets/product-page"; export default function Page() { - return ( - <> - - - - - - - - ); + return ; } diff --git a/src/app/resources/page.tsx b/src/app/resources/page.tsx index 7883ab1..9ef279a 100644 --- a/src/app/resources/page.tsx +++ b/src/app/resources/page.tsx @@ -1,15 +1,5 @@ -"use client"; - -import { FeaturedGrid, InfoColumns, InnerHero } from "@/widgets/template-ui"; -import { products } from "@/entities/site-content"; - +import { ResourcesPage } from "@/widgets/resources-page"; export default function Page() { - return ( - <> - - - - - ); + return ; } diff --git a/src/shared/ui/cta-panel.tsx b/src/shared/ui/cta-panel.tsx new file mode 100644 index 0000000..a137521 --- /dev/null +++ b/src/shared/ui/cta-panel.tsx @@ -0,0 +1,6 @@ +import Link from "next/link"; +import { ArrowRightIcon, SparklesIcon } from "lucide-react"; + +import { Button } from "@/shared/ui/button"; + +export function CtaPanel({ title, text, href, label }: { title: string; text: string; href: string; label: string }) { return

{title}

{text}

; } diff --git a/src/shared/ui/featured-grid.tsx b/src/shared/ui/featured-grid.tsx new file mode 100644 index 0000000..fbaa8d0 --- /dev/null +++ b/src/shared/ui/featured-grid.tsx @@ -0,0 +1,5 @@ +import { Badge } from "@/shared/ui/badge"; + +type TextItem = { name: string; price: string; tag: string; text: string }; + +export function FeaturedGrid({ eyebrow, title, text, items }: { eyebrow: string; title: string; text: string; items: readonly TextItem[] }) { return
{eyebrow}

{title}

{text}

{items.map((item) =>
{item.tag}
{item.price}

{item.name}

{item.text}

)}
; } diff --git a/src/shared/ui/icon-cards.tsx b/src/shared/ui/icon-cards.tsx new file mode 100644 index 0000000..f7fe8a9 --- /dev/null +++ b/src/shared/ui/icon-cards.tsx @@ -0,0 +1,6 @@ +import { eventTypes } from "@/entities/site-content"; + +type IconComponent = React.ComponentType<{ className?: string }>; +type IconItem = { title: string; text: string; icon: IconComponent }; + +export function IconCards({ items = eventTypes }: { items?: readonly IconItem[] }) { return
{items.map((item) => { const Icon = item.icon; return

{item.title}

{item.text}

; })}
; } diff --git a/src/shared/ui/info-columns.tsx b/src/shared/ui/info-columns.tsx new file mode 100644 index 0000000..73d4bed --- /dev/null +++ b/src/shared/ui/info-columns.tsx @@ -0,0 +1,3 @@ +import { ShieldCheckIcon } from "lucide-react"; + +export function InfoColumns({ title, items }: { title: string; items: readonly { title: string; text: string }[] }) { return

{title}

{items.map((item) =>

{item.title}

{item.text}

)}
; } diff --git a/src/shared/ui/inner-hero.tsx b/src/shared/ui/inner-hero.tsx new file mode 100644 index 0000000..60eac92 --- /dev/null +++ b/src/shared/ui/inner-hero.tsx @@ -0,0 +1,3 @@ +import { Badge } from "@/shared/ui/badge"; + +export function InnerHero({ eyebrow, title, text }: { eyebrow: string; title: string; text: string }) { return
{eyebrow}

{title}

{text}

; } diff --git a/src/shared/ui/pricing-tiles.tsx b/src/shared/ui/pricing-tiles.tsx new file mode 100644 index 0000000..3cfb28d --- /dev/null +++ b/src/shared/ui/pricing-tiles.tsx @@ -0,0 +1,7 @@ +import { CheckIcon } from "lucide-react"; + +import { Separator } from "@/shared/ui/separator"; + +type TileItem = { title: string; price: string; items: readonly string[] }; + +export function PricingTiles({ title, items }: { title: string; items: readonly TileItem[] }) { return

{title}

{items.map((item, index) =>

{item.title}

{item.price}
{item.items.map((line) =>
{line}
)}
)}
; } diff --git a/src/shared/ui/split-story.tsx b/src/shared/ui/split-story.tsx new file mode 100644 index 0000000..c9e8b62 --- /dev/null +++ b/src/shared/ui/split-story.tsx @@ -0,0 +1,4 @@ +import Image from "next/image"; +import { CheckIcon } from "lucide-react"; + +export function SplitStory({ image, eyebrow, title, text, points }: { image: string; eyebrow: string; title: string; text: string; points: readonly string[] }) { return
{title}
{eyebrow}

{title}

{text}

{points.map((point) =>
{point}
)}
; } diff --git a/src/widgets/customers-page.tsx b/src/widgets/customers-page.tsx new file mode 100644 index 0000000..7dfc4c1 --- /dev/null +++ b/src/widgets/customers-page.tsx @@ -0,0 +1,14 @@ +import { testimonials } from "@/entities/site-content"; +import { InnerHero } from "@/shared/ui/inner-hero"; +import { SplitStory } from "@/shared/ui/split-story"; +import { TestimonialBand } from "@/widgets/testimonial-band"; + +export function CustomersPage() { + return ( + <> + + + + + ); +} diff --git a/src/widgets/dashboard-page.tsx b/src/widgets/dashboard-page.tsx new file mode 100644 index 0000000..73b2dc1 --- /dev/null +++ b/src/widgets/dashboard-page.tsx @@ -0,0 +1,16 @@ +import { contactCards } from "@/entities/site-content"; +import { IconCards } from "@/shared/ui/icon-cards"; +import { InnerHero } from "@/shared/ui/inner-hero"; +import { IssueWorkflow } from "@/widgets/issue-workflow"; +import { SaasDashboard } from "@/widgets/saas-dashboard"; + +export function DashboardPage() { + return ( + <> + + + + + + ); +} diff --git a/src/widgets/home-page.tsx b/src/widgets/home-page.tsx new file mode 100644 index 0000000..11e5b45 --- /dev/null +++ b/src/widgets/home-page.tsx @@ -0,0 +1,31 @@ +import Link from "next/link"; +import { ArrowRightIcon } from "lucide-react"; + +import { highlights, products, site, testimonials } from "@/entities/site-content"; +import { Badge } from "@/shared/ui/badge"; +import { Button } from "@/shared/ui/button"; +import { FeaturedGrid } from "@/shared/ui/featured-grid"; +import { SplitStory } from "@/shared/ui/split-story"; +import { IntegrationStack } from "@/widgets/integration-stack"; +import { IssueWorkflow } from "@/widgets/issue-workflow"; +import { MetricStrip } from "@/widgets/metric-strip"; +import { SaasDashboard } from "@/widgets/saas-dashboard"; +import { TestimonialBand } from "@/widgets/testimonial-band"; + +function GrainientHero() { + return
60+ обновлений продукта

Support-команда видит проблему раньше очереди

{site.tagline}

topic spike +27%
; +} + +export function HomePage() { + return ( + <> + + + + + + + + + ); +} diff --git a/src/widgets/integration-stack.tsx b/src/widgets/integration-stack.tsx new file mode 100644 index 0000000..866af16 --- /dev/null +++ b/src/widgets/integration-stack.tsx @@ -0,0 +1 @@ +export function IntegrationStack() { const tools = ["Helpdesk", "CRM", "База знаний", "Биллинг", "Slack", "Email", "Чат", "Склад"] as const; return
Integrations

AI не отвечает вслепую

Шаблон показывает продуктовый слой интеграций без внешних вызовов, секретов и API.

{tools.map((tool, index) =>
{index + 1}
{tool}
)}
; } diff --git a/src/widgets/issue-workflow.tsx b/src/widgets/issue-workflow.tsx new file mode 100644 index 0000000..94cee08 --- /dev/null +++ b/src/widgets/issue-workflow.tsx @@ -0,0 +1 @@ +export function IssueWorkflow() { const flow = [["01", "Сигнал", "topic volume растет быстрее нормы"], ["02", "Кластер", "AI связывает диалоги с общей причиной"], ["03", "Handoff", "оператор получает summary и next action"], ["04", "QA", "система проверяет ответ и тон"]] as const; return
Workflow

От всплеска тикетов до корректного ответа

{flow.map(([num, title, text]) =>
{num}

{title}

{text}

)}
; } diff --git a/src/widgets/metric-strip.tsx b/src/widgets/metric-strip.tsx new file mode 100644 index 0000000..9385993 --- /dev/null +++ b/src/widgets/metric-strip.tsx @@ -0,0 +1,3 @@ +import { highlights } from "@/entities/site-content"; + +export function MetricStrip(_props: { items?: unknown } = {}) { return
{highlights.map((item) => { const Icon = item.icon; return
{item.value}

{item.title}

{item.text}

; })}
; } diff --git a/src/widgets/pricing-page.tsx b/src/widgets/pricing-page.tsx new file mode 100644 index 0000000..d615d1a --- /dev/null +++ b/src/widgets/pricing-page.tsx @@ -0,0 +1,14 @@ +import { tastingSets } from "@/entities/site-content"; +import { InfoColumns } from "@/shared/ui/info-columns"; +import { InnerHero } from "@/shared/ui/inner-hero"; +import { PricingTiles } from "@/shared/ui/pricing-tiles"; + +export function PricingPage() { + return ( + <> + + + + + ); +} diff --git a/src/widgets/product-page.tsx b/src/widgets/product-page.tsx new file mode 100644 index 0000000..18db4f8 --- /dev/null +++ b/src/widgets/product-page.tsx @@ -0,0 +1,20 @@ +import { eventTypes, products } from "@/entities/site-content"; +import { CtaPanel } from "@/shared/ui/cta-panel"; +import { FeaturedGrid } from "@/shared/ui/featured-grid"; +import { IconCards } from "@/shared/ui/icon-cards"; +import { InnerHero } from "@/shared/ui/inner-hero"; +import { IntegrationStack } from "@/widgets/integration-stack"; +import { IssueWorkflow } from "@/widgets/issue-workflow"; + +export function ProductPage() { + return ( + <> + + + + + + + + ); +} diff --git a/src/widgets/resources-page.tsx b/src/widgets/resources-page.tsx new file mode 100644 index 0000000..c6cf168 --- /dev/null +++ b/src/widgets/resources-page.tsx @@ -0,0 +1,14 @@ +import { products } from "@/entities/site-content"; +import { FeaturedGrid } from "@/shared/ui/featured-grid"; +import { InfoColumns } from "@/shared/ui/info-columns"; +import { InnerHero } from "@/shared/ui/inner-hero"; + +export function ResourcesPage() { + return ( + <> + + + + + ); +} diff --git a/src/widgets/saas-dashboard.tsx b/src/widgets/saas-dashboard.tsx new file mode 100644 index 0000000..2af1e64 --- /dev/null +++ b/src/widgets/saas-dashboard.tsx @@ -0,0 +1,7 @@ +import { Badge } from "@/shared/ui/badge"; + +const bars = [18, 26, 22, 42, 58, 48, 72, 80, 62, 88, 96, 76]; + +export function SaasDashboard({ compact = false }: { compact?: boolean }) { + return
Issue radar

Платежи не проходят у части клиентов

Онлайн
{bars.map((bar, index) =>
)}
affected 842
refund risk high
owner billing
{[["Затронутые диалоги", "842", "+27% за 2 часа"], ["Закрыто AI", "318", "без handoff"], ["QA warnings", "27", "требуют ревью"]].map(([label, value, meta]) =>
{label}
{value}
{meta}
)}
; +} diff --git a/src/widgets/site-shell.tsx b/src/widgets/site-shell.tsx new file mode 100644 index 0000000..244e820 --- /dev/null +++ b/src/widgets/site-shell.tsx @@ -0,0 +1,11 @@ +import Link from "next/link"; +import { MenuIcon } from "lucide-react"; + +import { site } from "@/entities/site-content"; +import { Button } from "@/shared/ui/button"; + +export function SiteHeader() { + return
Si{site.name}
; +} + +export function SiteFooter() { return
{site.name}

{site.tagline}

Страницы
{site.nav.slice(0,4).map((item) => {item.label})}
Шаблон
B2B SaaS лендинг с launch hero, workflow, pricing, customer stories и demo dashboard.
; } diff --git a/src/widgets/template-ui.tsx b/src/widgets/template-ui.tsx deleted file mode 100644 index 40a1f87..0000000 --- a/src/widgets/template-ui.tsx +++ /dev/null @@ -1,53 +0,0 @@ -"use client"; - -import Image from "next/image"; -import Link from "next/link"; -import { ArrowRightIcon, CheckIcon, MenuIcon, ShieldCheckIcon, SparklesIcon } from "lucide-react"; - -import { eventTypes, highlights, site, testimonials } from "@/entities/site-content"; -import { Badge } from "@/shared/ui/badge"; -import { Button } from "@/shared/ui/button"; -import { Separator } from "@/shared/ui/separator"; - -type IconComponent = React.ComponentType<{ className?: string }>; -type TextItem = { name: string; price: string; tag: string; text: string }; -type TileItem = { title: string; price: string; items: readonly string[] }; -type IconItem = { title: string; text: string; icon: IconComponent }; - -const bars = [18, 26, 22, 42, 58, 48, 72, 80, 62, 88, 96, 76]; - -export function SiteHeader() { - return
Si{site.name}
; -} - -export function SiteFooter() { return
{site.name}

{site.tagline}

Страницы
{site.nav.slice(0,4).map((item) => {item.label})}
Шаблон
B2B SaaS лендинг с launch hero, workflow, pricing, customer stories и demo dashboard.
; } - -export function GrainientHero() { - return
60+ обновлений продукта

Support-команда видит проблему раньше очереди

{site.tagline}

topic spike +27%
; -} - -export function SaasDashboard({ compact = false }: { compact?: boolean }) { - return
Issue radar

Платежи не проходят у части клиентов

Онлайн
{bars.map((bar, index) =>
)}
affected 842
refund risk high
owner billing
{[["Затронутые диалоги", "842", "+27% за 2 часа"], ["Закрыто AI", "318", "без handoff"], ["QA warnings", "27", "требуют ревью"]].map(([label, value, meta]) =>
{label}
{value}
{meta}
)}
; -} - -export function InnerHero({ eyebrow, title, text }: { eyebrow: string; title: string; text: string }) { return
{eyebrow}

{title}

{text}

; } - -export function IssueWorkflow() { const flow = [["01", "Сигнал", "topic volume растет быстрее нормы"], ["02", "Кластер", "AI связывает диалоги с общей причиной"], ["03", "Handoff", "оператор получает summary и next action"], ["04", "QA", "система проверяет ответ и тон"]] as const; return
Workflow

От всплеска тикетов до корректного ответа

{flow.map(([num, title, text]) =>
{num}

{title}

{text}

)}
; } - -export function MetricStrip(_props: { items?: unknown } = {}) { return
{highlights.map((item) => { const Icon = item.icon; return
{item.value}

{item.title}

{item.text}

; })}
; } - -export function FeaturedGrid({ eyebrow, title, text, items }: { eyebrow: string; title: string; text: string; items: readonly TextItem[] }) { return
{eyebrow}

{title}

{text}

{items.map((item) =>
{item.tag}
{item.price}

{item.name}

{item.text}

)}
; } - -export function IntegrationStack() { const tools = ["Helpdesk", "CRM", "База знаний", "Биллинг", "Slack", "Email", "Чат", "Склад"] as const; return
Integrations

AI не отвечает вслепую

Шаблон показывает продуктовый слой интеграций без внешних вызовов, секретов и API.

{tools.map((tool, index) =>
{index + 1}
{tool}
)}
; } - -export function IconCards({ items = eventTypes }: { items?: readonly IconItem[] }) { return
{items.map((item) => { const Icon = item.icon; return

{item.title}

{item.text}

; })}
; } - -export function PricingTiles({ title, items }: { title: string; items: readonly TileItem[] }) { return

{title}

{items.map((item, index) =>

{item.title}

{item.price}
{item.items.map((line) =>
{line}
)}
)}
; } - -export function InfoColumns({ title, items }: { title: string; items: readonly { title: string; text: string }[] }) { return

{title}

{items.map((item) =>

{item.title}

{item.text}

)}
; } - -export function SplitStory({ image, eyebrow, title, text, points }: { image: string; eyebrow: string; title: string; text: string; points: readonly string[] }) { return
{title}
{eyebrow}

{title}

{text}

{points.map((point) =>
{point}
)}
; } - -export function TestimonialBand(_props: { items?: unknown } = {}) { return
{testimonials.map((item) =>
{item.rating}

“{item.text}”

{item.name}
)}
; } - -export function CtaPanel({ title, text, href, label }: { title: string; text: string; href: string; label: string }) { return

{title}

{text}

; } diff --git a/src/widgets/testimonial-band.tsx b/src/widgets/testimonial-band.tsx new file mode 100644 index 0000000..98979bb --- /dev/null +++ b/src/widgets/testimonial-band.tsx @@ -0,0 +1,3 @@ +import { testimonials } from "@/entities/site-content"; + +export function TestimonialBand(_props: { items?: unknown } = {}) { return
{testimonials.map((item) =>
{item.rating}

“{item.text}”

)}
; }