UPD
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 611 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 594 KiB |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 780 B |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 710 B |
|
After Width: | Height: | Size: 447 B |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 763 B |
|
After Width: | Height: | Size: 836 B |
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 809 B |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 949 B |
|
After Width: | Height: | Size: 250 B |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 738 B |
|
After Width: | Height: | Size: 398 B |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 949 B |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 840 B |
|
After Width: | Height: | Size: 644 B |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 861 B |
|
After Width: | Height: | Size: 857 B |
|
Before Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 859 B |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 553 B |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 6.3 MiB |
|
After Width: | Height: | Size: 859 B |
|
After Width: | Height: | Size: 447 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 335 B |
|
After Width: | Height: | Size: 635 B |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 456 B |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 783 B |
|
After Width: | Height: | Size: 638 B |
|
After Width: | Height: | Size: 432 B |
|
After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 932 B |
|
After Width: | Height: | Size: 1.6 MiB |
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
{/* Заголовок + описание */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||