Compare commits
10 Commits
72779920fd
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed5677bcfb | ||
|
|
4b368d66e9 | ||
|
|
dd1f56b1db | ||
|
|
a053e76fbf | ||
|
|
a55a163c2c | ||
|
|
4ab369957b | ||
|
|
588a547684 | ||
|
|
16e83e48ff | ||
|
|
593c11f306 | ||
|
|
33262c12b8 |
BIN
public/logo-white.png
Normal file
BIN
public/logo-white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
public/logo.jpg
Normal file
BIN
public/logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -38,10 +38,9 @@ function buildInput(source) {
|
|||||||
displayValue: contact.displayValue,
|
displayValue: contact.displayValue,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
// privacyPolicy / termsOfUse are relations (single types) referenced by id.
|
// privacyPolicy / termsOfUse are markdown richtext strings.
|
||||||
// They are seeded separately; only forward them when an id is provided.
|
if (source.privacyPolicy != null) input.privacyPolicy = source.privacyPolicy;
|
||||||
if (source.privacyPolicy?.id) input.privacyPolicy = source.privacyPolicy.id;
|
if (source.termsOfUse != null) input.termsOfUse = source.termsOfUse;
|
||||||
if (source.termsOfUse?.id) input.termsOfUse = source.termsOfUse.id;
|
|
||||||
return input;
|
return input;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export function ContactForm({
|
|||||||
id={withPrefix(idPrefix, 'name')}
|
id={withPrefix(idPrefix, 'name')}
|
||||||
name="name"
|
name="name"
|
||||||
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all"
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all"
|
||||||
placeholder="Ваше имя"
|
placeholder="Ваше имя *"
|
||||||
defaultValue={prefillValues?.name}
|
defaultValue={prefillValues?.name}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -123,7 +123,7 @@ export function ContactForm({
|
|||||||
id={withPrefix(idPrefix, 'email')}
|
id={withPrefix(idPrefix, 'email')}
|
||||||
name="email"
|
name="email"
|
||||||
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all"
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all"
|
||||||
placeholder="Email"
|
placeholder="Email *"
|
||||||
defaultValue={prefillValues?.email}
|
defaultValue={prefillValues?.email}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -137,7 +137,7 @@ export function ContactForm({
|
|||||||
id={withPrefix(idPrefix, 'phone')}
|
id={withPrefix(idPrefix, 'phone')}
|
||||||
name="phone"
|
name="phone"
|
||||||
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all"
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all"
|
||||||
placeholder="Телефон"
|
placeholder="Телефон *"
|
||||||
defaultValue={prefillValues?.phone}
|
defaultValue={prefillValues?.phone}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -151,7 +151,7 @@ export function ContactForm({
|
|||||||
{fields.has('purpose') && (
|
{fields.has('purpose') && (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={withPrefix(idPrefix, 'purpose')} className="block text-sm text-gray-700 mb-2">
|
<label htmlFor={withPrefix(idPrefix, 'purpose')} className="block text-sm text-gray-700 mb-2">
|
||||||
Какую задачу решаете?
|
Какую задачу решаете? *
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id={withPrefix(idPrefix, 'purpose')}
|
id={withPrefix(idPrefix, 'purpose')}
|
||||||
@@ -180,7 +180,7 @@ export function ContactForm({
|
|||||||
{fields.has('timeline') && (
|
{fields.has('timeline') && (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={withPrefix(idPrefix, 'timeline')} className="block text-sm text-gray-700 mb-2">
|
<label htmlFor={withPrefix(idPrefix, 'timeline')} className="block text-sm text-gray-700 mb-2">
|
||||||
Сроки реализации
|
Сроки реализации *
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id={withPrefix(idPrefix, 'timeline')}
|
id={withPrefix(idPrefix, 'timeline')}
|
||||||
|
|||||||
@@ -155,8 +155,8 @@ const siteConfig = defineCollection({
|
|||||||
title: z.string(),
|
title: z.string(),
|
||||||
description: z.string().nullable(),
|
description: z.string().nullable(),
|
||||||
contacts: z.array(contactSchema),
|
contacts: z.array(contactSchema),
|
||||||
privacyPolicy: z.object({ url: z.string() }).nullable(),
|
privacyPolicy: z.string().nullable(),
|
||||||
termsOfUse: z.object({ url: z.string() }).nullable(),
|
termsOfUse: z.string().nullable(),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
3
src/env.d.ts
vendored
3
src/env.d.ts
vendored
@@ -4,6 +4,9 @@ interface ImportMetaEnv {
|
|||||||
readonly PUBLIC_API_URL: string
|
readonly PUBLIC_API_URL: string
|
||||||
readonly PUBLIC_GRAPHQL_URL: string
|
readonly PUBLIC_GRAPHQL_URL: string
|
||||||
readonly PUBLIC_UPLOADS_BASE_URL: string
|
readonly PUBLIC_UPLOADS_BASE_URL: string
|
||||||
|
/** Public origin of the site, e.g. https://softclick.ru — used for canonical/og:url. */
|
||||||
|
readonly PUBLIC_SITE_URL?: string
|
||||||
|
readonly BITRIX24_WEBHOOK_URL?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
13
src/graphql-documents/privacy-policy.gql.generated.ts
Normal file
13
src/graphql-documents/privacy-policy.gql.generated.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Generated by scripts/endpoint-generate.mjs — do not edit by hand */
|
||||||
|
import type { ExecutionResult } from "graphql";
|
||||||
|
|
||||||
|
import { execute } from "../graphql/execute";
|
||||||
|
import type { PrivacyPolicyQuery, PrivacyPolicyQueryVariables } from "../graphql/graphql";
|
||||||
|
import { privacyPolicyDocument } from "./privacy-policy.gql";
|
||||||
|
|
||||||
|
export const privacyPolicyQuery = {
|
||||||
|
async execute(variables?: PrivacyPolicyQueryVariables): Promise<ExecutionResult<PrivacyPolicyQuery>> {
|
||||||
|
return execute<PrivacyPolicyQuery, PrivacyPolicyQueryVariables>(privacyPolicyDocument, variables);
|
||||||
|
},
|
||||||
|
};
|
||||||
11
src/graphql-documents/privacy-policy.gql.ts
Normal file
11
src/graphql-documents/privacy-policy.gql.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import gql from 'graphql-tag'
|
||||||
|
|
||||||
|
export const privacyPolicyDocument = gql`
|
||||||
|
query PrivacyPolicy {
|
||||||
|
siteConfig {
|
||||||
|
title
|
||||||
|
description
|
||||||
|
privacyPolicy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
@@ -11,12 +11,6 @@ export const siteConfigDocument = gql`
|
|||||||
value
|
value
|
||||||
displayValue
|
displayValue
|
||||||
}
|
}
|
||||||
privacyPolicy {
|
|
||||||
url
|
|
||||||
}
|
|
||||||
termsOfUse {
|
|
||||||
url
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|||||||
13
src/graphql-documents/terms-of-use.gql.generated.ts
Normal file
13
src/graphql-documents/terms-of-use.gql.generated.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* Generated by scripts/endpoint-generate.mjs — do not edit by hand */
|
||||||
|
import type { ExecutionResult } from "graphql";
|
||||||
|
|
||||||
|
import { execute } from "../graphql/execute";
|
||||||
|
import type { TermsOfUseQuery, TermsOfUseQueryVariables } from "../graphql/graphql";
|
||||||
|
import { termsOfUseDocument } from "./terms-of-use.gql";
|
||||||
|
|
||||||
|
export const termsOfUseQuery = {
|
||||||
|
async execute(variables?: TermsOfUseQueryVariables): Promise<ExecutionResult<TermsOfUseQuery>> {
|
||||||
|
return execute<TermsOfUseQuery, TermsOfUseQueryVariables>(termsOfUseDocument, variables);
|
||||||
|
},
|
||||||
|
};
|
||||||
11
src/graphql-documents/terms-of-use.gql.ts
Normal file
11
src/graphql-documents/terms-of-use.gql.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import gql from 'graphql-tag'
|
||||||
|
|
||||||
|
export const termsOfUseDocument = gql`
|
||||||
|
query TermsOfUse {
|
||||||
|
siteConfig {
|
||||||
|
title
|
||||||
|
description
|
||||||
|
termsOfUse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
@@ -1213,9 +1213,9 @@ export type SiteConfig = {
|
|||||||
createdAt?: Maybe<Scalars['DateTime']['output']>;
|
createdAt?: Maybe<Scalars['DateTime']['output']>;
|
||||||
description?: Maybe<Scalars['String']['output']>;
|
description?: Maybe<Scalars['String']['output']>;
|
||||||
documentId: Scalars['ID']['output'];
|
documentId: Scalars['ID']['output'];
|
||||||
privacyPolicy?: Maybe<UploadFile>;
|
privacyPolicy?: Maybe<Scalars['String']['output']>;
|
||||||
publishedAt?: Maybe<Scalars['DateTime']['output']>;
|
publishedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||||
termsOfUse?: Maybe<UploadFile>;
|
termsOfUse?: Maybe<Scalars['String']['output']>;
|
||||||
title: Scalars['String']['output'];
|
title: Scalars['String']['output'];
|
||||||
updatedAt?: Maybe<Scalars['DateTime']['output']>;
|
updatedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||||
};
|
};
|
||||||
@@ -1230,9 +1230,9 @@ export type SiteConfigContactsArgs = {
|
|||||||
export type SiteConfigInput = {
|
export type SiteConfigInput = {
|
||||||
contacts?: InputMaybe<Array<InputMaybe<ComponentSharedContactInput>>>;
|
contacts?: InputMaybe<Array<InputMaybe<ComponentSharedContactInput>>>;
|
||||||
description?: InputMaybe<Scalars['String']['input']>;
|
description?: InputMaybe<Scalars['String']['input']>;
|
||||||
privacyPolicy?: InputMaybe<Scalars['ID']['input']>;
|
privacyPolicy?: InputMaybe<Scalars['String']['input']>;
|
||||||
publishedAt?: InputMaybe<Scalars['DateTime']['input']>;
|
publishedAt?: InputMaybe<Scalars['DateTime']['input']>;
|
||||||
termsOfUse?: InputMaybe<Scalars['ID']['input']>;
|
termsOfUse?: InputMaybe<Scalars['String']['input']>;
|
||||||
title?: InputMaybe<Scalars['String']['input']>;
|
title?: InputMaybe<Scalars['String']['input']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1627,7 +1627,17 @@ export type MenuItemsQuery = { __typename?: 'Query', categories: Array<{ __typen
|
|||||||
export type SiteConfigQueryVariables = Exact<{ [key: string]: never; }>;
|
export type SiteConfigQueryVariables = Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
|
|
||||||
export type SiteConfigQuery = { __typename?: 'Query', siteConfig?: { __typename?: 'SiteConfig', title: string, description?: string | null, contacts?: Array<{ __typename?: 'ComponentSharedContact', id: string, type: Enum_Componentsharedcontact_Type, value: string, displayValue?: string | null } | null> | null, privacyPolicy?: { __typename?: 'UploadFile', url: string } | null, termsOfUse?: { __typename?: 'UploadFile', url: string } | null } | null };
|
export type SiteConfigQuery = { __typename?: 'Query', siteConfig?: { __typename?: 'SiteConfig', title: string, description?: string | null, contacts?: Array<{ __typename?: 'ComponentSharedContact', id: string, type: Enum_Componentsharedcontact_Type, value: string, displayValue?: string | null } | null> | null } | null };
|
||||||
|
|
||||||
|
export type PrivacyPolicyQueryVariables = Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
|
|
||||||
|
export type PrivacyPolicyQuery = { __typename?: 'Query', siteConfig?: { __typename?: 'SiteConfig', title: string, description?: string | null, privacyPolicy?: string | null } | null };
|
||||||
|
|
||||||
|
export type TermsOfUseQueryVariables = Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
|
|
||||||
|
export type TermsOfUseQuery = { __typename?: 'Query', siteConfig?: { __typename?: 'SiteConfig', title: string, description?: string | null, termsOfUse?: string | null } | null };
|
||||||
|
|
||||||
export class TypedDocumentString<TResult, TVariables>
|
export class TypedDocumentString<TResult, TVariables>
|
||||||
extends String
|
extends String
|
||||||
@@ -1789,12 +1799,24 @@ export const SiteConfigDocument = new TypedDocumentString(`
|
|||||||
value
|
value
|
||||||
displayValue
|
displayValue
|
||||||
}
|
}
|
||||||
privacyPolicy {
|
|
||||||
url
|
|
||||||
}
|
|
||||||
termsOfUse {
|
|
||||||
url
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`) as unknown as TypedDocumentString<SiteConfigQuery, SiteConfigQueryVariables>;
|
`) as unknown as TypedDocumentString<SiteConfigQuery, SiteConfigQueryVariables>;
|
||||||
|
export const PrivacyPolicyDocument = new TypedDocumentString(`
|
||||||
|
query PrivacyPolicy {
|
||||||
|
siteConfig {
|
||||||
|
title
|
||||||
|
description
|
||||||
|
privacyPolicy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`) as unknown as TypedDocumentString<PrivacyPolicyQuery, PrivacyPolicyQueryVariables>;
|
||||||
|
export const TermsOfUseDocument = new TypedDocumentString(`
|
||||||
|
query TermsOfUse {
|
||||||
|
siteConfig {
|
||||||
|
title
|
||||||
|
description
|
||||||
|
termsOfUse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`) as unknown as TypedDocumentString<TermsOfUseQuery, TermsOfUseQueryVariables>;
|
||||||
52
src/layouts/DocumentLayout.astro
Normal file
52
src/layouts/DocumentLayout.astro
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
import Layout from './Layout.astro'
|
||||||
|
import '../styles/globals.css'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
siteTitle: string
|
||||||
|
siteDescription?: string
|
||||||
|
pageTitle: string
|
||||||
|
contentHtml: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const { siteTitle, siteDescription = '', pageTitle, contentHtml } = Astro.props
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout
|
||||||
|
title={`${pageTitle} | ${siteTitle}`}
|
||||||
|
description={siteDescription}
|
||||||
|
siteName={siteTitle}
|
||||||
|
type="article"
|
||||||
|
>
|
||||||
|
<div class="min-h-screen bg-white flex flex-col">
|
||||||
|
<header class="border-b border-gray-200">
|
||||||
|
<div class="max-w-3xl mx-auto px-4 h-16 flex items-center justify-between w-full">
|
||||||
|
<a href="/" class="text-xl font-bold text-gray-900">{siteTitle}</a>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
id="doc-back"
|
||||||
|
class="text-sm text-gray-600 hover:text-blue-600 transition-colors"
|
||||||
|
>
|
||||||
|
← Назад
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="flex-1 max-w-3xl mx-auto px-4 py-16 w-full">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 mb-8">{pageTitle}</h1>
|
||||||
|
<div class="markdown-content" set:html={contentHtml} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Prefer going back to the previous page; fall back to the home link href.
|
||||||
|
document.addEventListener('astro:page-load', () => {
|
||||||
|
const back = document.getElementById('doc-back')
|
||||||
|
back?.addEventListener('click', event => {
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
event.preventDefault()
|
||||||
|
window.history.back()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -2,21 +2,98 @@
|
|||||||
import ClientRouter from 'astro/components/ClientRouter.astro'
|
import ClientRouter from 'astro/components/ClientRouter.astro'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
/** Full <title> / og:title. Comes from Strapi (siteConfig.title, category.title). */
|
||||||
title?: string
|
title?: string
|
||||||
|
/** meta description / og:description. Comes from Strapi. */
|
||||||
description?: string
|
description?: string
|
||||||
|
/** og:site_name — the brand, without the per-page part. */
|
||||||
|
siteName?: string
|
||||||
|
/** og:type: 'website' for the home page, 'article' for content pages. */
|
||||||
|
type?: 'website' | 'article'
|
||||||
|
/** Absolute or site-root-relative image for og:image / twitter:image. */
|
||||||
|
image?: string
|
||||||
|
/** Pixel size of `image` — crawlers use it to lay the preview out before download. */
|
||||||
|
imageWidth?: number
|
||||||
|
imageHeight?: number
|
||||||
|
/** Override the canonical path (defaults to the current URL without query). */
|
||||||
|
canonicalPath?: string
|
||||||
|
/** Keep the page out of search indexes (error pages, gated pages). */
|
||||||
|
noindex?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title = '', description = '' } = Astro.props
|
const {
|
||||||
|
title = '',
|
||||||
|
description = '',
|
||||||
|
siteName = '',
|
||||||
|
type = 'website',
|
||||||
|
image = '/logo.jpg',
|
||||||
|
imageWidth = 1024, // matches public/logo.jpg
|
||||||
|
imageHeight = 1024,
|
||||||
|
canonicalPath,
|
||||||
|
noindex = false,
|
||||||
|
} = Astro.props
|
||||||
|
|
||||||
|
// Absolute URLs are required by Open Graph — relative ones are ignored by most
|
||||||
|
// crawlers and social scrapers. PUBLIC_SITE_URL pins the public origin; behind a
|
||||||
|
// reverse proxy Astro.url alone can resolve to the internal container host.
|
||||||
|
// Prefer process.env so the domain can be set at deploy/runtime (Docker), with
|
||||||
|
// import.meta.env as the `astro dev` fallback (see src/middleware.ts).
|
||||||
|
const siteOrigin =
|
||||||
|
process.env.PUBLIC_SITE_URL || import.meta.env.PUBLIC_SITE_URL || Astro.url.origin
|
||||||
|
const canonicalURL = new URL(canonicalPath ?? Astro.url.pathname, siteOrigin)
|
||||||
|
const imageURL = new URL(image, siteOrigin)
|
||||||
---
|
---
|
||||||
|
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content={description} />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<link rel="canonical" href={canonicalURL.href} />
|
||||||
|
{
|
||||||
|
noindex ? (
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
) : (
|
||||||
|
<meta name="robots" content="index, follow, max-image-preview:large" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Open Graph (VK, Telegram, WhatsApp, LinkedIn, Facebook) -->
|
||||||
|
<meta property="og:type" content={type} />
|
||||||
|
<meta property="og:url" content={canonicalURL.href} />
|
||||||
|
<meta property="og:title" content={title} />
|
||||||
|
<meta property="og:description" content={description} />
|
||||||
|
{siteName && <meta property="og:site_name" content={siteName} />}
|
||||||
|
<meta property="og:locale" content="ru_RU" />
|
||||||
|
<meta property="og:image" content={imageURL.href} />
|
||||||
|
<meta property="og:image:width" content={String(imageWidth)} />
|
||||||
|
<meta property="og:image:height" content={String(imageHeight)} />
|
||||||
|
<meta property="og:image:alt" content={title} />
|
||||||
|
|
||||||
|
<!-- Twitter/X card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content={title} />
|
||||||
|
<meta name="twitter:description" content={description} />
|
||||||
|
<meta name="twitter:image" content={imageURL.href} />
|
||||||
|
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<ClientRouter />
|
<ClientRouter />
|
||||||
|
<script is:inline type="text/javascript">
|
||||||
|
// prettier-ignore
|
||||||
|
;(function(m,e,t,r,i,k,a){
|
||||||
|
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
||||||
|
m[i].l=1*new Date();
|
||||||
|
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
|
||||||
|
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
|
||||||
|
})(window, document,'script','https://mc.yandex.ru/metrika/tag.js', 'ym')
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
|
ym(111077958, 'init', {webvisor:true, clickmap:true, referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true})
|
||||||
|
</script>
|
||||||
|
<!-- prettier-ignore -->
|
||||||
|
<noscript><div><img src="https://mc.yandex.ru/watch/111077958" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
|
||||||
|
<!-- /Yandex.Metrika counter -->
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
164
src/lib/bitrix24/leads.ts
Normal file
164
src/lib/bitrix24/leads.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* Forwards contact form submissions to Bitrix24 as CRM leads.
|
||||||
|
*
|
||||||
|
* The webhook base URL is configured via `BITRIX24_WEBHOOK_URL` (e.g.
|
||||||
|
* `https://portal.bitrix24.ru/rest/1/<token>/`). The REST method name
|
||||||
|
* (`crm.lead.add.json`) is appended to it for each call.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ContactSubmission } from '../contact/submission'
|
||||||
|
|
||||||
|
const LEAD_ADD_METHOD = 'crm.lead.add.json'
|
||||||
|
|
||||||
|
// Bitrix24 custom field IDs for the lead (configured in the CRM).
|
||||||
|
const UF_COMPANY = 'UF_CRM_1783104958'
|
||||||
|
const UF_PURPOSE = 'UF_CRM_1783105006'
|
||||||
|
const UF_TIMELINE = 'UF_CRM_1783105015'
|
||||||
|
const UF_MEETING_SLOT = 'UF_CRM_1783105027'
|
||||||
|
|
||||||
|
interface BitrixMultiField {
|
||||||
|
VALUE: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// The form submits stable option ids; the CRM should show the same Russian
|
||||||
|
// labels the user sees in the UI (see ContactForm.tsx). Unknown ids fall
|
||||||
|
// through unchanged.
|
||||||
|
const PURPOSE_LABELS: Record<string, string> = {
|
||||||
|
modernization: 'Модернизация инфраструктуры',
|
||||||
|
'software-replacement': 'Замена импортного ПО на российское',
|
||||||
|
'capacity-expansion': 'Расширение мощностей',
|
||||||
|
'new-infrastructure': 'Создание новой инфраструктуры',
|
||||||
|
'security-improvement': 'Повышение безопасности',
|
||||||
|
automation: 'Автоматизация процессов',
|
||||||
|
'cloud-migration': 'Миграция в облако',
|
||||||
|
'disaster-recovery': 'Резервирование и восстановление',
|
||||||
|
compliance: 'Соответствие требованиям регуляторов',
|
||||||
|
other: 'Другое',
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIMELINE_LABELS: Record<string, string> = {
|
||||||
|
urgent: 'До 1 месяца',
|
||||||
|
'1-3-months': '1-3 месяца',
|
||||||
|
'3-6-months': '3-6 месяцев',
|
||||||
|
'6-12-months': '6-12 месяцев',
|
||||||
|
'12-months-plus': 'Более 12 месяцев',
|
||||||
|
planning: 'На стадии планирования',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lead fields sent to `crm.lead.add`. UF_CRM_* are mapped form fields (see consts above). */
|
||||||
|
interface BitrixLeadFields {
|
||||||
|
SOURCE_ID: string
|
||||||
|
TITLE: string
|
||||||
|
NAME?: string
|
||||||
|
PHONE?: BitrixMultiField[]
|
||||||
|
EMAIL?: BitrixMultiField[]
|
||||||
|
COMMENTS?: string
|
||||||
|
/** company */
|
||||||
|
UF_CRM_1783104958?: string
|
||||||
|
/** purpose */
|
||||||
|
UF_CRM_1783105006?: string
|
||||||
|
/** timeline */
|
||||||
|
UF_CRM_1783105015?: string
|
||||||
|
/** preferred meeting slot, `YYYY-MM-DD HH:mm:ss` */
|
||||||
|
UF_CRM_1783105027?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shape of a successful `crm.lead.add` response. */
|
||||||
|
interface BitrixLeadAddResponse {
|
||||||
|
/** Newly created lead id. */
|
||||||
|
result?: number
|
||||||
|
error?: string
|
||||||
|
error_description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function readWebhookUrl(): string {
|
||||||
|
// Prefer process.env so the value can be set at deploy/runtime (Docker); fall
|
||||||
|
// back to import.meta.env so local `astro dev` picks it up from .env.
|
||||||
|
const url = process.env.BITRIX24_WEBHOOK_URL ?? import.meta.env.BITRIX24_WEBHOOK_URL
|
||||||
|
if (!url) {
|
||||||
|
throw new Error('BITRIX24_WEBHOOK_URL is not configured')
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a `datetime-local` value (`2026-06-27T14:30`) to Bitrix `2026-06-27 14:30:00`. */
|
||||||
|
function toBitrixDateTime(value: string): string {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return ''
|
||||||
|
const [date, time = ''] = trimmed.split('T')
|
||||||
|
const [hh = '00', mm = '00', ss = '00'] = time.split(':')
|
||||||
|
return `${date} ${hh}:${mm}:${ss}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimmed(value: string | undefined): string | undefined {
|
||||||
|
const result = value?.trim()
|
||||||
|
return result ? result : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a validated submission to Bitrix lead fields, omitting empty values. */
|
||||||
|
function toLeadFields(submission: ContactSubmission): BitrixLeadFields {
|
||||||
|
const fields: BitrixLeadFields = {
|
||||||
|
SOURCE_ID: 'WEB',
|
||||||
|
TITLE: submission.submissionSource
|
||||||
|
? `Лид с сайта — ${submission.submissionSource}`
|
||||||
|
: 'Лид с сайта',
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = trimmed(submission.name)
|
||||||
|
if (name) fields.NAME = name
|
||||||
|
|
||||||
|
const phone = trimmed(submission.phone)
|
||||||
|
if (phone) fields.PHONE = [{ VALUE: phone }]
|
||||||
|
|
||||||
|
const email = trimmed(submission.email)
|
||||||
|
if (email) fields.EMAIL = [{ VALUE: email }]
|
||||||
|
|
||||||
|
// The form renders either `message` or `taskComment` depending on placement.
|
||||||
|
const comments = trimmed(submission.taskComment) ?? trimmed(submission.message)
|
||||||
|
if (comments) fields.COMMENTS = comments
|
||||||
|
|
||||||
|
const company = trimmed(submission.company)
|
||||||
|
if (company) fields[UF_COMPANY] = company
|
||||||
|
|
||||||
|
const purpose = trimmed(submission.purpose)
|
||||||
|
if (purpose) fields[UF_PURPOSE] = PURPOSE_LABELS[purpose] ?? purpose
|
||||||
|
|
||||||
|
const timeline = trimmed(submission.timeline)
|
||||||
|
if (timeline) fields[UF_TIMELINE] = TIMELINE_LABELS[timeline] ?? timeline
|
||||||
|
|
||||||
|
const meetingSlot = trimmed(submission.meetingSlot1)
|
||||||
|
if (meetingSlot) fields[UF_MEETING_SLOT] = toBitrixDateTime(meetingSlot)
|
||||||
|
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Bitrix24 CRM lead from a contact form submission.
|
||||||
|
*
|
||||||
|
* Returns the new lead id. Throws if the webhook is unconfigured, the request
|
||||||
|
* fails, or Bitrix responds with an error.
|
||||||
|
*/
|
||||||
|
export async function createBitrixLead(submission: ContactSubmission): Promise<number> {
|
||||||
|
const base = readWebhookUrl().replace(/\/+$/, '')
|
||||||
|
const endpoint = `${base}/${LEAD_ADD_METHOD}`
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ FIELDS: toLeadFields(submission) }),
|
||||||
|
})
|
||||||
|
|
||||||
|
let payload: BitrixLeadAddResponse
|
||||||
|
try {
|
||||||
|
payload = (await response.json()) as BitrixLeadAddResponse
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Bitrix24 returned a non-JSON response (HTTP ${response.status})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || payload.error || typeof payload.result !== 'number') {
|
||||||
|
const detail = payload.error_description ?? payload.error ?? `HTTP ${response.status}`
|
||||||
|
throw new Error(`Bitrix24 lead creation failed: ${detail}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.result
|
||||||
|
}
|
||||||
21
src/lib/cms/markdown/render.ts
Normal file
21
src/lib/cms/markdown/render.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { createMarkdownProcessor } from '@astrojs/markdown-remark'
|
||||||
|
|
||||||
|
type MarkdownProcessor = Awaited<ReturnType<typeof createMarkdownProcessor>>
|
||||||
|
|
||||||
|
// Astro's own markdown engine (the same one it uses for .md files). We reuse a
|
||||||
|
// single processor instance since creating one is relatively expensive.
|
||||||
|
let processorPromise: Promise<MarkdownProcessor> | null = null
|
||||||
|
|
||||||
|
function getProcessor(): Promise<MarkdownProcessor> {
|
||||||
|
if (!processorPromise) {
|
||||||
|
processorPromise = createMarkdownProcessor({})
|
||||||
|
}
|
||||||
|
return processorPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a markdown string (e.g. CMS richtext) to an HTML string. */
|
||||||
|
export async function renderMarkdown(markdown: string): Promise<string> {
|
||||||
|
const processor = await getProcessor()
|
||||||
|
const { code } = await processor.render(markdown)
|
||||||
|
return code
|
||||||
|
}
|
||||||
66
src/lib/contact/submission.ts
Normal file
66
src/lib/contact/submission.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Types for contact form submissions.
|
||||||
|
*
|
||||||
|
* The browser posts `Object.fromEntries(new FormData(form))` as JSON, so every
|
||||||
|
* submitted value arrives as a `string`. A field is only present in the payload
|
||||||
|
* when its input is rendered (see `visibleFields` in ContactForm), therefore all
|
||||||
|
* fields are optional on the wire. Use `parseContactSubmission` to validate and
|
||||||
|
* narrow an unknown body into a `ContactSubmission`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shape of the JSON body received by `POST /api/contact`.
|
||||||
|
*
|
||||||
|
* Field names mirror the `name` attributes of the form inputs. Note `meetingSlot1`
|
||||||
|
* is the wire name for the meeting slot input. Every field is optional because the
|
||||||
|
* form renders a configurable subset of inputs per placement.
|
||||||
|
*/
|
||||||
|
export interface ContactSubmission {
|
||||||
|
/** Identifies which placement/form sent the request (hidden input). */
|
||||||
|
submissionSource?: string
|
||||||
|
name?: string
|
||||||
|
company?: string
|
||||||
|
email?: string
|
||||||
|
phone?: string
|
||||||
|
purpose?: string
|
||||||
|
timeline?: string
|
||||||
|
taskComment?: string
|
||||||
|
message?: string
|
||||||
|
/** Preferred ВКС meeting time, `datetime-local` string (e.g. `2026-06-27T14:30`). */
|
||||||
|
meetingSlot1?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStringOrUndefined(value: unknown): value is string | undefined {
|
||||||
|
return value === undefined || typeof value === 'string'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and narrow an unknown request body into a `ContactSubmission`.
|
||||||
|
*
|
||||||
|
* Returns the typed submission on success, or `null` when the body is not an
|
||||||
|
* object or any present field has an unexpected type. Every known field must be
|
||||||
|
* a string when present.
|
||||||
|
*/
|
||||||
|
export function parseContactSubmission(body: unknown): ContactSubmission | null {
|
||||||
|
if (typeof body !== 'object' || body === null || Array.isArray(body)) return null
|
||||||
|
|
||||||
|
const data = body as Record<string, unknown>
|
||||||
|
|
||||||
|
const stringFields: (keyof ContactSubmission)[] = [
|
||||||
|
'submissionSource',
|
||||||
|
'name',
|
||||||
|
'company',
|
||||||
|
'email',
|
||||||
|
'phone',
|
||||||
|
'purpose',
|
||||||
|
'timeline',
|
||||||
|
'taskComment',
|
||||||
|
'message',
|
||||||
|
'meetingSlot1',
|
||||||
|
]
|
||||||
|
for (const field of stringFields) {
|
||||||
|
if (!isStringOrUndefined(data[field])) return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as ContactSubmission
|
||||||
|
}
|
||||||
@@ -15,7 +15,12 @@ try {
|
|||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title={siteTitle} description={siteDescription}>
|
<Layout
|
||||||
|
title={siteTitle ? `Страница не найдена | ${siteTitle}` : 'Страница не найдена'}
|
||||||
|
description={siteDescription}
|
||||||
|
siteName={siteTitle}
|
||||||
|
noindex
|
||||||
|
>
|
||||||
<div class="min-h-screen grid content-center justify-items-center">
|
<div class="min-h-screen grid content-center justify-items-center">
|
||||||
<div>404</div>
|
<div>404</div>
|
||||||
<div>Страница не найдена</div>
|
<div>Страница не найдена</div>
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ try {
|
|||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title={siteTitle} description={siteDescription}>
|
<Layout
|
||||||
|
title={siteTitle ? `Ошибка сервера | ${siteTitle}` : 'Ошибка сервера'}
|
||||||
|
description={siteDescription}
|
||||||
|
siteName={siteTitle}
|
||||||
|
noindex
|
||||||
|
>
|
||||||
<div class="min-h-screen grid content-center justify-items-center">
|
<div class="min-h-screen grid content-center justify-items-center">
|
||||||
<div>500</div>
|
<div>500</div>
|
||||||
<div>Мы скоро всё починим</div>
|
<div>Мы скоро всё починим</div>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { siteConfigQuery } from '../graphql-documents/site-config.gql.generated'
|
|||||||
import { menuItemsQuery } from '../graphql-documents/menu-items.gql.generated'
|
import { menuItemsQuery } from '../graphql-documents/menu-items.gql.generated'
|
||||||
import { categoryQuery } from '../graphql-documents/category.gql.generated'
|
import { categoryQuery } from '../graphql-documents/category.gql.generated'
|
||||||
import { homePageQuery } from '../graphql-documents/home-page.gql.generated'
|
import { homePageQuery } from '../graphql-documents/home-page.gql.generated'
|
||||||
import { resolveUploadUrl } from '../lib/cms/uploads/url'
|
|
||||||
import { getHomeSectionAnchors } from '../lib/cms/sections/homeNav'
|
import { getHomeSectionAnchors } from '../lib/cms/sections/homeNav'
|
||||||
|
|
||||||
const [siteResult, menuItemsResult, homeResult] = await Promise.all([
|
const [siteResult, menuItemsResult, homeResult] = await Promise.all([
|
||||||
@@ -28,8 +27,6 @@ const siteConfig = siteResult.data?.siteConfig
|
|||||||
const siteTitle = siteConfig?.title ?? ''
|
const siteTitle = siteConfig?.title ?? ''
|
||||||
const siteDescription = siteConfig?.description ?? ''
|
const siteDescription = siteConfig?.description ?? ''
|
||||||
const contacts = siteConfig?.contacts?.filter(c => c != null) ?? []
|
const contacts = siteConfig?.contacts?.filter(c => c != null) ?? []
|
||||||
const privacyPolicyUrl = resolveUploadUrl(siteConfig?.privacyPolicy?.url)
|
|
||||||
const termsOfUseUrl = resolveUploadUrl(siteConfig?.termsOfUse?.url)
|
|
||||||
if (menuItemsResult.errors?.length) {
|
if (menuItemsResult.errors?.length) {
|
||||||
throw new Error(menuItemsResult.errors.map(e => e.message).join(', '))
|
throw new Error(menuItemsResult.errors.map(e => e.message).join(', '))
|
||||||
}
|
}
|
||||||
@@ -70,6 +67,9 @@ const vendors = (category.vendors?.filter(v => v != null) ?? []).map(vendor => (
|
|||||||
<Layout
|
<Layout
|
||||||
title={title ? `${title} | ${siteTitle}` : siteTitle}
|
title={title ? `${title} | ${siteTitle}` : siteTitle}
|
||||||
description={description ?? siteDescription}
|
description={description ?? siteDescription}
|
||||||
|
siteName={siteTitle}
|
||||||
|
type="article"
|
||||||
|
canonicalPath={`/${slug}`}
|
||||||
>
|
>
|
||||||
<div class="min-h-screen bg-white w-full overflow-x-hidden">
|
<div class="min-h-screen bg-white w-full overflow-x-hidden">
|
||||||
<Header client:load siteTitle={siteTitle} contacts={contacts} solutions={solutionCategories} services={serviceCategories} sectionAnchors={sectionAnchors} />
|
<Header client:load siteTitle={siteTitle} contacts={contacts} solutions={solutionCategories} services={serviceCategories} sectionAnchors={sectionAnchors} />
|
||||||
@@ -79,8 +79,6 @@ const vendors = (category.vendors?.filter(v => v != null) ?? []).map(vendor => (
|
|||||||
siteTitle={siteTitle}
|
siteTitle={siteTitle}
|
||||||
contacts={contacts}
|
contacts={contacts}
|
||||||
solutionCategories={solutionCategories}
|
solutionCategories={solutionCategories}
|
||||||
privacyPolicyUrl={privacyPolicyUrl}
|
|
||||||
termsOfUseUrl={termsOfUseUrl}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { APIRoute } from 'astro'
|
import type { APIRoute } from 'astro'
|
||||||
|
|
||||||
|
import { createBitrixLead } from '../../lib/bitrix24/leads'
|
||||||
|
import { parseContactSubmission } from '../../lib/contact/submission'
|
||||||
|
|
||||||
// Served on demand: this accepts live form submissions, never a build-time snapshot.
|
// Served on demand: this accepts live form submissions, never a build-time snapshot.
|
||||||
export const prerender = false
|
export const prerender = false
|
||||||
|
|
||||||
@@ -14,18 +17,32 @@ function json(body: unknown, status: number): Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
let data: Record<string, unknown>
|
let body: unknown
|
||||||
try {
|
try {
|
||||||
data = await request.json()
|
body = await request.json()
|
||||||
} catch {
|
} catch {
|
||||||
return json({ ok: false, error: 'Invalid JSON body' }, 400)
|
return json({ ok: false, error: 'Invalid JSON body' }, 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: forward to the CRM (Bitrix24) and/or persist. For now just log on the server.
|
const data = parseContactSubmission(body)
|
||||||
console.info('[contact]', {
|
if (!data) {
|
||||||
receivedAt: new Date().toISOString(),
|
return json({ ok: false, error: 'Invalid submission' }, 400)
|
||||||
data,
|
}
|
||||||
})
|
|
||||||
|
|
||||||
|
console.log(data)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const leadId = await createBitrixLead(data)
|
||||||
|
console.info('[contact] lead created', {
|
||||||
|
receivedAt: new Date().toISOString(),
|
||||||
|
leadId,
|
||||||
|
submissionSource: data.submissionSource,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[contact] failed to create Bitrix24 lead', error)
|
||||||
|
return json({ ok: false, error: 'Failed to submit. Please try again later.' }, 502)
|
||||||
|
}
|
||||||
|
|
||||||
return json({ ok: true }, 200)
|
return json({ ok: true }, 200)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,11 @@
|
|||||||
// It is intentionally self-contained — no shared Layout and no Strapi data — so
|
// It is intentionally self-contained — no shared Layout and no Strapi data — so
|
||||||
// it renders even while the rest of the site is gated.
|
// it renders even while the rest of the site is gated.
|
||||||
Astro.response.headers.set('X-Robots-Tag', 'noindex, nofollow')
|
Astro.response.headers.set('X-Robots-Tag', 'noindex, nofollow')
|
||||||
|
|
||||||
|
// Same origin resolution as src/layouts/Layout.astro — og:image must be absolute.
|
||||||
|
const siteOrigin =
|
||||||
|
process.env.PUBLIC_SITE_URL || import.meta.env.PUBLIC_SITE_URL || Astro.url.origin
|
||||||
|
const ogImage = new URL('/logo.jpg', siteOrigin).href
|
||||||
---
|
---
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
@@ -15,13 +20,26 @@ Astro.response.headers.set('X-Robots-Tag', 'noindex, nofollow')
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
<title>СОФТКЛИК — сайт в разработке</title>
|
<title>СОФТКЛИК — сайт в разработке</title>
|
||||||
|
<meta name="description" content="Сайт в разработке. Следите за обновлениями." />
|
||||||
|
<!-- Kept for messenger link previews (the preview link gets shared in chats);
|
||||||
|
indexing is still blocked by the robots meta above. -->
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:title" content="СОФТКЛИК — сайт в разработке" />
|
||||||
|
<meta property="og:description" content="Сайт в разработке. Следите за обновлениями." />
|
||||||
|
<meta property="og:site_name" content="СОФТКЛИК" />
|
||||||
|
<meta property="og:locale" content="ru_RU" />
|
||||||
|
<meta property="og:image" content={ogImage} />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="screen">
|
<div class="screen">
|
||||||
<h1 class="logo">
|
<div class="brand">
|
||||||
<span class="logo__line">СОФТ</span>
|
<h1 class="logo">
|
||||||
<span class="logo__line logo__line--indent">КЛИК</span>
|
<span class="logo__line">СОФТ</span>
|
||||||
</h1>
|
<span class="logo__line logo__line--indent">КЛИК</span>
|
||||||
|
</h1>
|
||||||
|
<img class="brand__logo" src="/logo.jpg" alt="СОФТКЛИК" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="media">
|
<div class="media">
|
||||||
<img class="astronaut" src="/astronaut.png" alt="Космонавт" />
|
<img class="astronaut" src="/astronaut.png" alt="Космонавт" />
|
||||||
@@ -70,14 +88,24 @@ Astro.response.headers.set('X-Robots-Tag', 'noindex, nofollow')
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.brand {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
grid-row: 1;
|
grid-row: 1;
|
||||||
align-self: start;
|
align-self: start;
|
||||||
margin: 0;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
/* top padding and size track viewport height so it fits at any height */
|
/* top padding and size track viewport height so it fits at any height */
|
||||||
padding: clamp(20px, 10vh, 100px) var(--pad) 0 var(--pad);
|
padding: clamp(20px, 10vh, 100px) var(--pad) 0 var(--pad);
|
||||||
font-weight: 500;
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.brand__logo {
|
||||||
|
flex: none;
|
||||||
|
width: clamp(96px, min(14vw, 24vh), 220px);
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
line-height: 0.88;
|
line-height: 0.88;
|
||||||
font-size: clamp(56px, min(9vw, 15vh), 134px);
|
font-size: clamp(56px, min(9vw, 15vh), 134px);
|
||||||
@@ -97,7 +125,7 @@ Astro.response.headers.set('X-Robots-Tag', 'noindex, nofollow')
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 var(--pad) var(--pad);
|
padding: 0 var(--pad) var(--pad);
|
||||||
font-size: clamp(15px, 1.6vw, 20px);
|
font-size: clamp(15px, 1.6vw, 20px);
|
||||||
font-weight: 400;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
@@ -123,9 +151,11 @@ Astro.response.headers.set('X-Robots-Tag', 'noindex, nofollow')
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
}
|
}
|
||||||
.logo {
|
.brand {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
grid-row: 1;
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
font-size: clamp(36px, min(16vw, 11vh), 72px);
|
font-size: clamp(36px, min(16vw, 11vh), 72px);
|
||||||
}
|
}
|
||||||
.media {
|
.media {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { Enum_Category_Group } from '../graphql/graphql'
|
|||||||
import { menuItemsQuery } from '../graphql-documents/menu-items.gql.generated'
|
import { menuItemsQuery } from '../graphql-documents/menu-items.gql.generated'
|
||||||
import { homePageQuery } from '../graphql-documents/home-page.gql.generated'
|
import { homePageQuery } from '../graphql-documents/home-page.gql.generated'
|
||||||
import { siteConfigQuery } from '../graphql-documents/site-config.gql.generated'
|
import { siteConfigQuery } from '../graphql-documents/site-config.gql.generated'
|
||||||
import { resolveUploadUrl } from '../lib/cms/uploads/url'
|
|
||||||
import { getHomeSectionAnchors } from '../lib/cms/sections/homeNav'
|
import { getHomeSectionAnchors } from '../lib/cms/sections/homeNav'
|
||||||
|
|
||||||
const [homeResult, siteResult, menuItemsResult] = await Promise.all([
|
const [homeResult, siteResult, menuItemsResult] = await Promise.all([
|
||||||
@@ -32,8 +31,6 @@ const siteConfig = siteResult.data?.siteConfig
|
|||||||
const siteTitle = siteConfig?.title ?? ''
|
const siteTitle = siteConfig?.title ?? ''
|
||||||
const siteDescription = siteConfig?.description ?? ''
|
const siteDescription = siteConfig?.description ?? ''
|
||||||
const contacts = siteConfig?.contacts?.filter(c => c != null) ?? []
|
const contacts = siteConfig?.contacts?.filter(c => c != null) ?? []
|
||||||
const privacyPolicyUrl = resolveUploadUrl(siteConfig?.privacyPolicy?.url)
|
|
||||||
const termsOfUseUrl = resolveUploadUrl(siteConfig?.termsOfUse?.url)
|
|
||||||
const menuItems = (menuItemsResult.data?.categories?.filter(c => c != null) ?? []).sort(
|
const menuItems = (menuItemsResult.data?.categories?.filter(c => c != null) ?? []).sort(
|
||||||
(a, b) => a.sortOrder - b.sortOrder
|
(a, b) => a.sortOrder - b.sortOrder
|
||||||
)
|
)
|
||||||
@@ -43,7 +40,7 @@ const serviceCategories = menuItems.filter(c => c.group === Enum_Category_Group.
|
|||||||
const sectionAnchors = getHomeSectionAnchors(homePage)
|
const sectionAnchors = getHomeSectionAnchors(homePage)
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title={siteTitle} description={siteDescription}>
|
<Layout title={siteTitle} description={siteDescription} siteName={siteTitle} type="website">
|
||||||
<div class="min-h-screen bg-white w-full overflow-x-hidden">
|
<div class="min-h-screen bg-white w-full overflow-x-hidden">
|
||||||
<Header client:load siteTitle={siteTitle} contacts={contacts} solutions={solutionCategories} services={serviceCategories} sectionAnchors={sectionAnchors} />
|
<Header client:load siteTitle={siteTitle} contacts={contacts} solutions={solutionCategories} services={serviceCategories} sectionAnchors={sectionAnchors} />
|
||||||
<HomePage client:load homePage={homePage} contacts={contacts} />
|
<HomePage client:load homePage={homePage} contacts={contacts} />
|
||||||
@@ -52,8 +49,6 @@ const sectionAnchors = getHomeSectionAnchors(homePage)
|
|||||||
siteTitle={siteTitle}
|
siteTitle={siteTitle}
|
||||||
contacts={contacts}
|
contacts={contacts}
|
||||||
solutionCategories={solutionCategories}
|
solutionCategories={solutionCategories}
|
||||||
privacyPolicyUrl={privacyPolicyUrl}
|
|
||||||
termsOfUseUrl={termsOfUseUrl}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
21
src/pages/privacy-policy.astro
Normal file
21
src/pages/privacy-policy.astro
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
import DocumentLayout from '../layouts/DocumentLayout.astro'
|
||||||
|
import { privacyPolicyQuery } from '../graphql-documents/privacy-policy.gql.generated'
|
||||||
|
import { renderMarkdown } from '../lib/cms/markdown/render'
|
||||||
|
|
||||||
|
const result = await privacyPolicyQuery.execute()
|
||||||
|
if (result.errors?.length) {
|
||||||
|
throw new Error(result.errors.map(e => e.message).join(', '))
|
||||||
|
}
|
||||||
|
|
||||||
|
const siteConfig = result.data?.siteConfig
|
||||||
|
const markdown = siteConfig?.privacyPolicy
|
||||||
|
const contentHtml = markdown ? await renderMarkdown(markdown) : ''
|
||||||
|
---
|
||||||
|
|
||||||
|
<DocumentLayout
|
||||||
|
siteTitle={siteConfig?.title ?? ''}
|
||||||
|
siteDescription={siteConfig?.description ?? ''}
|
||||||
|
pageTitle="Политика конфиденциальности"
|
||||||
|
contentHtml={contentHtml}
|
||||||
|
/>
|
||||||
21
src/pages/terms-of-use.astro
Normal file
21
src/pages/terms-of-use.astro
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
import DocumentLayout from '../layouts/DocumentLayout.astro'
|
||||||
|
import { termsOfUseQuery } from '../graphql-documents/terms-of-use.gql.generated'
|
||||||
|
import { renderMarkdown } from '../lib/cms/markdown/render'
|
||||||
|
|
||||||
|
const result = await termsOfUseQuery.execute()
|
||||||
|
if (result.errors?.length) {
|
||||||
|
throw new Error(result.errors.map(e => e.message).join(', '))
|
||||||
|
}
|
||||||
|
|
||||||
|
const siteConfig = result.data?.siteConfig
|
||||||
|
const markdown = siteConfig?.termsOfUse
|
||||||
|
const contentHtml = markdown ? await renderMarkdown(markdown) : ''
|
||||||
|
---
|
||||||
|
|
||||||
|
<DocumentLayout
|
||||||
|
siteTitle={siteConfig?.title ?? ''}
|
||||||
|
siteDescription={siteConfig?.description ?? ''}
|
||||||
|
pageTitle="Условия использования"
|
||||||
|
contentHtml={contentHtml}
|
||||||
|
/>
|
||||||
8
src/pages/yandex_3ed5369ecd498843.html
Normal file
8
src/pages/yandex_3ed5369ecd498843.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
Verification: 3ed5369ecd498843
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -54,7 +54,7 @@ export function About({ data }: AboutProps) {
|
|||||||
return (
|
return (
|
||||||
<section className="py-4 bg-gray-50">
|
<section className="py-4 bg-gray-50">
|
||||||
<div className="max-w-400 mx-auto px-6 xl:px-12 2xl:px-16">
|
<div className="max-w-400 mx-auto px-6 xl:px-12 2xl:px-16">
|
||||||
<div className="max-w-3xl mb-16">
|
<div className="mb-16">
|
||||||
<p className="text-sm uppercase tracking-wider text-gray-500 mb-4">О компании</p>
|
<p className="text-sm uppercase tracking-wider text-gray-500 mb-4">О компании</p>
|
||||||
{title || partners.length > 0 ? (
|
{title || partners.length > 0 ? (
|
||||||
<h2 className="text-4xl lg:text-5xl text-gray-900 mb-6 flex flex-wrap items-baseline gap-2">
|
<h2 className="text-4xl lg:text-5xl text-gray-900 mb-6 flex flex-wrap items-baseline gap-2">
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function Cases({ data }: CasesProps) {
|
|||||||
return (
|
return (
|
||||||
<section className="py-4 bg-white">
|
<section className="py-4 bg-white">
|
||||||
<div className="max-w-400 mx-auto px-6 xl:px-12 2xl:px-16">
|
<div className="max-w-400 mx-auto px-6 xl:px-12 2xl:px-16">
|
||||||
<div className="max-w-3xl mb-16">
|
<div className="mb-16">
|
||||||
<p className="text-sm uppercase tracking-wider text-gray-500 mb-4">Кейсы</p>
|
<p className="text-sm uppercase tracking-wider text-gray-500 mb-4">Кейсы</p>
|
||||||
{title ? <h2 className="text-4xl lg:text-5xl text-gray-900 mb-6">{title}</h2> : null}
|
{title ? <h2 className="text-4xl lg:text-5xl text-gray-900 mb-6">{title}</h2> : null}
|
||||||
{description ? <p className="text-xl text-gray-600">{description}</p> : null}
|
{description ? <p className="text-xl text-gray-600">{description}</p> : null}
|
||||||
|
|||||||
@@ -10,63 +10,55 @@ export interface FooterProps {
|
|||||||
siteTitle?: string
|
siteTitle?: string
|
||||||
contacts?: readonly SiteConfigContact[]
|
contacts?: readonly SiteConfigContact[]
|
||||||
solutionCategories?: CategoryNavItem[]
|
solutionCategories?: CategoryNavItem[]
|
||||||
privacyPolicyUrl?: string
|
|
||||||
termsOfUseUrl?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Footer({
|
export function Footer({ siteTitle = '', contacts = [], solutionCategories = [] }: FooterProps) {
|
||||||
siteTitle = '',
|
|
||||||
contacts = [],
|
|
||||||
solutionCategories = [],
|
|
||||||
privacyPolicyUrl,
|
|
||||||
termsOfUseUrl,
|
|
||||||
}: FooterProps) {
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-gray-900 text-gray-400 pt-20 pb-10 border-t border-gray-800">
|
<footer className="bg-gray-900 text-gray-400 pt-20 pb-10 border-t border-gray-800">
|
||||||
<div className="max-w-400 mx-auto px-6 xl:px-12 2xl:px-16">
|
<div className="max-w-400 mx-auto px-6 xl:px-12 2xl:px-16">
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-12 mb-12">
|
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-12 mb-12">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<div className="w-9 h-9 bg-linear-to-br from-blue-600 to-blue-700 rounded-lg flex items-center justify-center shadow-md">
|
<img src="/logo-white.png" alt={siteTitle} className="h-9 w-auto" />
|
||||||
<div className="w-5 h-5 border-2 border-white rounded"></div>
|
<span className="text-2xl text-white tracking-wide font-bold">{siteTitle}</span>
|
||||||
</div>
|
|
||||||
<span className="text-2xl text-white tracking-tight">{siteTitle}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-6 leading-relaxed">Комплексные IT-решения для развития бизнеса</p>
|
<p className="mb-6 leading-relaxed">Комплексные IT-решения для развития бизнеса</p>
|
||||||
<div className="flex gap-4">
|
{false && (
|
||||||
<a
|
<div className="flex gap-4">
|
||||||
href="#"
|
<a
|
||||||
className="w-10 h-10 bg-gray-800 rounded-lg flex items-center justify-center hover:bg-blue-600 transition-colors border border-gray-700 hover:border-blue-600"
|
href="#"
|
||||||
title="Telegram"
|
className="w-10 h-10 bg-gray-800 rounded-lg flex items-center justify-center hover:bg-blue-600 transition-colors border border-gray-700 hover:border-blue-600"
|
||||||
>
|
title="Telegram"
|
||||||
<Send className="w-5 h-5" />
|
>
|
||||||
</a>
|
<Send className="w-5 h-5" />
|
||||||
<a
|
</a>
|
||||||
href="#"
|
<a
|
||||||
className="w-10 h-10 bg-gray-800 rounded-lg flex items-center justify-center hover:bg-blue-600 transition-colors border border-gray-700 hover:border-blue-600"
|
href="#"
|
||||||
title="VK"
|
className="w-10 h-10 bg-gray-800 rounded-lg flex items-center justify-center hover:bg-blue-600 transition-colors border border-gray-700 hover:border-blue-600"
|
||||||
>
|
title="VK"
|
||||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
>
|
||||||
<path d="M12.785 16.241s.288-.032.436-.19c.136-.145.131-.419.131-.419s-.019-1.281.576-1.47c.586-.185 1.341 1.238 2.138 1.788.604.417 1.064.325 1.064.325l2.137-.03s1.117-.069.587-.948c-.043-.072-.309-.651-1.589-1.84-1.339-1.246-1.16-1.044.453-3.197.983-1.312 1.376-2.113 1.253-2.456-.117-.328-.84-.241-.84-.241l-2.406.015s-.178-.024-.31.055c-.129.077-.212.258-.212.258s-.381.987-.887 1.827c-1.063 1.766-1.497 1.86-1.668 1.748-.395-.259-.296-1.04-.296-1.595 0-1.734.263-2.456-.512-2.642-.258-.062-.447-.102-1.106-.109-.845-.009-1.56.003-1.964.201-.269.132-.476.426-.35.443.156.021.509.095.696.349.241.328.233 1.064.233 1.064s.139 2.042-.324 2.296c-.318.174-.754-.182-1.69-1.806-.479-.821-.841-1.729-.841-1.729s-.07-.171-.194-.263c-.151-.111-.362-.146-.362-.146l-2.286.015s-.343.01-.469.159c-.112.132-.009.405-.009.405s1.789 4.182 3.814 6.291c1.857 1.935 3.965 1.808 3.965 1.808h.955z" />
|
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||||
</svg>
|
<path d="M12.785 16.241s.288-.032.436-.19c.136-.145.131-.419.131-.419s-.019-1.281.576-1.47c.586-.185 1.341 1.238 2.138 1.788.604.417 1.064.325 1.064.325l2.137-.03s1.117-.069.587-.948c-.043-.072-.309-.651-1.589-1.84-1.339-1.246-1.16-1.044.453-3.197.983-1.312 1.376-2.113 1.253-2.456-.117-.328-.84-.241-.84-.241l-2.406.015s-.178-.024-.31.055c-.129.077-.212.258-.212.258s-.381.987-.887 1.827c-1.063 1.766-1.497 1.86-1.668 1.748-.395-.259-.296-1.04-.296-1.595 0-1.734.263-2.456-.512-2.642-.258-.062-.447-.102-1.106-.109-.845-.009-1.56.003-1.964.201-.269.132-.476.426-.35.443.156.021.509.095.696.349.241.328.233 1.064.233 1.064s.139 2.042-.324 2.296c-.318.174-.754-.182-1.69-1.806-.479-.821-.841-1.729-.841-1.729s-.07-.171-.194-.263c-.151-.111-.362-.146-.362-.146l-2.286.015s-.343.01-.469.159c-.112.132-.009.405-.009.405s1.789 4.182 3.814 6.291c1.857 1.935 3.965 1.808 3.965 1.808h.955z" />
|
||||||
</a>
|
</svg>
|
||||||
<a
|
</a>
|
||||||
href="#"
|
<a
|
||||||
className="w-10 h-10 bg-gray-800 rounded-lg flex items-center justify-center hover:bg-blue-600 transition-colors border border-gray-700 hover:border-blue-600"
|
href="#"
|
||||||
title="Habr"
|
className="w-10 h-10 bg-gray-800 rounded-lg flex items-center justify-center hover:bg-blue-600 transition-colors border border-gray-700 hover:border-blue-600"
|
||||||
>
|
title="Habr"
|
||||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
>
|
||||||
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm4.326 14.467H7.674v-1.652h8.652v1.652zm0-3.239H7.674V9.576h8.652v1.652z" />
|
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||||
</svg>
|
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm4.326 14.467H7.674v-1.652h8.652v1.652zm0-3.239H7.674V9.576h8.652v1.652z" />
|
||||||
</a>
|
</svg>
|
||||||
<a
|
</a>
|
||||||
href="#"
|
<a
|
||||||
className="w-10 h-10 bg-gray-800 rounded-lg flex items-center justify-center hover:bg-blue-600 transition-colors border border-gray-700 hover:border-blue-600"
|
href="#"
|
||||||
title="VK Video"
|
className="w-10 h-10 bg-gray-800 rounded-lg flex items-center justify-center hover:bg-blue-600 transition-colors border border-gray-700 hover:border-blue-600"
|
||||||
>
|
title="VK Video"
|
||||||
<Video className="w-5 h-5" />
|
>
|
||||||
</a>
|
<Video className="w-5 h-5" />
|
||||||
</div>
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -86,24 +78,16 @@ export function Footer({
|
|||||||
<h4 className="text-white mb-6">Компания</h4>
|
<h4 className="text-white mb-6">Компания</h4>
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
<li>
|
<li>
|
||||||
<a href="#" className="hover:text-blue-400 transition-colors">
|
<span>ООО «Софтклик»</span>
|
||||||
О нас
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#" className="hover:text-blue-400 transition-colors">
|
<span>ИНН 5044121280</span>
|
||||||
Карьера
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#" className="hover:text-blue-400 transition-colors">
|
<span>КПП 504701001</span>
|
||||||
Новости
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#" className="hover:text-blue-400 transition-colors">
|
<span>ОГРН 1215000015394</span>
|
||||||
Партнерам
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,28 +99,14 @@ export function Footer({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-8 border-t border-gray-800 flex flex-col md:flex-row justify-between items-center gap-4">
|
<div className="pt-8 border-t border-gray-800 flex flex-col md:flex-row justify-between items-center gap-4">
|
||||||
<div>© 2025 {siteTitle}. Все права защищены.</div>
|
<div>© 2026 {siteTitle}. Все права защищены.</div>
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
{privacyPolicyUrl ? (
|
<a href="/privacy-policy" className="hover:text-blue-400 transition-colors">
|
||||||
<a
|
Политика конфиденциальности
|
||||||
href={privacyPolicyUrl}
|
</a>
|
||||||
target="_blank"
|
<a href="/terms-of-use" className="hover:text-blue-400 transition-colors">
|
||||||
rel="noopener noreferrer"
|
Условия использования
|
||||||
className="hover:text-blue-400 transition-colors"
|
</a>
|
||||||
>
|
|
||||||
Политика конфиденциальности
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
{termsOfUseUrl ? (
|
|
||||||
<a
|
|
||||||
href={termsOfUseUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="hover:text-blue-400 transition-colors"
|
|
||||||
>
|
|
||||||
Условия использования
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,13 +44,6 @@ export function Hero({ onOpenContactModal, data }: HeroProps) {
|
|||||||
Обсудить проект
|
Обсудить проект
|
||||||
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<a
|
|
||||||
href="#cases"
|
|
||||||
className="flex items-center gap-2 px-8 py-4 bg-gray-50 text-gray-900 rounded-xl hover:bg-gray-100 transition-all"
|
|
||||||
>
|
|
||||||
Портфолио
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{stats.length > 0 ? (
|
{stats.length > 0 ? (
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function Services({ data }: ServicesProps) {
|
|||||||
return (
|
return (
|
||||||
<section id="services" className="py-24 bg-white">
|
<section id="services" className="py-24 bg-white">
|
||||||
<div className="max-w-400 mx-auto px-6 xl:px-12 2xl:px-16">
|
<div className="max-w-400 mx-auto px-6 xl:px-12 2xl:px-16">
|
||||||
<div className="max-w-3xl mb-16">
|
<div className="mb-16">
|
||||||
<p className="text-sm uppercase tracking-wider text-gray-500 mb-4">Услуги</p>
|
<p className="text-sm uppercase tracking-wider text-gray-500 mb-4">Услуги</p>
|
||||||
{title ? <h2 className="text-4xl lg:text-5xl text-gray-900 mb-6">{title}</h2> : null}
|
{title ? <h2 className="text-4xl lg:text-5xl text-gray-900 mb-6">{title}</h2> : null}
|
||||||
{description ? <p className="text-xl text-gray-600">{description}</p> : null}
|
{description ? <p className="text-xl text-gray-600">{description}</p> : null}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function Solutions({ data }: SolutionsProps) {
|
|||||||
return (
|
return (
|
||||||
<section id="solutions" className="py-24 bg-gray-50">
|
<section id="solutions" className="py-24 bg-gray-50">
|
||||||
<div className="max-w-400 mx-auto px-6 xl:px-12 2xl:px-16">
|
<div className="max-w-400 mx-auto px-6 xl:px-12 2xl:px-16">
|
||||||
<div className="max-w-3xl mb-16">
|
<div className="mb-16">
|
||||||
<p className="text-sm uppercase tracking-wider text-gray-500 mb-4">Отраслевые решения</p>
|
<p className="text-sm uppercase tracking-wider text-gray-500 mb-4">Отраслевые решения</p>
|
||||||
{title ? <h2 className="text-4xl lg:text-5xl text-gray-900 mb-6">{title}</h2> : null}
|
{title ? <h2 className="text-4xl lg:text-5xl text-gray-900 mb-6">{title}</h2> : null}
|
||||||
{description ? <p className="text-xl text-gray-600">{description}</p> : null}
|
{description ? <p className="text-xl text-gray-600">{description}</p> : null}
|
||||||
|
|||||||
@@ -200,3 +200,49 @@
|
|||||||
html {
|
html {
|
||||||
font-size: var(--font-size);
|
font-size: var(--font-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Rendered markdown content (CMS richtext: privacy policy, terms of use). */
|
||||||
|
.markdown-content {
|
||||||
|
color: #374151;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
.markdown-content h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111827;
|
||||||
|
margin: 2rem 0 1rem;
|
||||||
|
}
|
||||||
|
.markdown-content h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
margin: 1.5rem 0 0.75rem;
|
||||||
|
}
|
||||||
|
.markdown-content p {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
.markdown-content ul,
|
||||||
|
.markdown-content ol {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
.markdown-content ul {
|
||||||
|
list-style: disc;
|
||||||
|
}
|
||||||
|
.markdown-content ol {
|
||||||
|
list-style: decimal;
|
||||||
|
}
|
||||||
|
.markdown-content li {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
.markdown-content a {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.markdown-content a:hover {
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
.markdown-content strong {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user