849 lines
37 KiB
JavaScript
849 lines
37 KiB
JavaScript
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);
|
||
});
|