diff --git a/apps/xi.land/app/(withhero)/page.tsx b/apps/xi.land/app/(withhero)/page.tsx index af04c362..8bf286b4 100644 --- a/apps/xi.land/app/(withhero)/page.tsx +++ b/apps/xi.land/app/(withhero)/page.tsx @@ -1,13 +1,17 @@ import dynamic from 'next/dynamic'; -import { FeaturesBlock, Text, MessagesBlock } from 'components/main'; +import { + CapabilitiesBlock, + CommunityBlock, + DevicesBlock, + MessagesBlock, + TutorIdeasBlock, +} from 'components/main'; import { Metadata } from 'next'; import Script from 'next/script'; // Ниже первого экрана — выносим в отдельные чанки, чтобы не раздувать основной бандл -const Benefits = dynamic(() => import('components/main').then((m) => m.Benefits), { ssr: true }); const Faq = dynamic(() => import('components/main').then((m) => m.Faq), { ssr: true }); -const Telegram = dynamic(() => import('components/main').then((m) => m.Telegram), { ssr: true }); export const metadata: Metadata = { title: 'Проводите уроки онлайн. Платформа для репетиторов sovlium', @@ -91,16 +95,12 @@ export default function MainPage() { dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} />
+ + + - - - + -

Расписание всегда под контролем

Планируйте работу на день, неделю, месяц и год вперёд вместе с sovlium

diff --git a/apps/xi.land/app/head.tsx b/apps/xi.land/app/head.tsx index c8730fda..84da9c77 100644 --- a/apps/xi.land/app/head.tsx +++ b/apps/xi.land/app/head.tsx @@ -1,8 +1,6 @@ export default function Head() { return ( <> - - diff --git a/apps/xi.land/app/layout.tsx b/apps/xi.land/app/layout.tsx index 7e34c914..3915d011 100644 --- a/apps/xi.land/app/layout.tsx +++ b/apps/xi.land/app/layout.tsx @@ -1,5 +1,5 @@ import { Metadata } from 'next'; -import { Inter } from 'next/font/google'; +import { Manrope } from 'next/font/google'; import localFont from 'next/font/local'; import { Header } from 'components/Header'; @@ -99,17 +99,48 @@ const markerHand = localFont({ fallback: ['cursive'], }); -const inter = Inter({ +const manrope = Manrope({ weight: ['400', '500', '600', '700'], - style: ['normal'], subsets: ['latin', 'cyrillic'], display: 'swap', - variable: '--font-inter', + variable: '--font-manrope', +}); + +const neverMind = localFont({ + src: [ + { + path: '../public/fonts/NeverMind-Regular.ttf', + weight: '400', + style: 'normal', + }, + { + path: '../public/fonts/NeverMind-Medium.ttf', + weight: '500', + style: 'normal', + }, + { + path: '../public/fonts/NeverMind-DemiBold.ttf', + weight: '600', + style: 'normal', + }, + { + path: '../public/fonts/NeverMind-Bold.ttf', + weight: '700', + style: 'normal', + }, + ], + display: 'swap', + variable: '--font-never-mind', + fallback: ['ui-sans-serif', 'system-ui', 'sans-serif'], }); export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + {process.env.NODE_ENV === 'development' ? ( <> diff --git a/apps/xi.land/components/Header.tsx b/apps/xi.land/components/Header.tsx index 0b3dd6ff..24e1d7e1 100644 --- a/apps/xi.land/components/Header.tsx +++ b/apps/xi.land/components/Header.tsx @@ -7,6 +7,7 @@ import { motion } from 'motion/react'; import { cn } from '@xipkg/utils'; import { useMediaQuery } from '@xipkg/utils'; import { Button } from '@xipkg/button'; +import { ArrowRight } from '@xipkg/icons'; import { MobileNavigation, Navigation } from './navigation'; const HIDE_AFTER_DOWN = 80; // прячем хедер, если прокрутили вниз дальше 80 px @@ -59,65 +60,81 @@ export const Header = () => { return (
-
- {isDesktop ? ( - - - logo - - - ) : ( -
- - logo - -
- )} - +
+
+
+
+ {isDesktop ? ( + + + logo + + + ) : ( +
+ + logo + +
+ )} +
-
- - -
+
+ +
- +
+ + + +
+
+
); diff --git a/apps/xi.land/components/main/Benefits/Benefits.tsx b/apps/xi.land/components/main/Benefits/Benefits.tsx deleted file mode 100644 index d212bd65..00000000 --- a/apps/xi.land/components/main/Benefits/Benefits.tsx +++ /dev/null @@ -1,66 +0,0 @@ -'use client'; - -import { motion, Variants } from 'motion/react'; - -import { Sticker } from './Sticker'; - -const MotionSticker = motion.create(Sticker); - -const stickerAnimation: Variants = { - hidden: { opacity: 0, scale: 1.2, rotate: -3 }, - visible: { - opacity: 1, - scale: 1, - rotate: 0, - transition: { - opacity: { duration: 0.6, ease: 'easeOut' }, - scale: { type: 'spring', stiffness: 120, damping: 20 }, - rotate: { type: 'spring', stiffness: 120, damping: 20 }, - }, - }, -}; - -export const Benefits = () => { - return ( -
-
-
- {/* Первый стикер - всегда сверху */} - - - {/* Второй стикер - снизу на больших экранах, по центру на мобильных */} - - - {/* Третий стикер - сверху на больших экранах */} - -
-
-
- ); -}; diff --git a/apps/xi.land/components/main/Benefits/Sticker.tsx b/apps/xi.land/components/main/Benefits/Sticker.tsx deleted file mode 100644 index 764dbc15..00000000 --- a/apps/xi.land/components/main/Benefits/Sticker.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { forwardRef } from 'react'; - -import { cn } from '@xipkg/utils'; - -import { contentStickers } from './contentStickers'; - -const stickerStyles = { - violetSticker: 'bg-violet-20 shadow-[4px_4px_10px_rgba(131,48,196,0.2)]', - greenSticker: 'bg-green-0 shadow-[4px_4px_10px_rgba(46,132,46,0.2)]', - yellowSticker: 'bg-yellow-20 shadow-[4px_4px_10px_rgba(243,201,76,0.2)]', -} as const; - -const stickerSizes = { - violetSticker: - 'xs:w-[320px] xs:h-[278px] sm:w-[378px] sm:h-[366px] lg:w-[453px] lg:h-[491px] xl:w-[490px] xl:h-[491px]', - greenSticker: - 'xs:w-[313px] xs:h-[292px] sm:w-[378px] sm:h-[406px] lg:w-[453px] lg:h-[528px] xl:w-[490px] xl:h-[507px]', - yellowSticker: - 'xs:w-[320px] xs:h-[278px] sm:w-[378px] sm:h-[366px] lg:w-[453px] lg:h-[480px] xl:w-[490px] xl:h-[480px]', -} as const; - -type StickerType = keyof typeof stickerStyles; - -export const Sticker = forwardRef( - ({ className, type }, ref) => { - return ( -
-
- {contentStickers[type]} -
-
- ); - }, -); - -Sticker.displayName = 'Sticker'; diff --git a/apps/xi.land/components/main/Benefits/contentStickers.tsx b/apps/xi.land/components/main/Benefits/contentStickers.tsx deleted file mode 100644 index e91d114a..00000000 --- a/apps/xi.land/components/main/Benefits/contentStickers.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { cn } from '@xipkg/utils'; - -const fontSticker = - 'font-family-marker-hand font-normal text-m-base xs:text-l-base sm:text-xl-base lg:text-h5'; - -const getVioletStickerContent = () => { - return ( -
-
Быстрый переход между инструментами
-
+
-
Единая рабочая среда
-
=
-
Экономия до 2 часов в неделю
-
- ); -}; - -const getGreenStickerContent = () => { - return ( -
-
Ход опыта:
-
Шаг 1. Настройте платформу
-
Шаг 2. Удалите из преподавания скуку и рутину
-
Шаг 3. Оставьте главное — удовольствие от того, что ученик понял тему
-
- ); -}; - -const getYellowStickerContent = () => { - return ( -
-
Соберите материалы и учеников в одной платформе — и обретите дзен
-
Ваши знания бесценны. Делитесь ими с комфортом
-
- ); -}; - -export const contentStickers = { - violetSticker: getVioletStickerContent(), - greenSticker: getGreenStickerContent(), - yellowSticker: getYellowStickerContent(), -}; diff --git a/apps/xi.land/components/main/Benefits/index.ts b/apps/xi.land/components/main/Benefits/index.ts deleted file mode 100644 index 67375504..00000000 --- a/apps/xi.land/components/main/Benefits/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Benefits } from './Benefits'; diff --git a/apps/xi.land/components/main/Capabilities/CapabilitiesBlock.tsx b/apps/xi.land/components/main/Capabilities/CapabilitiesBlock.tsx new file mode 100644 index 00000000..8f85e500 --- /dev/null +++ b/apps/xi.land/components/main/Capabilities/CapabilitiesBlock.tsx @@ -0,0 +1,220 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { ChevronSmallRight } from '@xipkg/icons'; +import { cn } from '@xipkg/utils'; +import { motion, useReducedMotion } from 'motion/react'; + +import { + CAPABILITIES_HEADING, + CAPABILITIES_TITLE_FULL, + CAPABILITY_CARDS, + type CapabilityCardT, +} from './capabilities_content'; + +const cardStaggerDelay = (index: number, reduceMotion: boolean | null) => + reduceMotion ? 0 : 0.11 * index; + +const cardRevealTransition = (index: number, reduceMotion: boolean | null) => + reduceMotion + ? { duration: 0 } + : { + type: 'spring' as const, + stiffness: 380, + damping: 28, + mass: 0.85, + delay: cardStaggerDelay(index, reduceMotion), + }; + +type CapabilityCardPropsT = { + card: CapabilityCardT; + className?: string; +}; + +const CapabilityCard = ({ card, className }: CapabilityCardPropsT) => { + const { Icon, text, badge } = card; + const showBadgeRow = badge === 'new' || badge === 'soon'; + + return ( +
+
+ + + + {badge === 'new' ? ( + + новинка + + ) : null} + {badge === 'soon' ? ( + + скоро будет + + ) : null} +
+

+ {text} +

+
+ ); +}; + +export const CapabilitiesBlock = () => { + const reduceMotion = useReducedMotion(); + const sectionRef = useRef(null); + const scrollRef = useRef(null); + const hintPlayedRef = useRef(false); + const [fadeRightEdge, setFadeRightEdge] = useState(true); + const [showBounceHint, setShowBounceHint] = useState(true); + + const updateScrollHint = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + const maxScroll = Math.max(0, el.scrollWidth - el.clientWidth); + const nearEnd = maxScroll <= 0 || el.scrollLeft >= maxScroll - 8; + setFadeRightEdge(!nearEnd); + }, []); + + const dismissBounceHint = useCallback(() => setShowBounceHint(false), []); + + useEffect(() => { + updateScrollHint(); + window.addEventListener('resize', updateScrollHint); + return () => window.removeEventListener('resize', updateScrollHint); + }, [updateScrollHint]); + + useEffect(() => { + const el = scrollRef.current; + const section = sectionRef.current; + if (!el || !section || reduceMotion) return; + + const timeouts: number[] = []; + const io = new IntersectionObserver( + ([entry]) => { + if (!entry?.isIntersecting || hintPlayedRef.current) return; + if (el.scrollWidth <= el.clientWidth) return; + hintPlayedRef.current = true; + timeouts.push( + window.setTimeout(() => { + el.scrollTo({ left: 52, behavior: 'smooth' }); + timeouts.push( + window.setTimeout(() => { + el.scrollTo({ left: 0, behavior: 'smooth' }); + }, 780), + ); + }, 420), + ); + }, + { threshold: 0.28 }, + ); + + io.observe(section); + return () => { + timeouts.forEach(clearTimeout); + io.disconnect(); + }; + }, [reduceMotion]); + + return ( +
+
+
+

+ {CAPABILITIES_HEADING.line1} + {CAPABILITIES_HEADING.line2} +

+ +
+ {CAPABILITY_CARDS.map((card, index) => ( + + + + ))} +
+ +
+
+ + + + + +
+ {CAPABILITY_CARDS.map((card, index) => ( + + + + ))} +
+
+
+
+
+ ); +}; diff --git a/apps/xi.land/components/main/Capabilities/capabilities_content.ts b/apps/xi.land/components/main/Capabilities/capabilities_content.ts new file mode 100644 index 00000000..fc272b7d --- /dev/null +++ b/apps/xi.land/components/main/Capabilities/capabilities_content.ts @@ -0,0 +1,73 @@ +import type { ComponentType } from 'react'; +import { + Calendar, + Conference, + Notification, + Payments, + Section, + Task, + Users, + WhiteBoard, +} from '@xipkg/icons'; + +export type CapabilityIconT = ComponentType<{ className?: string }>; + +export type CapabilityCardT = { + id: string; + Icon: CapabilityIconT; + text: string; + badge?: 'new' | 'soon'; +}; + +export const CAPABILITIES_HEADING = { + line1: 'Максимум возможностей', + line2: 'вместе с\u00A0sovlium', +} as const; + +/** Полная строка для SEO / aria */ +export const CAPABILITIES_TITLE_FULL = `${CAPABILITIES_HEADING.line1} ${CAPABILITIES_HEADING.line2}`; + +export const CAPABILITY_CARDS: readonly CapabilityCardT[] = [ + { + id: 'schedule', + Icon: Calendar, + badge: 'new', + text: 'Назначайте и переносите занятия в\u00A0расписании. Ученики получат уведомления', + }, + { + id: 'materials', + Icon: WhiteBoard, + text: 'Собирайте материалы для\u00A0занятий на\u00A0онлайн-доске: картинки, аудио, PDF и другие файлы', + }, + { + id: 'calls', + Icon: Conference, + text: 'Проводите видеозвонки со\u00A0встроенной онлайн-доской и демонстрацией экрана', + }, + { + id: 'rooms', + Icon: Users, + text: 'Создавайте кабинеты для\u00A0отдельных учеников и целых групп', + }, + { + id: 'knowledge', + Icon: Section, + text: 'Создайте свою базу знаний и делитесь ей с\u00A0учениками', + }, + { + id: 'reminders', + Icon: Notification, + text: 'Настраивайте автоматические уведомления о\u00A0занятиях и оплатах', + }, + { + id: 'payments', + Icon: Payments, + text: 'Ведите учёт дохода в\u00A0sovlium с\u00A0помощью счетов, таблиц и диаграмм', + }, + { + id: 'homework', + Icon: Task, + badge: 'soon', + text: 'Задавайте интересные домашние задания — создать их можно с\u00A0помощью конструктора', + }, +]; diff --git a/apps/xi.land/components/main/Capabilities/index.ts b/apps/xi.land/components/main/Capabilities/index.ts new file mode 100644 index 00000000..e270a085 --- /dev/null +++ b/apps/xi.land/components/main/Capabilities/index.ts @@ -0,0 +1 @@ +export { CapabilitiesBlock } from './CapabilitiesBlock'; diff --git a/apps/xi.land/components/main/Community/CommunityBlock.tsx b/apps/xi.land/components/main/Community/CommunityBlock.tsx new file mode 100644 index 00000000..132bfcf2 --- /dev/null +++ b/apps/xi.land/components/main/Community/CommunityBlock.tsx @@ -0,0 +1,91 @@ +import Image from 'next/image'; +import Link from 'next/link'; +import { ArrowRight } from '@xipkg/icons'; +import { cn } from '@xipkg/utils'; + +import { + COMMUNITY_BANNER_IMAGE_SRC, + COMMUNITY_HEADING_LINES, + COMMUNITY_SUBTEXT, + COMMUNITY_TELEGRAM_HREF, + COMMUNITY_VK_HREF, +} from './community_content'; + +const communityLinkClass = + 'inline-flex w-full items-center justify-center gap-3 rounded-xl px-7 py-3.5 text-lg font-semibold leading-6 no-underline transition-opacity hover:opacity-90 sm:w-auto'; + +const CtaIconVk = () => ; + +const CtaIconTelegram = () => ; + +export const CommunityBlock = () => { + return ( +
+
+
+
+

+ {COMMUNITY_HEADING_LINES.map((line) => ( + + {line} + + ))} +

+

+ {COMMUNITY_SUBTEXT} +

+
+ +
+ Иллюстрация: сообщество репетиторов sovlium +
+ +
+ + Вконтакте + + + + Telegram + + +
+
+
+
+ ); +}; diff --git a/apps/xi.land/components/main/Community/community_content.ts b/apps/xi.land/components/main/Community/community_content.ts new file mode 100644 index 00000000..b75c327c --- /dev/null +++ b/apps/xi.land/components/main/Community/community_content.ts @@ -0,0 +1,12 @@ +export const COMMUNITY_HEADING_LINES = [ + 'Присоединяйтесь к\u00A0сообществу', + 'репетиторов', +] as const; + +export const COMMUNITY_SUBTEXT = + 'Обменивайтесь опытом и лайфхаками, читайте новости о\u00A0sovlium и просто общайтесь с\u00A0коллегами'; + +export const COMMUNITY_BANNER_IMAGE_SRC = '/assets/main/Community/main-community-banner.webp'; + +export const COMMUNITY_VK_HREF = 'https://vk.com/sovlium'; +export const COMMUNITY_TELEGRAM_HREF = 'https://t.me/sovlium'; diff --git a/apps/xi.land/components/main/Devices/DevicesBlock.tsx b/apps/xi.land/components/main/Devices/DevicesBlock.tsx new file mode 100644 index 00000000..93789bc2 --- /dev/null +++ b/apps/xi.land/components/main/Devices/DevicesBlock.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { useState } from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { ArrowRight } from '@xipkg/icons'; +import { Button } from '@xipkg/button'; +import { SwitcherAnimate } from '@xipkg/switcher-animate'; +import { cn } from '@xipkg/utils'; + +import { + DEVICE_SWITCHER_TABS, + DEVICE_VARIANT_BY_ID, + DEVICES_CTA_HREF, + DEVICES_CTA_LABEL, + DEVICES_HEADING_DESKTOP, + DEVICES_HEADING_MOBILE, + DEVICES_SUB_DESKTOP, + DEVICES_SUB_MOBILE, + DEVICES_SWITCHER_LAYOUT_ID, + type DeviceTabId, +} from './devices_content'; + +const DeviceVisual = ({ activeTab, className }: { activeTab: DeviceTabId; className?: string }) => { + const variant = DEVICE_VARIANT_BY_ID[activeTab]; + + return ( +
+ {variant.imageAlt} +
+ ); +}; + +export const DevicesBlock = () => { + const [activeTab, setActiveTab] = useState('computer'); + const variant = DEVICE_VARIANT_BY_ID[activeTab]; + + return ( +
+
+
+

+ {DEVICES_HEADING_MOBILE} +

+

+ {DEVICES_HEADING_DESKTOP} +

+

+ {DEVICES_SUB_MOBILE} +

+

+ {DEVICES_SUB_DESKTOP} +

+
+ +
+ setActiveTab(tabId as DeviceTabId)} + className={cn( + 'lg:col-start-2 lg:row-start-1', + 'w-full rounded-2xl bg-black/5 p-1 dark:bg-black/15 sm:p-1.5', + 'h-auto min-h-12 gap-1 sm:min-h-14', + )} + tabClassName={cn( + 'min-h-11 flex-1 rounded-2xl px-4 py-3 text-base font-medium leading-6', + 'sm:min-h-[52px] sm:px-5 sm:text-lg sm:leading-7', + 'text-gray-900/90 hover:text-gray-900 dark:text-gray-0/90 dark:hover:text-gray-0', + 'data-[state=active]:font-semibold data-[state=active]:text-white dark:data-[state=active]:text-white', + )} + indicatorClassName="rounded-2xl border-0 bg-brand-80 shadow-sm dark:bg-brand-80" + /> + + + +
+

+ {variant.headingLinesMobile.map((line, i) => ( + + {line} + + ))} +

+

+ {variant.headingLines.map((line, i) => ( + + {line} + + ))} +

+

+ {variant.descriptionMobile} +

+

+ {variant.description} +

+
+ +
+ +
+
+
+
+ ); +}; diff --git a/apps/xi.land/components/main/Devices/devices_content.ts b/apps/xi.land/components/main/Devices/devices_content.ts new file mode 100644 index 00000000..c904e5d9 --- /dev/null +++ b/apps/xi.land/components/main/Devices/devices_content.ts @@ -0,0 +1,73 @@ +import type { SwitcherAnimateTab } from '@xipkg/switcher-animate'; + +export type DeviceTabId = 'computer' | 'tablet'; + +/** Стабильный префикс id вкладок (`{layoutId}-tab-{id}`) для `aria-labelledby` у панели */ +export const DEVICES_SWITCHER_LAYOUT_ID = 'devices-block-switcher'; + +export const DEVICES_HEADING_DESKTOP = 'Ваш класс — весь мир'; + +export const DEVICES_SUB_DESKTOP = 'Платформа sovlium работает на\u00A0всех устройствах'; + +export const DEVICES_HEADING_MOBILE = 'Преподавайте там, где удобно'; + +export const DEVICES_SUB_MOBILE = 'Адаптировали платформу под\u00A0работу на\u00A0всех устройствах'; + +export const DEVICES_CTA_HREF = 'https://app.sovlium.ru/signup'; + +export const DEVICES_CTA_LABEL = 'Попробовать бесплатно'; + +export type DeviceVariantT = { + id: DeviceTabId; + label: string; + imageSrc: string; + imageAlt: string; + /** Строки заголовка на десктопе (каждая строка — отдельная строка блока) */ + headingLines: readonly string[]; + description: string; + /** Заголовок и текст под превью на мобилке (по макету могут отличаться от десктопа) */ + headingLinesMobile: readonly string[]; + descriptionMobile: string; + /** Позиционирование превью на десктопе */ + imageObjectClassName: string; +}; + +export const DEVICE_VARIANTS: readonly DeviceVariantT[] = [ + { + id: 'computer', + label: 'Компьютер', + imageSrc: '/assets/main/Devices/main-devices-desktop.webp', + imageAlt: 'Ноутбук с\u00A0платформой sovlium на\u00A0экране', + headingLines: ['Максимум возможностей —', 'на\u00A0большом экране'], + description: + 'Проводите онлайн-занятия, загружайте материалы и учитывайте доход в\u00A0два клика', + headingLinesMobile: ['Максимум возможностей —', 'на\u00A0большом экране'], + descriptionMobile: + 'На\u00A0компьютере доступны все инструменты для\u00A0уроков и работы с\u00A0материалами. С\u00A0телефона и планшета вы всегда на\u00A0связи с\u00A0учениками.', + imageObjectClassName: 'object-cover object-[center_38%]', + }, + { + id: 'tablet', + label: 'Планшет и телефон', + imageSrc: '/assets/main/Devices/main-devices-tablet.webp', + imageAlt: 'Планшет и смартфон с\u00A0приложением sovlium', + headingLines: ['Есть и мобильная версия'], + description: + 'Адаптировали платформу, чтобы вы могли учить из\u00A0любой точки мира. Даже в\u00A0парке или на\u00A0пляже', + headingLinesMobile: ['Всегда под\u00A0рукой'], + descriptionMobile: + 'Проверяйте уведомления, переписку и расписание в\u00A0любой момент — прямо с\u00A0телефона или планшета.', + imageObjectClassName: 'object-cover object-center', + }, +]; + +/** Данные вкладок для `@xipkg/switcher-animate` */ +export const DEVICE_SWITCHER_TABS: SwitcherAnimateTab[] = DEVICE_VARIANTS.map((v) => ({ + id: v.id, + label: v.label, +})); + +export const DEVICE_VARIANT_BY_ID: Record = { + computer: DEVICE_VARIANTS[0], + tablet: DEVICE_VARIANTS[1], +}; diff --git a/apps/xi.land/components/main/Devices/index.ts b/apps/xi.land/components/main/Devices/index.ts new file mode 100644 index 00000000..635a01fc --- /dev/null +++ b/apps/xi.land/components/main/Devices/index.ts @@ -0,0 +1 @@ +export { DevicesBlock } from './DevicesBlock'; diff --git a/apps/xi.land/components/main/Faq/Faq.tsx b/apps/xi.land/components/main/Faq/Faq.tsx index 71e976fd..64789376 100644 --- a/apps/xi.land/components/main/Faq/Faq.tsx +++ b/apps/xi.land/components/main/Faq/Faq.tsx @@ -1,36 +1,36 @@ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from 'pkg.accordion'; import { contentFaq } from './content'; -import { FaqIcon } from './FaqIcons'; export const Faq = () => { return ( -
-

- Часто задаваемые вопросы -

-
- - {contentFaq.map((item, key) => ( - - - {item.title} - - - {typeof item.texts === 'string' && item.texts.includes('|||') ? ( - item.texts - .split('|||') - .map((text, textKey) => ( -

- )) - ) : ( -

- )} -
-
- ))} -
+
+
+

+ Часто задаваемые вопросы +

+
+ + {contentFaq.map((item, key) => ( + + + {item.title} + + + {typeof item.texts === 'string' && item.texts.includes('|||') ? ( + item.texts + .split('|||') + .map((text, textKey) => ( +

+ )) + ) : ( +

+ )} + + + ))} + +

-
); }; diff --git a/apps/xi.land/components/main/Faq/FaqIcons.tsx b/apps/xi.land/components/main/Faq/FaqIcons.tsx deleted file mode 100644 index 0c8bdac9..00000000 --- a/apps/xi.land/components/main/Faq/FaqIcons.tsx +++ /dev/null @@ -1,63 +0,0 @@ -export const SpringIcon = ({ className }: { className?: string }) => { - return ( - - - - ); -}; -export const GlassesIcon = ({ className }: { className?: string }) => { - return ( - - - - ); -}; - -export const StarIcon = ({ className }: { className?: string }) => { - return ( - - - - ); -}; - -export const FaqIcon = () => { - return ( - <> - - - - - - ); -}; diff --git a/apps/xi.land/components/main/Faq/content.ts b/apps/xi.land/components/main/Faq/content.ts index 1c1b2d52..fc97463f 100644 --- a/apps/xi.land/components/main/Faq/content.ts +++ b/apps/xi.land/components/main/Faq/content.ts @@ -5,9 +5,9 @@ export const contentFaq = [ 'Мы хотим убедиться, что sovlium одинаково удобно работает в разных сценариях: для школьных занятий, языковых курсов, подготовки к экзаменам. Поэтому нам так важна живая практика. В рамках бета-тестирования вы получите полный доступ ко всем функциям sovlium, а мы попросим рассказать, насколько стабильно работает платформа, что уже нравится, а что стоит улучшить. После завершения тестов и официального релиза вы получите бонусный период использования расширенных функций в знак благодарности за помощь 😊', }, { - title: 'Есть ли какие-либо ограничения в использовании sovlium на бета-тесте?', + title: 'Есть ли какие-либо ограничения в\u00A0использовании sovlium на\u00A0бета-тесте?', texts: - 'На этом этапе платформа работает на компьютерах, ноутбуках и планшетах — в любых операционных системах. Лучше всего sovlium чувствует себя в браузерах на движке Chromium: Google Chrome, Яндекс.Браузер, Microsoft Edge и Opera. Можно использовать Firefox или Safari, но из-за особенностей этих браузеров возможны небольшие визуальные отличия.|||Полноценная мобильная версия появится после завершения бета-теста — сейчас мы фокусируемся на стабильной работе основных модулей.|||В этой версии sovlium нет расписания, оно будет реализовано позже.', + 'На этом этапе платформа работает на компьютерах, ноутбуках и планшетах — в любых операционных системах. Лучше всего sovlium чувствует себя в браузерах на движке Chromium: Google Chrome, Яндекс.Браузер, Microsoft Edge и Opera. Можно использовать Firefox или Safari, но из-за особенностей этих браузеров возможны небольшие визуальные отличия.|||Полноценная мобильная версия появится после завершения бета-теста — сейчас мы фокусируемся на стабильной работе основных модулей.|||В этой версии sovlium нет расписания, оно будет реализовано позже.', }, { title: 'Что будет, когда закончится тестирование?', @@ -21,8 +21,8 @@ export const contentFaq = [ Тариф PRO откроет максимум возможностей sovlium: больше учеников, групп и памяти для хранения материалов, а также дополнительные функции.`, }, { - title: 'Сколько будет стоить sovlium для ученика?', - texts: 'Для учеников sovlium полностью бесплатен! 😊', + title: 'Сколько будет стоить sovlium для\u00A0ученика?', + texts: 'Для учеников sovlium полностью бесплатен! 😊', }, { title: 'Нужно ли что-то устанавливать?', diff --git a/apps/xi.land/components/main/Features/Features.tsx b/apps/xi.land/components/main/Features/Features.tsx deleted file mode 100644 index fc3ae75e..00000000 --- a/apps/xi.land/components/main/Features/Features.tsx +++ /dev/null @@ -1,181 +0,0 @@ -'use client'; - -import { useRef, useState } from 'react'; -import Image from 'next/image'; -import Link from 'next/link'; -import { gsap } from 'gsap'; -import { ScrollTrigger } from 'gsap/ScrollTrigger'; -import { useGSAP } from '@gsap/react'; -import { Button } from '@xipkg/button'; -import { cn } from '@xipkg/utils'; -import { steps } from './content'; - -gsap.registerPlugin(ScrollTrigger, useGSAP); - -/* ---------- контент всех состояний ---------- */ - -export const Features = () => { - const sectionRef = useRef(null); - const titleRef = useRef(null); - const descRef = useRef(null); - const btnRef = useRef(null); - const lastStepRef = useRef(0); - const featuresTextWrapper = useRef(null); - const [currentStep, setCurrentStep] = useState(0); - - const slidesRef = useRef([]); - const collect = (el: HTMLDivElement | null) => { - if (el && !slidesRef.current.includes(el)) slidesRef.current.push(el); - }; - - /* ------------ util: смена контента + видимость кнопки ------------ */ - function setStep(idx: number) { - const { title, desc, href, cta } = steps[idx] ?? steps.at(-1)!; - - // Создаем массив элементов для анимации, исключая null - const elementsToAnimate: (HTMLElement | null)[] = [titleRef.current, descRef.current]; - if (btnRef.current) { - elementsToAnimate.push(btnRef.current); - } - const validElements = elementsToAnimate.filter(Boolean) as HTMLElement[]; - - const tl = gsap.timeline({ defaults: { duration: 0.3, ease: 'power2.out' } }); - - tl.to(validElements, { - autoAlpha: 0, - y: 20, - onComplete: () => { - if (titleRef.current) { - titleRef.current.textContent = title; - } - if (descRef.current) { - descRef.current.textContent = desc; - } - - // Обновляем состояние для корректного обновления href в Link - setCurrentStep(idx); - }, - }); - - tl.to(validElements, { - autoAlpha: 1, - y: 0, - delay: 0.05, - stagger: 0.05, - }); - } - - /* ----------------------------- GSAP ----------------------------- */ - useGSAP( - () => { - const total = slidesRef.current.length; - const triggerEnd = slidesRef.current[total - 1]; - - // Фиксация текста на протяжении всех слайдов - ScrollTrigger.create({ - trigger: sectionRef.current, - start: 'top top', - endTrigger: triggerEnd, - end: 'center center', - scrub: true, - pin: featuresTextWrapper.current, - pinSpacing: false, - }); - - // Точное переключение текста — при попадании каждого слайда в центр экрана - slidesRef.current.forEach((slide, i) => { - ScrollTrigger.create({ - trigger: slide, - start: 'center-=50% center', - end: 'center+=25% center', - onEnter: () => { - if (lastStepRef.current !== i) { - lastStepRef.current = i; - setStep(i); - } - }, - onEnterBack: () => { - if (lastStepRef.current !== i) { - lastStepRef.current = i; - setStep(i); - } - }, - onLeaveBack: () => { - if (i > 0 && lastStepRef.current !== i - 1) { - lastStepRef.current = i - 1; - setStep(i - 1); - } - }, - }); - }); - }, - { scope: sectionRef }, - ); - - /* ------------------------------ JSX ------------------------------ */ - return ( -
- {/* Левый фиксированный блок с контентом -------------------------------- */} -
-
-

- {steps[0].title} -

- -

- {steps[0].desc} -

- - {/* кнопка: скрывается через autoAlpha, а не display → плавный fade */} -
- {steps[currentStep].href && ( - - - - )} -
-
-
- - {/* Слайды с картинками -------------------------------------------------- */} - {[1, 2, 3, 4, 5, 6].map((n) => ( -
-
-
- {`feature-${n}`} -
-
-
- ))} -
- ); -}; diff --git a/apps/xi.land/components/main/Features/FeaturesBlock.tsx b/apps/xi.land/components/main/Features/FeaturesBlock.tsx deleted file mode 100644 index 4d8ecc83..00000000 --- a/apps/xi.land/components/main/Features/FeaturesBlock.tsx +++ /dev/null @@ -1,24 +0,0 @@ -'use client'; - -import dynamic from 'next/dynamic'; -import { useMediaQuery } from '@xipkg/utils'; -import { FeaturesMobile } from './FeaturesMobile'; - -// Десктопная версия тянет gsap + ScrollTrigger — загружаем лениво, чтобы не блокировать первый экран -const Features = dynamic(() => import('./Features').then((m) => m.Features), { - ssr: false, - loading: () => ( -
-
-
- ), -}); - -export const FeaturesBlock = () => { - const isMobile = useMediaQuery('(max-width: 768px)'); - - return isMobile ? : ; -}; diff --git a/apps/xi.land/components/main/Features/FeaturesMobile.tsx b/apps/xi.land/components/main/Features/FeaturesMobile.tsx deleted file mode 100644 index 1e992b88..00000000 --- a/apps/xi.land/components/main/Features/FeaturesMobile.tsx +++ /dev/null @@ -1,41 +0,0 @@ -'use client'; - -import { Button } from '@xipkg/button'; -import Link from 'next/link'; -import Image from 'next/image'; -import { steps } from './content'; - -export const FeaturesMobile = () => - steps.map((item) => ( -
-
-
- features image -
-
-
- {item.title} -
-

{item.desc}

- {item.href && ( - - - - )} -
-
-
- )); diff --git a/apps/xi.land/components/main/Features/content.ts b/apps/xi.land/components/main/Features/content.ts deleted file mode 100644 index 259e5565..00000000 --- a/apps/xi.land/components/main/Features/content.ts +++ /dev/null @@ -1,44 +0,0 @@ -export const steps = [ - { - id: 1, - title: 'Расписание всегда под контролем', - desc: 'Планируйте работу на день, неделю, месяц и год вперёд вместе с sovlium', - href: '/calendar', - cta: 'Узнать больше', - }, - { - id: 2, - title: 'Видеозвонки, которые не хочется заканчивать', - desc: 'Ваш идеальный цифровой класс. Слушайте, показывайте и объясняйте — просто и быстро', - href: '/calls', - cta: 'Узнать больше', - }, - { - id: 3, - title: 'Онлайн-доска для ваших идей', - desc: 'Готовьте уроки заранее или рисуйте на доске прямо во время видеозвонка', - href: '/whiteboard', - cta: 'Узнать больше', - }, - { - id: 4, - title: 'Собственная цифровая библиотека', - desc: 'Одна платформа вместо десяти сервисов: храните все материалы в sovlium', - href: '/materials', - cta: 'Узнать больше', - }, - { - id: 5, - title: 'Наглядная статистика заработка', - desc: 'Больше не нужно записывать каждую оплату отдельно. Платформа sovlium подсчитает всё за вас', - href: '/payments', - cta: 'Узнать больше', - }, - { - id: 6, - title: 'Работа с компьютера, телефона и планшета', - desc: 'Дома, в парке или на пляже — проводите уроки там, где удобно именно вам', - href: null, // <-- последняя: без кнопки - cta: null, - }, -]; diff --git a/apps/xi.land/components/main/Features/index.ts b/apps/xi.land/components/main/Features/index.ts deleted file mode 100644 index 95f559f3..00000000 --- a/apps/xi.land/components/main/Features/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './FeaturesBlock'; diff --git a/apps/xi.land/components/main/Hero/AnimationChem.tsx b/apps/xi.land/components/main/Hero/AnimationChem.tsx deleted file mode 100644 index b50dbbcc..00000000 --- a/apps/xi.land/components/main/Hero/AnimationChem.tsx +++ /dev/null @@ -1,80 +0,0 @@ -'use client'; - -import { useLayoutEffect, useRef } from 'react'; -import { gsap } from 'gsap'; - -export const AnimationChem = ({ active }: { active: boolean }) => { - const svgRef = useRef(null); - - useLayoutEffect(() => { - if (!active) return; - - const ctx = gsap.context(() => { - // берём все внутри svg - const paths = gsap.utils.toArray('path'); - - const tl = gsap.timeline({ defaults: { ease: 'none' } }); - - // добавляем общую задержку в 3 секунд перед началом анимации - tl.add(() => {}, 2); - - paths.forEach((path: SVGPathElement) => { - const length = path.getTotalLength(); - // получаем задержку из data-атрибута (в секундах) - const delay = parseFloat(path.getAttribute('data-delay') || '0'); - - // стартовое состояние — невидимая линия - gsap.set(path, { - strokeDasharray: length, - strokeDashoffset: length, - }); - - // «рисуем» путь с возможной задержкой - tl.to( - path, - { - strokeDashoffset: 0, - duration: length / 120, // скорость: 250 px/сек; подстройте по вкусу - }, - `+=${delay}`, // добавляем задержку перед началом анимации - ); - }); - }, svgRef); - - // очистка при размонтировании - return () => ctx.revert(); - }, [active]); - - return ( - - - - - - - - ); -}; diff --git a/apps/xi.land/components/main/Hero/AnimationEng.tsx b/apps/xi.land/components/main/Hero/AnimationEng.tsx deleted file mode 100644 index 68021f88..00000000 --- a/apps/xi.land/components/main/Hero/AnimationEng.tsx +++ /dev/null @@ -1,63 +0,0 @@ -'use client'; - -import { useLayoutEffect, useRef } from 'react'; -import { gsap } from 'gsap'; - -export const AnimationEng = ({ active }: { active: boolean }) => { - const svgRef = useRef(null); - - useLayoutEffect(() => { - if (!active) return; - - const ctx = gsap.context(() => { - // берём все внутри svg - const paths = gsap.utils.toArray('path'); - - const tl = gsap.timeline({ defaults: { ease: 'none' } }); - - // добавляем общую задержку в 3 секунд перед началом анимации - tl.add(() => {}, 5); - - paths.forEach((path: SVGPathElement) => { - const length = path.getTotalLength(); - // получаем задержку из data-атрибута (в секундах) - const delay = parseFloat(path.getAttribute('data-delay') || '0'); - - // стартовое состояние — невидимая линия - gsap.set(path, { - strokeDasharray: length, - strokeDashoffset: length, - }); - - // «рисуем» путь с возможной задержкой - tl.to( - path, - { - strokeDashoffset: 0, - duration: length / 250, // скорость: 250 px/сек; подстройте по вкусу - }, - `+=${delay}`, // добавляем задержку перед началом анимации - ); - }); - }, svgRef); - - // очистка при размонтировании - return () => ctx.revert(); - }, [active]); - - return ( - - - - ); -}; diff --git a/apps/xi.land/components/main/Hero/AnimationHistory.tsx b/apps/xi.land/components/main/Hero/AnimationHistory.tsx deleted file mode 100644 index 04974227..00000000 --- a/apps/xi.land/components/main/Hero/AnimationHistory.tsx +++ /dev/null @@ -1,67 +0,0 @@ -'use client'; - -import { useLayoutEffect, useRef } from 'react'; -import { gsap } from 'gsap'; - -export const AnimationHistory = ({ active }: { active: boolean }) => { - const svgRef = useRef(null); - - useLayoutEffect(() => { - if (!active) return; - - const ctx = gsap.context(() => { - // берём все внутри svg - const paths = gsap.utils.toArray('path'); - - const tl = gsap.timeline({ defaults: { ease: 'none' } }); - - // добавляем общую задержку в 3 секунд перед началом анимации - tl.add(() => {}, 6); - - paths.forEach((path: SVGPathElement) => { - const length = path.getTotalLength(); - // получаем задержку из data-атрибута (в секундах) - const delay = parseFloat(path.getAttribute('data-delay') || '0'); - - // стартовое состояние — невидимая линия - gsap.set(path, { - strokeDasharray: length, - strokeDashoffset: length, - }); - - // «рисуем» путь с возможной задержкой - tl.to( - path, - { - strokeDashoffset: 0, - duration: length / 20, // скорость: 250 px/сек; подстройте по вкусу - }, - `+=${delay}`, // добавляем задержку перед началом анимации - ); - }); - }, svgRef); - - // очистка при размонтировании - return () => ctx.revert(); - }, [active]); - - return ( - - - - ); -}; diff --git a/apps/xi.land/components/main/Hero/AnimationMath.tsx b/apps/xi.land/components/main/Hero/AnimationMath.tsx deleted file mode 100644 index 977c7b9e..00000000 --- a/apps/xi.land/components/main/Hero/AnimationMath.tsx +++ /dev/null @@ -1,238 +0,0 @@ -'use client'; - -import { useLayoutEffect, useRef } from 'react'; -import { gsap } from 'gsap'; - -export const AnimationMath = ({ active }: { active: boolean }) => { - const svgRef = useRef(null); - - useLayoutEffect(() => { - if (!active) return; - - const ctx = gsap.context(() => { - // берём все внутри svg - const paths = gsap.utils.toArray('path'); - - const tl = gsap.timeline({ defaults: { ease: 'none' } }); - - // добавляем общую задержку в 3 секунд перед началом анимации - tl.add(() => {}, 3); - - paths.forEach((path: SVGPathElement) => { - const length = path.getTotalLength(); - // получаем задержку из data-атрибута (в секундах) - const delay = parseFloat(path.getAttribute('data-delay') || '0'); - - // стартовое состояние — невидимая линия - gsap.set(path, { - strokeDasharray: length, - strokeDashoffset: length, - }); - - // «рисуем» путь с возможной задержкой - tl.to( - path, - { - strokeDashoffset: 0, - duration: length / 350, // скорость: 250 px/сек; подстройте по вкусу - }, - `+=${delay}`, // добавляем задержку перед началом анимации - ); - }); - }, svgRef); - - // очистка при размонтировании - return () => ctx.revert(); - }, [active]); - - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; diff --git a/apps/xi.land/components/main/Hero/Hero.tsx b/apps/xi.land/components/main/Hero/Hero.tsx index e23a5d8f..33a4bca1 100644 --- a/apps/xi.land/components/main/Hero/Hero.tsx +++ b/apps/xi.land/components/main/Hero/Hero.tsx @@ -1,378 +1,219 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ -// @ts-nocheck 'use client'; -import Link from 'next/link'; -import dynamic from 'next/dynamic'; - -// CSS fallback для случаев без JavaScript -const fallbackStyles = ` - @media (prefers-reduced-motion: reduce) { - .motion-fallback { - opacity: 1 !important; - transform: none !important; - transition: none !important; - } - } - - .no-js .motion-fallback { - opacity: 1 !important; - transform: none !important; - } -`; - -const subTitle = [ - { - id: 1, - text: 'видеозвонки', - textColor: 'text-orange-80', - bgColor: 'bg-yellow-20', - }, - { - id: 2, - text: 'автоматические напоминания', - textColor: 'text-violet-100', - bgColor: 'bg-violet-20', - }, - { - id: 3, - text: 'онлайн-доски', - textColor: 'text-green-80', - bgColor: 'bg-green-0', - }, - { - id: 4, - text: 'заметки', - textColor: 'text-cyan-100', - bgColor: 'bg-cyan-20', - }, - { - id: 5, - text: 'контроль оплат', - textColor: 'text-red-80', - bgColor: 'bg-red-0', - }, -]; +import { createContext, useContext, useRef, type ComponentType, type PointerEvent } from 'react'; +import Link from 'next/link'; import Image from 'next/image'; -import React from 'react'; -import { motion } from 'motion/react'; import { Button } from '@xipkg/button'; -import { usePathname } from 'next/navigation'; -import { config } from './config'; -import { useMediaQuery } from '@xipkg/utils'; +import { + ArrowRight, + Calendar, + Materials, + Notification, + Payments, + Users, + Conference, + WhiteBoard, +} from '@xipkg/icons'; +import { cn } from '@xipkg/utils'; +import { + motion, + useMotionValue, + useReducedMotion, + useSpring, + useTransform, + type MotionValue, +} from 'motion/react'; + +import type { HeroFeatureIconIdT, HeroFeatureT, HeroParallaxLayerT } from './hero_content'; +import { + HERO_CONTENT, + HERO_MAIN_COLLAGE_IMAGE, + HERO_PARALLAX_INTENSITY, + HERO_PARALLAX_LAYERS, +} from './hero_content'; + +const FEATURE_ICONS: Record> = { + conference: Conference, + whiteboard: WhiteBoard, + calendar: Calendar, + materials: Materials, + payments: Payments, + notifications: Notification, + rooms: Users, +}; -// Декоративные анимации и снег загружаются лениво — не блокируют первый рендер и не тянут gsap/react-snowfall в основной бандл -const AnimationChem = dynamic(() => import('./AnimationChem').then((m) => m.AnimationChem), { - ssr: false, -}); -const AnimationMath = dynamic(() => import('./AnimationMath').then((m) => m.AnimationMath), { - ssr: false, -}); -const AnimationEng = dynamic(() => import('./AnimationEng').then((m) => m.AnimationEng), { - ssr: false, -}); -const AnimationHistory = dynamic( - () => import('./AnimationHistory').then((m) => m.AnimationHistory), - { ssr: false }, -); -const SnowAnimation = dynamic(() => import('./SnowAnimation').then((m) => m.SnowAnimation), { - ssr: false, -}); +const buttonClassName = + 'inline-flex h-auto min-h-12 w-full shrink-0 self-start rounded-2xl border-0 px-7 py-3.5 text-lg font-semibold leading-6 hover:border-0 sm:w-auto lg:max-w-[304px]'; -const HeroText = () => { - const pathname = usePathname(); - const isDesktop = useMediaQuery('(min-width: 768px)'); +type HeroParallaxContextValueT = { + moveX: MotionValue; + moveY: MotionValue; +}; - const title = config[pathname]?.title ?? config['/'].title; - const wrapperClass = 'flex flex-col items-center gap-4 md:gap-6 motion-fallback'; - const h1Class = - 'text-xl-base sm:text-h2 md:text-[64px] leading-[1.2] sm:leading-[1] md:leading-[1.05] font-semibold text-gray-0 text-center whitespace-pre-line'; +const HeroParallaxContext = createContext(null); - if (!isDesktop) { - return ( -
-

{title}

-
- {subTitle.map((item) => ( -
-

- {item.text} -

-
- ))} -
-
- ); +const useHeroParallaxMotion = () => { + const ctx = useContext(HeroParallaxContext); + if (!ctx) { + throw new Error('useHeroParallaxMotion must be used within Hero parallax provider'); } + return ctx; +}; + +type HeroParallaxLayerPropsT = { + layer: HeroParallaxLayerT; +}; + +const HeroParallaxLayer = ({ layer }: HeroParallaxLayerPropsT) => { + const { moveX, moveY } = useHeroParallaxMotion(); + const depthPx = layer.depth * (layer.depthMultiplier ?? 1) * HERO_PARALLAX_INTENSITY; + const tx = useTransform(moveX, (v) => v * depthPx); + const ty = useTransform(moveY, (v) => v * depthPx); return ( - - {title} - - -
- {subTitle.map((item, index) => ( - -

- {item.text} -

-
- ))} +
+ {layer.alt}
); }; -const Blobs = () => ( - <> -
-
- -); - -const IDLE_DELAY_MS = 400; - -export const Hero = () => { - const pathname = usePathname(); - const isDesktop = useMediaQuery('(min-width: 768px)'); - const [showDecorations, setShowDecorations] = React.useState(false); - - // Добавляем CSS fallback - React.useEffect(() => { - const style = document.createElement('style'); - style.textContent = fallbackStyles; - document.head.appendChild(style); - - return () => { - document.head.removeChild(style); - }; - }, []); - - // Декоративные анимации (gsap) подгружаем после первого кадра, чтобы не блокировать LCP - React.useEffect(() => { - const id = - typeof requestIdleCallback !== 'undefined' - ? requestIdleCallback(() => setShowDecorations(true), { timeout: IDLE_DELAY_MS }) - : (setTimeout(() => setShowDecorations(true), IDLE_DELAY_MS) as unknown as number); - - return () => { - if (typeof cancelIdleCallback !== 'undefined') { - cancelIdleCallback(id as number); - } else { - clearTimeout(id); - } - }; - }, []); +type HeroFeatureBadgePropsT = { + feature: HeroFeatureT; +}; - const heroConfig = config[pathname] ?? config['/']; - const imageSrc = heroConfig?.image ?? config['/'].image; - const imageMobileSrc = heroConfig?.imageMobile ?? config['/'].imageMobile; +const HeroFeatureBadge = ({ feature }: HeroFeatureBadgePropsT) => { + const Icon = FEATURE_ICONS[feature.id]; return ( -
-
-
- - + + + + + {feature.label} + +
+ ); +}; - {showDecorations && ( - <> - - - +export const Hero = () => { + const sectionRef = useRef(null); + const shouldReduceMotion = useReducedMotion(); + const rawX = useMotionValue(0); + const rawY = useMotionValue(0); - - - + const spring = shouldReduceMotion + ? { stiffness: 500, damping: 48, mass: 0.35 } + : { stiffness: 168, damping: 19, mass: 0.42 }; - - - + const moveX = useSpring(rawX, spring); + const moveY = useSpring(rawY, spring); - - - - - )} + const updatePointer = (e: PointerEvent) => { + if (shouldReduceMotion) { + return; + } + const el = sectionRef.current; + if (!el) { + return; + } + const r = el.getBoundingClientRect(); + const w = r.width || 1; + const h = r.height || 1; + rawX.set((e.clientX - r.left) / w - 0.5); + rawY.set((e.clientY - r.top) / h - 0.5); + }; + + const resetPointer = () => { + rawX.set(0); + rawY.set(0); + }; -
- + return ( + +
+
+
+
+
+

+ {HERO_CONTENT.title} +

+

+ {HERO_CONTENT.subtitle} +

+
- {isDesktop ? ( - - - - ) : ( -
- +
+ {HERO_CONTENT.features.map((feature) => ( + + ))}
- )} +
- {isDesktop ? ( - + - Платформа на бета-тестировании - - ) : ( - - Платформа на бета-тестировании - - )} + {HERO_CONTENT.primaryButtonLabel} + + +
- {isDesktop ? ( - - screenshot of the site - - ) : ( -
- screenshot of the site -
- )} +
+
+
+ {HERO_CONTENT.heroImageAlt} +
- {isDesktop ? ( - - screenshot of the site - - ) : ( -
- screenshot of the site + {HERO_PARALLAX_LAYERS.map((layer) => ( + + ))}
- )} +
-
-
+
+ ); }; diff --git a/apps/xi.land/components/main/Hero/SnowAnimation.tsx b/apps/xi.land/components/main/Hero/SnowAnimation.tsx deleted file mode 100644 index b80533ea..00000000 --- a/apps/xi.land/components/main/Hero/SnowAnimation.tsx +++ /dev/null @@ -1,62 +0,0 @@ -'use client'; -import React from 'react'; -import Snowfall from 'react-snowfall'; -import { useMediaQuery } from '@xipkg/utils'; - -export const SnowAnimation = () => { - const isMobile = useMediaQuery('(max-width: 768px)'); - const [showSnow, setShowSnow] = React.useState(false); - - // Проверка, находится ли текущая дата в периоде показа снега (10 декабря - 10 февраля) - const isSnowPeriod = React.useMemo(() => { - const now = new Date(); - const month = now.getMonth() + 1; // getMonth() возвращает 0-11, поэтому +1 - const day = now.getDate(); - - // Декабрь (12): с 10 числа - if (month === 12 && day >= 10) { - return true; - } - // Январь (1): весь месяц - if (month === 1) { - return true; - } - // Февраль (2): до 10 числа включительно - if (month === 2 && day <= 10) { - return true; - } - - return false; - }, []); - - // Задержка начала анимации снега на 20 секунд (только в период показа) - React.useEffect(() => { - if (!isSnowPeriod) { - return; - } - - const timer = setTimeout(() => { - setShowSnow(true); - }, 20000); - - return () => clearTimeout(timer); - }, [isSnowPeriod]); - - if (!showSnow || isMobile || !isSnowPeriod) { - return null; - } - - return ( - - ); -}; diff --git a/apps/xi.land/components/main/Hero/config.ts b/apps/xi.land/components/main/Hero/config.ts deleted file mode 100644 index 29a23908..00000000 --- a/apps/xi.land/components/main/Hero/config.ts +++ /dev/null @@ -1,47 +0,0 @@ -export const config = { - '/': { - title: 'Все инструменты репетитора \nв одной платформе', - description: - 'Видеозвонки, онлайн-доски и заметки, контроль оплат\nи автоматические напоминания для учеников — всё в одном клике', - button: 'Попробовать бесплатно', - image: '/assets/main/Hero/main.webp', - imageMobile: '/assets/main/Hero/main-mobile.webp', - }, - '/calendar': { - title: 'Расписание всегда\nпод контролем', - description: 'Планируйте работу на день, неделю, месяц и год вперёд\nвместе с sovlium', - button: 'Попробовать раньше всех', - image: '/assets/main/Hero/calendar.webp', - imageMobile: '/assets/main/Hero/calendar-mobile.webp', - }, - '/calls': { - title: 'Видеозвонки, которые\nне хочется заканчивать', - description: - 'Ваш идеальный цифровой класс.\nСлушайте, показывайте, объясняйте —\nпросто и быстро', - button: 'Попробовать раньше всех', - image: '/assets/main/Hero/calls.webp', - imageMobile: '/assets/main/Hero/calls-mobile.webp', - }, - '/payments': { - title: 'Наглядная статистика дохода', - description: - 'Больше не нужно записывать каждую оплату отдельно — sovlium\nподсчитает всё за вас', - button: 'Попробовать раньше всех', - image: '/assets/main/Hero/payments.webp', - imageMobile: '/assets/main/Hero/payments-mobile.webp', - }, - '/whiteboard': { - title: 'Бесконечный холст\nдля ваших идей', - description: 'Готовьте уроки заранее или рисуйте на доске\nпрямо во время видеозвонка', - button: 'Попробовать раньше всех', - image: '/assets/main/Hero/whiteboard.webp', - imageMobile: '/assets/main/Hero/whiteboard-mobile.webp', - }, - '/materials': { - title: 'Собственная цифровая\nбиблиотека', - description: 'Сохраняйте материалы в одном сервисе вместо тысячи вкладок', - button: 'Попробовать раньше всех', - image: '/assets/main/Hero/materials.webp', - imageMobile: '/assets/main/Hero/materials-mobile.webp', - }, -}; diff --git a/apps/xi.land/components/main/Hero/hero_content.ts b/apps/xi.land/components/main/Hero/hero_content.ts new file mode 100644 index 00000000..35fa6277 --- /dev/null +++ b/apps/xi.land/components/main/Hero/hero_content.ts @@ -0,0 +1,141 @@ +export type HeroFeatureIconIdT = + | 'conference' + | 'whiteboard' + | 'calendar' + | 'materials' + | 'payments' + | 'notifications' + | 'rooms'; + +export type HeroFeatureT = { + id: HeroFeatureIconIdT; + label: string; + pillClassName: string; + labelClassName: string; +}; + +export type HeroParallaxLayerT = { + src: string; + alt: string; + /** Натуральная ширина файла (для сохранения пропорций без crop) */ + width: number; + /** Натуральная высота файла */ + height: number; + /** Базовый множитель смещения в px при крайнем положении курсора в секции */ + depth: number; + /** Лёгкий индивидуальный разброс (фиксированные коэффициенты, без runtime random) */ + depthMultiplier?: number; + /** Позиция и ширина слоя в процентах от контейнера коллажа (`w-[…%]`, `top/left/…`) */ + className: string; + /** Внутренняя обёртка — например `-translate-y-1/2` при якоре `top-1/2` у родителя */ + innerClassName?: string; +}; + +/** Общее усиление «притяжения» слоёв к курсору по секции hero */ +export const HERO_PARALLAX_INTENSITY = 1.42; + +export const HERO_PARALLAX_LAYERS: readonly HeroParallaxLayerT[] = [ + { + src: '/assets/main/Hero/main-hero-timer.webp', + alt: 'Таймер урока', + width: 789, + height: 142, + depth: 24, + depthMultiplier: 0.93, + className: 'left-[3%] top-[5%] z-[11] w-[37.2%]', + }, + { + src: '/assets/main/Hero/main-hero-tutor.webp', + alt: 'Курсор и окно преподавателя', + width: 534, + height: 186, + depth: 16, + depthMultiplier: 1.17, + className: 'bottom-[6%] left-[10%] z-[12] w-[21%]', + }, + { + src: '/assets/main/Hero/main-hero-lesson-card.webp', + alt: 'Карточка занятия', + width: 426, + height: 217, + depth: 20, + depthMultiplier: 0.88, + className: 'right-[4%] top-1/2 z-[13] w-[33.12%]', + innerClassName: '-translate-y-[calc(50%+64px)]', + }, + { + src: '/assets/main/Hero/main-hero-student.webp', + alt: 'Курсор и окно ученика', + width: 330, + height: 186, + depth: 26, + depthMultiplier: 1.14, + className: 'right-[11%] top-[14%] z-[14] w-[14.4%]', + }, +]; + +/** Фон коллажа hero: при static export Next не режет картинки — свой srcset в Hero.tsx. */ +export const HERO_MAIN_COLLAGE_IMAGE = { + src: '/assets/main/Hero/main-hero-1-1200w.webp', + srcSet: + '/assets/main/Hero/main-hero-1-640w.webp 640w, /assets/main/Hero/main-hero-1-960w.webp 960w, /assets/main/Hero/main-hero-1-1200w.webp 1200w', + /** Колонка макета max-w-[600px]; для DPR≤2 достаточно 1200w. */ + sizes: '600px', + width: 1200, + height: 1240, +} as const; + +export const HERO_CONTENT = { + title: 'Все инструменты репетитора в\u00A0одной платформе', + subtitle: + 'Легко проводите онлайн-занятия с\u00A0компьютера, телефона или планшета. Рутину мы берём на\u00A0себя', + primaryButtonLabel: 'Попробовать бесплатно', + primaryButtonHref: 'https://app.sovlium.ru/signup', + heroImageAlt: + 'Интерфейс платформы Sovlium: видеозвонок с\u00A0онлайн-доской и элементами расписания', + + features: [ + { + id: 'conference', + label: 'видеозвонки', + pillClassName: 'bg-yellow-20', + labelClassName: 'text-amber-900', + }, + { + id: 'whiteboard', + label: 'онлайн-доски', + pillClassName: 'bg-green-0', + labelClassName: 'text-green-80', + }, + { + id: 'calendar', + label: 'расписание', + pillClassName: 'bg-pink-20', + labelClassName: 'text-fuchsia-900', + }, + { + id: 'materials', + label: 'хранение учебных материалов', + pillClassName: 'bg-cyan-20', + labelClassName: 'text-cyan-100', + }, + { + id: 'payments', + label: 'контроль оплат', + pillClassName: 'bg-red-0', + labelClassName: 'text-red-80', + }, + { + id: 'notifications', + label: 'автоматические напоминания о\u00A0занятиях и оплате', + pillClassName: 'bg-orange-0', + labelClassName: 'text-orange-80', + }, + { + id: 'rooms', + label: 'учебные кабинеты', + pillClassName: 'bg-violet-20', + labelClassName: 'text-violet-100', + }, + ] satisfies readonly HeroFeatureT[], +} as const; diff --git a/apps/xi.land/components/main/Telegram/Telegram.tsx b/apps/xi.land/components/main/Telegram/Telegram.tsx deleted file mode 100644 index 329b5aab..00000000 --- a/apps/xi.land/components/main/Telegram/Telegram.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/* eslint-disable no-irregular-whitespace */ -/* eslint-disable @typescript-eslint/ban-ts-comment */ -// @ts-nocheck -'use client'; - -import React, { useRef } from 'react'; -import { motion, useInView } from 'motion/react'; -import { Button } from '@xipkg/button'; -import { Link } from '@xipkg/link'; - -const TgIcon = () => ( - - - -); - -export const Telegram = () => { - const ref = useRef(null); - const isInView = useInView(ref, { once: true, margin: '-200px' }); - - return ( -
- - -

- Присоединяйтесь к сообществу -
- репетиторов в Telegram -

-

- Обменивайтесь опытом и лайфхаками, читайте полезные статьи -
- и просто общайтесь с коллегами -

- - - -
-
- ); -}; diff --git a/apps/xi.land/components/main/Telegram/index.ts b/apps/xi.land/components/main/Telegram/index.ts deleted file mode 100644 index b3d71c80..00000000 --- a/apps/xi.land/components/main/Telegram/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Telegram } from './Telegram'; diff --git a/apps/xi.land/components/main/Text/Text.tsx b/apps/xi.land/components/main/Text/Text.tsx deleted file mode 100644 index 13881f5c..00000000 --- a/apps/xi.land/components/main/Text/Text.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { cn } from '@xipkg/utils'; -import React from 'react'; - -export const Text = ({ - theme, - text, - className, -}: { - theme: 'dark' | 'light'; - text: string; - className?: string; -}) => ( -
-
-

- {text} -

-
-
-); diff --git a/apps/xi.land/components/main/Text/index.ts b/apps/xi.land/components/main/Text/index.ts deleted file mode 100644 index b0c76af0..00000000 --- a/apps/xi.land/components/main/Text/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Text'; diff --git a/apps/xi.land/components/main/TutorIdeas/TutorIdeasBlock.tsx b/apps/xi.land/components/main/TutorIdeas/TutorIdeasBlock.tsx new file mode 100644 index 00000000..52039e6c --- /dev/null +++ b/apps/xi.land/components/main/TutorIdeas/TutorIdeasBlock.tsx @@ -0,0 +1,214 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import Image from 'next/image'; +import { ChevronSmallRight } from '@xipkg/icons'; +import { cn } from '@xipkg/utils'; +import { motion, useReducedMotion } from 'motion/react'; + +import { + TUTOR_IDEA_CARDS, + TUTOR_IDEAS_SUBTITLE, + TUTOR_IDEAS_TITLE, + TUTOR_IDEAS_TITLE_FULL, + type TutorIdeaCardT, +} from './tutorIdeas_content'; + +const cardStaggerDelay = (index: number, reduceMotion: boolean | null) => + reduceMotion ? 0 : 0.11 * index; + +const cardRevealTransition = (index: number, reduceMotion: boolean | null) => + reduceMotion + ? { duration: 0 } + : { + type: 'spring' as const, + stiffness: 380, + damping: 28, + mass: 0.85, + delay: cardStaggerDelay(index, reduceMotion), + }; + +type IdeaCardPropsT = { + card: TutorIdeaCardT; + className?: string; +}; + +const IdeaCard = ({ card, className }: IdeaCardPropsT) => ( +
+
+ {card.imageAlt} +
+
+ + {card.title} + +
+
+); + +export const TutorIdeasBlock = () => { + const reduceMotion = useReducedMotion(); + const sectionRef = useRef(null); + const scrollRef = useRef(null); + const hintPlayedRef = useRef(false); + const [fadeRightEdge, setFadeRightEdge] = useState(true); + const [showBounceHint, setShowBounceHint] = useState(true); + + const updateScrollHint = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + const maxScroll = Math.max(0, el.scrollWidth - el.clientWidth); + const nearEnd = maxScroll <= 0 || el.scrollLeft >= maxScroll - 8; + setFadeRightEdge(!nearEnd); + }, []); + + const dismissBounceHint = useCallback(() => setShowBounceHint(false), []); + + useEffect(() => { + updateScrollHint(); + window.addEventListener('resize', updateScrollHint); + return () => window.removeEventListener('resize', updateScrollHint); + }, [updateScrollHint]); + + useEffect(() => { + const el = scrollRef.current; + const section = sectionRef.current; + if (!el || !section || reduceMotion) return; + + const timeouts: number[] = []; + const io = new IntersectionObserver( + ([entry]) => { + if (!entry?.isIntersecting || hintPlayedRef.current) return; + if (el.scrollWidth <= el.clientWidth) return; + hintPlayedRef.current = true; + timeouts.push( + window.setTimeout(() => { + el.scrollTo({ left: 52, behavior: 'smooth' }); + timeouts.push( + window.setTimeout(() => { + el.scrollTo({ left: 0, behavior: 'smooth' }); + }, 780), + ); + }, 420), + ); + }, + { threshold: 0.28 }, + ); + + io.observe(section); + return () => { + timeouts.forEach(clearTimeout); + io.disconnect(); + }; + }, [reduceMotion]); + + return ( +
+
+
+

+ {TUTOR_IDEAS_TITLE} +

+

+ {TUTOR_IDEAS_SUBTITLE} +

+
+ +
+ {TUTOR_IDEA_CARDS.map((card, index) => ( + + + + ))} +
+ +
+
+ + + + + +
+ {TUTOR_IDEA_CARDS.map((card, index) => ( + + + + ))} +
+
+
+
+ ); +}; diff --git a/apps/xi.land/components/main/TutorIdeas/index.ts b/apps/xi.land/components/main/TutorIdeas/index.ts new file mode 100644 index 00000000..085935f8 --- /dev/null +++ b/apps/xi.land/components/main/TutorIdeas/index.ts @@ -0,0 +1 @@ +export { TutorIdeasBlock } from './TutorIdeasBlock'; diff --git a/apps/xi.land/components/main/TutorIdeas/tutorIdeas_content.ts b/apps/xi.land/components/main/TutorIdeas/tutorIdeas_content.ts new file mode 100644 index 00000000..25f45dc9 --- /dev/null +++ b/apps/xi.land/components/main/TutorIdeas/tutorIdeas_content.ts @@ -0,0 +1,67 @@ +export type TutorIdeaCardT = { + id: string; + title: string; + imageSrc: string; + imageAlt: string; + bgClassName: string; + badgeTextClassName: string; +}; + +export const TUTOR_IDEAS_TITLE = 'Собираем идеи от\u00A0самих репетиторов'; + +export const TUTOR_IDEAS_SUBTITLE = 'Вот что добавили недавно:'; + +/** Для aria и подписей к карточкам */ +export const TUTOR_IDEAS_TITLE_FULL = `${TUTOR_IDEAS_TITLE}. ${TUTOR_IDEAS_SUBTITLE}`; + +export const TUTOR_IDEA_CARDS: readonly TutorIdeaCardT[] = [ + { + id: 'pdf', + title: 'Работа с\u00A0PDF на\u00A0доске', + imageSrc: '/assets/main/Features/main-features-pdf.webp', + imageAlt: + 'Работа с\u00A0PDF на\u00A0доске: загрузка материалов в\u00A0PDF на\u00A0онлайн-доску', + bgClassName: 'bg-red-600', + badgeTextClassName: 'text-red-800', + }, + { + id: 'audio', + title: 'Аудиофайлы на\u00A0доске', + imageSrc: '/assets/main/Features/main-features-audio.webp', + imageAlt: 'Аудиофайлы на\u00A0онлайн-доске', + bgClassName: 'bg-brand-80', + badgeTextClassName: 'text-brand-80', + }, + { + id: 'chat', + title: 'Чат в\u00A0видеозвонке', + imageSrc: '/assets/main/Features/main-features-chat.webp', + imageAlt: 'Чат во\u00A0время видеозвонка', + bgClassName: 'bg-amber-400', + badgeTextClassName: 'text-amber-700', + }, + { + id: 'raise-hand', + title: 'Кнопка «Поднять руку»', + imageSrc: '/assets/main/Features/main-features-raise-hand.webp', + imageAlt: 'Кнопка поднять руку в\u00A0видеозвонке', + bgClassName: 'bg-sky-300', + badgeTextClassName: 'text-slate-600', + }, + { + id: 'frames', + title: 'Фреймы', + imageSrc: '/assets/main/Features/main-features-frames.webp', + imageAlt: 'Фреймы на\u00A0онлайн-доске', + bgClassName: 'bg-lime-400', + badgeTextClassName: 'text-lime-700', + }, + { + id: 'timer', + title: 'Таймер', + imageSrc: '/assets/main/Features/main-features-timer.webp', + imageAlt: 'Таймер урока на\u00A0доске', + bgClassName: 'bg-fuchsia-500', + badgeTextClassName: 'text-purple-800', + }, +]; diff --git a/apps/xi.land/components/main/callToAction/CallToAction.tsx b/apps/xi.land/components/main/callToAction/CallToAction.tsx deleted file mode 100644 index e02601aa..00000000 --- a/apps/xi.land/components/main/callToAction/CallToAction.tsx +++ /dev/null @@ -1,28 +0,0 @@ -'use client'; - -import { motion } from 'motion/react'; -import { CallToActionForm } from './CallToActionForm'; - -export const CallToAction = () => ( -
-
-

- Остались вопросы? -
- Запишитесь на демонстрацию -

- - - -
-
-); diff --git a/apps/xi.land/components/main/callToAction/CallToActionForm.tsx b/apps/xi.land/components/main/callToAction/CallToActionForm.tsx deleted file mode 100644 index 6cb53ade..00000000 --- a/apps/xi.land/components/main/callToAction/CallToActionForm.tsx +++ /dev/null @@ -1,133 +0,0 @@ -'use client'; - -import { Button } from '@xipkg/button'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { Form, FormControl, FormField, FormItem, FormLabel, useForm } from '@xipkg/form'; -import { Input } from '@xipkg/input'; -import { Link } from '@xipkg/link'; -import { useState } from 'react'; -import * as z from 'zod'; -import { toast } from 'sonner'; -import { FormSchema } from './formSchema'; - -type CallToActionFormPropsT = { - className?: string; - titleClassName?: string; - title?: string; -}; - -export const CallToActionForm = ({ - className = '', - titleClassName = '', - title, -}: CallToActionFormPropsT) => { - const [isButtonActive, setIsButtonActive] = useState(true); - - const form = useForm>({ - resolver: zodResolver(FormSchema), - defaultValues: { - name: '', - contact: '', - }, - }); - - const { - control, - handleSubmit, - formState: { errors }, - } = form; - - const onSubmit = async (data: z.infer) => { - setIsButtonActive(false); - const response = await fetch( - `${process.env.NEXT_PUBLIC_SERVER_URL_BACKEND}/api/public/user-service/demo-applications/`, - { - method: 'POST', - cache: 'no-cache', - credentials: 'include', - mode: 'cors', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - name: data.name, - contacts: [data.contact], - }), - }, - ); - - if (response.ok) { - toast.success('Спасибо, мы получили Ваш контакт', { - position: 'bottom-center', - }); - setIsButtonActive(true); - form.reset(); - } else { - console.error(`Ошибка HTTP: ${response.status}`); - toast(`Ошибка HTTP: ${response.status}`); - setIsButtonActive(true); - } - }; - - return ( -
- -

{title}

- ( - - Как к вам обращаться? - - - - - )} - /> - ( - - Телеграм или электронная почта - - - - - )} - /> -
- -

- Нажимая кнопку, вы соглашаетесь с  - - политикой обработки персональных данных - -

-
- - - ); -}; diff --git a/apps/xi.land/components/main/callToAction/CallToActionModal.tsx b/apps/xi.land/components/main/callToAction/CallToActionModal.tsx deleted file mode 100644 index 793ee19b..00000000 --- a/apps/xi.land/components/main/callToAction/CallToActionModal.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/* eslint-disable no-irregular-whitespace */ -'use client'; - -import { ReactNode } from 'react'; -import { motion } from 'motion/react'; - -import { Modal, ModalContent, ModalTrigger, ModalCloseButton, ModalTitle } from '@xipkg/modal'; -import { ArrowRight, Close } from '@xipkg/icons'; -import { CallToActionForm } from './CallToActionForm'; - -const transition = { - type: 'spring' as const, - mass: 0.6, - damping: 10, - stiffness: 100, - restDelta: 0.001, - restSpeed: 0.01, -}; - -type CallToActionModalT = { - children: ReactNode; -}; - -export const CallToActionModal = ({ children }: CallToActionModalT) => ( - - {children} - - - - - -
- -
- Демонстрация xi.effect -
-
-

- Живой разговор с нашим экспертом, не больше получаса -

-
- - - Узнаете, как быстро настроить работу - -
-
- - - Сориентируетесь по ценам и скидкам - -
-
- - - Посмотрите, как работает xi.effect - -
-
- -
-
-
-); diff --git a/apps/xi.land/components/main/callToAction/formSchema.ts b/apps/xi.land/components/main/callToAction/formSchema.ts deleted file mode 100644 index 50e40ef8..00000000 --- a/apps/xi.land/components/main/callToAction/formSchema.ts +++ /dev/null @@ -1,6 +0,0 @@ -import * as z from 'zod'; - -export const FormSchema = z.object({ - name: z.string().min(2), - contact: z.string().min(2), -}); diff --git a/apps/xi.land/components/main/callToAction/index.ts b/apps/xi.land/components/main/callToAction/index.ts deleted file mode 100644 index b68349ff..00000000 --- a/apps/xi.land/components/main/callToAction/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { CallToAction } from './CallToAction'; -export { CallToActionModal } from './CallToActionModal'; diff --git a/apps/xi.land/components/main/index.ts b/apps/xi.land/components/main/index.ts index 42098053..253929b7 100644 --- a/apps/xi.land/components/main/index.ts +++ b/apps/xi.land/components/main/index.ts @@ -1,8 +1,7 @@ export { Hero } from './Hero'; -export { CallToAction } from './callToAction'; -export { Telegram } from './Telegram'; -export { FeaturesBlock } from './Features'; -export { MessagesBlock } from './messages'; -export { Text } from './Text'; +export { CapabilitiesBlock } from './Capabilities'; +export { TutorIdeasBlock } from './TutorIdeas'; +export { DevicesBlock } from './Devices'; +export { MessagesBlock } from './messages/MessagesBlock'; +export { CommunityBlock } from './Community/CommunityBlock'; export { Faq } from './Faq'; -export { Benefits } from './Benefits'; diff --git a/apps/xi.land/components/main/messages/Messages.tsx b/apps/xi.land/components/main/messages/Messages.tsx deleted file mode 100644 index b829d356..00000000 --- a/apps/xi.land/components/main/messages/Messages.tsx +++ /dev/null @@ -1,159 +0,0 @@ -'use client'; - -import { useEffect, useRef, useState } from 'react'; -import { gsap } from 'gsap'; -import { ScrollTrigger } from 'gsap/ScrollTrigger'; -import { useGSAP } from '@gsap/react'; -import Image from 'next/image'; -import { messages } from './content'; -import { cn } from '@xipkg/utils'; - -gsap.registerPlugin(ScrollTrigger); - -export const Messages = () => { - const sectionRef = useRef(null); - const tailsRef = useRef(null); - const imgWrapRef = useRef(null); - const imagesContainerRef = useRef(null); - const [activeTextIndex, setActiveTextIndex] = useState(null); - - useGSAP( - () => { - if (!sectionRef.current || !tailsRef.current) return; - - const total = messages.length; - const scrollStep = 1500; // 1500px на каждый переход - const totalScrollDistance = scrollStep * total; // Для 3 элементов: 4500px - - gsap.timeline({ - scrollTrigger: { - trigger: sectionRef.current, - start: 'center center', - end: `+=${totalScrollDistance}`, // Используем тот же end, что и для анимации цветов - pin: true, - scrub: true, - pinSpacing: window.innerWidth >= 640, - anticipatePin: 1, - onUpdate: (self) => { - // Логика переключения цветов текста - const progress = Math.max(0, Math.min(1, self.progress)); - const scrollDistance = progress * totalScrollDistance; - let currentIndex = Math.floor(scrollDistance / scrollStep); - - if (currentIndex >= total) { - currentIndex = total - 1; - } - - if (currentIndex !== activeTextIndex) { - setActiveTextIndex(currentIndex); - } - }, - }, - }); - }, - { scope: sectionRef }, - ); - - // Анимация переключения изображений при смене текста - useEffect(() => { - if (!imagesContainerRef.current || activeTextIndex === null) return; - - const imageElements = Array.from(imagesContainerRef.current.children) as HTMLElement[]; - - if (imageElements.length === 0) return; - - // Устанавливаем начальное состояние для всех изображений - gsap.set(imageElements, { autoAlpha: 0, y: 20 }); - - // Анимация появления по очереди сверху вниз - const tl = gsap.timeline(); - imageElements.forEach((el, idx) => { - // Первое изображение появляется более плавно - const duration = idx === 0 ? 1.0 : 0.5; - tl.to( - el, - { - autoAlpha: 1, - y: 0, - duration, - ease: 'power2.out', - }, - idx === 0 ? 0 : '-=0.2', // Первое изображение без перекрытия, остальные с перекрытием - ); - }); - }, [activeTextIndex]); - - // Инициализация: первый текст активен по умолчанию - useEffect(() => { - setActiveTextIndex(0); - }, []); - - const TAILS = messages.map((t) => t.content); - - return ( -
-
-

- Переход в онлайн изменил всё -

-
-
-
-
- {TAILS.map((txt, idx) => { - const isActive = activeTextIndex === idx; - return ( -

- {txt} -

- ); - })} -
-
- - {/* ── справа: картинки ── */} -
-
- -
- {messages[activeTextIndex ?? 0]?.images?.map((image, imgIdx) => ( -
- message image -
- ))} -
-
-
-
-
- ); -}; diff --git a/apps/xi.land/components/main/messages/MessagesBlock.tsx b/apps/xi.land/components/main/messages/MessagesBlock.tsx index e6943eae..46d9a196 100644 --- a/apps/xi.land/components/main/messages/MessagesBlock.tsx +++ b/apps/xi.land/components/main/messages/MessagesBlock.tsx @@ -1,24 +1,311 @@ 'use client'; -import dynamic from 'next/dynamic'; -import { useMediaQuery } from '@xipkg/utils'; -import { MessagesMobile } from './MessagesMobile'; - -// Десктопная версия тянет gsap + ScrollTrigger — загружаем лениво -const Messages = dynamic(() => import('./Messages').then((m) => m.Messages), { - ssr: false, - loading: () => ( -
> = { + conference: Conference, + whiteboard: WhiteBoard, + calendar: Calendar, + materials: Materials, + payments: Payments, + notifications: Notification, + rooms: Users, +}; + +const heroFeature = (id: MessagesFloatingFeatureIdT): HeroFeatureT => { + const found = HERO_CONTENT.features.find((f) => f.id === id); + if (!found) { + throw new Error(`Hero feature "${id}" not found`); + } + return found; +}; + +const FEATURE_BY_ID: Record = { + calendar: heroFeature('calendar'), + materials: heroFeature('materials'), + conference: heroFeature('conference'), + payments: heroFeature('payments'), + notifications: heroFeature('notifications'), + rooms: heroFeature('rooms'), +}; + +/** Фиксированная высота строки по макету; анимации могут выходить за пределы через overflow-visible */ +const MESSAGES_ROW_HEIGHT_CLASS = 'h-[340px]'; + +type FloatingServiceIconPropsT = { + featureId: MessagesFloatingFeatureIdT; + className?: string; + style?: CSSProperties; + motionPhase: number; +}; + +const FloatingServiceIcon = ({ + featureId, + className, + style, + motionPhase, +}: FloatingServiceIconPropsT) => { + const feature = FEATURE_BY_ID[featureId]; + const Icon = FEATURE_ICONS[featureId]; + const reduceMotion = useReducedMotion(); + + return ( +
-
-
- ), -}); + + + +
+ ); +}; + +const BrandBadge = ({ children, className }: { children: ReactNode; className?: string }) => ( +
+ {children} +
+); + +const TextColumn = ({ + row, + icon, + iconBadgeClassName, +}: { + row: (typeof MESSAGES_ROWS)[number]; + icon: ReactNode; + /** Например `relative p-0`, когда иконка должна заполнять плашку целиком */ + iconBadgeClassName?: string; +}) => ( +
+ {icon} +
+

+ {row.titleLines.map((line) => ( + + {line} + + ))} +

+

+ {row.subtitle} +

+
+
+); + +const ShieldVisual = ({ imageAlt }: { imageAlt: string }) => ( +
+
+
+ {[ + { px: 420, delay: 0 }, + { px: 300, delay: 0.55 }, + { px: 220, delay: 1.1 }, + ].map(({ px, delay }) => ( +
+ ))} +
+
+ {imageAlt} +
+
+); + +const ToolsHubVisual = ({ centerAlt }: { centerAlt: string }) => { + return ( +
+
+
+
+
+ {MESSAGES_FLOATING_FEATURES.map((slot) => ( + + ))} +
+ {centerAlt} +
+
+
+
+ ); +}; + +const DashboardVisual = ({ imageAlt }: { imageAlt: string }) => ( +
+ {imageAlt} +
+); export const MessagesBlock = () => { - const isMobile = useMediaQuery('(max-width: 768px)'); + const [row1, row2, row3] = MESSAGES_ROWS; - return isMobile ? : ; + return ( +
+
+
+
+
+ +
+
+
+ } + /> +
+
+ +
+
+
+ +
+
+
+ + } + /> +
+
+ +
+
+
+ +
+
+
+ } + /> +
+
+
+
+ ); }; diff --git a/apps/xi.land/components/main/messages/MessagesMobile.tsx b/apps/xi.land/components/main/messages/MessagesMobile.tsx deleted file mode 100644 index db495b59..00000000 --- a/apps/xi.land/components/main/messages/MessagesMobile.tsx +++ /dev/null @@ -1,38 +0,0 @@ -'use client'; - -import Image from 'next/image'; -import { messages } from './content'; -import { cn } from '@xipkg/utils'; - -export const MessagesMobile = () => ( - <> -
-

- Переход в онлайн -
- изменил всё -

-
- {messages.map((item) => ( -
-
-
- messages image -
-
-

- {item.content} -

-
-
-
- ))} - -); diff --git a/apps/xi.land/components/main/messages/content.ts b/apps/xi.land/components/main/messages/content.ts deleted file mode 100644 index 020e396e..00000000 --- a/apps/xi.land/components/main/messages/content.ts +++ /dev/null @@ -1,30 +0,0 @@ -export const messages = [ - { - id: 1, - content: 'планирование', - className: 'bg-gradient-to-r from-[#667EEA] to-[#764BA2] bg-clip-text text-transparent', - images: [{ src: '/assets/main/Messages/11.webp' }, { src: '/assets/main/Messages/12.webp' }], - mobileImage: '/assets/main/Messages/mobile1.webp', - }, - { - id: 2, - content: 'методику', - className: - 'bg-gradient-to-r from-[#764BA2] via-[#D28CFF] to-[#FF5AD9] bg-clip-text text-transparent', - images: [{ src: '/assets/main/Messages/21.webp' }, { src: '/assets/main/Messages/22.webp' }], - mobileImage: '/assets/main/Messages/mobile2.webp', - }, - { - id: 3, - content: 'общение с учениками', - className: - 'bg-gradient-to-r from-[#FF6FD8] via-[#C084FC] to-[#2E8CFF] bg-clip-text text-transparent', - images: [ - { src: '/assets/main/Messages/31.webp' }, - { src: '/assets/main/Messages/32.webp' }, - { src: '/assets/main/Messages/33.webp' }, - { src: '/assets/main/Messages/34.webp' }, - ], - mobileImage: '/assets/main/Messages/mobile3.webp', - }, -]; diff --git a/apps/xi.land/components/main/messages/index.ts b/apps/xi.land/components/main/messages/index.ts deleted file mode 100644 index 8f8bc8b3..00000000 --- a/apps/xi.land/components/main/messages/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './MessagesBlock'; diff --git a/apps/xi.land/components/main/messages/messages_content.ts b/apps/xi.land/components/main/messages/messages_content.ts new file mode 100644 index 00000000..5fbe180d --- /dev/null +++ b/apps/xi.land/components/main/messages/messages_content.ts @@ -0,0 +1,60 @@ +export const MESSAGES_ASSETS = { + shield: '/assets/main/Messages/messages-shield-network.webp', + /** Логотип платформы (колонна) — центр хаба и иконка во втором блоке */ + platformColumn: '/assets/main/Messages/messages-platform-column.webp', + dashboard: '/assets/main/Messages/messages-dashboard.webp', +} as const; + +export type MessagesFloatingFeatureIdT = + | 'calendar' + | 'materials' + | 'conference' + | 'payments' + | 'notifications' + | 'rooms'; + +/** Иконки вокруг центрального лого — якоря в % с лёгким «разбросом», орбита не идеальная окружность */ +export const MESSAGES_FLOATING_FEATURES: readonly { + id: MessagesFloatingFeatureIdT; + /** Якорная позиция в процентах от контейнера визуала */ + leftPct: number; + topPct: number; + /** Фаза анимации плавания, с */ + motionPhase: number; +}[] = [ + { id: 'calendar', leftPct: 67, topPct: 10, motionPhase: 0 }, + { id: 'materials', leftPct: 46, topPct: 17, motionPhase: 0.35 }, + { id: 'conference', leftPct: 9, topPct: 38, motionPhase: 0.7 }, + { id: 'payments', leftPct: 17, topPct: 74, motionPhase: 0.2 }, + { id: 'notifications', leftPct: 61, topPct: 83, motionPhase: 0.55 }, + { id: 'rooms', leftPct: 90, topPct: 46, motionPhase: 0.9 }, +] as const; + +export type MessagesRowContentT = { + id: 'russia' | 'tools' | 'assistant'; + titleLines: readonly string[]; + subtitle: string; + visualAlt: string; +}; + +export const MESSAGES_ROWS: readonly MessagesRowContentT[] = [ + { + id: 'russia', + titleLines: ['Платформа sovlium', 'создана в\u00A0России'], + subtitle: 'Быстро открывается и всегда доступна.', + visualAlt: 'Стабильная работа платформы Sovlium: щит и глобус', + }, + { + id: 'tools', + titleLines: ['Всё для\u00A0занятий уже под\u00A0рукой'], + subtitle: 'Не нужно тратить время, чтобы переключиться между\u00A0инструментами.', + visualAlt: 'Центральный блок платформы Sovlium и связанные сервисы', + }, + { + id: 'assistant', + titleLines: ['Личный секретарь, библиотекарь', 'и бухгалтер'], + subtitle: + 'Разошлёт напоминания, поможет рассчитать доход и разложить материалы по\u00A0полочкам.', + visualAlt: 'Напоминания, доход и материалы в\u00A0интерфейсе Sovlium', + }, +] as const; diff --git a/apps/xi.land/components/main/shared/StickyScrollReveal.tsx b/apps/xi.land/components/main/shared/StickyScrollReveal.tsx index d287db92..055dfdc4 100644 --- a/apps/xi.land/components/main/shared/StickyScrollReveal.tsx +++ b/apps/xi.land/components/main/shared/StickyScrollReveal.tsx @@ -11,7 +11,7 @@ gsap.registerPlugin(ScrollTrigger); const steps = [ { id: 0, tail: 'планировании', src: '/assets/main/Messages/plans.svg' }, { id: 1, tail: 'подаче материала', src: '/assets/main/Messages/materials.svg' }, - { id: 2, tail: 'общении с учениками', src: '/assets/main/Messages/chat.svg' }, + { id: 2, tail: 'общении с\u00A0учениками', src: '/assets/main/Messages/chat.svg' }, ]; export default function Messages() { diff --git a/apps/xi.land/components/navigation/MenuItem.tsx b/apps/xi.land/components/navigation/MenuItem.tsx index e8e91b1d..c3dc08b1 100644 --- a/apps/xi.land/components/navigation/MenuItem.tsx +++ b/apps/xi.land/components/navigation/MenuItem.tsx @@ -1,10 +1,11 @@ import React from 'react'; import { motion } from 'motion/react'; import Link from 'next/link'; +import { ChevronSmallBottom } from '@xipkg/icons'; import { cn } from '@xipkg/utils'; type MenuItemPropsT = { - setActive: (item: string) => void; + setActive: (item: string | null) => void; active?: string | null; item: string; href?: string; @@ -32,33 +33,43 @@ export const MenuItem = ({
setActive(item)} className={cn( - 'relative text-s-base md:text-m-base lg:text-[18px] xl:text-l-base text-gray-70 dark:text-gray-20 font-normal flex items-center', + 'relative flex items-center pb-1 text-base font-medium leading-5 text-gray-80 dark:text-gray-20', )} > {href ? ( {item} ) : ( - {item} + + {item} + + )} {!href && active !== null && active === item && ( - + {children} diff --git a/apps/xi.land/components/navigation/MobileNavigation.tsx b/apps/xi.land/components/navigation/MobileNavigation.tsx index 02974563..0d73371f 100644 --- a/apps/xi.land/components/navigation/MobileNavigation.tsx +++ b/apps/xi.land/components/navigation/MobileNavigation.tsx @@ -15,8 +15,7 @@ import { Close, Burger } from '@xipkg/icons'; import Image from 'next/image'; import Link from 'next/link'; import { useState } from 'react'; -import { usePathname } from 'next/navigation'; -import { subMenu } from './Navigation'; +import { mainNavLinks, subMenu } from './nav_config'; // type LinkMenuItemT = { // label: string; @@ -52,7 +51,7 @@ export const MobileNavigation = () => {