diff --git a/scripts/seed-strapi-site-config.mjs b/scripts/seed-strapi-site-config.mjs index afbd218..277c857 100644 --- a/scripts/seed-strapi-site-config.mjs +++ b/scripts/seed-strapi-site-config.mjs @@ -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; } diff --git a/src/content.config.ts b/src/content.config.ts index be12ce4..35873ec 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -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(), }), }) diff --git a/src/env.d.ts b/src/env.d.ts index 3c6a868..b5df4da 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -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 { diff --git a/src/graphql-documents/privacy-policy.gql.generated.ts b/src/graphql-documents/privacy-policy.gql.generated.ts new file mode 100644 index 0000000..f62931f --- /dev/null +++ b/src/graphql-documents/privacy-policy.gql.generated.ts @@ -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> { + return execute(privacyPolicyDocument, variables); + }, +}; diff --git a/src/graphql-documents/privacy-policy.gql.ts b/src/graphql-documents/privacy-policy.gql.ts new file mode 100644 index 0000000..429ac1f --- /dev/null +++ b/src/graphql-documents/privacy-policy.gql.ts @@ -0,0 +1,10 @@ +import gql from 'graphql-tag' + +export const privacyPolicyDocument = gql` + query PrivacyPolicy { + siteConfig { + title + privacyPolicy + } + } +` diff --git a/src/graphql-documents/site-config.gql.ts b/src/graphql-documents/site-config.gql.ts index 407dbe5..deba5f5 100644 --- a/src/graphql-documents/site-config.gql.ts +++ b/src/graphql-documents/site-config.gql.ts @@ -11,12 +11,6 @@ export const siteConfigDocument = gql` value displayValue } - privacyPolicy { - url - } - termsOfUse { - url - } } } ` diff --git a/src/graphql-documents/terms-of-use.gql.generated.ts b/src/graphql-documents/terms-of-use.gql.generated.ts new file mode 100644 index 0000000..8975df7 --- /dev/null +++ b/src/graphql-documents/terms-of-use.gql.generated.ts @@ -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> { + return execute(termsOfUseDocument, variables); + }, +}; diff --git a/src/graphql-documents/terms-of-use.gql.ts b/src/graphql-documents/terms-of-use.gql.ts new file mode 100644 index 0000000..eb9b48e --- /dev/null +++ b/src/graphql-documents/terms-of-use.gql.ts @@ -0,0 +1,10 @@ +import gql from 'graphql-tag' + +export const termsOfUseDocument = gql` + query TermsOfUse { + siteConfig { + title + termsOfUse + } + } +` diff --git a/src/graphql/graphql.ts b/src/graphql/graphql.ts index 9370e2e..c1260cf 100644 --- a/src/graphql/graphql.ts +++ b/src/graphql/graphql.ts @@ -1213,9 +1213,9 @@ export type SiteConfig = { createdAt?: Maybe; description?: Maybe; documentId: Scalars['ID']['output']; - privacyPolicy?: Maybe; + privacyPolicy?: Maybe; publishedAt?: Maybe; - termsOfUse?: Maybe; + termsOfUse?: Maybe; title: Scalars['String']['output']; updatedAt?: Maybe; }; @@ -1230,9 +1230,9 @@ export type SiteConfigContactsArgs = { export type SiteConfigInput = { contacts?: InputMaybe>>; description?: InputMaybe; - privacyPolicy?: InputMaybe; + privacyPolicy?: InputMaybe; publishedAt?: InputMaybe; - termsOfUse?: InputMaybe; + termsOfUse?: InputMaybe; title?: InputMaybe; }; @@ -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 extends String @@ -1789,12 +1799,22 @@ export const SiteConfigDocument = new TypedDocumentString(` value displayValue } - privacyPolicy { - url - } - termsOfUse { - url - } } } - `) as unknown as TypedDocumentString; \ No newline at end of file + `) as unknown as TypedDocumentString; +export const PrivacyPolicyDocument = new TypedDocumentString(` + query PrivacyPolicy { + siteConfig { + title + privacyPolicy + } +} + `) as unknown as TypedDocumentString; +export const TermsOfUseDocument = new TypedDocumentString(` + query TermsOfUse { + siteConfig { + title + termsOfUse + } +} + `) as unknown as TypedDocumentString; \ No newline at end of file diff --git a/src/layouts/DocumentLayout.astro b/src/layouts/DocumentLayout.astro new file mode 100644 index 0000000..33b6ebe --- /dev/null +++ b/src/layouts/DocumentLayout.astro @@ -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 +--- + + +
+
+ +
+
+

{pageTitle}

+
+
+
+
+ + diff --git a/src/lib/bitrix24/leads.ts b/src/lib/bitrix24/leads.ts new file mode 100644 index 0000000..f908f2f --- /dev/null +++ b/src/lib/bitrix24/leads.ts @@ -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//`). 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 { + 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 +} diff --git a/src/lib/cms/markdown/render.ts b/src/lib/cms/markdown/render.ts new file mode 100644 index 0000000..9ea05fa --- /dev/null +++ b/src/lib/cms/markdown/render.ts @@ -0,0 +1,21 @@ +import { createMarkdownProcessor } from '@astrojs/markdown-remark' + +type MarkdownProcessor = Awaited> + +// 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 | null = null + +function getProcessor(): Promise { + 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 { + const processor = await getProcessor() + const { code } = await processor.render(markdown) + return code +} diff --git a/src/lib/contact/submission.ts b/src/lib/contact/submission.ts new file mode 100644 index 0000000..aa0869f --- /dev/null +++ b/src/lib/contact/submission.ts @@ -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 + + 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 +} diff --git a/src/pages/[slug].astro b/src/pages/[slug].astro index e861c15..898ac90 100644 --- a/src/pages/[slug].astro +++ b/src/pages/[slug].astro @@ -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} /> diff --git a/src/pages/api/contact.ts b/src/pages/api/contact.ts index 7b982f9..b432165 100644 --- a/src/pages/api/contact.ts +++ b/src/pages/api/contact.ts @@ -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 + 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]', { - receivedAt: new Date().toISOString(), - data, - }) + 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(), + 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) } diff --git a/src/pages/index.astro b/src/pages/index.astro index 579d393..dc0820e 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -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} /> diff --git a/src/pages/privacy-policy.astro b/src/pages/privacy-policy.astro new file mode 100644 index 0000000..59b28b7 --- /dev/null +++ b/src/pages/privacy-policy.astro @@ -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) : '' +--- + + diff --git a/src/pages/terms-of-use.astro b/src/pages/terms-of-use.astro new file mode 100644 index 0000000..a90959e --- /dev/null +++ b/src/pages/terms-of-use.astro @@ -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) : '' +--- + + diff --git a/src/sections/Footer.tsx b/src/sections/Footer.tsx index f57d31e..3ee358e 100644 --- a/src/sections/Footer.tsx +++ b/src/sections/Footer.tsx @@ -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 (