Архітектура 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-querybuilderField[].
- Хуки:
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),SectionCardRelations і Rollup; сайдбар —CollapsibleCardInfo, 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+ enumPortType/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, реєструє ClerkuseAuth().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також показує бекенд-ресурси без фронтенд-модуля:/usersCRUD,/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-querybuilderField[](клієнтська схема, окрема від серверної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 — два паралельні джерела схеми полів
- Клієнтське, з локального
FieldDef[](core/data-table/field-schema.ts,buildRqbFields) — керує клієнтською фільтрацією вже завантажених рядків уRecordTable(matchesQuery). - Серверне, з бекенд
FieldConfig(modules/creatives/model/query-fields.ts,toRqbFields) —query/sortпараметри серіалізуються черезformatQuery(query, "json")і йдуть уGET /creatives/GET /tasks/boardна бекенд.
Обидва механізми співіснують одночасно — важливо не плутати “фільтр у таблиці” (клієнтський) з “фільтр у запиті” (серверний) при зміні поведінки фільтрації.