chore: update types of privacy policy and terms of use, connect to crm
This commit is contained in:
@@ -38,10 +38,9 @@ function buildInput(source) {
|
||||
displayValue: contact.displayValue,
|
||||
})),
|
||||
};
|
||||
// privacyPolicy / termsOfUse are relations (single types) referenced by id.
|
||||
// They are seeded separately; only forward them when an id is provided.
|
||||
if (source.privacyPolicy?.id) input.privacyPolicy = source.privacyPolicy.id;
|
||||
if (source.termsOfUse?.id) input.termsOfUse = source.termsOfUse.id;
|
||||
// privacyPolicy / termsOfUse are markdown richtext strings.
|
||||
if (source.privacyPolicy != null) input.privacyPolicy = source.privacyPolicy;
|
||||
if (source.termsOfUse != null) input.termsOfUse = source.termsOfUse;
|
||||
return input;
|
||||
}
|
||||
|
||||
|
||||
@@ -155,8 +155,8 @@ const siteConfig = defineCollection({
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
contacts: z.array(contactSchema),
|
||||
privacyPolicy: z.object({ url: z.string() }).nullable(),
|
||||
termsOfUse: z.object({ url: z.string() }).nullable(),
|
||||
privacyPolicy: z.string().nullable(),
|
||||
termsOfUse: z.string().nullable(),
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
1
src/env.d.ts
vendored
1
src/env.d.ts
vendored
@@ -4,6 +4,7 @@ interface ImportMetaEnv {
|
||||
readonly PUBLIC_API_URL: string
|
||||
readonly PUBLIC_GRAPHQL_URL: string
|
||||
readonly PUBLIC_UPLOADS_BASE_URL: string
|
||||
readonly BITRIX24_WEBHOOK_URL?: string
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
10
src/graphql-documents/privacy-policy.gql.ts
Normal file
10
src/graphql-documents/privacy-policy.gql.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export const privacyPolicyDocument = gql`
|
||||
query PrivacyPolicy {
|
||||
siteConfig {
|
||||
title
|
||||
privacyPolicy
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -11,12 +11,6 @@ export const siteConfigDocument = gql`
|
||||
value
|
||||
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);
|
||||
},
|
||||
};
|
||||
10
src/graphql-documents/terms-of-use.gql.ts
Normal file
10
src/graphql-documents/terms-of-use.gql.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export const termsOfUseDocument = gql`
|
||||
query TermsOfUse {
|
||||
siteConfig {
|
||||
title
|
||||
termsOfUse
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -1213,9 +1213,9 @@ export type SiteConfig = {
|
||||
createdAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
description?: Maybe<Scalars['String']['output']>;
|
||||
documentId: Scalars['ID']['output'];
|
||||
privacyPolicy?: Maybe<UploadFile>;
|
||||
privacyPolicy?: Maybe<Scalars['String']['output']>;
|
||||
publishedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
termsOfUse?: Maybe<UploadFile>;
|
||||
termsOfUse?: Maybe<Scalars['String']['output']>;
|
||||
title: Scalars['String']['output'];
|
||||
updatedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
};
|
||||
@@ -1230,9 +1230,9 @@ export type SiteConfigContactsArgs = {
|
||||
export type SiteConfigInput = {
|
||||
contacts?: InputMaybe<Array<InputMaybe<ComponentSharedContactInput>>>;
|
||||
description?: InputMaybe<Scalars['String']['input']>;
|
||||
privacyPolicy?: InputMaybe<Scalars['ID']['input']>;
|
||||
privacyPolicy?: InputMaybe<Scalars['String']['input']>;
|
||||
publishedAt?: InputMaybe<Scalars['DateTime']['input']>;
|
||||
termsOfUse?: InputMaybe<Scalars['ID']['input']>;
|
||||
termsOfUse?: 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 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, privacyPolicy?: string | null } | null };
|
||||
|
||||
export type TermsOfUseQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type TermsOfUseQuery = { __typename?: 'Query', siteConfig?: { __typename?: 'SiteConfig', title: string, termsOfUse?: string | null } | null };
|
||||
|
||||
export class TypedDocumentString<TResult, TVariables>
|
||||
extends String
|
||||
@@ -1789,12 +1799,22 @@ export const SiteConfigDocument = new TypedDocumentString(`
|
||||
value
|
||||
displayValue
|
||||
}
|
||||
privacyPolicy {
|
||||
url
|
||||
}
|
||||
termsOfUse {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
`) as unknown as TypedDocumentString<SiteConfigQuery, SiteConfigQueryVariables>;
|
||||
export const PrivacyPolicyDocument = new TypedDocumentString(`
|
||||
query PrivacyPolicy {
|
||||
siteConfig {
|
||||
title
|
||||
privacyPolicy
|
||||
}
|
||||
}
|
||||
`) as unknown as TypedDocumentString<PrivacyPolicyQuery, PrivacyPolicyQueryVariables>;
|
||||
export const TermsOfUseDocument = new TypedDocumentString(`
|
||||
query TermsOfUse {
|
||||
siteConfig {
|
||||
title
|
||||
termsOfUse
|
||||
}
|
||||
}
|
||||
`) as unknown as TypedDocumentString<TermsOfUseQuery, TermsOfUseQueryVariables>;
|
||||
46
src/layouts/DocumentLayout.astro
Normal file
46
src/layouts/DocumentLayout.astro
Normal file
@@ -0,0 +1,46 @@
|
||||
---
|
||||
import Layout from './Layout.astro'
|
||||
import '../styles/globals.css'
|
||||
|
||||
interface Props {
|
||||
siteTitle: string
|
||||
pageTitle: string
|
||||
contentHtml: string
|
||||
}
|
||||
|
||||
const { siteTitle, pageTitle, contentHtml } = Astro.props
|
||||
---
|
||||
|
||||
<Layout title={`${pageTitle} | ${siteTitle}`}>
|
||||
<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>
|
||||
139
src/lib/bitrix24/leads.ts
Normal file
139
src/lib/bitrix24/leads.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
const timeline = trimmed(submission.timeline)
|
||||
if (timeline) fields[UF_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
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { siteConfigQuery } from '../graphql-documents/site-config.gql.generated'
|
||||
import { menuItemsQuery } from '../graphql-documents/menu-items.gql.generated'
|
||||
import { categoryQuery } from '../graphql-documents/category.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'
|
||||
|
||||
const [siteResult, menuItemsResult, homeResult] = await Promise.all([
|
||||
@@ -28,8 +27,6 @@ const siteConfig = siteResult.data?.siteConfig
|
||||
const siteTitle = siteConfig?.title ?? ''
|
||||
const siteDescription = siteConfig?.description ?? ''
|
||||
const contacts = siteConfig?.contacts?.filter(c => c != null) ?? []
|
||||
const privacyPolicyUrl = resolveUploadUrl(siteConfig?.privacyPolicy?.url)
|
||||
const termsOfUseUrl = resolveUploadUrl(siteConfig?.termsOfUse?.url)
|
||||
if (menuItemsResult.errors?.length) {
|
||||
throw new Error(menuItemsResult.errors.map(e => e.message).join(', '))
|
||||
}
|
||||
@@ -79,8 +76,6 @@ const vendors = (category.vendors?.filter(v => v != null) ?? []).map(vendor => (
|
||||
siteTitle={siteTitle}
|
||||
contacts={contacts}
|
||||
solutionCategories={solutionCategories}
|
||||
privacyPolicyUrl={privacyPolicyUrl}
|
||||
termsOfUseUrl={termsOfUseUrl}
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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.
|
||||
export const prerender = false
|
||||
|
||||
@@ -14,18 +17,32 @@ function json(body: unknown, status: number): Response {
|
||||
}
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
let data: Record<string, unknown>
|
||||
let body: unknown
|
||||
try {
|
||||
data = await request.json()
|
||||
body = await request.json()
|
||||
} catch {
|
||||
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.
|
||||
console.info('[contact]', {
|
||||
const data = parseContactSubmission(body)
|
||||
if (!data) {
|
||||
return json({ ok: false, error: 'Invalid submission' }, 400)
|
||||
}
|
||||
|
||||
|
||||
console.log(data)
|
||||
|
||||
try {
|
||||
const leadId = await createBitrixLead(data)
|
||||
console.info('[contact] lead created', {
|
||||
receivedAt: new Date().toISOString(),
|
||||
data,
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Enum_Category_Group } from '../graphql/graphql'
|
||||
import { menuItemsQuery } from '../graphql-documents/menu-items.gql.generated'
|
||||
import { homePageQuery } from '../graphql-documents/home-page.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'
|
||||
|
||||
const [homeResult, siteResult, menuItemsResult] = await Promise.all([
|
||||
@@ -32,8 +31,6 @@ const siteConfig = siteResult.data?.siteConfig
|
||||
const siteTitle = siteConfig?.title ?? ''
|
||||
const siteDescription = siteConfig?.description ?? ''
|
||||
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(
|
||||
(a, b) => a.sortOrder - b.sortOrder
|
||||
)
|
||||
@@ -52,8 +49,6 @@ const sectionAnchors = getHomeSectionAnchors(homePage)
|
||||
siteTitle={siteTitle}
|
||||
contacts={contacts}
|
||||
solutionCategories={solutionCategories}
|
||||
privacyPolicyUrl={privacyPolicyUrl}
|
||||
termsOfUseUrl={termsOfUseUrl}
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
20
src/pages/privacy-policy.astro
Normal file
20
src/pages/privacy-policy.astro
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
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 ?? ''}
|
||||
pageTitle="Политика конфиденциальности"
|
||||
contentHtml={contentHtml}
|
||||
/>
|
||||
20
src/pages/terms-of-use.astro
Normal file
20
src/pages/terms-of-use.astro
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
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 ?? ''}
|
||||
pageTitle="Условия использования"
|
||||
contentHtml={contentHtml}
|
||||
/>
|
||||
@@ -10,16 +10,12 @@ export interface FooterProps {
|
||||
siteTitle?: string
|
||||
contacts?: readonly SiteConfigContact[]
|
||||
solutionCategories?: CategoryNavItem[]
|
||||
privacyPolicyUrl?: string
|
||||
termsOfUseUrl?: string
|
||||
}
|
||||
|
||||
export function Footer({
|
||||
siteTitle = '',
|
||||
contacts = [],
|
||||
solutionCategories = [],
|
||||
privacyPolicyUrl,
|
||||
termsOfUseUrl,
|
||||
}: FooterProps) {
|
||||
return (
|
||||
<footer className="bg-gray-900 text-gray-400 pt-20 pb-10 border-t border-gray-800">
|
||||
@@ -117,26 +113,18 @@ export function Footer({
|
||||
<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 className="flex gap-6">
|
||||
{privacyPolicyUrl ? (
|
||||
<a
|
||||
href={privacyPolicyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="/privacy-policy"
|
||||
className="hover:text-blue-400 transition-colors"
|
||||
>
|
||||
Политика конфиденциальности
|
||||
</a>
|
||||
) : null}
|
||||
{termsOfUseUrl ? (
|
||||
<a
|
||||
href={termsOfUseUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="/terms-of-use"
|
||||
className="hover:text-blue-400 transition-colors"
|
||||
>
|
||||
Условия использования
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -200,3 +200,49 @@
|
||||
html {
|
||||
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