Initial commit

This commit is contained in:
Raphael Elita 2025-08-21 11:30:25 +02:00
commit ae822f4f8c
26 changed files with 11556 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

4
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules
.keystone/
keystone.db
*.log

52
backend/README.md Normal file
View File

@ -0,0 +1,52 @@
# Keystone Project Starter
Welcome to Keystone!
Run
```
npm run dev
```
To view the config for your new app, look at [./keystone.ts](./keystone.ts)
This project starter is designed to give you a sense of the power Keystone can offer you, and show off some of its main features. It's also a pretty simple setup if you want to build out from it.
We recommend you use this alongside our [getting started walkthrough](https://keystonejs.com/docs/walkthroughs/getting-started-with-create-keystone-app) which will walk you through what you get as part of this starter.
If you want an overview of all the features Keystone offers, check out our [features](https://keystonejs.com/why-keystone#features) page.
## Some Quick Notes On Getting Started
### Changing the database
We've set you up with an [SQLite database](https://keystonejs.com/docs/apis/config#sqlite) for ease-of-use. If you're wanting to use PostgreSQL, you can!
Just change the `db` property on line 16 of the Keystone file [./keystone.ts](./keystone.ts) to
```typescript
db: {
provider: 'postgresql',
url: process.env.DATABASE_URL || 'DATABASE_URL_TO_REPLACE',
}
```
And provide your database url from PostgreSQL.
For more on database configuration, check out or [DB API Docs](https://keystonejs.com/docs/apis/config#db)
### Auth
We've put auth into its own file to make this humble starter easier to navigate. To explore it without auth turned on, comment out the `isAccessAllowed` on line 21 of the Keystone file [./keystone.ts](./keystone.ts).
For more on auth, check out our [Authentication API Docs](https://keystonejs.com/docs/apis/auth#authentication-api)
### Adding a frontend
As a Headless CMS, Keystone can be used with any frontend that uses GraphQL. It provides a GraphQL endpoint you can write queries against at `/api/graphql` (by default [http://localhost:3000/api/graphql](http://localhost:3000/api/graphql)). At Thinkmill, we tend to use [Next.js](https://nextjs.org/) and [Apollo GraphQL](https://www.apollographql.com/docs/react/get-started/) as our frontend and way to write queries, but if you have your own favourite, feel free to use it.
A walkthrough on how to do this is forthcoming, but in the meantime our [todo example](https://github.com/keystonejs/keystone-react-todo-demo) shows a Keystone set up with a frontend. For a more full example, you can also look at an example app we built for [Prisma Day 2021](https://github.com/keystonejs/prisma-day-2021-workshop)
### Embedding Keystone in a Next.js frontend
While Keystone works as a standalone app, you can embed your Keystone app into a [Next.js](https://nextjs.org/) app. This is quite a different setup to the starter, and we recommend checking out our walkthrough for that [here](https://keystonejs.com/docs/walkthroughs/embedded-mode-with-sqlite-nextjs#how-to-embed-keystone-sq-lite-in-a-next-js-app).

59
backend/auth.ts Normal file
View File

@ -0,0 +1,59 @@
// Welcome to some authentication for Keystone
//
// This is using @keystone-6/auth to add the following
// - A sign-in page for your Admin UI
// - A cookie-based stateless session strategy
// - Using a User email as the identifier
// - 30 day cookie expiration
//
// This file does not configure what Users can do, and the default for this starter
// project is to allow anyone - logged-in or not - to do anything.
//
// If you want to prevent random people on the internet from accessing your data,
// you can find out how by reading https://keystonejs.com/docs/guides/auth-and-access-control
//
// If you want to learn more about how our out-of-the-box authentication works, please
// read https://keystonejs.com/docs/apis/auth#authentication-api
import { randomBytes } from 'node:crypto'
import { createAuth } from '@keystone-6/auth'
// see https://keystonejs.com/docs/apis/session for the session docs
import { statelessSessions } from '@keystone-6/core/session'
// withAuth is a function we can use to wrap our base configuration
const { withAuth } = createAuth({
listKey: 'User',
identityField: 'email',
// this is a GraphQL query fragment for fetching what data will be attached to a context.session
// this can be helpful for when you are writing your access control functions
// you can find out more at https://keystonejs.com/docs/guides/auth-and-access-control
sessionData: 'name createdAt',
secretField: 'password',
// WARNING: remove initFirstItem functionality in production
// see https://keystonejs.com/docs/config/auth#init-first-item for more
initFirstItem: {
// if there are no items in the database, by configuring this field
// you are asking the Keystone AdminUI to create a new user
// providing inputs for these fields
fields: ['name', 'email', 'password'],
// it uses context.sudo() to do this, which bypasses any access control you might have
// you shouldn't use this in production
},
})
// statelessSessions uses cookies for session tracking
// these cookies have an expiry, in seconds
// we use an expiry of 30 days for this starter
const sessionMaxAge = 60 * 60 * 24 * 30
// you can find out more at https://keystonejs.com/docs/apis/session#session-api
const session = statelessSessions({
maxAge: sessionMaxAge,
secret: process.env.SESSION_SECRET,
})
export { withAuth, session }

56
backend/keystone.ts Normal file
View File

@ -0,0 +1,56 @@
// Welcome to Keystone!
//
// This file is what Keystone uses as the entry-point to your headless backend
//
// Keystone imports the default export of this file, expecting a Keystone configuration object
// you can find out more at https://keystonejs.com/docs/apis/config
import { config } from '@keystone-6/core'
// to keep this file tidy, we define our schema in a different file
import { lists } from './schema'
// authentication is configured separately here too, but you might move this elsewhere
// when you write your list-level access control functions, as they typically rely on session data
import { withAuth, session } from './auth'
export default withAuth(
config({
server: {
port: 3004, // ← ставим отдельный порт
cors: { origin: ["http://localhost:3001"], credentials: true },
},
ui: {
basePath: '/admin', // путь для админки
},
db: {
// we're using sqlite for the fastest startup experience
// for more information on what database might be appropriate for you
// see https://keystonejs.com/docs/guides/choosing-a-database#title
provider: 'sqlite',
url: 'file:./keystone.db',
},
storage: {
local_images: {
kind: 'local',
type: 'image',
generateUrl: path => `/images${path}`,
serverRoute: {
path: '/images',
},
storagePath: 'public/images',
},
local_files: {
kind: 'local',
type: 'file',
generateUrl: path => `/files${path}`,
serverRoute: {
path: '/files',
},
storagePath: 'public/files',
},
},
lists,
session,
})
)

8161
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

16
backend/package.json Normal file
View File

@ -0,0 +1,16 @@
{
"name": "keystone-app",
"version": "1.0.3",
"private": true,
"scripts": {
"dev": "keystone dev",
"start": "keystone start",
"build": "keystone build"
},
"dependencies": {
"@keystone-6/auth": "^8.0.0",
"@keystone-6/core": "^6.0.0",
"@keystone-6/fields-document": "^9.0.0",
"typescript": "^5.5.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 878 KiB

2079
backend/schema.graphql Normal file

File diff suppressed because it is too large Load Diff

298
backend/schema.prisma Normal file
View File

@ -0,0 +1,298 @@
// This file is automatically generated by Keystone, do not modify it manually.
// Modify your Keystone config when you want to change this.
datasource sqlite {
url = env("DATABASE_URL")
shadowDatabaseUrl = env("SHADOW_DATABASE_URL")
provider = "sqlite"
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(cuid())
name String @default("")
email String @unique @default("")
password String
createdAt DateTime? @default(now())
}
model System {
id String @id @default(cuid())
siteTitle String @default("")
linedUrl String @default("")
headerLogo_id String?
headerLogo_filesize Int?
headerLogo_width Int?
headerLogo_height Int?
headerLogo_extension String?
footerLogo_id String?
footerLogo_filesize Int?
footerLogo_width Int?
footerLogo_height Int?
footerLogo_extension String?
footerDisclaimer String @default("[{\"type\":\"paragraph\",\"children\":[{\"text\":\"\"}]}]")
updatedAt DateTime? @default(now())
}
model NavItem {
id String @id @default(cuid())
label String @default("")
href String @default("")
order Int? @default(0)
isExternal Boolean @default(false)
enabled Boolean @default(true)
parent NavItem? @relation("NavItem_parent", fields: [parentId], references: [id])
parentId String? @map("parent")
children NavItem[] @relation("NavItem_parent")
@@index([parentId])
}
model HeroSection {
id String @id @default(cuid())
title String @default("")
description String @default("")
backgroundImage_id String?
backgroundImage_filesize Int?
backgroundImage_width Int?
backgroundImage_height Int?
backgroundImage_extension String?
buttonText String @default("")
buttonLink String @default("")
}
model FinanceRow {
id String @id @default(cuid())
value String @default("")
label String @default("")
trend String? @default("none")
order Int? @default(0)
section ScreenSecondSection? @relation("FinanceRow_section", fields: [sectionId], references: [id])
sectionId String? @map("section")
@@index([sectionId])
}
model ScreenSecondSection {
id String @id @default(cuid())
title String @default("")
description String @default("")
logo_id String?
logo_filesize Int?
logo_width Int?
logo_height Int?
logo_extension String?
rows FinanceRow[] @relation("FinanceRow_section")
footnote String @default("")
}
model ScreenThreeStatCard {
id String @id @default(cuid())
value String @default("")
label String @default("")
color String @default("")
order Int? @default(0)
section ScreenThreeSection? @relation("ScreenThreeStatCard_section", fields: [sectionId], references: [id])
sectionId String? @map("section")
@@index([sectionId])
}
model ScreenThreeIndex {
id String @id @default(cuid())
text String @default("")
order Int? @default(0)
section ScreenThreeSection? @relation("ScreenThreeIndex_section", fields: [sectionId], references: [id])
sectionId String? @map("section")
@@index([sectionId])
}
model ScreenThreeSection {
id String @id @default(cuid())
title String @default("")
description String @default("")
statCards ScreenThreeStatCard[] @relation("ScreenThreeStatCard_section")
indexes ScreenThreeIndex[] @relation("ScreenThreeIndex_section")
footnote String @default("")
}
model KeyBusinessSegmentsSection {
id String @id @default(cuid())
title String @default("")
segments KeySegment[] @relation("KeySegment_section")
}
model KeySegment {
id String @id @default(cuid())
key String @unique @default("")
name String @default("")
order Int? @default(0)
logo_id String?
logo_filesize Int?
logo_width Int?
logo_height Int?
logo_extension String?
section KeyBusinessSegmentsSection? @relation("KeySegment_section", fields: [sectionId], references: [id])
sectionId String? @map("section")
info KeySegmentInfo[] @relation("KeySegmentInfo_segment")
@@index([sectionId])
}
model KeySegmentInfo {
id String @id @default(cuid())
title String @default("")
order Int? @default(0)
colSpan String? @default("ONE")
segment KeySegment? @relation("KeySegmentInfo_segment", fields: [segmentId], references: [id])
segmentId String? @map("segment")
brandLink String @default("")
brandLogo_id String?
brandLogo_filesize Int?
brandLogo_width Int?
brandLogo_height Int?
brandLogo_extension String?
items KeySegmentInfoItem[] @relation("KeySegmentInfoItem_info")
@@index([segmentId])
}
model KeySegmentInfoItem {
id String @id @default(cuid())
text String @default("")
infoLogo_id String?
infoLogo_filesize Int?
infoLogo_width Int?
infoLogo_height Int?
infoLogo_extension String?
order Int? @default(0)
info KeySegmentInfo? @relation("KeySegmentInfoItem_info", fields: [infoId], references: [id])
infoId String? @map("info")
@@index([infoId])
}
model WhyChooseUsItem {
id String @id @default(cuid())
text String @default("")
order Int? @default(0)
section WhyChooseUsSection? @relation("WhyChooseUsItem_section", fields: [sectionId], references: [id])
sectionId String? @map("section")
createdAt DateTime? @default(now())
@@index([sectionId])
}
model WhyChooseUsSection {
id String @id @default(cuid())
title String @default("Why Choose Us")
leftTitle String @default("")
leftBackground_id String?
leftBackground_filesize Int?
leftBackground_width Int?
leftBackground_height Int?
leftBackground_extension String?
ctaText String @default("Partner with us")
ctaLink String @default("")
items WhyChooseUsItem[] @relation("WhyChooseUsItem_section")
}
model InnovationSection {
id String @id @default(cuid())
title String @default("Innovation at the intersection of telecom and fintech")
topLeftTitle String @default("")
topLeftText String @default("")
topLeftImage_id String?
topLeftImage_filesize Int?
topLeftImage_width Int?
topLeftImage_height Int?
topLeftImage_extension String?
topRightTitle String @default("")
topRightText String @default("")
bottom1Title String @default("")
bottom1Text String @default("")
bottom2Title String @default("")
bottom2Text String @default("")
createdAt DateTime? @default(now())
updatedAt DateTime? @updatedAt
}
model SolutionsSection {
id String @id @default(cuid())
title String @default("Solutions")
buttonText String @default("Partner with us")
buttonHref String @default("")
items SolutionItem[] @relation("SolutionItem_section")
}
model SolutionItem {
id String @id @default(cuid())
title String @default("")
text String @default("")
href String @default("")
image_id String?
image_filesize Int?
image_width Int?
image_height Int?
image_extension String?
order Int? @default(0)
section SolutionsSection? @relation("SolutionItem_section", fields: [sectionId], references: [id])
sectionId String? @map("section")
@@index([sectionId])
}
model CountriesMapSection {
id String @id @default(cuid())
title String @default("")
countries MapCountry[] @relation("MapCountry_section")
}
model MapCountry {
id String @id @default(cuid())
section CountriesMapSection? @relation("MapCountry_section", fields: [sectionId], references: [id])
sectionId String? @map("section")
code String @unique @default("kaz")
name String @default("")
isEnabled Boolean @default(true)
flag_id String?
flag_filesize Int?
flag_width Int?
flag_height Int?
flag_extension String?
items MapCountryItem[] @relation("MapCountryItem_country")
order Int? @default(0)
@@index([sectionId])
}
model MapCountryItem {
id String @id @default(cuid())
country MapCountry? @relation("MapCountryItem_country", fields: [countryId], references: [id])
countryId String? @map("country")
text String @default("")
order Int? @default(0)
@@index([countryId])
}
model ContactRequest {
id String @id @default(cuid())
name String @default("")
company String @default("")
position String @default("")
email String @default("")
phone String @default("")
countryCode String @default("")
createdAt DateTime? @default(now())
}
model Privacy {
id String @id @default(cuid())
title String @default("Privacy Policy")
content String @default("[{\"type\":\"paragraph\",\"children\":[{\"text\":\"\"}]}]")
}

820
backend/schema.ts Normal file
View File

@ -0,0 +1,820 @@
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", "colSpan", "order"],
inlineCreate: { fields: ["title", "colSpan", "order"] },
inlineEdit: { fields: ["title", "colSpan", "order"] },
linkToItem: true,
},
}),
},
}),
/* Info card (column block) */
KeySegmentInfo: list({
access: allowAll,
ui: { label: "Key Segment • Info Block", listView: { initialColumns: ["order", "title", "colSpan"] } },
fields: {
title: text({ validation: { isRequired: true } }),
order: integer({ defaultValue: 0 }),
colSpan: select({
type: 'enum',
options: [
{ label: 'Normal (1 column)', value: 'ONE' },
{ label: 'Wide (2 columns)', value: 'TWO' },
],
defaultValue: 'ONE',
ui: { displayMode: 'segmented-control' },
}),
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"],
inlineCreate: { fields: ["text", "infoLogo", "order"] },
inlineEdit: { fields: ["text", "infoLogo", "order"] },
linkToItem: true,
},
}),
},
}),
/* Info list item (только текст + лого) */
KeySegmentInfoItem: list({
access: allowAll,
ui: { label: "Key Segment • Info Item", listView: { initialColumns: ["order", "text", "infoLogo"] } },
fields: {
text: text({ validation: { isRequired: true } }),
infoLogo: image({ storage: 'local_images', ui: { description: "Лого в зелёном квадратике" } }),
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', 'countryCode'],
initialSort: { field: 'createdAt', direction: 'DESC' },
},
},
fields: {
name: text({ validation: { isRequired: true } }),
company: 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]],
}),
},
})
};

10
backend/tsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "esnext",
"module": "commonjs",
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
}
}

1
landing Submodule

@ -0,0 +1 @@
Subproject commit d8cc13520f5e88eeac3b3a45a59a9bbf7a8dfd59