123
This commit is contained in:
parent
b7815f3517
commit
de3ba95d58
848
backend/scripts/seed-furniture-demo-data.js
Normal file
848
backend/scripts/seed-furniture-demo-data.js
Normal 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);
|
||||
});
|
||||
@ -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 = [
|
||||
|
||||
|
||||
@ -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 = [
|
||||
|
||||
|
||||
@ -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 = [
|
||||
|
||||
|
||||
@ -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 = [
|
||||
|
||||
|
||||
@ -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 = [
|
||||
|
||||
|
||||
@ -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 = [
|
||||
|
||||
|
||||
@ -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 = [
|
||||
|
||||
|
||||
@ -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 = [
|
||||
|
||||
|
||||
@ -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 = [
|
||||
|
||||
|
||||
@ -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 = [
|
||||
|
||||
|
||||
@ -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 = [
|
||||
|
||||
|
||||
@ -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 = [
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
|
||||
2197
frontend/src/pages/agent-studio.tsx
Normal file
2197
frontend/src/pages/agent-studio.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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>;
|
||||
};
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user