diff --git a/backend/keystone.db b/backend/keystone.db index 63b0a7f..f3dcb7c 100644 Binary files a/backend/keystone.db and b/backend/keystone.db differ diff --git a/backend/public/images/1yNJP5inlOb3GDZpP_e-0w.png b/backend/public/images/1yNJP5inlOb3GDZpP_e-0w.png deleted file mode 100644 index 8f4ffee..0000000 Binary files a/backend/public/images/1yNJP5inlOb3GDZpP_e-0w.png and /dev/null differ diff --git a/backend/public/images/4cdUeLOY0rFNjm2pcUE8ig.png b/backend/public/images/4cdUeLOY0rFNjm2pcUE8ig.png new file mode 100644 index 0000000..8f908d0 Binary files /dev/null and b/backend/public/images/4cdUeLOY0rFNjm2pcUE8ig.png differ diff --git a/backend/schema.graphql b/backend/schema.graphql index debd8d0..6d2ffd9 100644 --- a/backend/schema.graphql +++ b/backend/schema.graphql @@ -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 diff --git a/backend/schema.prisma b/backend/schema.prisma index 5f4b018..8996952 100644 --- a/backend/schema.prisma +++ b/backend/schema.prisma @@ -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("") diff --git a/backend/schema.ts b/backend/schema.ts index 10ce217..89c0e35 100644 --- a/backend/schema.ts +++ b/backend/schema.ts @@ -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(), diff --git a/landing/public/Favicon.png b/landing/public/Favicon.png new file mode 100644 index 0000000..a2a9408 Binary files /dev/null and b/landing/public/Favicon.png differ diff --git a/landing/public/_favicon/apple-touch-icon.png b/landing/public/_favicon/apple-touch-icon.png new file mode 100644 index 0000000..16b24a0 Binary files /dev/null and b/landing/public/_favicon/apple-touch-icon.png differ diff --git a/landing/public/_favicon/favicon-96x96.png b/landing/public/_favicon/favicon-96x96.png new file mode 100644 index 0000000..61bffcc Binary files /dev/null and b/landing/public/_favicon/favicon-96x96.png differ diff --git a/landing/public/_favicon/favicon.ico b/landing/public/_favicon/favicon.ico new file mode 100644 index 0000000..1c73fcf Binary files /dev/null and b/landing/public/_favicon/favicon.ico differ diff --git a/landing/public/_favicon/favicon.svg b/landing/public/_favicon/favicon.svg new file mode 100644 index 0000000..0143c6f --- /dev/null +++ b/landing/public/_favicon/favicon.svg @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/landing/public/_favicon/site.webmanifest b/landing/public/_favicon/site.webmanifest new file mode 100644 index 0000000..ccf313a --- /dev/null +++ b/landing/public/_favicon/site.webmanifest @@ -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" +} \ No newline at end of file diff --git a/landing/public/_favicon/web-app-manifest-192x192.png b/landing/public/_favicon/web-app-manifest-192x192.png new file mode 100644 index 0000000..29eb369 Binary files /dev/null and b/landing/public/_favicon/web-app-manifest-192x192.png differ diff --git a/landing/public/_favicon/web-app-manifest-512x512.png b/landing/public/_favicon/web-app-manifest-512x512.png new file mode 100644 index 0000000..657e69d Binary files /dev/null and b/landing/public/_favicon/web-app-manifest-512x512.png differ diff --git a/landing/public/favicon/apple-touch-icon.png b/landing/public/favicon/apple-touch-icon.png new file mode 100644 index 0000000..ade7ec8 Binary files /dev/null and b/landing/public/favicon/apple-touch-icon.png differ diff --git a/landing/public/favicon/favicon-96x96.png b/landing/public/favicon/favicon-96x96.png new file mode 100644 index 0000000..54a7e10 Binary files /dev/null and b/landing/public/favicon/favicon-96x96.png differ diff --git a/landing/public/favicon/favicon.ico b/landing/public/favicon/favicon.ico new file mode 100644 index 0000000..198d72a Binary files /dev/null and b/landing/public/favicon/favicon.ico differ diff --git a/landing/public/favicon/favicon.svg b/landing/public/favicon/favicon.svg new file mode 100644 index 0000000..cf88d97 --- /dev/null +++ b/landing/public/favicon/favicon.svg @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/landing/public/favicon/site.webmanifest b/landing/public/favicon/site.webmanifest new file mode 100644 index 0000000..ccf313a --- /dev/null +++ b/landing/public/favicon/site.webmanifest @@ -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" +} \ No newline at end of file diff --git a/landing/public/favicon/web-app-manifest-192x192.png b/landing/public/favicon/web-app-manifest-192x192.png new file mode 100644 index 0000000..681bd5d Binary files /dev/null and b/landing/public/favicon/web-app-manifest-192x192.png differ diff --git a/landing/public/favicon/web-app-manifest-512x512.png b/landing/public/favicon/web-app-manifest-512x512.png new file mode 100644 index 0000000..fa5cae1 Binary files /dev/null and b/landing/public/favicon/web-app-manifest-512x512.png differ diff --git a/landing/public/index.html b/landing/public/index.html index aa069f2..39f73ca 100644 --- a/landing/public/index.html +++ b/landing/public/index.html @@ -2,7 +2,11 @@ - + + + + + +
+ + + + ); +} + + function App() { const [loading, setLoading] = useState(false); @@ -56,9 +70,11 @@ function App() { return ( + } /> } /> + } /> ); diff --git a/landing/src/ScrollToHashElement.tsx b/landing/src/ScrollToHashElement.tsx new file mode 100644 index 0000000..04164ad --- /dev/null +++ b/landing/src/ScrollToHashElement.tsx @@ -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; diff --git a/landing/src/components/ContactFormSection/index.tsx b/landing/src/components/ContactFormSection/index.tsx index 23641a5..d50f042 100644 --- a/landing/src/components/ContactFormSection/index.tsx +++ b/landing/src/components/ContactFormSection/index.tsx @@ -7,6 +7,7 @@ import { } from "../Typography"; import Input from "../Input"; import CountrySelect from "../CountrySelect"; +import Textarea from "../Textarea"; type GqlResponse = { data?: T; errors?: Array<{ message: string }> }; @@ -19,16 +20,14 @@ mutation CreateContact($data: ContactRequestCreateInput!) { function smoothScrollToContact(offset: number = 50) { const el = document.querySelector("#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(query: string, variables?: Record) { 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(null); + + // refs контейнеров полей — чтобы проскроллить к первому с ошибкой + const nameRef = React.useRef(null); + const countryRef = React.useRef(null); + const emailRef = React.useRef(null); + const phoneRef = React.useRef(null); + const messageRef = React.useRef(null); + const consentsRef = React.useRef(null); + const [busy, setBusy] = React.useState(false); - const [error, setError] = React.useState(null); const [sent, setSent] = React.useState(false); + const [submitError, setSubmitError] = React.useState(null); // ошибка отправки (сервер/сеть) // состояния согласий const [agreePrivacy, setAgreePrivacy] = React.useState(true); const [agreeComms, setAgreeComms] = React.useState(true); + // ошибки по полям + const [errors, setErrors] = React.useState({}); + + /** Снять ошибку конкретного поля (используем в 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) => { 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 ? (

Thank you!

-

- Your request has been submitted. Our team will contact you shortly. -

+

Your request has been submitted. Our team will contact you shortly.

) : (
{/* имя — на 2 колонки */} - - + + + clearFieldError("name")} + /> + {errors.name ? {errors.name} : null} + {/* страна — на 2 колонки, обязательный выбор из списка */} - - + + + clearFieldError("country")} + /> + {errors.country ? {errors.country} : null} + - {/* 2-я строка: 2 колонки */} - - + {/* 2-я строка: 2 колонки (необязательные) */} +
+ + + +
+
+ + + +
{/* 3-я строка: 2 колонки */} - - +
+ + clearFieldError("email")} + /> + {errors.email ? {errors.email} : null} + +
+ +
+ + clearFieldError("phone")} + /> + {errors.phone ? {errors.phone} : null} + +
+ + + +