825 lines
29 KiB
TypeScript
825 lines
29 KiB
TypeScript
import { list } from "@keystone-6/core";
|
||
import { allowAll } from "@keystone-6/core/access";
|
||
import { text, password, timestamp, image, relationship, select, integer, checkbox } from "@keystone-6/core/fields";
|
||
import { document } from '@keystone-6/fields-document';
|
||
import { type Lists } from ".keystone/types";
|
||
|
||
export const lists: Lists = {
|
||
// 👤 Пользователь
|
||
User: list({
|
||
access: allowAll,
|
||
fields: {
|
||
name: text({ validation: { isRequired: true } }),
|
||
email: text({ validation: { isRequired: true }, isIndexed: "unique" }),
|
||
password: password({ validation: { isRequired: true } }),
|
||
createdAt: timestamp({ defaultValue: { kind: "now" } }),
|
||
},
|
||
}),
|
||
System: list({
|
||
access: allowAll,
|
||
|
||
ui: {
|
||
label: 'System settings',
|
||
description: 'Глобальные настройки сайта',
|
||
listView: { initialColumns: ['siteTitle', 'updatedAt'] },
|
||
},
|
||
|
||
fields: {
|
||
siteTitle: text({
|
||
validation: { isRequired: true },
|
||
ui: { description: 'Title сайта (metatitle / отображение в шапке и т.п.)' },
|
||
}),
|
||
|
||
linedUrl: text({
|
||
validation: { isRequired: true },
|
||
ui: { description: 'LinedIn url' },
|
||
}),
|
||
|
||
headerLogo: image({
|
||
storage: 'local_images',
|
||
ui: { description: 'Логотип для хедера (светлый/темный вариант по макету)' },
|
||
}),
|
||
|
||
footerLogo: image({
|
||
storage: 'local_images',
|
||
ui: { description: 'Логотип для футера' },
|
||
}),
|
||
|
||
footerDisclaimer: document({
|
||
ui: { description: 'Дисклеймер в футере (rich-text)' },
|
||
formatting: {
|
||
headingLevels: [1, 2, 3, 4],
|
||
inlineMarks: {
|
||
bold: true, italic: true, underline: true, strikethrough: true, code: true,
|
||
superscript: true, subscript: true,
|
||
},
|
||
listTypes: { ordered: true, unordered: true },
|
||
alignment: { center: true, end: true },
|
||
blockTypes: { blockquote: true, code: true },
|
||
softBreaks: true,
|
||
},
|
||
links: true,
|
||
dividers: true,
|
||
}),
|
||
|
||
updatedAt: timestamp({
|
||
defaultValue: { kind: 'now' },
|
||
ui: { itemView: { fieldMode: 'read' } },
|
||
}),
|
||
},
|
||
|
||
hooks: {
|
||
async beforeOperation({ operation, context, listKey }) {
|
||
// Разрешаем только ОДНУ запись
|
||
if (operation === 'create') {
|
||
const count = await context.db[listKey].count({});
|
||
if (count > 0) throw new Error('Можно создать только одну запись System');
|
||
}
|
||
// Запрет удаления (чтобы случайно не потерять глобальные настройки)
|
||
if (operation === 'delete') {
|
||
throw new Error('Нельзя удалить System; измените существующую запись.');
|
||
}
|
||
},
|
||
async resolveInput({ operation, resolvedData }) {
|
||
if (operation === 'update') {
|
||
resolvedData.updatedAt = new Date().toISOString();
|
||
}
|
||
return resolvedData;
|
||
},
|
||
},
|
||
}),
|
||
NavItem: list({
|
||
access: allowAll,
|
||
ui: {
|
||
label: 'Navigation',
|
||
labelField: 'label',
|
||
listView: { initialColumns: ['label', 'href', 'order', 'parent'] },
|
||
},
|
||
fields: {
|
||
label: text({ validation: { isRequired: true } }),
|
||
href: text({
|
||
validation: { isRequired: true },
|
||
ui: { description: 'Например: /about, #contact, https://example.com' },
|
||
}),
|
||
order: integer({ defaultValue: 0 }),
|
||
isExternal: checkbox({
|
||
defaultValue: false,
|
||
ui: { description: 'Открывать в новой вкладке (target=_blank)' },
|
||
}),
|
||
enabled: checkbox({ defaultValue: true }),
|
||
|
||
// древовидная структура (подменю)
|
||
parent: relationship({ ref: 'NavItem.children' }),
|
||
children: relationship({
|
||
ref: 'NavItem.parent',
|
||
many: true,
|
||
ui: {
|
||
displayMode: 'cards',
|
||
cardFields: ['label', 'href', 'order', 'isExternal', 'enabled'],
|
||
inlineCreate: { fields: ['label', 'href', 'order', 'isExternal', 'enabled'] },
|
||
inlineEdit: { fields: ['label', 'href', 'order', 'isExternal', 'enabled'] },
|
||
},
|
||
}),
|
||
},
|
||
}),
|
||
|
||
// 🟩 Hero (как было)
|
||
HeroSection: list({
|
||
access: allowAll,
|
||
ui: { label: "Hero Section", listView: { initialColumns: ["title"] } },
|
||
fields: {
|
||
title: text({ validation: { isRequired: true }, ui: { description: "Главный заголовок секции" } }),
|
||
description: text({ ui: { displayMode: "textarea", description: "Подзаголовок / описание" } }),
|
||
backgroundImage: image({ storage: "local_images", ui: { description: "Фоновая картинка (1360x640)" } }),
|
||
buttonText: text({ ui: { description: "Текст кнопки" } }),
|
||
buttonLink: text({ ui: { description: "Ссылка кнопки (например: /contact)" } }),
|
||
},
|
||
hooks: {
|
||
async beforeOperation({ operation, listKey, context }) {
|
||
if (operation === "create") {
|
||
const existing = await context.db[listKey].findMany({ take: 1 });
|
||
if (existing.length > 0) throw new Error("Можно создать только один HeroSection");
|
||
}
|
||
},
|
||
},
|
||
}),
|
||
|
||
// 🟨 Универсальная плитка секции (и «статистика», и «финансы»)
|
||
FinanceRow: list({
|
||
access: allowAll,
|
||
ui: {
|
||
label: "Finance Row",
|
||
listView: { initialColumns: ["order", "value", "label", "trend"] },
|
||
},
|
||
fields: {
|
||
value: text({ validation: { isRequired: true } }), // напр. "$2,05 bln" или "5 mln"
|
||
label: text({ validation: { isRequired: true } }), // подпись под значением
|
||
trend: select({
|
||
type: "enum",
|
||
options: [
|
||
{ label: "Нет стрелки", value: "none" },
|
||
{ label: "Вверх", value: "up" },
|
||
{ label: "Вниз", value: "down" },
|
||
],
|
||
defaultValue: "none",
|
||
ui: { displayMode: "segmented-control" },
|
||
}),
|
||
order: integer({ defaultValue: 0 }), // для сортировки и «бить по 3»
|
||
section: relationship({ ref: "ScreenSecondSection.rows" }),
|
||
},
|
||
}),
|
||
|
||
// 🟦 Вторая секция (About/Overview) — singleton
|
||
ScreenSecondSection: list({
|
||
access: allowAll,
|
||
ui: { label: "Screen Second", listView: { initialColumns: ["title"] } },
|
||
fields: {
|
||
title: text({
|
||
validation: { isRequired: true },
|
||
ui: { description: "Заголовок секции (About Freedom Holding Corp)" },
|
||
}),
|
||
description: text({
|
||
ui: { displayMode: "textarea", description: "Описание под заголовком" },
|
||
}),
|
||
logo: image({ storage: "local_images", ui: { description: "Логотип справа" } }),
|
||
rows: relationship({
|
||
ref: "FinanceRow.section",
|
||
many: true,
|
||
ui: {
|
||
displayMode: "cards",
|
||
cardFields: ["value", "label", "trend", "order"],
|
||
inlineCreate: { fields: ["value", "label", "trend", "order"] },
|
||
inlineEdit: { fields: ["value", "label", "trend", "order"] },
|
||
linkToItem: true,
|
||
},
|
||
}),
|
||
footnote: text({ ui: { description: "Сноска (например: * for the year ended ...)" } }),
|
||
},
|
||
hooks: {
|
||
async beforeOperation({ operation, listKey, context }) {
|
||
if (operation === "create") {
|
||
const existing = await context.db[listKey].findMany({ take: 1 });
|
||
if (existing.length > 0) throw new Error("Можно создать только один ScreenSecondSection");
|
||
}
|
||
},
|
||
},
|
||
}),
|
||
ScreenThreeStatCard: list({
|
||
access: allowAll,
|
||
ui: {
|
||
label: 'Screen3 • Stat Card',
|
||
listView: { initialColumns: ['order', 'value', 'label', 'color'] },
|
||
},
|
||
fields: {
|
||
value: text({ validation: { isRequired: true } }), // напр. "USD ~ 10.8 bn*"
|
||
label: text({ validation: { isRequired: true } }), // "Current market capitalization"
|
||
color: text({
|
||
ui: { description: 'HEX цвет значения (например #16a34a). Можно оставить пустым.' },
|
||
}),
|
||
order: integer({ defaultValue: 0 }),
|
||
section: relationship({ ref: 'ScreenThreeSection.statCards' }),
|
||
},
|
||
}),
|
||
|
||
/* 🟨 Элемент списка индексов (bullet) */
|
||
ScreenThreeIndex: list({
|
||
access: allowAll,
|
||
ui: {
|
||
label: 'Screen3 • Index Item',
|
||
listView: { initialColumns: ['order', 'text'] },
|
||
},
|
||
fields: {
|
||
text: text({ validation: { isRequired: true } }), // напр. "MSCI U.S. Small Cap 1750"
|
||
order: integer({ defaultValue: 0 }),
|
||
section: relationship({ ref: 'ScreenThreeSection.indexes' }),
|
||
},
|
||
}),
|
||
|
||
/* 🟦 Секция Financial performance (singleton) */
|
||
ScreenThreeSection: list({
|
||
access: allowAll,
|
||
ui: {
|
||
label: 'Screen Three (Financial performance)',
|
||
listView: { initialColumns: ['title'] },
|
||
},
|
||
fields: {
|
||
title: text({
|
||
validation: { isRequired: true },
|
||
ui: { description: 'Заголовок секции (например: Financial performance)' },
|
||
}),
|
||
description: text({
|
||
ui: { displayMode: 'textarea', description: 'Описание под заголовком' },
|
||
}),
|
||
|
||
// Карточки справа (две у тебя в макете, но можно больше/меньше)
|
||
statCards: relationship({
|
||
ref: 'ScreenThreeStatCard.section',
|
||
many: true,
|
||
ui: {
|
||
displayMode: 'cards',
|
||
cardFields: ['value', 'label', 'color', 'order'],
|
||
inlineCreate: { fields: ['value', 'label', 'color', 'order'] },
|
||
inlineEdit: { fields: ['value', 'label', 'color', 'order'] },
|
||
linkToItem: true,
|
||
},
|
||
}),
|
||
|
||
// Список индексов (буллиты)
|
||
indexes: relationship({
|
||
ref: 'ScreenThreeIndex.section',
|
||
many: true,
|
||
ui: {
|
||
displayMode: 'cards',
|
||
cardFields: ['text', 'order'],
|
||
inlineCreate: { fields: ['text', 'order'] },
|
||
inlineEdit: { fields: ['text', 'order'] },
|
||
linkToItem: true,
|
||
},
|
||
}),
|
||
|
||
footnote: text({
|
||
ui: { description: 'Сноска под секцией (например: * Based on the price ...)' },
|
||
}),
|
||
},
|
||
|
||
hooks: {
|
||
// singleton: разрешаем только одну запись
|
||
async beforeOperation({ operation, listKey, context }) {
|
||
if (operation === 'create') {
|
||
const existing = await context.db[listKey].findMany({ take: 1 });
|
||
if (existing.length > 0) {
|
||
throw new Error('Можно создать только одну ScreenThreeSection');
|
||
}
|
||
}
|
||
},
|
||
},
|
||
}),
|
||
KeyBusinessSegmentsSection: list({
|
||
access: allowAll,
|
||
ui: { label: "Key Business Segments (Section)", listView: { initialColumns: ["title"] } },
|
||
fields: {
|
||
title: text({ validation: { isRequired: true } }),
|
||
segments: relationship({
|
||
ref: "KeySegment.section",
|
||
many: true,
|
||
ui: {
|
||
displayMode: "cards",
|
||
cardFields: ["name", "key", "order", "logo"],
|
||
inlineCreate: { fields: ["name", "key", "logo", "order"] },
|
||
inlineEdit: { fields: ["name", "key", "logo", "order"] },
|
||
linkToItem: true,
|
||
},
|
||
}),
|
||
},
|
||
hooks: {
|
||
async beforeOperation({ operation, listKey, context }) {
|
||
if (operation === "create") {
|
||
const exists = await context.db[listKey].findMany({ take: 1 });
|
||
if (exists.length) throw new Error("Можно создать только один KeyBusinessSegmentsSection");
|
||
}
|
||
},
|
||
},
|
||
}),
|
||
|
||
/* Segment */
|
||
KeySegment: list({
|
||
access: allowAll,
|
||
ui: { label: "Key Segment", listView: { initialColumns: ["order", "name", "key"] } },
|
||
fields: {
|
||
key: text({ isIndexed: 'unique', ui: { description: "Код: broker/bank/telecom…" } }),
|
||
name: text({ validation: { isRequired: true } }),
|
||
order: integer({ defaultValue: 0 }),
|
||
logo: image({ storage: 'local_images' }),
|
||
section: relationship({ ref: "KeyBusinessSegmentsSection.segments" }),
|
||
info: relationship({
|
||
ref: "KeySegmentInfo.segment",
|
||
many: true,
|
||
ui: {
|
||
displayMode: "cards",
|
||
cardFields: ["title", "order"],
|
||
inlineCreate: { fields: ["title", "order", "brandLink", "brandLogo"] },
|
||
inlineEdit: { fields: ["title", "order", "brandLink", "brandLogo"] },
|
||
linkToItem: true,
|
||
},
|
||
}),
|
||
},
|
||
}),
|
||
|
||
|
||
/* Info card (column block) */
|
||
KeySegmentInfo: list({
|
||
access: allowAll,
|
||
ui: { label: "Key Segment • Info Block", listView: { initialColumns: ["order", "title"] } },
|
||
fields: {
|
||
title: text({ validation: { isRequired: true } }),
|
||
order: integer({ defaultValue: 0 }),
|
||
segment: relationship({ ref: "KeySegment.info" }),
|
||
|
||
// бренд принадлежит целому блоку
|
||
brandLink: text({ ui: { description: "Ссылка бренда (опц.)" } }),
|
||
brandLogo: image({ storage: 'local_images', ui: { description: "Лого бренда (опц.)" } }),
|
||
|
||
items: relationship({
|
||
ref: "KeySegmentInfoItem.info",
|
||
many: true,
|
||
ui: {
|
||
displayMode: "cards",
|
||
cardFields: ["text", "order", "infoLogo", "colSpan"],
|
||
inlineCreate: { fields: ["text", "infoLogo", "colSpan", "order"] },
|
||
inlineEdit: { fields: ["text", "infoLogo", "colSpan", "order"] },
|
||
linkToItem: true,
|
||
},
|
||
}),
|
||
},
|
||
}),
|
||
|
||
|
||
/* Info list item (только текст + лого) */
|
||
KeySegmentInfoItem: list({
|
||
access: allowAll,
|
||
ui: { label: "Key Segment • Info Item", listView: { initialColumns: ["order", "text", "infoLogo", "colSpan"] } },
|
||
fields: {
|
||
text: text({ validation: { isRequired: true } }),
|
||
infoLogo: image({ storage: 'local_images', ui: { description: "Лого в зелёном квадратике" } }),
|
||
colSpan: select({
|
||
type: 'enum',
|
||
options: [
|
||
{ label: 'Normal (1 column)', value: 'ONE' },
|
||
{ label: 'Wide (2 columns)', value: 'TWO' },
|
||
],
|
||
defaultValue: 'ONE',
|
||
ui: { displayMode: 'segmented-control' },
|
||
}),
|
||
order: integer({ defaultValue: 0 }),
|
||
info: relationship({ ref: "KeySegmentInfo.items" }),
|
||
},
|
||
}),
|
||
|
||
|
||
WhyChooseUsItem: list({
|
||
access: allowAll,
|
||
ui: {
|
||
label: 'Why Choose Us – Item',
|
||
listView: { initialColumns: ['text', 'order', 'section'] },
|
||
},
|
||
fields: {
|
||
text: text({
|
||
validation: { isRequired: true },
|
||
ui: { displayMode: 'textarea' },
|
||
}),
|
||
order: integer({
|
||
defaultValue: 0,
|
||
ui: { description: 'Сортировка по возрастанию' },
|
||
}),
|
||
section: relationship({ ref: 'WhyChooseUsSection.items' }),
|
||
createdAt: timestamp({ defaultValue: { kind: 'now' } }),
|
||
},
|
||
}),
|
||
|
||
WhyChooseUsSection: list({
|
||
access: allowAll,
|
||
ui: {
|
||
label: 'Why Choose Us (section)',
|
||
listView: { initialColumns: ['title'] },
|
||
},
|
||
fields: {
|
||
// Заголовок секции ("Why Choose Us")
|
||
title: text({ defaultValue: 'Why Choose Us' }),
|
||
|
||
// Левая карточка
|
||
leftTitle: text({
|
||
validation: { isRequired: true },
|
||
ui: { description: 'Текст в левой карточке' },
|
||
}),
|
||
leftBackground: image({
|
||
storage: 'local_images',
|
||
ui: { description: 'Фон для левой карточки (например Backgroung_2.png)' },
|
||
}),
|
||
ctaText: text({ defaultValue: 'Partner with us' }),
|
||
ctaLink: text({ ui: { description: 'Ссылка для кнопки (опционально)' } }),
|
||
|
||
// Правая колонка: пункты (много)
|
||
items: relationship({
|
||
ref: 'WhyChooseUsItem.section',
|
||
many: true,
|
||
ui: {
|
||
displayMode: 'cards',
|
||
cardFields: ['text', 'order'],
|
||
inlineCreate: { fields: ['text', 'order'] },
|
||
inlineEdit: { fields: ['text', 'order'] },
|
||
linkToItem: true,
|
||
},
|
||
}),
|
||
}
|
||
}),
|
||
|
||
InnovationSection: list({
|
||
access: allowAll,
|
||
ui: {
|
||
label: 'Innovation (section)',
|
||
listView: {
|
||
initialColumns: ['title', 'updatedAt'],
|
||
},
|
||
},
|
||
fields: {
|
||
// Заголовок секции
|
||
title: text({
|
||
defaultValue: 'Innovation at the intersection of telecom and fintech',
|
||
ui: { description: 'Заголовок над сеткой карточек' },
|
||
}),
|
||
|
||
// ВЕРХ: левая карточка (текст + картинка справа)
|
||
topLeftTitle: text({
|
||
validation: { isRequired: true },
|
||
ui: { description: 'Заголовок левой верхней карточки' },
|
||
}),
|
||
topLeftText: text({
|
||
ui: { displayMode: 'textarea', description: 'Текст левой верхней карточки' },
|
||
}),
|
||
topLeftImage: image({
|
||
storage: 'local_images',
|
||
ui: { description: 'Картинка в правой части левой верхней карточки' },
|
||
}),
|
||
|
||
// ВЕРХ: правая зелёная карточка
|
||
topRightTitle: text({
|
||
validation: { isRequired: true },
|
||
ui: { description: 'Заголовок правой верхней зелёной карточки' },
|
||
}),
|
||
topRightText: text({
|
||
ui: { displayMode: 'textarea', description: 'Текст правой верхней карточки' },
|
||
}),
|
||
|
||
// НИЗ: две карточки
|
||
bottom1Title: text({ ui: { description: 'Заголовок левой нижней карточки' } }),
|
||
bottom1Text: text({
|
||
ui: { displayMode: 'textarea', description: 'Текст левой нижней карточки' },
|
||
}),
|
||
bottom2Title: text({ ui: { description: 'Заголовок правой нижней карточки' } }),
|
||
bottom2Text: text({
|
||
ui: { displayMode: 'textarea', description: 'Текст правой нижней карточки' },
|
||
}),
|
||
|
||
// тех.поля
|
||
createdAt: timestamp({ defaultValue: { kind: 'now' } }),
|
||
updatedAt: timestamp({
|
||
db: { updatedAt: true },
|
||
}),
|
||
},
|
||
|
||
// Singleton: разрешаем только одну запись
|
||
hooks: {
|
||
async beforeOperation({ operation, listKey, context }) {
|
||
if (operation === 'create') {
|
||
const existing = await context.db[listKey].findMany({ where: {} });
|
||
if (existing.length > 0) {
|
||
throw new Error('Можно создать только один InnovationSection');
|
||
}
|
||
}
|
||
},
|
||
},
|
||
}),
|
||
SolutionsSection : list({
|
||
access: allowAll,
|
||
ui: {
|
||
label: 'Solutions (Section)',
|
||
listView: { initialColumns: ['title'] },
|
||
},
|
||
fields: {
|
||
title: text({
|
||
ui: { description: 'Заголовок секции (на фронте "Solutions")' },
|
||
defaultValue: 'Solutions',
|
||
}),
|
||
buttonText: text({
|
||
ui: { description: 'Текст кнопки внизу (CTA)' },
|
||
defaultValue: 'Partner with us',
|
||
}),
|
||
buttonHref: text({
|
||
validation: { isRequired: false },
|
||
ui: { description: "Куда ведёт кнопка 'Partner with us'" },
|
||
}),
|
||
// массив карточек
|
||
items: relationship({
|
||
ref: 'SolutionItem.section',
|
||
many: true,
|
||
ui: {
|
||
displayMode: 'cards',
|
||
cardFields: ['title', 'order', 'image'],
|
||
inlineCreate: { fields: ['title', 'text', 'href', 'image', 'order'] },
|
||
inlineEdit: { fields: ['title', 'text', 'href', 'image', 'order'] },
|
||
linkToItem: true,
|
||
inlineConnect: true,
|
||
},
|
||
}),
|
||
},
|
||
hooks: {
|
||
// Ограничиваем до одной записи
|
||
async beforeOperation({ operation, listKey, context }) {
|
||
if (operation === 'create') {
|
||
const existing = await context.db[listKey].findMany({ take: 1 });
|
||
if (existing.length) throw new Error('Можно создать только один SolutionsSection');
|
||
}
|
||
},
|
||
},
|
||
}),
|
||
|
||
SolutionItem: list({
|
||
access: allowAll,
|
||
ui: {
|
||
label: 'Solutions → Item',
|
||
listView: { initialColumns: ['title', 'order'] },
|
||
},
|
||
fields: {
|
||
title: text({ validation: { isRequired: true } }),
|
||
text: text({ ui: { displayMode: 'textarea' } }),
|
||
href: text({ ui: { description: 'Опциональная ссылка "Learn more"' } }),
|
||
image: image({
|
||
storage: 'local_images',
|
||
ui: { description: 'Фоновая картинка карточки' },
|
||
}),
|
||
order: integer({
|
||
defaultValue: 0,
|
||
ui: { description: 'Порядок сортировки (чем меньше, тем выше)' },
|
||
}),
|
||
// обратная связь к секции
|
||
section: relationship({
|
||
ref: 'SolutionsSection.items',
|
||
ui: { hideCreate: true },
|
||
}),
|
||
},
|
||
}),
|
||
CountriesMapSection: list({
|
||
access: allowAll,
|
||
ui: {
|
||
label: 'Countries Map (Section)',
|
||
listView: { initialColumns: ['title'] },
|
||
},
|
||
fields: {
|
||
title: text({
|
||
ui: { description: 'Опциональный заголовок секции' },
|
||
}),
|
||
countries: relationship({
|
||
ref: 'MapCountry.section',
|
||
many: true,
|
||
ui: {
|
||
displayMode: 'cards',
|
||
cardFields: ['code', 'name', 'isEnabled', 'flag'],
|
||
inlineCreate: { fields: ['code', 'name', 'isEnabled', 'flag'] },
|
||
inlineEdit: { fields: ['name', 'isEnabled', 'flag'] },
|
||
linkToItem: true,
|
||
},
|
||
}),
|
||
},
|
||
hooks: {
|
||
async beforeOperation({ operation, listKey, context }) {
|
||
if (operation === 'create') {
|
||
const existing = await context.db[listKey].findMany({ where: {} });
|
||
if (existing.length > 0) {
|
||
throw new Error('Можно создать только одну CountriesMapSection');
|
||
}
|
||
}
|
||
},
|
||
},
|
||
}),
|
||
|
||
/* === Страна на карте === */
|
||
MapCountry: list({
|
||
access: allowAll,
|
||
ui: {
|
||
label: 'Countries',
|
||
listView: { initialColumns: ['code', 'name', 'isEnabled'] },
|
||
},
|
||
fields: {
|
||
section: relationship({
|
||
ref: 'CountriesMapSection.countries',
|
||
ui: { hideCreate: true },
|
||
}),
|
||
code: select({
|
||
type: 'enum',
|
||
// value — то, что увидит фронт; label — удобочитаемое имя в админке
|
||
options: [
|
||
{ label: 'United States of America', value: 'usa' },
|
||
{ label: 'Kazakhstan', value: 'kaz' },
|
||
{ label: 'Uzbekistan', value: 'uzb' },
|
||
{ label: 'Kyrgyzstan', value: 'kgz' },
|
||
{ label: 'Tajikistan', value: 'tjk' },
|
||
{ label: 'Turkey', value: 'tur' },
|
||
{ label: 'Cyprus', value: 'cyp' },
|
||
{ label: 'Armenia', value: 'arm' },
|
||
{ label: 'United Arab Emirates', value: 'uae' },
|
||
],
|
||
validation: { isRequired: true },
|
||
defaultValue: 'kaz',
|
||
ui: { description: 'Код страны (фиксированный набор для фронта)' },
|
||
isIndexed: 'unique', // одна запись на код
|
||
}),
|
||
name: text({
|
||
validation: { isRequired: true },
|
||
ui: { description: 'Подпись на карточке (можно локализовать)' },
|
||
}),
|
||
isEnabled: checkbox({
|
||
defaultValue: true,
|
||
ui: { description: 'Показывать страну на карте' },
|
||
}),
|
||
flag: image({
|
||
storage: 'local_images',
|
||
ui: { description: 'Иконка флага (SVG/PNG), будет вместо зелёной точки' },
|
||
}),
|
||
items: relationship({
|
||
ref: 'MapCountryItem.country',
|
||
many: true,
|
||
ui: {
|
||
displayMode: 'cards',
|
||
cardFields: ['text', 'order'],
|
||
inlineCreate: { fields: ['text', 'order'] },
|
||
inlineEdit: { fields: ['text', 'order'] },
|
||
linkToItem: false,
|
||
},
|
||
}),
|
||
order: integer({
|
||
defaultValue: 0,
|
||
ui: { description: 'Произвольная сортировка стран (меньше — раньше)' },
|
||
}),
|
||
},
|
||
}),
|
||
|
||
/* === Пункты внутри карточки страны === */
|
||
MapCountryItem: list({
|
||
access: allowAll,
|
||
ui: {
|
||
label: 'Country Items',
|
||
isHidden: false,
|
||
listView: { initialColumns: ['country', 'text', 'order'] },
|
||
},
|
||
fields: {
|
||
country: relationship({
|
||
ref: 'MapCountry.items',
|
||
ui: { hideCreate: true },
|
||
}),
|
||
text: text({
|
||
validation: { isRequired: true },
|
||
ui: { description: 'Строка в списке (bullet)' },
|
||
}),
|
||
order: integer({
|
||
defaultValue: 0,
|
||
ui: { description: 'Порядок внутри карточки' },
|
||
}),
|
||
},
|
||
}),
|
||
|
||
ContactRequest: list({
|
||
access: {
|
||
// Создавать может кто угодно, читать/править — только авторизованные (админка)
|
||
operation: {
|
||
query: ({ session }) => !!session,
|
||
create: allowAll,
|
||
update: ({ session }) => !!session,
|
||
delete: ({ session }) => !!session,
|
||
},
|
||
},
|
||
ui: {
|
||
label: 'Contact requests',
|
||
listView: {
|
||
initialColumns: ['createdAt', 'name', 'email', 'company', 'message', 'countryCode'],
|
||
initialSort: { field: 'createdAt', direction: 'DESC' },
|
||
},
|
||
},
|
||
fields: {
|
||
name: text({ validation: { isRequired: true } }),
|
||
company: text(),
|
||
message: text(),
|
||
position: text(),
|
||
email: text({ validation: { isRequired: true } }),
|
||
phone: text(),
|
||
countryCode: text({ ui: { description: 'ISO-код страны (например, US)' } }),
|
||
createdAt: timestamp({ defaultValue: { kind: 'now' } }),
|
||
},
|
||
hooks: {
|
||
// после успешного создания — отправляем письмо
|
||
afterOperation: async ({ operation, item }) => {
|
||
if (operation !== 'create' || !item) return;
|
||
|
||
const {
|
||
SMTP_HOST,
|
||
SMTP_PORT = '587',
|
||
SMTP_USER,
|
||
SMTP_PASS,
|
||
SMTP_SECURE = 'false',
|
||
MAIL_FROM = 'no-reply@example.com',
|
||
CONTACT_TO = MAIL_FROM,
|
||
} = process.env;
|
||
|
||
if (!SMTP_HOST || !CONTACT_TO) {
|
||
console.warn('[ContactRequest] SMTP not configured — skip email.');
|
||
return;
|
||
}
|
||
|
||
const transporter = nodemailer.createTransport({
|
||
host: SMTP_HOST,
|
||
port: Number(SMTP_PORT),
|
||
secure: SMTP_SECURE === 'true',
|
||
auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
|
||
});
|
||
|
||
const html = `
|
||
<h2>New contact request</h2>
|
||
<ul>
|
||
<li><b>Name:</b> ${item.name ?? ''}</li>
|
||
<li><b>Company:</b> ${item.company ?? ''}</li>
|
||
<li><b>Position:</b> ${item.position ?? ''}</li>
|
||
<li><b>Email:</b> ${item.email ?? ''}</li>
|
||
<li><b>Phone:</b> ${item.phone ?? ''}</li>
|
||
<li><b>Country:</b> ${item.countryCode ?? ''}</li>
|
||
<li><b>Created:</b> ${item.createdAt ?? ''}</li>
|
||
</ul>
|
||
`;
|
||
|
||
try {
|
||
await transporter.sendMail({
|
||
from: MAIL_FROM,
|
||
to: CONTACT_TO,
|
||
subject: 'New contact request',
|
||
html,
|
||
});
|
||
} catch (err) {
|
||
console.error('[ContactRequest] Email send failed:', err);
|
||
}
|
||
},
|
||
},
|
||
}),
|
||
Privacy : list({
|
||
access: allowAll,
|
||
ui: {
|
||
label: "Privacy Policy",
|
||
isHidden: false,
|
||
},
|
||
graphql: {
|
||
singular: "privacy",
|
||
plural: "privacys", // можно и "privacyList", если хочешь по-другому
|
||
},
|
||
fields: {
|
||
title: text({
|
||
validation: { isRequired: true },
|
||
defaultValue: "Privacy Policy",
|
||
}),
|
||
content: document({
|
||
label: "Privacy Policy Content",
|
||
formatting: {
|
||
inlineMarks: {
|
||
bold: true,
|
||
italic: true,
|
||
underline: true,
|
||
strikethrough: true,
|
||
},
|
||
listTypes: true,
|
||
headingLevels: [1, 2, 3],
|
||
alignment: true,
|
||
},
|
||
links: true,
|
||
layouts: [[1], [1, 1]],
|
||
}),
|
||
},
|
||
})
|
||
|
||
};
|