UPD
|
Before Width: | Height: | Size: 611 B |
|
After Width: | Height: | Size: 883 B |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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("")
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
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,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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 |
|
|
@ -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">
|
||||
<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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
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;
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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`
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||