---
title: Архітектура frontend
description: Довідник по структурі Kumquat Client — роути, модулі, сутності, сервіси.
---

> Цей файл — довідник по структурі проєкту: роути, модулі, сутності, сервіси.
>
> Документ описує стан кодової бази станом на 2026-07-29.

## Стек

- **Next.js 16** (App Router, `output: "standalone"`), **React 19**, TypeScript 5
- **Clerk** (`@clerk/nextjs`, `@clerk/ui`) — автентифікація, тема `shadcn`
- **`@base-ui/react`** — unstyled примітиви, обгорнуті в shadcn-style компоненти
  (це **не** Radix, попри зовнішню схожість на shadcn/ui)
- **SWR** + **axios** — фетчинг даних
- **`react-querybuilder`** — серверні і клієнтські фільтри
- **`@xyflow/react`** (React Flow) — граф-канвас (`canvas`)
- **`@dnd-kit/*`** — drag-and-drop для Kanban-дошки
- **BlockNote** (`@blocknote/*`) — rich-text редактор
- **Tailwind v4**
- **`orval`** — codegen типів і axios-клієнтів з backend OpenAPI (dev-only,
  `npm run generate:api`, конфіг — `orval.config.ts`)
- Path alias: `@/* → ./src/*`

## Архітектурна модель

Модульна (module-per-domain), **не** Feature-Sliced Design — немає шарів
`entities/features/widgets`, немає правила "імпорт тільки вниз".

```
src/
  app/                 # тонкі Next-роути, тільки композиція модулів
  core/                # наскрізна інфраструктура, нічого доменного
    api/                # createApiClient, TypedAxiosInstance, useGetService, auth, generated/
    ui/                 # shadcn/base-ui кіт + app-sidebar
    data-table/          # спільний рушій RecordTable/Kanban/FilterBar (фактично другий "core"-пакет)
    lib/                 # cn() + utils
  modules/<domain>/    # самодостатній модуль
    components/ services/ hooks/ model/
    index.ts             # публічне API модуля
```

Правила залежностей:
- `modules/*` може імпортувати з `core/*`.
- модуль ↔ модуль — тільки через чужий `index.ts`, ніколи напряму у внутрішні файли.
- `core/*` ніколи не імпортує з `modules/*`.

## Роути (`src/app`)

Автентифікація: `src/proxy.ts` (перейменований у Next 16 middleware-файл) —
`clerkMiddleware()` **без** перевірок роутів (`createRouteMatcher` задепрекейчений
у Clerk 7.5+), плюс `config.matcher`. Сама перевірка ресурсна:
`await auth.protect()` в `app/(app)/layout.tsx`, тому все під групою `(app)`
приватне, а sign-in/sign-up публічні просто тим, що лежать поза групою.

| Роут | Файл | Що рендерить |
|---|---|---|
| `/` | `app/page.tsx` | `redirect("/tasks")` |
| `/sign-in/*` | `app/sign-in/[[...sign-in]]/page.tsx` | Clerk `<SignIn/>` |
| `/sign-up/*` | `app/sign-up/[[...sign-up]]/page.tsx` | Clerk `<SignUp/>` |
| `/canvas` | `app/(app)/canvas/page.tsx` | `Canvas` з `@/modules/canvas` |
| `/funnels` | `app/(app)/funnels/page.tsx` | заглушка `ComingSoon` (модуля ще нема) |
| `/creatives` | `app/(app)/creatives/page.tsx` | `CreativesTable` з `@/modules/creatives` |
| `/creatives/[id]` | `app/(app)/creatives/[id]/page.tsx` | `CreativeViewPage` (динамічний, async server component) |
| `/analytics` | `app/(app)/analytics/page.tsx` | `CreativeAnalyticsTable` з `@/modules/analytics` |
| `/topics` | `app/(app)/topics/page.tsx` | заглушка `ComingSoon` |
| `/tasks` | `app/(app)/tasks/page.tsx` | `TasksTable` з `@/modules/tasks` |
| `/tasks/[id]` | `app/(app)/tasks/[id]/page.tsx` | `TaskEditPage` (динамічний, async) |
| `/tasks/new` | `app/(app)/tasks/new/page.tsx` | `TaskCreatePage` (статичний сегмент має пріоритет над `[id]`) |

`app/(app)/layout.tsx` — route-group layout без власного URL-сегмента, огортає
все під ним у `ApiAuthProvider` → `SwrProvider` → `SidebarProvider`/`AppSidebar`
(з `@/core/ui`). Sign-in/sign-up сторінки живуть **поза** цією групою — без
SWR/sidebar/api-auth.

`app/(app)/_components/coming-soon.tsx` — приватний для route-group компонент
(не експортується через жоден `index.ts`), просто плейсхолдер `ComingSoon({title})`.

API route handlers (`route.ts`) відсутні взагалі — весь бекенд-трафік іде
напряму на зовнішній API (`NEXT_PUBLIC_API_URL`) через axios, Next тут лише
рендерить сторінки.

Nav-меню (`src/core/ui/app-sidebar.tsx`) містить 4 пункти: Canvas,
Creatives, Analytics, Tasks. Роути `/funnels` і `/topics` існують як заглушки
`ComingSoon`, але в меню їх немає.

## Модулі (`src/modules/<domain>`)

### `analytics` — аналітика/звітність по перформансу креативів

Бекендова частина — [Аналітика креативів](/backend/creative-analytics).

- **Публічне API**: `CreativeAnalyticsTable`, тип `CreativeAnalyticsParams`.
- **Компоненти**: `analytics-toolbar` (пошук з debounce, date range, фільтри
  по вимірах), `creative-analytics-table` (сортування, пагінація),
  `creative-details-dialog` (drill-down по кліку на рядок, згрупований по
  `creo_name`: відео креатива, таби Metrics/Details і кнопка на його сторінку —
  **міжмодульна залежність**, імпортує `useCreativeByName`/`Creative`,
  `CreativeMediaPlayer` і `CreativeMetricsPanel` з `@/modules/creatives`, тобто
  скоркард у діалозі — рівно та сама панель, що на сторінці креатива, а не
  друга її реалізація), `date-range-picker`,
  `dimension-filter`, `totals-strip` (KPI-картки по всьому вікну: spend, LTV,
  ROAS, ROMI, impressions, clicks, installs, purchases, creatives count),
  `summary-cards` → `metric-summary-card` + `sparkline` (шість карток —
  LTV, ROAS, impressions, CPA, CTR, spend — з денним графіком на recharts і
  дельтою проти попереднього періоду тієї ж довжини).
- **Сервіси**: `createAnalyticsService(client)` → `GET /analytics/creatives`
  (фільтри як repeated query-параметри, `paramsSerializer: {indexes: null}`),
  `GET /analytics/creatives/daily`, `GET /analytics/creatives/schema`.
- **Модель**: `CreativeAnalyticsResult`, `CreativeAnalyticsSchema`,
  `CreativeMetricsRow`, `PortfolioTotals`, `DailyAnalyticsResult`,
  `DailyMetricsPoint`, `PeriodTotals`, `DimensionDef`, `MetricDef`
  (згенеровані); локально — `dimensions.ts` (каталог вимірів як його
  декларує бекенд, і водночас allowlist для `group_by`/`sort_by`),
  `analytics-url.ts` (весь стан таблиці в URL через `nuqs`, кожен параметр
  валідується під бекендові межі), `format.ts` (`formatMetric` за юнітом:
  usd/percent/ratio/int). ISO-хелпери й пресети діапазонів — спільні,
  `@/core/lib/date-range`.
- **Хуки**: `use-analytics-schema` (гейт `useApiReady`, без ревалідації по
  stale), `use-creative-analytics` і `use-daily-analytics` (ключ включає
  `JSON.stringify(params)`, `keepPreviousData`).
- **Залежності**: `@/modules/creatives`.

### `creatives` — реєстр креативних асетів і їх деталка (найбільша доменна модель)

- **Публічне API**: `CreativesTable`, `CreativesView`, `CreativesGallery`,
  `CreativeMediaCard`, `CreativeMediaPlayer`, `CreativeMetricsPanel`,
  `CreativeViewPage`, `useCreative`, `useCreativeByName`,
  `useCreativesOfTask`, `useCreativeNames`, `useCreativeSearch`,
  `useCreativeVariants`, типи `Creative`/`CreativesList`/`CreativeVariant`.
  (`useCreativeRows`, `creativesService`,
  `creativeToRow`, field/query/scorecard-модулі — внутрішні.)
- **Компоненти**: `creatives-view` (перемикач таблиця/галерея через
  localStorage), `creatives-table` (тонка обгортка над `RecordTable`),
  `creatives-gallery` (keyset-стрічка з IntersectionObserver, чіпи-фільтри +
  серверний `FilterBar`), `creative-media-card` (картка галереї),
  `creative-media-player` (постер → відео по кліку; спільний для картки
  галереї, drive-картки й діалога аналітики — файл не тягнеться, поки хтось не
  натисне play), `creative-view-page` → `creative-detail-form` (двоколонкова
  форма з top-facts стрічкою + сайдбар-секціями), `creative-metrics-panel`,
  `creative-metric-tiles`, `creative-drive-card`, `creative-lineage-cards`
  (оригінал, похідні задачі і блок «Variants» — сусідні LOC/WHT/ADPT/RSZ-картки того
  самого оригінала), `creative-testing-coverage`, `task-autocomplete`.
- **Сервіси**: `createCreativesService(client)` → `GET /creatives` (запит
  будується через `formatQuery(query, "json")` з `react-querybuilder` +
  JSON-сортування), `GET /creatives/gallery` (keyset-курсор),
  `GET /creatives/by-name/:name`, `GET /creatives/:id`,
  `GET /creatives/variants?origin_ids=...` (похідні LOC/WHT/ADPT/RSZ-картки
  переданих оригіналів, до 50 id за запит).
- **Модель** — найбагатший шар мапінгу backend-enum ↔ label в проєкті:
  - `types.ts` — ~22 згенеровані enum-константи (BodyCtaType,
    BodyFormat, BodyMarketingBeat, BodyScript, CameraAngle, Cast,
    EmotionalDriver, Geo, HookFormat, HookMarketingAngle, HookScript,
    HookVisualTrigger, Language, MediaKind, MediaType, MetricConfidence,
    MetricStatus, Modifier, Pacing, PerformanceTier, Setting, Source, TaskType,
    TestStatus, VisualRealism, VisualStyle, WorkStream) + форми (`Creative`,
    `MediaInfo`, `Metrics`, `MetricWindows`, `WindowStats`, `RateCI`,
    `MetricBench`, `MetricConfidenceBlock`, `HookAnalysis`, `BodyAnalysis`,
    `ContentAnalysis`, `VlmAnalysis`, `OriginRef`, `CreativeDates`);
    локально — `CreativesMeta`/`CreativesList`/`CreativeGalleryPage` (backend
    типізує `filter_schema`/`sort_schema` як opaque dict).
  - `creative-fields.ts` — `CREATIVE_TABLE_COLUMNS`, `CREATIVE_FIELDS` (~80
    полів у 10 секціях: Creo Info, Status, Metrics, Hook, Body, Content &
    Messaging, Context, Drive, People, Dates), `METRIC_FIELD_KEYS` —
    декларативна схема, що керує і таблицею, і `RecordEditForm`/`FieldInput`.
    Імпортує `PRODUCT_AVATARS` з `@/modules/products`.
  - `creative-mapping.ts` — `creativeToRow(creative)`: ~14 хардкод-мап
    snake_case backend enum → людський лейбл (TEST_STATUS,
    PERFORMANCE_TIER, HOOK_*, CAST, CAMERA_ANGLE, SETTING, BODY_*,
    EMOTIONAL_DRIVER, VISUAL_REALISM, VISUAL_STYLE, PACING).
  - `metrics.ts` — `CREATIVE_METRICS`/`METRIC_ORDER` (spend/ctr/cpi/ipm),
    `metricValue()`. Ці чотири плитки змішують вікна, тому кожна несе `window`.
  - `scorecard.ts` — уся семантика скоркарду (див. нижче).
  - `query-fields.ts` — `toRqbFields(FieldConfig[])`: адаптує серверний
    `FieldConfig` (з `@/core/api`) у `react-querybuilder` `Field[]`.
- **Хуки**: `use-creatives` (список, `keepPreviousData`), `use-creative-gallery`
  (SWR-infinite по курсору, `total`/схеми лише з першої сторінки),
  `use-creative` (за id, з `mutate`), `use-creative-by-name`
  (`shouldRetryOnError:false`), `use-creative-rows` (список → мапінг через
  `creativeToRow`, фіксований `limit:100`), `use-creative-names`,
  `use-creative-search`, `use-creatives-of-task`, `use-creative-variants`
  (похідні картки оригіналів; ключ `null` поки список оригіналів порожній, тож
  форма без `Source Creatives` не робить запиту).
- **Залежності**: `@/modules/products` (аватарки продуктів у `creative-fields.ts`).

#### Скоркард метрик (`model/scorecard.ts`)

`creatives.metrics` пише батч `kumquat-creative-metrics`: три вікна
`w.{d7,t7,life}` зі ставками у формі `RateCI {v, lo, hi, hw, n}`, `status`,
`confidence`, `bench` проти медіани продукту. Легасі-скаляри (`hook_rate`,
`ctr`, `spend`…) — проєкції цих вікон і лишаються контрактом фільтрів/сортів.
Семантика полів — [Mongo-схема](/backend/db-schema), для нетехнічних —
README пакета `kumquat-creative-metrics`.

Уся презентаційна логіка живе в `scorecard.ts`, компоненти нічого не вирішують
самі: `METRIC_WINDOWS` і `DEFAULT_METRIC_WINDOW` (`d7`), `SCORECARD_RATES`,
`metricStatusMeta`/`confidenceMeta`/`benchIndexMeta` (Tailwind-класи в
`Record`-мапах, як `*-mapping.ts`), форматери `formatRate`/`formatPoints`/
`formatCount`/`formatUsd*`/`formatRatio`, і предикати `hasScorecard`,
`hasBench`, `isRetentionApplicable`, `isWindowProvisional`, `productMismatch`.

Три речі, що легко зламати:

- **`rateDisplay(ci, confidence)` — єдине місце контракту «null ≠ 0».** `v`
  буває справді null (нульовий знаменник), і це має виглядати інакше, ніж
  чесний 0%. Компоненти гілкуються по `display.missing`, ніколи по `v == null`.
  У `creativeToRow` з тієї ж причини `num()` (`?? undefined`), ніколи `?? 0`.
- **Легасі-ставки в рядку — рядки, не числа.** `FieldInput` у `readonly` робить
  `String(value)`, тож сирий `0.42` показувався як `0.42`. Форматуються в
  `creativeToRow`; тому вони не `filterable` і не колонки таблиці — інакше
  `matchesQuery`/`compare` сортуватимуть їх як текст.
- **`isRetentionApplicable` гілкується по типу медіа, не по відсутності
  значення.** У відео з null-hook це баг, який варто побачити; у статики це
  норма (`confidence: none`) — картка показує їй CTR замість hook/hold.

Нові поля стають фільтрами/сортами **на бекенді** (`CreativeFilter`,
`GALLERY_SORT_OPTIONS`) і доїжджають у тулбар галереї самі через
`filter_schema`/`sort_schema` — правити `creatives-gallery.tsx` для цього не
треба. Єдиний виняток — локальний `SORT_OPTIONS`-фолбек у ньому, дзеркало
`GALLERY_SORT_OPTIONS` до приходу першої сторінки; його треба тримати в синхроні
руками. Кожен сорт мусить бути індексованим полем (keyset-пагінація).

### `tasks` — пайплайн виробничих тасків зі статус-воркфлоу + Kanban

- **Публічне API**: `TasksTable`, `TaskEditPage`, `TaskCreatePage`,
  `TASKS_VIEW_KEY`, `useTask`, `tasksService`, `createTasksService`,
  `Task`/`TaskCreate`/`TaskUpdate`.
- **Компоненти**: `tasks-table` (`RecordTable` з `kanbanGroupBy="status"`,
  `canDrop` через `canTransition`, drag → `useTaskStatus.moveStatus`, toast
  через `useTaskToasts`, кнопка «Add new task» у слоті `action`),
  `task-edit-page` / `task-create-page` — сторінки: тримають драфт, `saving`,
  тости й сабміт (при зміні статус-лейбла шлють окремий `transitionStatus`
  поруч із патчем полів), `task-detail-form` / `task-create-form` —
  **тільки композиція** блоків з `core/data-table`, `task-drive-strip`,
  `task-submit-button`.
- **Розкладка деталки** описана самим JSX, без неявних фільтрів: шапка —
  `FactsStrip` з `TASK_HEADER_KEYS`; головна колонка — Drive-полоса, картка ТЗ
  (`varDescription`), `SectionCard` Relations і Rollup; сайдбар —
  `CollapsibleCard` Info, Task Config, Context (відкриті) і Other. Поля, які
  рендеряться поза своєю секцією, перелічені в кожній композиції одним
  `PLACED_OUTSIDE_SECTIONS`. Сторінка створення — окрема композиція
  (`editableFields`, власне текстове поле `name`, без Drive/Relations/Rollup).
- **Сервіси**: `createTasksService(client)` → `GET /tasks/board/columns`
  (перша сторінка всіх колонок канбану одним запитом), `GET /tasks/board`
  (keyset-сторінка однієї колонки — «Load more» і табличний вигляд),
  `GET /tasks/schema` (filter/sort/status схеми, один раз за сесію),
  `GET /tasks/options`, `GET /tasks/:id`, `POST /tasks`, `PATCH /tasks/:id`,
  `PATCH /tasks/:id/status` (`{target_status}`, типізовано `TaskStatusTransition`).
  Offset-пагінованого `GET /tasks` більше немає — обидва вигляди сидять на
  keyset-фіді; деталі в [пагінація](/backend/range-based-cursor-pagination)
  та [воркфлоу задач](/backend/workflow).
- **Запис на сторінці задачі**: кнопки Save немає — правка йде сама, коли її
  перестали робити (`useAutosave`). Рядок задачі поділений навпіл:
  `TASK_WRITABLE_KEYS` — те, що форма справді відправляє (з нього збирається
  `taskRowToUpdate`), решта (`number`, `generated_name`, `drive_folder_id`,
  ролапи, `automation.*`, а також `started_at`/`submitted_at`/`completed_at` —
  їх ставить сам воркфлоу, і ні в `TaskUpdate`, ні в `TaskCreate` їх немає)
  належить серверу. Питання «чи є що зберігати» ставиться
  тільки по writable-половині, а статус — окремо, бо він іде своїм ендпоінтом.
  Тому зміна самого статусу — це рівно один запит, `PATCH /tasks/:id/status`, без
  супутнього `PATCH /tasks/:id`. Відповідь сервера вливається назад у чернетку
  через `taskServerFields()` — лише серверну половину, щоб форма побачила
  `automation.state = pending` і замкнулась одразу, не чекаючи кадру зі стріму, і
  щоб при цьому не витерти те, що користувач саме зараз друкує.
- **Наймінг**: домен у фронті — **task**, на дроті — досі **task**. Бекенд
  (`/api/v1/tasks`, поля `number`/`generated_name`/`task_id`, ключ пейлоада
  `tasks`) і orval-типи (`Task`, `TaskCreate`, …) не перейменовані. Межа —
  `model/types.ts` (реекспорт `Task as Task` тощо), URL-и в
  `services/tasks.service.ts` і SWR-ключі `["/tasks", …]`.
- **Модель**:
  - `types.ts` — enum (Geo, Language, MediaType, Resolution, TaskStatus,
    Source, TaskType, TestStatus, VariedElement, WorkStream) + форми (Task,
    TaskCreate, TaskUpdate, TaskStatusTransition, TaskDates, TaskRollups) —
    усе аліасами з `core/api/generated/model`; локально —
    `TaskSchemas` (filter/sort схеми + `status_schema: {transitions}`),
    `TasksBoardPage` / `TasksBoardColumns` / `BoardColumn` (ключ пейлоада —
    `tasks` / `columns`).
  - `task-mapping.ts` — `STATUS_LABELS` (7 станів у порядку воркфлоу: backlog,
    ready_for_production, in_progress, tech_check, creative_check, done,
    archived) і похідний `STATUS_OPTIONS` — цей порядок і є порядком колонок
    канбана та опцій селекта, `TEST_STATUS_LABELS`, `VARIED_ELEMENT_LABELS`,
    `taskToRow(task)`, `STATUS_TO_ENUM` (обернена мапа для workflow),
    `taskRowToUpdate` (row → TaskUpdate), `taskRowToCreate` (row → TaskCreate)
    і `missingCreateFields()` — валідація обовʼязкових полів до запиту.
    Обовʼязковість не фіксована: `requiredFieldKeys(row)` рахує її від
    `Work Stream` і `Task Type` (розділ «Обовʼязкові поля й дублікати
    варіантів»). Там же — `originIdsFrom(row)`, `findDuplicateVariants(row,
    variants)` (`{exact, partial}`) і `duplicateVariantFieldKeys(taskType)`:
    які саме поля підсвітити як конфліктні.
  - `task-fields.ts` — `TASK_TABLE_COLUMNS`, `TASK_FIELDS` (імпортує
    `PRODUCT_AVATARS` з `@/modules/products` + Lucide-іконки на статус для
    kanban). Секції: Info, Task Config, Context, Other (сайдбар), Relations,
    Rollup (головна колонка), Status і Drive — службові: їхні поля рендеряться
    в шапці / Drive-полосі, окремими блоками не виводяться. `status`/`testStatus`
    мусять лишатися в секції `Status` — по ній `shouldBadge()` у
    `core/ui/badge.tsx` вирішує, чи фарбувати бейдж у таблиці й на kanban.
  - `task-header.ts` — `TASK_HEADER_KEYS`, `TASK_HEADER_ICONS`,
    `TASK_DESCRIPTION_KEY`, `TASK_DRIVE_KEY`: доменна конфігурація шапки. Плюс
    `TASK_SIDE_SECTIONS` (порядок і `defaultOpen` секцій сайдбару),
    `taskSideFields()` і `visibleSideSections()` — які секції взагалі
    показувати (підрозділ нижче).
  - `task-view.ts` — `TASKS_VIEW_KEY` (ключ куки вигляду; читається на сервері
    в `app/(app)/tasks/page.tsx` і передається як `initialView`).
  - `task-workflow.ts` — `statusFromLabel()`, `canTransition(transitions,
    fromLabel, toLabel)` — стейт-машина переходів статусів (з backend) для
    guard'а drag-and-drop у Kanban. Плюс `AUTOMATION_STATUS`
    (`ready_for_production` — статус, вхід у який запускає автоматизацію) і
    `isAutomationLocked(statusLabel, automationState)` /
    `isAutomationRunning(task)`: форма заморожена, лише поки прогон у польоті
    (`pending`), а не весь час, поки задача стоїть у цьому статусі.
- **Хуки**: `use-tasks` (SWR **з polling**: `refreshInterval=3000ms` тільки
  поки є task зі `automation?.state === "pending"`, інакше `0`; віддає
  `isLoading: isLoading || !enabled` — інакше при `null`-ключі SWR каже
  `isLoading: false` і UI малює empty state замість скелетонів), `use-task`,
  `use-task-rows` (мапінг через `taskToRow`, `limit:100`),
  `use-task-status` (`moveStatus` → `tasksService.transitionStatus` + toast
  на `ApiError` + `mutate()`), `use-task-toasts` (локальна toast-черга; сама
  діагностує завершення автоматизації, діфаючи `automationState` між
  пулінгами — чистий клієнтський стейт, без SWR/сервіс-викликів).
- **Залежності**: `@/modules/products`.

#### Обовʼязкові поля й дублікати варіантів

Обовʼязковість поля — не властивість поля, а функція від драфта, тому живе в
`task-mapping.ts`, а не в `TASK_FIELDS`. `requiredEntries(row)` рахує набір для
поточних `Work Stream` + `Task Type`, і з нього виходять дві різні речі:

- `missingCreateFields(row)` / `missingCreateFieldKeys(row)` — те, що **не
  заповнено**: блокує сабміт.
- `requiredFieldKeys(row)` — те, що **обовʼязкове взагалі**, заповнене чи ні:
  малює червону зірочку біля лейбла (проп `required` у `PropRow`). `name`
  звідси виключений — він рендериться заголовком сторінки, зірочку чіпляти нема
  до чого.

Правила дзеркалять бекендний гейт автоматизації (`_validate` у
`workflow/handlers.py`) — щоб форма не пускала те, що автоматизація потім
відкине:

| Умова драфта | Що обовʼязкове |
| --- | --- |
| `Work Stream ≠ Performance` | лише `Name` — автоматизація не запускається, тож вписана руками назва авторитетна |
| `Work Stream = Performance` | `PERFORMANCE_REQUIRED`, плюс `Language`/`Geo` (окрім `WHT`/`ADPT`/`RSZ` — успадковують з оригінала), плюс `Source` (окрім `LOC` — успадковує з оригінала) |
| `NEW`/`VAR`/`NEWDES`/`VARDES`/`VARAI`/`CLN` | додатково `Count > 0` |
| `LOC`/`WHT`/`ADPT`/`RSZ` | додатково хоч один `Source Creative` |
| `VAR`/`VARDES`/`VARAI` | додатково один `Origin Creative` |

**Сховані секції.** `visibleSideSections(taskType, workStream)` прибирає
`Product Stack`, `Creative Stack` і `Marketing Funnel` для `LOC`/`WHT`/`ADPT`/`RSZ`:
ці типи будують картку з полів оригінала, автоматизація цих секцій не читає, і
показувати їх означало б запрошувати редагувати значення, які нікуди не йдуть.
Ховаються, а не дизейбляться. Обмежено `Work Stream = Performance` — тільки там
працює та автоматизація, тож non-Performance задача з типом `LOC` редагує всі
секції як звичайна.

**Дублікати варіантів.** `findDuplicateVariants(row, variants)` порівнює драфт
із наявними похідними картками його оригіналів (дані — з `useCreativeVariants`)
і розділяє знайдене на два рівні:

- `exact` — та сама вісь, що й на бекенді (`LOC` — `Language`+`Geo`,
  `WHT`/`ADPT` — `Source`, `RSZ` — `Resolution`; деталі в [воркфлоу задач](/backend/workflow)). Сабміт
  блокується, поля з `duplicateVariantFieldKeys(taskType)` підсвічуються через
  `invalidKeys`. Це та сама умова, на якій бекенд віддає `409` — фронт лише
  показує її до запиту.
- `partial` — тільки для `LOC`: збігся `Language` **або** `Geo`, але не обидва.
  Не блокує, показується попередженням: такий сусідній варіант буває і
  легітимним, і ознакою помилки в гео.


### `canvas` — візуальний граф-білдер воркфлоу (React Flow, з бекендом)

- **Публічне API**: `Canvas`; типи `GraphDoc`, `GraphDefinition`,
  `GraphRunDoc`, `NodeSpec`, `RunEvent`.
- **Компоненти**: `canvas` (головний `ReactFlow`-канвас: стан
  вузлів/ребер через `useNodesState`/`useEdgesState`, кастомний тип вузла
  `EngineNode`, оркеструє CRUD графа + старт рану + підписку на live-події),
  `top-toolbar` (вибір/новий/зберегти/запустити граф, статус рану),
  `bottom-toolbar` (палітра вузлів за категоріями text/image/video),
  `engine-node` (кастомний вузол React Flow з live параметрами/станом),
  `node-parts` (`StatusBadge`, `ParamWidget`, `OutputPreview`, `PORT_COLORS`).
- **Сервіси**: `createCanvasService(client)` → `GET /nodes` (каталог
  вузлів), `GET/POST/PATCH/DELETE /graphs(/:id)`, `POST /graphs/:id/runs`
  (старт рану), `GET /runs/:id`.
- **Модель**: `NodeSpec`/`InputPort`/`OutputPort`/`NodeRunState` +
  enum `PortType`/`RunStatus`/`NodeRunStatus` (згенеровані); локально —
  `GraphNodeDef`/`GraphEdgeDef`/`GraphDefinition`/`GraphUiState`/`GraphDoc`/
  `GraphsList`/`GraphRunDoc` (backend типізує `definition`/`ui_state` як opaque
  dict); `RunEvent` — union **поза** REST OpenAPI, описує кадри WebSocket-стріму
  запуску (`snapshot`, `run_started/finished/failed`,
  `node_started/progress/finished/cached/failed`); `MediaArtifact`.
- **Хуки**: `use-graphs`, `use-node-specs` (звичайні SWR); `use-run-events` —
  **не SWR** — відкриває сирий `WebSocket` на
  `.../api/v1/ws/runs/:runId?token=...` (токен через `getAuthToken()` з
  `@/core/api`), стрімить `RunEvent`-фрейми в колбек.
- **Залежності**: немає (самодостатній, напряму юзає `@xyflow/react`).

### `products` — довідкові дані (продукти), спільна залежність для інших модулів

- **Публічне API**: `useProducts`, `productsService`, `createProductsService`,
  `Product`/`ProductCreate`/`ProductUpdate`, `PRODUCT_AVATARS`.
- Без `components/` — чистий дата/сервіс-модуль, споживається іншими модулями
  для UI (аватарки), сам нічого не рендерить.
- **Сервіси**: `createProductsService(client)` — повний CRUD:
  `GET /products`, `GET/PATCH/DELETE /products/:id`, `POST /products`.
- **Модель**: `Product`/`ProductCreate`/`ProductUpdate` (згенеровані);
  `PRODUCT_AVATARS: Record<string,string>` — код продукту (PLT/CNI/RKI/SPD/SNR)
  → шлях до лого в `/products/*.png` (assets у `public/products/`).
- **Хуки**: `use-products` (SWR, `revalidateOnFocus:false`).
- **Споживачі**: `@/modules/creatives`, `@/modules/tasks` — єдиний модуль, що
  виступає спільними "довідковими даними" для двох інших доменів.

## Core (`src/core`)

### `core/api` — HTTP-клієнт, auth-wiring, згенеровані типи

- `client.ts` — `createApiClient(baseURL): TypedAxiosInstance` (axios, timeout
  30s); request-інтерсептор додає `Authorization: Bearer <token>` через
  `getAuthToken()`; response-інтерсептор розгортає envelope
  `{success, data, message, errors}` (`unwrap()`) і мапить axios-помилки в
  `ApiError` (`toApiError()`). Синглтон `api = createApiClient(...)`.
  `TypedAxiosInstance` має лише `get/post/patch/delete` (без `put`), кожен
  одразу повертає `T`, а не `AxiosResponse`.
- `auth.ts` — синглтон `tokenGetter`; `registerTokenGetter(getter)` /
  `getAuthToken()` — розв'язує plain-TS клієнт від React/Clerk.
- `api-auth-provider.tsx` — `ApiAuthProvider`, реєструє Clerk `useAuth().getToken`
  як token getter — міст між Clerk-сесією і axios-клієнтом.
- `use-api-ready.ts` — `useApiReady()` = `isLoaded && !!isSignedIn` — гейтить
  усі SWR-ключі в усіх модулях, щоб запити не летіли до готовності auth.
- `use-get-service.ts` — `useGetService<T>(key, service, config)` — тонка
  типізована обгортка над `useSWR`; **база** всіх дата-хуків проєкту.
- `swr-provider.tsx` — `SwrProvider` — глобальний `SWRConfig`
  (`revalidateOnFocus:false, shouldRetryOnError:false`).
- `api-error.ts` — `ApiError extends Error` з `status`/`errors[]`.
- `types.ts` — `ApiResponse<T>`, `PaginationMeta`, `SortDirection`/`SortParams`,
  `FieldOption`/`FieldConfig` (схема динамічних фільтрів з бекенду).
- `generated/` — orval-кодоген з `../backend/openapi.json`
  (`make -C ../backend openapi && npm run generate:api`): `endpoints.ts`
  (axios-функції + `*Result`-типи на кожен ендпоїнт) і `generated/model/*.ts`
  (~130 файлів — по одному на схему/enum). **Важливо**: ручні `services/*.ts`
  у модулях імпортують з `generated/model` тільки типи, самі виклики йдуть
  через ручний `TypedAxiosInstance` (`api`), а не через згенеровані
  axios-функції з `endpoints.ts`. `endpoints.ts` також показує бекенд-ресурси
  **без фронтенд-модуля**: `/users` CRUD, `/health`, `/webhooks/clerk`.

### `core/ui` — shadcn-style кіт на `@base-ui/react`

Усі — `"use client"` презентаційні обгортки: `@base-ui/react/*` примітив +
`class-variance-authority` варіанти + `cn()`. Base-ui-based: `button`,
`input`, `dialog`/`sheet` (sheet перевикористовує Dialog-примітив),
`select`, `popover`, `separator`, `switch`, `tooltip`, `sidebar` (+
`merge-props`/`use-render`). Не base-ui: `card`, `label`, `table`,
`textarea`, `skeleton` (голі styled div + `cn`), `calendar`
(`react-day-picker`, обгорнутий у `buttonVariants`), `rich-text-editor`
(`@blocknote/react` + `@blocknote/shadcn`), `app-sidebar` (nav + Clerk
`UserButton`).

`badge.tsx` — доменно-обізнана інфра: `Badge`, `OptionBadge`,
`shouldBadge(field)`, `optionClasses/optionColumnClasses(field, value)` —
детерміноване призначення кольору select-полям (8-hue ramp, хеш по ключу
поля або явний `field.hue`), щоб значення були консистентно розфарбовані і в
таблиці, і в kanban. Залежить від `FieldDef` з `core/data-table`.

### `core/data-table` — спільний рушій RecordTable/Kanban/FilterBar

Фактично другий "core"-пакет — бекбон, спільний для `creatives` і `tasks`
(таблиця+деталка+kanban), параметризований схемою `model/*-fields.ts` +
мапінг-функціями `*-mapping.ts` кожного модуля.

- `field-schema.ts` — `FieldType` (text/textarea/number/select/multiselect/
  checkbox/date/datetime/readonly/richtext), `FieldDef`, `RecordRow`;
  `buildRqbFields()` — `FieldDef[]` → `react-querybuilder` `Field[]`
  (клієнтська схема, окрема від серверної `toRqbFields` у creatives);
  `matchesQuery()`/`matchesRule()` — **клієнтська** оцінка фільтрів
  (=, !=, contains, beginsWith, endsWith, <, <=, >, >=, between, null,
  notNull, in, notIn) над уже завантаженими рядками; `pickFields(fields, keys)`,
  `sectionFields(fields, section)`, `editableFields(fields)` — вибірки, якими
  композиції деталок задають розкладку.
- `record-table.tsx` — `RecordTable`: перемикач table/kanban (kanban лише
  якщо переданий `kanbanGroupBy`), `FilterBar`, слот `action` для кнопок
  домену (праворуч у рядку фільтрів), скелетон-рядки на `isLoading`,
  клієнтське сортування (`compare()`), `formatCell()`, клік по рядку →
  `router.push(getRowHref(row))`. Довгі значення обрізаються за кількістю
  символів + `Tooltip` (40 у клітинках таблиці, 20 у compact-рядках).
- `facts-strip.tsx` / `section-card.tsx` / `collapsible-card.tsx` /
  `prop-row.tsx` — оболонки деталки: шапка запінених полів, картка головної
  колонки (2 колонки), акордеон сайдбару, рядок label/value. Дурні й
  безстанові — розкладку задає композиція в модулі.
- `date-field.tsx` — `DateField`: `Popover` + `Calendar` на одну дату (той самий
  вигляд, що в date-range пікері аналітики), використовується `FieldInput` для
  `type: "date"`.
- `use-stored-view.ts` / `view-cookie.ts` — запамʼятовування вибору
  table/kanban у **куці** `qumquat.view.<key>` (не `localStorage`: сторінка
  рендериться на сервері, тож вигляд треба знати до першого кадру, інакше
  видно перемикання після гідрації). Читання через `useSyncExternalStore`;
  чисті хелпери (`viewCookieName`, `parseView`) лежать у `view-cookie.ts`
  **без** `"use client"`, щоб їх міг імпортувати серверний компонент роуту.
- `record-edit-form.tsx` — `RecordEditForm` — секціонована форма з `FieldDef[]`
  + `sections[]`, рендер полів делегує `FieldInput`. Наразі не використовується
  жодним модулем (деталки складаються з оболонок вище).
- `field-input.tsx` — `FieldInput` — контрол на кожен `FieldType`, `RichTextEditor`
  ліниво через `next/dynamic({ssr:false})`.
- `filter-bar.tsx` — `FilterBar` — popover з `<QueryBuilder>` на
  `shadcnControlElements`.
- `active-filters.tsx` — чіпи активних правил.
- `query-builder-controls.tsx` — `shadcnControlElements` — кастомні контроли
  querybuilder на `@/core/ui`.
- `kanban-board.tsx` — `KanbanBoard`/`CardNotice` на `@dnd-kit/core`+`sortable`.
- `cell-avatar.tsx` — `CellAvatar` — аватар з fallback на ініціали.

### `core/lib`

`utils.ts` — `cn(...inputs) = twMerge(clsx(inputs))`.
`date.ts` — `parseISODate`, `toISODate`, `formatDay` (спільні для `DateField` і
date-range пікера аналітики, який їх реекспортує).

### `core/hooks`

Окремої директорії немає. Спільні недоменні хуки (`useApiReady`,
`useGetService`) живуть у `core/api`. Решта хуків — доменні, всередині
`modules/<domain>/hooks/`.

## Наскрізні аспекти

### Clerk auth

- `app/layout.tsx` — `ClerkProvider` у корені, з env (`CLERK_PUBLISHABLE_KEY`,
  `CLERK_SIGN_IN_URL`, `CLERK_SIGN_UP_URL`, `..._FALLBACK_REDIRECT_URL`),
  тема `shadcn` з `@clerk/ui/themes`.
- `src/proxy.ts` — `clerkMiddleware()` без перевірок роутів (лише
  `publishableKey` + `config.matcher`); `createRouteMatcher` задепрекейчений.
- `app/(app)/layout.tsx` — `await auth.protect()`: захист прив'язаний до
  ресурсу, а не до матчингу шляхів. Публічні сторінки лежать поза групою
  `(app)`, тому списку публічних роутів немає.
- `core/api/api-auth-provider.tsx` — міст Clerk-токена в axios-клієнт.
- `core/api/use-api-ready.ts` — гейт для всіх дата-хуків.
- `core/ui/app-sidebar.tsx` — `<UserButton/>`.
- `core/api/generated/endpoints.ts` показує бекенд `POST /webhooks/clerk`
  (бекенд-only, фронтенд його не викликає).

### Провайдери (композиція в `(app)/layout.tsx`)

`ClerkProvider` (root) → `ApiAuthProvider` → `SwrProvider` →
`SidebarProvider`/`AppSidebar`/`SidebarInset`. Sign-in/up сторінки — поза
цим деревом.

### `react-querybuilder` — два паралельні джерела схеми полів

1. Клієнтське, з локального `FieldDef[]` (`core/data-table/field-schema.ts`,
   `buildRqbFields`) — керує **клієнтською** фільтрацією вже завантажених
   рядків у `RecordTable` (`matchesQuery`).
2. Серверне, з бекенд `FieldConfig` (`modules/creatives/model/query-fields.ts`,
   `toRqbFields`) — `query`/`sort` параметри серіалізуються через
   `formatQuery(query, "json")` і йдуть у `GET /creatives`/`GET /tasks/board` на
   бекенд.

Обидва механізми співіснують одночасно — важливо не плутати "фільтр у
таблиці" (клієнтський) з "фільтр у запиті" (серверний) при зміні поведінки
фільтрації.
