This commit is contained in:
Raphael Elita 2025-08-22 16:40:24 +02:00
parent d22ef62afb
commit e653b9a225
35 changed files with 1063 additions and 336 deletions

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 611 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 883 B

View File

@ -1597,6 +1597,7 @@ type ContactRequest {
id: ID!
name: String
company: String
message: String
position: String
email: String
phone: String
@ -1615,6 +1616,7 @@ input ContactRequestWhereInput {
id: IDFilter
name: StringFilter
company: StringFilter
message: StringFilter
position: StringFilter
email: StringFilter
phone: StringFilter
@ -1626,6 +1628,7 @@ input ContactRequestOrderByInput {
id: OrderDirection
name: OrderDirection
company: OrderDirection
message: OrderDirection
position: OrderDirection
email: OrderDirection
phone: OrderDirection
@ -1636,6 +1639,7 @@ input ContactRequestOrderByInput {
input ContactRequestUpdateInput {
name: String
company: String
message: String
position: String
email: String
phone: String
@ -1651,6 +1655,7 @@ input ContactRequestUpdateArgs {
input ContactRequestCreateInput {
name: String
company: String
message: String
position: String
email: String
phone: String

View File

@ -284,6 +284,7 @@ model ContactRequest {
id String @id @default(cuid())
name String @default("")
company String @default("")
message String @default("")
position String @default("")
email String @default("")
phone String @default("")

View File

@ -720,13 +720,14 @@ export const lists: Lists = {
ui: {
label: 'Contact requests',
listView: {
initialColumns: ['createdAt', 'name', 'email', 'company', 'countryCode'],
initialColumns: ['createdAt', 'name', 'email', 'company', 'message', 'countryCode'],
initialSort: { field: 'createdAt', direction: 'DESC' },
},
},
fields: {
name: text({ validation: { isRequired: true } }),
company: text(),
message: text(),
position: text(),
email: text({ validation: { isRequired: true } }),
phone: text(),

BIN
landing/public/Favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -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

View File

@ -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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -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,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"></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

View File

@ -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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

View File

@ -2,7 +2,11 @@
<html lang="en">
<head>
<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="theme-color" content="#000000" />
<meta

View File

@ -5,6 +5,8 @@ import Loader from "./components/Loader";
import Header from "./components/Header";
import FooterSection from "./components/FooterSection";
import ScrollToHashElement from "./ScrollToHashElement";
// Страницы/секции
import ScreenFirst from "./components/ScreenFirst";
import ScreenSecond from "./components/ScreenSecond";
@ -18,6 +20,7 @@ import LeadershipSection from "./components/LeadershipSection";
import NewsSection from "./components/NewsSection";
import ContactFormSection from "./components/ContactFormSection";
import Privacy from "./components/Privacy";
import Disclaimer from "./components/Disclaimer";
@ -49,6 +52,17 @@ function PrivacyPage() {
);
}
function DisclaimerPage() {
return (
<>
<Header />
<Disclaimer/>
<FooterSection />
</>
);
}
function App() {
const [loading, setLoading] = useState(false);
@ -56,9 +70,11 @@ function App() {
return (
<Router>
<ScrollToHashElement offset={50} />
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/privacy" element={<PrivacyPage />} />
<Route path="/disclaimer" element={<DisclaimerPage />} />
</Routes>
</Router>
);

View File

@ -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;

View File

@ -7,6 +7,7 @@ import {
} from "../Typography";
import Input from "../Input";
import CountrySelect from "../CountrySelect";
import Textarea from "../Textarea";
type GqlResponse<T> = { data?: T; errors?: Array<{ message: string }> };
@ -19,16 +20,14 @@ mutation CreateContact($data: ContactRequestCreateInput!) {
function smoothScrollToContact(offset: number = 50) {
const el = document.querySelector<HTMLElement>("#contact");
if (!el) return;
const top = el.getBoundingClientRect().top + window.scrollY - offset;
window.scrollTo({ top, behavior: "smooth" });
}
const top =
el.getBoundingClientRect().top + window.scrollY - offset;
console.log(top);
window.scrollTo({
top,
behavior: "smooth",
});
/** Плавный скролл к элементу с учётом отступа (например, под фикс-хедер) */
function smoothScrollToEl(el: HTMLElement, offset: number = 150) {
const top = el.getBoundingClientRect().top + window.scrollY - offset;
window.scrollTo({ top, behavior: "smooth" });
}
// универсальный helper
@ -43,22 +42,73 @@ async function fetchGraphQL<T>(query: string, variables?: Record<string, any>) {
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 userLocale =
typeof navigator !== "undefined" ? navigator.language : "en";
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 [error, setError] = React.useState<string | null>(null);
const [sent, setSent] = React.useState(false);
const [submitError, setSubmitError] = React.useState<string | null>(null); // ошибка отправки (сервер/сеть)
// состояния согласий
const [agreePrivacy, setAgreePrivacy] = 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>) => {
e.preventDefault();
setError(null);
setSubmitError(null);
setErrors({});
const form = formRef.current;
if (!form) return;
@ -71,17 +121,40 @@ const ContactFormSection: React.FC = () => {
const position = String(fd.get("position") || "").trim();
const email = String(fd.get("email") || "").trim();
const phone = String(fd.get("phone") || "").trim();
const message = String(fd.get("message") || "").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.");
// валидация по полям
const nextErrors: FieldErrors = {};
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) {
return setError("Please accept both consents.");
nextErrors.consents = "Please accept both consents.";
}
if (Object.keys(nextErrors).length) {
setErrors(nextErrors);
scrollToFirstError(nextErrors);
return;
}
try {
@ -98,18 +171,19 @@ const ContactFormSection: React.FC = () => {
position,
email,
phone,
message,
},
}
);
setSent(true); // прячем форму, показываем «спасибо»
form.reset(); // чистим поля, на всякий
// на всякий возвращаем чекбоксы в true (форма уже скрыта)
setSent(true); // прячем форму, показываем «спасибо»
form.reset(); // чистим поля, на всякий
setAgreePrivacy(true);
setAgreeComms(true);
setErrors({});
smoothScrollToContact(150);
} catch (err: any) {
setError(err?.message || "Failed to submit. Please try again.");
setSubmitError(err?.message || "Failed to submit. Please try again.");
} finally {
setBusy(false);
}
@ -131,61 +205,118 @@ const ContactFormSection: React.FC = () => {
{sent ? (
<ThanksBox>
<h3>Thank you!</h3>
<p>
Your request has been submitted. Our team will contact you shortly.
</p>
<p>Your request has been submitted. Our team will contact you shortly.</p>
</ThanksBox>
) : (
<Form ref={formRef} onSubmit={onSubmit} noValidate>
<Grid>
{/* имя — на 2 колонки */}
<ColSpan2>
<Input label="Your name" fullWidth name="name" required />
<ColSpan2 ref={nameRef}>
<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>
{/* страна — на 2 колонки, обязательный выбор из списка */}
<ColSpan2>
<CountrySelect
label="Country"
name="country"
locale={userLocale}
fullWidth
/>
<ColSpan2 ref={countryRef}>
<Field invalid={!!errors.country}>
<CountrySelect
label="Country"
name="country"
locale={userLocale}
fullWidth
aria-invalid={!!errors.country}
onChange={() => clearFieldError("country")}
/>
{errors.country ? <ErrorText>{errors.country}</ErrorText> : null}
</Field>
</ColSpan2>
{/* 2-я строка: 2 колонки */}
<Input label="Company" fullWidth name="company" />
<Input label="Position" fullWidth name="position" />
{/* 2-я строка: 2 колонки (необязательные) */}
<div>
<Field>
<Input label="Company" fullWidth name="company" />
</Field>
</div>
<div>
<Field>
<Input label="Position" fullWidth name="position" />
</Field>
</div>
{/* 3-я строка: 2 колонки */}
<Input
label="Business email"
type="email"
fullWidth
name="email"
autoComplete="email"
required
/>
<Input
label="Telephone"
type="tel"
fullWidth
name="phone"
autoComplete="tel"
/>
<div ref={emailRef}>
<Field invalid={!!errors.email}>
<Input
label="Business email"
type="email"
fullWidth
name="email"
autoComplete="email"
required
aria-invalid={!!errors.email}
onChange={() => clearFieldError("email")}
/>
{errors.email ? <ErrorText>{errors.email}</ErrorText> : null}
</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>
{error ? <ErrorNote>{error}</ErrorNote> : null}
{/* ошибка уровня формы (сервер/сеть) */}
{submitError ? <GlobalErrorNote>{submitError}</GlobalErrorNote> : null}
{/* согласия */}
<Consents>
<Consents ref={consentsRef}>
<ConsentRow>
<Checkbox
type="checkbox"
id="c1"
name="consentPrivacy"
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">
I accept the{" "}
@ -202,13 +333,20 @@ const ContactFormSection: React.FC = () => {
id="c2"
name="consentComms"
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">
I confirm my consent to receive information from the company via
the provided contact methods of Email and/or Telephone
</label>
</ConsentRow>
{errors.consents ? <ErrorText>{errors.consents}</ErrorText> : null}
</Consents>
<Actions>
@ -230,7 +368,10 @@ const Wrap = styled.section`
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 0 10px 72px;
padding: 64px 10px 72px;
@media (max-width: 768px) {
padding: 48px 10px 48px;
}
`;
const Form = styled.form`
@ -255,8 +396,27 @@ const ColSpan2 = styled.div`
}
`;
const ErrorNote = styled.div`
margin-top: 8px;
/** Обёртка поля: если invalid=true — подсветка (оставил отключённой по вашему текущему коду) */
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;
font-size: 14px;
`;

View File

@ -33,7 +33,7 @@ const clamp = (n: number, min: number, max: number) =>
type CmsItem = { id: string; text: string; order: number };
type CmsCountry = {
id: string;
code: "usa" | "kaz" | "uzb" | "kgz" | "tjk" | "tur" | "cyp" | "arm" | "uae";
code: any;
name: string;
isEnabled: boolean;
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"] },
];
// Сопоставление названия из 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 =================== */
function mapCountries(section?: CmsSection | null): CountryCard[] {
const list = section?.countries ?? [];
@ -171,7 +200,28 @@ export default function CountriesMap() {
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 mobile = typeof window !== "undefined" ? window.innerWidth < 700 : false;
@ -220,7 +270,8 @@ export default function CountriesMap() {
{({ geographies }: { geographies: any[] }) =>
geographies.map((geo: any) => {
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;
return (
<Geography
@ -230,14 +281,14 @@ export default function CountriesMap() {
onMouseLeave={() => setHovered((prev) => (prev === name ? null : prev))}
style={{
default: {
fill: isActive ? "#0E7C44" : isAlways ? "#2EA86B" : "#CFE1E7",
fill: isActive ? "#16a34a" : isAlways ? "#16a48c" : "#CFE1E7",
stroke: "#FFFFFF",
strokeWidth: 0.6,
outline: "none",
transition: "fill .2s ease",
},
hover: { fill: "#0E7C44", outline: "none" },
pressed: { fill: "#0E7C44", outline: "none" },
hover: { fill: "#16a34a", outline: "none" },
pressed: { fill: "#16a34a", outline: "none" },
}}
/>
);

View File

@ -34,9 +34,9 @@ const Section = styled.section`
width: 100%;
max-width: 1220px;
margin: 0 auto;
padding: 64px 10px 64px;
padding: 64px 10px 0px;
@media (max-width: 768px) {
padding: 48px 10px;
padding: 48px 10px 0px;
}
`;

View File

@ -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;
}
`;

View File

@ -3,19 +3,16 @@ import React, { useMemo } from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
import {
s_f_Text18_400 as Text18_400,
s_Text14_400 as Text14_400,
s_Text12_400 as Text12_400,
} from "../Typography";
import { useSingleton, useCollection } from "cms/factory";
import { DocumentRenderer, DocumentRendererProps } from "@keystone-6/document-renderer";
/* ============ CMS selections ============ */
type CmsSystem = {
id: string;
footerLogo?: { url?: string | null } | null;
footerDisclaimer?: { document: { content: any } } | null;
linedUrl?: string | null;
};
@ -31,7 +28,6 @@ type CmsNavItem = {
const SYS_SEL = `
id
footerLogo { url }
footerDisclaimer { document }
linedUrl
`;
@ -43,12 +39,12 @@ const NAV_SEL = `
/* ============ Component ============ */
const FooterSection: React.FC = () => {
// CMS
const { data: system } = useSingleton<CmsSystem>("System", SYS_SEL);
const { data: navItems } = useCollection<CmsNavItem>("NavItem", NAV_SEL);
const logoSrc = system?.footerLogo?.url || "/imgs/logo.svg";
const linkedinUrl = system?.linedUrl || "#";
const year = new Date().getFullYear();
const items = useMemo(() => {
if (!navItems) return [];
@ -67,63 +63,27 @@ const FooterSection: React.FC = () => {
<Wrap>
<Inner>
<Top>
<Left>
<LogoRow >
<Logo
onClick={() => {
window.location.href = "/#freedom";
}}
style={{ cursor:'pointer' }}
src={logoSrc}
alt="Logo"
/>
<LogoRow>
<Logo
onClick={() => (window.location.href = "/#freedom")}
style={{ cursor: "pointer" }}
src={logoSrc}
alt="Logo"
/>
</LogoRow>
</LogoRow>
{/* Копирайт — отдельная секция, чтобы на мобиле идти выше меню */}
<CopyMobile>{`© ${year} Freedom Telecom International. All Rights Reserved`}</CopyMobile>
<Nav>
{items.map(i => (
<NavLink key={i.id} href={i.href}>
{i.label}
</NavLink>
))}
</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>
<Nav>
{items.map(i => (
<NavLink key={i.id} href={i.href}>
{i.label}
</NavLink>
))}
</Nav>
{/* Правый верх — соцсети */}
<Socials>
<SocialBtn
as="a"
@ -137,6 +97,21 @@ const FooterSection: React.FC = () => {
</SocialBtn>
</Socials>
</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>
</Wrap>
);
@ -151,7 +126,7 @@ const Wrap = styled.footer`
background: #1d2023;
color: #ffffff;
padding: 64px 40px;
@media (max-width: 700px) {
@media (max-width: 950px) {
padding: 32px 40px;
}
`;
@ -162,15 +137,27 @@ const Inner = 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`
margin-bottom: 16px;
padding:8px 0px;
grid-area: logo;
padding: 8px 0;
`;
const Logo = styled.img`
@ -180,19 +167,31 @@ const Logo = styled.img`
`;
const Nav = styled.nav`
grid-area: nav;
display: flex;
flex-wrap: wrap;
gap: 16px 24px;
margin-bottom: 16px;
margin-top: 4px;
margin-right: auto;
@media (max-width: 520px) {
@media (max-width: 950px) {
flex-direction: column;
gap: 12px;
margin:32px 0px;
}
`;
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;
text-decoration: none;
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;
text-align: left;
`;
const Divider = styled.hr`
border: 0;
border-top: 1px solid #414141;
margin: 16px 0;
const CopyDesktop = styled(CopyBase)`
display: block;
font-size: 14px;
@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;
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};
color: #acacad;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
text-decoration: underline;
text-decoration: none;
text-underline-offset: 2px;
&:hover {
opacity: 0.9;
text-decoration: underline;
opacity: 0.95;
}
`;
const Socials = styled.div`
position: absolute;
right: 0;
top: 0;
@media (max-width: 950px) {
position: absolute;
right: 0;
top: 0;
}
`;
const SocialBtn = styled.button`

View File

@ -52,7 +52,7 @@ const InnovationSection: React.FC = () => {
const imgUrl = data?.topLeftImage?.url || "/imgs/image_1.png";
return (
<Section>
<Section id="innovation">
<TitleRow>
<Text40_700>
{data?.title ?? "Innovation at the intersection of telecom and fintech"}
@ -126,10 +126,10 @@ export default InnovationSection;
const Section = styled.section`
width: 100%;
max-width: 1220px;
padding: 64px 10px 64px;
padding: 64px 10px 0px;
margin: 0 auto;
@media (max-width: 768px) {
padding: 48px 10px 48px;
padding: 48px 10px 0px;
}
`;

View File

@ -114,13 +114,13 @@ const CARD_SHADOW = "0px 4px 27.3px 0px #00000014";
const Section = styled.section`
width: 100%;
max-width: 1220px;
padding: 64px 0px;
padding: 64px 0px 28px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 0;
@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;`)}
margin: 0 auto;
cursor: pointer;
@media (max-width: 768px) {
flex: 0 0
${({ $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`
display: inline-block; /* ← важно для columns */
width: 100%;
margin: 0 0 32px; /* ← вертикальный зазор между карточками */
break-inside: avoid; /* стандарт */
@ -241,7 +241,7 @@ const Bullets = styled.ul`
margin-top: 24px;
display: grid;
grid-template-columns: 1fr 1fr; /* 2 колонки */
grid-template-columns: auto auto; /* 2 колонки */
gap: 16px 24px;
@media (max-width: 900px) {
@ -456,7 +456,7 @@ export default function KeyBusinessSegments() {
const activeSegment = segments[Math.min(Math.max(realActiveIndex, 0), N - 1)];
return (
<Section>
<Section id="key_business">
<HeaderRow>
<Text40_700>{data?.title || "Key business segments"}</Text40_700>
</HeaderRow>
@ -470,7 +470,7 @@ export default function KeyBusinessSegments() {
key={seg.id}
$active={isActive}
onMouseEnter={() => {
if (!isOverflow && !isMobile) setHoverIndex(idx);
if (!isOverflow && !isMobile) setHoverIndex(null);
}}
onMouseLeave={() => {
if (!isOverflow && !isMobile) setHoverIndex(null);

View File

@ -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);

View File

@ -1,4 +1,5 @@
// src/components/ScreenThree/index.tsx
import React from "react";
import styled from "styled-components";
import {
s_f_Text18_400 as Text18_400,
@ -7,6 +8,7 @@ import {
s_Text14_400 as Text14_400,
} from "../Typography";
import TradingViewChart from "./TradingViewChart";
import TradingViewSymbolInfo from "./TradingViewSymbolInfo";
import { useSingleton } from "cms/factory";
import { AsyncReveal } from "../AsyncReveal";
@ -51,6 +53,22 @@ function chunkInto<T>(arr: T[], size: number): T[][] {
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 ===================== */
const ScreenSecondBlock = styled.div`
width: 100%;
@ -87,7 +105,7 @@ const HeaderRow = styled.div`
flex-direction: column-reverse;
align-items: flex-start;
gap: 12px;
margin-bottom: 32px;
margin-bottom: 32px;
}
`;
@ -109,16 +127,33 @@ const ChartAndStats = 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;
border: 1px solid #F5F8FB;
border-radius: 8px;
@media (max-width: 768px) {
width: 100%;
height: 436px; /* фиксированная высота вместо min-height */
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`
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`
display: flex;
flex-direction: row;
gap: 24px;
align-items: stretch;
display:none;
@media (max-width: 768px) {
width: 100%;
@ -189,6 +242,22 @@ function ScreenThree() {
const cardGroups = chunkInto<StatCard>(s3?.statCards ?? [], 2);
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 (
<ScreenSecondBlock id="business_areas">
<ContentCard>
@ -214,13 +283,16 @@ function ScreenThree() {
</HeaderRow>
<ChartAndStats>
{/* График — оставляем как есть */}
<ChartWrapper>
<TradingViewChart />
<ChartWrapperIn>
<TradingViewChart />
</ChartWrapperIn>
{isMobile && ChartFooter}
</ChartWrapper>
{/* Карточки + индексы из CMS */}
<StatsWrapper>
<InfoWrapper>
<TradingViewSymbolInfo />
</InfoWrapper>
{/* Карточки — группами по 2 */}
{cardGroups.map((row: StatCard[], ri: number) => (
<StatCardRow key={`row-${ri}`}>
@ -274,6 +346,9 @@ function ScreenThree() {
) : null}
</StatsWrapper>
</ChartAndStats>
{/* На десктопе футер здесь */}
{!isMobile && ChartFooter}
</AsyncReveal>
</ContentCard>
</ScreenSecondBlock>

View File

@ -1,6 +1,6 @@
// src/sections/SolutionsSection.tsx
import React, { useEffect, useMemo, useRef, useState } from "react";
import styled, { css, keyframes } from "styled-components";
import styled, { css } from "styled-components";
import {
s_Text40_700 as Text40_700,
s_Text24_700 as Title18_400,
@ -94,12 +94,10 @@ const SELECTION = `
}
`;
const GAP = 24;
const MIN_W = 275;
const MIN_W = 275; // минимальная ширина карточки
const DESK_H = 295;
const MOB_H = 266;
const DURATION = 260; // ms
export default function SolutionsSection({
items: overrideItems,
@ -111,7 +109,7 @@ export default function SolutionsSection({
buttonText?: string;
}) {
/* ===== получаем данные из CMS ===== */
const { data: section, loading, error } = useSingleton<CmsSolutionsSection>(
const { data: section, error } = useSingleton<CmsSolutionsSection>(
"SolutionsSection",
SELECTION
);
@ -123,12 +121,12 @@ export default function SolutionsSection({
title: it.title,
text: it.text ?? "",
href: it.href ?? undefined,
image: it.image?.url ?? "", // у тебя /images проксируются — отдаст абсолютный путь
image: it.image?.url ?? "",
})),
[section]
);
// что показываем в итоге
// итоговые данные
const usedItems: SolutionItem[] =
overrideItems && overrideItems.length > 0
? overrideItems
@ -142,87 +140,108 @@ export default function SolutionsSection({
const buttonHref = section?.buttonHref ?? "#";
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);
// показываем ли горизонтальный скролл (если 3×MIN_W + 2×GAP не влезают)
const [scroll, setScroll] = useState(false);
// анимация
const [animating, setAnimating] = useState(false);
const [mode, setMode] = useState<"next" | "prev">("next");
const [overlayItems, setOverlayItems] = useState<SolutionItem[]>([]);
// состояния краёв для дизейбла стрелок
const [atStart, setAtStart] = useState(true);
const [atEnd, setAtEnd] = useState(false);
// пересчёт 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(() => {
const el = viewportRef.current;
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 w = el.clientWidth;
const need = 3 * MIN_W + 2 * GAP;
const need = 3 * MIN_W + 2 * GAP; // хотим 3 колонки, если помещаются
if (w >= need) {
// тянем три колонки на всю ширину контейнера, но в ПИКСЕЛЯХ
const px = Math.floor((w - 2 * GAP) / 3) - 3.33;
setCardW(px);
setScroll(false);
} else {
setCardW(MIN_W - 3.33);
setScroll(true);
}
syncEdges();
};
recompute();
const ro = new ResizeObserver(recompute);
ro.observe(el);
el.addEventListener("scroll", syncEdges, { passive: true });
window.addEventListener("orientationchange", recompute);
return () => {
ro.disconnect();
el.removeEventListener("scroll", syncEdges);
window.removeEventListener("orientationchange", recompute);
};
}, []);
// держим start в допустимых пределах при изменении количества карточек
// при изменении количества карточек / ширины карточки — обновим края
useEffect(() => {
const maxStart = Math.max(0, usedItems.length - VISIBLE);
if (start > maxStart) setStart(maxStart);
}, [usedItems.length, VISIBLE, start]);
const atStart = start === 0;
const atEnd = start + VISIBLE >= usedItems.length;
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 el = viewportRef.current;
if (!el) return;
const eps = 2;
setAtStart(el.scrollLeft <= eps);
setAtEnd(el.scrollLeft + el.clientWidth >= el.scrollWidth - eps);
}, [usedItems.length, cardW]);
const animatePrev = () => {
if (animating || atStart || VISIBLE < 3) return;
setMode("prev");
setAnimating(true);
// порядок 4-х: [E, M1, M2, L]
const entering = usedItems[start - 1]; // новая слева
setOverlayItems([entering, windowItems[0], windowItems[1], windowItems[2]]);
window.setTimeout(() => {
setStart((s) => s - 1);
setAnimating(false);
setOverlayItems([]);
}, DURATION);
if (atStart) return;
scrollToIndex(getIndex() - 1);
};
const animateNext = () => {
if (atEnd) return;
scrollToIndex(getIndex() + 1);
};
return (
@ -232,14 +251,14 @@ export default function SolutionsSection({
<Controls>
<ArrowBtn
aria-label="Prev"
disabled={atStart || animating}
disabled={atStart}
onClick={animatePrev}
>
<ArrowIcon $left src={arrowRight} alt="" />
</ArrowBtn>
<ArrowBtn
aria-label="Next"
disabled={atEnd || animating}
disabled={atEnd}
onClick={animateNext}
>
<ArrowIcon src={arrowRight} alt="" />
@ -247,15 +266,14 @@ export default function SolutionsSection({
</Controls>
</Header>
<Viewport ref={viewportRef} $scroll={scroll}>
{/* базовый ряд из 3 карточек фиксированной ширины в пикселях */}
<Row
$gap={GAP}
$hidden={animating}
style={!scroll ? undefined : { width: `${3 * MIN_W + 2 * GAP}px` }}
>
{windowItems.map((it) => (
<Frame key={it.id} style={{ width: `${cardW}px` }}>
<Viewport ref={viewportRef}>
<Row ref={rowRef} $gap={GAP}>
{usedItems.map((it, i) => (
<Frame
key={it.id}
ref={i === 0 ? firstFrameRef : undefined}
style={{ width: `${cardW}px` }}
>
<CardStatic
$img={it.image}
$deskH={DESK_H}
@ -270,7 +288,7 @@ export default function SolutionsSection({
<CardLink href={it.href}>
<Text18_400
as="span"
style={{ textDecoration: "underline", color:'#fff' }}
style={{ textDecoration: "underline", color: "#fff" }}
>
Learn more
</Text18_400>
@ -281,37 +299,6 @@ export default function SolutionsSection({
</Frame>
))}
</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>
<ActionRow>
@ -336,9 +323,9 @@ const Section = styled.section`
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 0 0px 64px;
padding: 64px 0px 64px;
@media (max-width: 768px) {
padding: 0 0px 48px;
padding: 48px 0px 48px;
}
`;
@ -382,16 +369,19 @@ const ArrowIcon = styled.img<{ $left?: boolean }>`
width: 10px;
height: 20px;
object-fit: contain;
${(p) => p.$left && css`
transform: rotate(180deg);
`}
${(p) =>
p.$left &&
css`
transform: rotate(180deg);
`}
`;
const Viewport = styled.div<{ $scroll: boolean }>`
const Viewport = styled.div`
position: relative;
width: 100%;
overflow-x: ${(p) => (p.$scroll ? "auto" : "hidden")};
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-left: 10px; /* переносим левый отступ сюда, чтобы он не влиял на вычисление шага */
/* скрываем скроллбар */
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;
gap: ${(p) => p.$gap}px;
visibility: ${(p) => (p.$hidden ? "hidden" : "visible")};
margin-left:10px;
`;
const Frame = styled.div`
@ -480,66 +468,9 @@ const ActionRow = styled.div`
display: flex;
padding-left: 10px;
@media (max-width: 768px) {
a{
width:100%;
margin-right:10px;
a {
width: 100%;
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>
);

View File

@ -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;