Этап 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:
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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
Whitespace-only changes.
@@ -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
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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' } })
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Brand } from '../types'
|
||||
|
||||
/** ВейликсВПН: классический VPN. Тексты — brand.mass в сообщениях next-intl */
|
||||
export const mass: Brand = {
|
||||
favicon: '/static/brands/mass/favicon.svg',
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type Brand = {
|
||||
favicon: string
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Валюта для переключателя: код ISO 4217, символ и название на языке страницы */
|
||||
export type CurrencyOption = {
|
||||
code: string
|
||||
symbol: string
|
||||
name: string
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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}`,
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -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": "На главную"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
})
|
||||
Vendored
+11
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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'),
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
}
|
||||
@@ -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' },
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
}
|
||||
@@ -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', 'Описание изображения'),
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
}
|
||||
@@ -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.',
|
||||
'Каждый домен сайта показывает часть этих языков.',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
@@ -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],
|
||||
}))
|
||||
@@ -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
|
||||
}
|
||||
@@ -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', 'Контент'),
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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).*)'],
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}`
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user