Skip to content
Kumquat
Esc
↑↓navigate↵open⌘Jpreview
On this page

Архітектура frontend

Довідник по структурі 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 — аналітика/звітність по перформансу креативів

Бекендова частина — Аналітика креативів.

  • Публічне 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-схема, для нетехнічних — 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-фіді; деталі в пагінація та воркфлоу задач.
  • Запис на сторінці задачі: кнопки 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; деталі в воркфлоу задач). Сабміт блокується, поля з 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 на бекенд.

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

Was this page helpful?