This commit is contained in:
Raphael Elita 2025-08-21 22:40:51 +02:00
parent c811cf8bba
commit fe830346eb
97 changed files with 308 additions and 157 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 763 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 949 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 949 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 840 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 857 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 335 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 635 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 783 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 932 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@ -852,7 +852,6 @@ type KeySegmentInfo {
id: ID!
title: String
order: Int
colSpan: KeySegmentInfoColSpanType
segment: KeySegment
brandLink: String
brandLogo: ImageFieldOutput
@ -860,11 +859,6 @@ type KeySegmentInfo {
itemsCount(where: KeySegmentInfoItemWhereInput! = {}): Int
}
enum KeySegmentInfoColSpanType {
ONE
TWO
}
input KeySegmentInfoWhereUniqueInput {
id: ID
}
@ -876,19 +870,11 @@ input KeySegmentInfoWhereInput {
id: IDFilter
title: StringFilter
order: IntNullableFilter
colSpan: KeySegmentInfoColSpanTypeNullableFilter
segment: KeySegmentWhereInput
brandLink: StringFilter
items: KeySegmentInfoItemManyRelationFilter
}
input KeySegmentInfoColSpanTypeNullableFilter {
equals: KeySegmentInfoColSpanType
in: [KeySegmentInfoColSpanType!]
notIn: [KeySegmentInfoColSpanType!]
not: KeySegmentInfoColSpanTypeNullableFilter
}
input KeySegmentInfoItemManyRelationFilter {
every: KeySegmentInfoItemWhereInput
some: KeySegmentInfoItemWhereInput
@ -899,14 +885,12 @@ input KeySegmentInfoOrderByInput {
id: OrderDirection
title: OrderDirection
order: OrderDirection
colSpan: OrderDirection
brandLink: OrderDirection
}
input KeySegmentInfoUpdateInput {
title: String
order: Int
colSpan: KeySegmentInfoColSpanType
segment: KeySegmentRelateToOneForUpdateInput
brandLink: String
brandLogo: ImageFieldInput
@ -934,7 +918,6 @@ input KeySegmentInfoUpdateArgs {
input KeySegmentInfoCreateInput {
title: String
order: Int
colSpan: KeySegmentInfoColSpanType
segment: KeySegmentRelateToOneForCreateInput
brandLink: String
brandLogo: ImageFieldInput
@ -955,10 +938,16 @@ type KeySegmentInfoItem {
id: ID!
text: String
infoLogo: ImageFieldOutput
colSpan: KeySegmentInfoItemColSpanType
order: Int
info: KeySegmentInfo
}
enum KeySegmentInfoItemColSpanType {
ONE
TWO
}
input KeySegmentInfoItemWhereUniqueInput {
id: ID
}
@ -969,19 +958,29 @@ input KeySegmentInfoItemWhereInput {
NOT: [KeySegmentInfoItemWhereInput!]
id: IDFilter
text: StringFilter
colSpan: KeySegmentInfoItemColSpanTypeNullableFilter
order: IntNullableFilter
info: KeySegmentInfoWhereInput
}
input KeySegmentInfoItemColSpanTypeNullableFilter {
equals: KeySegmentInfoItemColSpanType
in: [KeySegmentInfoItemColSpanType!]
notIn: [KeySegmentInfoItemColSpanType!]
not: KeySegmentInfoItemColSpanTypeNullableFilter
}
input KeySegmentInfoItemOrderByInput {
id: OrderDirection
text: OrderDirection
colSpan: OrderDirection
order: OrderDirection
}
input KeySegmentInfoItemUpdateInput {
text: String
infoLogo: ImageFieldInput
colSpan: KeySegmentInfoItemColSpanType
order: Int
info: KeySegmentInfoRelateToOneForUpdateInput
}
@ -1000,6 +999,7 @@ input KeySegmentInfoItemUpdateArgs {
input KeySegmentInfoItemCreateInput {
text: String
infoLogo: ImageFieldInput
colSpan: KeySegmentInfoItemColSpanType
order: Int
info: KeySegmentInfoRelateToOneForCreateInput
}

View File

@ -147,7 +147,6 @@ model KeySegmentInfo {
id String @id @default(cuid())
title String @default("")
order Int? @default(0)
colSpan String? @default("ONE")
segment KeySegment? @relation("KeySegmentInfo_segment", fields: [segmentId], references: [id])
segmentId String? @map("segment")
brandLink String @default("")
@ -169,6 +168,7 @@ model KeySegmentInfoItem {
infoLogo_width Int?
infoLogo_height Int?
infoLogo_extension String?
colSpan String? @default("ONE")
order Int? @default(0)
info KeySegmentInfo? @relation("KeySegmentInfoItem_info", fields: [infoId], references: [id])
infoId String? @map("info")

View File

@ -336,22 +336,51 @@ export const lists: Lists = {
many: true,
ui: {
displayMode: "cards",
cardFields: ["title", "colSpan", "order"],
inlineCreate: { fields: ["title", "colSpan", "order"] },
inlineEdit: { fields: ["title", "colSpan", "order"] },
cardFields: ["title", "order"],
inlineCreate: { fields: ["title", "order", "brandLink", "brandLogo"] },
inlineEdit: { fields: ["title", "order", "brandLink", "brandLogo"] },
linkToItem: true,
},
}),
},
}),
/* Info card (column block) */
KeySegmentInfo: list({
access: allowAll,
ui: { label: "Key Segment • Info Block", listView: { initialColumns: ["order", "title", "colSpan"] } },
ui: { label: "Key Segment • Info Block", listView: { initialColumns: ["order", "title"] } },
fields: {
title: text({ validation: { isRequired: true } }),
order: integer({ defaultValue: 0 }),
segment: relationship({ ref: "KeySegment.info" }),
// бренд принадлежит целому блоку
brandLink: text({ ui: { description: "Ссылка бренда (опц.)" } }),
brandLogo: image({ storage: 'local_images', ui: { description: "Лого бренда (опц.)" } }),
items: relationship({
ref: "KeySegmentInfoItem.info",
many: true,
ui: {
displayMode: "cards",
cardFields: ["text", "order", "infoLogo", "colSpan"],
inlineCreate: { fields: ["text", "infoLogo", "colSpan", "order"] },
inlineEdit: { fields: ["text", "infoLogo", "colSpan", "order"] },
linkToItem: true,
},
}),
},
}),
/* Info list item (только текст + лого) */
KeySegmentInfoItem: list({
access: allowAll,
ui: { label: "Key Segment • Info Item", listView: { initialColumns: ["order", "text", "infoLogo", "colSpan"] } },
fields: {
text: text({ validation: { isRequired: true } }),
infoLogo: image({ storage: 'local_images', ui: { description: "Лого в зелёном квадратике" } }),
colSpan: select({
type: 'enum',
options: [
@ -361,38 +390,12 @@ export const lists: Lists = {
defaultValue: 'ONE',
ui: { displayMode: 'segmented-control' },
}),
segment: relationship({ ref: "KeySegment.info" }),
// 🔽 теперь бренд принадлежит целому блоку
brandLink: text({ ui: { description: "Ссылка бренда (опц.)" } }),
brandLogo: image({ storage: 'local_images', ui: { description: "Лого бренда (опц.)" } }),
items: relationship({
ref: "KeySegmentInfoItem.info",
many: true,
ui: {
displayMode: "cards",
cardFields: ["text", "order", "infoLogo"],
inlineCreate: { fields: ["text", "infoLogo", "order"] },
inlineEdit: { fields: ["text", "infoLogo", "order"] },
linkToItem: true,
},
}),
},
}),
/* Info list item (только текст + лого) */
KeySegmentInfoItem: list({
access: allowAll,
ui: { label: "Key Segment • Info Item", listView: { initialColumns: ["order", "text", "infoLogo"] } },
fields: {
text: text({ validation: { isRequired: true } }),
infoLogo: image({ storage: 'local_images', ui: { description: "Лого в зелёном квадратике" } }),
order: integer({ defaultValue: 0 }),
info: relationship({ ref: "KeySegmentInfo.items" }),
},
}),
WhyChooseUsItem: list({
access: allowAll,
ui: {

View File

@ -37,6 +37,10 @@ const ContactFormSection: React.FC = () => {
const [error, setError] = React.useState<string | null>(null);
const [sent, setSent] = React.useState(false);
// состояния согласий
const [agreePrivacy, setAgreePrivacy] = React.useState(true);
const [agreeComms, setAgreeComms] = React.useState(true);
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError(null);
@ -53,10 +57,17 @@ const ContactFormSection: React.FC = () => {
const email = String(fd.get("email") || "").trim();
const phone = String(fd.get("phone") || "").trim();
// читаем согласия из формы (и дублируем защиту состояниями)
const consentPrivacy = fd.get("consentPrivacy") === "on";
const consentComms = fd.get("consentComms") === "on";
// простая валидация
if (!name) return setError("Please enter your name.");
if (!country) return setError("Please choose a country from the list.");
if (!email) return setError("Please enter your business email.");
if (!consentPrivacy || !consentComms || !agreePrivacy || !agreeComms) {
return setError("Please accept both consents.");
}
try {
setBusy(true);
@ -67,7 +78,7 @@ const ContactFormSection: React.FC = () => {
{
data: {
name,
countryIso: country,
countryCode: country,
company,
position,
email,
@ -76,28 +87,11 @@ const ContactFormSection: React.FC = () => {
}
);
// 2) шлём письмо (если у тебя есть этот эндпоинт; ошибки не блокируют успех)
try {
await fetch("/api/send-mail", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
subject: "New contact request from landing",
text:
`Name: ${name}\n` +
`Country: ${country}\n` +
`Company: ${company}\n` +
`Position: ${position}\n` +
`Email: ${email}\n` +
`Phone: ${phone}\n`,
}),
});
} catch {
// молча игнорим — форма уже сохранена в БД
}
setSent(true); // прячем форму, показываем «спасибо»
form.reset(); // чистим поля, на всякий
// на всякий возвращаем чекбоксы в true (форма уже скрыта)
setAgreePrivacy(true);
setAgreeComms(true);
} catch (err: any) {
setError(err?.message || "Failed to submit. Please try again.");
} finally {
@ -170,7 +164,13 @@ const ContactFormSection: React.FC = () => {
{/* согласия */}
<Consents>
<ConsentRow>
<Checkbox type="checkbox" id="c1" defaultChecked />
<Checkbox
type="checkbox"
id="c1"
name="consentPrivacy"
checked={agreePrivacy}
onChange={(e) => setAgreePrivacy(e.target.checked)}
/>
<label htmlFor="c1">
I accept the{" "}
<a href="/privacy" target="_blank" rel="noreferrer">
@ -181,7 +181,13 @@ const ContactFormSection: React.FC = () => {
</ConsentRow>
<ConsentRow>
<Checkbox type="checkbox" id="c2" defaultChecked />
<Checkbox
type="checkbox"
id="c2"
name="consentComms"
checked={agreeComms}
onChange={(e) => setAgreeComms(e.target.checked)}
/>
<label htmlFor="c2">
I confirm my consent to receive information from the company via
the provided contact methods of Email and/or Telephone
@ -190,7 +196,7 @@ const ContactFormSection: React.FC = () => {
</Consents>
<Actions>
<SubmitButton type="submit" disabled={busy}>
<SubmitButton type="submit" disabled={busy || !agreePrivacy || !agreeComms}>
{busy ? "Sending…" : "Submit a request"}
</SubmitButton>
</Actions>

View File

@ -20,6 +20,15 @@ const GEO_URL = "/data/countries-110m.json";
const CARD_W = 290;
const CARD_H = 160;
const MIN_ZOOM = 0.8;
const MAX_ZOOM = 8;
const INITIAL_ZOOM = 4;
const ZOOM_STEP = 1.25;
const clamp = (n: number, min: number, max: number) =>
Math.min(max, Math.max(min, n));
/* =================== CMS types/selection =================== */
type CmsItem = { id: string; text: string; order: number };
type CmsCountry = {
@ -110,6 +119,33 @@ function mapCountries(section?: CmsSection | null): CountryCard[] {
});
}
function updateScaleKeepCenter(newScale: number) {
const g = document.querySelector<SVGGElement>("g.rsm-zoomable-group");
const svg = g?.closest("svg") as SVGSVGElement | null;
if (!g || !svg) return;
const transform = g.getAttribute("transform") || "";
const matchT = transform.match(/translate\((-?\d+\.?\d*)[, ]+(-?\d+\.?\d*)\)/);
const matchS = transform.match(/scale\((-?\d+\.?\d*)\)/);
let tx = matchT ? parseFloat(matchT[1]) : 0;
let ty = matchT ? parseFloat(matchT[2]) : 0;
const currentScale = matchS ? parseFloat(matchS[1]) : 1;
// центр SVG
const cx = svg.clientWidth / 2;
const cy = svg.clientHeight / 2;
// пересчёт translate
const ratio = newScale / currentScale;
tx = cx - (cx - tx) * ratio;
ty = cy - (cy - ty) * ratio;
g.setAttribute("transform", `translate(${tx} ${ty}) scale(${newScale})`);
}
/* =================== component =================== */
export default function CountriesMap() {
const wrapRef = useRef<HTMLDivElement>(null);
@ -142,7 +178,20 @@ export default function CountriesMap() {
const initialCenter: [number, number] = mobile ? [65, 38] : [52, 38];
const [center, setCenter] = useState<[number, number]>(initialCenter);
const [zoom, setZoom] = useState(4);
const [zoom, setZoom] = useState(INITIAL_ZOOM);
const [zoomScale, setZoomScale] = useState(INITIAL_ZOOM);
useEffect(() => {
updateScaleKeepCenter(zoomScale);
},[zoomScale]);
const zoomIn = () => setZoomScale(z => clamp(z * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM));
const zoomOut = () => setZoomScale(z => clamp(z / ZOOM_STEP, MIN_ZOOM, MAX_ZOOM));
const resetView = () => {
setCenter(initialCenter);
setZoom(INITIAL_ZOOM);
};
return (
<Wrap ref={wrapRef}>
@ -161,6 +210,7 @@ export default function CountriesMap() {
minZoom={0.8}
maxZoom={8}
translateExtent={[[-1000, -500], [1000, 900]]}
filterZoomEvent={(e) => e.type !== "wheel" && e.type !== "dblclick"}
onMoveEnd={() => {
setCenter(initialCenter);
setZoom(zoom);
@ -306,6 +356,12 @@ export default function CountriesMap() {
})}
</ZoomableGroup>
</ComposableMap>
<ZoomControls>
<ZoomBtn onClick={zoomIn} aria-label="Zoom in">+</ZoomBtn>
<ZoomBtn onClick={zoomOut} aria-label="Zoom out"></ZoomBtn>
<ZoomBtn onClick={resetView} aria-label="Reset"></ZoomBtn>
</ZoomControls>
</Wrap>
);
}
@ -318,3 +374,31 @@ const Wrap = styled.div`
overflow: hidden;
width: 100%;
`;
const ZoomControls = styled.div`
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
z-index: 2;
pointer-events: auto;
flex-direction: column;
`;
const ZoomBtn = styled.button`
width: 36px;
height: 36px;
border-radius: 10px;
background: #fff;
border: 1px solid #14935f;
box-shadow: 0px 8px 14.13px 0px #2764698f;
font-weight: 700;
line-height: 0;
display: grid;
place-items: center;
cursor: pointer;
user-select: none;
&:active { transform: translateY(1px); }
`;

View File

@ -68,8 +68,15 @@ const FooterSection: React.FC = () => {
<Inner>
<Top>
<Left>
<LogoRow>
<Logo src={logoSrc} alt="Logo" />
<LogoRow >
<Logo
onClick={() => {
window.location.href = "/#freedom";
}}
src={logoSrc}
alt="Logo"
/>
</LogoRow>
<Nav>
@ -143,6 +150,9 @@ const Wrap = styled.footer`
background: #1d2023;
color: #ffffff;
padding: 64px 40px;
@media (max-width: 700px) {
padding: 32px 40px;
}
`;
const Inner = styled.div`
@ -155,7 +165,6 @@ const Top = styled.div`
`;
const Left = styled.div`
max-width: 610px;
`;
const LogoRow = styled.div`

View File

@ -89,14 +89,34 @@ const Submenu = styled.div`
}
`;
const Burger = styled.button`
const Burger = styled.button<{ open: boolean }>`
display: none;
@media (max-width: 768px) { display:flex; }
width: 24px; height: 18px; flex-direction: column; justify-content: space-between; cursor:pointer; margin-left:12px;
width: 24px; height: 18px;
flex-direction: column; justify-content: space-between;
cursor:pointer; margin-left:12px;
background: transparent; border: 0;
span { display:block; height:3px; border-radius:2px; background:#1d2023; }
span {
display:block;
height:3px; width:100%;
border-radius:3px;
background:#1d2023;
transition: all 0.3s ease;
}
span:nth-child(1) {
transform: ${({ open }) => open ? "rotate(45deg) translate(5px, 5px)" : "none"};
}
span:nth-child(2) {
opacity: ${({ open }) => open ? 0 : 1};
}
span:nth-child(3) {
transform: ${({ open }) => open ? "rotate(-45deg) translate(5px, -5px)" : "none"};
}
`;
const MobileMenu = styled.div<{ open: boolean }>`
position: absolute; top: 48px; right: 0; width: 100%;
background:#fff; box-shadow: 0 2px 10px rgba(0,0,0,0.1);
@ -165,7 +185,7 @@ function Header() {
<HeaderPlaceholder />
<HeaderBlockOver>
<HeaderBlock>
<img src={logoSrc} style={{ marginRight: "auto", height: "28px" }} alt={system?.siteTitle || "Logo"} />
<img onClick={() => handleGo("/#freedom")} src={logoSrc} style={{ marginRight: "auto", height: "28px" }} alt={system?.siteTitle || "Logo"} />
{/* Desktop menu */}
<Menu>
@ -199,7 +219,7 @@ function Header() {
</Button>
{/* Burger */}
<Burger onClick={() => setOpen(v => !v)} aria-label="Menu">
<Burger open={open} onClick={() => setOpen(v => !v)} aria-label="Menu">
<span /><span /><span />
</Burger>

View File

@ -3,7 +3,7 @@ import React from "react";
import styled from "styled-components";
import {
s_Text40_700 as Text40_700,
s_Text24_700 as Text24_700,
Text24_700 as Text24_700,
s_f_Text18_400 as Text18_400,
} from "../Typography";
import { useSingleton } from "cms/factory";

View File

@ -4,7 +4,8 @@ import styled, { css } from "styled-components";
import {
s_Text40_700 as Text40_700,
s_Text24_700 as Text24_700,
s_f_Text18_400 as Text18_400,
Text18_400 as Text18_400_dom,
Text24_700 as Text24_700_dom,
} from "../Typography";
import { useSingleton } from "cms/factory";
import fallbackLogo from "imgs/logo.svg";
@ -15,6 +16,7 @@ type CmsItem = {
text: string;
order: number;
infoLogo?: { url?: string | null } | null;
colSpan?: "ONE" | "TWO" | "1" | "2" | 1 | 2 | null;
};
type CmsInfo = {
@ -50,17 +52,17 @@ const SELECTION = `
id key name order
logo { url }
info(orderBy: { order: asc }) {
id title order colSpan
id title order
brandLink
brandLogo { url }
items(orderBy: { order: asc }) {
id text order infoLogo { url }
id text order colSpan infoLogo { url }
}
}
}
`;
const toSpan = (v: CmsInfo["colSpan"]) =>
const toSpan = (v: CmsItem["colSpan"]) =>
v === "TWO" || v === "2" || v === 2 ? 2 : 1;
/* ===== View-model mapping ===== */
@ -73,13 +75,13 @@ function mapSegments(s?: CmsSection | null) {
info: (seg.info ?? []).map((block) => ({
id: block.id,
title: block.title,
span: toSpan(block.colSpan),
brandLink: block.brandLink ?? undefined,
brandLogo: block.brandLogo?.url ?? undefined,
items: (block.items ?? []).map((it) => ({
id: it.id,
text: it.text,
infoLogo: it.infoLogo?.url ?? undefined,
span: toSpan(it.colSpan), // ← ТУТ ВАЖНО
})),
})),
}));
@ -164,6 +166,7 @@ const LogoCard = styled.div<{ $active: boolean }>`
transition: flex-basis 220ms ease, height 220ms ease, box-shadow 220ms ease,
transform 220ms ease;
${({ $active }) => ($active ? css`box-shadow: ${CARD_SHADOW};` : css`box-shadow: none;`)}
margin: 0 auto;
@media (max-width: 768px) {
flex: 0 0
@ -189,12 +192,11 @@ const LogoImg = styled.div<{ $active: boolean }>`
`;
const InfoGrid = styled.div`
display: grid;
padding: 0 10px;
grid-template-columns: 1fr 1fr;
gap: 24px 32px;
column-count: 2; /* ← две колонки */
column-gap: 32px; /* ← горизонтальный зазор между колонками */
@media (max-width: 900px) {
grid-template-columns: 1fr;
column-count: 1;
}
`;
@ -204,32 +206,46 @@ const InfoGridSizer = styled.div<{ $minh: number }>`
min-height: ${({ $minh }) => ($minh ? `${$minh}px` : "0px")};
`;
const InfoCard = styled.div<{ $span: number }>`
// карточка должна быть «неделимой» и вести себя как блочный элемент в колонке
const InfoCard = styled.div`
display: inline-block; /* ← важно для columns */
width: 100%;
margin: 0 0 24px; /* ← вертикальный зазор между карточками */
break-inside: avoid; /* стандарт */
-webkit-column-break-inside: avoid; /* сафари/старые вебкиты */
-moz-column-break-inside: avoid;
background: #fff;
border-radius: 16px;
box-shadow: ${CARD_SHADOW};
padding: 24px;
grid-column: span ${({ $span }) => Math.max(1, Math.min(2, $span))};
@media (max-width: 900px) {
grid-column: span 1;
}
`;
const InfoTitleRow = styled.div`
display: flex;
align-items: center;
align-items: start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
`;
const Bullets = styled.ul`
margin: 0;
padding-left: 0;
list-style: none;
margin-top: 24px;
display: grid;
gap: 10px;
grid-template-columns: 1fr 1fr; /* 2 колонки */
gap: 16px 24px;
@media (max-width: 900px) {
grid-template-columns: 1fr; /* на мобилке одна колонка */
}
`;
// li с поддержкой span
const BulletItem = styled.li<{ $span: number }>`
grid-column: span ${({ $span }) => Math.min(2, Math.max(1, $span))};
`;
const CheckDot = styled.div`
@ -399,7 +415,7 @@ export default function KeyBusinessSegments() {
title={seg.name}
>
<LogoImg $active={isActive}>
<img width="100%" height="100%" src={seg.logoSrc} alt={seg.name} />
<img height="100%" style={{maxHeight: '100%'}} src={seg.logoSrc} alt={seg.name} />
</LogoImg>
</LogoCard>
);
@ -412,9 +428,9 @@ export default function KeyBusinessSegments() {
{activeSegment ? (
<InfoGrid>
{activeSegment.info.map((block) => (
<InfoCard key={block.id} $span={block.span}>
<InfoCard key={block.id}>
<InfoTitleRow>
<Text24_700>{block.title}</Text24_700>
<Text24_700_dom>{block.title}</Text24_700_dom>
{block.brandLogo ? (
<a
href={block.brandLink || "#"}
@ -422,29 +438,30 @@ export default function KeyBusinessSegments() {
rel="noreferrer"
style={{ display: "inline-flex", alignItems: "center" }}
>
<img src={block.brandLogo} alt="brand" style={{ height: 24 }} />
<img src={block.brandLogo} alt="brand" style={{ height: 32 }} />
</a>
) : null}
</InfoTitleRow>
<Bullets>
{block.items.map((item) => (
<li key={item.id}>
<ItemRow>
<CheckDot
style={{
backgroundImage: item.infoLogo
? `url(${item.infoLogo})`
: undefined,
}}
/>
<Text18_400 style={{ marginRight: item.infoLogo ? 10 : 0 }}>
{item.text}
</Text18_400>
</ItemRow>
</li>
))}
</Bullets>
{block.items?.length > 0 && (
<Bullets>
{block.items.map((item) => (
<BulletItem key={item.id} $span={item.span}>
<ItemRow>
<CheckDot
style={{
backgroundImage: item.infoLogo ? `url(${item.infoLogo})` : undefined,
}}
/>
<Text18_400_dom
style={{ textAlign: "left", marginRight: item.infoLogo ? 10 : 0 }}
>
{item.text}
</Text18_400_dom>
</ItemRow>
</BulletItem>
))}
</Bullets>
)}
</InfoCard>
))}
</InfoGrid>
@ -503,29 +520,33 @@ function HiddenSizer({
{segments.map((seg, i) => (
<InfoGrid key={`sizer-${seg.id}`} ref={(el) => { refs.current[i] = el; }}>
{seg.info.map((block) => (
<InfoCard key={block.id} $span={block.span}>
<InfoCard key={block.id}>
<InfoTitleRow>
<Text24_700>{block.title}</Text24_700>
<Text24_700_dom>{block.title}</Text24_700_dom>
{block.brandLogo ? (
<img src={block.brandLogo} alt="" style={{ height: 24 }} />
<img src={block.brandLogo} alt="" style={{ height: 32 }} />
) : null}
</InfoTitleRow>
<Bullets>
{block.items.map((item) => (
<li key={item.id}>
<ItemRow>
<CheckDot
style={{
backgroundImage: item.infoLogo
? `url(${item.infoLogo})`
: undefined,
}}
/>
<Text18_400>{item.text}</Text18_400>
</ItemRow>
</li>
))}
</Bullets>
{block.items?.length > 0 && (
<Bullets>
{block.items.map((item) => (
<BulletItem key={item.id} $span={item.span}>
<ItemRow>
<CheckDot
style={{
backgroundImage: item.infoLogo ? `url(${item.infoLogo})` : undefined,
}}
/>
<Text18_400_dom
style={{ textAlign: "left", marginRight: item.infoLogo ? 10 : 0 }}
>
{item.text}
</Text18_400_dom>
</ItemRow>
</BulletItem>
))}
</Bullets>
)}
</InfoCard>
))}
</InfoGrid>

View File

@ -64,9 +64,23 @@ const MobileButton = styled(Button)`
left: 16px;
right: 16px;
width: calc(100% - 32px);
opacity: 0; /* изначально невидим */
animation: fadeIn 1s ease forwards;
animation-delay: 1s; /* первая секунда — скрыт */
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
`;
function ScreenFirst() {
const { data: hero, loading, error } = useSingleton<Hero>(
"HeroSection",

View File

@ -183,7 +183,7 @@ function ScreenSecond() {
const logoSrc = section?.logo?.url || fallbackLogo;
return (
<ScreenSecondBlock>
<ScreenSecondBlock id="holding">
<ContentCard>
<AsyncReveal loading={loading} error={error} stagger={0.08} from={12}>
{/* Header */}

View File

@ -145,7 +145,7 @@ const StatCardRow = styled.div`
`;
const StatCardBox = styled.div`
background: #f9fafb;
background: #F5F8FB;
border-radius: 12px;
padding: 33px 32px;
text-align: left;
@ -190,7 +190,7 @@ function ScreenThree() {
const indexes: IndexItem[] = s3?.indexes ?? [];
return (
<ScreenSecondBlock>
<ScreenSecondBlock id="business_areas">
<ContentCard>
<AsyncReveal loading={loading} error={error} stagger={0.08} from={12}>
{/* Заголовок + описание */}

View File

@ -226,7 +226,7 @@ export default function SolutionsSection({
};
return (
<Section>
<Section id="solutions">
<Header>
<Text40_700>{section?.title || "Solutions"}</Text40_700>
<Controls>
@ -270,7 +270,7 @@ export default function SolutionsSection({
<CardLink href={it.href}>
<Text18_400
as="span"
style={{ textDecoration: "underline" }}
style={{ textDecoration: "underline", color:'#fff' }}
>
Learn more
</Text18_400>

View File

@ -40,7 +40,6 @@ export const Text14_500 = styled.p`
leading-trim: NONE;
line-height: 16px;
letter-spacing: 0%;
text-align: center;
vertical-align: middle;
}
`;
@ -80,7 +79,6 @@ export const Text52_700 = styled.p`
leading-trim: NONE;
line-height: 38px;
letter-spacing: 0%;
text-align: center;
}
`;
@ -112,7 +110,6 @@ export const Text18_500 = styled.p`
leading-trim: NONE;
line-height: 20px;
letter-spacing: 0%;
text-align: center;
vertical-align: middle;
}
`;
@ -139,7 +136,6 @@ export const Text24_700 = styled.p`
leading-trim: NONE;
line-height: 28px;
letter-spacing: 0%;
text-align: center;
vertical-align: bottom;
@ -176,9 +172,8 @@ export const s_f_Text18_400 = styled.p`
font-style: Regular;
font-size: 16px;
leading-trim: NONE;
line-height: 24px;
line-height: 20px;
letter-spacing: 0%;
text-align: center;
}
`;
@ -209,7 +204,6 @@ export const s_Text24_700 = styled.p`
leading-trim: NONE;
line-height: 24px;
letter-spacing: 0%;
text-align: center;
vertical-align: bottom;
}
`;
@ -245,7 +239,6 @@ export const s_Text18_400 = styled.p`
leading-trim: NONE;
line-height: 20px;
letter-spacing: 0%;
text-align: center;
}
`;

View File

@ -66,7 +66,7 @@ const WhyChooseUs: React.FC<WhyChooseUsProps> = ({
(data?.items?.length ? data.items.map((i) => i.text) : items) ?? [];
return (
<Section>
<Section id="why_choose_us">
<AsyncReveal loading={loading} error={error}>
<TitleRow>
<Text40_700>{uiTitle}</Text40_700>

View File

@ -51,6 +51,7 @@ declare module "react-simple-maps" {
onMoveStart?: (pos: { coordinates: [number, number]; zoom: number }) => void;
onMove?: (pos: { coordinates: [number, number]; zoom: number }) => void; // ← добавили
onMoveEnd?: (pos: { coordinates: [number, number]; zoom: number }) => void;
filterZoomEvent?: (e: any) => boolean;
}
export const ZoomableGroup: React.FC<ZoomableGroupProps>;
}