This commit is contained in:
Flatlogic Bot 2026-05-16 19:08:05 +00:00
parent b7815f3517
commit de3ba95d58
20 changed files with 3362 additions and 249 deletions

View File

@ -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);
});

View File

@ -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 = [

View File

@ -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 = [

View File

@ -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 = [

View File

@ -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 = [

View File

@ -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 = [

View File

@ -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 = [

View File

@ -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 = [

View File

@ -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 = [

View File

@ -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 = [

View File

@ -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 = [

View File

@ -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 = [

View File

@ -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 = [

View File

@ -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';

View File

@ -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'

View File

@ -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'

View File

@ -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',

File diff suppressed because it is too large Load Diff

View File

@ -1,166 +1,291 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import {
mdiAccountArrowRight,
mdiArrowRight,
mdiArrowTopRight,
mdiBullhornOutline,
mdiCalendarMonth,
mdiChartTimelineVariant,
mdiCheckCircleOutline,
mdiRobotOutline,
} from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
import type { ReactElement } from 'react';
import React from 'react';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import BaseIcon from '../components/BaseIcon';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
import LayoutGuest from '../layouts/Guest';
const featureCards = [
{
icon: mdiCalendarMonth,
title: 'Контент-календарь',
description: 'Планируйте посты, Stories и Reels в едином ритме, а не в формате «вспомнили — опубликовали».',
},
{
icon: mdiRobotOutline,
title: 'AI-тексты и идеи',
description: 'Получайте готовые подписи, офферы, хэштеги и заготовки для диалогов в директе на базе ваших кейсов и услуг.',
},
{
icon: mdiAccountArrowRight,
title: 'Лиды без потерь',
description: 'Фиксируйте входящие заявки из соцсетей и сразу видьте, что нужно сделать дальше по каждому клиенту.',
},
];
export default function Starter() {
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('video');
const [contentPosition, setContentPosition] = useState('left');
const textColor = useAppSelector((state) => state.style.linkColor);
const workflowSteps = [
{
step: '01',
title: 'Соберите контекст бренда',
text: 'Кейсы, продукты, каналы и офферы создают базу, на которой AI пишет не «как у всех», а про ваш мебельный бизнес.',
},
{
step: '02',
title: 'Запустите Agent Studio',
text: 'Опишите задачу кампании и получите caption, Stories-структуру, хэштеги и ответ для тёплого лида.',
},
{
step: '03',
title: 'Сохраните в рабочий поток',
text: 'Черновик, бриф и позиция в контент-плане сохраняются в систему, чтобы маркетинг, контент и лиды были в одном месте.',
},
];
const title = 'Furniture SMM AI Agent'
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
const controlCards = [
{
icon: mdiRobotOutline,
title: 'Agent Studio',
description: 'Новая рабочая зона для быстрого запуска AI-контента и сохранения результата в CRM.',
href: '/agent-studio',
action: 'Открыть workflow',
},
{
icon: mdiChartTimelineVariant,
title: 'Админ-интерфейс',
description: 'Авторизация, сущности, календарь контента, лиды и аналитика уже доступны из SaaS-панели.',
href: '/login',
action: 'Войти в админку',
},
{
icon: mdiBullhornOutline,
title: 'Контент и лиды',
description: 'Подходит владельцу или маркетологу мебельного производства, которому нужен регулярный SMM без хаоса.',
href: '/login',
action: 'Запустить кабинет',
},
];
export default function Home() {
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<>
<Head>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle('Furniture marketing AI')}</title>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your Furniture SMM AI Agent app!"/>
<div className="space-y-3">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(37,99,235,0.12),_transparent_32%),linear-gradient(180deg,#f8fafc_0%,#eef2ff_45%,#ffffff_100%)] text-slate-900">
<header className="sticky top-0 z-20 border-b border-slate-200/80 bg-white/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-7xl items-center justify-between px-6 py-4 lg:px-8">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-blue-700">Furniture SMM AI Agent</p>
<p className="mt-1 text-sm text-slate-500">ИИ-агент и мини-CRM для мебельного производства на заказ</p>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
<div className="hidden items-center gap-8 lg:flex">
<a href="#features" className="text-sm font-medium text-slate-600 transition hover:text-slate-900">
Возможности
</a>
<a href="#workflow" className="text-sm font-medium text-slate-600 transition hover:text-slate-900">
Workflow
</a>
<a href="#workspace" className="text-sm font-medium text-slate-600 transition hover:text-slate-900">
Рабочая зона
</a>
</div>
<BaseButtons mb="mb-0" classAddon="mr-2 last:mr-0 mb-0" noWrap>
<BaseButton href="/agent-studio" label="Agent Studio" color="lightDark" />
<BaseButton href="/login" label="Войти в админку" color="info" />
</BaseButtons>
</CardBox>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
</div>
</header>
</div>
<main>
<section className="mx-auto grid w-full max-w-7xl gap-12 px-6 py-16 lg:grid-cols-[1.1fr,0.9fr] lg:px-8 lg:py-24">
<div>
<div className="inline-flex items-center rounded-full border border-blue-100 bg-blue-50 px-4 py-2 text-sm font-semibold text-blue-700">
<BaseIcon path={mdiCheckCircleOutline} size={18} className="mr-2" />
Первый MVP-срез уже включает рабочий контент-flow внутри панели
</div>
<h1 className="mt-6 max-w-4xl text-5xl font-semibold tracking-tight text-slate-950 sm:text-6xl">
Премиальный SMM-агент для бизнеса на мебели под заказ от идеи поста до сохранённого контент-плана.
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600">
Приложение помогает владельцу или маркетологу регулярно выпускать контент, генерировать входящие заявки и не терять
лиды из Instagram, Telegram и других каналов. Всё в едином аккуратном кабинете.
</p>
<BaseButtons className="mt-8" type="justify-start" mb="mb-0" classAddon="mr-3 last:mr-0 mb-3">
<BaseButton href="/login" label="Войти в админку" color="info" icon={mdiArrowRight} />
<BaseButton href="/agent-studio" label="Открыть Agent Studio" color="lightDark" icon={mdiRobotOutline} />
</BaseButtons>
<div className="mt-10 grid gap-4 sm:grid-cols-3">
<div className="rounded-3xl border border-slate-200 bg-white/80 p-5 shadow-sm">
<p className="text-sm text-slate-500">Контент-поток</p>
<p className="mt-3 text-3xl font-semibold tracking-tight text-slate-900">AI План</p>
<p className="mt-3 text-sm leading-6 text-slate-500">Черновик, бриф и запись в календарь сохраняются последовательно.</p>
</div>
<div className="rounded-3xl border border-slate-200 bg-white/80 p-5 shadow-sm">
<p className="text-sm text-slate-500">Лид-ориентация</p>
<p className="mt-3 text-3xl font-semibold tracking-tight text-slate-900">DM reply</p>
<p className="mt-3 text-sm leading-6 text-slate-500">Для каждой публикации можно сразу получить ответ на тёплый входящий запрос.</p>
</div>
<div className="rounded-3xl border border-slate-200 bg-white/80 p-5 shadow-sm">
<p className="text-sm text-slate-500">Под мебельный бренд</p>
<p className="mt-3 text-3xl font-semibold tracking-tight text-slate-900">Clean / premium</p>
<p className="mt-3 text-sm leading-6 text-slate-500">Тон, офферы, кейсы и продукты делают AI-контент ближе к реальному бизнесу.</p>
</div>
</div>
</div>
<div className="relative">
<div className="absolute -right-8 -top-10 h-40 w-40 rounded-full bg-cyan-200/60 blur-3xl" />
<div className="absolute -left-10 bottom-10 h-44 w-44 rounded-full bg-blue-200/60 blur-3xl" />
<div className="relative overflow-hidden rounded-[32px] border border-slate-200 bg-slate-950 p-6 text-white shadow-2xl">
<div className="rounded-[28px] border border-white/10 bg-white/5 p-5 backdrop-blur">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-200">Agent Studio</p>
<h2 className="mt-3 text-2xl font-semibold">Готовый рабочий сценарий для контент-маркетинга</h2>
</div>
<span className="rounded-full border border-white/10 bg-white/10 px-3 py-1 text-xs font-semibold text-cyan-200">
MVP Slice
</span>
</div>
<div className="mt-6 space-y-3">
<div className="rounded-2xl border border-white/10 bg-white/10 p-4">
<p className="text-sm font-medium text-slate-300">1. Бриф кампании</p>
<p className="mt-2 text-lg font-semibold">Кухни под новые квартиры</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/10 p-4">
<p className="text-sm font-medium text-slate-300">2. AI-выгрузка</p>
<p className="mt-2 text-sm leading-6 text-slate-200">
Caption, Stories, хэштеги, визуальный бриф и ответ для тёплого лида в одном экране.
</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/10 p-4">
<p className="text-sm font-medium text-slate-300">3. Сохранение</p>
<p className="mt-2 text-sm leading-6 text-slate-200">
Created: Content brief Generated content Content plan item.
</p>
</div>
</div>
</div>
<div className="mt-6 grid gap-4 sm:grid-cols-3">
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<p className="text-sm text-slate-300">Контент-план</p>
<p className="mt-2 text-2xl font-semibold">Планомерно</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<p className="text-sm text-slate-300">AI-черновики</p>
<p className="mt-2 text-2xl font-semibold">Быстрее</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<p className="text-sm text-slate-300">Лиды</p>
<p className="mt-2 text-2xl font-semibold">Без потерь</p>
</div>
</div>
</div>
</div>
</section>
<section id="features" className="mx-auto w-full max-w-7xl px-6 py-8 lg:px-8 lg:py-12">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-blue-700">Capabilities</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-slate-950">Что уже закрывает первый релиз</h2>
</div>
<p className="max-w-2xl text-sm leading-7 text-slate-500">
Не просто красивый лендинг: уже сейчас есть конкретный рабочий сценарий для маркетолога мебельной компании.
</p>
</div>
<div className="mt-8 grid gap-5 lg:grid-cols-3">
{featureCards.map((feature) => (
<div key={feature.title} className="rounded-[28px] border border-slate-200 bg-white p-6 shadow-sm">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-blue-50 text-blue-700">
<BaseIcon path={feature.icon} size={26} />
</div>
<h3 className="mt-5 text-xl font-semibold text-slate-900">{feature.title}</h3>
<p className="mt-3 text-sm leading-7 text-slate-500">{feature.description}</p>
</div>
))}
</div>
</section>
<section id="workflow" className="mx-auto w-full max-w-7xl px-6 py-8 lg:px-8 lg:py-12">
<div className="overflow-hidden rounded-[32px] border border-slate-200 bg-white p-8 shadow-sm lg:p-10">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-blue-700">Workflow</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-slate-950">Тонкий end-to-end слой вместо очередной пустой витрины</h2>
</div>
<p className="max-w-2xl text-sm leading-7 text-slate-500">
Главная ценность первого MVP реальная цепочка действий внутри продукта, а не просто список сущностей.
</p>
</div>
<div className="mt-10 grid gap-5 lg:grid-cols-3">
{workflowSteps.map((item) => (
<div key={item.step} className="rounded-3xl border border-slate-200 bg-slate-50 p-6">
<p className="text-sm font-semibold tracking-[0.2em] text-blue-700">{item.step}</p>
<h3 className="mt-4 text-xl font-semibold text-slate-900">{item.title}</h3>
<p className="mt-3 text-sm leading-7 text-slate-500">{item.text}</p>
</div>
))}
</div>
</div>
</section>
<section id="workspace" className="mx-auto w-full max-w-7xl px-6 py-8 lg:px-8 lg:py-12">
<div className="grid gap-5 lg:grid-cols-3">
{controlCards.map((card) => (
<a
key={card.title}
href={card.href}
className="group rounded-[28px] border border-slate-200 bg-white p-6 shadow-sm transition hover:-translate-y-0.5 hover:border-blue-300"
>
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-950 text-white">
<BaseIcon path={card.icon} size={24} />
</div>
<h3 className="mt-5 text-xl font-semibold text-slate-900">{card.title}</h3>
<p className="mt-3 text-sm leading-7 text-slate-500">{card.description}</p>
<div className="mt-6 inline-flex items-center text-sm font-semibold text-blue-700">
{card.action}
<BaseIcon path={mdiArrowTopRight} size={18} className="ml-2 transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</div>
</a>
))}
</div>
</section>
</main>
<footer className="border-t border-slate-200 bg-white/70">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-6 py-8 text-sm text-slate-500 lg:flex-row lg:items-center lg:justify-between lg:px-8">
<div>
<p className="font-semibold text-slate-900">Furniture SMM AI Agent</p>
<p className="mt-1">Публичный лендинг + рабочий вход в админку и Agent Studio.</p>
</div>
<BaseButtons mb="mb-0" classAddon="mr-2 last:mr-0 mb-0" noWrap>
<BaseButton href="/agent-studio" label="Agent Studio" color="lightDark" />
<BaseButton href="/login" label="Login / Admin" color="info" />
</BaseButtons>
</div>
</footer>
</div>
</>
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
Home.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -1,9 +1,7 @@
import React, { ReactElement, useEffect, useState } from 'react';
import Head from 'next/head';
import 'react-datepicker/dist/react-datepicker.css';
import { useAppDispatch } from '../stores/hooks';
import { useAppSelector } from '../stores/hooks';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
import { useRouter } from 'next/router';
import LayoutAuthenticated from '../layouts/Authenticated';