From de3ba95d58d3ca56b14643f8389ff274f91ecfea Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sat, 16 May 2026 19:08:05 +0000 Subject: [PATCH] 123 --- backend/scripts/seed-furniture-demo-data.js | 848 +++++++ backend/src/db/api/ai_agent_settings.js | 11 +- backend/src/db/api/analytics_snapshots.js | 11 +- backend/src/db/api/case_studies.js | 10 +- backend/src/db/api/content_assets.js | 10 +- backend/src/db/api/content_briefs.js | 11 +- backend/src/db/api/content_ideas.js | 11 +- backend/src/db/api/content_plan_items.js | 11 +- backend/src/db/api/generated_contents.js | 11 +- backend/src/db/api/leads.js | 11 +- backend/src/db/api/products.js | 10 +- backend/src/db/api/publishing_logs.js | 11 +- backend/src/db/api/social_channels.js | 11 +- frontend/src/components/AsideMenuLayer.tsx | 4 +- frontend/src/components/NavBarItem.tsx | 3 +- frontend/src/layouts/Authenticated.tsx | 3 +- frontend/src/menuAside.ts | 6 + frontend/src/pages/agent-studio.tsx | 2197 +++++++++++++++++++ frontend/src/pages/index.tsx | 417 ++-- frontend/src/pages/search.tsx | 4 +- 20 files changed, 3362 insertions(+), 249 deletions(-) create mode 100644 backend/scripts/seed-furniture-demo-data.js create mode 100644 frontend/src/pages/agent-studio.tsx diff --git a/backend/scripts/seed-furniture-demo-data.js b/backend/scripts/seed-furniture-demo-data.js new file mode 100644 index 0000000..63f7fb2 --- /dev/null +++ b/backend/scripts/seed-furniture-demo-data.js @@ -0,0 +1,848 @@ +const axios = require('axios'); +const config = require('../src/config'); + +const API_BASE_URL = process.env.DEMO_DATA_API_URL || 'http://127.0.0.1:3000/api'; +const LOGIN_EMAIL = process.env.DEMO_DATA_EMAIL || config.admin_email; +const LOGIN_PASSWORD = process.env.DEMO_DATA_PASSWORD || config.admin_pass; +const LIST_LIMIT = 200; + +const api = axios.create({ + baseURL: API_BASE_URL, + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + }, +}); + +const summary = { + social_channels: { created: 0, updated: 0 }, + products: { created: 0, updated: 0 }, + case_studies: { created: 0, updated: 0 }, + content_briefs: { created: 0, updated: 0 }, + content_ideas: { created: 0, updated: 0 }, + content_plan_items: { created: 0, updated: 0 }, + generated_contents: { created: 0, updated: 0 }, + leads: { created: 0, updated: 0 }, + lead_activities: { created: 0, updated: 0 }, +}; + +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response) { + console.error('API request failed:', { + method: error.config?.method, + url: error.config?.url, + status: error.response.status, + response: error.response.data, + }); + } else { + console.error('Request failed before response:', error.message); + } + + throw error; + }, +); + +function track(resource, action) { + summary[resource][action] += 1; +} + +function makeImportHash(parts) { + return ['furniture-demo', ...parts].join(':'); +} + +function normalize(value) { + return String(value || '') + .trim() + .toLowerCase(); +} + +function dateOffset(daysOffset, hour, minute = 0) { + const value = new Date(); + value.setUTCDate(value.getUTCDate() + daysOffset); + value.setUTCHours(hour, minute, 0, 0); + return value.toISOString(); +} + +async function login() { + const { data: token } = await api.post('/auth/signin/local', { + email: LOGIN_EMAIL, + password: LOGIN_PASSWORD, + }); + + api.defaults.headers.common.Authorization = `Bearer ${token}`; + + const { data: currentUser } = await api.get('/auth/me'); + + return currentUser; +} + +async function list(resource) { + const rows = []; + let page = 0; + + while (page < 20) { + const { data } = await api.get(`/${resource}?limit=${LIST_LIMIT}&page=${page}`); + const batch = Array.isArray(data.rows) ? data.rows : []; + rows.push(...batch); + + if (batch.length < LIST_LIMIT) { + break; + } + + page += 1; + } + + return rows; +} + +function findExisting(rows, payload, matchFields = []) { + if (payload.importHash) { + const matchByImportHash = rows.find((item) => item.importHash === payload.importHash); + + if (matchByImportHash) { + return matchByImportHash; + } + } + + if (!matchFields.length) { + return null; + } + + return rows.find((item) => + matchFields.every((field) => normalize(item[field]) === normalize(payload[field])), + ); +} + +async function findByImportHashInDatabase(resource, importHash) { + if (!importHash) { + return null; + } + + if (!/^[a-z_]+$/.test(resource)) { + throw new Error(`Unsafe resource name: ${resource}`); + } + + const escapedImportHash = importHash.replace(/'/g, ); + const sql = `SELECT id, "importHash" FROM ${resource} WHERE "importHash" = '${escapedImportHash}' LIMIT 1`; + const { data } = await api.post('/sql', { sql }); + + return Array.isArray(data.rows) && data.rows.length ? data.rows[0] : null; +} + +async function upsert(resource, rows, payload, options = {}) { + const { matchFields = [], labelFields = [] } = options; + let existing = findExisting(rows, payload, matchFields); + const label = labelFields.map((field) => payload[field]).find(Boolean) || payload.importHash || resource; + + if (!existing && payload.importHash) { + existing = await findByImportHashInDatabase(resource, payload.importHash); + + if (existing && !rows.some((item) => item.id === existing.id)) { + rows.push(existing); + } + } + + if (existing) { + await api.put(`/${resource}/${existing.id}`, { + id: existing.id, + data: payload, + }); + + track(resource, 'updated'); + console.log(`↺ Updated ${resource}: ${label}`); + + const updatedRow = { + ...existing, + ...payload, + id: existing.id, + }; + + const index = rows.findIndex((item) => item.id === existing.id); + if (index >= 0) { + rows[index] = updatedRow; + } else { + rows.push(updatedRow); + } + + return updatedRow; + } + + const { data: createResponse } = await api.post(`/${resource}`, { + data: payload, + }); + + let created = createResponse; + + if (!created || !created.id) { + created = (payload.importHash && (await findByImportHashInDatabase(resource, payload.importHash))) || null; + + if (!created) { + const refreshedRows = await list(resource); + created = findExisting(refreshedRows, payload, matchFields); + rows.splice(0, rows.length, ...refreshedRows); + } + } + + if (!created || !created.id) { + throw new Error(`Created ${resource} item could not be located afterwards: ${label}`); + } + + track(resource, 'created'); + + if (!rows.some((item) => item.id === created.id)) { + rows.push(created); + } + + console.log(`+ Created ${resource}: ${label}`); + + return created; +} + +async function main() { + console.log(`Connecting to ${API_BASE_URL}`); + let currentUser = await login(); + + const organizationName = currentUser.organization?.name || 'Фабрика мебели на заказ'; + const organizationId = currentUser.organization?.id || null; + const ownerUserId = currentUser.id; + + console.log(`Signed in as ${currentUser.email} (${organizationName})`); + + const [ + socialChannels, + products, + caseStudies, + contentBriefs, + contentIdeas, + contentPlanItems, + generatedContents, + leads, + leadActivities, + ] = await Promise.all([ + list('social_channels'), + list('products'), + list('case_studies'), + list('content_briefs'), + list('content_ideas'), + list('content_plan_items'), + list('generated_contents'), + list('leads'), + list('lead_activities'), + ]); + + const channels = {}; + for (const channelPayload of [ + { + key: 'instagram_showroom', + platform: 'instagram', + account_name: 'atelier_forma_msk', + account_url: 'https://instagram.com/atelier_forma_msk', + access_status: 'connected', + notes: `Основная витрина ${organizationName}: кейсы, до/после, рилсы из цеха.`, + is_active: true, + importHash: makeImportHash(['social_channels', 'instagram-showroom']), + }, + { + key: 'telegram_channel', + platform: 'telegram', + account_name: '@atelier_forma_design', + account_url: 'https://t.me/atelier_forma_design', + access_status: 'connected', + notes: 'Канал с кейсами, материалами, ответами на частые вопросы и CTA на замер.', + is_active: true, + importHash: makeImportHash(['social_channels', 'telegram-channel']), + }, + { + key: 'vk_local', + platform: 'vk', + account_name: 'atelier_forma_custom', + account_url: 'https://vk.com/atelier_forma_custom', + access_status: 'connected', + notes: 'Локальное продвижение, отзывы клиентов и подборки реализованных проектов.', + is_active: true, + importHash: makeImportHash(['social_channels', 'vk-local']), + }, + ]) { + const savedChannel = await upsert('social_channels', socialChannels, channelPayload, { + matchFields: ['account_name'], + labelFields: ['account_name'], + }); + + channels[channelPayload.key] = savedChannel.id; + } + + const productIds = {}; + for (const productPayload of [ + { + key: 'premium_kitchens', + category: 'kitchen', + name: 'Премиальные кухни на заказ', + description: + 'Кухни под размеры клиента: остров, пеналы до потолка, встроенная техника и продуманная эргономика.', + starting_price: 390000, + materials: 'МДФ эмаль, дубовый шпон, HPL-пластик, кварцевые столешницы.', + features: 'Blum, скрытая подсветка, внутренние органайзеры, проект и монтаж под ключ.', + is_active: true, + importHash: makeImportHash(['products', 'premium-kitchens']), + }, + { + key: 'wardrobes_to_ceiling', + category: 'wardrobe', + name: 'Шкафы-купе до потолка', + description: + 'Шкафы для прихожих, спален и ниш с точной подгонкой под геометрию помещения.', + starting_price: 210000, + materials: 'ЛДСП Egger, МДФ в эмали, алюминиевые профили, стекло и зеркало.', + features: 'Максимум хранения, антресоли, подсветка полок, скрытые ручки.', + is_active: true, + importHash: makeImportHash(['products', 'wardrobes-to-ceiling']), + }, + { + key: 'walk_in_closets', + category: 'closet', + name: 'Гардеробные системы под размер', + description: + 'Гардеробные для мастер-спален и отдельных комнат: открытые секции, островные тумбы и зеркала.', + starting_price: 340000, + materials: 'Шпон, ЛДСП премиум-класса, металл, тонированное стекло.', + features: 'Сценарии хранения, секции под обувь и сумки, мягкая подсветка, доводчики.', + is_active: true, + importHash: makeImportHash(['products', 'walk-in-closets']), + }, + { + key: 'living_room_systems', + category: 'living_room', + name: 'ТВ-зоны и системы хранения для гостиной', + description: + 'Композиции для гостиной с витринами, скрытыми модулями и зонами под технику.', + starting_price: 280000, + materials: 'Шпон ореха, матовые фасады, фрезерованные панели, металл.', + features: 'Скрытая проводка, витрины с подсветкой, комбинация открытого и закрытого хранения.', + is_active: true, + importHash: makeImportHash(['products', 'living-room-systems']), + }, + { + key: 'home_offices', + category: 'office', + name: 'Домашние кабинеты и библиотеки', + description: + 'Рабочие зоны, библиотеки и мебель для кабинета с эргономикой под ежедневную нагрузку.', + starting_price: 260000, + materials: 'Шпон дуба, эмаль, акустические панели, металл в порошковой окраске.', + features: 'Кабель-менеджмент, столешницы увеличенной глубины, закрытые модули, подсветка.', + is_active: true, + importHash: makeImportHash(['products', 'home-offices']), + }, + ]) { + const savedProduct = await upsert('products', products, productPayload, { + matchFields: ['name'], + labelFields: ['name'], + }); + + productIds[productPayload.key] = savedProduct.id; + } + + const caseStudyIds = {}; + for (const caseStudyPayload of [ + { + key: 'kitchen_symbol', + title: 'Матовая кухня с островом для семьи в ЖК «Символ»', + project_type: 'kitchen', + client_name: 'Семья П.', + location: 'Москва, ЖК «Символ»', + budget: 680000, + project_start_at: dateOffset(-95, 8), + project_end_at: dateOffset(-72, 17), + problem: + 'Нужно было разместить много хранения и полноценную рабочую поверхность в кухне-гостиной без визуального перегруза.', + solution: + 'Сделали матовые фасады в теплом сером цвете, остров с хранением, высокие пеналы и скрытую подсветку рабочей зоны.', + results: + 'Клиенты получили кухню с удобной посадкой, местом под технику и аккуратным премиальным видом для фото и видео.', + is_featured: true, + status: 'published', + product: productIds.premium_kitchens, + importHash: makeImportHash(['case_studies', 'kitchen-symbol']), + }, + { + key: 'hallway_wardrobe', + title: 'Шкаф-купе до потолка в прихожей с зеркальными фасадами', + project_type: 'wardrobe', + client_name: 'Антон и Мария С.', + location: 'Москва, Хамовники', + budget: 245000, + project_start_at: dateOffset(-68, 9), + project_end_at: dateOffset(-54, 16), + problem: + 'Узкая прихожая быстро захламлялась, а обычный шкаф визуально сужал пространство.', + solution: + 'Собрали шкаф-купе до потолка со светлыми фасадами, зеркалом, секцией под обувь и антресолью под сезонные вещи.', + results: + 'Хранение стало компактным, прихожая визуально расширилась, а клиент отметил удобство ежедневного использования.', + is_featured: true, + status: 'published', + product: productIds.wardrobes_to_ceiling, + importHash: makeImportHash(['case_studies', 'hallway-wardrobe']), + }, + { + key: 'master_bedroom_closet', + title: 'Гардеробная 8 м² с островной тумбой в мастер-спальне', + project_type: 'closet', + client_name: 'Екатерина В.', + location: 'Московская область, Истра', + budget: 430000, + project_start_at: dateOffset(-46, 8), + project_end_at: dateOffset(-31, 18), + problem: + 'Клиенту нужен был удобный сценарий хранения одежды, обуви и аксессуаров без ощущения тесноты.', + solution: + 'Разделили гардеробную на мужскую и женскую зоны, добавили островную тумбу, подсветку и высокие зеркала.', + results: + 'Комната стала функциональной и визуально лёгкой, а порядок поддерживается без лишних усилий.', + is_featured: true, + status: 'published', + product: productIds.walk_in_closets, + importHash: makeImportHash(['case_studies', 'master-bedroom-closet']), + }, + { + key: 'home_office_library', + title: 'Домашний кабинет с библиотекой и скрытой проводкой', + project_type: 'office', + client_name: 'Илья Н.', + location: 'Москва, Раменки', + budget: 315000, + project_start_at: dateOffset(-28, 9), + project_end_at: dateOffset(-16, 17), + problem: + 'Кабинет должен был выглядеть статусно на видеозвонках и одновременно быть удобным для ежедневной работы.', + solution: + 'Сделали библиотеку по всей стене, рабочий стол увеличенной глубины, кабель-каналы и акцентную подсветку.', + results: + 'Кабинет выглядит цельно и дорого, а рабочее место стало удобнее для длительной концентрации.', + is_featured: false, + status: 'published', + product: productIds.home_offices, + importHash: makeImportHash(['case_studies', 'home-office-library']), + }, + ]) { + const savedCaseStudy = await upsert('case_studies', caseStudies, caseStudyPayload, { + matchFields: ['title'], + labelFields: ['title'], + }); + + caseStudyIds[caseStudyPayload.key] = savedCaseStudy.id; + } + + const briefIds = {}; + for (const briefPayload of [ + { + key: 'lead_generation_june', + title: 'Июнь: заявки на кухни для новостроек', + goal: 'lead_generation', + audience: 'new_apartments', + key_offers: + 'Бесплатный замер, проект под размеры клиента, монтаж под ключ, подбор материалов под бюджет.', + constraints: + 'Не обещать нереалистичные сроки, не использовать агрессивные скидочные формулировки, держать премиальный тон.', + brand_voice: `${organizationName}: спокойный экспертный тон, короткие фразы, акцент на удобстве и процессе.`, + call_to_action: 'Напишите в сообщения, чтобы получить расчёт и записаться на замер.', + hashtags_seed: 'кухниназаказ мебельназаказ мебельподразмер дизайнкухни замербесплатно', + notes: + 'Нужны посты и reels, которые помогают собрать входящие заявки от клиентов из новых квартир и домов.', + status: 'active', + owner_user: ownerUserId, + importHash: makeImportHash(['content_briefs', 'lead-generation-june']), + }, + { + key: 'behind_scenes_series', + title: 'Серия: как создаётся мебель в цехе', + goal: 'brand_awareness', + audience: 'homeowners', + key_offers: + 'Показать производство, качество фурнитуры, этапы сборки и внимание к деталям.', + constraints: + 'Без личных данных сотрудников и клиентов, без лишнего технического жаргона.', + brand_voice: 'Тёплый уверенный тон мастеров, ощущение прозрачности и аккуратного ремесла.', + call_to_action: 'Сохраните пост и задайте вопрос по своему проекту в сообщениях.', + hashtags_seed: 'мебельныйцех производство мебели столярка мебельназаказ кейсыпомебели', + notes: + 'Подходит для reels и stories: показать процесс, детали, фурнитуру и чистоту производства.', + status: 'active', + owner_user: ownerUserId, + importHash: makeImportHash(['content_briefs', 'behind-scenes-series']), + }, + ]) { + const savedBrief = await upsert('content_briefs', contentBriefs, briefPayload, { + matchFields: ['title'], + labelFields: ['title'], + }); + + briefIds[briefPayload.key] = savedBrief.id; + } + + const ideaIds = {}; + for (const ideaPayload of [ + { + key: 'measurement_checklist', + title: 'Чек-лист: что подготовить перед бесплатным замером кухни', + format: 'carousel', + pillar: 'education', + hook: 'Этот чек-лист экономит время и помогает избежать дорогих переделок.', + outline: + 'Показать 5 шагов: размеры помещения, техника, розетки, желаемый стиль, сроки ремонта.', + suggested_visuals: + 'Минималистичный карусельный дизайн, схема кухни, 1 фото реализованного проекта.', + suggested_hashtags: 'замеркухни кухниназаказ планированиекуxни', + status: 'approved', + brief: briefIds.lead_generation_june, + importHash: makeImportHash(['content_ideas', 'measurement-checklist']), + }, + { + key: 'kitchen_reel', + title: 'Reel: кухня с островом — до/после за 25 секунд', + format: 'reel', + pillar: 'portfolio', + hook: 'Из пустой коробки — в кухню, где хочется жить каждый день.', + outline: + 'Короткий таймлайн: пустое помещение, монтаж, подсветка, остров, финальный широкий кадр.', + suggested_visuals: + 'Вертикальное видео 9:16, монтаж с быстрыми склейками, крупные планы фурнитуры и света.', + suggested_hashtags: 'допосле кухнясостровом мебельназаказ reel', + status: 'approved', + brief: briefIds.behind_scenes_series, + importHash: makeImportHash(['content_ideas', 'kitchen-reel']), + }, + { + key: 'facades_faq', + title: 'Пост: как выбрать материал фасадов без переплаты', + format: 'post', + pillar: 'faq', + hook: 'Разница в фасадах — не только во внешнем виде, но и в сценарии использования.', + outline: + 'Сравнить МДФ в эмали, шпон и HPL по внешнему виду, стойкости, стоимости и уходу.', + suggested_visuals: + 'Карточка-сравнение материалов и фото реальных фасадов крупным планом.', + suggested_hashtags: 'фасадыкухни мдфэмаль шпон hpl мебельсоветы', + status: 'approved', + brief: briefIds.lead_generation_june, + importHash: makeImportHash(['content_ideas', 'facades-faq']), + }, + { + key: 'wardrobe_stories', + title: 'Stories: 3 причины выбрать шкаф до потолка', + format: 'story', + pillar: 'faq', + hook: 'Когда хранение работает лучше, чем кажется на первый взгляд.', + outline: + 'Показать выгоду по объёму хранения, визуальной чистоте и удобству уборки.', + suggested_visuals: + 'Три коротких вертикальных кадра: общий план, антресоль, внутреннее наполнение.', + suggested_hashtags: 'шкафкупе хранениевквартире мебельдляприхожей', + status: 'approved', + brief: briefIds.lead_generation_june, + importHash: makeImportHash(['content_ideas', 'wardrobe-stories']), + }, + ]) { + const savedIdea = await upsert('content_ideas', contentIdeas, ideaPayload, { + matchFields: ['title'], + labelFields: ['title'], + }); + + ideaIds[ideaPayload.key] = savedIdea.id; + } + + const planItemIds = {}; + for (const planItemPayload of [ + { + key: 'checklist_carousel', + content_type: 'carousel', + scheduled_at: dateOffset(1, 10), + publish_window_end_at: dateOffset(1, 18), + status: 'ready', + title: 'Карусель: что подготовить к замеру кухни', + notes: 'Нужен сильный CTA в последнем слайде: заявка на бесплатный замер.', + channel: channels.telegram_channel, + idea: ideaIds.measurement_checklist, + case_study: caseStudyIds.kitchen_symbol, + importHash: makeImportHash(['content_plan_items', 'checklist-carousel']), + }, + { + key: 'kitchen_reel_publish', + content_type: 'reel', + scheduled_at: dateOffset(3, 18, 30), + publish_window_end_at: dateOffset(3, 21), + status: 'scheduled', + title: 'Reel: трансформация кухни с островом', + notes: 'Добавить подписи по этапам монтажа и вынести в первые 2 секунды финальный кадр.', + channel: channels.instagram_showroom, + idea: ideaIds.kitchen_reel, + case_study: caseStudyIds.kitchen_symbol, + importHash: makeImportHash(['content_plan_items', 'kitchen-reel-publish']), + }, + { + key: 'facades_post', + content_type: 'post', + scheduled_at: dateOffset(5, 12), + publish_window_end_at: dateOffset(5, 19), + status: 'planned', + title: 'Пост: как выбрать фасады без переплаты', + notes: 'Сделать в конце короткий блок “что выбрать под ваш сценарий использования”.', + channel: channels.vk_local, + idea: ideaIds.facades_faq, + case_study: caseStudyIds.kitchen_symbol, + importHash: makeImportHash(['content_plan_items', 'facades-post']), + }, + { + key: 'wardrobe_story_pack', + content_type: 'story', + scheduled_at: dateOffset(7, 17), + publish_window_end_at: dateOffset(7, 21), + status: 'drafting', + title: 'Stories: 3 мифа о шкафах до потолка', + notes: 'Финальный сторис с вопросом “рассчитать ваш шкаф?”.', + channel: channels.instagram_showroom, + idea: ideaIds.wardrobe_stories, + case_study: caseStudyIds.hallway_wardrobe, + importHash: makeImportHash(['content_plan_items', 'wardrobe-story-pack']), + }, + ]) { + const savedPlanItem = await upsert('content_plan_items', contentPlanItems, planItemPayload, { + matchFields: ['title'], + labelFields: ['title'], + }); + + planItemIds[planItemPayload.key] = savedPlanItem.id; + } + + for (const generatedContentPayload of [ + { + kind: 'post_caption', + language: 'ru', + prompt: + 'Сделай продающий пост о подготовке к замеру кухни на заказ для аудитории новостроек в Москве.', + output_text: [ + 'Перед замером кухни важно подготовить не только размеры помещения, но и сценарий жизни семьи.', + 'Сохраните этот чек-лист: техника, розетки, любимые материалы, сроки ремонта и желаемый стиль.', + 'Чем точнее вводные, тем быстрее мы соберём проект без лишних переделок и сюрпризов по бюджету.', + 'Напишите в сообщения — подскажем, что лучше подготовить именно для вашей кухни.', + ].join('\n\n'), + hashtags: '#кухниназаказ #замеркухни #дизайнкухни #мебельподразмер #кухнямечты', + status: 'approved', + generated_at: dateOffset(-1, 9), + brief: briefIds.lead_generation_june, + plan_item: planItemIds.checklist_carousel, + importHash: makeImportHash(['generated_contents', 'caption-checklist']), + }, + { + kind: 'reel_script', + language: 'ru', + prompt: 'Сделай короткий сценарий reels про кухню с островом: до/после, монтаж, детали, CTA.', + output_text: [ + 'Сцена 1: пустое помещение — текст на экране “Когда кухня ещё только в планах”.', + 'Сцена 2: монтаж пеналов и острова — “Работаем под размеры помещения, а не по шаблону”.', + 'Сцена 3: крупные планы фурнитуры и подсветки — “Детали, которые ощущаются каждый день”.', + 'Сцена 4: финальный общий кадр — “Хотите такой же проект? Напишите в сообщения”.', + ].join('\n\n'), + hashtags: '#reelмебель #кухнясостровом #допосле #мебельназаказ', + status: 'approved', + generated_at: dateOffset(-1, 14), + brief: briefIds.behind_scenes_series, + plan_item: planItemIds.kitchen_reel_publish, + importHash: makeImportHash(['generated_contents', 'reel-kitchen']), + }, + { + kind: 'hashtags', + language: 'ru', + prompt: 'Подбери хэштеги для поста о выборе фасадов кухни без переплаты.', + output_text: + 'Подборка хэштегов для поста о фасадах кухни: сочетание поисковых, локальных и экспертных тегов.', + hashtags: + '#фасадыкухни #мдфэмаль #шпон #hpl #кухниназаказ #мебельсоветы #ремонтквартиры #дизайнинтерьера', + status: 'draft', + generated_at: dateOffset(-2, 11), + brief: briefIds.lead_generation_june, + plan_item: planItemIds.facades_post, + importHash: makeImportHash(['generated_contents', 'hashtags-facades']), + }, + { + kind: 'dm_reply', + language: 'ru', + prompt: 'Сделай короткий ответ на входящий лид, который спрашивает стоимость шкафа до потолка.', + output_text: [ + 'Здравствуйте! Стоимость шкафа зависит от размеров, наполнения и материалов фасадов.', + 'Если отправите примерные размеры или фото ниши, мы быстро дадим ориентир и предложим удобный вариант под ваш бюджет.', + ].join('\n\n'), + hashtags: '#ответлиду #продаживдирект', + status: 'approved', + generated_at: dateOffset(-3, 16), + brief: briefIds.lead_generation_june, + plan_item: planItemIds.wardrobe_story_pack, + importHash: makeImportHash(['generated_contents', 'dm-reply-wardrobe']), + }, + ]) { + await upsert('generated_contents', generatedContents, generatedContentPayload, { + matchFields: ['importHash'], + labelFields: ['kind', 'importHash'], + }); + } + + const leadIds = {}; + for (const leadPayload of [ + { + key: 'anna_kitchen', + source_platform: 'instagram', + source_detail: 'Входящий после reels про кухню с островом', + full_name: 'Анна Петрова', + phone: '+7 999 111-22-33', + email: 'anna.pet.demo@example.com', + messenger_handle: '@anna_home_project', + city: 'Москва', + interest_category: 'kitchen', + estimated_budget: 650000, + requested_at: dateOffset(-1, 8, 30), + next_follow_up_at: dateOffset(1, 12), + status: 'new', + notes: + 'Квартира в новостройке, нужна кухня с островом и местом под встроенную технику. Просит ориентир по срокам.', + assigned_to: ownerUserId, + importHash: makeImportHash(['leads', 'anna-kitchen']), + }, + { + key: 'ilya_wardrobe', + source_platform: 'website', + source_detail: 'Форма заявки на странице шкафов-купе', + full_name: 'Илья Смирнов', + phone: '+7 999 222-33-44', + email: 'ilya.wardrobe.demo@example.com', + messenger_handle: '@ilya_flat', + city: 'Химки', + interest_category: 'wardrobe', + estimated_budget: 230000, + requested_at: dateOffset(-2, 13, 10), + next_follow_up_at: dateOffset(1, 15), + status: 'contacted', + notes: + 'Интересует шкаф-купе до потолка в прихожую. Уже отправил размеры ниши и хочет понять разницу по материалам.', + assigned_to: ownerUserId, + importHash: makeImportHash(['leads', 'ilya-wardrobe']), + }, + { + key: 'ekaterina_closet', + source_platform: 'telegram', + source_detail: 'Личный диалог после кейса про гардеробную', + full_name: 'Екатерина Волкова', + phone: '+7 999 333-44-55', + email: 'ekaterina.closet.demo@example.com', + messenger_handle: '@ekaterina_style_home', + city: 'Истра', + interest_category: 'closet', + estimated_budget: 420000, + requested_at: dateOffset(-4, 10), + next_follow_up_at: dateOffset(2, 11), + status: 'proposal_sent', + notes: + 'Запрос на гардеробную в мастер-спальне. Коммерческое предложение отправлено, ждём согласование по наполнению.', + assigned_to: ownerUserId, + importHash: makeImportHash(['leads', 'ekaterina-closet']), + }, + { + key: 'olga_office', + source_platform: 'vk', + source_detail: 'Сообщение после поста про домашние кабинеты', + full_name: 'Ольга Миронова', + phone: '+7 999 444-55-66', + email: 'olga.office.demo@example.com', + messenger_handle: '@olga_remote_work', + city: 'Москва', + interest_category: 'office', + estimated_budget: 310000, + requested_at: dateOffset(-5, 9, 40), + next_follow_up_at: dateOffset(3, 10), + status: 'qualified', + notes: + 'Нужен кабинет с библиотекой и скрытой проводкой. Клиент готов к замеру после согласования сроков ремонта.', + assigned_to: ownerUserId, + importHash: makeImportHash(['leads', 'olga-office']), + }, + ]) { + const savedLead = await upsert('leads', leads, leadPayload, { + matchFields: ['email'], + labelFields: ['full_name'], + }); + + leadIds[leadPayload.key] = savedLead.id; + } + + for (const leadActivityPayload of [ + { + activity_type: 'task', + occurred_at: null, + due_at: dateOffset(1, 12), + outcome: 'planned', + summary: 'Связаться с Анной и уточнить размеры кухни', + details: + 'Нужно запросить план квартиры, список техники и желаемые сроки установки, затем назначить бесплатный замер.', + lead: leadIds.anna_kitchen, + author_user: ownerUserId, + organizations: organizationId, + importHash: makeImportHash(['lead_activities', 'anna-follow-up']), + }, + { + activity_type: 'message', + occurred_at: dateOffset(0, 10, 15), + due_at: null, + outcome: 'done', + summary: 'Отправлены варианты наполнения для шкафа Илье', + details: + 'Клиенту отправлены 2 варианта наполнения и пояснение по разнице между ЛДСП и МДФ в эмали.', + lead: leadIds.ilya_wardrobe, + author_user: ownerUserId, + organizations: organizationId, + importHash: makeImportHash(['lead_activities', 'ilya-options-sent']), + }, + { + activity_type: 'meeting', + occurred_at: null, + due_at: dateOffset(2, 11), + outcome: 'planned', + summary: 'Созвон по КП с Екатериной', + details: + 'Подготовить 2 сценария наполнения гардеробной и пройтись по бюджету перед финальным подтверждением.', + lead: leadIds.ekaterina_closet, + author_user: ownerUserId, + organizations: organizationId, + importHash: makeImportHash(['lead_activities', 'ekaterina-proposal-call']), + }, + { + activity_type: 'call', + occurred_at: dateOffset(-1, 17, 20), + due_at: null, + outcome: 'done', + summary: 'Первичный звонок по кабинету для Ольги', + details: + 'Обсудили задачи по хранению документов, видеозвонкам и скрытой проводке. Следующий шаг — замер после готовности стен.', + lead: leadIds.olga_office, + author_user: ownerUserId, + organizations: organizationId, + importHash: makeImportHash(['lead_activities', 'olga-intro-call']), + }, + ]) { + await upsert('lead_activities', leadActivities, leadActivityPayload, { + matchFields: ['importHash'], + labelFields: ['summary'], + }); + } + + console.log('\nDemo data sync complete.'); + Object.entries(summary).forEach(([resource, stats]) => { + console.log(`- ${resource}: created ${stats.created}, updated ${stats.updated}`); + }); +} + +main().catch((error) => { + console.error('Furniture demo data seeding failed.'); + console.error(error.stack || error.message || error); + process.exit(1); +}); diff --git a/backend/src/db/api/ai_agent_settings.js b/backend/src/db/api/ai_agent_settings.js index 511e51f..48a49f8 100644 --- a/backend/src/db/api/ai_agent_settings.js +++ b/backend/src/db/api/ai_agent_settings.js @@ -1,7 +1,5 @@ const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); const Utils = require('../utils'); @@ -87,7 +85,7 @@ module.exports = class Ai_agent_settingsDBApi { ); - await ai_agent_settings.setOrganization(currentUser.organization.id || null, { + await ai_agent_settings.setOrganization(currentUser.organization?.id || currentUser.organizations?.id || null, { transaction, }); @@ -235,7 +233,7 @@ module.exports = class Ai_agent_settingsDBApi { if (data.organization !== undefined) { await ai_agent_settings.setOrganization( - (globalAccess ? data.organization : currentUser.organization.id), + (globalAccess ? data.organization : (currentUser.organization?.id || currentUser.organizations?.id)), { transaction } ); @@ -357,16 +355,13 @@ module.exports = class Ai_agent_settingsDBApi { if (userOrganizations) { if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; + where.organizationId = options.currentUser.organizationsId; } } offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; let include = [ diff --git a/backend/src/db/api/analytics_snapshots.js b/backend/src/db/api/analytics_snapshots.js index 7a10eee..8479f20 100644 --- a/backend/src/db/api/analytics_snapshots.js +++ b/backend/src/db/api/analytics_snapshots.js @@ -1,7 +1,5 @@ const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); const Utils = require('../utils'); @@ -89,7 +87,7 @@ module.exports = class Analytics_snapshotsDBApi { ); - await analytics_snapshots.setOrganization(currentUser.organization.id || null, { + await analytics_snapshots.setOrganization(currentUser.organization?.id || currentUser.organizations?.id || null, { transaction, }); @@ -246,7 +244,7 @@ module.exports = class Analytics_snapshotsDBApi { if (data.organization !== undefined) { await analytics_snapshots.setOrganization( - (globalAccess ? data.organization : currentUser.organization.id), + (globalAccess ? data.organization : (currentUser.organization?.id || currentUser.organizations?.id)), { transaction } ); @@ -382,16 +380,13 @@ module.exports = class Analytics_snapshotsDBApi { if (userOrganizations) { if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; + where.organizationId = options.currentUser.organizationsId; } } offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; let include = [ diff --git a/backend/src/db/api/case_studies.js b/backend/src/db/api/case_studies.js index 9bd7612..fd29df8 100644 --- a/backend/src/db/api/case_studies.js +++ b/backend/src/db/api/case_studies.js @@ -1,7 +1,6 @@ const db = require('../models'); const FileDBApi = require('./file'); -const crypto = require('crypto'); const Utils = require('../utils'); @@ -90,7 +89,7 @@ module.exports = class Case_studiesDBApi { ); - await case_studies.setOrganization(currentUser.organization.id || null, { + await case_studies.setOrganization(currentUser.organization?.id || currentUser.organizations?.id || null, { transaction, }); @@ -292,7 +291,7 @@ module.exports = class Case_studiesDBApi { if (data.organization !== undefined) { await case_studies.setOrganization( - (globalAccess ? data.organization : currentUser.organization.id), + (globalAccess ? data.organization : (currentUser.organization?.id || currentUser.organizations?.id)), { transaction } ); @@ -462,16 +461,13 @@ module.exports = class Case_studiesDBApi { if (userOrganizations) { if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; + where.organizationId = options.currentUser.organizationsId; } } offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; let include = [ diff --git a/backend/src/db/api/content_assets.js b/backend/src/db/api/content_assets.js index 866ec1b..44acea3 100644 --- a/backend/src/db/api/content_assets.js +++ b/backend/src/db/api/content_assets.js @@ -1,7 +1,6 @@ const db = require('../models'); const FileDBApi = require('./file'); -const crypto = require('crypto'); const Utils = require('../utils'); @@ -55,7 +54,7 @@ module.exports = class Content_assetsDBApi { ); - await content_assets.setOrganization(currentUser.organization.id || null, { + await content_assets.setOrganization(currentUser.organization?.id || currentUser.organizations?.id || null, { transaction, }); @@ -201,7 +200,7 @@ module.exports = class Content_assetsDBApi { if (data.organization !== undefined) { await content_assets.setOrganization( - (globalAccess ? data.organization : currentUser.organization.id), + (globalAccess ? data.organization : (currentUser.organization?.id || currentUser.organizations?.id)), { transaction } ); @@ -367,16 +366,13 @@ module.exports = class Content_assetsDBApi { if (userOrganizations) { if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; + where.organizationId = options.currentUser.organizationsId; } } offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; let include = [ diff --git a/backend/src/db/api/content_briefs.js b/backend/src/db/api/content_briefs.js index 71c17ec..d598bf4 100644 --- a/backend/src/db/api/content_briefs.js +++ b/backend/src/db/api/content_briefs.js @@ -1,7 +1,5 @@ const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); const Utils = require('../utils'); @@ -79,7 +77,7 @@ module.exports = class Content_briefsDBApi { ); - await content_briefs.setOrganization(currentUser.organization.id || null, { + await content_briefs.setOrganization(currentUser.organization?.id || currentUser.organizations?.id || null, { transaction, }); @@ -220,7 +218,7 @@ module.exports = class Content_briefsDBApi { if (data.organization !== undefined) { await content_briefs.setOrganization( - (globalAccess ? data.organization : currentUser.organization.id), + (globalAccess ? data.organization : (currentUser.organization?.id || currentUser.organizations?.id)), { transaction } ); @@ -364,16 +362,13 @@ module.exports = class Content_briefsDBApi { if (userOrganizations) { if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; + where.organizationId = options.currentUser.organizationsId; } } offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; let include = [ diff --git a/backend/src/db/api/content_ideas.js b/backend/src/db/api/content_ideas.js index 6d89a29..06c7f32 100644 --- a/backend/src/db/api/content_ideas.js +++ b/backend/src/db/api/content_ideas.js @@ -1,7 +1,5 @@ const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); const Utils = require('../utils'); @@ -69,7 +67,7 @@ module.exports = class Content_ideasDBApi { ); - await content_ideas.setOrganization(currentUser.organization.id || null, { + await content_ideas.setOrganization(currentUser.organization?.id || currentUser.organizations?.id || null, { transaction, }); @@ -194,7 +192,7 @@ module.exports = class Content_ideasDBApi { if (data.organization !== undefined) { await content_ideas.setOrganization( - (globalAccess ? data.organization : currentUser.organization.id), + (globalAccess ? data.organization : (currentUser.organization?.id || currentUser.organizations?.id)), { transaction } ); @@ -334,16 +332,13 @@ module.exports = class Content_ideasDBApi { if (userOrganizations) { if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; + where.organizationId = options.currentUser.organizationsId; } } offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; let include = [ diff --git a/backend/src/db/api/content_plan_items.js b/backend/src/db/api/content_plan_items.js index f525c2d..4d0fef8 100644 --- a/backend/src/db/api/content_plan_items.js +++ b/backend/src/db/api/content_plan_items.js @@ -1,7 +1,5 @@ const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); const Utils = require('../utils'); @@ -59,7 +57,7 @@ module.exports = class Content_plan_itemsDBApi { ); - await content_plan_items.setOrganization(currentUser.organization.id || null, { + await content_plan_items.setOrganization(currentUser.organization?.id || currentUser.organizations?.id || null, { transaction, }); @@ -176,7 +174,7 @@ module.exports = class Content_plan_itemsDBApi { if (data.organization !== undefined) { await content_plan_items.setOrganization( - (globalAccess ? data.organization : currentUser.organization.id), + (globalAccess ? data.organization : (currentUser.organization?.id || currentUser.organizations?.id)), { transaction } ); @@ -352,16 +350,13 @@ module.exports = class Content_plan_itemsDBApi { if (userOrganizations) { if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; + where.organizationId = options.currentUser.organizationsId; } } offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; let include = [ diff --git a/backend/src/db/api/generated_contents.js b/backend/src/db/api/generated_contents.js index c4f4f02..fde9fc2 100644 --- a/backend/src/db/api/generated_contents.js +++ b/backend/src/db/api/generated_contents.js @@ -1,7 +1,5 @@ const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); const Utils = require('../utils'); @@ -64,7 +62,7 @@ module.exports = class Generated_contentsDBApi { ); - await generated_contents.setOrganization(currentUser.organization.id || null, { + await generated_contents.setOrganization(currentUser.organization?.id || currentUser.organizations?.id || null, { transaction, }); @@ -185,7 +183,7 @@ module.exports = class Generated_contentsDBApi { if (data.organization !== undefined) { await generated_contents.setOrganization( - (globalAccess ? data.organization : currentUser.organization.id), + (globalAccess ? data.organization : (currentUser.organization?.id || currentUser.organizations?.id)), { transaction } ); @@ -335,16 +333,13 @@ module.exports = class Generated_contentsDBApi { if (userOrganizations) { if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; + where.organizationId = options.currentUser.organizationsId; } } offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; let include = [ diff --git a/backend/src/db/api/leads.js b/backend/src/db/api/leads.js index 0933df7..9bea7aa 100644 --- a/backend/src/db/api/leads.js +++ b/backend/src/db/api/leads.js @@ -1,7 +1,5 @@ const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); const Utils = require('../utils'); @@ -94,7 +92,7 @@ module.exports = class LeadsDBApi { ); - await leads.setOrganization(currentUser.organization.id || null, { + await leads.setOrganization(currentUser.organization?.id || currentUser.organizations?.id || null, { transaction, }); @@ -259,7 +257,7 @@ module.exports = class LeadsDBApi { if (data.organization !== undefined) { await leads.setOrganization( - (globalAccess ? data.organization : currentUser.organization.id), + (globalAccess ? data.organization : (currentUser.organization?.id || currentUser.organizations?.id)), { transaction } ); @@ -399,16 +397,13 @@ module.exports = class LeadsDBApi { if (userOrganizations) { if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; + where.organizationId = options.currentUser.organizationsId; } } offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; let include = [ diff --git a/backend/src/db/api/products.js b/backend/src/db/api/products.js index 50833ba..fe9e40a 100644 --- a/backend/src/db/api/products.js +++ b/backend/src/db/api/products.js @@ -1,7 +1,6 @@ const db = require('../models'); const FileDBApi = require('./file'); -const crypto = require('crypto'); const Utils = require('../utils'); @@ -65,7 +64,7 @@ module.exports = class ProductsDBApi { ); - await products.setOrganization(currentUser.organization.id || null, { + await products.setOrganization(currentUser.organization?.id || currentUser.organizations?.id || null, { transaction, }); @@ -201,7 +200,7 @@ module.exports = class ProductsDBApi { if (data.organization !== undefined) { await products.setOrganization( - (globalAccess ? data.organization : currentUser.organization.id), + (globalAccess ? data.organization : (currentUser.organization?.id || currentUser.organizations?.id)), { transaction } ); @@ -342,16 +341,13 @@ module.exports = class ProductsDBApi { if (userOrganizations) { if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; + where.organizationId = options.currentUser.organizationsId; } } offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; let include = [ diff --git a/backend/src/db/api/publishing_logs.js b/backend/src/db/api/publishing_logs.js index 2ac6e40..449f756 100644 --- a/backend/src/db/api/publishing_logs.js +++ b/backend/src/db/api/publishing_logs.js @@ -1,7 +1,5 @@ const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); const Utils = require('../utils'); @@ -59,7 +57,7 @@ module.exports = class Publishing_logsDBApi { ); - await publishing_logs.setOrganization(currentUser.organization.id || null, { + await publishing_logs.setOrganization(currentUser.organization?.id || currentUser.organizations?.id || null, { transaction, }); @@ -172,7 +170,7 @@ module.exports = class Publishing_logsDBApi { if (data.organization !== undefined) { await publishing_logs.setOrganization( - (globalAccess ? data.organization : currentUser.organization.id), + (globalAccess ? data.organization : (currentUser.organization?.id || currentUser.organizations?.id)), { transaction } ); @@ -322,16 +320,13 @@ module.exports = class Publishing_logsDBApi { if (userOrganizations) { if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; + where.organizationId = options.currentUser.organizationsId; } } offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; let include = [ diff --git a/backend/src/db/api/social_channels.js b/backend/src/db/api/social_channels.js index 1515b81..94da5c1 100644 --- a/backend/src/db/api/social_channels.js +++ b/backend/src/db/api/social_channels.js @@ -1,7 +1,5 @@ const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); const Utils = require('../utils'); @@ -60,7 +58,7 @@ module.exports = class Social_channelsDBApi { ); - await social_channels.setOrganization(currentUser.organization.id || null, { + await social_channels.setOrganization(currentUser.organization?.id || currentUser.organizations?.id || null, { transaction, }); @@ -166,7 +164,7 @@ module.exports = class Social_channelsDBApi { if (data.organization !== undefined) { await social_channels.setOrganization( - (globalAccess ? data.organization : currentUser.organization.id), + (globalAccess ? data.organization : (currentUser.organization?.id || currentUser.organizations?.id)), { transaction } ); @@ -300,16 +298,13 @@ module.exports = class Social_channelsDBApi { if (userOrganizations) { if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; + where.organizationId = options.currentUser.organizationsId; } } offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; let include = [ diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index 259b378..7694faf 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -3,10 +3,8 @@ import { mdiLogout, mdiClose } from '@mdi/js' import BaseIcon from './BaseIcon' import AsideMenuList from './AsideMenuList' import { MenuAsideItem } from '../interfaces' -import { useAppSelector } from '../stores/hooks' import Link from 'next/link'; - -import { useAppDispatch } from '../stores/hooks'; +import { useAppDispatch, useAppSelector } from '../stores/hooks'; import { createAsyncThunk } from '@reduxjs/toolkit'; import axios from 'axios'; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 2235c3c..8b1803b 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -7,6 +7,12 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiViewDashboardOutline, label: 'Dashboard', }, + { + href: '/agent-studio', + label: 'Agent Studio', + icon: 'mdiRobotOutline' in icon ? icon['mdiRobotOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, + permissions: ['CREATE_CONTENT_BRIEFS', 'CREATE_GENERATED_CONTENTS', 'CREATE_CONTENT_PLAN_ITEMS', 'READ_GENERATED_CONTENTS'] + }, { href: '/users/users-list', diff --git a/frontend/src/pages/agent-studio.tsx b/frontend/src/pages/agent-studio.tsx new file mode 100644 index 0000000..4578265 --- /dev/null +++ b/frontend/src/pages/agent-studio.tsx @@ -0,0 +1,2197 @@ +import { + mdiAccountArrowRight, + mdiAccountGroupOutline, + mdiArrowRight, + mdiArrowTopRight, + mdiBullhornOutline, + mdiCalendarMonth, + mdiChartTimelineVariant, + mdiCheckCircleOutline, + mdiLightbulbOn, + mdiRobotOutline, + mdiStarFourPointsOutline, +} from '@mdi/js'; +import axios from 'axios'; +import Head from 'next/head'; +import Link from 'next/link'; +import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react'; +import BaseButton from '../components/BaseButton'; +import BaseButtons from '../components/BaseButtons'; +import BaseDivider from '../components/BaseDivider'; +import BaseIcon from '../components/BaseIcon'; +import CardBox from '../components/CardBox'; +import FormField from '../components/FormField'; +import NotificationBar from '../components/NotificationBar'; +import SectionMain from '../components/SectionMain'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../config'; +import { hasPermission } from '../helpers/userPermissions'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import { aiResponse as requestAiResponse } from '../stores/openAiSlice'; +import { useAppDispatch, useAppSelector } from '../stores/hooks'; + +type GoalValue = 'lead_generation' | 'brand_awareness' | 'engagement' | 'sales' | 'recruiting'; +type AudienceValue = 'homeowners' | 'new_apartments' | 'families' | 'businesses' | 'designers' | 'other'; +type ContentTypeValue = 'post' | 'story' | 'reel' | 'carousel'; +type LanguageValue = 'ru' | 'en'; +type GeneratedKindValue = 'post_caption' | 'story_script' | 'reel_script'; + +type AgentFormState = { + brandName: string; + city: string; + campaignTitle: string; + channelId: string; + productId: string; + caseStudyId: string; + goal: GoalValue; + audience: AudienceValue; + format: ContentTypeValue; + tone: string; + keyOffer: string; + callToAction: string; + language: LanguageValue; + publishAt: string; + notes: string; +}; + +type ChannelRecord = { + id: string; + platform?: string | null; + account_name?: string | null; + account_url?: string | null; + is_active?: boolean; +}; + +type ProductRecord = { + id: string; + category?: string | null; + name?: string | null; + description?: string | null; + features?: string | null; +}; + +type CaseStudyRecord = { + id: string; + title?: string | null; + project_type?: string | null; + location?: string | null; + solution?: string | null; + results?: string | null; + is_featured?: boolean; +}; + +type LeadRecord = { + id: string; + full_name?: string | null; + source_platform?: string | null; + status?: string | null; + interest_category?: string | null; + requested_at?: string | null; + createdAt?: string | null; +}; + +type GeneratedContentRecord = { + id: string; + kind?: string | null; + status?: string | null; + hashtags?: string | null; + output_text?: string | null; + generated_at?: string | null; + createdAt?: string | null; + brief?: { + title?: string | null; + } | null; + plan_item?: { + title?: string | null; + } | null; +}; + +type PlanItemRecord = { + id: string; + title?: string | null; + content_type?: string | null; + status?: string | null; + scheduled_at?: string | null; + channel?: { + account_name?: string | null; + platform?: string | null; + } | null; +}; + +type WorkspaceState = { + loading: boolean; + channels: ChannelRecord[]; + products: ProductRecord[]; + caseStudies: CaseStudyRecord[]; + recentDrafts: GeneratedContentRecord[]; + upcomingPlanItems: PlanItemRecord[]; + recentLeads: LeadRecord[]; + totals: { + drafts: number; + planItems: number; + leads: number; + }; +}; + +type GeneratedDraft = { + briefTitle: string; + scheduleTitle: string; + contentTitle: string; + hook: string; + caption: string; + storySlides: string[]; + hashtags: string[]; + cta: string; + leadDmReply: string; + imageBrief: string; +}; + +type SaveResult = { + briefId: string; + planItemId: string; + generatedContentId: string; +}; + +type AiResponseContent = { + type?: string; + text?: string; +}; + +type AiResponseOutput = { + type?: string; + content?: AiResponseContent[]; +}; + +type AiProxyResponse = { + data?: { + output?: AiResponseOutput[]; + }; +}; + +type PromptContext = { + channel?: ChannelRecord; + product?: ProductRecord; + caseStudy?: CaseStudyRecord; +}; + +type AgentTemplateFocus = 'content' | 'lead_reply'; + +type AgentTemplatePreset = { + id: string; + badge: string; + title: string; + description: string; + campaignTitle: string; + goal: GoalValue; + audience: AudienceValue; + format: ContentTypeValue; + tone: string; + keyOffer: string; + callToAction: string; + language: LanguageValue; + notes: string; + channelPlatforms?: string[]; + productKeywords?: string[]; + caseStudyKeywords?: string[]; + leadStatuses?: string[]; + leadSourcePlatforms?: string[]; + leadInterestKeywords?: string[]; + focus?: AgentTemplateFocus; +}; + +const AGENT_TEMPLATE_PRESETS: AgentTemplatePreset[] = [ + { + id: 'kitchens-new-buildings', + badge: 'Лиды', + title: 'Кухни для новых квартир', + description: 'Готовый шаблон для поста или карусели с оффером на замер, проект и расчёт кухни под размеры.', + campaignTitle: 'Кухни на заказ для новых квартир: бесплатный замер и проект под размеры', + goal: 'lead_generation', + audience: 'new_apartments', + format: 'carousel', + tone: + 'Премиально, спокойно и экспертно. Объяснять выгоду простым языком без агрессивных скидок и шаблонных обещаний.', + keyOffer: + 'Бесплатный замер, проект под размеры клиента, подбор материалов под бюджет, монтаж под ключ и аккуратная эргономика.', + callToAction: 'Напишите в сообщения, чтобы получить расчёт кухни и записаться на замер.', + language: 'ru', + notes: + 'Сделать упор на новые квартиры, удобство хранения, встроенную технику и сопровождение от проекта до монтажа.', + channelPlatforms: ['instagram'], + productKeywords: ['кухн'], + caseStudyKeywords: ['остров', 'символ'], + }, + { + id: 'kitchen-island-reel', + badge: 'Reel', + title: 'Reel: кухня с островом до/после', + description: 'Вертикальный сценарий для Reels с акцентом на трансформацию пространства, монтаж и финальный результат.', + campaignTitle: 'Reel: кухня с островом — до/после за 25 секунд', + goal: 'brand_awareness', + audience: 'homeowners', + format: 'reel', + tone: + 'Современно, визуально и премиально. Короткие фразы, сильный hook в начале, ощущение красивой трансформации.', + keyOffer: + 'Показываем реальный кейс: проект, монтаж, подсветка, остров, детали фурнитуры и финальную атмосферу кухни.', + callToAction: 'Напишите в директ, если хотите похожую кухню под ваш метраж и сценарий жизни.', + language: 'ru', + notes: + 'Нужны 3–5 коротких сцен, ритм reel и упор на визуальные детали, которые хочется досмотреть до конца.', + channelPlatforms: ['instagram'], + productKeywords: ['кухн'], + caseStudyKeywords: ['остров', 'символ'], + }, + { + id: 'wardrobe-ceiling-stories', + badge: 'Stories', + title: 'Stories: шкаф до потолка', + description: 'Сторис-шаблон про хранение, порядок и визуальную чистоту прихожей или спальни.', + campaignTitle: 'Stories: 3 причины выбрать шкаф до потолка в прихожую', + goal: 'lead_generation', + audience: 'families', + format: 'story', + tone: + 'Понятно, тепло и уверенно. Показывать повседневную пользу: больше хранения, проще уборка, аккуратный интерьер.', + keyOffer: + 'Максимум хранения, антресоли, точная подгонка под геометрию помещения, зеркальные фасады и чистый визуальный ряд.', + callToAction: 'Напишите, чтобы получить расчёт шкафа-купе под вашу нишу и сценарий хранения.', + language: 'ru', + notes: + 'Сделать акцент на семье, сезонных вещах, обуви и порядке без визуального шума. Избегать кричащих скидок.', + channelPlatforms: ['telegram', 'vk'], + productKeywords: ['шкаф', 'wardrobe'], + caseStudyKeywords: ['прихож', 'зеркальн'], + }, + { + id: 'facades-faq-post', + badge: 'FAQ', + title: 'FAQ: материалы фасадов без переплаты', + description: 'Образовательный шаблон для прогрева аудитории и снятия возражений перед заявкой.', + campaignTitle: 'Пост: как выбрать материал фасадов без переплаты', + goal: 'lead_generation', + audience: 'homeowners', + format: 'post', + tone: + 'Экспертно и понятно. Объяснять разницу между материалами простым языком без перегруза техническими терминами.', + keyOffer: + 'Помогаем подобрать фасады по бюджету, износостойкости и визуальному стилю, без переплаты за лишние опции.', + callToAction: 'Сохраните пост и напишите нам, чтобы подобрать материалы под ваш проект.', + language: 'ru', + notes: + 'Сравнить МДФ в эмали, шпон и HPL. Добавить доверие через реальные примеры, а не абстрактные обещания.', + channelPlatforms: ['telegram'], + productKeywords: ['кухн'], + caseStudyKeywords: ['кухн', 'символ'], + }, + { + id: 'walk-in-closet-premium', + badge: 'Премиум', + title: 'Гардеробная под размер', + description: 'Премиальный шаблон для гардеробной с акцентом на сценарии хранения, свет и ощущение порядка.', + campaignTitle: 'Гардеробная под размер: порядок, свет и сценарии хранения', + goal: 'sales', + audience: 'homeowners', + format: 'carousel', + tone: + 'Премиально, статусно, но без снобизма. Делать акцент на удобстве, порядке и ощущении спокойствия в спальне.', + keyOffer: + 'Индивидуальная планировка, островная тумба, подсветка, высокие зеркала и секции под одежду, обувь и аксессуары.', + callToAction: 'Оставьте запрос, чтобы получить планировку гардеробной под ваш метраж.', + language: 'ru', + notes: + 'Показать, как гардеробная экономит время, делает комнату спокойнее визуально и поддерживает порядок каждый день.', + channelPlatforms: ['instagram'], + productKeywords: ['гардероб'], + caseStudyKeywords: ['гардероб', 'мастер'], + }, +]; + +const LEAD_REPLY_TEMPLATE_PRESETS: AgentTemplatePreset[] = [ + { + id: 'lead-first-reply-premium', + badge: 'Lead DM', + title: 'Первый ответ на новую заявку', + description: + 'Тёплый первый ответ в директ или мессенджере: быстро включиться, снять напряжение и перевести лид к следующему шагу.', + campaignTitle: 'Первый ответ на новую заявку по мебели на заказ', + goal: 'lead_generation', + audience: 'homeowners', + format: 'story', + tone: + 'Человечно, быстро и уверенно. Ответ должен звучать как внимательный менеджер премиальной мебельной студии без канцелярита и навязчивых продаж.', + keyOffer: + 'Быстрая консультация, предварительный ориентир по бюджету после 2–3 уточнений, запись на замер или короткий созвон.', + callToAction: 'Предложите клиенту прислать размеры, фото помещения или удобное время для короткого созвона.', + language: 'ru', + notes: + 'Главный приоритет — поле lead_dm_reply. Ответ должен признать запрос, задать 1–2 уместных вопроса и мягко перевести лида к замеру или расчёту.', + channelPlatforms: ['instagram', 'telegram', 'vk'], + productKeywords: ['кухн', 'шкаф', 'гардероб'], + leadStatuses: ['new', 'contacted'], + leadSourcePlatforms: ['instagram', 'telegram', 'vk'], + focus: 'lead_reply', + }, + { + id: 'lead-price-question', + badge: 'Цена', + title: 'Клиент спрашивает цену', + description: + 'Сценарий, когда лид пишет «Сколько стоит?» и важно не уйти в сухой прайс или общие слова.', + campaignTitle: 'Ответ на вопрос клиента о цене мебели на заказ', + goal: 'lead_generation', + audience: 'homeowners', + format: 'post', + tone: + 'Спокойно, открыто и по делу. Не оправдываться, а уверенно объяснять, от чего зависит цена и почему расчёт делается под задачу.', + keyOffer: + 'Прозрачный подход к расчёту: материалы, фурнитура, размеры, проект, доставка и монтаж без скрытых сюрпризов.', + callToAction: 'Предложите прислать размеры, план помещения или референсы, чтобы подготовить точный расчёт.', + language: 'ru', + notes: + 'Главный приоритет — lead_dm_reply для возражения про цену. Дайте короткий человеческий ответ, затем мягко запросите 2–3 вводных для расчёта.', + productKeywords: ['кухн', 'шкаф', 'гардероб'], + leadStatuses: ['new', 'qualified'], + leadSourcePlatforms: ['instagram', 'telegram', 'website'], + leadInterestKeywords: ['кухн', 'шкаф', 'гардероб'], + focus: 'lead_reply', + }, + { + id: 'lead-follow-up-gentle', + badge: 'Follow-up', + title: 'Лид пропал после диалога', + description: + 'Мягкий follow-up без давления: напомнить о себе, вернуть интерес и предложить простой следующий шаг.', + campaignTitle: 'Follow-up для лида, который перестал отвечать после первого диалога', + goal: 'lead_generation', + audience: 'homeowners', + format: 'story', + tone: + 'Вежливо, спокойно и уважительно. Никакого чувства вины для клиента и никакой пассивной агрессии.', + keyOffer: + 'Возвращаемся с пользой: готовы уточнить размеры, подобрать материалы, показать похожий кейс и вернуться к расчёту в удобное время.', + callToAction: 'Предложите один очень простой следующий шаг: прислать референс, размеры или выбрать удобное время для связи.', + language: 'ru', + notes: + 'Главный приоритет — lead_dm_reply и follow-up тон. Сделайте сообщение коротким, тёплым и ненавязчивым, как заботливое напоминание.', + channelPlatforms: ['instagram', 'telegram', 'vk'], + leadStatuses: ['contacted', 'qualified', 'proposal_sent'], + leadSourcePlatforms: ['instagram', 'telegram', 'vk'], + focus: 'lead_reply', + }, + { + id: 'lead-measurement-booking', + badge: 'Замер', + title: 'Перевести лида на замер', + description: + 'Сценарий для тёплого лида, который уже готов обсуждать проект и его нужно перевести к конкретному действию.', + campaignTitle: 'Подтверждение замера и следующий шаг по проекту мебели на заказ', + goal: 'sales', + audience: 'families', + format: 'story', + tone: + 'Собранно, уверенно и заботливо. Чётко объяснить, что будет дальше и что подготовить перед замером.', + keyOffer: 'Быстрый выезд на замер, фиксация пожеланий, понятный процесс проекта и расчёт после встречи.', + callToAction: 'Предложите 2 удобных слота для замера и напомните, какие фото или размеры можно подготовить заранее.', + language: 'ru', + notes: + 'Главный приоритет — lead_dm_reply для тёплого лида. Ответ должен звучать конкретно и помогать договориться о времени без лишней переписки.', + productKeywords: ['кухн', 'шкаф', 'гардероб'], + leadStatuses: ['qualified', 'proposal_sent'], + focus: 'lead_reply', + }, + { + id: 'lead-designer-partner', + badge: 'B2B', + title: 'Запрос от дизайнера', + description: + 'Партнёрский ответ для дизайнера интерьеров или студии, которым важны сроки, узлы и удобство коммуникации.', + campaignTitle: 'Ответ на запрос от дизайнера интерьеров по мебели на заказ', + goal: 'sales', + audience: 'designers', + format: 'carousel', + tone: + 'Партнёрски, структурно и уверенно. Покажите, что с вами комфортно работать в связке по проекту и авторскому надзору.', + keyOffer: 'Индивидуальные расчёты, нестандартные размеры, быстрые согласования, образцы материалов и аккуратные монтажи.', + callToAction: 'Предложите короткий созвон или обмен ТЗ, чтобы быстро оценить проект и следующий шаг.', + language: 'ru', + notes: + 'Главный приоритет — lead_dm_reply для партнёрского входящего запроса. Нужно звучать профессионально, но по-человечески, без формализма.', + productKeywords: ['гардероб', 'шкаф', 'кабинет', 'кухн'], + leadStatuses: ['new', 'qualified'], + leadSourcePlatforms: ['instagram', 'website', 'referral'], + focus: 'lead_reply', + }, +]; + +const ALL_AGENT_TEMPLATE_PRESETS = [...AGENT_TEMPLATE_PRESETS, ...LEAD_REPLY_TEMPLATE_PRESETS]; + +const GOAL_OPTIONS: Array<{ value: GoalValue; label: string }> = [ + { value: 'lead_generation', label: 'Лиды и заявки' }, + { value: 'brand_awareness', label: 'Узнаваемость бренда' }, + { value: 'engagement', label: 'Вовлечённость' }, + { value: 'sales', label: 'Продажи' }, + { value: 'recruiting', label: 'Найм и HR-бренд' }, +]; + +const AUDIENCE_OPTIONS: Array<{ value: AudienceValue; label: string }> = [ + { value: 'homeowners', label: 'Владельцы квартир и домов' }, + { value: 'new_apartments', label: 'Покупатели новых квартир' }, + { value: 'families', label: 'Семьи с детьми' }, + { value: 'businesses', label: 'Бизнес и коммерческие объекты' }, + { value: 'designers', label: 'Дизайнеры и студии' }, + { value: 'other', label: 'Другая аудитория' }, +]; + +const FORMAT_OPTIONS: Array<{ value: ContentTypeValue; label: string }> = [ + { value: 'post', label: 'Пост' }, + { value: 'story', label: 'Stories' }, + { value: 'reel', label: 'Reel' }, + { value: 'carousel', label: 'Карусель' }, +]; + +const LANGUAGE_OPTIONS: Array<{ value: LanguageValue; label: string }> = [ + { value: 'ru', label: 'Русский' }, + { value: 'en', label: 'English' }, +]; + +const PLATFORM_LABELS: Record = { + instagram: 'Instagram', + facebook: 'Facebook', + tiktok: 'TikTok', + vk: 'VK', + youtube: 'YouTube', + pinterest: 'Pinterest', + telegram: 'Telegram', + website: 'Website', + referral: 'Referral', + other: 'Other', +}; + +const STATUS_STYLES: Record = { + draft: 'bg-slate-100 text-slate-700', + planned: 'bg-blue-50 text-blue-700', + drafting: 'bg-violet-50 text-violet-700', + ready: 'bg-emerald-50 text-emerald-700', + scheduled: 'bg-cyan-50 text-cyan-700', + published: 'bg-emerald-50 text-emerald-700', + approved: 'bg-emerald-50 text-emerald-700', + new: 'bg-amber-50 text-amber-700', + contacted: 'bg-blue-50 text-blue-700', + qualified: 'bg-violet-50 text-violet-700', + proposal_sent: 'bg-indigo-50 text-indigo-700', + won: 'bg-emerald-50 text-emerald-700', + lost: 'bg-rose-50 text-rose-700', +}; + +const KIND_BY_FORMAT: Record = { + post: 'post_caption', + story: 'story_script', + reel: 'reel_script', + carousel: 'post_caption', +}; + +const initialWorkspaceState: WorkspaceState = { + loading: true, + channels: [], + products: [], + caseStudies: [], + recentDrafts: [], + upcomingPlanItems: [], + recentLeads: [], + totals: { + drafts: 0, + planItems: 0, + leads: 0, + }, +}; + +const createInitialFormState = (): AgentFormState => ({ + brandName: '', + city: '', + campaignTitle: '', + channelId: '', + productId: '', + caseStudyId: '', + goal: 'lead_generation', + audience: 'homeowners', + format: 'post', + tone: 'Премиально, экспертно, тепло, без шаблонных обещаний и кричащих скидок.', + keyOffer: '', + callToAction: 'Напишите в директ, чтобы получить расчёт и эскиз проекта.', + language: 'ru', + publishAt: getDefaultPublishAt(), + notes: '', +}); + +function getDefaultPublishAt() { + const date = new Date(); + date.setDate(date.getDate() + 1); + date.setHours(11, 0, 0, 0); + return toDateTimeInputValue(date); +} + +function toDateTimeInputValue(date: Date) { + const pad = (value: number) => String(value).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +function formatDateLabel(value?: string | null) { + if (!value) { + return 'Без даты'; + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return 'Без даты'; + } + + return new Intl.DateTimeFormat('ru-RU', { + day: '2-digit', + month: 'short', + hour: '2-digit', + minute: '2-digit', + }).format(date); +} + +function formatShortLabel(value?: string | null) { + if (!value) { + return 'Не указано'; + } + + return value + .replace(/_/g, ' ') + .replace(/\b\w/g, (symbol) => symbol.toUpperCase()); +} + +function formatPlatformLabel(value?: string | null) { + if (!value) { + return 'Не подключено'; + } + + return PLATFORM_LABELS[value] || formatShortLabel(value); +} + +function normalizeTemplateSearchValue(value?: string | null) { + return String(value || '').trim().toLowerCase(); +} + +function includesTemplateKeyword(value: string, keywords?: string[]) { + if (!keywords?.length) { + return false; + } + + const normalizedValue = normalizeTemplateSearchValue(value); + + if (!normalizedValue) { + return false; + } + + return keywords.some((keyword) => normalizedValue.includes(normalizeTemplateSearchValue(keyword))); +} + +function findMatchingChannel(channels: ChannelRecord[], platforms?: string[]) { + if (!platforms?.length) { + return null; + } + + return ( + channels.find((channel) => + platforms.some((platform) => normalizeTemplateSearchValue(channel.platform) === normalizeTemplateSearchValue(platform)), + ) || null + ); +} + +function findMatchingProduct(products: ProductRecord[], keywords?: string[]) { + if (!keywords?.length) { + return null; + } + + return ( + products.find((product) => + includesTemplateKeyword([product.name, product.category, product.description, product.features].filter(Boolean).join(' '), keywords), + ) || null + ); +} + +function findMatchingCaseStudy(caseStudies: CaseStudyRecord[], keywords?: string[]) { + if (!keywords?.length) { + return null; + } + + return ( + caseStudies.find((caseStudy) => + includesTemplateKeyword( + [caseStudy.title, caseStudy.project_type, caseStudy.location, caseStudy.solution, caseStudy.results].filter(Boolean).join(' '), + keywords, + ), + ) || null + ); +} + +function findMatchingLead(leads: LeadRecord[], template: AgentTemplatePreset) { + const hasLeadFilters = Boolean( + template.leadStatuses?.length || template.leadSourcePlatforms?.length || template.leadInterestKeywords?.length, + ); + + if (!hasLeadFilters) { + return null; + } + + return ( + leads.find((lead) => { + const matchesStatus = + !template.leadStatuses?.length || + template.leadStatuses.some((status) => normalizeTemplateSearchValue(lead.status) === normalizeTemplateSearchValue(status)); + const matchesSource = + !template.leadSourcePlatforms?.length || + template.leadSourcePlatforms.some( + (platform) => normalizeTemplateSearchValue(lead.source_platform) === normalizeTemplateSearchValue(platform), + ); + const matchesInterest = + !template.leadInterestKeywords?.length || + includesTemplateKeyword([lead.interest_category].filter(Boolean).join(' '), template.leadInterestKeywords); + + return matchesStatus && matchesSource && matchesInterest; + }) || null + ); +} + +function formatLeadContextLabel(lead: LeadRecord) { + return [lead.full_name || 'Новый лид', formatPlatformLabel(lead.source_platform), lead.interest_category ? formatShortLabel(lead.interest_category) : ''] + .filter(Boolean) + .join(' · '); +} + +function resolveTemplateMatches(workspace: WorkspaceState, template: AgentTemplatePreset) { + const matchedChannel = findMatchingChannel(workspace.channels, template.channelPlatforms); + const matchedProduct = findMatchingProduct(workspace.products, template.productKeywords); + const matchedCaseStudy = findMatchingCaseStudy(workspace.caseStudies, template.caseStudyKeywords); + const matchedLead = findMatchingLead(workspace.recentLeads, template); + + return { + matchedChannel, + matchedProduct, + matchedCaseStudy, + matchedLead, + }; +} + +function createUuid() { + if (typeof globalThis.crypto !== 'undefined' && typeof globalThis.crypto.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + + if (typeof globalThis.crypto === 'undefined') { + throw new Error('Crypto API is not available in this browser.'); + } + + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')); + + return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`; +} + +function getFutureWindowEnd(value: string) { + if (!value) { + return null; + } + + const start = new Date(value); + + if (Number.isNaN(start.getTime())) { + return null; + } + + start.setHours(start.getHours() + 2); + return start.toISOString(); +} + +function extractAiText(response: AiProxyResponse | null) { + const output = response?.data?.output; + + if (!Array.isArray(output)) { + return ''; + } + + const message = output.find((item) => item?.type === 'message'); + const content = Array.isArray(message?.content) ? message.content : []; + const outputText = content.find((item) => item?.type === 'output_text'); + + return typeof outputText?.text === 'string' ? outputText.text.trim() : ''; +} + +function parseGeneratedDraft(rawText: string): GeneratedDraft | null { + if (!rawText) { + return null; + } + + const trimmedText = rawText.trim(); + const candidate = trimmedText.startsWith('{') + ? trimmedText + : trimmedText + .replace(/^```json\s*/i, '') + .replace(/^```\s*/i, '') + .replace(/```$/i, ''); + + const fallbackStart = candidate.indexOf('{'); + const fallbackEnd = candidate.lastIndexOf('}'); + const jsonText = fallbackStart >= 0 && fallbackEnd > fallbackStart ? candidate.slice(fallbackStart, fallbackEnd + 1) : candidate; + + try { + const parsed = JSON.parse(jsonText) as { + brief_title?: string; + schedule_title?: string; + content_title?: string; + hook?: string; + caption?: string; + story_slides?: string[] | string; + hashtags?: string[] | string; + cta?: string; + lead_dm_reply?: string; + image_brief?: string; + }; + + const storySlides = Array.isArray(parsed.story_slides) + ? parsed.story_slides + : typeof parsed.story_slides === 'string' + ? parsed.story_slides.split('\n') + : []; + + const hashtags = Array.isArray(parsed.hashtags) + ? parsed.hashtags + : typeof parsed.hashtags === 'string' + ? parsed.hashtags.split(/[\s,]+/) + : []; + + return { + briefTitle: parsed.brief_title?.trim() || '', + scheduleTitle: parsed.schedule_title?.trim() || '', + contentTitle: parsed.content_title?.trim() || '', + hook: parsed.hook?.trim() || '', + caption: parsed.caption?.trim() || '', + storySlides: storySlides.map((item) => item.trim()).filter(Boolean).slice(0, 5), + hashtags: hashtags + .map((item) => item.trim()) + .filter(Boolean) + .map((item) => (item.startsWith('#') ? item : `#${item.replace(/^#/, '')}`)) + .slice(0, 12), + cta: parsed.cta?.trim() || '', + leadDmReply: parsed.lead_dm_reply?.trim() || '', + imageBrief: parsed.image_brief?.trim() || '', + }; + } catch (error) { + console.error('Failed to parse AI draft JSON:', error, rawText); + return null; + } +} + +function buildSystemPrompt(language: LanguageValue) { + return language === 'ru' + ? 'Ты — senior SMM-стратег и копирайтер для премиального мебельного производства на заказ. Верни только JSON без markdown и пояснений. Будь конкретным, премиальным, современным и ориентированным на лиды.' + : 'You are a senior social media strategist and copywriter for a premium custom furniture workshop. Return JSON only with no markdown or commentary. Be specific, modern, premium, and lead-oriented.'; +} + +function buildUserPrompt(form: AgentFormState, context: PromptContext, activeTemplate?: AgentTemplatePreset | null) { + const productSummary = context.product + ? `${context.product.name || 'Product'}; category: ${context.product.category || 'n/a'}; description: ${context.product.description || 'n/a'}; features: ${context.product.features || 'n/a'}` + : 'Not selected'; + + const caseStudySummary = context.caseStudy + ? `${context.caseStudy.title || 'Case study'}; location: ${context.caseStudy.location || 'n/a'}; solution: ${context.caseStudy.solution || 'n/a'}; results: ${context.caseStudy.results || 'n/a'}` + : 'Not selected'; + + const channelSummary = context.channel + ? `${formatPlatformLabel(context.channel.platform)} / ${context.channel.account_name || 'account'} / ${context.channel.account_url || 'no url'}` + : 'Not selected'; + + return [ + `Language: ${form.language}.`, + activeTemplate?.title ? `Applied template: ${activeTemplate.title}.` : '', + `Business: ${form.brandName || 'Custom furniture workshop'}.`, + `City or region: ${form.city || 'Not specified'}.`, + `Campaign focus: ${form.campaignTitle}.`, + `Goal: ${form.goal}.`, + `Audience: ${form.audience}.`, + `Primary content format: ${form.format}.`, + activeTemplate?.focus === 'lead_reply' + ? 'Primary priority: make lead_dm_reply the strongest field. It should sound human, premium, concise, and suitable for a warm inbound lead or follow-up.' + : 'Primary priority: make the social content publish-ready while keeping lead_dm_reply practical and ready to send.', + `Brand voice: ${form.tone}.`, + `Offer / USP: ${form.keyOffer}.`, + `Preferred CTA: ${form.callToAction}.`, + `Planned publish time: ${form.publishAt || 'Not specified'}.`, + `Selected social channel: ${channelSummary}.`, + `Selected product context: ${productSummary}.`, + `Selected case study context: ${caseStudySummary}.`, + `Additional notes: ${form.notes || 'None'}.`, + 'Output requirements:', + '- brief_title: short name for the brief.', + '- schedule_title: short calendar item title (max 70 chars).', + '- content_title: title or internal name for the generated draft.', + '- hook: one compelling hook sentence.', + '- caption: a polished caption ready to publish.', + '- story_slides: 3 to 5 concise story frames.', + '- hashtags: 8 to 12 relevant hashtags.', + '- cta: one final CTA sentence.', + activeTemplate?.focus === 'lead_reply' + ? '- lead_dm_reply: make this the strongest field; 2 to 4 sentences, warm and human, acknowledge the request, clarify 1 or 2 details, and propose a concrete next step.' + : '- lead_dm_reply: short reply for a warm inbound lead in direct messages.', + '- image_brief: visual brief for the designer or videographer.', + 'Return strict JSON with exactly these keys.', + ] + .filter(Boolean) + .join('\n'); +} + +function buildPromptSummary(form: AgentFormState, context: PromptContext, activeTemplate?: AgentTemplatePreset | null) { + return [ + activeTemplate?.title ? `Template: ${activeTemplate.title}` : '', + activeTemplate?.focus === 'lead_reply' ? 'Mode: lead_reply' : '', + `Brand: ${form.brandName || '—'}`, + `City: ${form.city || '—'}`, + `Campaign: ${form.campaignTitle}`, + `Goal: ${form.goal}`, + `Audience: ${form.audience}`, + `Format: ${form.format}`, + `Tone: ${form.tone}`, + `Offer: ${form.keyOffer}`, + `CTA: ${form.callToAction}`, + `Channel: ${context.channel?.account_name || formatPlatformLabel(context.channel?.platform) || '—'}`, + `Product: ${context.product?.name || '—'}`, + `Case study: ${context.caseStudy?.title || '—'}`, + `Notes: ${form.notes || '—'}`, + ] + .filter(Boolean) + .join('\n'); +} + +function buildStoredOutput(draft: GeneratedDraft) { + const sections = [ + draft.contentTitle ? `Название: ${draft.contentTitle}` : '', + draft.hook ? `Хук: ${draft.hook}` : '', + draft.caption, + draft.storySlides.length ? `Stories:\n${draft.storySlides.map((item, index) => `${index + 1}. ${item}`).join('\n')}` : '', + draft.leadDmReply ? `Ответ на входящий лид:\n${draft.leadDmReply}` : '', + draft.imageBrief ? `ТЗ на визуал:\n${draft.imageBrief}` : '', + draft.cta ? `CTA: ${draft.cta}` : '', + ]; + + return sections.filter(Boolean).join('\n\n'); +} + +function sortByDateDescending(items: T[], ...keys: Array) { + return [...items].sort((firstItem, secondItem) => { + const firstDate = keys + .map((key) => firstItem[key]) + .find((value) => typeof value === 'string' && value)?.toString() || firstItem.createdAt || ''; + const secondDate = keys + .map((key) => secondItem[key]) + .find((value) => typeof value === 'string' && value)?.toString() || secondItem.createdAt || ''; + + return new Date(secondDate).getTime() - new Date(firstDate).getTime(); + }); +} + +function sortPlanItems(items: PlanItemRecord[]) { + return [...items].sort((firstItem, secondItem) => { + const firstValue = firstItem.scheduled_at ? new Date(firstItem.scheduled_at).getTime() : Number.MAX_SAFE_INTEGER; + const secondValue = secondItem.scheduled_at ? new Date(secondItem.scheduled_at).getTime() : Number.MAX_SAFE_INTEGER; + return firstValue - secondValue; + }); +} + +function getStatusClass(value?: string | null) { + if (!value) { + return 'bg-slate-100 text-slate-600'; + } + + return STATUS_STYLES[value] || 'bg-slate-100 text-slate-600'; +} + +function MetricCard({ + icon, + label, + value, + hint, +}: { + icon: string; + label: string; + value: string; + hint: string; +}) { + return ( +
+
+ +
+

{label}

+

{value}

+

{hint}

+
+ ); +} + +function EmptyState({ + icon, + title, + description, + action, +}: { + icon: string; + title: string; + description: string; + action?: React.ReactNode; +}) { + return ( +
+
+ +
+

{title}

+

{description}

+ {action ?
{action}
: null} +
+ ); +} + +const AgentStudio = () => { + const dispatch = useAppDispatch(); + const { currentUser } = useAppSelector((state) => state.auth); + const { isAskingResponse, errorMessage: aiErrorMessage } = useAppSelector((state) => state.openAi); + + const [form, setForm] = useState(createInitialFormState); + const [workspace, setWorkspace] = useState(initialWorkspaceState); + const [generatedDraft, setGeneratedDraft] = useState(null); + const [validationErrors, setValidationErrors] = useState([]); + const [generationError, setGenerationError] = useState(''); + const [rawAiText, setRawAiText] = useState(''); + const [isSaving, setIsSaving] = useState(false); + const [saveResult, setSaveResult] = useState(null); + const [copyMessage, setCopyMessage] = useState(''); + const [activeTemplateId, setActiveTemplateId] = useState(''); + + const canReadBriefs = hasPermission(currentUser, 'READ_CONTENT_BRIEFS'); + const canReadChannels = hasPermission(currentUser, 'READ_SOCIAL_CHANNELS'); + const canReadProducts = hasPermission(currentUser, 'READ_PRODUCTS'); + const canReadCaseStudies = hasPermission(currentUser, 'READ_CASE_STUDIES'); + const canReadGenerated = hasPermission(currentUser, 'READ_GENERATED_CONTENTS'); + const canReadPlan = hasPermission(currentUser, 'READ_CONTENT_PLAN_ITEMS'); + const canReadLeads = hasPermission(currentUser, 'READ_LEADS'); + const canCreateBrief = hasPermission(currentUser, 'CREATE_CONTENT_BRIEFS'); + const canCreatePlan = hasPermission(currentUser, 'CREATE_CONTENT_PLAN_ITEMS'); + const canCreateGenerated = hasPermission(currentUser, 'CREATE_GENERATED_CONTENTS'); + const canPersist = canCreateBrief && canCreatePlan && canCreateGenerated; + + const selectedChannel = useMemo( + () => workspace.channels.find((item) => item.id === form.channelId), + [form.channelId, workspace.channels], + ); + const selectedProduct = useMemo( + () => workspace.products.find((item) => item.id === form.productId), + [form.productId, workspace.products], + ); + const selectedCaseStudy = useMemo( + () => workspace.caseStudies.find((item) => item.id === form.caseStudyId), + [form.caseStudyId, workspace.caseStudies], + ); + const activeTemplate = useMemo( + () => ALL_AGENT_TEMPLATE_PRESETS.find((item) => item.id === activeTemplateId) || null, + [activeTemplateId], + ); + + const loadWorkspace = useCallback(async () => { + if (!currentUser) { + return; + } + + setWorkspace((currentState) => ({ + ...currentState, + loading: true, + })); + + try { + const [channelsResponse, productsResponse, caseStudiesResponse, draftsResponse, planResponse, leadsResponse] = await Promise.all([ + canReadChannels ? axios.get('/social_channels?limit=100&page=0') : Promise.resolve({ data: { rows: [], count: 0 } }), + canReadProducts ? axios.get('/products?limit=100&page=0') : Promise.resolve({ data: { rows: [], count: 0 } }), + canReadCaseStudies ? axios.get('/case_studies?limit=100&page=0') : Promise.resolve({ data: { rows: [], count: 0 } }), + canReadGenerated ? axios.get('/generated_contents?limit=100&page=0') : Promise.resolve({ data: { rows: [], count: 0 } }), + canReadPlan ? axios.get('/content_plan_items?limit=100&page=0') : Promise.resolve({ data: { rows: [], count: 0 } }), + canReadLeads ? axios.get('/leads?limit=100&page=0') : Promise.resolve({ data: { rows: [], count: 0 } }), + ]); + + const allDrafts = Array.isArray(draftsResponse.data.rows) ? (draftsResponse.data.rows as GeneratedContentRecord[]) : []; + const allPlanItems = Array.isArray(planResponse.data.rows) ? (planResponse.data.rows as PlanItemRecord[]) : []; + const allLeads = Array.isArray(leadsResponse.data.rows) ? (leadsResponse.data.rows as LeadRecord[]) : []; + + setWorkspace({ + loading: false, + channels: Array.isArray(channelsResponse.data.rows) ? (channelsResponse.data.rows as ChannelRecord[]) : [], + products: Array.isArray(productsResponse.data.rows) ? (productsResponse.data.rows as ProductRecord[]) : [], + caseStudies: Array.isArray(caseStudiesResponse.data.rows) ? (caseStudiesResponse.data.rows as CaseStudyRecord[]) : [], + recentDrafts: sortByDateDescending(allDrafts, 'generated_at').slice(0, 4), + upcomingPlanItems: sortPlanItems(allPlanItems).slice(0, 4), + recentLeads: sortByDateDescending(allLeads, 'requested_at').slice(0, 4), + totals: { + drafts: draftsResponse.data.count ?? allDrafts.length, + planItems: planResponse.data.count ?? allPlanItems.length, + leads: leadsResponse.data.count ?? allLeads.length, + }, + }); + } catch (error) { + console.error('Failed to load agent studio workspace:', error); + setWorkspace((currentState) => ({ + ...currentState, + loading: false, + })); + } + }, [canReadCaseStudies, canReadChannels, canReadGenerated, canReadLeads, canReadPlan, canReadProducts, currentUser]); + + useEffect(() => { + if (!currentUser) { + return; + } + + setForm((currentState) => ({ + ...currentState, + brandName: currentState.brandName || currentUser?.organizations?.name || currentState.brandName, + })); + }, [currentUser]); + + useEffect(() => { + loadWorkspace().then(); + }, [loadWorkspace]); + + useEffect(() => { + if (!copyMessage) { + return undefined; + } + + const timer = window.setTimeout(() => { + setCopyMessage(''); + }, 2000); + + return () => window.clearTimeout(timer); + }, [copyMessage]); + + const handleFieldChange = (field: K, value: AgentFormState[K]) => { + setForm((currentState) => ({ + ...currentState, + [field]: value, + })); + }; + + const validateForm = () => { + const errors: string[] = []; + + if (!form.campaignTitle.trim()) { + errors.push('Добавьте тему или акцию для публикации.'); + } + + if (!form.keyOffer.trim()) { + errors.push('Опишите оффер или ключевую ценность продукта.'); + } + + if (!form.callToAction.trim()) { + errors.push('Укажите целевой call-to-action.'); + } + + if (!form.tone.trim()) { + errors.push('Опишите tone of voice бренда.'); + } + + return errors; + }; + + const handleApplyTemplate = (template: AgentTemplatePreset) => { + const { matchedChannel, matchedProduct, matchedCaseStudy, matchedLead } = resolveTemplateMatches(workspace, template); + const leadContextNote = matchedLead + ? `Контекст свежего лида: ${formatLeadContextLabel(matchedLead)}${matchedLead.status ? ` · статус: ${formatShortLabel(matchedLead.status)}` : ''}.` + : ''; + + setForm((currentState) => ({ + ...currentState, + campaignTitle: template.campaignTitle, + goal: template.goal, + audience: template.audience, + format: template.format, + tone: template.tone, + keyOffer: template.keyOffer, + callToAction: template.callToAction, + language: template.language, + notes: [template.notes, leadContextNote].filter(Boolean).join('\n\n'), + channelId: matchedChannel?.id || currentState.channelId, + productId: matchedProduct?.id || currentState.productId, + caseStudyId: matchedCaseStudy?.id || currentState.caseStudyId, + })); + + setActiveTemplateId(template.id); + setGeneratedDraft(null); + setValidationErrors([]); + setGenerationError(''); + setRawAiText(''); + setSaveResult(null); + setCopyMessage(`Шаблон «${template.title}» загружен в форму.`); + }; + + const handleGenerate = async () => { + const errors = validateForm(); + setValidationErrors(errors); + setGenerationError(''); + setSaveResult(null); + setRawAiText(''); + + if (errors.length) { + return; + } + + const promptContext: PromptContext = { + channel: selectedChannel, + product: selectedProduct, + caseStudy: selectedCaseStudy, + }; + + const action = await dispatch( + requestAiResponse({ + input: [ + { + role: 'system', + content: buildSystemPrompt(form.language), + }, + { + role: 'user', + content: buildUserPrompt(form, promptContext, activeTemplate), + }, + ], + options: { + poll_interval: 5, + poll_timeout: 300, + }, + }), + ); + + if (!requestAiResponse.fulfilled.match(action)) { + console.error('AI response request failed:', action.payload || action.error); + setGeneratedDraft(null); + setGenerationError(aiErrorMessage || 'Не удалось получить ответ от AI. Попробуйте ещё раз.'); + return; + } + + const payload = action.payload as AiProxyResponse; + const text = extractAiText(payload); + setRawAiText(text); + + const parsedDraft = parseGeneratedDraft(text); + + if (!parsedDraft) { + setGeneratedDraft(null); + setGenerationError('AI вернул ответ, но он не распознался как структурированный контент. Попробуйте ещё раз.'); + return; + } + + setGeneratedDraft({ + briefTitle: parsedDraft.briefTitle || `Бриф: ${form.campaignTitle}`, + scheduleTitle: parsedDraft.scheduleTitle || parsedDraft.contentTitle || form.campaignTitle, + contentTitle: parsedDraft.contentTitle || form.campaignTitle, + hook: parsedDraft.hook, + caption: parsedDraft.caption, + storySlides: parsedDraft.storySlides, + hashtags: parsedDraft.hashtags, + cta: parsedDraft.cta || form.callToAction, + leadDmReply: parsedDraft.leadDmReply, + imageBrief: parsedDraft.imageBrief, + }); + }; + + const handleSave = async () => { + if (!generatedDraft || !currentUser?.id) { + return; + } + + if (!canPersist) { + setGenerationError('Для сохранения нужны права на создание брифа, плана публикаций и generated content.'); + return; + } + + const promptContext: PromptContext = { + channel: selectedChannel, + product: selectedProduct, + caseStudy: selectedCaseStudy, + }; + + const briefId = createUuid(); + const planItemId = createUuid(); + const generatedContentId = createUuid(); + + setIsSaving(true); + setGenerationError(''); + + try { + await axios.post('/content_briefs', { + data: { + id: briefId, + owner_user: currentUser.id, + title: generatedDraft.briefTitle, + goal: form.goal, + audience: form.audience, + key_offers: form.keyOffer, + constraints: form.notes, + brand_voice: form.tone, + call_to_action: form.callToAction, + hashtags_seed: generatedDraft.hashtags.join(' '), + notes: [ + form.city ? `Город: ${form.city}` : '', + activeTemplate?.title ? `Шаблон: ${activeTemplate.title}` : '', + selectedProduct?.name ? `Продукт: ${selectedProduct.name}` : '', + selectedCaseStudy?.title ? `Кейс: ${selectedCaseStudy.title}` : '', + selectedChannel?.account_name ? `Канал: ${selectedChannel.account_name}` : '', + generatedDraft.imageBrief ? `Визуал: ${generatedDraft.imageBrief}` : '', + form.notes, + ] + .filter(Boolean) + .join('\n'), + status: 'active', + }, + }); + + await axios.post('/content_plan_items', { + data: { + id: planItemId, + channel: form.channelId || null, + case_study: form.caseStudyId || null, + content_type: form.format, + scheduled_at: form.publishAt ? new Date(form.publishAt).toISOString() : null, + publish_window_end_at: getFutureWindowEnd(form.publishAt), + status: 'planned', + title: generatedDraft.scheduleTitle, + notes: [generatedDraft.hook, generatedDraft.cta, generatedDraft.imageBrief].filter(Boolean).join('\n\n'), + }, + }); + + await axios.post('/generated_contents', { + data: { + id: generatedContentId, + brief: briefId, + plan_item: planItemId, + kind: KIND_BY_FORMAT[form.format], + language: form.language, + prompt: buildPromptSummary(form, promptContext, activeTemplate), + output_text: buildStoredOutput(generatedDraft), + hashtags: generatedDraft.hashtags.join(' '), + status: 'draft', + generated_at: new Date().toISOString(), + }, + }); + + setSaveResult({ + briefId, + planItemId, + generatedContentId, + }); + + await loadWorkspace(); + } catch (error) { + console.error('Failed to persist agent studio records:', error); + setGenerationError('Черновик не сохранился. Проверьте права доступа и попробуйте ещё раз.'); + } finally { + setIsSaving(false); + } + }; + + const handleCopy = async (value: string, label: string) => { + if (!value) { + return; + } + + try { + await navigator.clipboard.writeText(value); + setCopyMessage(`${label} скопирован(а).`); + } catch (error) { + console.error('Failed to copy generated text:', error); + setCopyMessage('Не удалось скопировать текст.'); + } + }; + + const infoLinks = ( + + {canReadChannels ? : null} + {canReadCaseStudies ? : null} + {canReadProducts ? : null} + + ); + + return ( + <> + + {getPageTitle('Agent Studio')} + + + + + {canReadPlan ? : null} + {canReadGenerated ? : null} + + + +
+
+
+ + Modern / clean / premium + +

+ AI-агент, который превращает идеи мебельного бренда в готовый контент и рабочий контент-план. +

+

+ Сформулируйте оффер, выберите канал и кейс — студия сгенерирует черновик публикации, Stories-подводку, + ответ для тёплого лида и сразу сохранит всё в ваш контент-календарь. +

+
+
+ + Контент под оффер и стиль бренда +
+
+ + Сохранение в календарь одним действием +
+
+ + Отдельный ответ для входящего лида +
+
+
+
+
+

Контент в работе

+

{workspace.loading ? '…' : workspace.totals.drafts}

+

AI-черновики, которые ждут утверждения или публикации.

+
+
+

План публикаций

+

{workspace.loading ? '…' : workspace.totals.planItems}

+

Материалы в календаре, которые помогают публиковаться стабильно.

+
+
+

Лиды под рукой

+

{workspace.loading ? '…' : workspace.totals.leads}

+

Новые обращения из соцсетей и сайта — без потери заявок.

+
+
+
+
+ + {copyMessage ? {copyMessage} : null} + + {saveResult ? ( + + {canReadGenerated ? : null} + {canReadPlan ? : null} + + } + > + Черновик, бриф и запись в контент-плане сохранены. Это первая рабочая итерация — теперь можно доработать тексты и + передать задачу в продакшн. + + ) : null} + + {validationErrors.length ? ( + +
+

Перед генерацией заполните обязательные поля:

+
    + {validationErrors.map((error) => ( +
  • {error}
  • + ))} +
+
+
+ ) : null} + + {generationError ? {generationError} : null} + + {(!workspace.channels.length || !workspace.caseStudies.length || !workspace.products.length) && !workspace.loading ? ( + + Чтобы агент писал точнее, подключите хотя бы один соцканал и добавьте пару кейсов или карточек продукта. Сама студия уже + работает, но контент станет заметно персональнее. + + ) : null} + + {!canPersist ? ( + + AI-генерация доступна, но для сохранения полного workflow нужны права: CREATE_CONTENT_BRIEFS, CREATE_CONTENT_PLAN_ITEMS и + CREATE_GENERATED_CONTENTS. + + ) : null} + +
+
+
+ + + +
+ + +
+
+
+ Step 1 +
+

Соберите задачу для AI-агента

+

+ Заполните контекст кампании: оффер, тон коммуникации, канал и кейс. После этого AI подготовит готовый контент для + публикации и лид-ориентированный ответ в директ. +

+
+
+ + Бриф → AI → сохранение в CRM +
+
+ + + +
+
+
+

Контент-шаблоны

+

Заполните бриф для публикации в 1 клик

+

+ Это быстрые сценарии для мебельного бизнеса: кухни, Reels, шкафы, гардеробные и FAQ-посты. Нажмите на + шаблон — и основная форма заполнится автоматически. +

+
+
+ Сначала шаблон → потом генерация +
+
+ +
+ {AGENT_TEMPLATE_PRESETS.map((template) => { + const matchedChannel = findMatchingChannel(workspace.channels, template.channelPlatforms); + const matchedProduct = findMatchingProduct(workspace.products, template.productKeywords); + const matchedCaseStudy = findMatchingCaseStudy(workspace.caseStudies, template.caseStudyKeywords); + const autofillParts = [ + matchedChannel + ? `Канал: ${formatPlatformLabel(matchedChannel.platform)}${matchedChannel.account_name ? ` · ${matchedChannel.account_name}` : ''}` + : '', + matchedProduct ? `Продукт: ${matchedProduct.name || 'Подходящий продукт'}` : '', + matchedCaseStudy ? `Кейс: ${matchedCaseStudy.title || 'Подходящий кейс'}` : '', + ].filter(Boolean); + + return ( +
+
+
+ + {template.badge} + +

{template.title}

+
+ {activeTemplateId === template.id ? ( + + Выбран + + ) : null} +
+ +

{template.description}

+ +
+ + {FORMAT_OPTIONS.find((option) => option.value === template.format)?.label || template.format} + + + {GOAL_OPTIONS.find((option) => option.value === template.goal)?.label || template.goal} + + + {AUDIENCE_OPTIONS.find((option) => option.value === template.audience)?.label || template.audience} + +
+ +

+ {autofillParts.length + ? `Подставит: ${autofillParts.join(' · ')}` + : 'Подставит тему, оффер, тон, CTA и заметки. Если демо-данные есть в базе, форма сама выберет канал, продукт и кейс.'} +

+ +
+ handleApplyTemplate(template)} /> +
+
+ ); + })} +
+
+ +
+
+
+

Lead-шаблоны

+

Ответы для заявок и follow-up в 1 клик

+

+ Это готовые сценарии для первого ответа, вопроса о цене, мягкого follow-up, записи на замер и запроса от + дизайнера. Если в базе уже есть лиды, форма подставит ещё и свежий контекст в заметки. +

+
+
+ Главный акцент → ответ в директ +
+
+ +
+ {LEAD_REPLY_TEMPLATE_PRESETS.map((template) => { + const { matchedChannel, matchedProduct, matchedCaseStudy, matchedLead } = resolveTemplateMatches(workspace, template); + const autofillParts = [ + matchedLead ? `Лид: ${formatLeadContextLabel(matchedLead)}` : '', + matchedChannel + ? `Канал: ${formatPlatformLabel(matchedChannel.platform)}${matchedChannel.account_name ? ` · ${matchedChannel.account_name}` : ''}` + : '', + matchedProduct ? `Продукт: ${matchedProduct.name || 'Подходящий продукт'}` : '', + matchedCaseStudy ? `Кейс: ${matchedCaseStudy.title || 'Подходящий кейс'}` : '', + ].filter(Boolean); + + return ( +
+
+
+ + {template.badge} + +

{template.title}

+
+ {activeTemplateId === template.id ? ( + + Выбран + + ) : null} +
+ +

{template.description}

+ +
+ + {FORMAT_OPTIONS.find((option) => option.value === template.format)?.label || template.format} + + + {GOAL_OPTIONS.find((option) => option.value === template.goal)?.label || template.goal} + + + {AUDIENCE_OPTIONS.find((option) => option.value === template.audience)?.label || template.audience} + +
+ +

+ {autofillParts.length + ? `Подставит: ${autofillParts.join(' · ')}` + : 'Подставит тему, тон, CTA и lead-контекст. Даже без загруженных лидов шаблон сразу заполнит форму под нужный сценарий.'} +

+ +
+ handleApplyTemplate(template)} /> +
+
+ ); + })} +
+
+ + + +
+
+ + handleFieldChange('brandName', event.target.value)} + placeholder="Например, Atelier Kitchen" + /> + + + + handleFieldChange('campaignTitle', event.target.value)} + placeholder="Например, кухни на заказ для новых квартир" + /> + + + +