UPD
|
Before Width: | Height: | Size: 611 B |
|
After Width: | Height: | Size: 883 B |
|
|
@ -1597,6 +1597,7 @@ type ContactRequest {
|
||||||
id: ID!
|
id: ID!
|
||||||
name: String
|
name: String
|
||||||
company: String
|
company: String
|
||||||
|
message: String
|
||||||
position: String
|
position: String
|
||||||
email: String
|
email: String
|
||||||
phone: String
|
phone: String
|
||||||
|
|
@ -1615,6 +1616,7 @@ input ContactRequestWhereInput {
|
||||||
id: IDFilter
|
id: IDFilter
|
||||||
name: StringFilter
|
name: StringFilter
|
||||||
company: StringFilter
|
company: StringFilter
|
||||||
|
message: StringFilter
|
||||||
position: StringFilter
|
position: StringFilter
|
||||||
email: StringFilter
|
email: StringFilter
|
||||||
phone: StringFilter
|
phone: StringFilter
|
||||||
|
|
@ -1626,6 +1628,7 @@ input ContactRequestOrderByInput {
|
||||||
id: OrderDirection
|
id: OrderDirection
|
||||||
name: OrderDirection
|
name: OrderDirection
|
||||||
company: OrderDirection
|
company: OrderDirection
|
||||||
|
message: OrderDirection
|
||||||
position: OrderDirection
|
position: OrderDirection
|
||||||
email: OrderDirection
|
email: OrderDirection
|
||||||
phone: OrderDirection
|
phone: OrderDirection
|
||||||
|
|
@ -1636,6 +1639,7 @@ input ContactRequestOrderByInput {
|
||||||
input ContactRequestUpdateInput {
|
input ContactRequestUpdateInput {
|
||||||
name: String
|
name: String
|
||||||
company: String
|
company: String
|
||||||
|
message: String
|
||||||
position: String
|
position: String
|
||||||
email: String
|
email: String
|
||||||
phone: String
|
phone: String
|
||||||
|
|
@ -1651,6 +1655,7 @@ input ContactRequestUpdateArgs {
|
||||||
input ContactRequestCreateInput {
|
input ContactRequestCreateInput {
|
||||||
name: String
|
name: String
|
||||||
company: String
|
company: String
|
||||||
|
message: String
|
||||||
position: String
|
position: String
|
||||||
email: String
|
email: String
|
||||||
phone: String
|
phone: String
|
||||||
|
|
|
||||||
|
|
@ -284,6 +284,7 @@ model ContactRequest {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String @default("")
|
name String @default("")
|
||||||
company String @default("")
|
company String @default("")
|
||||||
|
message String @default("")
|
||||||
position String @default("")
|
position String @default("")
|
||||||
email String @default("")
|
email String @default("")
|
||||||
phone String @default("")
|
phone String @default("")
|
||||||
|
|
|
||||||
|
|
@ -720,13 +720,14 @@ export const lists: Lists = {
|
||||||
ui: {
|
ui: {
|
||||||
label: 'Contact requests',
|
label: 'Contact requests',
|
||||||
listView: {
|
listView: {
|
||||||
initialColumns: ['createdAt', 'name', 'email', 'company', 'countryCode'],
|
initialColumns: ['createdAt', 'name', 'email', 'company', 'message', 'countryCode'],
|
||||||
initialSort: { field: 'createdAt', direction: 'DESC' },
|
initialSort: { field: 'createdAt', direction: 'DESC' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
name: text({ validation: { isRequired: true } }),
|
name: text({ validation: { isRequired: true } }),
|
||||||
company: text(),
|
company: text(),
|
||||||
|
message: text(),
|
||||||
position: text(),
|
position: text(),
|
||||||
email: text({ validation: { isRequired: true } }),
|
email: text({ validation: { isRequired: true } }),
|
||||||
phone: text(),
|
phone: text(),
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 821 B |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="48" height="48" viewBox="0 0 48 48"><image width="48" height="48" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAALKSURBVHgB7ZpNaBNBFMffSr0k1S3YattDaavVYAwooh7MIWdTj7Emp9qDn9CLSjxJFT/woAdFoT30JCJ6EUVE0IPgSZGIpeAXpkiQWAO1NMY0H637ooKY7ZuZ3dnOLuR33De7zH/mvTfv7a62aAAeZgV4nIYA1XheQBNlHJ+4ClOzH0A1Z8PXlrSRAtLG5Cdzr8HNNGJANa4XkJ9ZIO2uFzDndQFTE2XS7moB6TclKP6gSzUyjaqiUlqsTT7zvsocK10A+my1LF7g4qTLpd9Bm/1UhQrnM6QL+PiqBN+nF2C5IGPAv3IVuB2GgGZQTVdbJ2l3fRrV/fQikgLW+tpBNbqfdmOGgA5QzZqW1aSdFOBzQQwEOvtIO5lGe/QNIEp7bxO0rBM7B/AMyLyrmNpC3ZvIe0kB6EK4C4VyHnjp6BU/WrBcsCqAmYW2tG4DVWAK7Wqj45ApINi6FVQRDm5njnHFDvzMm5ceuzdLENCj9zmejTCIzYjujAALrpO4f30MnCSXqS+bE5G9oPvYtRiXACfdqGi4TzZdLyBuCOCBW0DQARE4+dTT+brrmH3CHP6PcBdzA4EDIAP095mv1VrH9fLRvGnLmIwd5H0cf0PzdxcmcylyXOpJ0VZDg6uf4HQfRKiclrULFBcGjwuNFxKAu+BkRsKVj+6IiNwi3tAMBIYcKbPRdZKxQyCKsABsM5O7zoNsHoyMMeseMyy1lHg6D4WGQRY3jp6xNHnEck+MsbDPcCe7nDLcJh7pB6vYei+0/09WuvN2HKxwcfAEHN4TBzvYfrGFIpqNuLhtiOBtfLDGuX5sRDjjmKHJ+tA9XcjC6efD8Ph+mjzIQt0b4ebJK5Z9/n802V/qL98bg3O3Ruuu46ofiSaEygQeNCd+Nfj87QtcujsKD188g9nCXO2Awhwva9X/RXPyXwmcPMJT11tFa/zsoRjPC/gF+iTd3/mfumMAAAAASUVORK5CYII="></image><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
|
||||||
|
@media (prefers-color-scheme: dark) { :root { filter: none; } }
|
||||||
|
</style></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"name": "MyWebSite",
|
||||||
|
"short_name": "MySite",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="48" height="48" viewBox="0 0 48 48"><image width="48" height="48" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAMsklEQVRoQ+1ZC4xU5RX+/vucx84ui1hNSSqxtTY26SOpwapV02piTOzD+mjapG0aExtrMCZWi1pqY6wWQWhFtE0UslIVURAoEeQhsiwsyIILsitUhcpz2WV32Z3Zmfvud/7Z4SHI7iw2jY03O5mZO/+993znfOec7/yr8Ck/1KfcfnwG4H8dwf/vCCx769W72g5tvcN3gjqVWKkESOzIjGMDgTKKpQjRQGQYRQOqFCM2DRgK/AADMY+E35PYSAL+npez/FP8bCYJ18mP/Byr2ARCiysVXzWJESWILS6w3AROMiZzzrJbL7vt9o+L9Gkj8PiCB3ft6G0b59lFhAat52ojtgAV0lwPsYpoBZ9PU6HiwWdU3j/6SK6RQ9bS+srnWCzgtQZ8JiRdRKTQ90zBDDOI8y4aft1gKKXkx5OO0wKYuvzBYFtHizXgDiA0Qw1AaWNDRIanb2bGjlgg7iSgZPBdDJLvfNfnaTKvE2j6XQ2u16voEB20QN9XA+A5FaVhhTVwvVGY9fPZZxFAd1UAkiRRk5ffF2/rboEACAigbJAYENNJngZj8yGITNimBS8owSRJgshHynUQxgENo1818HIE5Zqj32HCNFxEXJfEHgyT95dIk1UqSsEOapAq1uGRX07+9rnq3OZqARiTl0+MtnVvRiGVhy8RIIuVUIhe0gD42Y6ySEIF0zQRRREcx9LvpqlomHj0NAeRhGHMaxxmRwjPLzB5QlhmBlacgVlKw+jN4q+/efKGMWrMgmoBmFOX3Rdu695CAAVGICJDDVKG3hEA5L8chlBA85qBoOG2bSMI6NGELpeIiUuPHqekMa8LoAy51tTrVWwj8Vw4QQ6H3u3Fi3985frRavQ/qwXgTFv6gLd1EEBI74u5Jr1uCKkVjaSBYeLDsCQqxEGjTdOmV8XzSoMRUCcDOJboFq8NSTnxvByx5DDrloM61CT12PT6Vqx49o2rSaGV1QKwpy2d5G/VOVBgEtPr4u/QhakdyXrJRE6kQhEMyya9x+Qjr1kmEdNuAVMGUInCyZVKDBagAl7o5DoZuFYORzqL6Np1BPtaO/DO/HcvqFOp96oFYDICYWuPAJAckCRUcEKHiaj4SsgcH1YqRCkoas/Jw0MWEwESRyQaG4aheOFRAGXg+mBWix8003hfXaoI3DLT8AYi7Nq5D3vaDqDWz2HHwtbz0yq9q1oA1pTXJwatPZvQn84jsFiFaEgFAJ9WLoJ8aOAn9KIL28rgUEc3jZc8sVhNbE0lDWAwT07sF0I5C4GEi3+loo/u7j50dfTCz4fIMJGzzIXti1rGZ5TaWBUAWfzYsolJS99GJnE/Aumj4qFIaCLRELKyjEofCFlGrSxKhRibW9pxuDNPgyS5bXr3xAiU+8LxVGJZZRgkCvISokpZlmKRitNIF220Lmn+ySil5lYNYOaqPyere1agjwDsiNWBtLB160xYlaQS0ZA4oa9dJnYN+1AWb61vR+eBPL+n2RtS2rPlo2x0pR9UzlJr6BzwQp9Jw8LAch3ys83+YAcu6uM6NM9f84sxSjVUDeBvbzyeLD+8lBTqgxva9D4joO2I4dksq6zdFEDEw7zwWbcjPmxtG3o6WZmCjJYZrFuDzy1XmTJ8OcqJLU2r/EPM7h7CsAMEiQdLWTA9G+fXjcPKZxddUqfUhqoBzG6emSzeswD5dC+cmB4hVQSALp/sC/JAARALrQjAUfVoWr0V+V5Sgt6TXBBDjk/coxGQdiwwmPCeF7DypJgLPhI3YlBZHET2lYCLv/BNLJvekCO9yMuTj9Nqofnbn298fvusy/szAkAiQIOE2xqARwBlChkEp9iRbbMea1eycx/heZZbxfzQDW3Q3yfKPJEYksQKfilAzq1H0aNAdHhvArFYq13PwFUXXYqXH37qY+08LYBluxcveOatJ35YSPWSDtYpASSklWNkNQCwaqx74230dpcoLxz2C77YCzRDtBY6HoJoqhB+NACDuZWzz2YJVqx2zK+IFGKos5QoPxp/LZ6+55GRAdjQ2/jSX15/9KZCuptcldrPhItcTYnIZO0XgyQvWLsRpmiAja0tO9HdWeB5VifmgDQnHYHjqo9mz6CqhenDtWuQ9KaZH+wj7MxRQj1klDSACTf+ChN/esfIALyXtN/20LzfPZ1P9VAyUMwRhEm6cPDgzFFiJ6aGEQoZ7KRM8iiw0HWwD31HPKauy/VMYYo8HQGmbpn2Ur3KXBZoolztJIfdrd2kYg6JlWbhohhURWSCGH+/fzKuv/iakQEgf1O3vnhTMZ86zD7A8iZygZ7Vs4VZBmAOzhkJE9xQHEJIG99jY6MhutLopGdSa8PL0aj0AWmFoURmII31S9oR+1kOSnKdRGwA2SDC8mfm5S86+8u5UyVw2TFDHFPX/z7Z8P4aqJzCAL3lku++X+LAx4iwZqMimfUUxWrEl56odJnUQ+XgQEPaUcEm/C5ARHXqCsTyr/xavLlwG9tAPS8lANFS/hF8bew4NM5cnGYFYj069TEkgEUfNuydv2be2Lzbj9jmw2mcKEeHE+uAn4dDKXBMKrD0aclw7LYi8kTqiKrW0prJkJAicl4oGbK6Bf0ptKzahTDPPKCQCwZKyDkKP7jie3jqzsmntXFIANvDja9Na5h8bSHXj6LJ5JQZi5Sw7IT1m1w32G0HvX2yj2KdA9rYRLwvyq0895bPsbM7tejaV8SWNR9QDOVIPY6RYOUaKGDKfQ/iZ5fdcGYA+FDjnudvjw6nOtAVdcBySA8aEbNWCwUSmdCENpqQH5XL5RlCjgoA0TsCin1KUy1mvziwuw9tm/ZzD6IOxXyE+lQOaSbwzlebh3TwkAvk4S9smp281v4qCuzIwnvhcFn/i8qs8J3GDnr3aOfltRF3SbTBFHWVplYGRCewcmWtz2Hzxn9h765+pIxaRoFRoSS/8bvXYsZdDw9p35AL5GG7/O3j/zDn/uZSXR5eQhpx/LMsDu2s8WW9L94XagzOwMc3LOZERZHGBCOHRE6O2HdQ6LbQ3NSGsCSC0EWNYp/p97HkhfnnfWv0Fz88mZYnnhkWALnkT2vvT7bsb6YKJQDqIL2bQHv1MPJxAGQHghGSXSzZKtF9hIAlcgFLpF9Q2Lz+3+jpovbPjEHsMeEHQnx93IV4c8bcYdk2rEVi38Zk5aXT50xtUrUhijEjIZXITpFK4lW9C1T2rt4nEj1/bIiRRhcTQMSiHwQh+vsLOHTgIDoP9qPQZ1KK1HIKC1HDjpyh1ppy951n3XzpzafcB/poRIYNQC58dMWkZEfnNvSqLsRZn7Mwd+YoIUQSWw4rBymwYc0OdO0nnwnElM1FCr6IMQiZ6PIu05dr2chw/CwWWcWcFBPXRy1nh1Ro4fxR56xsnL346qGoU/m9KgBbk01fmT5rSntUP4De5BDHAFKJg4uiZNbe5fi3Zd0e5A8Lyygl6G2TqlK2WUDKmcwbZbEnc9fC4/ioSCVxQn2uFvGREvchXLy/cOjKczy4qgDIhc+2Pdm8YuOS8XGtx0G/ROM8OJbLOYF897LYtG4vug6JmmEy0sBItA4plOJGSVAMKNLKTa1ECmZyNXoLxusros5wcNPV110xbcJDjcP1vk69ahbLWpY/84GlE8KdB9ph1yXwDSY1ZbFF+WxxImtp3o89ewmMFLJSlAXcNhTBY3GvR/LCZdGKOY5KDoWkU4pVRxrXhWPHPbFqxtwJ1dpTNQB5wLvJ2s9Pa3hiX9HqQ1xbRDHhsOTTrWEObzez4R0UmcAIkPcmc0AqDnyFbDbLTawSZ4AS0mlObD73gSgC3cBe8cGS9ddUa/yIIlB5yIr9877zysqX13RgD5J0wIqUQeSlONDsRl+PDDmkB1WpiD0Z2rmjpJtXKSyQQpy2UpTghQCj4kzT+4uaLx+J8WcEQC5+bstTV63+YNWy7qjTcbkvJDsTq5e3o5t13XXrtOeZGVQY7K6yYUXtlKHhUpl6+npxTnZU6565G74xUuPPGIDcYFbbzEvWtTet6O7ryrqZs9DUuBMdHUWKMk5oslXoWug/0o9RuTGcfX1GhvLCK2FUJte6e27TGRn/iQCQmyze848LljY2Ts8n8XWr1r6Dwz0DSCgb+H8illhWJH52qDoj/lMqzXQYmxv90ttzlt5yJp4fUR8Y6oG/fe7exxatbr67q3+AZVREHHM3yOsyaoL/RyiEwVfP+9K9b858cdpQ9xru7yOqQqe7+cz5DVc+9syTqwtUrT43fx0WI5edOu1n1uxcuO7K4Ro23HWfOIDKgyfMeOC6lU1Nk/r7Omt//P2bb5l226RtwzWqmnX/NQDVGHEmaz8DcCbe+ySu/dRH4D8zaUyLGRJxXwAAAABJRU5ErkJggg=="></image><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
|
||||||
|
@media (prefers-color-scheme: dark) { :root { filter: none; } }
|
||||||
|
</style></svg>
|
||||||
|
After Width: | Height: | Size: 4.7 KiB |
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"name": "MyWebSite",
|
||||||
|
"short_name": "MySite",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
|
@ -2,7 +2,11 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
<link rel="icon" type="image/png" href="%PUBLIC_URL%/favicon/favicon-96x96.png" sizes="96x96" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="%PUBLIC_URL%/favicon/favicon.svg" />
|
||||||
|
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/favicon/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/favicon/site.webmanifest" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<meta
|
<meta
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import Loader from "./components/Loader";
|
||||||
import Header from "./components/Header";
|
import Header from "./components/Header";
|
||||||
import FooterSection from "./components/FooterSection";
|
import FooterSection from "./components/FooterSection";
|
||||||
|
|
||||||
|
import ScrollToHashElement from "./ScrollToHashElement";
|
||||||
|
|
||||||
// Страницы/секции
|
// Страницы/секции
|
||||||
import ScreenFirst from "./components/ScreenFirst";
|
import ScreenFirst from "./components/ScreenFirst";
|
||||||
import ScreenSecond from "./components/ScreenSecond";
|
import ScreenSecond from "./components/ScreenSecond";
|
||||||
|
|
@ -18,6 +20,7 @@ import LeadershipSection from "./components/LeadershipSection";
|
||||||
import NewsSection from "./components/NewsSection";
|
import NewsSection from "./components/NewsSection";
|
||||||
import ContactFormSection from "./components/ContactFormSection";
|
import ContactFormSection from "./components/ContactFormSection";
|
||||||
import Privacy from "./components/Privacy";
|
import Privacy from "./components/Privacy";
|
||||||
|
import Disclaimer from "./components/Disclaimer";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -49,6 +52,17 @@ function PrivacyPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DisclaimerPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<Disclaimer/>
|
||||||
|
<FooterSection />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
|
@ -56,9 +70,11 @@ function App() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
|
<ScrollToHashElement offset={50} />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/privacy" element={<PrivacyPage />} />
|
<Route path="/privacy" element={<PrivacyPage />} />
|
||||||
|
<Route path="/disclaimer" element={<DisclaimerPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
// ScrollToHashElement.tsx
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
|
||||||
|
const ScrollToHashElement: React.FC<{ offset?: number }> = ({ offset = 80 }) => {
|
||||||
|
const { hash } = useLocation();
|
||||||
|
|
||||||
|
console.log('ScrollToHashElement');
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hash) return;
|
||||||
|
const el = document.querySelector(hash);
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const y = el.getBoundingClientRect().top + window.scrollY - offset;
|
||||||
|
window.scrollTo({ top: y, behavior: "smooth" });
|
||||||
|
}, [hash, offset]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ScrollToHashElement;
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
} from "../Typography";
|
} from "../Typography";
|
||||||
import Input from "../Input";
|
import Input from "../Input";
|
||||||
import CountrySelect from "../CountrySelect";
|
import CountrySelect from "../CountrySelect";
|
||||||
|
import Textarea from "../Textarea";
|
||||||
|
|
||||||
type GqlResponse<T> = { data?: T; errors?: Array<{ message: string }> };
|
type GqlResponse<T> = { data?: T; errors?: Array<{ message: string }> };
|
||||||
|
|
||||||
|
|
@ -19,16 +20,14 @@ mutation CreateContact($data: ContactRequestCreateInput!) {
|
||||||
function smoothScrollToContact(offset: number = 50) {
|
function smoothScrollToContact(offset: number = 50) {
|
||||||
const el = document.querySelector<HTMLElement>("#contact");
|
const el = document.querySelector<HTMLElement>("#contact");
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
const top = el.getBoundingClientRect().top + window.scrollY - offset;
|
||||||
|
window.scrollTo({ top, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
const top =
|
/** Плавный скролл к элементу с учётом отступа (например, под фикс-хедер) */
|
||||||
el.getBoundingClientRect().top + window.scrollY - offset;
|
function smoothScrollToEl(el: HTMLElement, offset: number = 150) {
|
||||||
|
const top = el.getBoundingClientRect().top + window.scrollY - offset;
|
||||||
console.log(top);
|
window.scrollTo({ top, behavior: "smooth" });
|
||||||
|
|
||||||
window.scrollTo({
|
|
||||||
top,
|
|
||||||
behavior: "smooth",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// универсальный helper
|
// универсальный helper
|
||||||
|
|
@ -43,22 +42,73 @@ async function fetchGraphQL<T>(query: string, variables?: Record<string, any>) {
|
||||||
return json.data as T;
|
return json.data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Поля, для которых показываем ошибки под элементом управления */
|
||||||
|
type FieldErrors = Partial<{
|
||||||
|
name: string;
|
||||||
|
country: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
message: string;
|
||||||
|
consents: string; // сводная ошибка по чекбоксам согласий
|
||||||
|
}>;
|
||||||
|
|
||||||
const ContactFormSection: React.FC = () => {
|
const ContactFormSection: React.FC = () => {
|
||||||
const userLocale =
|
const userLocale =
|
||||||
typeof navigator !== "undefined" ? navigator.language : "en";
|
typeof navigator !== "undefined" ? navigator.language : "en";
|
||||||
|
|
||||||
const formRef = React.useRef<HTMLFormElement>(null);
|
const formRef = React.useRef<HTMLFormElement>(null);
|
||||||
|
|
||||||
|
// refs контейнеров полей — чтобы проскроллить к первому с ошибкой
|
||||||
|
const nameRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const countryRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const emailRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const phoneRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const messageRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const consentsRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [busy, setBusy] = React.useState(false);
|
const [busy, setBusy] = React.useState(false);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
|
||||||
const [sent, setSent] = React.useState(false);
|
const [sent, setSent] = React.useState(false);
|
||||||
|
const [submitError, setSubmitError] = React.useState<string | null>(null); // ошибка отправки (сервер/сеть)
|
||||||
|
|
||||||
// состояния согласий
|
// состояния согласий
|
||||||
const [agreePrivacy, setAgreePrivacy] = React.useState(true);
|
const [agreePrivacy, setAgreePrivacy] = React.useState(true);
|
||||||
const [agreeComms, setAgreeComms] = React.useState(true);
|
const [agreeComms, setAgreeComms] = React.useState(true);
|
||||||
|
|
||||||
|
// ошибки по полям
|
||||||
|
const [errors, setErrors] = React.useState<FieldErrors>({});
|
||||||
|
|
||||||
|
/** Снять ошибку конкретного поля (используем в onChange) */
|
||||||
|
const clearFieldError = React.useCallback((field: keyof FieldErrors) => {
|
||||||
|
setErrors((prev) => {
|
||||||
|
if (!prev[field]) return prev;
|
||||||
|
const { [field]: _drop, ...rest } = prev;
|
||||||
|
return rest as FieldErrors;
|
||||||
|
});
|
||||||
|
if (submitError) setSubmitError(null);
|
||||||
|
}, [submitError]);
|
||||||
|
|
||||||
|
const scrollToFirstError = (errs: FieldErrors) => {
|
||||||
|
const cands: Array<[keyof FieldErrors, HTMLElement | null]> = [
|
||||||
|
["name", nameRef.current],
|
||||||
|
["country", countryRef.current],
|
||||||
|
["email", emailRef.current],
|
||||||
|
["phone", phoneRef.current],
|
||||||
|
["message", messageRef.current],
|
||||||
|
["consents", consentsRef.current],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [key, el] of cands) {
|
||||||
|
if (errs[key] && el) {
|
||||||
|
smoothScrollToEl(el, 150);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setSubmitError(null);
|
||||||
|
setErrors({});
|
||||||
|
|
||||||
const form = formRef.current;
|
const form = formRef.current;
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
|
|
@ -71,17 +121,40 @@ const ContactFormSection: React.FC = () => {
|
||||||
const position = String(fd.get("position") || "").trim();
|
const position = String(fd.get("position") || "").trim();
|
||||||
const email = String(fd.get("email") || "").trim();
|
const email = String(fd.get("email") || "").trim();
|
||||||
const phone = String(fd.get("phone") || "").trim();
|
const phone = String(fd.get("phone") || "").trim();
|
||||||
|
const message = String(fd.get("message") || "").trim();
|
||||||
|
|
||||||
// читаем согласия из формы (и дублируем защиту состояниями)
|
// читаем согласия из формы (и дублируем защиту состояниями)
|
||||||
const consentPrivacy = fd.get("consentPrivacy") === "on";
|
const consentPrivacy = fd.get("consentPrivacy") === "on";
|
||||||
const consentComms = fd.get("consentComms") === "on";
|
const consentComms = fd.get("consentComms") === "on";
|
||||||
|
|
||||||
// простая валидация
|
// валидация по полям
|
||||||
if (!name) return setError("Please enter your name.");
|
const nextErrors: FieldErrors = {};
|
||||||
if (!country) return setError("Please choose a country from the list.");
|
|
||||||
if (!email) return setError("Please enter your business email.");
|
if (!name) nextErrors.name = "Please enter your name.";
|
||||||
|
if (!country) nextErrors.country = "Please choose a country from the list.";
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
nextErrors.email = "Please enter your business email.";
|
||||||
|
} else if (!email.includes("@")) {
|
||||||
|
nextErrors.email = "Please enter a valid email (must contain @).";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phone && !/^\+?\d+$/.test(phone)) {
|
||||||
|
nextErrors.phone = "Phone number can contain only digits and '+'.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.length > 500) {
|
||||||
|
nextErrors.message = "Message must be 500 characters or fewer.";
|
||||||
|
}
|
||||||
|
|
||||||
if (!consentPrivacy || !consentComms || !agreePrivacy || !agreeComms) {
|
if (!consentPrivacy || !consentComms || !agreePrivacy || !agreeComms) {
|
||||||
return setError("Please accept both consents.");
|
nextErrors.consents = "Please accept both consents.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(nextErrors).length) {
|
||||||
|
setErrors(nextErrors);
|
||||||
|
scrollToFirstError(nextErrors);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -98,18 +171,19 @@ const ContactFormSection: React.FC = () => {
|
||||||
position,
|
position,
|
||||||
email,
|
email,
|
||||||
phone,
|
phone,
|
||||||
|
message,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
setSent(true); // прячем форму, показываем «спасибо»
|
setSent(true); // прячем форму, показываем «спасибо»
|
||||||
form.reset(); // чистим поля, на всякий
|
form.reset(); // чистим поля, на всякий
|
||||||
// на всякий возвращаем чекбоксы в true (форма уже скрыта)
|
|
||||||
setAgreePrivacy(true);
|
setAgreePrivacy(true);
|
||||||
setAgreeComms(true);
|
setAgreeComms(true);
|
||||||
|
setErrors({});
|
||||||
smoothScrollToContact(150);
|
smoothScrollToContact(150);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.message || "Failed to submit. Please try again.");
|
setSubmitError(err?.message || "Failed to submit. Please try again.");
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
|
|
@ -131,61 +205,118 @@ const ContactFormSection: React.FC = () => {
|
||||||
{sent ? (
|
{sent ? (
|
||||||
<ThanksBox>
|
<ThanksBox>
|
||||||
<h3>Thank you!</h3>
|
<h3>Thank you!</h3>
|
||||||
<p>
|
<p>Your request has been submitted. Our team will contact you shortly.</p>
|
||||||
Your request has been submitted. Our team will contact you shortly.
|
|
||||||
</p>
|
|
||||||
</ThanksBox>
|
</ThanksBox>
|
||||||
) : (
|
) : (
|
||||||
<Form ref={formRef} onSubmit={onSubmit} noValidate>
|
<Form ref={formRef} onSubmit={onSubmit} noValidate>
|
||||||
<Grid>
|
<Grid>
|
||||||
{/* имя — на 2 колонки */}
|
{/* имя — на 2 колонки */}
|
||||||
<ColSpan2>
|
<ColSpan2 ref={nameRef}>
|
||||||
<Input label="Your name" fullWidth name="name" required />
|
<Field invalid={!!errors.name}>
|
||||||
|
<Input
|
||||||
|
label="Your name"
|
||||||
|
fullWidth
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
aria-invalid={!!errors.name}
|
||||||
|
onChange={() => clearFieldError("name")}
|
||||||
|
/>
|
||||||
|
{errors.name ? <ErrorText>{errors.name}</ErrorText> : null}
|
||||||
|
</Field>
|
||||||
</ColSpan2>
|
</ColSpan2>
|
||||||
|
|
||||||
{/* страна — на 2 колонки, обязательный выбор из списка */}
|
{/* страна — на 2 колонки, обязательный выбор из списка */}
|
||||||
<ColSpan2>
|
<ColSpan2 ref={countryRef}>
|
||||||
<CountrySelect
|
<Field invalid={!!errors.country}>
|
||||||
label="Country"
|
<CountrySelect
|
||||||
name="country"
|
label="Country"
|
||||||
locale={userLocale}
|
name="country"
|
||||||
fullWidth
|
locale={userLocale}
|
||||||
/>
|
fullWidth
|
||||||
|
aria-invalid={!!errors.country}
|
||||||
|
onChange={() => clearFieldError("country")}
|
||||||
|
/>
|
||||||
|
{errors.country ? <ErrorText>{errors.country}</ErrorText> : null}
|
||||||
|
</Field>
|
||||||
</ColSpan2>
|
</ColSpan2>
|
||||||
|
|
||||||
{/* 2-я строка: 2 колонки */}
|
{/* 2-я строка: 2 колонки (необязательные) */}
|
||||||
<Input label="Company" fullWidth name="company" />
|
<div>
|
||||||
<Input label="Position" fullWidth name="position" />
|
<Field>
|
||||||
|
<Input label="Company" fullWidth name="company" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Field>
|
||||||
|
<Input label="Position" fullWidth name="position" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 3-я строка: 2 колонки */}
|
{/* 3-я строка: 2 колонки */}
|
||||||
<Input
|
<div ref={emailRef}>
|
||||||
label="Business email"
|
<Field invalid={!!errors.email}>
|
||||||
type="email"
|
<Input
|
||||||
fullWidth
|
label="Business email"
|
||||||
name="email"
|
type="email"
|
||||||
autoComplete="email"
|
fullWidth
|
||||||
required
|
name="email"
|
||||||
/>
|
autoComplete="email"
|
||||||
<Input
|
required
|
||||||
label="Telephone"
|
aria-invalid={!!errors.email}
|
||||||
type="tel"
|
onChange={() => clearFieldError("email")}
|
||||||
fullWidth
|
/>
|
||||||
name="phone"
|
{errors.email ? <ErrorText>{errors.email}</ErrorText> : null}
|
||||||
autoComplete="tel"
|
</Field>
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
|
<div ref={phoneRef}>
|
||||||
|
<Field invalid={!!errors.phone}>
|
||||||
|
<Input
|
||||||
|
label="Telephone"
|
||||||
|
type="tel"
|
||||||
|
fullWidth
|
||||||
|
name="phone"
|
||||||
|
autoComplete="tel"
|
||||||
|
aria-invalid={!!errors.phone}
|
||||||
|
onChange={() => clearFieldError("phone")}
|
||||||
|
/>
|
||||||
|
{errors.phone ? <ErrorText>{errors.phone}</ErrorText> : null}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ColSpan2 ref={messageRef}>
|
||||||
|
<Field invalid={!!errors.message}>
|
||||||
|
<Textarea
|
||||||
|
label="Your message to us"
|
||||||
|
name="message"
|
||||||
|
fullWidth
|
||||||
|
maxLength={500}
|
||||||
|
maxRows={4}
|
||||||
|
aria-invalid={!!errors.message}
|
||||||
|
onChange={() => clearFieldError("message")}
|
||||||
|
/>
|
||||||
|
{errors.message ? <ErrorText>{errors.message}</ErrorText> : null}
|
||||||
|
</Field>
|
||||||
|
</ColSpan2>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{error ? <ErrorNote>{error}</ErrorNote> : null}
|
{/* ошибка уровня формы (сервер/сеть) */}
|
||||||
|
{submitError ? <GlobalErrorNote>{submitError}</GlobalErrorNote> : null}
|
||||||
|
|
||||||
{/* согласия */}
|
{/* согласия */}
|
||||||
<Consents>
|
<Consents ref={consentsRef}>
|
||||||
<ConsentRow>
|
<ConsentRow>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="c1"
|
id="c1"
|
||||||
name="consentPrivacy"
|
name="consentPrivacy"
|
||||||
checked={agreePrivacy}
|
checked={agreePrivacy}
|
||||||
onChange={(e) => setAgreePrivacy(e.target.checked)}
|
onChange={(e) => {
|
||||||
|
const v = e.target.checked;
|
||||||
|
setAgreePrivacy(v);
|
||||||
|
if (v && agreeComms) clearFieldError("consents");
|
||||||
|
}}
|
||||||
|
aria-invalid={!!errors.consents}
|
||||||
/>
|
/>
|
||||||
<label htmlFor="c1">
|
<label htmlFor="c1">
|
||||||
I accept the{" "}
|
I accept the{" "}
|
||||||
|
|
@ -202,13 +333,20 @@ const ContactFormSection: React.FC = () => {
|
||||||
id="c2"
|
id="c2"
|
||||||
name="consentComms"
|
name="consentComms"
|
||||||
checked={agreeComms}
|
checked={agreeComms}
|
||||||
onChange={(e) => setAgreeComms(e.target.checked)}
|
onChange={(e) => {
|
||||||
|
const v = e.target.checked;
|
||||||
|
setAgreeComms(v);
|
||||||
|
if (v && agreePrivacy) clearFieldError("consents");
|
||||||
|
}}
|
||||||
|
aria-invalid={!!errors.consents}
|
||||||
/>
|
/>
|
||||||
<label htmlFor="c2">
|
<label htmlFor="c2">
|
||||||
I confirm my consent to receive information from the company via
|
I confirm my consent to receive information from the company via
|
||||||
the provided contact methods of Email and/or Telephone
|
the provided contact methods of Email and/or Telephone
|
||||||
</label>
|
</label>
|
||||||
</ConsentRow>
|
</ConsentRow>
|
||||||
|
|
||||||
|
{errors.consents ? <ErrorText>{errors.consents}</ErrorText> : null}
|
||||||
</Consents>
|
</Consents>
|
||||||
|
|
||||||
<Actions>
|
<Actions>
|
||||||
|
|
@ -230,7 +368,10 @@ const Wrap = styled.section`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 10px 72px;
|
padding: 64px 10px 72px;
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: 48px 10px 48px;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Form = styled.form`
|
const Form = styled.form`
|
||||||
|
|
@ -255,8 +396,27 @@ const ColSpan2 = styled.div`
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ErrorNote = styled.div`
|
/** Обёртка поля: если invalid=true — подсветка (оставил отключённой по вашему текущему коду) */
|
||||||
margin-top: 8px;
|
const Field = styled.div<{ invalid?: boolean }>`
|
||||||
|
& input,
|
||||||
|
& textarea,
|
||||||
|
& select,
|
||||||
|
& [role="combobox"] {
|
||||||
|
outline: ${({ invalid }) => (invalid ? "none" : "none")};
|
||||||
|
outline-offset: 0;
|
||||||
|
border-color: ${({ invalid }) => (invalid ? undefined : undefined)};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ErrorText = styled.div`
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.3;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const GlobalErrorNote = styled.div`
|
||||||
|
margin-top: 12px;
|
||||||
color: #dc2626;
|
color: #dc2626;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ const clamp = (n: number, min: number, max: number) =>
|
||||||
type CmsItem = { id: string; text: string; order: number };
|
type CmsItem = { id: string; text: string; order: number };
|
||||||
type CmsCountry = {
|
type CmsCountry = {
|
||||||
id: string;
|
id: string;
|
||||||
code: "usa" | "kaz" | "uzb" | "kgz" | "tjk" | "tur" | "cyp" | "arm" | "uae";
|
code: any;
|
||||||
name: string;
|
name: string;
|
||||||
isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
order: number;
|
order: number;
|
||||||
|
|
@ -97,6 +97,35 @@ const FALLBACK: CountryCard[] = [
|
||||||
{ id: "uae", name: "United Arab Emirates", coord: [54.4, 24.3], dx: -10, dy: 30, items: ["Freedom Telecom International","Freedom Broker"] },
|
{ id: "uae", name: "United Arab Emirates", coord: [54.4, 24.3], dx: -10, dy: 30, items: ["Freedom Telecom International","Freedom Broker"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Сопоставление названия из GeoJSON к коду
|
||||||
|
const GEO_TO_CODE: Record<string, CmsCountry["code"]> = {
|
||||||
|
"United States of America": "usa",
|
||||||
|
Kazakhstan: "kaz",
|
||||||
|
Uzbekistan: "uzb",
|
||||||
|
Kyrgyzstan: "kgz",
|
||||||
|
Tajikistan: "tjk",
|
||||||
|
Turkey: "tur",
|
||||||
|
Cyprus: "cyp",
|
||||||
|
Armenia: "arm",
|
||||||
|
"United Arab Emirates": "uae",
|
||||||
|
|
||||||
|
// новые
|
||||||
|
Azerbaijan: "azerbaijan",
|
||||||
|
"United Kingdom": "uk",
|
||||||
|
Austria: "austria",
|
||||||
|
Belgium: "belgium",
|
||||||
|
Bulgaria: "bulgaria",
|
||||||
|
France: "france",
|
||||||
|
Germany: "germany",
|
||||||
|
Greece: "greece",
|
||||||
|
Italy: "italy",
|
||||||
|
Lithuania: "lithuania",
|
||||||
|
Netherlands: "netherlands",
|
||||||
|
Poland: "poland",
|
||||||
|
Spain: "spain",
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
/* =================== map CMS → view =================== */
|
/* =================== map CMS → view =================== */
|
||||||
function mapCountries(section?: CmsSection | null): CountryCard[] {
|
function mapCountries(section?: CmsSection | null): CountryCard[] {
|
||||||
const list = section?.countries ?? [];
|
const list = section?.countries ?? [];
|
||||||
|
|
@ -171,7 +200,28 @@ export default function CountriesMap() {
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const highlighted = useMemo(() => new Set(COUNTRIES.map((d) => d.name)), [COUNTRIES]);
|
const highlightedCodes = useMemo(() => {
|
||||||
|
const baseIds = COUNTRIES.map((d) => d.id);
|
||||||
|
|
||||||
|
const extraIds = [
|
||||||
|
"azerbaijan",
|
||||||
|
"uk",
|
||||||
|
"austria",
|
||||||
|
"belgium",
|
||||||
|
"bulgaria",
|
||||||
|
"france",
|
||||||
|
"germany",
|
||||||
|
"greece",
|
||||||
|
"italy",
|
||||||
|
"lithuania",
|
||||||
|
"netherlands",
|
||||||
|
"poland",
|
||||||
|
"spain",
|
||||||
|
];
|
||||||
|
|
||||||
|
return new Set([...baseIds, ...extraIds]);
|
||||||
|
}, [COUNTRIES]);
|
||||||
|
|
||||||
const [hovered, setHovered] = useState<string | null>(null);
|
const [hovered, setHovered] = useState<string | null>(null);
|
||||||
|
|
||||||
const mobile = typeof window !== "undefined" ? window.innerWidth < 700 : false;
|
const mobile = typeof window !== "undefined" ? window.innerWidth < 700 : false;
|
||||||
|
|
@ -220,7 +270,8 @@ export default function CountriesMap() {
|
||||||
{({ geographies }: { geographies: any[] }) =>
|
{({ geographies }: { geographies: any[] }) =>
|
||||||
geographies.map((geo: any) => {
|
geographies.map((geo: any) => {
|
||||||
const name = geo.properties.name as string;
|
const name = geo.properties.name as string;
|
||||||
const isAlways = highlighted.has(name);
|
const code = GEO_TO_CODE[geo.properties.name];
|
||||||
|
const isAlways = highlightedCodes.has(code);
|
||||||
const isActive = hovered === name;
|
const isActive = hovered === name;
|
||||||
return (
|
return (
|
||||||
<Geography
|
<Geography
|
||||||
|
|
@ -230,14 +281,14 @@ export default function CountriesMap() {
|
||||||
onMouseLeave={() => setHovered((prev) => (prev === name ? null : prev))}
|
onMouseLeave={() => setHovered((prev) => (prev === name ? null : prev))}
|
||||||
style={{
|
style={{
|
||||||
default: {
|
default: {
|
||||||
fill: isActive ? "#0E7C44" : isAlways ? "#2EA86B" : "#CFE1E7",
|
fill: isActive ? "#16a34a" : isAlways ? "#16a48c" : "#CFE1E7",
|
||||||
stroke: "#FFFFFF",
|
stroke: "#FFFFFF",
|
||||||
strokeWidth: 0.6,
|
strokeWidth: 0.6,
|
||||||
outline: "none",
|
outline: "none",
|
||||||
transition: "fill .2s ease",
|
transition: "fill .2s ease",
|
||||||
},
|
},
|
||||||
hover: { fill: "#0E7C44", outline: "none" },
|
hover: { fill: "#16a34a", outline: "none" },
|
||||||
pressed: { fill: "#0E7C44", outline: "none" },
|
pressed: { fill: "#16a34a", outline: "none" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,9 @@ const Section = styled.section`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1220px;
|
max-width: 1220px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 64px 10px 64px;
|
padding: 64px 10px 0px;
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
padding: 48px 10px;
|
padding: 48px 10px 0px;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
// src/pages/DisclaimerPage.tsx
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useSingleton } from "cms/factory";
|
||||||
|
import { DocumentRenderer, DocumentRendererProps } from "@keystone-6/document-renderer";
|
||||||
|
import arrowRight from "../../imgs/arrow_right.svg";
|
||||||
|
import {
|
||||||
|
s_Text40_700,
|
||||||
|
s_Text24_700,
|
||||||
|
s_f_Text18_400,
|
||||||
|
Text18_500,
|
||||||
|
Text24_500,
|
||||||
|
} from "../Typography";
|
||||||
|
|
||||||
|
type CmsSystem = {
|
||||||
|
id: string;
|
||||||
|
footerDisclaimer?: { document: { content: any } } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SYS_SEL = `
|
||||||
|
id
|
||||||
|
footerDisclaimer { document }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Disclaimer: React.FC = () => {
|
||||||
|
const { data: system } = useSingleton<CmsSystem>("System", SYS_SEL);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = "Disclaimer";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Wrap>
|
||||||
|
<BackBtn as={Link} to="/">
|
||||||
|
<ArrowIcon src={arrowRight} alt="Back" />
|
||||||
|
Back
|
||||||
|
</BackBtn>
|
||||||
|
|
||||||
|
{system?.footerDisclaimer && (
|
||||||
|
<DocumentRenderer
|
||||||
|
document={
|
||||||
|
system.footerDisclaimer?.document as unknown as DocumentRendererProps["document"]
|
||||||
|
}
|
||||||
|
renderers={{
|
||||||
|
block: {
|
||||||
|
heading: ({ level, children }) => {
|
||||||
|
if (level === 1) return <Title as="h1">{children}</Title>;
|
||||||
|
if (level === 2) return <SubTitle as="h2">{children}</SubTitle>;
|
||||||
|
if (level === 3) return <SubSubTitle as="h3">{children}</SubSubTitle>;
|
||||||
|
return <p>{children}</p>;
|
||||||
|
},
|
||||||
|
paragraph: ({ children }) => <Paragraph>{children}</Paragraph>,
|
||||||
|
},
|
||||||
|
inline: {
|
||||||
|
link: ({ children, href }) => (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
style={{ color: "#16a34a", textDecoration: "underline" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Wrap>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Disclaimer;
|
||||||
|
|
||||||
|
/* ================= styles ================= */
|
||||||
|
|
||||||
|
const Wrap = styled.div`
|
||||||
|
max-width: 1300px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 20px 80px;
|
||||||
|
text-align: left;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const BackBtn = styled(Text24_500)`
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #1d2023;
|
||||||
|
text-decoration: none;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ArrowIcon = styled.img`
|
||||||
|
width: 5px;
|
||||||
|
height: 10px;
|
||||||
|
transform: rotate(180deg);
|
||||||
|
margin-right: 8px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Title = styled(s_Text40_700)`
|
||||||
|
margin-bottom: 24px;
|
||||||
|
text-align: left;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SubTitle = styled(s_Text24_700)`
|
||||||
|
margin: 24px 0 16px;
|
||||||
|
text-align: left;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SubSubTitle = styled(s_f_Text18_400)`
|
||||||
|
margin: 16px 0 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: left;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Paragraph = styled.p`
|
||||||
|
font-family: "GTAmerica", sans-serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 24px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
@ -3,19 +3,16 @@ import React, { useMemo } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import {
|
import {
|
||||||
s_f_Text18_400 as Text18_400,
|
|
||||||
s_Text14_400 as Text14_400,
|
s_Text14_400 as Text14_400,
|
||||||
s_Text12_400 as Text12_400,
|
s_Text12_400 as Text12_400,
|
||||||
} from "../Typography";
|
} from "../Typography";
|
||||||
import { useSingleton, useCollection } from "cms/factory";
|
import { useSingleton, useCollection } from "cms/factory";
|
||||||
import { DocumentRenderer, DocumentRendererProps } from "@keystone-6/document-renderer";
|
|
||||||
|
|
||||||
/* ============ CMS selections ============ */
|
/* ============ CMS selections ============ */
|
||||||
|
|
||||||
type CmsSystem = {
|
type CmsSystem = {
|
||||||
id: string;
|
id: string;
|
||||||
footerLogo?: { url?: string | null } | null;
|
footerLogo?: { url?: string | null } | null;
|
||||||
footerDisclaimer?: { document: { content: any } } | null;
|
|
||||||
linedUrl?: string | null;
|
linedUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -31,7 +28,6 @@ type CmsNavItem = {
|
||||||
const SYS_SEL = `
|
const SYS_SEL = `
|
||||||
id
|
id
|
||||||
footerLogo { url }
|
footerLogo { url }
|
||||||
footerDisclaimer { document }
|
|
||||||
linedUrl
|
linedUrl
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -43,12 +39,12 @@ const NAV_SEL = `
|
||||||
/* ============ Component ============ */
|
/* ============ Component ============ */
|
||||||
|
|
||||||
const FooterSection: React.FC = () => {
|
const FooterSection: React.FC = () => {
|
||||||
// CMS
|
|
||||||
const { data: system } = useSingleton<CmsSystem>("System", SYS_SEL);
|
const { data: system } = useSingleton<CmsSystem>("System", SYS_SEL);
|
||||||
const { data: navItems } = useCollection<CmsNavItem>("NavItem", NAV_SEL);
|
const { data: navItems } = useCollection<CmsNavItem>("NavItem", NAV_SEL);
|
||||||
|
|
||||||
const logoSrc = system?.footerLogo?.url || "/imgs/logo.svg";
|
const logoSrc = system?.footerLogo?.url || "/imgs/logo.svg";
|
||||||
const linkedinUrl = system?.linedUrl || "#";
|
const linkedinUrl = system?.linedUrl || "#";
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
const items = useMemo(() => {
|
const items = useMemo(() => {
|
||||||
if (!navItems) return [];
|
if (!navItems) return [];
|
||||||
|
|
@ -67,63 +63,27 @@ const FooterSection: React.FC = () => {
|
||||||
<Wrap>
|
<Wrap>
|
||||||
<Inner>
|
<Inner>
|
||||||
<Top>
|
<Top>
|
||||||
<Left>
|
<LogoRow>
|
||||||
<LogoRow >
|
<Logo
|
||||||
<Logo
|
onClick={() => (window.location.href = "/#freedom")}
|
||||||
onClick={() => {
|
style={{ cursor: "pointer" }}
|
||||||
window.location.href = "/#freedom";
|
src={logoSrc}
|
||||||
}}
|
alt="Logo"
|
||||||
style={{ cursor:'pointer' }}
|
/>
|
||||||
src={logoSrc}
|
</LogoRow>
|
||||||
alt="Logo"
|
|
||||||
/>
|
|
||||||
|
|
||||||
</LogoRow>
|
{/* Копирайт — отдельная секция, чтобы на мобиле идти выше меню */}
|
||||||
|
<CopyMobile>{`© ${year} Freedom Telecom International. All Rights Reserved`}</CopyMobile>
|
||||||
|
|
||||||
<Nav>
|
<Nav>
|
||||||
{items.map(i => (
|
{items.map(i => (
|
||||||
<NavLink key={i.id} href={i.href}>
|
<NavLink key={i.id} href={i.href}>
|
||||||
{i.label}
|
{i.label}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</Nav>
|
</Nav>
|
||||||
|
|
||||||
{system?.footerDisclaimer && (
|
|
||||||
<Description as="div">
|
|
||||||
<DocumentRenderer
|
|
||||||
document={
|
|
||||||
system.footerDisclaimer?.document as unknown as DocumentRendererProps["document"]
|
|
||||||
}
|
|
||||||
renderers={{
|
|
||||||
block: {
|
|
||||||
paragraph: ({ children }) => (
|
|
||||||
<p style={{ margin: "0 0 12px" }}>{children}</p>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
inline: {
|
|
||||||
link: ({ children, href }) => (
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
style={{ color: "#16a34a", textDecoration: "underline" }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Description>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<PrivacyRow>
|
|
||||||
<PrivacyLink as={Link} to="/privacy">
|
|
||||||
Privacy policy
|
|
||||||
</PrivacyLink>
|
|
||||||
</PrivacyRow>
|
|
||||||
</Left>
|
|
||||||
|
|
||||||
|
{/* Правый верх — соцсети */}
|
||||||
<Socials>
|
<Socials>
|
||||||
<SocialBtn
|
<SocialBtn
|
||||||
as="a"
|
as="a"
|
||||||
|
|
@ -137,6 +97,21 @@ const FooterSection: React.FC = () => {
|
||||||
</SocialBtn>
|
</SocialBtn>
|
||||||
</Socials>
|
</Socials>
|
||||||
</Top>
|
</Top>
|
||||||
|
{/* Низ: на десктопе — копирайт слева и ссылки справа; на мобиле скрыт */}
|
||||||
|
<BottomRow>
|
||||||
|
<CopyDesktop>{`© ${year} Freedom Telecom International. All Rights Reserved`}</CopyDesktop>
|
||||||
|
<LegalRowDesktop>
|
||||||
|
<LegalLink to="/privacy">Privacy policy</LegalLink>
|
||||||
|
<Separator>·</Separator>
|
||||||
|
<LegalLink to="/disclaimer">Disclaimer</LegalLink>
|
||||||
|
</LegalRowDesktop>
|
||||||
|
</BottomRow>
|
||||||
|
|
||||||
|
{/* Низ для мобилы — отдельная строка ссылок */}
|
||||||
|
<LegalRowMobile>
|
||||||
|
<LegalLink to="/privacy">Privacy policy</LegalLink>
|
||||||
|
<LegalLink to="/disclaimer">Disclaimer</LegalLink>
|
||||||
|
</LegalRowMobile>
|
||||||
</Inner>
|
</Inner>
|
||||||
</Wrap>
|
</Wrap>
|
||||||
);
|
);
|
||||||
|
|
@ -151,7 +126,7 @@ const Wrap = styled.footer`
|
||||||
background: #1d2023;
|
background: #1d2023;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
padding: 64px 40px;
|
padding: 64px 40px;
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 950px) {
|
||||||
padding: 32px 40px;
|
padding: 32px 40px;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
@ -162,15 +137,27 @@ const Inner = styled.div`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Top = styled.div`
|
const Top = styled.div`
|
||||||
position: relative;
|
display: flex;
|
||||||
`;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 32px;
|
||||||
|
|
||||||
const Left = styled.div`
|
@media (max-width: 950px) {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-row-gap: 0px;
|
||||||
|
grid-template-areas:
|
||||||
|
"logo"
|
||||||
|
"copy-mobile"
|
||||||
|
"nav"
|
||||||
|
"legal-mobile";
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const LogoRow = styled.div`
|
const LogoRow = styled.div`
|
||||||
margin-bottom: 16px;
|
grid-area: logo;
|
||||||
padding:8px 0px;
|
padding: 8px 0;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Logo = styled.img`
|
const Logo = styled.img`
|
||||||
|
|
@ -180,19 +167,31 @@ const Logo = styled.img`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Nav = styled.nav`
|
const Nav = styled.nav`
|
||||||
|
grid-area: nav;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 16px 24px;
|
gap: 16px 24px;
|
||||||
margin-bottom: 16px;
|
margin-top: 4px;
|
||||||
|
margin-right: auto;
|
||||||
|
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 950px) {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
margin:32px 0px;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const NavLink = styled.a`
|
const NavLink = styled.a`
|
||||||
${Text12_400 as any};
|
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: Regular;
|
||||||
|
font-size: 12px;
|
||||||
|
leading-trim: NONE;
|
||||||
|
line-height: 16px;
|
||||||
|
letter-spacing: 4%;
|
||||||
|
vertical-align: middle;
|
||||||
|
text-transform: uppercase;
|
||||||
|
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
|
|
@ -202,40 +201,86 @@ const NavLink = styled.a`
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Description = styled(Text14_400)`
|
/* --- копирайт --- */
|
||||||
|
const CopyBase = styled.div`
|
||||||
|
${Text12_400 as any};
|
||||||
color: #acacad;
|
color: #acacad;
|
||||||
text-align: left;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Divider = styled.hr`
|
const CopyDesktop = styled(CopyBase)`
|
||||||
border: 0;
|
display: block;
|
||||||
border-top: 1px solid #414141;
|
font-size: 14px;
|
||||||
margin: 16px 0;
|
@media (max-width: 950px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const PrivacyRow = styled.div`
|
const CopyMobile = styled(CopyBase)`
|
||||||
|
grid-area: copy-mobile;
|
||||||
|
display: none;
|
||||||
|
font-size:14px;
|
||||||
|
@media (max-width: 950px) {
|
||||||
|
display: block;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/* --- низ на десктопе --- */
|
||||||
|
const BottomRow = styled.div`
|
||||||
|
grid-area: bottom;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top:16px;
|
||||||
|
|
||||||
|
@media (max-width: 950px) {
|
||||||
|
display: none; /* на мобиле скрываем: используется мобильный низ */
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const PrivacyLink = styled.button`
|
const LegalRowDesktop = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size:16px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Separator = styled.span`
|
||||||
|
color: #6a6d78;
|
||||||
|
opacity: 0.8;
|
||||||
|
`;
|
||||||
|
|
||||||
|
/* --- низ на мобиле --- */
|
||||||
|
const LegalRowMobile = styled.div`
|
||||||
|
grid-area: legal-mobile;
|
||||||
|
display: none;
|
||||||
|
font-size:16px;
|
||||||
|
|
||||||
|
@media (max-width: 950px) {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const LegalLink = styled(Link)`
|
||||||
${Text14_400 as any};
|
${Text14_400 as any};
|
||||||
color: #acacad;
|
color: #acacad;
|
||||||
background: transparent;
|
text-decoration: none;
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 2px;
|
text-underline-offset: 2px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
opacity: 0.9;
|
text-decoration: underline;
|
||||||
|
opacity: 0.95;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Socials = styled.div`
|
const Socials = styled.div`
|
||||||
position: absolute;
|
@media (max-width: 950px) {
|
||||||
right: 0;
|
position: absolute;
|
||||||
top: 0;
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const SocialBtn = styled.button`
|
const SocialBtn = styled.button`
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ const InnovationSection: React.FC = () => {
|
||||||
const imgUrl = data?.topLeftImage?.url || "/imgs/image_1.png";
|
const imgUrl = data?.topLeftImage?.url || "/imgs/image_1.png";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section>
|
<Section id="innovation">
|
||||||
<TitleRow>
|
<TitleRow>
|
||||||
<Text40_700>
|
<Text40_700>
|
||||||
{data?.title ?? "Innovation at the intersection of telecom and fintech"}
|
{data?.title ?? "Innovation at the intersection of telecom and fintech"}
|
||||||
|
|
@ -126,10 +126,10 @@ export default InnovationSection;
|
||||||
const Section = styled.section`
|
const Section = styled.section`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1220px;
|
max-width: 1220px;
|
||||||
padding: 64px 10px 64px;
|
padding: 64px 10px 0px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
padding: 48px 10px 48px;
|
padding: 48px 10px 0px;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -114,13 +114,13 @@ const CARD_SHADOW = "0px 4px 27.3px 0px #00000014";
|
||||||
const Section = styled.section`
|
const Section = styled.section`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1220px;
|
max-width: 1220px;
|
||||||
padding: 64px 0px;
|
padding: 64px 0px 28px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
padding: 48px 0px;
|
padding: 48px 0px 28px;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -174,6 +174,7 @@ const LogoCard = styled.div<{ $active: boolean }>`
|
||||||
${({ $active }) => ($active ? css`box-shadow: ${CARD_SHADOW};` : css`box-shadow: none;`)}
|
${({ $active }) => ($active ? css`box-shadow: ${CARD_SHADOW};` : css`box-shadow: none;`)}
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
flex: 0 0
|
flex: 0 0
|
||||||
${({ $active }) => ($active ? ACTIVE_BLOCK_W_M : INACTIVE_BLOCK_W_M)}px;
|
${({ $active }) => ($active ? ACTIVE_BLOCK_W_M : INACTIVE_BLOCK_W_M)}px;
|
||||||
|
|
@ -214,7 +215,6 @@ const InfoGridSizer = styled.div<{ $minh: number }>`
|
||||||
|
|
||||||
// карточка должна быть «неделимой» и вести себя как блочный элемент в колонке
|
// карточка должна быть «неделимой» и вести себя как блочный элемент в колонке
|
||||||
const InfoCard = styled.div`
|
const InfoCard = styled.div`
|
||||||
display: inline-block; /* ← важно для columns */
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 0 32px; /* ← вертикальный зазор между карточками */
|
margin: 0 0 32px; /* ← вертикальный зазор между карточками */
|
||||||
break-inside: avoid; /* стандарт */
|
break-inside: avoid; /* стандарт */
|
||||||
|
|
@ -241,7 +241,7 @@ const Bullets = styled.ul`
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr; /* 2 колонки */
|
grid-template-columns: auto auto; /* 2 колонки */
|
||||||
gap: 16px 24px;
|
gap: 16px 24px;
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
|
|
@ -456,7 +456,7 @@ export default function KeyBusinessSegments() {
|
||||||
const activeSegment = segments[Math.min(Math.max(realActiveIndex, 0), N - 1)];
|
const activeSegment = segments[Math.min(Math.max(realActiveIndex, 0), N - 1)];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section>
|
<Section id="key_business">
|
||||||
<HeaderRow>
|
<HeaderRow>
|
||||||
<Text40_700>{data?.title || "Key business segments"}</Text40_700>
|
<Text40_700>{data?.title || "Key business segments"}</Text40_700>
|
||||||
</HeaderRow>
|
</HeaderRow>
|
||||||
|
|
@ -470,7 +470,7 @@ export default function KeyBusinessSegments() {
|
||||||
key={seg.id}
|
key={seg.id}
|
||||||
$active={isActive}
|
$active={isActive}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
if (!isOverflow && !isMobile) setHoverIndex(idx);
|
if (!isOverflow && !isMobile) setHoverIndex(null);
|
||||||
}}
|
}}
|
||||||
onMouseLeave={() => {
|
onMouseLeave={() => {
|
||||||
if (!isOverflow && !isMobile) setHoverIndex(null);
|
if (!isOverflow && !isMobile) setHoverIndex(null);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
// TradingViewSymbolInfo.tsx
|
||||||
|
import React, { useEffect, useRef, memo } from "react";
|
||||||
|
|
||||||
|
const TradingViewSymbolInfo: React.FC = () => {
|
||||||
|
const container = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!container.current) return;
|
||||||
|
|
||||||
|
// Проверяем, чтобы не дублировать скрипт
|
||||||
|
if (container.current.querySelector("script")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src =
|
||||||
|
"https://s3.tradingview.com/external-embedding/embed-widget-symbol-info.js";
|
||||||
|
script.type = "text/javascript";
|
||||||
|
script.async = true;
|
||||||
|
script.innerHTML = `
|
||||||
|
{
|
||||||
|
"symbol": "NASDAQ:FRHC",
|
||||||
|
"colorTheme": "light",
|
||||||
|
"isTransparent": false,
|
||||||
|
"locale": "en",
|
||||||
|
"width": "100%"
|
||||||
|
}`;
|
||||||
|
|
||||||
|
container.current.appendChild(script);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (container.current) {
|
||||||
|
container.current.innerHTML = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tradingview-widget-container" ref={container}>
|
||||||
|
<div className="tradingview-widget-container__widget"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(TradingViewSymbolInfo);
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
// src/components/ScreenThree/index.tsx
|
// src/components/ScreenThree/index.tsx
|
||||||
|
import React from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import {
|
import {
|
||||||
s_f_Text18_400 as Text18_400,
|
s_f_Text18_400 as Text18_400,
|
||||||
|
|
@ -7,6 +8,7 @@ import {
|
||||||
s_Text14_400 as Text14_400,
|
s_Text14_400 as Text14_400,
|
||||||
} from "../Typography";
|
} from "../Typography";
|
||||||
import TradingViewChart from "./TradingViewChart";
|
import TradingViewChart from "./TradingViewChart";
|
||||||
|
import TradingViewSymbolInfo from "./TradingViewSymbolInfo";
|
||||||
import { useSingleton } from "cms/factory";
|
import { useSingleton } from "cms/factory";
|
||||||
import { AsyncReveal } from "../AsyncReveal";
|
import { AsyncReveal } from "../AsyncReveal";
|
||||||
|
|
||||||
|
|
@ -51,6 +53,22 @@ function chunkInto<T>(arr: T[], size: number): T[][] {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===================== HOOKS ===================== */
|
||||||
|
function useIsMobile(maxWidth = 768) {
|
||||||
|
const [isMobile, setIsMobile] = React.useState(
|
||||||
|
typeof window !== "undefined" ? window.innerWidth <= maxWidth : false
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const media = window.matchMedia(`(max-width: ${maxWidth}px)`);
|
||||||
|
const listener = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||||
|
media.addEventListener("change", listener);
|
||||||
|
return () => media.removeEventListener("change", listener);
|
||||||
|
}, [maxWidth]);
|
||||||
|
|
||||||
|
return isMobile;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===================== STYLES ===================== */
|
/* ===================== STYLES ===================== */
|
||||||
const ScreenSecondBlock = styled.div`
|
const ScreenSecondBlock = styled.div`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -87,7 +105,7 @@ const HeaderRow = styled.div`
|
||||||
flex-direction: column-reverse;
|
flex-direction: column-reverse;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -109,16 +127,33 @@ const ChartAndStats = styled.div`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ChartWrapper = styled.div`
|
const ChartWrapper = styled.div`
|
||||||
|
display:flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom:32px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ChartWrapperIn = styled.div`
|
||||||
flex: 1;
|
flex: 1;
|
||||||
border: 1px solid #F5F8FB;
|
border: 1px solid #F5F8FB;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 436px; /* фиксированная высота вместо min-height */
|
|
||||||
margin-bottom:32px;
|
margin-bottom:32px;
|
||||||
|
min-height: 436px;
|
||||||
|
height: 436px !important;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const ChartDesc = styled.div`
|
||||||
|
margin-top:48px;
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
margin-top:-10px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
const StatsWrapper = styled.div`
|
const StatsWrapper = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -132,11 +167,29 @@ const StatsWrapper = styled.div`
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const InfoWrapper = styled.div`
|
||||||
|
border: 1px solid #F5F8FB;
|
||||||
|
border-radius: 8px;
|
||||||
|
gap: 24px;
|
||||||
|
min-width: 450px;
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
min-width: 400px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
min-width: 350px;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
min-width: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
const StatCardRow = styled.div`
|
const StatCardRow = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
display:none;
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -189,6 +242,22 @@ function ScreenThree() {
|
||||||
const cardGroups = chunkInto<StatCard>(s3?.statCards ?? [], 2);
|
const cardGroups = chunkInto<StatCard>(s3?.statCards ?? [], 2);
|
||||||
const indexes: IndexItem[] = s3?.indexes ?? [];
|
const indexes: IndexItem[] = s3?.indexes ?? [];
|
||||||
|
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
const ChartFooter = (
|
||||||
|
<Text18_400 style={{ color: "#ACACAD" }}>
|
||||||
|
<ChartDesc>
|
||||||
|
For more information, visit:{" "}
|
||||||
|
<a
|
||||||
|
href="https://ir.freedomholdingcorp.com/"
|
||||||
|
style={{ color: "#16A34A" }}
|
||||||
|
>
|
||||||
|
{"Freedom Holding Corp Investor Relations"}
|
||||||
|
</a>
|
||||||
|
</ChartDesc>
|
||||||
|
</Text18_400>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenSecondBlock id="business_areas">
|
<ScreenSecondBlock id="business_areas">
|
||||||
<ContentCard>
|
<ContentCard>
|
||||||
|
|
@ -214,13 +283,16 @@ function ScreenThree() {
|
||||||
</HeaderRow>
|
</HeaderRow>
|
||||||
|
|
||||||
<ChartAndStats>
|
<ChartAndStats>
|
||||||
{/* График — оставляем как есть */}
|
|
||||||
<ChartWrapper>
|
<ChartWrapper>
|
||||||
<TradingViewChart />
|
<ChartWrapperIn>
|
||||||
|
<TradingViewChart />
|
||||||
|
</ChartWrapperIn>
|
||||||
|
{isMobile && ChartFooter}
|
||||||
</ChartWrapper>
|
</ChartWrapper>
|
||||||
|
|
||||||
{/* Карточки + индексы из CMS */}
|
|
||||||
<StatsWrapper>
|
<StatsWrapper>
|
||||||
|
<InfoWrapper>
|
||||||
|
<TradingViewSymbolInfo />
|
||||||
|
</InfoWrapper>
|
||||||
{/* Карточки — группами по 2 */}
|
{/* Карточки — группами по 2 */}
|
||||||
{cardGroups.map((row: StatCard[], ri: number) => (
|
{cardGroups.map((row: StatCard[], ri: number) => (
|
||||||
<StatCardRow key={`row-${ri}`}>
|
<StatCardRow key={`row-${ri}`}>
|
||||||
|
|
@ -274,6 +346,9 @@ function ScreenThree() {
|
||||||
) : null}
|
) : null}
|
||||||
</StatsWrapper>
|
</StatsWrapper>
|
||||||
</ChartAndStats>
|
</ChartAndStats>
|
||||||
|
|
||||||
|
{/* На десктопе футер здесь */}
|
||||||
|
{!isMobile && ChartFooter}
|
||||||
</AsyncReveal>
|
</AsyncReveal>
|
||||||
</ContentCard>
|
</ContentCard>
|
||||||
</ScreenSecondBlock>
|
</ScreenSecondBlock>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// src/sections/SolutionsSection.tsx
|
// src/sections/SolutionsSection.tsx
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import styled, { css, keyframes } from "styled-components";
|
import styled, { css } from "styled-components";
|
||||||
import {
|
import {
|
||||||
s_Text40_700 as Text40_700,
|
s_Text40_700 as Text40_700,
|
||||||
s_Text24_700 as Title18_400,
|
s_Text24_700 as Title18_400,
|
||||||
|
|
@ -94,12 +94,10 @@ const SELECTION = `
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
||||||
const GAP = 24;
|
const GAP = 24;
|
||||||
const MIN_W = 275;
|
const MIN_W = 275; // минимальная ширина карточки
|
||||||
const DESK_H = 295;
|
const DESK_H = 295;
|
||||||
const MOB_H = 266;
|
const MOB_H = 266;
|
||||||
const DURATION = 260; // ms
|
|
||||||
|
|
||||||
export default function SolutionsSection({
|
export default function SolutionsSection({
|
||||||
items: overrideItems,
|
items: overrideItems,
|
||||||
|
|
@ -111,7 +109,7 @@ export default function SolutionsSection({
|
||||||
buttonText?: string;
|
buttonText?: string;
|
||||||
}) {
|
}) {
|
||||||
/* ===== получаем данные из CMS ===== */
|
/* ===== получаем данные из CMS ===== */
|
||||||
const { data: section, loading, error } = useSingleton<CmsSolutionsSection>(
|
const { data: section, error } = useSingleton<CmsSolutionsSection>(
|
||||||
"SolutionsSection",
|
"SolutionsSection",
|
||||||
SELECTION
|
SELECTION
|
||||||
);
|
);
|
||||||
|
|
@ -123,12 +121,12 @@ export default function SolutionsSection({
|
||||||
title: it.title,
|
title: it.title,
|
||||||
text: it.text ?? "",
|
text: it.text ?? "",
|
||||||
href: it.href ?? undefined,
|
href: it.href ?? undefined,
|
||||||
image: it.image?.url ?? "", // у тебя /images проксируются — отдаст абсолютный путь
|
image: it.image?.url ?? "",
|
||||||
})),
|
})),
|
||||||
[section]
|
[section]
|
||||||
);
|
);
|
||||||
|
|
||||||
// что показываем в итоге
|
// итоговые данные
|
||||||
const usedItems: SolutionItem[] =
|
const usedItems: SolutionItem[] =
|
||||||
overrideItems && overrideItems.length > 0
|
overrideItems && overrideItems.length > 0
|
||||||
? overrideItems
|
? overrideItems
|
||||||
|
|
@ -142,87 +140,108 @@ export default function SolutionsSection({
|
||||||
const buttonHref = section?.buttonHref ?? "#";
|
const buttonHref = section?.buttonHref ?? "#";
|
||||||
|
|
||||||
const viewportRef = useRef<HTMLDivElement>(null);
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
const rowRef = useRef<HTMLDivElement>(null);
|
||||||
|
const firstFrameRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const VISIBLE = Math.min(3, usedItems.length);
|
|
||||||
|
|
||||||
// текущая левая граница «окна»
|
|
||||||
const [start, setStart] = useState(0);
|
|
||||||
// вычисленная ширина карточки в пикселях
|
// вычисленная ширина карточки в пикселях
|
||||||
const [cardW, setCardW] = useState<number>(MIN_W - 3.33);
|
const [cardW, setCardW] = useState<number>(MIN_W - 3.33);
|
||||||
// показываем ли горизонтальный скролл (если 3×MIN_W + 2×GAP не влезают)
|
|
||||||
const [scroll, setScroll] = useState(false);
|
|
||||||
|
|
||||||
// анимация
|
// состояния краёв для дизейбла стрелок
|
||||||
const [animating, setAnimating] = useState(false);
|
const [atStart, setAtStart] = useState(true);
|
||||||
const [mode, setMode] = useState<"next" | "prev">("next");
|
const [atEnd, setAtEnd] = useState(false);
|
||||||
const [overlayItems, setOverlayItems] = useState<SolutionItem[]>([]);
|
|
||||||
|
|
||||||
// пересчёт cardW и scroll (фиксированные пиксели)
|
// шаг прокрутки — по факту DOM: ширина первой карточки + реальный gap
|
||||||
|
const getStep = () => {
|
||||||
|
const row = rowRef.current;
|
||||||
|
const first = firstFrameRef.current;
|
||||||
|
const gap =
|
||||||
|
row ? parseFloat(getComputedStyle(row).gap || "0") : GAP;
|
||||||
|
const w =
|
||||||
|
first ? Math.round(first.getBoundingClientRect().width) : Math.round(cardW);
|
||||||
|
return w + gap;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
|
||||||
|
|
||||||
|
const scrollToIndex = (idx: number) => {
|
||||||
|
const el = viewportRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const step = getStep();
|
||||||
|
const maxLeft = el.scrollWidth - el.clientWidth;
|
||||||
|
const left = clamp(idx * step, 0, Math.max(0, maxLeft));
|
||||||
|
el.scrollTo({ left, behavior: "smooth" });
|
||||||
|
// обновим края после инерции
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const eps = 2;
|
||||||
|
setAtStart(el.scrollLeft <= eps);
|
||||||
|
setAtEnd(el.scrollLeft + el.clientWidth >= el.scrollWidth - eps);
|
||||||
|
}, 250);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIndex = () => {
|
||||||
|
const el = viewportRef.current;
|
||||||
|
if (!el) return 0;
|
||||||
|
const step = getStep();
|
||||||
|
return Math.round(el.scrollLeft / step);
|
||||||
|
};
|
||||||
|
|
||||||
|
// пересчёт cardW (фиксированные пиксели) + актуализация краёв
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = viewportRef.current;
|
const el = viewportRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
|
const syncEdges = () => {
|
||||||
|
const eps = 2;
|
||||||
|
const sl = el.scrollLeft;
|
||||||
|
const cw = el.clientWidth;
|
||||||
|
const sw = el.scrollWidth;
|
||||||
|
setAtStart(sl <= eps);
|
||||||
|
setAtEnd(sl + cw >= sw - eps);
|
||||||
|
};
|
||||||
|
|
||||||
const recompute = () => {
|
const recompute = () => {
|
||||||
const w = el.clientWidth;
|
const w = el.clientWidth;
|
||||||
const need = 3 * MIN_W + 2 * GAP;
|
const need = 3 * MIN_W + 2 * GAP; // хотим 3 колонки, если помещаются
|
||||||
if (w >= need) {
|
if (w >= need) {
|
||||||
// тянем три колонки на всю ширину контейнера, но в ПИКСЕЛЯХ
|
|
||||||
const px = Math.floor((w - 2 * GAP) / 3) - 3.33;
|
const px = Math.floor((w - 2 * GAP) / 3) - 3.33;
|
||||||
setCardW(px);
|
setCardW(px);
|
||||||
setScroll(false);
|
|
||||||
} else {
|
} else {
|
||||||
setCardW(MIN_W - 3.33);
|
setCardW(MIN_W - 3.33);
|
||||||
setScroll(true);
|
|
||||||
}
|
}
|
||||||
|
syncEdges();
|
||||||
};
|
};
|
||||||
|
|
||||||
recompute();
|
recompute();
|
||||||
const ro = new ResizeObserver(recompute);
|
const ro = new ResizeObserver(recompute);
|
||||||
ro.observe(el);
|
ro.observe(el);
|
||||||
|
|
||||||
|
el.addEventListener("scroll", syncEdges, { passive: true });
|
||||||
window.addEventListener("orientationchange", recompute);
|
window.addEventListener("orientationchange", recompute);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
|
el.removeEventListener("scroll", syncEdges);
|
||||||
window.removeEventListener("orientationchange", recompute);
|
window.removeEventListener("orientationchange", recompute);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// держим start в допустимых пределах при изменении количества карточек
|
// при изменении количества карточек / ширины карточки — обновим края
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const maxStart = Math.max(0, usedItems.length - VISIBLE);
|
const el = viewportRef.current;
|
||||||
if (start > maxStart) setStart(maxStart);
|
if (!el) return;
|
||||||
}, [usedItems.length, VISIBLE, start]);
|
const eps = 2;
|
||||||
|
setAtStart(el.scrollLeft <= eps);
|
||||||
const atStart = start === 0;
|
setAtEnd(el.scrollLeft + el.clientWidth >= el.scrollWidth - eps);
|
||||||
const atEnd = start + VISIBLE >= usedItems.length;
|
}, [usedItems.length, cardW]);
|
||||||
|
|
||||||
const windowItems = usedItems.slice(start, start + VISIBLE);
|
|
||||||
|
|
||||||
const animateNext = () => {
|
|
||||||
if (animating || atEnd || VISIBLE < 3) return;
|
|
||||||
setMode("next");
|
|
||||||
setAnimating(true);
|
|
||||||
// порядок 4-х: [L, M1, M2, E]
|
|
||||||
const entering = usedItems[start + VISIBLE]; // новая справа
|
|
||||||
setOverlayItems([windowItems[0], windowItems[1], windowItems[2], entering]);
|
|
||||||
window.setTimeout(() => {
|
|
||||||
setStart((s) => s + 1);
|
|
||||||
setAnimating(false);
|
|
||||||
setOverlayItems([]);
|
|
||||||
}, DURATION);
|
|
||||||
};
|
|
||||||
|
|
||||||
const animatePrev = () => {
|
const animatePrev = () => {
|
||||||
if (animating || atStart || VISIBLE < 3) return;
|
if (atStart) return;
|
||||||
setMode("prev");
|
scrollToIndex(getIndex() - 1);
|
||||||
setAnimating(true);
|
};
|
||||||
// порядок 4-х: [E, M1, M2, L]
|
|
||||||
const entering = usedItems[start - 1]; // новая слева
|
const animateNext = () => {
|
||||||
setOverlayItems([entering, windowItems[0], windowItems[1], windowItems[2]]);
|
if (atEnd) return;
|
||||||
window.setTimeout(() => {
|
scrollToIndex(getIndex() + 1);
|
||||||
setStart((s) => s - 1);
|
|
||||||
setAnimating(false);
|
|
||||||
setOverlayItems([]);
|
|
||||||
}, DURATION);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -232,14 +251,14 @@ export default function SolutionsSection({
|
||||||
<Controls>
|
<Controls>
|
||||||
<ArrowBtn
|
<ArrowBtn
|
||||||
aria-label="Prev"
|
aria-label="Prev"
|
||||||
disabled={atStart || animating}
|
disabled={atStart}
|
||||||
onClick={animatePrev}
|
onClick={animatePrev}
|
||||||
>
|
>
|
||||||
<ArrowIcon $left src={arrowRight} alt="" />
|
<ArrowIcon $left src={arrowRight} alt="" />
|
||||||
</ArrowBtn>
|
</ArrowBtn>
|
||||||
<ArrowBtn
|
<ArrowBtn
|
||||||
aria-label="Next"
|
aria-label="Next"
|
||||||
disabled={atEnd || animating}
|
disabled={atEnd}
|
||||||
onClick={animateNext}
|
onClick={animateNext}
|
||||||
>
|
>
|
||||||
<ArrowIcon src={arrowRight} alt="" />
|
<ArrowIcon src={arrowRight} alt="" />
|
||||||
|
|
@ -247,15 +266,14 @@ export default function SolutionsSection({
|
||||||
</Controls>
|
</Controls>
|
||||||
</Header>
|
</Header>
|
||||||
|
|
||||||
<Viewport ref={viewportRef} $scroll={scroll}>
|
<Viewport ref={viewportRef}>
|
||||||
{/* базовый ряд из 3 карточек фиксированной ширины в пикселях */}
|
<Row ref={rowRef} $gap={GAP}>
|
||||||
<Row
|
{usedItems.map((it, i) => (
|
||||||
$gap={GAP}
|
<Frame
|
||||||
$hidden={animating}
|
key={it.id}
|
||||||
style={!scroll ? undefined : { width: `${3 * MIN_W + 2 * GAP}px` }}
|
ref={i === 0 ? firstFrameRef : undefined}
|
||||||
>
|
style={{ width: `${cardW}px` }}
|
||||||
{windowItems.map((it) => (
|
>
|
||||||
<Frame key={it.id} style={{ width: `${cardW}px` }}>
|
|
||||||
<CardStatic
|
<CardStatic
|
||||||
$img={it.image}
|
$img={it.image}
|
||||||
$deskH={DESK_H}
|
$deskH={DESK_H}
|
||||||
|
|
@ -270,7 +288,7 @@ export default function SolutionsSection({
|
||||||
<CardLink href={it.href}>
|
<CardLink href={it.href}>
|
||||||
<Text18_400
|
<Text18_400
|
||||||
as="span"
|
as="span"
|
||||||
style={{ textDecoration: "underline", color:'#fff' }}
|
style={{ textDecoration: "underline", color: "#fff" }}
|
||||||
>
|
>
|
||||||
Learn more
|
Learn more
|
||||||
</Text18_400>
|
</Text18_400>
|
||||||
|
|
@ -281,37 +299,6 @@ export default function SolutionsSection({
|
||||||
</Frame>
|
</Frame>
|
||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{/* оверлей-анимация на 4 фреймах, ширины — строго в пикселях */}
|
|
||||||
{animating && overlayItems.length === 4 && (
|
|
||||||
<AnimOverlay
|
|
||||||
$gap={GAP}
|
|
||||||
$mode={mode}
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
width: `${(scroll ? MIN_W : cardW) * 3 + GAP * 2}px`,
|
|
||||||
["--w" as any]: `${scroll ? MIN_W : cardW}px`,
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{overlayItems.map((it, idx) => (
|
|
||||||
<AnimFrame key={`${it.id}-${idx}`}>
|
|
||||||
<CardStatic
|
|
||||||
$img={it.image}
|
|
||||||
$deskH={DESK_H}
|
|
||||||
$mobH={MOB_H}
|
|
||||||
style={{ width: `${scroll ? MIN_W : cardW}px` }}
|
|
||||||
>
|
|
||||||
<Overlay />
|
|
||||||
<CardBody>
|
|
||||||
<CardTitle as="h3">{it.title}</CardTitle>
|
|
||||||
<CardText as="p">{it.text}</CardText>
|
|
||||||
</CardBody>
|
|
||||||
</CardStatic>
|
|
||||||
</AnimFrame>
|
|
||||||
))}
|
|
||||||
</AnimOverlay>
|
|
||||||
)}
|
|
||||||
</Viewport>
|
</Viewport>
|
||||||
|
|
||||||
<ActionRow>
|
<ActionRow>
|
||||||
|
|
@ -336,9 +323,9 @@ const Section = styled.section`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 0px 64px;
|
padding: 64px 0px 64px;
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
padding: 0 0px 48px;
|
padding: 48px 0px 48px;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -382,16 +369,19 @@ const ArrowIcon = styled.img<{ $left?: boolean }>`
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
${(p) => p.$left && css`
|
${(p) =>
|
||||||
transform: rotate(180deg);
|
p.$left &&
|
||||||
`}
|
css`
|
||||||
|
transform: rotate(180deg);
|
||||||
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Viewport = styled.div<{ $scroll: boolean }>`
|
const Viewport = styled.div`
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow-x: ${(p) => (p.$scroll ? "auto" : "hidden")};
|
overflow-x: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding-left: 10px; /* переносим левый отступ сюда, чтобы он не влиял на вычисление шага */
|
||||||
|
|
||||||
/* скрываем скроллбар */
|
/* скрываем скроллбар */
|
||||||
scrollbar-width: none; /* Firefox */
|
scrollbar-width: none; /* Firefox */
|
||||||
|
|
@ -401,11 +391,9 @@ const Viewport = styled.div<{ $scroll: boolean }>`
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Row = styled.div<{ $gap: number; $hidden?: boolean }>`
|
const Row = styled.div<{ $gap: number }>`
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: ${(p) => p.$gap}px;
|
gap: ${(p) => p.$gap}px;
|
||||||
visibility: ${(p) => (p.$hidden ? "hidden" : "visible")};
|
|
||||||
margin-left:10px;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Frame = styled.div`
|
const Frame = styled.div`
|
||||||
|
|
@ -480,66 +468,9 @@ const ActionRow = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
a{
|
a {
|
||||||
width:100%;
|
width: 100%;
|
||||||
margin-right:10px;
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const leavePx = keyframes`
|
|
||||||
0% { width: var(--w); }
|
|
||||||
50% { width: calc(var(--w) * 0.5); }
|
|
||||||
100% { width: 0px; }
|
|
||||||
`;
|
|
||||||
const enterPx = keyframes`
|
|
||||||
0% { width: 0px; }
|
|
||||||
50% { width: calc(var(--w) * 0.5); }
|
|
||||||
100% { width: var(--w); }
|
|
||||||
`;
|
|
||||||
|
|
||||||
const AnimOverlay = styled.div<{ $gap: number; $mode: "next" | "prev" }>`
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
gap: ${(p) => p.$gap}px;
|
|
||||||
|
|
||||||
& > div {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
width: var(--w);
|
|
||||||
overflow-x: hidden;
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
${(p) =>
|
|
||||||
p.$mode === "next" &&
|
|
||||||
css`
|
|
||||||
& > div:nth-child(1) {
|
|
||||||
animation: ${leavePx} ${DURATION}ms ease forwards;
|
|
||||||
}
|
|
||||||
& > div:nth-child(4) {
|
|
||||||
animation: ${enterPx} ${DURATION}ms ease forwards;
|
|
||||||
width: 0;
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
|
|
||||||
${(p) =>
|
|
||||||
p.$mode === "prev" &&
|
|
||||||
css`
|
|
||||||
& > div:nth-child(1) {
|
|
||||||
animation: ${enterPx} ${DURATION}ms ease forwards;
|
|
||||||
width: 0;
|
|
||||||
}
|
|
||||||
& > div:nth-child(4) {
|
|
||||||
animation: ${leavePx} ${DURATION}ms ease forwards;
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const AnimFrame = ({ children }: { children: React.ReactNode }) => (
|
|
||||||
<div>
|
|
||||||
<div style={{ width: "var(--w)" }}>{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,196 @@
|
||||||
|
// src/components/Textarea.tsx
|
||||||
|
import React from "react";
|
||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
type Props = Omit<
|
||||||
|
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||||
|
"placeholder"
|
||||||
|
> & {
|
||||||
|
label: string;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
errorText?: string;
|
||||||
|
/** максимум строк для автосайза (по умолчанию 4) */
|
||||||
|
maxRows?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* базовый textarea — в тех же цветах и шрифтах, что и Input */
|
||||||
|
const TextareaEl = styled.textarea`
|
||||||
|
width: 100%;
|
||||||
|
min-height: 56px;
|
||||||
|
height: auto;
|
||||||
|
border: 0;
|
||||||
|
outline: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f5f8fb;
|
||||||
|
padding: 23px 16px 8px; /* сверху место под плавающий лейбл */
|
||||||
|
color: #24303b;
|
||||||
|
resize: none; /* управляем высотой сами */
|
||||||
|
overflow-y: hidden; /* до достижения maxRows не показываем скролл */
|
||||||
|
|
||||||
|
font-family: "GTAmerica", sans-serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 24px;
|
||||||
|
|
||||||
|
transition: box-shadow 0.18s ease, background 0.18s ease;
|
||||||
|
|
||||||
|
/* нужен пустой placeholder, чтобы работал :placeholder-shown */
|
||||||
|
&::placeholder {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.22);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Label = styled.label`
|
||||||
|
position: absolute;
|
||||||
|
left: 16px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
transform-origin: left top;
|
||||||
|
background:#f5f8fb;
|
||||||
|
padding: 5px 0px;
|
||||||
|
padding-right:10px;
|
||||||
|
color: #1d2023;
|
||||||
|
font-family: "GTAmerica", sans-serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 24px;
|
||||||
|
border-radius:5px;
|
||||||
|
|
||||||
|
pointer-events: none;
|
||||||
|
transition: top 0.18s ease, transform 0.18s ease, font-size 0.18s ease,
|
||||||
|
line-height 0.18s ease;
|
||||||
|
|
||||||
|
/* плавающий лейбл — при фокусе или когда поле не пустое */
|
||||||
|
${TextareaEl}:focus + &,
|
||||||
|
${TextareaEl}:not(:placeholder-shown) + & {
|
||||||
|
top: 1px;
|
||||||
|
transform: none;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Field = styled.div<{ $full?: boolean; $error?: boolean }>`
|
||||||
|
position: relative;
|
||||||
|
width: ${({ $full }) => ($full ? "100%" : "auto")};
|
||||||
|
|
||||||
|
${({ $error }) =>
|
||||||
|
$error
|
||||||
|
? `
|
||||||
|
${TextareaEl} { box-shadow: 0 0 0 2px rgba(220, 38, 38, .25); }
|
||||||
|
${Label} { color: #dc2626; }
|
||||||
|
`
|
||||||
|
: ""}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ErrorText = styled.div`
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #dc2626;
|
||||||
|
font-family: "GTAmerica", sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
function useMergedRefs<T>(
|
||||||
|
...refs: Array<React.Ref<T> | undefined>
|
||||||
|
): React.RefCallback<T> {
|
||||||
|
return React.useCallback(
|
||||||
|
(value: T) => {
|
||||||
|
refs.forEach((ref) => {
|
||||||
|
if (!ref) return;
|
||||||
|
if (typeof ref === "function") ref(value);
|
||||||
|
else (ref as React.MutableRefObject<T | null>).current = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[refs]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, Props>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
id,
|
||||||
|
fullWidth,
|
||||||
|
errorText,
|
||||||
|
maxRows = 4,
|
||||||
|
maxLength = 500,
|
||||||
|
onChange,
|
||||||
|
onInput,
|
||||||
|
style,
|
||||||
|
...rest
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const autoId = React.useId();
|
||||||
|
const textareaId = id ?? autoId;
|
||||||
|
const innerRef = React.useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
const mergedRef = useMergedRefs(ref, innerRef);
|
||||||
|
|
||||||
|
const resize = React.useCallback(() => {
|
||||||
|
const el = innerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
// предварительно сбрасываем высоту, чтобы корректно считать scrollHeight
|
||||||
|
el.style.height = "auto";
|
||||||
|
|
||||||
|
const cs = window.getComputedStyle(el);
|
||||||
|
const lineHeight = parseFloat(cs.lineHeight || "24") || 24;
|
||||||
|
const paddingTop = parseFloat(cs.paddingTop || "0");
|
||||||
|
const paddingBottom = parseFloat(cs.paddingBottom || "0");
|
||||||
|
const borderTop = parseFloat(cs.borderTopWidth || "0");
|
||||||
|
const borderBottom = parseFloat(cs.borderBottomWidth || "0");
|
||||||
|
|
||||||
|
const maxPx = lineHeight * maxRows + paddingTop + paddingBottom + borderTop + borderBottom;
|
||||||
|
|
||||||
|
const next = Math.min(el.scrollHeight, Math.ceil(maxPx));
|
||||||
|
el.style.height = `${next}px`;
|
||||||
|
|
||||||
|
// если достигли предела — позволяем вертикальный скролл
|
||||||
|
el.style.overflowY = el.scrollHeight > maxPx ? "auto" : "hidden";
|
||||||
|
}, [maxRows]);
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
resize();
|
||||||
|
}, [resize]);
|
||||||
|
|
||||||
|
const handleInput: React.FormEventHandler<HTMLTextAreaElement> = (e) => {
|
||||||
|
resize();
|
||||||
|
onInput?.(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange: React.ChangeEventHandler<HTMLTextAreaElement> = (e) => {
|
||||||
|
// лишняя защита: обрезаем вручную, если вдруг превысили
|
||||||
|
if (e.currentTarget.value.length > maxLength) {
|
||||||
|
e.currentTarget.value = e.currentTarget.value.slice(0, maxLength);
|
||||||
|
}
|
||||||
|
onChange?.(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field $full={fullWidth} $error={!!errorText}>
|
||||||
|
{/* placeholder=" " — чтобы работал :placeholder-shown */}
|
||||||
|
<TextareaEl
|
||||||
|
id={textareaId}
|
||||||
|
ref={mergedRef}
|
||||||
|
maxLength={maxLength}
|
||||||
|
rows={1}
|
||||||
|
placeholder=" "
|
||||||
|
onInput={handleInput}
|
||||||
|
onChange={handleChange}
|
||||||
|
style={style}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={textareaId}>{label}</Label>
|
||||||
|
{errorText && <ErrorText>{errorText}</ErrorText>}
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Textarea.displayName = "Textarea";
|
||||||
|
export default Textarea;
|
||||||