Этап 1a: фундамент

- Next 16.4 и Payload 3.91 в одном приложении, PostgreSQL с UUID,
  TypeScript 6, ESLint 9, Prettier, Vitest
- Payload: администраторы с ролями, multi-tenant по сайтам, локализация
  en/ru/de/fr/es, интерфейс админки ru/en, языки сайта (sites.locales)
- Коллекции sites, domains, currencies, countries, locations, tags,
  tariffs, payment-systems (manual), media и начальные данные (pnpm seed)
- Proxy: хост → домен, язык и валюта, внутренние маршруты по хосту;
  /admin и /api закрыты на доменах брендов
- Токены и темы брендов, оболочки сайта и кабинета, переключатели языка,
  валюты и темы, заглушки страниц
- /svc/health, юнит systemd veilix-dev.service
- Документация: решения D-022…D-024, команды в CLAUDE.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
andreypr207andClaude Opus 5.5 committed 2026-10-21 05:08:05 +02:00
1 parent 3400f58a5a
commit 7e331c2c9d
140 files changed
+15885 -43

No files matched your search

@@ -0,0 +1,12 @@
import type { ReactNode } from 'react'
import { cabinetShell } from '@/app/(front)/context'
import { Plain } from '@/ui/cabinet/Plain'
type Props = { children: ReactNode; params: Promise<{ host: string; locale: string }> }
/** Вход и оформление заказа: без навигации кабинета */
export default async function PlainLayout({ children, params }: Props) {
const { host, locale } = await params
return <Plain shell={await cabinetShell(host, locale)}>{children}</Plain>
}
@@ -0,0 +1,7 @@
import { cabinetStub } from '@/app/(front)/stubs'
const page = cabinetStub('cabinet.login', 'card')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,7 @@
import { cabinetStub } from '@/app/(front)/stubs'
const page = cabinetStub('cabinet.order', 'card')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,6 @@
import { notFound } from 'next/navigation'
/** Любой другой путь кабинета — 404 с навигацией кабинета */
export default function UnknownPage() {
notFound()
}
@@ -0,0 +1,7 @@
import { cabinetStub } from '@/app/(front)/stubs'
const page = cabinetStub('cabinet.billing')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,7 @@
import { cabinetStub } from '@/app/(front)/stubs'
const page = cabinetStub('cabinet.buy')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,7 @@
import { cabinetStub } from '@/app/(front)/stubs'
const page = cabinetStub('cabinet.key')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,7 @@
import { cabinetStub } from '@/app/(front)/stubs'
const page = cabinetStub('cabinet.keys')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,12 @@
import type { ReactNode } from 'react'
import { cabinetShell } from '@/app/(front)/context'
import { Shell } from '@/ui/cabinet/Shell'
type Props = { children: ReactNode; params: Promise<{ host: string; locale: string }> }
/** Разделы кабинета с навигацией */
export default async function ShellLayout({ children, params }: Props) {
const { host, locale } = await params
return <Shell shell={await cabinetShell(host, locale)}>{children}</Shell>
}
@@ -0,0 +1,70 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { cabinetShell } from '@/app/(front)/context'
import { localizedPath } from '@/core/routing/paths'
import { LanguageSegments } from '@/ui/switchers/LanguageSwitch'
import { ThemeSwitch } from '@/ui/theme/ThemeSwitch'
type Props = { params: Promise<{ host: string; locale: string }> }
const rowClass =
'flex min-h-14 items-center justify-between gap-3 rounded-2xl border border-border bg-surface px-[18px] py-3.5 text-base text-ink hover:text-ink'
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { host, locale } = await params
const shell = await cabinetShell(host, locale)
const t = await getTranslations({ locale: shell.locale })
return { title: t('cabinet.more') }
}
/** Вкладка «Ещё» на узком экране: остальные разделы и настройки (App.dc.html) */
export default async function MorePage({ params }: Props) {
const { host, locale } = await params
const shell = await cabinetShell(host, locale)
setRequestLocale(shell.locale)
const t = await getTranslations()
const href = (path: string) => localizedPath(path, shell.locale, shell.defaultLocale)
const chevron = (
<span aria-hidden className="text-ink-3">
›
</span>
)
return (
<div className="grid gap-4">
<h1 className="text-[26px] font-semibold tracking-[-0.03em]">{t('cabinet.more')}</h1>
<Link href={href('/billing')} className={rowClass}>
<span>{t('cabinet.billing')}</span>
{chevron}
</Link>
<Link href={href('/referral')} className={rowClass}>
<span>{t('cabinet.referral')}</span>
{chevron}
</Link>
{shell.supportHref && (
<a href={shell.supportHref} className={rowClass}>
<span>{t('cabinet.support')}</span>
{chevron}
</a>
)}
<div className="grid gap-3 rounded-2xl border border-border bg-surface px-[18px] py-3.5">
<div className="flex items-center justify-between gap-3">
<div className="text-[15px]">{t('switch.theme')}</div>
<ThemeSwitch variant="more" />
</div>
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
<div className="text-[15px]">{t('switch.language')}</div>
<LanguageSegments
variant="more"
locale={shell.locale}
locales={shell.locales}
defaultLocale={shell.defaultLocale}
/>
</div>
</div>
{/* «Выйти · почта» — с входом по коду, этап 1c */}
</div>
)
}
@@ -0,0 +1,5 @@
import { NotFound } from '@/ui/NotFound'
export default function CabinetNotFound() {
return <NotFound size="cabinet" />
}
@@ -0,0 +1,7 @@
import { cabinetStub } from '@/app/(front)/stubs'
const page = cabinetStub('cabinet.home')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,7 @@
import { cabinetStub } from '@/app/(front)/stubs'
const page = cabinetStub('cabinet.referral')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,65 @@
import '@/styles/globals.css'
import type { Metadata } from 'next'
import { NextIntlClientProvider } from 'next-intl'
import { getMessages, getTranslations, setRequestLocale } from 'next-intl/server'
import type { ReactNode } from 'react'
import { cabinetContext } from '@/app/(front)/context'
import { brands } from '@/brands'
import { pick } from '@/i18n/pick'
import { fontVariables } from '@/ui/fonts'
import { THEME_SCRIPT } from '@/ui/theme/script'
import { ThemeWatcher } from '@/ui/theme/ThemeWatcher'
type Params = Promise<{ host: string; locale: string }>
// кабинет рендерится на сервере для каждого запроса, без кэша (D-017)
export const dynamic = 'force-dynamic'
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { host, locale } = await params
const context = await cabinetContext(host, locale)
const t = await getTranslations({ locale: context.locale })
const name = t(`brand.${context.domain.site}.name`)
return {
title: { default: name, template: `%s · ${name}` },
icons: { icon: brands[context.domain.site].favicon },
// кабинет не индексируется
robots: { index: false, follow: false },
}
}
/** Корневой layout кабинета: бренд — data-site, тема — data-theme */
export default async function CabinetLayout({
children,
params,
}: {
children: ReactNode
params: Params
}) {
const { host, locale } = await params
const context = await cabinetContext(host, locale)
setRequestLocale(context.locale)
const messages = await getMessages()
return (
<html
lang={context.locale}
data-site={context.domain.site}
className={fontVariables}
suppressHydrationWarning
>
<head>
<script dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
</head>
{/* расширения браузера (ColorZilla, Grammarly) дописывают атрибуты в <body> до гидратации */}
<body suppressHydrationWarning>
<NextIntlClientProvider messages={pick(messages, ['nav', 'switch'])}>
<ThemeWatcher />
{children}
</NextIntlClientProvider>
</body>
</html>
)
}
+83
View File
@@ -0,0 +1,83 @@
import { notFound } from 'next/navigation'
import { cache } from 'react'
import { listCurrencies } from '@/core/currencies/server'
import { getDomain, getPrimaryDomain } from '@/core/domains/server'
import type { DomainConfig } from '@/core/domains/types'
import { localizedPath } from '@/core/routing/paths'
import { isLocale, type Locale } from '@/i18n/locales'
import type { CabinetShell } from '@/ui/cabinet/types'
import type { MarketingShell } from '@/ui/marketing/types'
/** Адрес страницы на другом домене бренда; язык — текущий, если он там включён */
export const urlOn = (domain: DomainConfig | null, path: string, locale: Locale): string | null => {
if (!domain) return null
const target = domain.locales.includes(locale) ? locale : domain.defaultLocale
return `https://${domain.hostname}${localizedPath(path, target, domain.defaultLocale)}`
}
/** Маркетинговая страница: /m/{хост}/{язык}/{валюта}/… Неизвестное сочетание — 404 */
export const marketingContext = cache(async (host: string, locale: string, currency: string) => {
const domain = await getDomain(host)
const code = currency.toUpperCase()
if (
!domain ||
domain.app !== 'marketing' ||
!isLocale(locale) ||
!domain.locales.includes(locale) ||
!domain.currencies.includes(code)
) {
notFound()
}
return { domain, locale, currency: code }
})
export const marketingShell = cache(
async (host: string, rawLocale: string, rawCurrency: string): Promise<MarketingShell> => {
const { domain, locale, currency } = await marketingContext(host, rawLocale, rawCurrency)
const [cabinet, currencies] = await Promise.all([
getPrimaryDomain(domain.site, 'cabinet'),
listCurrencies(domain.currencies, locale),
])
return {
site: domain.site,
locale,
locales: domain.locales,
defaultLocale: domain.defaultLocale,
currency,
currencies,
loginHref: urlOn(cabinet, '/login', locale),
}
},
)
/** Страница кабинета: /c/{хост}/{язык}/… */
export const cabinetContext = cache(async (host: string, locale: string) => {
const domain = await getDomain(host)
if (
!domain ||
domain.app !== 'cabinet' ||
!isLocale(locale) ||
!domain.locales.includes(locale)
) {
notFound()
}
return { domain, locale }
})
export const cabinetShell = cache(
async (host: string, rawLocale: string): Promise<CabinetShell> => {
const { domain, locale } = await cabinetContext(host, rawLocale)
const site = await getPrimaryDomain(domain.site, 'marketing')
return {
site: domain.site,
locale,
locales: domain.locales,
defaultLocale: domain.defaultLocale,
siteHref: urlOn(site, '/', locale),
supportHref: urlOn(site, '/support', locale),
termsHref: urlOn(site, '/legal/terms', locale),
privacyHref: urlOn(site, '/legal/privacy', locale),
}
},
)
@@ -0,0 +1,9 @@
import { notFound } from 'next/navigation'
// неизвестные адреса не кэшируем: иначе случайные пути от ботов копились бы в кэше страниц
export const dynamic = 'force-dynamic'
/** Любой другой путь сайта — 404 в оформлении бренда */
export default function UnknownPage() {
notFound()
}
@@ -0,0 +1,23 @@
import type { ReactNode } from 'react'
import { marketingShell } from '@/app/(front)/context'
import { Footer } from '@/ui/marketing/Footer'
import { Header } from '@/ui/marketing/Header'
type Props = {
children: ReactNode
params: Promise<{ host: string; locale: string; currency: string }>
}
/** Шапка и подвал маркетингового сайта */
export default async function SiteLayout({ children, params }: Props) {
const { host, locale, currency } = await params
const shell = await marketingShell(host, locale, currency)
return (
<>
<Header shell={shell} />
<main id="top">{children}</main>
<Footer shell={shell} />
</>
)
}
@@ -0,0 +1,7 @@
import { siteStub } from '@/app/(front)/stubs'
const page = siteStub('footer.cookies')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,7 @@
import { siteStub } from '@/app/(front)/stubs'
const page = siteStub('footer.privacy')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,7 @@
import { siteStub } from '@/app/(front)/stubs'
const page = siteStub('footer.refund')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,7 @@
import { siteStub } from '@/app/(front)/stubs'
const page = siteStub('footer.terms')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,7 @@
import { siteStub } from '@/app/(front)/stubs'
const page = siteStub('nav.locations')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,5 @@
import { NotFound } from '@/ui/NotFound'
export default function SiteNotFound() {
return <NotFound size="site" />
}
@@ -0,0 +1,22 @@
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { marketingContext } from '@/app/(front)/context'
import { Stub } from '@/ui/Stub'
type Props = { params: Promise<{ host: string; locale: string; currency: string }> }
/** Главная бренда — заглушка до этапа 1b */
export default async function HomePage({ params }: Props) {
const { host, locale, currency } = await params
const context = await marketingContext(host, locale, currency)
setRequestLocale(context.locale)
const t = await getTranslations()
const site = context.domain.site
return (
<Stub
size="site"
title={t(`brand.${site}.name`)}
text={`${t(`brand.${site}.tagline`)} ${t('stub.text')}`}
/>
)
}
@@ -0,0 +1,7 @@
import { siteStub } from '@/app/(front)/stubs'
const page = siteStub('nav.pricing')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,7 @@
import { siteStub } from '@/app/(front)/stubs'
const page = siteStub('nav.support')
export const generateMetadata = page.generateMetadata
export default page.Page
@@ -0,0 +1,70 @@
import '@/styles/globals.css'
import type { Metadata } from 'next'
import { NextIntlClientProvider } from 'next-intl'
import { getMessages, getTranslations, setRequestLocale } from 'next-intl/server'
import type { ReactNode } from 'react'
import { marketingContext } from '@/app/(front)/context'
import { brands } from '@/brands'
import { pick } from '@/i18n/pick'
import { fontVariables } from '@/ui/fonts'
import { THEME_SCRIPT } from '@/ui/theme/script'
import { ThemeWatcher } from '@/ui/theme/ThemeWatcher'
type Params = Promise<{ host: string; locale: string; currency: string }>
// варианты «язык × валюта» собираются при первом запросе и кэшируются;
// запасное обновление — раз в 5 минут, пересборка по событию из Payload — этап 1b (D-017)
export const generateStaticParams = () => []
export const revalidate = 300
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { host, locale, currency } = await params
const context = await marketingContext(host, locale, currency)
const t = await getTranslations({ locale: context.locale })
const name = t(`brand.${context.domain.site}.name`)
return {
title: { default: name, template: `%s · ${name}` },
icons: { icon: brands[context.domain.site].favicon },
robots: context.domain.indexable ? undefined : { index: false, follow: false },
}
}
/**
* Корневой layout маркетингового сайта: документ, бренд (data-site) и тема (data-theme).
* Шапка и подвал — в (site)/layout.tsx: внутри вложенного сегмента 404 рендерится
* на сервере вместе с ними, а у корневого layout с динамическими сегментами — нет.
*/
export default async function MarketingRootLayout({
children,
params,
}: {
children: ReactNode
params: Params
}) {
const { host, locale, currency } = await params
const context = await marketingContext(host, locale, currency)
setRequestLocale(context.locale)
const messages = await getMessages()
return (
<html
lang={context.locale}
data-site={context.domain.site}
className={fontVariables}
suppressHydrationWarning
>
<head>
<script dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
</head>
{/* расширения браузера (ColorZilla, Grammarly) дописывают атрибуты в <body> до гидратации */}
<body suppressHydrationWarning>
<NextIntlClientProvider messages={pick(messages, ['nav', 'switch'])}>
<ThemeWatcher />
{children}
</NextIntlClientProvider>
</body>
</html>
)
}
+62
View File
@@ -0,0 +1,62 @@
import type { Metadata } from 'next'
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { Stub } from '@/ui/Stub'
import { cabinetContext, marketingContext } from './context'
type SiteKey =
| 'nav.pricing'
| 'nav.locations'
| 'nav.support'
| 'footer.terms'
| 'footer.privacy'
| 'footer.refund'
| 'footer.cookies'
type CabinetKey =
| 'cabinet.home'
| 'cabinet.keys'
| 'cabinet.key'
| 'cabinet.buy'
| 'cabinet.billing'
| 'cabinet.referral'
| 'cabinet.login'
| 'cabinet.order'
type SiteParams = { params: Promise<{ host: string; locale: string; currency: string }> }
type CabinetParams = { params: Promise<{ host: string; locale: string }> }
/** Заглушка страницы сайта до этапа 1b: заголовок и короткая подпись */
export const siteStub = (key: SiteKey) => ({
async generateMetadata({ params }: SiteParams): Promise<Metadata> {
const { host, locale, currency } = await params
const context = await marketingContext(host, locale, currency)
const t = await getTranslations({ locale: context.locale })
return { title: t(key) }
},
async Page({ params }: SiteParams) {
const { host, locale, currency } = await params
const context = await marketingContext(host, locale, currency)
setRequestLocale(context.locale)
const t = await getTranslations()
return <Stub size="site" title={t(key)} text={t('stub.text')} />
},
})
/** Заглушка страницы кабинета до этапов 1c–1g; card — для страниц без навигации */
export const cabinetStub = (key: CabinetKey, size: 'cabinet' | 'card' = 'cabinet') => ({
async generateMetadata({ params }: CabinetParams): Promise<Metadata> {
const { host, locale } = await params
const context = await cabinetContext(host, locale)
const t = await getTranslations({ locale: context.locale })
return { title: t(key) }
},
async Page({ params }: CabinetParams) {
const { host, locale } = await params
const context = await cabinetContext(host, locale)
setRequestLocale(context.locale)
const t = await getTranslations()
return <Stub size={size} title={t(key)} text={t('stub.text')} />
},
})
@@ -0,0 +1,24 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next'
import config from '@payload-config'
import { NotFoundPage, generatePageMetadata } from '@payloadcms/next/views'
import { importMap } from '../importMap'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const NotFound = ({ params, searchParams }: Args) =>
NotFoundPage({ config, params, searchParams, importMap })
export default NotFound
@@ -0,0 +1,24 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next'
import config from '@payload-config'
import { RootPage, generatePageMetadata } from '@payloadcms/next/views'
import { importMap } from '../importMap'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const Page = ({ params, searchParams }: Args) =>
RootPage({ config, params, searchParams, importMap })
export default Page
+18
View File
@@ -0,0 +1,18 @@
import { TenantField as TenantField_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client'
import { CurrencyCodeField as CurrencyCodeField_5f6e45c2d44d3ceaa88e6ac4e2162ad8 } from '../../../payload/components/CurrencyCodeField'
import { AssignTenantFieldTrigger as AssignTenantFieldTrigger_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client'
import { WatchTenantCollection as WatchTenantCollection_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client'
import { TenantSelector as TenantSelector_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc'
import { TenantSelectionProvider as TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
/** @type import('payload').ImportMap */
export const importMap = {
"@payloadcms/plugin-multi-tenant/client#TenantField": TenantField_1d0591e3cf4f332c83a86da13a0de59a,
"/payload/components/CurrencyCodeField#CurrencyCodeField": CurrencyCodeField_5f6e45c2d44d3ceaa88e6ac4e2162ad8,
"@payloadcms/plugin-multi-tenant/client#AssignTenantFieldTrigger": AssignTenantFieldTrigger_1d0591e3cf4f332c83a86da13a0de59a,
"@payloadcms/plugin-multi-tenant/client#WatchTenantCollection": WatchTenantCollection_1d0591e3cf4f332c83a86da13a0de59a,
"@payloadcms/plugin-multi-tenant/rsc#TenantSelector": TenantSelector_d6d5f193a167989e2ee7d14202901e62,
"@payloadcms/plugin-multi-tenant/rsc#TenantSelectionProvider": TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
}
+19
View File
@@ -0,0 +1,19 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import '@payloadcms/next/css'
import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from '@payloadcms/next/routes'
export const GET = REST_GET(config)
export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config)
export const PUT = REST_PUT(config)
export const OPTIONS = REST_OPTIONS(config)
View File
Whitespace-only changes.
+31
View File
@@ -0,0 +1,31 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import '@payloadcms/next/css'
import type { ServerFunctionClient } from 'payload'
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
import React from 'react'
import { importMap } from './admin/importMap.js'
import './custom.scss'
type Args = {
children: React.ReactNode
}
const serverFunction: ServerFunctionClient = async function (args) {
'use server'
return handleServerFunctions({
...args,
config,
importMap,
})
}
const Layout = ({ children }: Args) => (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
)
export default Layout
+24
View File
@@ -0,0 +1,24 @@
import config from '@payload-config'
import { getPayload } from 'payload'
export const dynamic = 'force-dynamic'
const TIMEOUT_MS = 3000
/** Проверка живости для выкладки и мониторинга: приложение отвечает и база доступна */
export async function GET() {
const headers = { 'Cache-Control': 'no-store' }
try {
const check = (async () => {
const payload = await getPayload({ config })
await payload.count({ collection: 'sites', overrideAccess: true })
})()
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('timeout')), TIMEOUT_MS),
)
await Promise.race([check, timeout])
return Response.json({ status: 'ok', db: 'ok' }, { headers })
} catch {
return Response.json({ status: 'error', db: 'unavailable' }, { status: 503, headers })
}
}
+20
View File
@@ -0,0 +1,20 @@
import { timingSafeEqual } from 'node:crypto'
import { listActiveDomains } from '@/core/domains/server'
export const dynamic = 'force-dynamic'
const isAuthorized = (request: Request) => {
const expected = process.env.INTERNAL_TOKEN
const given = request.headers.get('x-vx-internal-token')
if (!expected || !given) return false
const a = Buffer.from(given)
const b = Buffer.from(expected)
return a.length === b.length && timingSafeEqual(a, b)
}
/** Список активных доменов для proxy: тот не ходит в Payload сам, а берёт его здесь */
export async function GET(request: Request) {
if (!isAuthorized(request)) return new Response('Not Found', { status: 404 })
return Response.json(await listActiveDomains(), { headers: { 'Cache-Control': 'no-store' } })
}
+6
View File
@@ -0,0 +1,6 @@
import type { Brand } from '../types'
/** Вейликс: личный VPN с индивидуальным IP. Тексты — brand.dedicated в сообщениях next-intl */
export const dedicated: Brand = {
favicon: '/static/brands/dedicated/favicon.svg',
}
+8
View File
@@ -0,0 +1,8 @@
import type { SiteCode } from '@/core/sites'
import { dedicated } from './dedicated/brand'
import { mass } from './mass/brand'
import type { Brand } from './types'
/** Различия брендов; цвета — токены в src/styles/tokens.css по data-site */
export const brands: Record<SiteCode, Brand> = { dedicated, mass }
+6
View File
@@ -0,0 +1,6 @@
import type { Brand } from '../types'
/** ВейликсВПН: классический VPN. Тексты — brand.mass в сообщениях next-intl */
export const mass: Brand = {
favicon: '/static/brands/mass/favicon.svg',
}
+3
View File
@@ -0,0 +1,3 @@
export type Brand = {
favicon: string
}
+24
View File
@@ -0,0 +1,24 @@
import config from '@payload-config'
import { getPayload } from 'payload'
import type { Locale } from '@/i18n/locales'
import type { CurrencyOption } from './types'
/** Включённые валюты из списка кодов, в порядке справочника, с названиями на языке страницы */
export const listCurrencies = async (
codes: string[],
locale: Locale,
): Promise<CurrencyOption[]> => {
const payload = await getPayload({ config })
const { docs } = await payload.find({
collection: 'currencies',
where: { and: [{ code: { in: codes } }, { active: { equals: true } }] },
locale,
sort: 'sort',
depth: 0,
pagination: false,
overrideAccess: true,
})
return docs.map(({ code, symbol, name }) => ({ code, symbol, name }))
}
+6
View File
@@ -0,0 +1,6 @@
/** Валюта для переключателя: код ISO 4217, символ и название на языке страницы */
export type CurrencyOption = {
code: string
symbol: string
name: string
}
+54
View File
@@ -0,0 +1,54 @@
import config from '@payload-config'
import { getPayload } from 'payload'
import { cache } from 'react'
import {
type DomainApp,
type DomainConfig,
DOMAINS_TTL_MS,
type SiteCode,
toDomainConfig,
} from './types'
/** Активные домены из Payload */
export const listActiveDomains = async (): Promise<DomainConfig[]> => {
const payload = await getPayload({ config })
const { docs } = await payload.find({
collection: 'domains',
where: { active: { equals: true } },
depth: 1,
pagination: false,
overrideAccess: true,
})
return docs.map(toDomainConfig).filter((domain) => domain !== null)
}
let cached: { at: number; domains: Promise<DomainConfig[]> } | null = null
const activeDomains = (): Promise<DomainConfig[]> => {
if (!cached || Date.now() - cached.at > DOMAINS_TTL_MS) {
const domains = listActiveDomains()
cached = { at: Date.now(), domains }
domains.catch(() => {
cached = null
})
}
return cached.domains
}
/** Домен по хосту для страниц; null — домена нет или он выключен */
export const getDomain = cache(async (hostname: string): Promise<DomainConfig | null> => {
const domains = await activeDomains()
return domains.find((domain) => domain.hostname === hostname) ?? null
})
/** Основной домен сайта нужного типа: на него ведут ссылки между сайтом и кабинетом */
export const getPrimaryDomain = cache(
async (site: SiteCode, app: DomainApp): Promise<DomainConfig | null> => {
const domains = await activeDomains()
return (
domains.find((domain) => domain.site === site && domain.app === app && domain.isPrimary) ??
null
)
},
)
+53
View File
@@ -0,0 +1,53 @@
import { type DomainApp, isSiteCode, type SiteCode } from '@/core/sites'
import { isLocale, type Locale } from '@/i18n/locales'
import type { Domain } from '@/payload-types'
export type { DomainApp, SiteCode }
/** Сколько живёт список доменов в памяти: в proxy и в процессе страниц */
export const DOMAINS_TTL_MS = 15_000
/** Всё, что proxy и страницы знают о домене. Берётся из коллекции «Домены» */
export type DomainConfig = {
hostname: string
site: SiteCode
app: DomainApp
locales: Locale[]
defaultLocale: Locale
currencies: string[]
defaultCurrency: string
/** валюта по умолчанию для языка, например ru → RUB */
localeCurrency: Partial<Record<Locale, string>>
isPrimary: boolean
indexable: boolean
}
const siteCode = (site: unknown): SiteCode | null => {
if (typeof site !== 'object' || site === null || !('code' in site)) return null
return isSiteCode(site.code) ? site.code : null
}
/** Документ домена (с depth: 1, чтобы пришёл код сайта) → конфигурация; битая запись → null */
export const toDomainConfig = (doc: Domain): DomainConfig | null => {
const site = siteCode(doc.site)
const locales = (doc.locales ?? []).filter(isLocale)
if (!site || !isLocale(doc.defaultLocale) || !locales.includes(doc.defaultLocale)) return null
const localeCurrency: Partial<Record<Locale, string>> = {}
for (const row of doc.localeCurrency ?? []) {
if (isLocale(row.locale) && row.currency) localeCurrency[row.locale] = row.currency
}
return {
hostname: doc.hostname,
site,
app: doc.app,
locales,
defaultLocale: doc.defaultLocale,
currencies: doc.currencies ?? [],
defaultCurrency: doc.defaultCurrency,
localeCurrency,
isPrimary: Boolean(doc.isPrimary),
indexable: Boolean(doc.indexable),
}
}
+22
View File
@@ -0,0 +1,22 @@
/** Что умеет провайдер, кроме приёма платежа */
export type PaymentCapability = 'recurring' | 'refunds' | 'saveMethod'
export type PaymentAdapter = {
capabilities: readonly PaymentCapability[]
}
/**
* Реализации платёжных систем. Запись в коллекции «Платёжные системы» выбирает адаптер по коду.
* Этап 4 добавит stripe, процессинги RUB и криптовалюту.
*/
export const paymentAdapters = {
// ручной платёж администратора: проводится как настоящий, провайдера нет
manual: { capabilities: [] },
} as const satisfies Record<string, PaymentAdapter>
export type PaymentAdapterCode = keyof typeof paymentAdapters
export const PAYMENT_ADAPTER_CODES = Object.keys(paymentAdapters) as PaymentAdapterCode[]
export const isPaymentAdapterCode = (value: unknown): value is PaymentAdapterCode =>
typeof value === 'string' && value in paymentAdapters
+186
View File
@@ -0,0 +1,186 @@
import { describe, expect, it } from 'vitest'
import type { DomainConfig } from '@/core/domains/types'
import { decide, pickCurrency, type RouteInput } from './decide'
const marketing: DomainConfig = {
hostname: 'dev.veilix.app',
site: 'dedicated',
app: 'marketing',
locales: ['en', 'ru', 'de', 'fr', 'es'],
defaultLocale: 'en',
currencies: ['RUB', 'USD'],
defaultCurrency: 'USD',
localeCurrency: { ru: 'RUB' },
isPrimary: true,
indexable: false,
}
const cabinet: DomainConfig = { ...marketing, hostname: 'dev.my.veilix.app', app: 'cabinet' }
const input = (overrides: Partial<RouteInput>): RouteInput => ({
method: 'GET',
pathname: '/',
search: '',
isAdminHost: false,
domain: marketing,
cookieLocale: undefined,
cookieCurrency: undefined,
acceptLanguage: null,
country: null,
isBot: false,
...overrides,
})
describe('хост админки', () => {
it('корень ведёт в админку, /admin и /api пропускаются, остальное — 404', () => {
const admin = { isAdminHost: true, domain: null }
expect(decide(input({ ...admin, pathname: '/' }))).toEqual({
type: 'redirect',
location: '/admin',
})
expect(decide(input({ ...admin, pathname: '/admin/collections/sites' }))).toEqual({
type: 'next',
})
expect(decide(input({ ...admin, pathname: '/api/sites' }))).toEqual({ type: 'next' })
expect(decide(input({ ...admin, pathname: '/pricing' }))).toEqual({ type: 'not-found' })
})
})
describe('домены брендов', () => {
it('/svc работает на любом хосте, даже неизвестном', () => {
expect(decide(input({ domain: null, pathname: '/svc/health' }))).toEqual({ type: 'next' })
})
it('неизвестный хост — 404', () => {
expect(decide(input({ domain: null, pathname: '/pricing' }))).toEqual({ type: 'not-found' })
})
it('/admin и /api закрыты', () => {
expect(decide(input({ pathname: '/admin' }))).toEqual({ type: 'not-found' })
expect(decide(input({ pathname: '/api/admins' }))).toEqual({ type: 'not-found' })
expect(decide(input({ pathname: '/administrator' }))).toEqual({
type: 'rewrite',
pathname: '/m/dev.veilix.app/en/usd/administrator',
})
})
it('медиатека — только чтение через файловый маршрут Payload', () => {
expect(decide(input({ pathname: '/media/logo.png' }))).toEqual({
type: 'rewrite',
pathname: '/api/media/file/logo.png',
})
expect(decide(input({ method: 'POST', pathname: '/media/logo.png' }))).toEqual({
type: 'not-found',
})
expect(decide(input({ pathname: '/media' }))).toEqual({ type: 'not-found' })
})
it('прямой запрос к внутреннему маршруту получает ещё один префикс и попадает в 404', () => {
expect(decide(input({ pathname: '/m/dev.veilix.app/ru/rub/pricing' }))).toEqual({
type: 'rewrite',
pathname: '/m/dev.veilix.app/en/usd/m/dev.veilix.app/ru/rub/pricing',
})
})
})
describe('язык', () => {
it('английский без префикса', () => {
expect(decide(input({ pathname: '/pricing' }))).toEqual({
type: 'rewrite',
pathname: '/m/dev.veilix.app/en/usd/pricing',
})
})
it('префикс языка и валюта для языка', () => {
expect(decide(input({ pathname: '/ru/pricing' }))).toEqual({
type: 'rewrite',
pathname: '/m/dev.veilix.app/ru/rub/pricing',
})
expect(decide(input({ pathname: '/de' }))).toEqual({
type: 'rewrite',
pathname: '/m/dev.veilix.app/de/usd',
})
})
it('префикс языка по умолчанию убирается', () => {
expect(decide(input({ pathname: '/en/pricing', search: '?a=1' }))).toEqual({
type: 'redirect',
location: '/pricing?a=1',
})
})
it('язык, выключенный на домене, — 404', () => {
const onlyEnRu = { ...marketing, locales: ['en' as const, 'ru' as const] }
expect(decide(input({ domain: onlyEnRu, pathname: '/de/pricing' }))).toEqual({
type: 'not-found',
})
})
it('первый заход: по языку браузера, затем по стране', () => {
expect(decide(input({ pathname: '/pricing', acceptLanguage: 'fr-FR,fr;q=0.9' }))).toEqual({
type: 'redirect',
location: '/fr/pricing',
})
expect(decide(input({ pathname: '/', country: 'KZ' }))).toEqual({
type: 'redirect',
location: '/ru',
})
expect(decide(input({ pathname: '/', acceptLanguage: 'en-GB', country: 'KZ' }))).toEqual({
type: 'rewrite',
pathname: '/m/dev.veilix.app/en/usd',
})
})
it('выбранный ранее язык важнее браузера; выбранный английский не перенаправляется', () => {
expect(decide(input({ cookieLocale: 'es', acceptLanguage: 'ru' }))).toEqual({
type: 'redirect',
location: '/es',
})
expect(decide(input({ cookieLocale: 'en', acceptLanguage: 'ru' }))).toEqual({
type: 'rewrite',
pathname: '/m/dev.veilix.app/en/usd',
})
})
it('cookie с языком, которого нет на домене, не учитывается', () => {
expect(decide(input({ cookieLocale: 'ja', acceptLanguage: 'de' }))).toEqual({
type: 'redirect',
location: '/de',
})
})
it('поисковых ботов и не-GET запросы не перенаправляем', () => {
expect(decide(input({ isBot: true, acceptLanguage: 'ru', country: 'RU' }))).toEqual({
type: 'rewrite',
pathname: '/m/dev.veilix.app/en/usd',
})
expect(decide(input({ method: 'POST', cookieLocale: 'ru' }))).toEqual({
type: 'rewrite',
pathname: '/m/dev.veilix.app/en/usd',
})
})
})
describe('валюта', () => {
it('выбор из cookie, если валюта есть на домене', () => {
expect(pickCurrency(marketing, 'ru', 'usd', false)).toBe('USD')
expect(pickCurrency(marketing, 'en', 'RUB', false)).toBe('RUB')
expect(pickCurrency(marketing, 'en', 'EUR', false)).toBe('USD')
})
it('боты видят валюту по умолчанию', () => {
expect(pickCurrency(marketing, 'en', 'RUB', true)).toBe('USD')
expect(pickCurrency(marketing, 'ru', 'USD', true)).toBe('RUB')
})
})
describe('кабинет', () => {
it('без валюты в маршруте', () => {
expect(decide(input({ domain: cabinet, pathname: '/ru/keys' }))).toEqual({
type: 'rewrite',
pathname: '/c/dev.my.veilix.app/ru/keys',
})
})
})
+121
View File
@@ -0,0 +1,121 @@
import type { DomainConfig } from '@/core/domains/types'
import { isLocale, type Locale } from '@/i18n/locales'
import { detectLocale } from './locale'
import { localizedPath } from './paths'
export type RouteInput = {
method: string
pathname: string
search: string
isAdminHost: boolean
/** домен по Host; null — неизвестный или выключенный */
domain: DomainConfig | null
cookieLocale: string | undefined
cookieCurrency: string | undefined
acceptLanguage: string | null
/** страна от nginx (MaxMind) */
country: string | null
isBot: boolean
}
export type RouteDecision =
| { type: 'next' }
| { type: 'rewrite'; pathname: string }
| { type: 'redirect'; location: string }
| { type: 'not-found' }
const startsWith = (pathname: string, segment: string) =>
pathname === `/${segment}` || pathname.startsWith(`/${segment}/`)
const isNavigation = (method: string) => method === 'GET' || method === 'HEAD'
/** Валюта страницы: выбор из cookie, если она есть на домене; иначе валюта для языка или по умолчанию */
export const pickCurrency = (
domain: DomainConfig,
locale: Locale,
cookie: string | undefined,
isBot: boolean,
): string => {
const chosen = cookie?.toUpperCase()
if (!isBot && chosen && domain.currencies.includes(chosen)) return chosen
const forLocale = domain.localeCurrency[locale]
return forLocale && domain.currencies.includes(forLocale) ? forLocale : domain.defaultCurrency
}
/**
* Что делать с запросом: пропустить, переписать во внутренний маршрут, перенаправить или 404.
* Правила — 02-architecture, раздел 2, и 06-i18n, разделы 3, 4 и 6.
*/
export const decide = (input: RouteInput): RouteDecision => {
const { domain, pathname, search } = input
// свои эндпоинты работают на любом хосте
if (startsWith(pathname, 'svc')) return { type: 'next' }
if (input.isAdminHost) {
if (pathname === '/') return { type: 'redirect', location: '/admin' }
if (startsWith(pathname, 'admin') || startsWith(pathname, 'api')) return { type: 'next' }
return { type: 'not-found' }
}
if (!domain) return { type: 'not-found' }
// админка и REST Payload — только на хосте админки
if (startsWith(pathname, 'admin') || startsWith(pathname, 'api')) return { type: 'not-found' }
// файлы медиатеки: файловый маршрут Payload, только чтение
if (startsWith(pathname, 'media')) {
const file = pathname.slice('/media/'.length)
return isNavigation(input.method) && file
? { type: 'rewrite', pathname: `/api/media/file/${file}` }
: { type: 'not-found' }
}
// MiniApp — этап 3: язык берётся из профиля, префикс необязателен
if (domain.app === 'miniapp') {
return { type: 'rewrite', pathname: `/t/${domain.hostname}${pathname}` }
}
const segments = pathname.split('/').filter(Boolean)
const prefix = isLocale(segments[0]) ? segments[0] : null
const rest = `/${(prefix ? segments.slice(1) : segments).join('/')}`
let locale: Locale = domain.defaultLocale
if (prefix) {
if (!domain.locales.includes(prefix)) return { type: 'not-found' }
// у языка по умолчанию префикса нет: /en/pricing → /pricing
if (prefix === domain.defaultLocale) return { type: 'redirect', location: rest + search }
locale = prefix
} else if (isNavigation(input.method) && !input.isBot) {
// без префикса: выбранный ранее язык, а при первом заходе — язык браузера или страны
const chosen =
isLocale(input.cookieLocale) && domain.locales.includes(input.cookieLocale)
? input.cookieLocale
: null
const target =
chosen ??
detectLocale({
acceptLanguage: input.acceptLanguage,
country: input.country,
enabled: domain.locales,
fallback: domain.defaultLocale,
})
if (target !== domain.defaultLocale) {
return {
type: 'redirect',
location: localizedPath(rest, target, domain.defaultLocale) + search,
}
}
}
const tail = rest === '/' ? '' : rest
if (domain.app === 'cabinet') {
return { type: 'rewrite', pathname: `/c/${domain.hostname}/${locale}${tail}` }
}
const currency = pickCurrency(domain, locale, input.cookieCurrency, input.isBot)
return {
type: 'rewrite',
pathname: `/m/${domain.hostname}/${locale}/${currency.toLowerCase()}${tail}`,
}
}
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import type { Locale } from '@/i18n/locales'
import { detectLocale, localeForCountry, matchAcceptLanguage, parseAcceptLanguage } from './locale'
const all: Locale[] = ['en', 'ru', 'de', 'fr', 'es']
describe('parseAcceptLanguage', () => {
it('сортирует по весу, при равном весе сохраняет порядок', () => {
expect(parseAcceptLanguage('de;q=0.5, ru-RU, en;q=0.9, fr')).toEqual([
'ru-ru',
'fr',
'en',
'de',
])
})
it('отбрасывает *, нулевой и битый вес', () => {
expect(parseAcceptLanguage('*, es;q=0, de;q=abc, fr;q=0.1')).toEqual(['fr'])
})
it('пустой заголовок — пустой список', () => {
expect(parseAcceptLanguage(null)).toEqual([])
expect(parseAcceptLanguage('')).toEqual([])
})
})
describe('matchAcceptLanguage', () => {
it('берёт основной подтег', () => {
expect(matchAcceptLanguage('es-419,es;q=0.9', all)).toBe('es')
})
it('пропускает языки, которых нет на домене', () => {
expect(matchAcceptLanguage('zh-CN, de;q=0.8', ['en', 'ru'])).toBeNull()
expect(matchAcceptLanguage('pt-BR, ru;q=0.7', all)).toBe('ru')
})
})
describe('localeForCountry', () => {
it('сопоставляет страны по 06-i18n', () => {
expect(localeForCountry('KZ', all)).toBe('ru')
expect(localeForCountry('at', all)).toBe('de')
expect(localeForCountry('MX', all)).toBe('es')
expect(localeForCountry('BR', all)).toBeNull()
expect(localeForCountry('NL', all)).toBeNull()
})
it('учитывает языки домена', () => {
expect(localeForCountry('DE', ['en', 'ru'])).toBeNull()
})
})
describe('detectLocale', () => {
it('язык браузера важнее страны', () => {
expect(
detectLocale({ acceptLanguage: 'en-US', country: 'RU', enabled: all, fallback: 'en' }),
).toBe('en')
})
it('без подходящего языка браузера — страна, затем язык по умолчанию', () => {
expect(
detectLocale({ acceptLanguage: 'zh', country: 'RU', enabled: all, fallback: 'en' }),
).toBe('ru')
expect(
detectLocale({ acceptLanguage: null, country: null, enabled: all, fallback: 'en' }),
).toBe('en')
})
})
+84
View File
@@ -0,0 +1,84 @@
import { isLocale, type Locale } from '@/i18n/locales'
/** Страна → язык по умолчанию (06-i18n, раздел 4.4). Остальные страны — язык домена по умолчанию */
const COUNTRY_LOCALE: Record<string, Locale> = {
RU: 'ru',
BY: 'ru',
KZ: 'ru',
KG: 'ru',
DE: 'de',
AT: 'de',
FR: 'fr',
ES: 'es',
// испаноязычная Латинская Америка
AR: 'es',
BO: 'es',
CL: 'es',
CO: 'es',
CR: 'es',
CU: 'es',
DO: 'es',
EC: 'es',
GT: 'es',
HN: 'es',
MX: 'es',
NI: 'es',
PA: 'es',
PE: 'es',
PR: 'es',
PY: 'es',
SV: 'es',
UY: 'es',
VE: 'es',
}
/** Языковые теги из Accept-Language в порядке убывания веса */
export const parseAcceptLanguage = (header: string | null | undefined): string[] => {
if (!header) return []
return header
.split(',')
.map((part, index) => {
const [tag = '', ...params] = part.trim().split(';')
const q = params.map((param) => param.trim()).find((param) => param.startsWith('q='))
const weight = q === undefined ? 1 : Number(q.slice(2))
return { tag: tag.trim().toLowerCase(), weight: Number.isFinite(weight) ? weight : 0, index }
})
.filter((item) => item.tag !== '' && item.tag !== '*' && item.weight > 0)
.sort((a, b) => b.weight - a.weight || a.index - b.index)
.map((item) => item.tag)
}
/** Первый язык браузера среди включённых: точное совпадение (pt-br), затем основной подтег */
export const matchAcceptLanguage = (
header: string | null | undefined,
enabled: readonly Locale[],
): Locale | null => {
for (const tag of parseAcceptLanguage(header)) {
if (isLocale(tag) && enabled.includes(tag)) return tag
const primary = tag.split('-')[0]
if (isLocale(primary) && enabled.includes(primary)) return primary
}
return null
}
export const localeForCountry = (
country: string | null | undefined,
enabled: readonly Locale[],
): Locale | null => {
const locale = country ? COUNTRY_LOCALE[country.toUpperCase()] : undefined
return locale && enabled.includes(locale) ? locale : null
}
/** Язык нового посетителя: язык браузера → страна по MaxMind → язык домена по умолчанию */
export const detectLocale = ({
acceptLanguage,
country,
enabled,
fallback,
}: {
acceptLanguage: string | null | undefined
country: string | null | undefined
enabled: readonly Locale[]
fallback: Locale
}): Locale =>
matchAcceptLanguage(acceptLanguage, enabled) ?? localeForCountry(country, enabled) ?? fallback
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { localizedPath, pagePath } from './paths'
describe('pagePath', () => {
it('внешний адрес', () => {
expect(pagePath('/')).toBe('/')
expect(pagePath('/pricing')).toBe('/pricing')
expect(pagePath('/ru')).toBe('/')
expect(pagePath('/ru/legal/terms')).toBe('/legal/terms')
})
it('внутренний маршрут сайта и кабинета', () => {
expect(pagePath('/m/dev.veilix.app/ru/rub')).toBe('/')
expect(pagePath('/m/dev.veilix.app/en/usd/pricing')).toBe('/pricing')
expect(pagePath('/c/dev.my.veilix.app/de/keys/abc')).toBe('/keys/abc')
})
it('путь, похожий на внутренний, но без языка, не трогает', () => {
expect(pagePath('/m/anything')).toBe('/m/anything')
})
})
describe('localizedPath', () => {
it('язык по умолчанию — без префикса', () => {
expect(localizedPath('/pricing', 'en', 'en')).toBe('/pricing')
expect(localizedPath('/', 'en', 'en')).toBe('/')
})
it('остальные языки — с префиксом', () => {
expect(localizedPath('/pricing', 'ru', 'en')).toBe('/ru/pricing')
expect(localizedPath('/', 'de', 'en')).toBe('/de')
})
})
+36
View File
@@ -0,0 +1,36 @@
import { isLocale, type Locale } from '@/i18n/locales'
/**
* Сегментов во внутреннем префиксе маршрута:
* /m/{хост}/{язык}/{валюта}/… — маркетинговый сайт, /c/{хост}/{язык}/… — кабинет.
*/
const INTERNAL_PREFIX = { m: 4, c: 3 } as const
const isInternalHead = (segment: string | undefined): segment is keyof typeof INTERNAL_PREFIX =>
segment === 'm' || segment === 'c'
/**
* Путь страницы без языка: «/pricing», «/».
* Понимает и внешний адрес (/ru/pricing), и внутренний маршрут: при статической сборке страница
* видит внутренний путь, а в браузере — внешний, и ссылки должны совпасть.
*/
export const pagePath = (pathname: string): string => {
const segments = pathname.split('/').filter(Boolean)
const head = segments[0]
let rest: string[]
if (isInternalHead(head) && isLocale(segments[2])) {
rest = segments.slice(INTERNAL_PREFIX[head])
} else if (isLocale(head)) {
rest = segments.slice(1)
} else {
rest = segments
}
return `/${rest.join('/')}`
}
/** Адрес страницы на языке: язык домена по умолчанию — без префикса */
export const localizedPath = (path: string, locale: Locale, defaultLocale: Locale): string => {
const normalized = path.startsWith('/') ? path : `/${path}`
if (locale === defaultLocale) return normalized
return normalized === '/' ? `/${locale}` : `/${locale}${normalized}`
}
+12
View File
@@ -0,0 +1,12 @@
/** Бренды: по коду сайта выбираются тема и страницы */
export const SITE_CODES = ['dedicated', 'mass'] as const
export type SiteCode = (typeof SITE_CODES)[number]
export const isSiteCode = (value: unknown): value is SiteCode =>
typeof value === 'string' && (SITE_CODES as readonly string[]).includes(value)
/** Тип фронтенда домена */
export const DOMAIN_APPS = ['marketing', 'cabinet', 'miniapp'] as const
export type DomainApp = (typeof DOMAIN_APPS)[number]
+19
View File
@@ -0,0 +1,19 @@
/** Языки приложения: контент Payload и интерфейс next-intl. Новый язык — правка этого списка и переводы. */
export const LOCALES = ['en', 'ru', 'de', 'fr', 'es'] as const
export type Locale = (typeof LOCALES)[number]
/** Язык по умолчанию и язык отката для непереведённого контента */
export const DEFAULT_LOCALE: Locale = 'en'
/** Самоназвания языков: так они показываются в переключателях и в админке */
export const LOCALE_NAMES: Record<Locale, string> = {
en: 'English',
ru: 'Русский',
de: 'Deutsch',
fr: 'Français',
es: 'Español',
}
export const isLocale = (value: unknown): value is Locale =>
typeof value === 'string' && (LOCALES as readonly string[]).includes(value)
+1
View File
@@ -0,0 +1 @@
{}
+63
View File
@@ -0,0 +1,63 @@
{
"brand": {
"dedicated": {
"name": "Veilix",
"tagline": "Personal VPN servers with a dedicated IP. Running since 2022."
},
"mass": {
"name": "VeilixVPN",
"tagline": "A VPN you never have to switch off. Running since 2022."
}
},
"nav": {
"main": "Main navigation",
"home": "Home",
"pricing": "Pricing",
"locations": "Locations",
"support": "Support",
"logIn": "Log in",
"getVpn": "Get a VPN",
"menu": "Menu"
},
"footer": {
"service": "Service",
"legal": "Legal",
"terms": "Terms of use",
"privacy": "Privacy policy",
"refund": "Refunds",
"cookies": "Cookie policy",
"copyright": "© {year} {brand}"
},
"switch": {
"language": "Language",
"currency": "Currency",
"theme": "Theme",
"light": "Light theme",
"dark": "Dark theme",
"system": "Match the system"
},
"cabinet": {
"home": "Overview",
"keys": "All keys",
"keysShort": "Keys",
"key": "Key",
"buy": "Buy a key",
"buyShort": "Buy",
"billing": "Billing",
"referral": "Referral program",
"more": "More",
"support": "Support",
"login": "Sign in",
"order": "Checkout",
"terms": "Terms",
"privacy": "Privacy"
},
"stub": {
"text": "This section is coming in the next stages."
},
"notFound": {
"title": "Page not found",
"text": "The page may have moved or never existed.",
"home": "Go to the home page"
}
}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+63
View File
@@ -0,0 +1,63 @@
{
"brand": {
"dedicated": {
"name": "Вейликс",
"tagline": "Персональные VPN-серверы с индивидуальным IP. Работаем с 2022 года."
},
"mass": {
"name": "ВейликсВПН",
"tagline": "VPN, который не нужно отключать. Работаем с 2022 года."
}
},
"nav": {
"main": "Основная навигация",
"home": "Главная",
"pricing": "Цены",
"locations": "Локации",
"support": "Поддержка",
"logIn": "Войти",
"getVpn": "Получить VPN",
"menu": "Меню"
},
"footer": {
"service": "Сервис",
"legal": "Документы",
"terms": "Условия использования",
"privacy": "Политика конфиденциальности",
"refund": "Возврат средств",
"cookies": "Политика cookies",
"copyright": "© {year} {brand}"
},
"switch": {
"language": "Язык",
"currency": "Валюта",
"theme": "Тема",
"light": "Светлая тема",
"dark": "Тёмная тема",
"system": "Как в системе"
},
"cabinet": {
"home": "Главная",
"keys": "Список ключей",
"keysShort": "Ключи",
"key": "Ключ",
"buy": "Купить ключ",
"buyShort": "Купить",
"billing": "Счёт и платежи",
"referral": "Реферальная программа",
"more": "Ещё",
"support": "Поддержка",
"login": "Вход в кабинет",
"order": "Оформление заказа",
"terms": "Условия",
"privacy": "Конфиденциальность"
},
"stub": {
"text": "Раздел появится на следующих этапах."
},
"notFound": {
"title": "Страница не найдена",
"text": "Возможно, она переехала или её никогда не было.",
"home": "На главную"
}
}
+3
View File
@@ -0,0 +1,3 @@
/** Часть сообщений для клиентских компонентов: в браузер уходят только нужные разделы */
export const pick = <T extends object, K extends keyof T>(messages: T, keys: K[]): Pick<T, K> =>
Object.fromEntries(keys.map((key) => [key, messages[key]])) as Pick<T, K>
+29
View File
@@ -0,0 +1,29 @@
import { hasLocale } from 'next-intl'
import { getRequestConfig } from 'next-intl/server'
import { DEFAULT_LOCALE, type Locale, LOCALES } from './locales'
type Messages = { [key: string]: string | Messages }
const load = async (locale: Locale): Promise<Messages> =>
(await import(`./messages/${locale}.json`)).default
/** Перевод накладывается на английские сообщения: непереведённый ключ показывается по-английски */
const merge = (base: Messages, override: Messages): Messages => {
const result: Messages = { ...base }
for (const [key, value] of Object.entries(override)) {
const current = result[key]
result[key] =
typeof value === 'object' && typeof current === 'object' ? merge(current, value) : value
}
return result
}
/** Язык приходит из сегмента [locale] внутреннего маршрута (setRequestLocale в layout) */
export default getRequestConfig(async ({ requestLocale }) => {
const requested = await requestLocale
const locale = hasLocale(LOCALES, requested) ? requested : DEFAULT_LOCALE
const base = await load(DEFAULT_LOCALE)
const messages = locale === DEFAULT_LOCALE ? base : merge(base, await load(locale))
return { locale, messages }
})
+11
View File
@@ -0,0 +1,11 @@
import type messages from './messages/en.json'
import type { Locale } from './locales'
/** Ключи сообщений и языки проверяются при сборке: источник — en.json */
declare module 'next-intl' {
interface AppConfig {
Locale: Locale
Messages: typeof messages
}
}
+793
View File
@@ -0,0 +1,793 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config {
auth: {
admins: AdminAuthOperations;
};
blocks: {};
collections: {
tariffs: Tariff;
locations: Location;
countries: Country;
tags: Tag;
media: Media;
sites: Site;
domains: Domain;
currencies: Currency;
'payment-systems': PaymentSystem;
admins: Admin;
'payload-kv': PayloadKv;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
tariffs: TariffsSelect<false> | TariffsSelect<true>;
locations: LocationsSelect<false> | LocationsSelect<true>;
countries: CountriesSelect<false> | CountriesSelect<true>;
tags: TagsSelect<false> | TagsSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
sites: SitesSelect<false> | SitesSelect<true>;
domains: DomainsSelect<false> | DomainsSelect<true>;
currencies: CurrenciesSelect<false> | CurrenciesSelect<true>;
'payment-systems': PaymentSystemsSelect<false> | PaymentSystemsSelect<true>;
admins: AdminsSelect<false> | AdminsSelect<true>;
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
};
fallbackLocale:
| ('false' | 'none' | 'null')
| false
| null
| ('en' | 'ru' | 'de' | 'fr' | 'es')
| ('en' | 'ru' | 'de' | 'fr' | 'es')[];
globals: {};
globalsSelect: {};
locale: 'en' | 'ru' | 'de' | 'fr' | 'es';
widgets: {
collections: CollectionsWidget;
};
user: Admin;
jobs: {
tasks: unknown;
workflows: unknown;
};
}
export interface AdminAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "tariffs".
*/
export interface Tariff {
id: string;
site?: (string | null) | Site;
name: string;
code: string;
/**
* dedicated: the country is chosen at purchase; mass: all tariff countries
*/
type: 'dedicated' | 'mass';
description?: string | null;
features?:
| {
text: string;
id?: string | null;
}[]
| null;
period: {
unit: 'month' | 'year';
count: number;
};
devicesLimit: number;
/**
* One row per currency. No row means the tariff is not sold in that currency.
*/
prices?:
| {
currency: string;
/**
* In minor units: 249900 = 2,499.00
*/
firstPeriodAmount: number;
/**
* In minor units: 249900 = 2,499.00
*/
renewalAmount: number;
id?: string | null;
}[]
| null;
countries?: (string | Country)[] | null;
tags?: (string | Tag)[] | null;
/**
* e.g. “best value”
*/
badge?: string | null;
isDefault?: boolean | null;
active?: boolean | null;
sort?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "sites".
*/
export interface Site {
id: string;
code: 'dedicated' | 'mass';
name: string;
orderPrefix: string;
/**
* Each domain of the site shows some of these languages.
*/
locales: ('en' | 'ru' | 'de' | 'fr' | 'es')[];
updatedAt: string;
createdAt: string;
}
/**
* Shared by both brands
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "countries".
*/
export interface Country {
id: string;
iso2: string;
name: string;
/**
* Default point on the map
*/
lat?: number | null;
lng?: number | null;
sort?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* For filtering tariffs and, later, automatic key provisioning
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "tags".
*/
export interface Tag {
id: string;
name: string;
code: string;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "locations".
*/
export interface Location {
id: string;
site?: (string | null) | Site;
country: string | Country;
city: string;
/**
* City point on the map
*/
lat?: number | null;
lng?: number | null;
status: 'available' | 'on_demand' | 'soon';
/**
* Optional counter. Whether to show it on the site is decided in stage 1b.
*/
freeServers?: number | null;
visible?: boolean | null;
sort?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: string;
alt?: string | null;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* A new mirror is a record here plus an nginx block and a certificate.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "domains".
*/
export interface Domain {
id: string;
site?: (string | null) | Site;
hostname: string;
app: 'marketing' | 'cabinet' | 'miniapp';
locales: ('en' | 'ru' | 'de' | 'fr' | 'es')[];
/**
* Opens without a URL prefix
*/
defaultLocale: 'en' | 'ru' | 'de' | 'fr' | 'es';
currencies: string[];
defaultCurrency: string;
/**
* Default currency for a language, e.g. ru → RUB. Other languages get the default currency.
*/
localeCurrency?:
| {
locale: 'en' | 'ru' | 'de' | 'fr' | 'es';
currency: string;
id?: string | null;
}[]
| null;
/**
* Used in links and the sitemap
*/
isPrimary?: boolean | null;
indexable?: boolean | null;
active?: boolean | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "currencies".
*/
export interface Currency {
id: string;
code: string;
symbol: string;
minorUnits: number;
name: string;
active?: boolean | null;
sort?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* Available in its currencies on every site where they are enabled. Provider keys are in .env only.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payment-systems".
*/
export interface PaymentSystem {
id: string;
name: string;
adapter: 'manual';
/**
* e.g. “Bank card”
*/
title: string;
description?: string | null;
icon?: (string | null) | Media;
currencies: string[];
purposes: ('order' | 'topup')[];
limits?:
| {
currency: string;
/**
* In minor units: 249900 = 2,499.00
*/
min: number;
/**
* In minor units: 249900 = 2,499.00
*/
max: number;
id?: string | null;
}[]
| null;
capabilities?: string | null;
visibleToCustomers?: boolean | null;
mode: 'test' | 'live';
active?: boolean | null;
sort?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "admins".
*/
export interface Admin {
id: string;
name?: string | null;
/**
* Owner and admin see all sites; support and content see only the sites below.
*/
roles?: ('owner' | 'admin' | 'support' | 'content')[] | null;
/**
* Sites available to support and content roles
*/
sites?:
| {
site: string | Site;
id?: string | null;
}[]
| null;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
resetPasswordRequestedAt?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
collection: 'admins';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv".
*/
export interface PayloadKv {
id: string;
key: string;
data:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
document?:
| ({
relationTo: 'tariffs';
value: string | Tariff;
} | null)
| ({
relationTo: 'locations';
value: string | Location;
} | null)
| ({
relationTo: 'countries';
value: string | Country;
} | null)
| ({
relationTo: 'tags';
value: string | Tag;
} | null)
| ({
relationTo: 'media';
value: string | Media;
} | null)
| ({
relationTo: 'sites';
value: string | Site;
} | null)
| ({
relationTo: 'domains';
value: string | Domain;
} | null)
| ({
relationTo: 'currencies';
value: string | Currency;
} | null)
| ({
relationTo: 'payment-systems';
value: string | PaymentSystem;
} | null)
| ({
relationTo: 'admins';
value: string | Admin;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'admins';
value: string | Admin;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
user: {
relationTo: 'admins';
value: string | Admin;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "tariffs_select".
*/
export interface TariffsSelect<T extends boolean = true> {
site?: T;
name?: T;
code?: T;
type?: T;
description?: T;
features?:
| T
| {
text?: T;
id?: T;
};
period?:
| T
| {
unit?: T;
count?: T;
};
devicesLimit?: T;
prices?:
| T
| {
currency?: T;
firstPeriodAmount?: T;
renewalAmount?: T;
id?: T;
};
countries?: T;
tags?: T;
badge?: T;
isDefault?: T;
active?: T;
sort?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "locations_select".
*/
export interface LocationsSelect<T extends boolean = true> {
site?: T;
country?: T;
city?: T;
lat?: T;
lng?: T;
status?: T;
freeServers?: T;
visible?: T;
sort?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "countries_select".
*/
export interface CountriesSelect<T extends boolean = true> {
iso2?: T;
name?: T;
lat?: T;
lng?: T;
sort?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "tags_select".
*/
export interface TagsSelect<T extends boolean = true> {
name?: T;
code?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media_select".
*/
export interface MediaSelect<T extends boolean = true> {
alt?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "sites_select".
*/
export interface SitesSelect<T extends boolean = true> {
code?: T;
name?: T;
orderPrefix?: T;
locales?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "domains_select".
*/
export interface DomainsSelect<T extends boolean = true> {
site?: T;
hostname?: T;
app?: T;
locales?: T;
defaultLocale?: T;
currencies?: T;
defaultCurrency?: T;
localeCurrency?:
| T
| {
locale?: T;
currency?: T;
id?: T;
};
isPrimary?: T;
indexable?: T;
active?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "currencies_select".
*/
export interface CurrenciesSelect<T extends boolean = true> {
code?: T;
symbol?: T;
minorUnits?: T;
name?: T;
active?: T;
sort?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payment-systems_select".
*/
export interface PaymentSystemsSelect<T extends boolean = true> {
name?: T;
adapter?: T;
title?: T;
description?: T;
icon?: T;
currencies?: T;
purposes?: T;
limits?:
| T
| {
currency?: T;
min?: T;
max?: T;
id?: T;
};
capabilities?: T;
visibleToCustomers?: T;
mode?: T;
active?: T;
sort?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "admins_select".
*/
export interface AdminsSelect<T extends boolean = true> {
name?: T;
roles?: T;
sites?:
| T
| {
site?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
resetPasswordRequestedAt?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv_select".
*/
export interface PayloadKvSelect<T extends boolean = true> {
key?: T;
data?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "collections_widget".
*/
export interface CollectionsWidget {
data?: {
[k: string]: unknown;
};
width: 'full';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
export interface GeneratedTypes extends Config {}
}
+97
View File
@@ -0,0 +1,97 @@
import { postgresAdapter } from '@payloadcms/db-postgres'
import { multiTenantPlugin } from '@payloadcms/plugin-multi-tenant'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { en } from '@payloadcms/translations/languages/en'
import { ru } from '@payloadcms/translations/languages/ru'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { buildConfig } from 'payload'
import sharp from 'sharp'
import type { Config } from './payload-types'
import { hasRole } from './payload/access/roles'
import { Admins } from './payload/collections/Admins'
import { Countries } from './payload/collections/Countries'
import { Currencies } from './payload/collections/Currencies'
import { Domains } from './payload/collections/Domains'
import { Locations } from './payload/collections/Locations'
import { Media } from './payload/collections/Media'
import { PaymentSystems } from './payload/collections/PaymentSystems'
import { Sites } from './payload/collections/Sites'
import { Tags } from './payload/collections/Tags'
import { Tariffs } from './payload/collections/Tariffs'
import { localization } from './payload/localization'
const dirname = path.dirname(fileURLToPath(import.meta.url))
const adminOrigin = process.env.ADMIN_HOST ? `https://${process.env.ADMIN_HOST}` : undefined
export default buildConfig({
admin: {
user: Admins.slug,
importMap: { baseDir: dirname },
meta: { titleSuffix: ' · Veilix' },
},
// порядок коллекций — порядок в меню админки
collections: [
Tariffs,
Locations,
Countries,
Tags,
Media,
Sites,
Domains,
Currencies,
PaymentSystems,
Admins,
],
// cookie администратора принимаются только с хоста админки
csrf: adminOrigin ? [adminOrigin] : [],
db: postgresAdapter({
pool: { connectionString: process.env.DATABASE_URL },
idType: 'uuid',
// переводы полей — в таблицах <коллекция>_l10n: имя <коллекция>_locales нужно полю locales
// у сайтов и доменов (по умолчанию оба имени совпали бы)
localesSuffix: '_l10n',
migrationDir: path.resolve(dirname, 'migrations'),
// отдельная схема — только для проверки миграций на пустой базе
...(process.env.DATABASE_SCHEMA ? { schemaName: process.env.DATABASE_SCHEMA } : {}),
}),
editor: lexicalEditor(),
graphQL: { disable: true },
i18n: {
supportedLanguages: { en, ru },
fallbackLanguage: 'en',
},
localization,
plugins: [
multiTenantPlugin<Config>({
tenantsSlug: 'sites',
tenantField: { name: 'site' },
// поле «Сайты» администратора объявлено в Admins.ts: ему нужно условие показа
tenantsArrayField: {
arrayFieldName: 'sites',
arrayTenantFieldName: 'site',
includeDefaultField: false,
},
collections: {
domains: {},
locations: {},
tariffs: {},
},
userHasAccessToAllTenants: (user) => hasRole(user, 'owner', 'admin'),
i18n: {
translations: {
en: { 'nav-tenantSelector-label': 'Site' },
ru: { 'nav-tenantSelector-label': 'Сайт' },
},
},
}),
],
secret: process.env.PAYLOAD_SECRET ?? '',
sharp,
telemetry: false,
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})
+34
View File
@@ -0,0 +1,34 @@
import type { Access, FieldAccess } from 'payload'
export const ROLES = ['owner', 'admin', 'support', 'content'] as const
export type Role = (typeof ROLES)[number]
type AdminUser = { collection: 'admins'; id: string; roles?: Role[] | null }
export const isAdminUser = (user: unknown): user is AdminUser =>
typeof user === 'object' && user !== null && 'collection' in user && user.collection === 'admins'
export const hasRole = (user: unknown, ...roles: Role[]): boolean =>
isAdminUser(user) && (user.roles ?? []).some((role) => roles.includes(role))
/** Любой администратор с хотя бы одной ролью */
export const anyRole: Access = ({ req }) =>
isAdminUser(req.user) && (req.user.roles ?? []).length > 0
/** owner и admin: правка настроек и справочников */
export const managers: Access = ({ req }) => hasRole(req.user, 'owner', 'admin')
export const owners: Access = ({ req }) => hasRole(req.user, 'owner')
/** owner, admin и content: правка контента */
export const contentEditors: Access = ({ req }) => hasRole(req.user, 'owner', 'admin', 'content')
export const ownersField: FieldAccess = ({ req }) => hasRole(req.user, 'owner')
/** owner видит всех, остальные — только себя */
export const ownerOrSelf: Access = ({ req }) => {
if (hasRole(req.user, 'owner')) return true
if (isAdminUser(req.user)) return { id: { equals: req.user.id } }
return false
}
+155
View File
@@ -0,0 +1,155 @@
import { tenantsArrayField } from '@payloadcms/plugin-multi-tenant/fields'
import type {
ArrayField,
CollectionBeforeChangeHook,
CollectionBeforeDeleteHook,
CollectionConfig,
Field,
PayloadRequest,
} from 'payload'
import { APIError } from 'payload'
import { ownerOrSelf, owners, ownersField, type Role } from '../access/roles'
import { groups, label, message } from '../i18n'
const roleOptions: { value: Role; label: { en: string; ru: string } }[] = [
{ value: 'owner', label: label('Owner', 'Владелец') },
{ value: 'admin', label: label('Admin', 'Администратор') },
{ value: 'support', label: label('Support', 'Поддержка') },
{ value: 'content', label: label('Content', 'Контент') },
]
/** Первый администратор окружения (экран первого входа Payload) становится owner */
const firstAdminIsOwner: CollectionBeforeChangeHook = async ({ data, operation, req }) => {
if (operation !== 'create') return data
const { totalDocs } = await req.payload.count({
collection: 'admins',
overrideAccess: true,
req,
})
return totalDocs === 0 ? { ...data, roles: ['owner'] } : data
}
const otherOwnersCount = async (req: PayloadRequest, id: number | string) => {
const { totalDocs } = await req.payload.count({
collection: 'admins',
where: { and: [{ roles: { in: ['owner'] } }, { id: { not_equals: id } }] },
overrideAccess: true,
req,
})
return totalDocs
}
const lastOwnerError = (req: PayloadRequest) =>
new APIError(
message(req, {
en: 'This is the last owner. Make someone else an owner first.',
ru: 'Это последний владелец. Сначала назначьте владельцем кого-то ещё.',
}),
400,
undefined,
true,
)
/** Без владельца админкой некому управлять: последний owner не теряет роль */
const keepLastOwner: CollectionBeforeChangeHook = async ({ data, operation, originalDoc, req }) => {
if (operation !== 'update' || !originalDoc?.roles?.includes('owner')) return data
const roles: Role[] | undefined = data.roles
if (!roles || roles.includes('owner')) return data
if ((await otherOwnersCount(req, originalDoc.id)) === 0) throw lastOwnerError(req)
return data
}
const keepLastOwnerOnDelete: CollectionBeforeDeleteHook = async ({ id, req }) => {
const doc = await req.payload.findByID({
collection: 'admins',
id,
depth: 0,
overrideAccess: true,
req,
})
if (doc.roles?.includes('owner') && (await otherOwnersCount(req, id)) === 0) {
throw lastOwnerError(req)
}
}
const pluginSites = tenantsArrayField({
tenantsArrayFieldName: 'sites',
tenantsArrayTenantFieldName: 'site',
tenantsCollectionSlug: 'sites',
arrayFieldAccess: { create: ownersField, update: ownersField },
})
/**
* Сайты, которые видит администратор (поле плагина multi-tenant). Нужно только поддержке
* и контенту: owner и admin видят все сайты. На экране первого входа пользователя ещё нет,
* а первый администратор и так становится owner, поэтому там поле скрыто.
*/
const sitesField: ArrayField = {
...pluginSites,
label: label('Sites', 'Сайты'),
labels: { singular: label('Site', 'Сайт'), plural: label('Sites', 'Сайты') },
fields: pluginSites.fields.map((field: Field) =>
'name' in field && field.name === 'site' ? { ...field, label: label('Site', 'Сайт') } : field,
) as ArrayField['fields'],
admin: {
condition: (data, _siblingData, { user }) =>
Boolean(user) &&
!(data.roles ?? []).some((role: Role) => role === 'owner' || role === 'admin'),
description: label(
'Sites available to support and content roles',
'Сайты, доступные ролям «Поддержка» и «Контент»',
),
},
}
export const Admins: CollectionConfig = {
slug: 'admins',
labels: {
singular: label('Administrator', 'Администратор'),
plural: label('Administrators', 'Администраторы'),
},
admin: {
group: groups.settings,
useAsTitle: 'email',
defaultColumns: ['email', 'name', 'roles'],
},
auth: {
maxLoginAttempts: 5,
lockTime: 15 * 60 * 1000,
// админка открывается только по HTTPS через nginx
cookies: { secure: true, sameSite: 'Lax' },
},
access: {
read: ownerOrSelf,
create: owners,
update: ownerOrSelf,
delete: owners,
},
hooks: {
beforeChange: [firstAdminIsOwner, keepLastOwner],
beforeDelete: [keepLastOwnerOnDelete],
},
fields: [
{
name: 'name',
type: 'text',
label: label('Name', 'Имя'),
},
{
name: 'roles',
type: 'select',
hasMany: true,
options: roleOptions,
label: label('Roles', 'Роли'),
access: { create: ownersField, update: ownersField },
admin: {
description: label(
'Owner and admin see all sites; support and content see only the sites below.',
'Владелец и администратор видят все сайты, поддержка и контент — только сайты ниже.',
),
},
},
sitesField,
],
}
+75
View File
@@ -0,0 +1,75 @@
import type { CollectionConfig, FieldHook, TextFieldSingleValidation } from 'payload'
import { anyRole, managers } from '../access/roles'
import { sortField } from '../fields/common'
import { groups, label, message } from '../i18n'
const upperCase: FieldHook = ({ value }) =>
typeof value === 'string' ? value.trim().toUpperCase() : value
const validateIso2: TextFieldSingleValidation = (value, { req }) =>
typeof value === 'string' && /^[A-Z]{2}$/.test(value)
? true
: message(req, {
en: 'Two capital letters ISO 3166-1, e.g. NL',
ru: 'Две заглавные буквы ISO 3166-1, например NL',
})
export const Countries: CollectionConfig = {
slug: 'countries',
labels: {
singular: label('Country', 'Страна'),
plural: label('Countries', 'Страны'),
},
admin: {
group: groups.catalog,
useAsTitle: 'name',
defaultColumns: ['name', 'iso2', 'sort'],
description: label('Shared by both brands', 'Общий справочник обоих брендов'),
},
defaultSort: 'sort',
access: {
read: anyRole,
create: managers,
update: managers,
delete: managers,
},
fields: [
{
type: 'row',
fields: [
{
name: 'iso2',
type: 'text',
required: true,
unique: true,
label: label('Code (ISO 3166-1)', 'Код (ISO 3166-1)'),
hooks: { beforeValidate: [upperCase] },
validate: validateIso2,
},
{
name: 'name',
type: 'text',
required: true,
localized: true,
label: label('Name', 'Название'),
},
],
},
{
type: 'row',
fields: [
{
name: 'lat',
type: 'number',
min: -90,
max: 90,
label: label('Latitude', 'Широта'),
admin: { description: label('Default point on the map', 'Точка на карте по умолчанию') },
},
{ name: 'lng', type: 'number', min: -180, max: 180, label: label('Longitude', 'Долгота') },
],
},
sortField,
],
}
+130
View File
@@ -0,0 +1,130 @@
import type {
CollectionBeforeDeleteHook,
CollectionConfig,
FieldHook,
TextFieldSingleValidation,
} from 'payload'
import { APIError } from 'payload'
import { anyRole, managers } from '../access/roles'
import { integerField, sortField } from '../fields/common'
import { groups, label, message } from '../i18n'
const upperCase: FieldHook = ({ value }) =>
typeof value === 'string' ? value.trim().toUpperCase() : value
const validateCode: TextFieldSingleValidation = (value, { req }) =>
typeof value === 'string' && /^[A-Z]{3}$/.test(value)
? true
: message(req, {
en: 'Three capital letters ISO 4217, e.g. USD',
ru: 'Три заглавные буквы ISO 4217, например USD',
})
/** Используемую валюту не удаляем: на её код ссылаются домены, тарифы и платёжные системы */
const keepUsedCurrency: CollectionBeforeDeleteHook = async ({ id, req }) => {
const { code } = await req.payload.findByID({
collection: 'currencies',
id,
depth: 0,
overrideAccess: true,
req,
})
const domains = await req.payload.count({
collection: 'domains',
where: { or: [{ currencies: { in: [code] } }, { defaultCurrency: { equals: code } }] },
overrideAccess: true,
req,
})
const paymentSystems = await req.payload.count({
collection: 'payment-systems',
where: { currencies: { in: [code] } },
overrideAccess: true,
req,
})
const tariffs = await req.payload.count({
collection: 'tariffs',
where: { 'prices.currency': { equals: code } },
overrideAccess: true,
req,
})
if (domains.totalDocs + paymentSystems.totalDocs + tariffs.totalDocs > 0) {
throw new APIError(
message(req, {
en: 'The currency is used by domains, tariffs or payment systems. Turn it off instead.',
ru: 'Валюта используется в доменах, тарифах или платёжных системах. Вместо удаления выключите её.',
}),
400,
undefined,
true,
)
}
}
export const Currencies: CollectionConfig = {
slug: 'currencies',
labels: {
singular: label('Currency', 'Валюта'),
plural: label('Currencies', 'Валюты'),
},
admin: {
group: groups.settings,
useAsTitle: 'code',
defaultColumns: ['code', 'symbol', 'name', 'active', 'sort'],
},
defaultSort: 'sort',
access: {
read: anyRole,
create: managers,
update: managers,
delete: managers,
},
hooks: {
beforeDelete: [keepUsedCurrency],
},
fields: [
{
type: 'row',
fields: [
{
name: 'code',
type: 'text',
required: true,
unique: true,
label: label('Code (ISO 4217)', 'Код (ISO 4217)'),
hooks: { beforeValidate: [upperCase] },
validate: validateCode,
// на код ссылаются денежные записи, после создания не меняется
access: { update: () => false },
},
{
name: 'symbol',
type: 'text',
required: true,
label: label('Symbol', 'Символ'),
},
integerField('minorUnits', label('Minor units', 'Знаков после запятой'), {
required: true,
defaultValue: 2,
min: 0,
max: 4,
}),
],
},
{
name: 'name',
type: 'text',
required: true,
localized: true,
label: label('Name', 'Название'),
},
{
name: 'active',
type: 'checkbox',
defaultValue: true,
label: label('Active', 'Включена'),
admin: { position: 'sidebar' },
},
sortField,
],
}
+267
View File
@@ -0,0 +1,267 @@
import type {
CollectionBeforeChangeHook,
CollectionConfig,
FieldHook,
TextFieldSingleValidation,
Where,
} from 'payload'
import { ValidationError } from 'payload'
import { text } from 'payload/shared'
import { anyRole, managers } from '../access/roles'
import { currenciesField, currencyField } from '../fields/currency'
import { localeOptions } from '../fields/locales'
import { groups, label, message } from '../i18n'
import { relationId } from '../utils'
const HOSTNAME = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/
const normalizeHostname: FieldHook = ({ value }) =>
typeof value === 'string' ? value.trim().toLowerCase().replace(/\.$/, '') : value
const validateHostname: TextFieldSingleValidation = (value, args) => {
const base = text(value, args)
if (base !== true) return base
return typeof value === 'string' && HOSTNAME.test(value)
? true
: message(args.req, {
en: 'Hostname without protocol and path, e.g. veilix.app',
ru: 'Имя хоста без протокола и пути, например veilix.app',
})
}
type LocaleCurrencyRow = { locale?: string | null; currency?: string | null }
/** Проверки, которым нужны несколько полей сразу и сайт домена */
const validateDomain: CollectionBeforeChangeHook = async ({
collection,
data,
originalDoc,
req,
}) => {
const doc = { ...originalDoc, ...data }
const locales: string[] = doc.locales ?? []
const currencies: string[] = doc.currencies ?? []
const errors: { path: string; message: string }[] = []
const notInLocales = message(req, {
en: 'Not among the domain languages',
ru: 'Не входит в языки домена',
})
const notInCurrencies = message(req, {
en: 'Not among the domain currencies',
ru: 'Не входит в валюты домена',
})
const siteId = relationId(doc.site)
if (siteId) {
const site = await req.payload.findByID({
collection: 'sites',
id: siteId,
depth: 0,
overrideAccess: true,
req,
})
const extra = locales.filter((locale) => !(site.locales ?? []).includes(locale as never))
if (extra.length > 0) {
errors.push({
path: 'locales',
message: message(req, {
en: `The site has no content in these languages: ${extra.join(', ')}`,
ru: `У сайта нет контента на этих языках: ${extra.join(', ')}`,
}),
})
}
}
if (doc.defaultLocale && !locales.includes(doc.defaultLocale)) {
errors.push({ path: 'defaultLocale', message: notInLocales })
}
if (doc.defaultCurrency && !currencies.includes(doc.defaultCurrency)) {
errors.push({ path: 'defaultCurrency', message: notInCurrencies })
}
const seen = new Set<string>()
const rows: LocaleCurrencyRow[] = doc.localeCurrency ?? []
rows.forEach((row, index) => {
if (row.locale && !locales.includes(row.locale)) {
errors.push({ path: `localeCurrency.${index}.locale`, message: notInLocales })
} else if (row.locale && seen.has(row.locale)) {
errors.push({
path: `localeCurrency.${index}.locale`,
message: message(req, { en: 'Language is listed twice', ru: 'Язык указан дважды' }),
})
}
if (row.locale) seen.add(row.locale)
if (row.currency && !currencies.includes(row.currency)) {
errors.push({ path: `localeCurrency.${index}.currency`, message: notInCurrencies })
}
})
if (doc.isPrimary && siteId) {
const where: Where = {
and: [
{ site: { equals: siteId } },
{ app: { equals: doc.app } },
{ isPrimary: { equals: true } },
...(originalDoc?.id ? [{ id: { not_equals: originalDoc.id } }] : []),
],
}
const { docs } = await req.payload.find({
collection: 'domains',
where,
depth: 0,
limit: 1,
overrideAccess: true,
req,
})
if (docs[0]) {
errors.push({
path: 'isPrimary',
message: message(req, {
en: `The site already has a primary domain of this type: ${docs[0].hostname}`,
ru: `У сайта уже есть основной домен этого типа: ${docs[0].hostname}`,
}),
})
}
}
if (errors.length > 0) throw new ValidationError({ collection: collection.slug, errors, req })
return data
}
export const Domains: CollectionConfig = {
slug: 'domains',
labels: {
singular: label('Domain', 'Домен'),
plural: label('Domains', 'Домены'),
},
admin: {
group: groups.settings,
useAsTitle: 'hostname',
defaultColumns: ['hostname', 'app', 'locales', 'currencies', 'active'],
description: label(
'A new mirror is a record here plus an nginx block and a certificate.',
'Новое зеркало — запись здесь плюс блок nginx и сертификат.',
),
},
access: {
read: anyRole,
create: managers,
update: managers,
delete: managers,
},
hooks: {
beforeChange: [validateDomain],
},
fields: [
{
name: 'hostname',
type: 'text',
required: true,
unique: true,
label: label('Hostname', 'Хост'),
hooks: { beforeValidate: [normalizeHostname] },
validate: validateHostname,
},
{
name: 'app',
type: 'select',
required: true,
options: [
{ value: 'marketing', label: label('Website', 'Сайт') },
{ value: 'cabinet', label: label('Account and checkout', 'Кабинет и оформление заказа') },
{ value: 'miniapp', label: label('Telegram MiniApp', 'MiniApp в Telegram') },
],
label: label('Frontend', 'Фронтенд'),
},
{
type: 'row',
fields: [
{
name: 'locales',
type: 'select',
hasMany: true,
required: true,
options: localeOptions,
label: label('Languages', 'Языки'),
},
{
name: 'defaultLocale',
type: 'select',
required: true,
defaultValue: 'en',
options: localeOptions,
label: label('Default language', 'Язык по умолчанию'),
admin: {
description: label('Opens without a URL prefix', 'Открывается без префикса в адресе'),
},
},
],
},
{
type: 'row',
fields: [
currenciesField({
name: 'currencies',
label: label('Currencies', 'Валюты'),
required: true,
}),
currencyField({
name: 'defaultCurrency',
label: label('Default currency', 'Валюта по умолчанию'),
required: true,
}),
],
},
{
name: 'localeCurrency',
type: 'array',
label: label('Currency by language', 'Валюта для языка'),
labels: { singular: label('Rule', 'Правило'), plural: label('Rules', 'Правила') },
admin: {
description: label(
'Default currency for a language, e.g. ru → RUB. Other languages get the default currency.',
'Валюта по умолчанию для языка, например ru → RUB. Для остальных языков — валюта по умолчанию.',
),
},
fields: [
{
type: 'row',
fields: [
{
name: 'locale',
type: 'select',
required: true,
options: localeOptions,
label: label('Language', 'Язык'),
},
currencyField({ name: 'currency', label: label('Currency', 'Валюта'), required: true }),
],
},
],
},
{
name: 'isPrimary',
type: 'checkbox',
label: label('Primary domain of this type', 'Основной домен этого типа'),
admin: {
position: 'sidebar',
description: label('Used in links and the sitemap', 'Используется в ссылках и sitemap'),
},
},
{
name: 'indexable',
type: 'checkbox',
defaultValue: false,
label: label('Allow search indexing', 'Разрешить индексацию'),
admin: { position: 'sidebar' },
},
{
name: 'active',
type: 'checkbox',
defaultValue: true,
label: label('Active', 'Включён'),
admin: { position: 'sidebar' },
},
],
}
+149
View File
@@ -0,0 +1,149 @@
import type { CollectionBeforeChangeHook, CollectionConfig } from 'payload'
import { ValidationError } from 'payload'
import type { SiteCode } from '@/core/sites'
import { anyRole, managers } from '../access/roles'
import { integerField, sortField } from '../fields/common'
import { uniqueInSite } from '../hooks/uniqueInSite'
import { groups, label, message } from '../i18n'
import { relationId } from '../utils'
export const LOCATION_STATUSES = ['available', 'on_demand', 'soon'] as const
/** dedicated: «Серверы в наличии» и «Установим за 24 часа»; mass: «Включено» и «Скоро» */
const STATUSES_BY_SITE: Record<SiteCode, readonly string[]> = {
dedicated: ['available', 'on_demand'],
mass: ['available', 'soon'],
}
const validateStatus: CollectionBeforeChangeHook = async ({
collection,
data,
originalDoc,
req,
}) => {
const doc = { ...originalDoc, ...data }
const siteId = relationId(doc.site)
if (!siteId || !doc.status) return data
const site = await req.payload.findByID({
collection: 'sites',
id: siteId,
depth: 0,
overrideAccess: true,
req,
})
if (!STATUSES_BY_SITE[site.code].includes(doc.status)) {
throw new ValidationError({
collection: collection.slug,
errors: [
{
path: 'status',
message: message(req, {
en: 'This status is not used by the brand of the site',
ru: 'Этот статус не используется у бренда сайта',
}),
},
],
req,
})
}
return data
}
export const Locations: CollectionConfig = {
slug: 'locations',
labels: {
singular: label('Location', 'Локация'),
plural: label('Locations', 'Локации'),
},
admin: {
group: groups.catalog,
useAsTitle: 'city',
defaultColumns: ['city', 'country', 'status', 'visible', 'sort'],
},
defaultSort: 'sort',
access: {
read: anyRole,
create: managers,
update: managers,
delete: managers,
},
indexes: [{ fields: ['site', 'country'], unique: true }],
hooks: {
beforeChange: [
validateStatus,
uniqueInSite('country', {
en: 'The site already has a location in this country',
ru: 'У сайта уже есть локация в этой стране',
}),
],
},
fields: [
{
type: 'row',
fields: [
{
name: 'country',
type: 'relationship',
relationTo: 'countries',
required: true,
label: label('Country', 'Страна'),
},
{
name: 'city',
type: 'text',
required: true,
localized: true,
label: label('City', 'Город'),
},
],
},
{
type: 'row',
fields: [
{
name: 'lat',
type: 'number',
min: -90,
max: 90,
label: label('Latitude', 'Широта'),
admin: { description: label('City point on the map', 'Точка города на карте') },
},
{ name: 'lng', type: 'number', min: -180, max: 180, label: label('Longitude', 'Долгота') },
],
},
{
name: 'status',
type: 'select',
required: true,
options: [
{ value: 'available', label: label('Available now', 'Доступна сейчас') },
{
value: 'on_demand',
label: label('Ready within 24 hours (Veilix)', 'Установим за 24 часа (Вейликс)'),
},
{ value: 'soon', label: label('Coming soon (VeilixVPN)', 'Скоро (ВейликсВПН)') },
],
label: label('Status', 'Статус'),
},
integerField('freeServers', label('Free servers', 'Свободных серверов'), {
min: 0,
admin: {
description: label(
'Optional counter. Whether to show it on the site is decided in stage 1b.',
'Необязательный счётчик. Показывать ли его на сайте — решаем на этапе 1b.',
),
},
}),
{
name: 'visible',
type: 'checkbox',
defaultValue: true,
label: label('Shown on the site', 'Показывать на сайте'),
admin: { position: 'sidebar' },
},
sortField,
],
}
+36
View File
@@ -0,0 +1,36 @@
import path from 'node:path'
import type { CollectionConfig } from 'payload'
import { contentEditors } from '../access/roles'
import { groups, label } from '../i18n'
export const Media: CollectionConfig = {
slug: 'media',
labels: {
singular: label('File', 'Файл'),
plural: label('Media', 'Медиатека'),
},
admin: {
group: groups.content,
},
access: {
// файлы открыты всем: их показывают сайты брендов
read: () => true,
create: contentEditors,
update: contentEditors,
delete: contentEditors,
},
upload: {
// папка media в корне проекта; в prod это ссылка на shared/media
staticDir: path.resolve(process.cwd(), 'media'),
mimeTypes: ['image/*'],
},
fields: [
{
name: 'alt',
type: 'text',
localized: true,
label: label('Alt text', 'Описание изображения'),
},
],
}
+209
View File
@@ -0,0 +1,209 @@
import type { CollectionBeforeChangeHook, CollectionConfig, FieldHook } from 'payload'
import { ValidationError } from 'payload'
import {
isPaymentAdapterCode,
PAYMENT_ADAPTER_CODES,
paymentAdapters,
} from '@/core/payments/adapters'
import { anyRole, managers } from '../access/roles'
import { amountField, sortField } from '../fields/common'
import { currenciesField, currencyField } from '../fields/currency'
import { groups, label, message } from '../i18n'
type LimitRow = { currency?: string | null; min?: number | null; max?: number | null }
const validatePaymentSystem: CollectionBeforeChangeHook = ({
collection,
data,
originalDoc,
req,
}) => {
const doc = { ...originalDoc, ...data }
const currencies: string[] = doc.currencies ?? []
const errors: { path: string; message: string }[] = []
// ручным платежом пользуются только администраторы
if (doc.adapter === 'manual' && doc.visibleToCustomers) {
errors.push({
path: 'visibleToCustomers',
message: message(req, {
en: 'Manual payments are for administrators only',
ru: 'Ручным платежом пользуются только администраторы',
}),
})
}
const rows: LimitRow[] = doc.limits ?? []
rows.forEach((row, index) => {
if (row.currency && !currencies.includes(row.currency)) {
errors.push({
path: `limits.${index}.currency`,
message: message(req, {
en: 'Not among the payment system currencies',
ru: 'Не входит в валюты платёжной системы',
}),
})
}
if (typeof row.min === 'number' && typeof row.max === 'number' && row.min > row.max) {
errors.push({
path: `limits.${index}.max`,
message: message(req, {
en: 'Maximum is less than minimum',
ru: 'Максимум меньше минимума',
}),
})
}
})
if (errors.length > 0) throw new ValidationError({ collection: collection.slug, errors, req })
return data
}
/** Возможности не хранятся: их объявляет адаптер в коде */
const readCapabilities: FieldHook = ({ siblingData }) => {
const adapter: unknown = siblingData?.adapter
if (!isPaymentAdapterCode(adapter)) return null
const capabilities: readonly string[] = paymentAdapters[adapter].capabilities
return capabilities.length > 0 ? capabilities.join(', ') : '—'
}
export const PaymentSystems: CollectionConfig = {
slug: 'payment-systems',
labels: {
singular: label('Payment system', 'Платёжная система'),
plural: label('Payment systems', 'Платёжные системы'),
},
admin: {
group: groups.settings,
useAsTitle: 'name',
defaultColumns: ['name', 'adapter', 'currencies', 'mode', 'active'],
description: label(
'Available in its currencies on every site where they are enabled. Provider keys are in .env only.',
'Доступна в своих валютах на всех сайтах, где они включены. Ключи провайдеров — только в .env.',
),
},
defaultSort: 'sort',
access: {
read: anyRole,
create: managers,
update: managers,
delete: managers,
},
hooks: {
beforeChange: [validatePaymentSystem],
},
fields: [
{
type: 'row',
fields: [
{
name: 'name',
type: 'text',
required: true,
label: label('Name in admin', 'Название в админке'),
},
{
name: 'adapter',
type: 'select',
required: true,
options: PAYMENT_ADAPTER_CODES.map((code) => ({ value: code, label: code })),
label: label('Adapter', 'Адаптер'),
},
],
},
{
name: 'title',
type: 'text',
required: true,
localized: true,
label: label('Title for customers', 'Название для пользователя'),
admin: { description: label('e.g. “Bank card”', 'например, «Банковская карта»') },
},
{
name: 'description',
type: 'text',
localized: true,
label: label('Hint for customers', 'Подсказка для пользователя'),
},
{
name: 'icon',
type: 'upload',
relationTo: 'media',
label: label('Icon', 'Иконка'),
},
{
type: 'row',
fields: [
currenciesField({
name: 'currencies',
label: label('Currencies', 'Валюты'),
required: true,
}),
{
name: 'purposes',
type: 'select',
hasMany: true,
required: true,
options: [
{ value: 'order', label: label('Order payment', 'Оплата заказа') },
{ value: 'topup', label: label('Balance top-up', 'Пополнение баланса') },
],
label: label('Purposes', 'Назначения'),
},
],
},
{
name: 'limits',
type: 'array',
label: label('Limits', 'Лимиты'),
labels: { singular: label('Limit', 'Лимит'), plural: label('Limits', 'Лимиты') },
fields: [
{
type: 'row',
fields: [
currencyField({ name: 'currency', label: label('Currency', 'Валюта'), required: true }),
amountField('min', label('Minimum', 'Минимум')),
amountField('max', label('Maximum', 'Максимум')),
],
},
],
},
{
name: 'capabilities',
type: 'text',
virtual: true,
label: label('Capabilities (from the adapter)', 'Возможности (из адаптера)'),
admin: { readOnly: true },
hooks: { afterRead: [readCapabilities] },
},
{
name: 'visibleToCustomers',
type: 'checkbox',
defaultValue: false,
label: label('Visible to customers', 'Видна пользователям'),
admin: { position: 'sidebar' },
},
{
name: 'mode',
type: 'select',
required: true,
defaultValue: 'test',
options: [
{ value: 'test', label: label('Test', 'Тестовый') },
{ value: 'live', label: label('Live', 'Боевой') },
],
label: label('Mode', 'Режим'),
admin: { position: 'sidebar' },
},
{
name: 'active',
type: 'checkbox',
defaultValue: true,
label: label('Active', 'Включена'),
admin: { position: 'sidebar' },
},
sortField,
],
}
+123
View File
@@ -0,0 +1,123 @@
import type {
CollectionConfig,
SelectFieldManyValidation,
TextFieldSingleValidation,
} from 'payload'
import { select } from 'payload/shared'
import { DEFAULT_LOCALE, LOCALES } from '@/i18n/locales'
import { anyRole, managers, owners } from '../access/roles'
import { localeOptions } from '../fields/locales'
import { groups, label, message } from '../i18n'
/**
* Языки сайта — языки его контента. Домен показывает любую их часть.
* Английский обязателен: на него откатывается непереведённый контент.
*/
const validateLocales: SelectFieldManyValidation = async (value, args) => {
const base = select(value, args)
if (base !== true) return base
const locales = value ?? []
if (!locales.includes(DEFAULT_LOCALE)) {
return message(args.req, {
en: 'English is required: untranslated content falls back to it',
ru: 'Английский обязателен: на него откатывается непереведённый контент',
})
}
if (args.id) {
const { docs } = await args.req.payload.find({
collection: 'domains',
where: { site: { equals: args.id } },
depth: 0,
pagination: false,
overrideAccess: true,
req: args.req,
})
const used = new Set(docs.flatMap((domain) => domain.locales ?? []))
const missing = [...used].filter((locale) => !locales.includes(locale))
if (missing.length > 0) {
return message(args.req, {
en: `These languages are enabled on the site's domains, remove them there first: ${missing.join(', ')}`,
ru: `Эти языки включены на доменах сайта, сначала уберите их там: ${missing.join(', ')}`,
})
}
}
return true
}
const validateOrderPrefix: TextFieldSingleValidation = (value, { req }) =>
typeof value === 'string' && /^[A-Z]{2,4}$/.test(value)
? true
: message(req, {
en: '2–4 capital Latin letters, e.g. VX',
ru: '2–4 заглавные латинские буквы, например VX',
})
export const Sites: CollectionConfig = {
slug: 'sites',
labels: {
singular: label('Site', 'Сайт'),
plural: label('Sites', 'Сайты'),
},
admin: {
group: groups.settings,
useAsTitle: 'name',
defaultColumns: ['name', 'code', 'orderPrefix', 'locales'],
},
access: {
read: anyRole,
create: managers,
update: managers,
// вместе с сайтом плагин удаляет все его документы
delete: owners,
},
fields: [
{
name: 'code',
type: 'select',
required: true,
unique: true,
options: [
{ value: 'dedicated', label: label('Veilix (dedicated)', 'Вейликс (dedicated)') },
{ value: 'mass', label: label('VeilixVPN (mass)', 'ВейликсВПН (mass)') },
],
label: label('Brand', 'Бренд'),
// по коду выбираются тема и страницы бренда, после создания не меняется
access: { update: () => false },
},
{
name: 'name',
type: 'text',
required: true,
localized: true,
label: label('Name', 'Название'),
},
{
name: 'orderPrefix',
type: 'text',
required: true,
unique: true,
label: label('Order number prefix', 'Префикс номеров'),
validate: validateOrderPrefix,
},
{
name: 'locales',
type: 'select',
hasMany: true,
required: true,
options: localeOptions,
defaultValue: [...LOCALES],
label: label('Content languages', 'Языки контента'),
validate: validateLocales,
admin: {
description: label(
'Each domain of the site shows some of these languages.',
'Каждый домен сайта показывает часть этих языков.',
),
},
},
],
}
+51
View File
@@ -0,0 +1,51 @@
import type { CollectionConfig, TextFieldSingleValidation } from 'payload'
import { anyRole, managers } from '../access/roles'
import { groups, label, message } from '../i18n'
const validateCode: TextFieldSingleValidation = (value, { req }) =>
typeof value === 'string' && /^[a-z0-9][a-z0-9_-]*$/.test(value)
? true
: message(req, {
en: 'Lowercase Latin letters, digits, - and _',
ru: 'Строчные латинские буквы, цифры, - и _',
})
export const Tags: CollectionConfig = {
slug: 'tags',
labels: {
singular: label('Tag', 'Тег'),
plural: label('Tags', 'Теги'),
},
admin: {
group: groups.catalog,
useAsTitle: 'name',
defaultColumns: ['name', 'code'],
description: label(
'For filtering tariffs and, later, automatic key provisioning',
'Для фильтрации тарифов и будущей автоматической выдачи ключей',
),
},
access: {
read: anyRole,
create: managers,
update: managers,
delete: managers,
},
fields: [
{
type: 'row',
fields: [
{ name: 'name', type: 'text', required: true, label: label('Name', 'Название') },
{
name: 'code',
type: 'text',
required: true,
unique: true,
label: label('Code', 'Код'),
validate: validateCode,
},
],
},
],
}
+213
View File
@@ -0,0 +1,213 @@
import type { ArrayFieldValidation, CollectionConfig, TextFieldSingleValidation } from 'payload'
import { array } from 'payload/shared'
import { anyRole, managers } from '../access/roles'
import { amountField, integerField, sortField } from '../fields/common'
import { currencyField } from '../fields/currency'
import { uniqueInSite } from '../hooks/uniqueInSite'
import { groups, label, message } from '../i18n'
const validateCode: TextFieldSingleValidation = (value, { req }) =>
typeof value === 'string' && /^[a-z0-9][a-z0-9_-]*$/.test(value)
? true
: message(req, {
en: 'Lowercase Latin letters, digits, - and _',
ru: 'Строчные латинские буквы, цифры, - и _',
})
/** Строка на валюту: нет строки — тариф в этой валюте не продаётся */
const validatePrices: ArrayFieldValidation = (value, args) => {
const base = array(value, args)
if (base !== true) return base
const codes = ((value ?? []) as { currency?: string }[])
.map((row) => row.currency)
.filter((code): code is string => Boolean(code))
const duplicates = codes.filter((code, index) => codes.indexOf(code) !== index)
return duplicates.length === 0
? true
: message(args.req, {
en: `One row per currency: ${duplicates.join(', ')}`,
ru: `По одной строке на валюту: ${duplicates.join(', ')}`,
})
}
export const Tariffs: CollectionConfig = {
slug: 'tariffs',
labels: {
singular: label('Tariff', 'Тариф'),
plural: label('Tariffs', 'Тарифы'),
},
admin: {
group: groups.catalog,
useAsTitle: 'name',
defaultColumns: ['name', 'code', 'type', 'active', 'sort'],
},
defaultSort: 'sort',
access: {
read: anyRole,
create: managers,
update: managers,
delete: managers,
},
indexes: [{ fields: ['site', 'code'], unique: true }],
hooks: {
beforeChange: [
uniqueInSite('code', {
en: 'The site already has a tariff with this code',
ru: 'У сайта уже есть тариф с таким кодом',
}),
],
},
fields: [
{
type: 'row',
fields: [
{
name: 'name',
type: 'text',
required: true,
localized: true,
label: label('Name', 'Название'),
},
{
name: 'code',
type: 'text',
required: true,
label: label('Code', 'Код'),
validate: validateCode,
},
{
name: 'type',
type: 'select',
required: true,
options: [
{ value: 'dedicated', label: label('Dedicated IP', 'Индивидуальный IP') },
{ value: 'mass', label: label('Shared VPN', 'Общий VPN') },
],
label: label('Key type', 'Тип ключа'),
admin: {
description: label(
'dedicated: the country is chosen at purchase; mass: all tariff countries',
'dedicated — страна выбирается при покупке, mass — все страны тарифа',
),
},
},
],
},
{
name: 'description',
type: 'textarea',
localized: true,
label: label('Description', 'Описание'),
},
{
name: 'features',
type: 'array',
label: label('What is included', 'Что входит'),
labels: { singular: label('Item', 'Пункт'), plural: label('Items', 'Пункты') },
fields: [
{
name: 'text',
type: 'text',
required: true,
localized: true,
label: label('Text', 'Текст'),
},
],
},
{
name: 'period',
type: 'group',
label: label('Period', 'Период'),
fields: [
{
type: 'row',
fields: [
{
name: 'unit',
type: 'select',
required: true,
defaultValue: 'month',
options: [
{ value: 'month', label: label('Month', 'Месяц') },
{ value: 'year', label: label('Year', 'Год') },
],
label: label('Unit', 'Единица'),
},
integerField('count', label('Count', 'Количество'), {
required: true,
defaultValue: 1,
min: 1,
}),
],
},
],
},
integerField('devicesLimit', label('Devices per key', 'Устройств на ключ'), {
required: true,
defaultValue: 5,
min: 1,
}),
{
name: 'prices',
type: 'array',
label: label('Prices', 'Цены'),
labels: { singular: label('Price', 'Цена'), plural: label('Prices', 'Цены') },
validate: validatePrices,
admin: {
description: label(
'One row per currency. No row means the tariff is not sold in that currency.',
'Строка на валюту. Нет строки — тариф в этой валюте не продаётся.',
),
},
fields: [
{
type: 'row',
fields: [
currencyField({ name: 'currency', label: label('Currency', 'Валюта'), required: true }),
amountField('firstPeriodAmount', label('First period', 'Первый период')),
amountField('renewalAmount', label('Renewal', 'Продление')),
],
},
],
},
{
name: 'countries',
type: 'relationship',
relationTo: 'countries',
hasMany: true,
label: label('Countries', 'Страны'),
},
{
name: 'tags',
type: 'relationship',
relationTo: 'tags',
hasMany: true,
label: label('Tags', 'Теги'),
},
{
name: 'badge',
type: 'text',
localized: true,
label: label('Badge', 'Плашка'),
admin: { description: label('e.g. “best value”', 'например, «выгоднее»') },
},
{
name: 'isDefault',
type: 'checkbox',
label: label(
'Selected by default on the pricing page',
'Выбран по умолчанию на странице цен',
),
admin: { position: 'sidebar' },
},
{
name: 'active',
type: 'checkbox',
defaultValue: true,
label: label('On sale', 'В продаже'),
admin: { position: 'sidebar' },
},
sortField,
],
}
@@ -0,0 +1,72 @@
'use client'
import { SelectInput, useConfig, useField } from '@payloadcms/ui'
import type { OptionObject, StaticDescription, TextFieldClientComponent } from 'payload'
import { useCallback, useEffect, useState } from 'react'
type CurrencyDoc = { code: string; symbol: string }
/** Поле с кодом валюты: выпадающий список из справочника «Валюты» */
export const CurrencyCodeField: TextFieldClientComponent = ({
field,
path: pathFromProps,
readOnly,
}) => {
const { admin, hasMany = false, label, localized, name, required } = field
const {
config: {
routes: { api },
serverURL,
},
} = useConfig()
const { disabled, path, setValue, showError, value } = useField<string | string[]>({
potentiallyStalePath: pathFromProps,
})
const [options, setOptions] = useState<OptionObject[]>([])
useEffect(() => {
const controller = new AbortController()
fetch(`${serverURL}${api}/currencies?depth=0&limit=100&sort=sort`, {
credentials: 'include',
signal: controller.signal,
})
.then((res) => res.json())
.then((data: { docs?: CurrencyDoc[] }) =>
setOptions(
(data.docs ?? []).map((doc) => ({ label: `${doc.code} ${doc.symbol}`, value: doc.code })),
),
)
.catch(() => {})
return () => controller.abort()
}, [api, serverURL])
const onChange = useCallback(
(selected: unknown) => {
if (hasMany) {
setValue(
Array.isArray(selected) ? selected.map((option: OptionObject) => option.value) : [],
)
} else {
setValue(selected && !Array.isArray(selected) ? (selected as OptionObject).value : null)
}
},
[hasMany, setValue],
)
return (
<SelectInput
description={admin?.description as StaticDescription | undefined}
hasMany={hasMany}
label={label}
localized={localized}
name={name}
onChange={onChange}
options={options}
path={path}
readOnly={readOnly || disabled}
required={required}
showError={showError}
value={value ?? undefined}
/>
)
}
+49
View File
@@ -0,0 +1,49 @@
import type { NumberField, NumberFieldSingleValidation } from 'payload'
import { number } from 'payload/shared'
import { label, message } from '../i18n'
export const sortField: NumberField = {
name: 'sort',
type: 'number',
defaultValue: 0,
label: label('Sort order', 'Порядок'),
admin: { position: 'sidebar' },
}
const integer: NumberFieldSingleValidation = (value, args) => {
const base = number(value, args)
if (base !== true) return base
if (typeof value === 'number' && !Number.isInteger(value)) {
return message(args.req, { en: 'Must be a whole number', ru: 'Нужно целое число' })
}
return true
}
/** Сумма денег: целое число в минимальных единицах валюты */
export const amountField = (name: string, fieldLabel: { en: string; ru: string }): NumberField => ({
name,
type: 'number',
required: true,
min: 0,
label: fieldLabel,
validate: integer,
admin: {
description: label(
'In minor units: 249900 = 2,499.00',
'В минимальных единицах: 249900 = 2 499,00',
),
},
})
export const integerField = (
name: string,
fieldLabel: { en: string; ru: string },
options: Pick<NumberField, 'defaultValue' | 'min' | 'max' | 'required' | 'admin'> = {},
): NumberField => ({
name,
type: 'number',
label: fieldLabel,
validate: integer,
...options,
})
+95
View File
@@ -0,0 +1,95 @@
import type {
FieldHook,
TextField,
TextFieldManyValidation,
TextFieldSingleValidation,
} from 'payload'
import { text } from 'payload/shared'
import { message } from '../i18n'
type Options = {
name: string
label: TextField['label']
required?: boolean
description?: { en: string; ru: string }
}
const normalize: FieldHook = ({ value }) => {
if (Array.isArray(value)) return value.map((code) => String(code).trim().toUpperCase())
return typeof value === 'string' ? value.trim().toUpperCase() : value
}
/**
* Коды из списка есть в справочнике «Валюты» и не повторяются.
* Валюту храним кодом ISO 4217, а не ссылкой: так её хранят и денежные записи.
*/
const checkCodes = async (
codes: string[],
req: Parameters<TextFieldSingleValidation>[1]['req'],
): Promise<string | true> => {
if (codes.length === 0) return true
const duplicates = codes.filter((code, index) => codes.indexOf(code) !== index)
if (duplicates.length > 0) {
return message(req, {
en: `Currency is listed twice: ${duplicates.join(', ')}`,
ru: `Валюта указана дважды: ${duplicates.join(', ')}`,
})
}
const { docs } = await req.payload.find({
collection: 'currencies',
where: { code: { in: codes } },
depth: 0,
pagination: false,
overrideAccess: true,
req,
})
const known = new Set(docs.map((doc) => doc.code))
const unknown = codes.filter((code) => !known.has(code))
if (unknown.length > 0) {
return message(req, {
en: `Not in the currencies list: ${unknown.join(', ')}`,
ru: `Нет в справочнике валют: ${unknown.join(', ')}`,
})
}
return true
}
const validateOne: TextFieldSingleValidation = async (value, args) => {
const base = text(value, args)
if (base !== true) return base
return checkCodes(value ? [value] : [], args.req)
}
const validateMany: TextFieldManyValidation = async (value, args) => {
const codes = value ?? []
if (args.required && codes.length === 0) return args.req.t('validation:required')
return checkCodes(codes, args.req)
}
const component = '/payload/components/CurrencyCodeField#CurrencyCodeField'
/** Код одной валюты: в админке — выпадающий список из справочника */
export const currencyField = ({ name, label, required, description }: Options): TextField => ({
name,
type: 'text',
label,
required,
hooks: { beforeValidate: [normalize] },
validate: validateOne,
admin: { components: { Field: component }, description },
})
/** Несколько валют */
export const currenciesField = ({ name, label, required, description }: Options): TextField => ({
name,
type: 'text',
hasMany: true,
label,
required,
hooks: { beforeValidate: [normalize] },
validate: validateMany,
admin: { components: { Field: component }, description },
})
+8
View File
@@ -0,0 +1,8 @@
import type { OptionObject } from 'payload'
import { LOCALE_NAMES, LOCALES } from '@/i18n/locales'
export const localeOptions: OptionObject[] = LOCALES.map((code) => ({
value: code,
label: LOCALE_NAMES[code],
}))
+40
View File
@@ -0,0 +1,40 @@
import type { CollectionBeforeChangeHook, CollectionSlug, Where } from 'payload'
import { ValidationError } from 'payload'
import { message } from '../i18n'
import { relationId } from '../utils'
/**
* Значение поля уникально в пределах сайта. Гарантию даёт составной индекс в базе,
* а хук нужен для понятного сообщения вместо ошибки базы.
*/
export const uniqueInSite =
(field: string, text: { en: string; ru: string }): CollectionBeforeChangeHook =>
async ({ collection, data, originalDoc, req }) => {
const doc = { ...originalDoc, ...data }
const siteId = relationId(doc.site)
const value = relationId(doc[field]) ?? doc[field]
if (!siteId || value === undefined || value === null) return data
const where: Where = {
and: [
{ site: { equals: siteId } },
{ [field]: { equals: value } },
...(originalDoc?.id ? [{ id: { not_equals: originalDoc.id } }] : []),
],
}
const { totalDocs } = await req.payload.count({
collection: collection.slug as CollectionSlug,
where,
overrideAccess: true,
req,
})
if (totalDocs > 0) {
throw new ValidationError({
collection: collection.slug,
errors: [{ path: field, message: message(req, text) }],
req,
})
}
return data
}
+16
View File
@@ -0,0 +1,16 @@
import type { PayloadRequest } from 'payload'
type Text = { en: string; ru: string }
/** Подпись в админке на двух её языках интерфейса */
export const label = (en: string, ru: string): Text => ({ en, ru })
/** Сообщение (например, об ошибке проверки) на языке интерфейса админки */
export const message = (req: Partial<PayloadRequest> | undefined, text: Text): string =>
req?.i18n?.language === 'ru' ? text.ru : text.en
export const groups = {
settings: label('Settings', 'Настройки'),
catalog: label('Catalog', 'Каталог'),
content: label('Content', 'Контент'),
}
+34
View File
@@ -0,0 +1,34 @@
import { getTenantFromCookie } from '@payloadcms/plugin-multi-tenant/utilities'
import type { Config } from 'payload'
import { DEFAULT_LOCALE, LOCALE_NAMES, LOCALES } from '@/i18n/locales'
type Localization = Exclude<Config['localization'], false | undefined>
/** В админке видны только языки сайта, выбранного в переключателе. Без выбранного сайта — все. */
const filterAvailableLocales: NonNullable<Localization['filterAvailableLocales']> = async ({
locales,
req,
}) => {
const siteId = getTenantFromCookie(req.headers, 'text')
if (!siteId) return locales
try {
const site = await req.payload.findByID({
collection: 'sites',
id: siteId,
depth: 0,
overrideAccess: true,
})
const enabled = new Set<string>(site.locales ?? [])
return locales.filter((locale) => enabled.has(locale.code))
} catch {
return locales
}
}
export const localization: Localization = {
locales: LOCALES.map((code) => ({ code, label: LOCALE_NAMES[code] })),
defaultLocale: DEFAULT_LOCALE,
fallback: true,
filterAvailableLocales,
}
+13
View File
@@ -0,0 +1,13 @@
/** id связанного документа: в зависимости от depth Payload отдаёт id или сам документ */
export const relationId = (value: unknown): string | null => {
if (typeof value === 'string') return value
if (
typeof value === 'object' &&
value !== null &&
'id' in value &&
typeof value.id === 'string'
) {
return value.id
}
return null
}
+90
View File
@@ -0,0 +1,90 @@
import { isbot } from 'isbot'
import { type NextRequest, NextResponse } from 'next/server'
import { type DomainConfig, DOMAINS_TTL_MS } from '@/core/domains/types'
import { decide } from '@/core/routing/decide'
/**
* Proxy работает отдельно от кода страниц и не делит с ним память,
* поэтому домены он берёт у /svc/internal/domains и держит у себя DOMAINS_TTL_MS.
*/
let domains: { at: number; byHost: Map<string, DomainConfig> } | null = null
let loading: Promise<Map<string, DomainConfig>> | null = null
const loadDomains = (): Promise<Map<string, DomainConfig>> => {
loading ??= fetch(`${process.env.INTERNAL_URL}/svc/internal/domains`, {
headers: { 'x-vx-internal-token': process.env.INTERNAL_TOKEN ?? '' },
cache: 'no-store',
})
.then(async (response) => {
if (!response.ok) throw new Error(`domains: HTTP ${response.status}`)
const list: DomainConfig[] = await response.json()
const byHost = new Map(list.map((domain) => [domain.hostname, domain]))
domains = { at: Date.now(), byHost }
return byHost
})
.finally(() => {
loading = null
})
return loading
}
/** Устаревший список отдаётся сразу и обновляется в фоне: запрос не ждёт обновления */
const getDomains = async (): Promise<Map<string, DomainConfig>> => {
if (!domains) return loadDomains()
if (Date.now() - domains.at > DOMAINS_TTL_MS) loadDomains().catch(() => {})
return domains.byHost
}
const notFound = () => new NextResponse('Not Found', { status: 404 })
export async function proxy(request: NextRequest) {
const { pathname, search } = request.nextUrl
const host = (request.headers.get('host') ?? '').toLowerCase().replace(/:\d+$/, '')
const isAdminHost = host === process.env.ADMIN_HOST
const isService = pathname === '/svc' || pathname.startsWith('/svc/')
let domain: DomainConfig | null = null
if (!isAdminHost && !isService) {
try {
domain = (await getDomains()).get(host) ?? null
} catch {
return new NextResponse('Service Unavailable', {
status: 503,
headers: { 'Retry-After': '5' },
})
}
}
const decision = decide({
method: request.method,
pathname,
search,
isAdminHost,
domain,
cookieLocale: request.cookies.get('NEXT_LOCALE')?.value,
cookieCurrency: request.cookies.get('vx_currency')?.value,
acceptLanguage: request.headers.get('accept-language'),
country: request.headers.get('x-geo-country'),
isBot: isbot(request.headers.get('user-agent')),
})
switch (decision.type) {
case 'next':
return NextResponse.next()
case 'rewrite':
return NextResponse.rewrite(new URL(decision.pathname + search, request.url))
case 'redirect': {
// за nginx запрос приходит по http, а снаружи сайт открыт по https
const proto = request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim() || 'https'
return NextResponse.redirect(new URL(decision.location, `${proto}://${host}`), 307)
}
case 'not-found':
return notFound()
}
}
// служебные пути Next (статика, оптимизация картинок, горячая перезагрузка) и public/static — мимо proxy
export const config = {
matcher: ['/((?!_next/|static/|favicon.ico).*)'],
}
+166
View File
@@ -0,0 +1,166 @@
/**
* Начальные данные. Локации — из макета Price.dc.html и docs/01-product.md, раздел 7:
* страна, город, координаты, готовность и ценовой уровень dedicated.
*/
export type Tier = 'base' | 'rare' | 'distant'
type Names = { en: string; ru: string }
export type LocationSeed = {
iso2: string
country: Names
city: Names
lat: number
lng: number
/** готова — «Серверы в наличии» / «Включено»; иначе «Установим за 24 часа» / «Скоро» */
ready: boolean
tier: Tier
}
const location = (
iso2: string,
country: Names,
city: Names,
lng: number,
lat: number,
ready: boolean,
tier: Tier,
): LocationSeed => ({ iso2, country, city, lat, lng, ready, tier })
// prettier-ignore
export const LOCATIONS: LocationSeed[] = [
location('NL', { ru: 'Нидерланды', en: 'Netherlands' }, { ru: 'Амстердам', en: 'Amsterdam' }, 4.9, 52.37, true, 'base'),
location('DE', { ru: 'Германия', en: 'Germany' }, { ru: 'Франкфурт', en: 'Frankfurt' }, 8.68, 50.11, true, 'base'),
location('US', { ru: 'США', en: 'United States' }, { ru: 'Нью-Йорк', en: 'New York' }, -74.01, 40.71, true, 'base'),
location('GB', { ru: 'Великобритания', en: 'United Kingdom' }, { ru: 'Лондон', en: 'London' }, -0.13, 51.51, true, 'base'),
location('FR', { ru: 'Франция', en: 'France' }, { ru: 'Париж', en: 'Paris' }, 2.35, 48.86, true, 'base'),
location('FI', { ru: 'Финляндия', en: 'Finland' }, { ru: 'Хельсинки', en: 'Helsinki' }, 24.94, 60.17, true, 'base'),
location('SE', { ru: 'Швеция', en: 'Sweden' }, { ru: 'Стокгольм', en: 'Stockholm' }, 18.07, 59.33, true, 'base'),
location('NO', { ru: 'Норвегия', en: 'Norway' }, { ru: 'Осло', en: 'Oslo' }, 10.75, 59.91, false, 'base'),
location('DK', { ru: 'Дания', en: 'Denmark' }, { ru: 'Копенгаген', en: 'Copenhagen' }, 12.57, 55.68, false, 'base'),
location('CH', { ru: 'Швейцария', en: 'Switzerland' }, { ru: 'Цюрих', en: 'Zurich' }, 8.54, 47.37, true, 'rare'),
location('AT', { ru: 'Австрия', en: 'Austria' }, { ru: 'Вена', en: 'Vienna' }, 16.37, 48.21, true, 'base'),
location('ES', { ru: 'Испания', en: 'Spain' }, { ru: 'Мадрид', en: 'Madrid' }, -3.7, 40.42, true, 'base'),
location('IT', { ru: 'Италия', en: 'Italy' }, { ru: 'Милан', en: 'Milan' }, 9.19, 45.46, true, 'base'),
location('PL', { ru: 'Польша', en: 'Poland' }, { ru: 'Варшава', en: 'Warsaw' }, 21.01, 52.23, true, 'base'),
location('CZ', { ru: 'Чехия', en: 'Czechia' }, { ru: 'Прага', en: 'Prague' }, 14.44, 50.08, true, 'base'),
location('RO', { ru: 'Румыния', en: 'Romania' }, { ru: 'Бухарест', en: 'Bucharest' }, 26.1, 44.43, false, 'base'),
location('LT', { ru: 'Литва', en: 'Lithuania' }, { ru: 'Вильнюс', en: 'Vilnius' }, 25.28, 54.69, true, 'base'),
location('LV', { ru: 'Латвия', en: 'Latvia' }, { ru: 'Рига', en: 'Riga' }, 24.11, 56.95, true, 'base'),
location('EE', { ru: 'Эстония', en: 'Estonia' }, { ru: 'Таллин', en: 'Tallinn' }, 24.75, 59.44, false, 'base'),
location('TR', { ru: 'Турция', en: 'Türkiye' }, { ru: 'Стамбул', en: 'Istanbul' }, 28.98, 41.01, true, 'base'),
location('KZ', { ru: 'Казахстан', en: 'Kazakhstan' }, { ru: 'Алматы', en: 'Almaty' }, 76.89, 43.24, false, 'base'),
location('AM', { ru: 'Армения', en: 'Armenia' }, { ru: 'Ереван', en: 'Yerevan' }, 44.51, 40.18, false, 'base'),
location('GE', { ru: 'Грузия', en: 'Georgia' }, { ru: 'Тбилиси', en: 'Tbilisi' }, 44.8, 41.72, false, 'base'),
location('AE', { ru: 'ОАЭ', en: 'UAE' }, { ru: 'Дубай', en: 'Dubai' }, 55.27, 25.2, true, 'rare'),
location('SG', { ru: 'Сингапур', en: 'Singapore' }, { ru: 'Сингапур', en: 'Singapore' }, 103.82, 1.35, true, 'rare'),
location('JP', { ru: 'Япония', en: 'Japan' }, { ru: 'Токио', en: 'Tokyo' }, 139.69, 35.69, true, 'rare'),
location('KR', { ru: 'Южная Корея', en: 'South Korea' }, { ru: 'Сеул', en: 'Seoul' }, 126.98, 37.57, false, 'rare'),
location('CA', { ru: 'Канада', en: 'Canada' }, { ru: 'Торонто', en: 'Toronto' }, -79.38, 43.65, true, 'base'),
location('AU', { ru: 'Австралия', en: 'Australia' }, { ru: 'Сидней', en: 'Sydney' }, 151.21, -33.87, false, 'distant'),
location('BR', { ru: 'Бразилия', en: 'Brazil' }, { ru: 'Сан-Паулу', en: 'São Paulo' }, -46.63, -23.55, false, 'distant'),
]
/** RU — страна RU-адреса у ключей dedicated, локации на сайтах нет */
export const RUSSIA = {
iso2: 'RU',
country: { ru: 'Россия', en: 'Russia' },
lat: 55.76,
lng: 37.62,
}
export const CURRENCIES = [
{ code: 'RUB', symbol: '₽' },
{ code: 'USD', symbol: '$' },
]
type PriceSeed = { currency: string; firstPeriodAmount: number; renewalAmount: number }
export type TariffSeed = {
code: string
name: Names
period: { unit: 'month' | 'year'; count: number }
prices: PriceSeed[]
}
/** Dedicated: ценовые уровни со своими странами, первый месяц включает подготовку сервера */
export const DEDICATED_TARIFFS: (TariffSeed & { tier: Tier })[] = [
{
code: 'base',
tier: 'base',
name: { en: 'Base', ru: 'Базовый' },
period: { unit: 'month', count: 1 },
prices: [
{ currency: 'RUB', firstPeriodAmount: 490000, renewalAmount: 249900 },
{ currency: 'USD', firstPeriodAmount: 4900, renewalAmount: 2400 },
],
},
{
code: 'rare',
tier: 'rare',
name: { en: 'Rare locations', ru: 'Редкие локации' },
period: { unit: 'month', count: 1 },
prices: [
{ currency: 'RUB', firstPeriodAmount: 690000, renewalAmount: 349900 },
{ currency: 'USD', firstPeriodAmount: 6900, renewalAmount: 3400 },
],
},
{
code: 'distant',
tier: 'distant',
name: { en: 'Distant locations', ru: 'Дальние локации' },
period: { unit: 'month', count: 1 },
prices: [
{ currency: 'RUB', firstPeriodAmount: 990000, renewalAmount: 499900 },
{ currency: 'USD', firstPeriodAmount: 9900, renewalAmount: 4900 },
],
},
]
/**
* Mass: все страны тарифа, первый период стоит как продление.
* Цены в USD временные, пока не решён Q-02 (docs/00-index.md, раздел 4.2).
*/
export const MASS_TARIFFS: TariffSeed[] = [
{
code: 'month',
name: { en: 'Monthly', ru: 'Месяц' },
period: { unit: 'month', count: 1 },
prices: [
{ currency: 'RUB', firstPeriodAmount: 69900, renewalAmount: 69900 },
{ currency: 'USD', firstPeriodAmount: 699, renewalAmount: 699 },
],
},
{
code: 'year',
name: { en: 'Yearly', ru: 'Год' },
period: { unit: 'year', count: 1 },
prices: [
{ currency: 'RUB', firstPeriodAmount: 699000, renewalAmount: 699000 },
{ currency: 'USD', firstPeriodAmount: 6990, renewalAmount: 6990 },
],
},
]
export const SITES = [
{
code: 'dedicated',
orderPrefix: 'VX',
name: { en: 'Veilix', ru: 'Вейликс' },
baseDomain: 'veilix.app',
},
{
code: 'mass',
orderPrefix: 'VV',
name: { en: 'VeilixVPN', ru: 'ВейликсВПН' },
baseDomain: 'veilixvpn.com',
},
] as const
/** Тип фронтенда и поддомен: сайт на основном домене, кабинет на my., MiniApp на tg. */
export const APPS = [
{ app: 'marketing', subdomain: '' },
{ app: 'cabinet', subdomain: 'my.' },
{ app: 'miniapp', subdomain: 'tg.' },
] as const
+246
View File
@@ -0,0 +1,246 @@
/**
* Начальные данные: `pnpm seed` (dev) или `SEED_ENV=prod pnpm seed`.
* Документы ищутся по естественному ключу: повторный запуск обновляет их, а не дублирует,
* и возвращает значения seed, если их меняли в админке.
*/
import config from '@payload-config'
import { type CollectionSlug, getPayload, type Where } from 'payload'
import { DEFAULT_LOCALE, type Locale, LOCALES } from '@/i18n/locales'
import {
APPS,
CURRENCIES,
DEDICATED_TARIFFS,
LOCATIONS,
MASS_TARIFFS,
RUSSIA,
SITES,
type TariffSeed,
} from './data'
const env = process.env.SEED_ENV === 'prod' ? 'prod' : 'dev'
const hostPrefix = env === 'dev' ? 'dev.' : ''
const payload = await getPayload({ config })
type Fields = Record<string, unknown>
type Localized = Partial<Record<Locale, Fields>>
/** Создаёт или обновляет документ; локализованные поля пишутся по одному языку за запрос */
const upsert = async (
collection: CollectionSlug,
where: Where,
data: Fields,
localized: Localized = {},
): Promise<string> => {
const { [DEFAULT_LOCALE]: defaultFields, ...otherLocales } = localized
const base = { ...data, ...defaultFields } as never
const { docs } = await payload.find({
collection,
where,
depth: 0,
limit: 1,
overrideAccess: true,
})
const existing = docs[0]
const { id } = existing
? await payload.update({
collection,
id: existing.id,
data: base,
locale: DEFAULT_LOCALE,
depth: 0,
overrideAccess: true,
})
: await payload.create({
collection,
data: base,
locale: DEFAULT_LOCALE,
depth: 0,
overrideAccess: true,
})
for (const [locale, fields] of Object.entries(otherLocales)) {
await payload.update({
collection,
id,
data: fields as never,
locale: locale as Locale,
depth: 0,
overrideAccess: true,
})
}
return String(id)
}
const capitalize = (value: string) => value.charAt(0).toLocaleUpperCase() + value.slice(1)
/** Названия из данных ICU для языков, которых нет в макете */
const intlName = (type: 'region' | 'currency', code: string, locale: Locale) =>
capitalize(new Intl.DisplayNames([locale], { type }).of(code) ?? code)
/** en и ru — из макета, остальные языки — из ICU */
const localizedNames = (
field: string,
type: 'region' | 'currency',
code: string,
names?: { en: string; ru: string },
): Localized =>
Object.fromEntries(
LOCALES.map((locale) => [
locale,
{
[field]:
names && (locale === 'en' || locale === 'ru')
? names[locale]
: intlName(type, code, locale),
},
]),
)
const sortOf = (index: number) => (index + 1) * 10
// валюты
for (const [index, currency] of CURRENCIES.entries()) {
await upsert(
'currencies',
{ code: { equals: currency.code } },
{ ...currency, minorUnits: 2, active: true, sort: sortOf(index) },
localizedNames('name', 'currency', currency.code),
)
}
// страны: 30 стран локаций и RU
const countryIds: Record<string, string> = {}
for (const [index, item] of [...LOCATIONS, { ...RUSSIA, tier: null }].entries()) {
countryIds[item.iso2] = await upsert(
'countries',
{ iso2: { equals: item.iso2 } },
{
iso2: item.iso2,
lat: item.lat,
lng: item.lng,
sort: item.iso2 === 'RU' ? 1000 : sortOf(index),
},
localizedNames('name', 'region', item.iso2, item.country),
)
}
for (const site of SITES) {
const siteId = await upsert(
'sites',
{ code: { equals: site.code } },
{ code: site.code, orderPrefix: site.orderPrefix, locales: [...LOCALES] },
{ en: { name: site.name.en }, ru: { name: site.name.ru } },
)
for (const { app, subdomain } of APPS) {
const hostname = `${hostPrefix}${subdomain}${site.baseDomain}`
await upsert(
'domains',
{ hostname: { equals: hostname } },
{
hostname,
site: siteId,
app,
locales: [...LOCALES],
defaultLocale: 'en',
currencies: ['RUB', 'USD'],
defaultCurrency: 'USD',
localeCurrency: [{ locale: 'ru', currency: 'RUB' }],
isPrimary: true,
indexable: env === 'prod' && app === 'marketing',
// MiniApp появится на этапе 3
active: app !== 'miniapp',
},
)
}
for (const [index, item] of LOCATIONS.entries()) {
const status = item.ready ? 'available' : site.code === 'dedicated' ? 'on_demand' : 'soon'
await upsert(
'locations',
{
and: [{ site: { equals: siteId } }, { country: { equals: countryIds[item.iso2] } }],
},
{
site: siteId,
country: countryIds[item.iso2],
lat: item.lat,
lng: item.lng,
status,
visible: true,
sort: sortOf(index),
},
{ en: { city: item.city.en }, ru: { city: item.city.ru } },
)
}
const allCountries = LOCATIONS.map((item) => countryIds[item.iso2])
const tariffs: (TariffSeed & { countries: string[] })[] =
site.code === 'dedicated'
? DEDICATED_TARIFFS.map((tariff) => ({
...tariff,
countries: LOCATIONS.filter((item) => item.tier === tariff.tier).map(
(item) => countryIds[item.iso2],
),
}))
: MASS_TARIFFS.map((tariff) => ({ ...tariff, countries: allCountries }))
for (const [index, tariff] of tariffs.entries()) {
await upsert(
'tariffs',
{ and: [{ site: { equals: siteId } }, { code: { equals: tariff.code } }] },
{
site: siteId,
code: tariff.code,
type: site.code,
period: tariff.period,
devicesLimit: 5,
prices: tariff.prices,
countries: tariff.countries,
active: true,
sort: sortOf(index),
},
{ en: { name: tariff.name.en }, ru: { name: tariff.name.ru } },
)
}
}
await upsert(
'payment-systems',
{ adapter: { equals: 'manual' } },
{
name: 'Ручной платёж',
adapter: 'manual',
currencies: ['RUB', 'USD'],
purposes: ['order', 'topup'],
visibleToCustomers: false,
mode: env === 'prod' ? 'live' : 'test',
active: true,
sort: 10,
},
{
en: { title: 'Manual payment', description: 'Recorded by an administrator' },
ru: { title: 'Ручной платёж', description: 'Проводит администратор' },
},
)
const counts = await Promise.all(
(
[
'sites',
'domains',
'currencies',
'countries',
'locations',
'tariffs',
'payment-systems',
] as const
).map(
async (collection) =>
`${collection}: ${(await payload.count({ collection, overrideAccess: true })).totalDocs}`,
),
)
payload.logger.info(`Seed (${env}) готов. ${counts.join(', ')}`)
process.exit(0)
+100
View File
@@ -0,0 +1,100 @@
@import 'tailwindcss';
@import './tokens.css';
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));
/* только цвета из токенов брендов: палитра Tailwind по умолчанию отключена */
@theme {
--color-*: initial;
/* 900px: шапка сайта и кабинет переходят в мобильный вид, как в макетах */
--breakpoint-nav: 56.25rem;
}
@theme inline {
--color-bg: var(--bg);
--color-surface: var(--surface);
--color-surface-2: var(--surface-2);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-ink-3: var(--ink-3);
--color-border: var(--border);
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-accent-soft: var(--accent-soft);
--color-sel: var(--sel);
--color-on-accent: var(--on-accent);
--color-glow-1: var(--glow-1);
--color-glow-2: var(--glow-2);
--color-glow-3: var(--glow-3);
--color-veil: var(--veil);
--color-warn: var(--warn);
--color-tg: var(--tg);
--color-land: var(--land);
--color-land-line: var(--land-line);
--color-ocean: var(--ocean);
--shadow-card: var(--shadow);
--font-sans: var(--font-commissioner), system-ui, sans-serif;
--font-logo: var(--font-unbounded), var(--font-commissioner), sans-serif;
}
@layer base {
html {
color-scheme: light;
}
html[data-theme='dark'] {
color-scheme: dark;
}
body {
min-height: 100vh;
background: var(--bg);
color: var(--ink);
font-family: var(--font-sans);
font-weight: 400;
letter-spacing: -0.01em;
-webkit-font-smoothing: antialiased;
transition:
background-color 240ms ease,
color 240ms ease;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
color: var(--accent-hover);
}
button {
cursor: pointer;
}
input,
textarea,
select {
font-size: 16px;
}
[hidden] {
display: none !important;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
}
+106
View File
@@ -0,0 +1,106 @@
/*
* Токены брендов из <style> прототипов design/handoff/project.
* Бренд — атрибут data-site на <html>, тема — data-theme (ставит скрипт в <head> до отрисовки).
* --sel — подсветка выбранного пункта: у Вейликса это --accent-soft, у ВейликсВПН свой цвет.
* Цвета карты (--land, --land-line, --ocean) у Вейликса взяты из Locations: в Main глобус светлее,
* какой вариант оставить — решаем на этапе 1b.
*/
[data-site='dedicated'] {
--bg: oklch(99% 0.005 296);
--surface: oklch(100% 0 0);
--surface-2: oklch(97.5% 0.01 296);
--ink: oklch(24% 0.04 296);
--ink-2: oklch(45% 0.03 296);
--ink-3: oklch(60% 0.025 296);
--border: oklch(92% 0.014 296);
--accent: oklch(45% 0.17 296);
--accent-hover: oklch(38% 0.17 296);
--accent-soft: oklch(95.5% 0.028 296);
--sel: var(--accent-soft);
--on-accent: oklch(99% 0.01 296);
--glow-1: oklch(86% 0.1 296);
--glow-2: oklch(89% 0.09 266);
--glow-3: oklch(91% 0.08 332);
--veil: oklch(100% 0 0 / 0.55);
--warn: oklch(62% 0.14 62);
--tg: oklch(62% 0.15 250);
--land: oklch(93% 0.015 296);
--land-line: oklch(87% 0.02 296);
--ocean: oklch(97.5% 0.007 296);
--shadow: 0 1px 2px oklch(24% 0.04 296 / 0.05), 0 12px 32px -12px oklch(24% 0.04 296 / 0.14);
}
[data-site='dedicated'][data-theme='dark'] {
--bg: oklch(17% 0.022 296);
--surface: oklch(21% 0.026 296);
--surface-2: oklch(24% 0.03 296);
--ink: oklch(96% 0.01 296);
--ink-2: oklch(79% 0.02 296);
--ink-3: oklch(63% 0.025 296);
--border: oklch(31% 0.032 296);
--accent: oklch(74% 0.15 296);
--accent-hover: oklch(80% 0.13 296);
--accent-soft: oklch(27% 0.05 296);
--on-accent: oklch(17% 0.03 296);
--glow-1: oklch(45% 0.16 296);
--glow-2: oklch(40% 0.14 266);
--glow-3: oklch(42% 0.13 332);
--veil: oklch(100% 0 0 / 0.07);
--warn: oklch(78% 0.13 62);
--tg: oklch(70% 0.13 250);
--land: oklch(27% 0.03 296);
--land-line: oklch(34% 0.035 296);
--ocean: oklch(24% 0.025 296);
--shadow: 0 1px 2px oklch(10% 0.02 296 / 0.4), 0 16px 40px -16px oklch(8% 0.02 296 / 0.7);
}
[data-site='mass'] {
--bg: oklch(99.6% 0.0015 215);
--surface: oklch(100% 0 0);
--surface-2: oklch(98.4% 0.003 215);
--ink: oklch(24% 0.04 204);
--ink-2: oklch(45% 0.03 204);
--ink-3: oklch(60% 0.025 204);
--border: oklch(92% 0.014 200);
--accent: oklch(48% 0.105 178);
--accent-hover: oklch(41% 0.1 178);
--accent-soft: oklch(95.5% 0.032 180);
--sel: oklch(96.5% 0.016 190);
--on-accent: oklch(99% 0.01 180);
--glow-1: oklch(86% 0.085 190);
--glow-2: oklch(88% 0.075 205);
--glow-3: oklch(89% 0.07 172);
--veil: oklch(100% 0 0 / 0.55);
--warn: oklch(62% 0.14 62);
--tg: oklch(62% 0.15 250);
--land: oklch(95% 0.012 196);
--land-line: oklch(90% 0.018 196);
--ocean: oklch(97.5% 0.007 196);
--shadow: 0 1px 2px oklch(24% 0.04 204 / 0.05), 0 12px 32px -12px oklch(24% 0.04 204 / 0.14);
}
[data-site='mass'][data-theme='dark'] {
--bg: oklch(23% 0.026 210);
--surface: oklch(26.5% 0.028 210);
--surface-2: oklch(30% 0.03 210);
--ink: oklch(96% 0.012 196);
--ink-2: oklch(80% 0.02 196);
--ink-3: oklch(65% 0.025 196);
--border: oklch(36% 0.04 204);
--accent: oklch(76% 0.105 190);
--accent-hover: oklch(83% 0.09 190);
--accent-soft: oklch(31% 0.05 190);
--sel: oklch(30% 0.03 195);
--on-accent: oklch(20% 0.04 204);
--glow-1: oklch(52% 0.09 200);
--glow-2: oklch(47% 0.085 215);
--glow-3: oklch(50% 0.08 180);
--veil: oklch(100% 0 0 / 0.07);
--warn: oklch(78% 0.13 62);
--tg: oklch(70% 0.13 250);
--land: oklch(31% 0.035 204);
--land-line: oklch(37% 0.04 204);
--ocean: oklch(27% 0.035 204);
--shadow: 0 1px 2px oklch(12% 0.03 204 / 0.4), 0 16px 40px -16px oklch(9% 0.03 204 / 0.7);
}
+52
View File
@@ -0,0 +1,52 @@
/** Знак «Слои»: два перекрывающихся скруглённых квадрата, цвета — из токенов бренда */
const MARK = {
// шапка сайта
lg: { box: 28, square: 21, radius: 7, offset: 3, blur: true },
// карточка входа
card: { box: 26, square: 19, radius: 6, offset: 3, blur: false },
// подвал сайта и кабинет
md: { box: 24, square: 18, radius: 6, offset: 3, blur: false },
} as const
const TEXT_SIZE = { lg: 'text-[17px]', card: 'text-base', md: 'text-[15px]' } as const
export function LogoMark({ size }: { size: keyof typeof MARK }) {
const { box, square, radius, offset, blur } = MARK[size]
return (
<span aria-hidden className="relative block flex-none" style={{ width: box, height: box }}>
<span
className="absolute left-0 block"
style={{
top: offset,
width: square,
height: square,
borderRadius: radius,
background:
'linear-gradient(140deg, var(--accent), color-mix(in oklab, var(--accent) 55%, var(--glow-3)))',
}}
/>
<span
className="absolute right-0 block border bg-veil"
style={{
bottom: offset,
width: square,
height: square,
borderRadius: radius,
borderColor: 'color-mix(in oklab, var(--accent) 35%, transparent)',
backdropFilter: blur ? 'blur(2px)' : undefined,
}}
/>
</span>
)
}
/** Надпись логотипа: Unbounded 600 */
export function LogoText({ children, size }: { children: string; size: keyof typeof TEXT_SIZE }) {
return (
<span
className={`font-logo font-semibold tracking-[-0.02em] whitespace-nowrap ${TEXT_SIZE[size]}`}
>
{children}
</span>
)
}
+20
View File
@@ -0,0 +1,20 @@
import { getTranslations } from 'next-intl/server'
/** 404 в оформлении бренда. «/» ведёт на главную, proxy сам выберет язык */
export async function NotFound({ size }: { size: 'site' | 'cabinet' }) {
const t = await getTranslations('notFound')
return (
<div className={size === 'site' ? 'mx-auto max-w-[1200px] px-6 pt-16 pb-24' : 'p-6'}>
<h1 className="mb-1.5 text-[34px] font-semibold tracking-[-0.03em] text-ink">{t('title')}</h1>
<p className="mb-6 max-w-[40em] text-[15px] leading-[1.55] font-light text-ink-2">
{t('text')}
</p>
<a
href="/"
className="inline-flex h-[46px] items-center rounded-xl bg-accent px-[22px] text-[15px] font-medium text-on-accent hover:bg-accent-hover hover:text-on-accent"
>
{t('home')}
</a>
</div>
)
}
+23
View File
@@ -0,0 +1,23 @@
const TITLE = {
site: 'mb-1.5 text-[34px]',
cabinet: 'mb-1.5 text-[30px]',
card: 'mb-2.5 text-[26px]',
} as const
/** Заглушка раздела: настоящие страницы появятся на этапах 1b–1g */
export function Stub({
title,
text,
size,
}: {
title: string
text: string
size: keyof typeof TITLE
}) {
return (
<div className={size === 'site' ? 'mx-auto max-w-[1200px] px-6 pt-16 pb-24' : ''}>
<h1 className={`font-semibold tracking-[-0.03em] text-ink ${TITLE[size]}`}>{title}</h1>
<p className="max-w-[40em] text-[15px] leading-[1.55] font-light text-ink-2">{text}</p>
</div>
)
}
+39
View File
@@ -0,0 +1,39 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import type { ReactNode } from 'react'
import { pagePath } from '@/core/routing/paths'
type Props = {
href: string
/** путь раздела без языка: «/», «/keys» */
section: string
className: string
activeClassName: string
inactiveClassName: string
children: ReactNode
}
/** Пункт навигации кабинета; раздел подсвечен и на вложенных страницах (/keys/…) */
export function NavLink({
href,
section,
className,
activeClassName,
inactiveClassName,
children,
}: Props) {
const path = pagePath(usePathname())
const active = section === '/' ? path === '/' : path === section || path.startsWith(`${section}/`)
return (
<Link
href={href}
aria-current={active ? 'page' : undefined}
className={`${className} ${active ? activeClassName : inactiveClassName}`}
>
{children}
</Link>
)
}
+61
View File
@@ -0,0 +1,61 @@
import { getTranslations } from 'next-intl/server'
import type { ReactNode } from 'react'
import { LogoMark, LogoText } from '@/ui/Logo'
import { LanguageSegments } from '@/ui/switchers/LanguageSwitch'
import { ThemeSwitch } from '@/ui/theme/ThemeSwitch'
import type { CabinetShell } from './types'
const linkClass = 'text-ink-3 hover:text-ink'
/** Страницы кабинета без навигации — вход и оформление заказа: карточка по центру и подвал */
export async function Plain({ shell, children }: { shell: CabinetShell; children: ReactNode }) {
const t = await getTranslations()
const name = t(`brand.${shell.site}.name`)
return (
<div className="grid min-h-screen grid-rows-[1fr_auto]">
<div className="flex items-center justify-center p-6">
<div className="w-full max-w-[420px] rounded-3xl border border-border bg-surface p-9 shadow-card">
<a
href={shell.siteHref ?? '/'}
className="mb-[26px] flex items-center gap-2.5 text-ink hover:text-ink"
>
<LogoMark size="card" />
<LogoText size="card">{name}</LogoText>
</a>
{children}
</div>
</div>
<footer className="flex flex-wrap items-center gap-x-5 gap-y-3 border-t border-border px-6 py-3.5 text-[13px] text-ink-3">
<span>{t('footer.copyright', { year: new Date().getFullYear(), brand: name })}</span>
{shell.termsHref && (
<a href={shell.termsHref} className={linkClass}>
{t('cabinet.terms')}
</a>
)}
{shell.privacyHref && (
<a href={shell.privacyHref} className={linkClass}>
{t('cabinet.privacy')}
</a>
)}
{shell.supportHref && (
<a href={shell.supportHref} className={linkClass}>
{t('cabinet.support')}
</a>
)}
<div className="ml-auto flex items-center gap-2">
<LanguageSegments
variant="plain"
locale={shell.locale}
locales={shell.locales}
defaultLocale={shell.defaultLocale}
/>
<ThemeSwitch variant="plain" />
</div>
</footer>
</div>
)
}
+134
View File
@@ -0,0 +1,134 @@
import { getTranslations } from 'next-intl/server'
import type { ReactNode } from 'react'
import { localizedPath } from '@/core/routing/paths'
import {
CardIcon,
HomeIcon,
KeysIcon,
MoreIcon,
PlusIcon,
ReferralIcon,
SupportIcon,
} from '@/ui/icons'
import { LogoMark, LogoText } from '@/ui/Logo'
import { LanguageSegments } from '@/ui/switchers/LanguageSwitch'
import { ThemeSwitch } from '@/ui/theme/ThemeSwitch'
import { NavLink } from './NavLink'
import type { CabinetShell } from './types'
/**
* Оболочка кабинета: App.dc.html и MassApp.dc.html. Подписи у брендов одинаковые —
* «Список ключей», без выбора страны у mass (07-frontend, раздел 7.4).
* От 900px — боковая панель, уже — верхняя полоса и плавающие вкладки.
*/
export async function Shell({ shell, children }: { shell: CabinetShell; children: ReactNode }) {
const t = await getTranslations()
const href = (path: string) => localizedPath(path, shell.locale, shell.defaultLocale)
const name = t(`brand.${shell.site}.name`)
const logo = (
<a
href={shell.siteHref ?? href('/')}
className="flex items-center gap-2.5 text-ink hover:text-ink"
>
<LogoMark size="md" />
<LogoText size="md">{name}</LogoText>
</a>
)
const sections = [
{ section: '/', label: t('cabinet.home'), icon: <HomeIcon /> },
{ section: '/keys', label: t('cabinet.keys'), icon: <KeysIcon /> },
{ section: '/buy', label: t('cabinet.buy'), icon: <PlusIcon /> },
{ section: '/billing', label: t('cabinet.billing'), icon: <CardIcon /> },
{ section: '/referral', label: t('cabinet.referral'), icon: <ReferralIcon /> },
]
const tabs = [
{ section: '/', label: t('cabinet.home'), icon: <HomeIcon size={20} /> },
{ section: '/keys', label: t('cabinet.keysShort'), icon: <KeysIcon size={20} /> },
{ section: '/buy', label: t('cabinet.buyShort'), icon: <PlusIcon size={20} /> },
{ section: '/more', label: t('cabinet.more'), icon: <MoreIcon size={20} /> },
]
return (
<>
<div className="grid min-h-screen grid-cols-[minmax(0,1fr)] nav:grid-cols-[248px_minmax(0,1fr)]">
<div className="sticky top-0 z-30 flex items-center gap-2.5 border-b border-border bg-bg/85 px-4 py-3 backdrop-blur-[18px] nav:hidden">
{logo}
</div>
<aside className="sticky top-0 hidden h-screen flex-col gap-6 border-r border-border bg-surface-2 px-4 py-[22px] nav:flex">
<div className="px-2 py-1">{logo}</div>
<nav aria-label={t('nav.main')} className="grid gap-1">
{sections.map((item) => (
<NavLink
key={item.section}
href={href(item.section)}
section={item.section}
className="flex items-center gap-3 rounded-xl px-3 py-[11px] text-[15px] hover:bg-surface"
activeClassName="bg-sel text-accent hover:text-accent"
inactiveClassName="text-ink-2 hover:text-ink-2"
>
{item.icon}
{item.label}
</NavLink>
))}
</nav>
<div className="mt-auto grid gap-3.5 px-1">
{shell.supportHref && (
<a
href={shell.supportHref}
className="flex items-center gap-3 rounded-xl px-2 py-[11px] text-[15px] text-ink-2 hover:bg-surface hover:text-ink"
>
<SupportIcon />
{t('cabinet.support')}
</a>
)}
<div>
<div className="mb-1.5 px-1 text-xs text-ink-3">{t('switch.theme')}</div>
<ThemeSwitch variant="sidebar" />
</div>
<div>
<div className="mb-1.5 px-1 text-xs text-ink-3">{t('switch.language')}</div>
<LanguageSegments
variant="sidebar"
locale={shell.locale}
locales={shell.locales}
defaultLocale={shell.defaultLocale}
/>
</div>
{/* «Выйти · почта» — с входом по коду, этап 1c */}
</div>
</aside>
<main className="max-w-[960px] px-4 pt-5 pb-[110px] nav:px-10 nav:pt-9 nav:pb-16">
{children}
</main>
</div>
<nav
aria-label={t('nav.main')}
className="fixed bottom-[18px] left-1/2 z-50 flex -translate-x-1/2 gap-1 rounded-full border border-ink/8 bg-surface/62 p-1.5 shadow-[0_10px_30px_-10px_color-mix(in_oklab,var(--ink)_40%,transparent)] backdrop-blur-[24px] backdrop-saturate-180 nav:hidden"
>
{tabs.map((tab) => (
<NavLink
key={tab.section}
href={href(tab.section)}
section={tab.section}
className="grid min-h-[52px] min-w-[76px] justify-items-center gap-1 rounded-full px-3 py-2 text-[11px]"
activeClassName="bg-sel text-accent hover:text-accent"
inactiveClassName="text-ink-2 hover:text-ink-2"
>
{tab.icon}
{tab.label}
</NavLink>
))}
</nav>
</>
)
}
+17
View File
@@ -0,0 +1,17 @@
import type { SiteCode } from '@/core/sites'
import type { Locale } from '@/i18n/locales'
/** Что нужно оболочке кабинета */
export type CabinetShell = {
site: SiteCode
locale: Locale
locales: Locale[]
defaultLocale: Locale
/** главная маркетингового сайта бренда (логотип ведёт туда) */
siteHref: string | null
/** поддержка на маркетинговом сайте */
supportHref: string | null
/** документы на маркетинговом сайте */
termsHref: string | null
privacyHref: string | null
}
+18
View File
@@ -0,0 +1,18 @@
import { Commissioner, Unbounded } from 'next/font/google'
/** Весь текст. Вариативный шрифт: один файл на набор символов вместо файла на каждое начертание */
export const commissioner = Commissioner({
subsets: ['latin', 'latin-ext', 'cyrillic'],
variable: '--font-commissioner',
display: 'swap',
})
/** Только надпись логотипа */
export const unbounded = Unbounded({
subsets: ['latin', 'latin-ext', 'cyrillic'],
weight: '600',
variable: '--font-unbounded',
display: 'swap',
})
export const fontVariables = `${commissioner.variable} ${unbounded.variable}`
+119
View File
@@ -0,0 +1,119 @@
import type { ReactNode } from 'react'
/**
* Иконки из макетов. В прототипе они нарисованы CSS-фигурами; здесь те же фигуры в SVG,
* чтобы одинаково масштабировались до 18 и 20 px.
*/
type IconProps = { size?: number }
const svg = (size: number, children: ReactNode, viewBox = 18) => (
<svg
aria-hidden
width={size}
height={size}
viewBox={`0 0 ${viewBox} ${viewBox}`}
fill="currentColor"
className="block flex-none"
>
{children}
</svg>
)
export const HomeIcon = ({ size = 18 }: IconProps) =>
svg(
size,
<>
<rect x="0" y="0" width="8" height="8" rx="2" />
<rect x="10" y="0" width="8" height="8" rx="2" opacity="0.45" />
<rect x="0" y="10" width="8" height="8" rx="2" opacity="0.45" />
<rect x="10" y="10" width="8" height="8" rx="2" />
</>,
)
export const KeysIcon = ({ size = 18 }: IconProps) =>
svg(
size,
<>
<rect x="0" y="2" width="18" height="4" rx="2" />
<rect x="0" y="8" width="18" height="4" rx="2" opacity="0.6" />
<rect x="0" y="14" width="18" height="4" rx="2" opacity="0.3" />
</>,
)
export const PlusIcon = ({ size = 18 }: IconProps) =>
svg(
size,
<>
<rect x="7" y="0" width="4" height="18" rx="2" />
<rect x="0" y="7" width="18" height="4" rx="2" />
</>,
)
export const CardIcon = ({ size = 18 }: IconProps) =>
svg(
size,
<>
<rect
x="1"
y="3"
width="16"
height="12"
rx="3"
fill="none"
stroke="currentColor"
strokeWidth="2"
/>
<rect x="0" y="6" width="18" height="3" />
</>,
)
export const ReferralIcon = ({ size = 18 }: IconProps) =>
svg(
size,
<>
<circle cx="4" cy="9" r="3" fill="none" stroke="currentColor" strokeWidth="2" />
<circle cx="14" cy="4" r="4" />
<circle cx="12" cy="14" r="4" opacity="0.45" />
</>,
)
export const SupportIcon = ({ size = 18 }: IconProps) =>
svg(
size,
<>
<path d="M2 17V9a7 7 0 0 1 14 0v8" fill="none" stroke="currentColor" strokeWidth="2" />
<rect x="0" y="8" width="5" height="9" rx="2" />
<rect x="13" y="8" width="5" height="9" rx="2" />
</>,
)
export const MoreIcon = ({ size = 20 }: IconProps) =>
svg(
size,
<>
<circle cx="2" cy="10" r="2" />
<circle cx="10" cy="10" r="2" />
<circle cx="18" cy="10" r="2" />
</>,
20,
)
/** Глобус кнопки языка */
export const GlobeIcon = () => (
<span
aria-hidden
className="relative block size-4 flex-none rounded-full border-[1.5px] border-current"
>
<span className="absolute top-[5.75px] left-[-1.5px] block h-[1.5px] w-4 bg-current" />
<span className="absolute top-[-1.5px] left-[3.25px] block h-4 w-2 rounded-full border-[1.5px] border-current" />
</span>
)
export const BurgerIcon = () => (
<span aria-hidden className="flex flex-col items-center gap-[5px]">
<span className="block h-0.5 w-[18px] rounded-sm bg-current" />
<span className="block h-0.5 w-[18px] rounded-sm bg-current" />
<span className="block h-0.5 w-[18px] rounded-sm bg-current" />
</span>
)
+85
View File
@@ -0,0 +1,85 @@
import Link from 'next/link'
import { getTranslations } from 'next-intl/server'
import { localizedPath } from '@/core/routing/paths'
import { LogoMark, LogoText } from '@/ui/Logo'
import { CurrencySegments } from '@/ui/switchers/CurrencySwitch'
import { LanguageSegments } from '@/ui/switchers/LanguageSwitch'
import { ThemeSwitch } from '@/ui/theme/ThemeSwitch'
import type { MarketingShell } from './types'
const linkClass = 'text-ink-2 hover:text-ink'
/** Подвал маркетингового сайта: Main.dc.html и MassMain.dc.html */
export async function Footer({ shell }: { shell: MarketingShell }) {
const { site, locale, locales, defaultLocale, currency, currencies } = shell
const t = await getTranslations()
const href = (path: string) => localizedPath(path, locale, defaultLocale)
const name = t(`brand.${site}.name`)
return (
<footer className="mt-10 border-t border-border bg-surface-2">
<div className="mx-auto grid max-w-[1200px] grid-cols-[repeat(auto-fit,minmax(300px,1fr))] gap-8 px-[clamp(16px,4vw,24px)] pt-12 pb-8">
<div>
<div className="mb-3.5 flex items-center gap-2.5 text-ink">
<LogoMark size="md" />
<LogoText size="md">{name}</LogoText>
</div>
<p className="mb-5 max-w-[26em] text-sm leading-[1.55] font-light text-ink-3">
{t(`brand.${site}.tagline`)}
</p>
{/* плашки способов оплаты — по видимым платёжным системам сайта (07-frontend, раздел 7.9) */}
</div>
<div className="grid grid-cols-[repeat(2,minmax(130px,1fr))] content-start gap-x-8 gap-y-6">
<nav aria-label={t('footer.service')} className="grid content-start gap-2.5 text-sm">
<div className="mb-0.5 text-[13px] text-ink-3">{t('footer.service')}</div>
<Link href={href('/pricing')} className={linkClass}>
{t('nav.pricing')}
</Link>
<Link href={href('/locations')} className={linkClass}>
{t('nav.locations')}
</Link>
<Link href={href('/support')} className={linkClass}>
{t('nav.support')}
</Link>
</nav>
<nav aria-label={t('footer.legal')} className="grid content-start gap-2.5 text-sm">
<div className="mb-0.5 text-[13px] text-ink-3">{t('footer.legal')}</div>
<Link href={href('/legal/terms')} className={linkClass}>
{t('footer.terms')}
</Link>
<Link href={href('/legal/privacy')} className={linkClass}>
{t('footer.privacy')}
</Link>
<Link href={href('/legal/refund')} className={linkClass}>
{t('footer.refund')}
</Link>
<Link href={href('/legal/cookies')} className={linkClass}>
{t('footer.cookies')}
</Link>
</nav>
</div>
</div>
<div className="mx-auto flex max-w-[1200px] flex-wrap items-center justify-between gap-3.5 px-[clamp(16px,4vw,24px)] pb-10">
<div className="text-[13px] text-ink-3">
{t('footer.copyright', { year: new Date().getFullYear(), brand: name })}
</div>
<div className="flex flex-wrap items-center gap-2">
<ThemeSwitch variant="footer" />
{currencies.length > 1 && (
<CurrencySegments variant="footer" currency={currency} currencies={currencies} />
)}
<LanguageSegments
variant="footer"
locale={locale}
locales={locales}
defaultLocale={defaultLocale}
/>
</div>
</div>
</footer>
)
}
+76
View File
@@ -0,0 +1,76 @@
import Link from 'next/link'
import { getTranslations } from 'next-intl/server'
import { localizedPath } from '@/core/routing/paths'
import { LogoMark, LogoText } from '@/ui/Logo'
import { CurrencyMenu } from '@/ui/switchers/CurrencySwitch'
import { LanguageMenu } from '@/ui/switchers/LanguageSwitch'
import { MobileMenu } from './MobileMenu'
import type { MarketingShell } from './types'
/** Шапка маркетингового сайта: Main.dc.html и MassMain.dc.html */
export async function Header({ shell }: { shell: MarketingShell }) {
const { site, locale, locales, defaultLocale, currency, currencies, loginHref } = shell
const t = await getTranslations('nav')
const brand = await getTranslations('brand')
const href = (path: string) => localizedPath(path, locale, defaultLocale)
const links = [
{ href: href('/pricing'), label: t('pricing') },
{ href: href('/locations'), label: t('locations') },
{ href: href('/support'), label: t('support') },
]
// кнопка «Получить VPN» ведёт к ценам; на главной — к блоку цен (появится на этапе 1b)
const getVpn = href('/pricing')
return (
<header className="sticky top-0 z-40 border-b border-border bg-bg/82 backdrop-blur-[20px] backdrop-saturate-140">
<div className="mx-auto flex min-h-[66px] max-w-[1200px] flex-nowrap items-center gap-x-10 gap-y-3.5 px-6 py-3.5">
<Link
href={href('/')}
className="flex flex-none items-center gap-2.5 text-ink hover:text-ink"
>
<LogoMark size="lg" />
<LogoText size="lg">{brand(`${site}.name`)}</LogoText>
</Link>
<nav
aria-label={t('main')}
className="hidden flex-wrap gap-x-7 gap-y-2.5 text-[15px] font-medium nav:flex"
>
{links.map((link) => (
<Link key={link.href} href={link.href} className="text-ink-2 hover:text-ink">
{link.label}
</Link>
))}
</nav>
<div className="ml-auto flex flex-wrap items-center gap-2">
<div className="hidden nav:block">
<LanguageMenu locale={locale} locales={locales} defaultLocale={defaultLocale} />
</div>
{currencies.length > 1 && (
<div className="hidden nav:block">
<CurrencyMenu currency={currency} currencies={currencies} />
</div>
)}
{loginHref && (
<a
href={loginHref}
className="hidden h-[38px] w-[78px] flex-none items-center justify-center rounded-[10px] text-[15px] text-ink-2 hover:bg-surface-2 hover:text-ink nav:flex"
>
{t('logIn')}
</a>
)}
<Link
href={getVpn}
className="hidden h-[38px] min-w-[142px] flex-none items-center justify-center rounded-[10px] bg-accent px-3 text-[15px] font-medium text-on-accent shadow-[0_6px_18px_-8px_var(--accent)] hover:bg-accent-hover hover:text-on-accent nav:flex"
>
{t('getVpn')}
</Link>
<MobileMenu shell={shell} links={links} getVpn={getVpn} />
</div>
</div>
</header>
)
}
+102
View File
@@ -0,0 +1,102 @@
'use client'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { useCallback, useEffect, useId, useState } from 'react'
import { BurgerIcon } from '@/ui/icons'
import { CurrencySegments } from '@/ui/switchers/CurrencySwitch'
import { LanguageSegments } from '@/ui/switchers/LanguageSwitch'
import type { MarketingShell } from './types'
type Props = {
shell: MarketingShell
links: { href: string; label: string }[]
getVpn: string
}
/** Меню узкого экрана (до 900px): ссылки, вход, язык, валюта, «Получить VPN» */
export function MobileMenu({ shell, links, getVpn }: Props) {
const t = useTranslations()
const [open, setOpen] = useState(false)
const id = useId()
const close = useCallback(() => setOpen(false), [])
useEffect(() => {
if (!open) return
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') close()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [open, close])
return (
<>
<button
type="button"
title={t('nav.menu')}
aria-label={t('nav.menu')}
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen((value) => !value)}
className="flex h-[38px] w-[42px] items-center justify-center rounded-[10px] border border-border bg-transparent text-ink hover:border-ink-3 nav:hidden"
>
<BurgerIcon />
</button>
<div
id={id}
hidden={!open}
className="absolute top-full right-0 left-0 z-[45] grid gap-0.5 border-b border-border bg-bg px-5 pt-2.5 pb-[18px] shadow-card nav:hidden"
>
{links.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={close}
className="px-1.5 py-[13px] text-[17px] text-ink hover:text-ink"
>
{link.label}
</Link>
))}
<div className="mt-1.5 flex items-center justify-between gap-3 border-t border-border pt-1.5">
{shell.loginHref ? (
<a
href={shell.loginHref}
className="px-1.5 py-[13px] text-[17px] text-ink hover:text-ink"
>
{t('nav.logIn')}
</a>
) : (
<span />
)}
<LanguageSegments
variant="menu"
locale={shell.locale}
locales={shell.locales}
defaultLocale={shell.defaultLocale}
/>
</div>
{shell.currencies.length > 1 && (
<div className="flex items-center justify-between gap-3 py-1.5">
<span className="px-1.5 text-[15px] text-ink-3">{t('switch.currency')}</span>
<CurrencySegments
variant="menu"
currency={shell.currency}
currencies={shell.currencies}
/>
</div>
)}
<Link
href={getVpn}
onClick={close}
className="mt-3 flex h-[50px] items-center justify-center rounded-xl bg-accent text-base font-medium text-on-accent hover:bg-accent-hover hover:text-on-accent"
>
{t('nav.getVpn')}
</Link>
</div>
</>
)
}
+15
View File
@@ -0,0 +1,15 @@
import type { CurrencyOption } from '@/core/currencies/types'
import type { SiteCode } from '@/core/sites'
import type { Locale } from '@/i18n/locales'
/** Что нужно шапке и подвалу маркетингового сайта */
export type MarketingShell = {
site: SiteCode
locale: Locale
locales: Locale[]
defaultLocale: Locale
currency: string
currencies: CurrencyOption[]
/** вход в кабинет на основном домене кабинета; null — кабинета нет */
loginHref: string | null
}
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff. Show more