diff --git a/package.json b/package.json index 3523b06..1b1bb17 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,9 @@ "graphql:schema": "graphql-codegen -p introspect", "endpoint:generate": "node scripts/endpoint-generate.mjs", "seed:categories": "node scripts/seed-strapi-categories.mjs", - "seed:vendors": "node scripts/seed-strapi-vendors.mjs" + "seed:vendors": "node scripts/seed-strapi-vendors.mjs", + "seed:home-page": "node scripts/seed-strapi-home-page.mjs", + "seed:site-config": "node scripts/seed-strapi-site-config.mjs" }, "dependencies": { "@astrojs/node": "^9.5.5", diff --git a/scripts/seed-strapi-home-page.mjs b/scripts/seed-strapi-home-page.mjs new file mode 100644 index 0000000..f6b8ac7 --- /dev/null +++ b/scripts/seed-strapi-home-page.mjs @@ -0,0 +1,241 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, ".."); +const sourceFile = path.join(root, "src", "content", "home-page.json"); + +const defaultEndpoint = "http://localhost:1337/graphql"; +const endpoint = + process.env.STRAPI_GRAPHQL_URL ?? + process.env.PUBLIC_GRAPHQL_URL ?? + defaultEndpoint; + +const token = + process.env.STRAPI_TOKEN ?? + process.env.STRAPI_API_TOKEN ?? + process.env.AUTH_TOKEN; +const isDryRun = process.argv.includes("--dry-run"); + +function readSource() { + if (!fs.existsSync(sourceFile)) { + throw new Error(`Missing source file: ${path.relative(root, sourceFile)}`); + } + return JSON.parse(fs.readFileSync(sourceFile, "utf8")); +} + +// Drop null/undefined keys so optional Strapi fields (e.g. anchor) are omitted +// rather than sent as explicit nulls. +function compact(object) { + return Object.fromEntries( + Object.entries(object).filter(([, value]) => value !== null && value !== undefined) + ); +} + +// Maps a source section ("type") to its Strapi dynamic-zone component payload. +// resolveCategory turns a category slug into the documentId Strapi expects for +// the card -> category relation. +const SECTION_BUILDERS = { + hero: (section) => + compact({ + __component: "sections.home-hero", + title: section.title, + description: section.description, + anchor: section.anchor, + badge: section.badge, + stats: (section.stats ?? []).map((stat) => ({ + key: stat.key, + value: stat.value, + })), + }), + services: (section, resolveCategory) => + compact({ + __component: "sections.services", + title: section.title, + description: section.description, + anchor: section.anchor, + cards: (section.cards ?? []).map((card) => + compact({ + title: card.title, + description: card.description, + icon: { value: card.icon }, + color: { value: card.color }, + category: resolveCategory(card.category), + }) + ), + }), + solutions: (section, resolveCategory) => + compact({ + __component: "sections.solutions", + title: section.title, + description: section.description, + anchor: section.anchor, + cards: (section.cards ?? []).map((card) => + compact({ + title: card.title, + description: card.description, + footnote: card.footnote, + icon: { value: card.icon }, + category: resolveCategory(card.category), + }) + ), + }), + cases: (section) => + compact({ + __component: "sections.cases", + title: section.title, + description: section.description, + anchor: section.anchor, + cards: (section.cards ?? []).map((card) => + compact({ + title: card.title, + description: card.description, + pretitle: card.pretitle, + badge: card.badge, + footnote: card.footnote, + color: { value: card.color }, + icon: { value: card.icon }, + }) + ), + }), + about: (section) => + compact({ + __component: "sections.about", + title: section.title, + description: section.description, + anchor: section.anchor, + partners: (section.partners ?? []).map((value) => ({ value })), + cards: (section.cards ?? []).map((card) => + compact({ + title: card.title, + description: card.description, + color: { value: card.color }, + icon: { value: card.icon }, + }) + ), + }), +}; + +async function gql(query, variables = {}) { + const headers = { + "Content-Type": "application/json", + Accept: "application/graphql-response+json, application/json", + }; + if (token) headers.Authorization = `Bearer ${token}`; + + const response = await fetch(endpoint, { + method: "POST", + headers, + body: JSON.stringify({ query, variables }), + }); + + if (!response.ok) { + throw new Error(`GraphQL HTTP ${response.status}: ${response.statusText}`); + } + + const payload = await response.json(); + if (payload.errors?.length) { + const messages = payload.errors.map((err) => err.message).join("; "); + throw new Error(`GraphQL error: ${messages}`); + } + + return payload.data; +} + +async function fetchCategoryDocumentIds() { + const query = ` + query CategoryIds { + categories(pagination: { limit: 500 }) { + documentId + slug + } + } + `; + + const data = await gql(query); + const map = new Map(); + for (const category of data?.categories ?? []) { + if (!category?.slug || !category?.documentId) continue; + map.set(category.slug, category.documentId); + } + return map; +} + +function buildSections(source, categoryIdBySlug) { + const sections = Array.isArray(source.sections) ? source.sections : []; + + const resolveCategory = (slug) => { + const documentId = categoryIdBySlug.get(slug); + if (!documentId) { + throw new Error( + `Category "${slug}" is missing in Strapi. Seed categories before seeding the home page.` + ); + } + return documentId; + }; + + return sections.map((section) => { + const build = SECTION_BUILDERS[section.type]; + if (!build) { + throw new Error(`Unknown home-page section type "${String(section.type)}".`); + } + return build(section, resolveCategory); + }); +} + +async function updateHomePage(sections) { + const mutation = ` + mutation UpdateHomePage($data: HomePageInput!) { + updateHomePage(data: $data) { + sections { + __typename + } + } + } + `; + + const data = await gql(mutation, { data: { sections } }); + return data?.updateHomePage; +} + +async function main() { + console.log(`Seeding home page to ${endpoint}${isDryRun ? " (dry-run)" : ""}`); + if (!token) { + console.warn("No STRAPI token provided. If endpoint is protected, set STRAPI_TOKEN."); + } + + const source = readSource(); + + if (isDryRun && process.argv.includes("--offline")) { + // Allow inspecting the payload shape without touching Strapi. + const sections = buildSections(source, new Map( + (source.sections ?? []) + .flatMap((section) => section.cards ?? []) + .map((card) => card.category) + .filter(Boolean) + .map((slug) => [slug, `dryrun-${slug}`]) + )); + console.log(JSON.stringify(sections, null, 2)); + return; + } + + const categoryIdBySlug = await fetchCategoryDocumentIds(); + const sections = buildSections(source, categoryIdBySlug); + + if (isDryRun) { + console.log(JSON.stringify(sections, null, 2)); + console.log(`DRY-RUN UPDATE homePage (${sections.length} sections)`); + return; + } + + const updated = await updateHomePage(sections); + console.log(`UPDATE homePage (${updated.sections.length} sections)`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/seed-strapi-site-config.mjs b/scripts/seed-strapi-site-config.mjs new file mode 100644 index 0000000..afbd218 --- /dev/null +++ b/scripts/seed-strapi-site-config.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, ".."); +const sourceFile = path.join(root, "src", "content", "site-config.json"); + +const defaultEndpoint = "http://localhost:1337/graphql"; +const endpoint = + process.env.STRAPI_GRAPHQL_URL ?? + process.env.PUBLIC_GRAPHQL_URL ?? + defaultEndpoint; + +const token = + process.env.STRAPI_TOKEN ?? + process.env.STRAPI_API_TOKEN ?? + process.env.AUTH_TOKEN; +const isDryRun = process.argv.includes("--dry-run"); + +function readSource() { + if (!fs.existsSync(sourceFile)) { + throw new Error(`Missing source file: ${path.relative(root, sourceFile)}`); + } + return JSON.parse(fs.readFileSync(sourceFile, "utf8")); +} + +function buildInput(source) { + const contacts = Array.isArray(source.contacts) ? source.contacts : []; + const input = { + title: source.title, + description: source.description ?? null, + contacts: contacts.map((contact) => ({ + type: contact.type, + value: contact.value, + displayValue: contact.displayValue, + })), + }; + // privacyPolicy / termsOfUse are relations (single types) referenced by id. + // They are seeded separately; only forward them when an id is provided. + if (source.privacyPolicy?.id) input.privacyPolicy = source.privacyPolicy.id; + if (source.termsOfUse?.id) input.termsOfUse = source.termsOfUse.id; + return input; +} + +async function gql(query, variables = {}) { + const headers = { + "Content-Type": "application/json", + Accept: "application/graphql-response+json, application/json", + }; + if (token) headers.Authorization = `Bearer ${token}`; + + const response = await fetch(endpoint, { + method: "POST", + headers, + body: JSON.stringify({ query, variables }), + }); + + if (!response.ok) { + throw new Error(`GraphQL HTTP ${response.status}: ${response.statusText}`); + } + + const payload = await response.json(); + if (payload.errors?.length) { + const messages = payload.errors.map((err) => err.message).join("; "); + throw new Error(`GraphQL error: ${messages}`); + } + + return payload.data; +} + +async function updateSiteConfig(input) { + const mutation = ` + mutation UpdateSiteConfig($data: SiteConfigInput!) { + updateSiteConfig(data: $data) { + title + contacts { + type + value + } + } + } + `; + + const data = await gql(mutation, { data: input }); + return data?.updateSiteConfig; +} + +async function main() { + console.log(`Seeding site config to ${endpoint}${isDryRun ? " (dry-run)" : ""}`); + if (!token) { + console.warn("No STRAPI token provided. If endpoint is protected, set STRAPI_TOKEN."); + } + + const source = readSource(); + const input = buildInput(source); + + if (isDryRun) { + console.log(JSON.stringify(input, null, 2)); + console.log(`DRY-RUN UPDATE siteConfig (${input.contacts.length} contacts)`); + return; + } + + const updated = await updateSiteConfig(input); + console.log( + `UPDATE siteConfig -> ${updated.title} (${updated.contacts.length} contacts)` + ); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/content.config.ts b/src/content.config.ts index b4e8811..be12ce4 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -1,5 +1,36 @@ import { defineCollection, z } from 'astro:content' -import { glob } from 'astro/loaders' +import { file, glob } from 'astro/loaders' + +const iconValue = z.enum([ + 'cloud', + 'shield', + 'cpu', + 'database', + 'server', + 'settings', + 'building', + 'shopping_cart', + 'factory', + 'heart', + 'graduation_cap', + 'landmark', + 'rocket', + 'zap', + 'award', + 'users', + 'check_circle', + 'package', +]) + +const colorValue = z.enum([ + 'blue', + 'violet', + 'emerald', + 'orange', + 'pink', + 'indigo', + 'cyan', +]) const vendorSchema = z.object({ name: z.string(), @@ -18,4 +49,115 @@ const categories = defineCollection({ }), }) -export const collections = { categories } +const heroSection = z.object({ + type: z.literal('hero'), + title: z.string(), + description: z.string(), + anchor: z.string().nullable(), + badge: z.string(), + stats: z.array(z.object({ key: z.string(), value: z.string() })), +}) + +const servicesSection = z.object({ + type: z.literal('services'), + title: z.string(), + description: z.string(), + anchor: z.string().nullable(), + cards: z.array( + z.object({ + title: z.string(), + description: z.string(), + icon: iconValue, + color: colorValue, + category: z.string(), + }), + ), +}) + +const solutionsSection = z.object({ + type: z.literal('solutions'), + title: z.string(), + description: z.string(), + anchor: z.string().nullable(), + cards: z.array( + z.object({ + title: z.string(), + description: z.string(), + footnote: z.string(), + icon: iconValue, + category: z.string(), + }), + ), +}) + +const casesSection = z.object({ + type: z.literal('cases'), + title: z.string(), + description: z.string(), + anchor: z.string().nullable(), + cards: z.array( + z.object({ + title: z.string(), + description: z.string(), + pretitle: z.string(), + badge: z.string(), + footnote: z.string(), + color: colorValue, + icon: iconValue, + }), + ), +}) + +const aboutSection = z.object({ + type: z.literal('about'), + title: z.string(), + description: z.string(), + anchor: z.string().nullable(), + partners: z.array(z.string()), + cards: z.array( + z.object({ + title: z.string(), + description: z.string(), + color: colorValue, + icon: iconValue, + }), + ), +}) + +const homePage = defineCollection({ + loader: file('./src/content/home-page.json', { + parser: (text) => ({ 'home-page': JSON.parse(text) }), + }), + schema: z.object({ + sections: z.array( + z.discriminatedUnion('type', [ + heroSection, + servicesSection, + solutionsSection, + casesSection, + aboutSection, + ]), + ), + }), +}) + +const contactSchema = z.object({ + type: z.enum(['phone', 'working_hours', 'email', 'address']), + value: z.string(), + displayValue: z.string(), +}) + +const siteConfig = defineCollection({ + loader: file('./src/content/site-config.json', { + parser: (text) => ({ 'site-config': JSON.parse(text) }), + }), + schema: z.object({ + title: z.string(), + description: z.string().nullable(), + contacts: z.array(contactSchema), + privacyPolicy: z.object({ url: z.string() }).nullable(), + termsOfUse: z.object({ url: z.string() }).nullable(), + }), +}) + +export const collections = { categories, homePage, siteConfig } diff --git a/src/content/home-page.json b/src/content/home-page.json new file mode 100644 index 0000000..8d912ae --- /dev/null +++ b/src/content/home-page.json @@ -0,0 +1,196 @@ +{ + "sections": [ + { + "type": "hero", + "title": "IT-решения для роста вашего бизнеса", + "description": "Проектируем, внедряем и сопровождаем корпоративные IT-системы. Облачная инфраструктура, безопасность, автоматизация процессов.", + "anchor": null, + "badge": "Надежный партнер с 2021 года", + "stats": [ + { "key": "клиентов", "value": "300+" }, + { "key": "партнерств", "value": "50+" }, + { "key": "повторных обращений", "value": "95%" } + ] + }, + { + "type": "services", + "title": "Комплексные IT-решения для вашего бизнеса", + "description": "Полный цикл: от стратегии до технической поддержки", + "anchor": null, + "cards": [ + { + "title": "Облачные решения", + "description": "Миграция и управление инфраструктурой на базе Яндекс.Облако и VK Cloud", + "icon": "cloud", + "color": "blue", + "category": "cloud" + }, + { + "title": "Кибербезопасность", + "description": "Комплексная защита периметра и данных с решениями Kaspersky", + "icon": "shield", + "color": "violet", + "category": "cybersecurity" + }, + { + "title": "Цифровая трансформация", + "description": "Автоматизация процессов на платформах 1С и Галактика", + "icon": "cpu", + "color": "emerald", + "category": "implementation" + }, + { + "title": "Управление данными", + "description": "Развертывание и поддержка СУБД Postgres Pro", + "icon": "database", + "color": "orange", + "category": "integration" + }, + { + "title": "Серверная инфраструктура", + "description": "Поставка и настройка оборудования Kraftway", + "icon": "server", + "color": "pink", + "category": "cloud" + }, + { + "title": "Системная интеграция", + "description": "Построение корпоративной ИТ-архитектуры на Astra Linux", + "icon": "settings", + "color": "indigo", + "category": "industrial-solutions" + } + ] + }, + { + "type": "solutions", + "title": "Решения под ключ для разных отраслей", + "description": "Глубокое понимание специфики бизнеса в каждой отрасли", + "anchor": null, + "cards": [ + { + "title": "Корпоративный сектор", + "description": "ERP, CRM, автоматизация документооборота", + "footnote": "1200+ проектов", + "icon": "building", + "category": "business" + }, + { + "title": "Ритейл и e-commerce", + "description": "Омниканальные платформы продаж", + "footnote": "850+ проектов", + "icon": "shopping_cart", + "category": "business" + }, + { + "title": "Производство", + "description": "IoT, системы управления производством", + "footnote": "650+ проектов", + "icon": "factory", + "category": "specialized-services" + }, + { + "title": "Здравоохранение", + "description": "Медицинские информационные системы", + "footnote": "420+ проектов", + "icon": "heart", + "category": "implementation" + }, + { + "title": "Образование", + "description": "Платформы дистанционного обучения", + "footnote": "380+ проектов", + "icon": "graduation_cap", + "category": "specialized-services" + }, + { + "title": "Государственный сектор", + "description": "Цифровизация госуслуг", + "footnote": "500+ проектов", + "icon": "landmark", + "category": "government" + } + ] + }, + { + "type": "cases", + "title": "Реализованные проекты", + "description": "Примеры успешного внедрения с конкретными результатами", + "anchor": null, + "cards": [ + { + "title": "Миграция в Яндекс.Облако", + "description": "Перенос корпоративной инфраструктуры 500+ серверов с нулевым простоем", + "pretitle": "Торговая сеть", + "badge": "Облако", + "footnote": "−40% TCO", + "color": "blue", + "icon": "cloud" + }, + { + "title": "Внедрение 1С:ERP", + "description": "Автоматизация производственного и финансового учета для 15 предприятий", + "pretitle": "Производственный холдинг", + "badge": "Автоматизация", + "footnote": "+25% эффективность", + "color": "emerald", + "icon": "cpu" + }, + { + "title": "Защита периметра Kaspersky", + "description": "Построение многоуровневой системы защиты критичной инфраструктуры", + "pretitle": "Финансовая группа", + "badge": "Безопасность", + "footnote": "99.9% uptime", + "color": "violet", + "icon": "shield" + } + ] + }, + { + "type": "about", + "title": "Сотрудничаем с", + "description": "Комплексные решения для цифровой трансформации бизнеса. Работаем с 2021 года.", + "anchor": null, + "partners": ["Видеомост", "Jazz Telecom", "СКБ Контур"], + "cards": [ + { + "title": "Поставка оборудования", + "description": "Комплексные поставки серверов, СХД и сетевого оборудования от российских производителей.", + "color": "blue", + "icon": "package" + }, + { + "title": "Официальное партнерство", + "description": "Статус золотого партнера ведущих российских вендоров. Прямые контракты и поддержка.", + "color": "emerald", + "icon": "award" + }, + { + "title": "Сертифицированные специалисты", + "description": "Инженеры с подтвержденной квалификацией по всем внедряемым решениям и платформам.", + "color": "violet", + "icon": "check_circle" + }, + { + "title": "Оперативный отклик", + "description": "Быстрое реагирование на запросы клиента. Четкие сроки решения задач и прозрачная коммуникация.", + "color": "orange", + "icon": "zap" + }, + { + "title": "Выделенная команда", + "description": "Персональный менеджер проекта и команда сертифицированных специалистов.", + "color": "cyan", + "icon": "users" + }, + { + "title": "Быстрый старт", + "description": "Запуск типовых проектов за 2 недели. Пилотное внедрение перед полным развертываием.", + "color": "pink", + "icon": "rocket" + } + ] + } + ] +} diff --git a/src/content/site-config.json b/src/content/site-config.json new file mode 100644 index 0000000..762ba20 --- /dev/null +++ b/src/content/site-config.json @@ -0,0 +1,28 @@ +{ + "title": "СОФТКЛИК", + "description": null, + "contacts": [ + { + "type": "phone", + "value": "+74951234567", + "displayValue": "+7 (495) 123-45-67" + }, + { + "type": "working_hours", + "value": "Пн-Пт 9:00-18:00", + "displayValue": "Пн-Пт 9:00-18:00" + }, + { + "type": "email", + "value": "mailto:info@softclick.ru", + "displayValue": "info@softclick.ru" + }, + { + "type": "address", + "value": "Москва, Пресненская наб., 12", + "displayValue": "Москва, Пресненская наб., 12" + } + ], + "privacyPolicy": null, + "termsOfUse": null +}