Initial commit
|
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules
|
||||||
|
.keystone/
|
||||||
|
keystone.db
|
||||||
|
*.log
|
||||||
|
|
@ -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).
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 514 KiB |
|
After Width: | Height: | Size: 335 B |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 878 KiB |
|
|
@ -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\":\"\"}]}]")
|
||||||
|
}
|
||||||
|
|
@ -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]],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "esnext",
|
||||||
|
"module": "commonjs",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit d8cc13520f5e88eeac3b3a45a59a9bbf7a8dfd59
|
||||||