@@ -0,0 +1,10 @@ + + + + + + + + + + @@ -0,0 +1,3 @@ + + + @@ -1,5 +1,4 @@ -import React, { useEffect, useMemo } from 'react' -import { Avatar, Box, Button, Typography } from '@mui/material' +import React, { useMemo } from 'react' import Image from 'next/image' import Link from 'next/link' import LockSvg from '#/assets/svg/lock.svg?react' @@ -11,7 +10,6 @@ import { c } from '#/shared/lib/helpers' import { IShortModel } from '#/entities/model-entity' import { SvgIcon } from '#/shared/ui/svg' import { CommonButton } from '#/shared/ui/button' -import { uid } from 'chart.js/dist/helpers/helpers.core' export interface ChatModelCardProps extends IShortModel { accessed_models: string[] | null @@ -22,11 +20,13 @@ export function ChatModelCard({ description, image, title, slug, accessed_models return accessed_models && !accessed_models.includes(slug) ? '/account?scope=subscribe' : `chat-bot/${slug}` }, [accessed_models]) - const { theme } = useThemeAndDevice() - return ( -
+
{title}
@@ -26,7 +26,11 @@ export function ImageModelCard({ description, image, title, slug, accessed_model return ( -
+
{title}
@@ -0,0 +1,90 @@ +import { type Tour } from 'nextstepjs' + +import { Device } from '#/shared/lib/types/entities' + +const baseTourSteps: Tour[] = [ + { + tour: 'text-models', + steps: [ + { + icon: '', + title: 'Давайте начнем! 🚀', + content: 'Здесь вы сможете найти ChatGPT и её различные версии, а также LLaMA и Mistral и другие. Выберем одну из них для начала работы.', + selector: '#text-models-tour-1', + side: 'right', + showControls: true, + nextRoute: '/chat-bot/chatgpt', + showSkip: true, + }, + { + icon: '', + title: 'Ввод запроса', + content: 'Используйте это поле для ввода вашего запроса, а затем кликните на кнопку отправки.', + selector: '#text-models-tour-2', + side: 'top', + showControls: true, + showSkip: true, + }, + { + icon: '', + title: 'Выбор версии модели', + content: 'В этом блоке вы можете выбрать интересующую вас версию модели', + selector: '#text-models-tour-3', + side: 'left', + showControls: true, + showSkip: true, + }, + ], + }, + { + tour: 'images', + steps: [ + { + icon: '', + title: 'Множество популярных моделей! 🖼️', + content: 'Здесь представлены мощные модели для генерации изображений: Flux, Stable Diffusion, Midjourney, DALL-E и другие. Выберем одну из них для начала работы.', + selector: '#images-models-tour-1', + side: 'top', + showControls: true, + showSkip: true, + }, + { + icon: '', + title: 'Ввод запроса', + content: 'Используйте это поле для ввода вашего запроса, а затем кликните на кнопку отправки.', + selector: '#images-models-tour-2', + side: 'bottom', + showControls: true, + showSkip: true, + }, + { + icon: '', + title: 'Параметры генерации', + content: 'В этом блоке вы можете корректировать параметры генерации изображения', + selector: '#images-models-tour-3', + side: 'left', + showControls: true, + showSkip: true, + }, + ], + }, +] + +export const getTourSteps = (device: Device): Tour[] => { + if (device === 'mobile') { + return baseTourSteps.map(tour => ({ + ...tour, + steps: tour.steps.map(step => ({ + ...step, + side: 'bottom' as const, + })), + })) + } + return baseTourSteps +} + +// Для обратной совместимости (по умолчанию desktop) +export const tourSteps = baseTourSteps + +export type TourName = 'text-models' | 'images' + @@ -0,0 +1,12 @@ +const TOUR_COMPLETED_KEY = 'nextstep-tour-completed' + +export const setTourCompleted = () => { + if (typeof window === 'undefined') return + localStorage.setItem(TOUR_COMPLETED_KEY, 'true') +} + +export const isTourCompleted = (): boolean => { + if (typeof window === 'undefined') return false + return localStorage.getItem(TOUR_COMPLETED_KEY) === 'true' +} + @@ -0,0 +1,125 @@ +.tourCard { + background-color: #373737; + border-radius: 20px; + padding: 16px; + min-width: 400px; + max-width: 500px; + position: relative; + box-shadow: 0px 8px 24px rgba(0, 0, 0, 0.3); + + @media (max-width: 600px) { + min-width: 90vw; + max-width: 90vw; + padding: 12px; + } +} + +.closeButton { + cursor: pointer; + z-index: 1; + position: absolute !important; + top: 16px !important; + right: 16px !important; + color: white !important; + + &:hover { + background-color: rgba(255, 255, 255, 0.1) !important; + } +} + +.content { + display: flex; + flex-direction: column; + gap: 12px; +} + +.iconContainer { + display: flex; + justify-content: flex-start; + align-items: center; + margin-bottom: 8px; +} + +.iconText { + font-size: 2rem !important; +} + +.title { + color: white; + font-weight: bold; + font-size: 1.5rem; + text-align: left; + margin: 0 !important; + + @media (max-width: 600px) { + font-size: 1.25rem; + } +} + +.contentText { + color: rgba(255, 255, 255, 0.9); + font-size: 1rem; + line-height: 1.5; + text-align: left; + margin: 0 !important; + + @media (max-width: 600px) { + font-size: 0.9rem; + } +} + +.controls { + margin-top: 8px; + width: 100%; +} + +.button { + text-transform: none; + font-weight: 500; + padding: 10px 24px; + border-radius: 8px; +} + +.buttonBack { + border-color: rgba(255, 255, 255, 0.3) !important; + color: white !important; + + &:hover { + border-color: rgba(255, 255, 255, 0.5) !important; + background-color: rgba(255, 255, 255, 0.1) !important; + } +} + +.buttonNext { + background-color: #7F7DF3 !important; + color: white !important; + flex: 1 !important; + + &:hover { + background-color: #6B69E0 !important; + } +} + +.stepIndicator { + display: flex; + justify-content: center; + gap: 8px; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.stepDot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: rgba(255, 255, 255, 0.3); + transition: all 0.3s ease; + + &Active { + background-color: #7F7DF3; + width: 24px; + border-radius: 4px; + } +} + @@ -0,0 +1,75 @@ +import React from 'react' +import { Box, Button, IconButton, Stack, Typography } from '@mui/material' +import { type CardComponentProps } from 'nextstepjs' +import Image from 'next/image' + +import styles from './tour-card.module.scss' + +export const TourCard: React.FC = ({ + step, + currentStep, + totalSteps, + nextStep, + prevStep, + skipTour, + arrow, +}) => { + return ( + + {step.showSkip && skipTour && ( + + Закрыть + + )} + + + {step.icon && ( + + + {step.icon} + + + )} + + {step.title && ( + + {step.title} + + )} + + {step.content && ( + + {step.content} + + )} + + {step.showControls && ( + + {currentStep > 0 && ( + + )} + + + + )} + + + ) +} + @@ -0,0 +1,141 @@ +import React, { useEffect, useState, useRef } from 'react' +import { useRouter } from 'next/router' +import { useSession } from 'next-auth/react' +import { useNextStep } from 'nextstepjs' + +import { useAppSelector } from '#/app/store/store' +import { useFeatureFlag } from '#/shared/lib/hooks/use-feature-flag' +import { TourName } from '../lib/constants' +import { setTourCompleted, isTourCompleted } from '../lib/tour-storage' +import { TourSelector } from './tour-selector' + +interface TourManagerProps { + children: React.ReactNode +} + +export const TourManager: React.FC = ({ children }) => { + const { startNextStep, setCurrentStep, currentTour, currentStep } = useNextStep() + const { data: session } = useSession() + const isOnboardingEnabled = useFeatureFlag('onboarding-enabled') + const [showTourSelector, setShowTourSelector] = useState(false) + const [pendingTour, setPendingTour] = useState(null) + const hasAdvancedStep = useRef(false) + const currentStepRef = useRef(currentStep) + const router = useRouter() + + useEffect(() => { + currentStepRef.current = currentStep + }, [currentStep]) + + const isTourSelectorShown = (): boolean => { + if (typeof window === 'undefined') return false + return sessionStorage.getItem('tour-selector-shown') === 'true' + } + + + const setTourSelectorShown = (): void => { + if (typeof window !== 'undefined') { + sessionStorage.setItem('tour-selector-shown', 'true') + } + } + + const handleTourSelect = (tourName: TourName) => { + setShowTourSelector(false) + + if (tourName === 'text-models') { + // Переходим на страницу с текстовыми моделями + setPendingTour(tourName) + router.push('/chat-bot') + } else if (tourName === 'images') { + // Переходим на страницу с изображениями + setPendingTour(tourName) + router.push('/images') + } + } + + const handleSkip = () => { + setTourCompleted() + setShowTourSelector(false) + setTourSelectorShown() + } + + useEffect(() => { + // Проверяем feature flag: tour-manager работает только если onboarding-enabled включен + if (!isOnboardingEnabled) { + return + } + + if (pendingTour === 'text-models' && router.pathname === '/chat-bot') { + const timer = setTimeout(() => { + startNextStep(pendingTour) + setPendingTour(null) + }, 500) + + return () => clearTimeout(timer) + } + + if (pendingTour === 'images' && router.pathname === '/images') { + const timer = setTimeout(() => { + startNextStep(pendingTour) + setPendingTour(null) + }, 500) + + return () => clearTimeout(timer) + } + + if (router.pathname === '/chat-bot/[slug]' || router.pathname === '/images/[slug]') { + if (!hasAdvancedStep.current && currentTour) { + const timer = setTimeout(() => { + setCurrentStep(1) + hasAdvancedStep.current = true + }, 500) + + return () => clearTimeout(timer) + } + } else { + hasAdvancedStep.current = false + } + + }, [router.pathname, pendingTour, startNextStep, currentTour, setCurrentStep, isOnboardingEnabled]) + + const created_at = useAppSelector((state) => state.user.created_at) + + const isNewUser = (): boolean => { + if (!created_at) return false + + const createdDate = new Date(created_at) + const now = new Date() + const minutesSinceCreation = (now.getTime() - createdDate.getTime()) / (1000 * 60) + + // Пользователь считается новым, если зарегистрирован менее 5 минут назад + return minutesSinceCreation < 5 + } + + useEffect(() => { + // Проверяем feature flag: tour-manager работает только если onboarding-enabled включен + if (!isOnboardingEnabled) { + return + } + + if (session?.user && session?.access) { + // Показываем TourSelector только если: + // 1. Тур еще не завершен + // 2. TourSelector еще не был показан в текущей сессии + // 3. Пользователь зарегшистрировался недавно + if (!isTourCompleted() && !isTourSelectorShown() && isNewUser()) { + setTimeout(() => { + setShowTourSelector(true) + setTourSelectorShown() + }, 3000) + } + } + }, [session, isOnboardingEnabled]) + + return ( + <> + + {children} + + ) +} + @@ -0,0 +1,33 @@ +.close { + cursor: pointer; + position: absolute; + top: 22px; + right: 25px; + z-index: 1; +} + +.wrap { + padding: 40px; + width: 100%; + text-align: left; + box-sizing: border-box; + + @media (max-width: 800px) { + padding: 24px; + } +} + +.dialog { + :global(.MuiDialog-container) { + align-items: flex-start; + padding-top: 10vh; + } + + :global(.MuiPaper-root) { + border-radius: 20px !important; + background-color: #373737 !important; + max-width: 800px !important; + width: 100% !important; + margin: 20px !important; + } +} @@ -0,0 +1,34 @@ +import React from 'react' +import { Box } from '@mui/material' +import Dialog from '@mui/material/Dialog' +import Image from 'next/image' + +import styles from './tour-modal.module.scss' + +interface TourModalProps { + open: boolean + onClose: (e?: any) => void + children?: React.ReactNode +} + +export const TourModal: React.FC = ({ open, onClose, children }) => { + return ( + + {''} + {children} + + ) +} + @@ -0,0 +1,180 @@ +.container { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + max-width: 742px; + padding: 0; + box-sizing: border-box; + + @media (max-width: 800px) { + max-width: 100%; + } +} + +.title { + margin-bottom: 0 !important; + align-self: flex-start; + font-weight: bold; + color: white; + font-size: 1.5rem; +} + +.buttonsContainer { + display: flex; + flex-direction: row; + gap: 12px; + width: 100%; + + @media (max-width: 800px) { + flex-direction: column; + gap: 12px; + } +} + +.button { + padding: 24px; + text-transform: none; + flex: 1; + border-radius: 12px; + transition: all 0.3s ease; + min-height: 120px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: none !important; + transform: none !important; + + &:focus, + &:active { + box-shadow: none !important; + transform: none !important; + outline: none !important; + } + + &:hover { + transform: translateY(-2px); + box-shadow: 0px 4px 12px rgba(127, 125, 243, 0.3); + } +} + +.buttonText { + background-color: #2A2A2A !important; + border: 1px solid #7F7DF3 !important; + color: #7F7DF3 !important; + box-shadow: none !important; + + .buttonLabel { + color: #7F7DF3 !important; + } + + &:focus, + &:active { + background-color: #2A2A2A !important; + border-color: #7F7DF3 !important; + box-shadow: none !important; + color: #7F7DF3 !important; + } + + &:hover { + background-color: #7F7DF3 !important; + border-color: #7F7DF3 !important; + color: white !important; + + .buttonLabel { + color: white !important; + } + + .icon { + :global(div) { + filter: brightness(0) invert(1); + } + + :global(svg) { + stroke: white !important; + fill: white !important; + + :global(path), + :global(circle), + :global(rect), + :global(line) { + stroke: white !important; + fill: white !important; + } + } + } + } +} + +.buttonContent { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + width: 100%; +} + +.icon { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + transition: all 0.3s ease; + + :global(svg), + :global(path), + :global(circle), + :global(rect) { + transition: all 0.3s ease; + } +} + +.buttonLabel { + font-size: 1rem; + font-weight: 500; + color: #7F7DF3; + text-align: center; + transition: color 0.3s ease; +} + +.skipLink { + color: rgba(255, 255, 255, 0.6); + font-size: 0.9rem; + cursor: pointer; + align-self: flex-end; + transition: color 0.3s ease; + margin-top: 8px; + + &:hover { + color: rgba(255, 255, 255, 0.9); + } +} + +.confirmModalButtons { + display: flex; + gap: 16px; + margin-top: 16px; +} + +.confirmButton { + background-color: #7F7DF3 !important; + color: white !important; + text-transform: none !important; + + &:hover { + background-color: #6B69E0 !important; + } +} + +.skipButton { + border-color: rgba(255, 255, 255, 0.3) !important; + color: white !important; + text-transform: none !important; + + &:hover { + border-color: rgba(255, 255, 255, 0.5) !important; + background-color: rgba(255, 255, 255, 0.1) !important; + } +} @@ -0,0 +1,98 @@ +import React, { useState } from 'react' +import { Box, Button, Stack, Typography } from '@mui/material' + +import { SvgIcon } from '#/shared/ui/svg' +import { Modal } from '#/shared/ui/modal/modal' +import { TourName } from '../lib/constants' +import { TourModal } from './tour-modal' + +import styles from './tour-selector.module.scss' + +interface TourSelectorProps { + open: boolean + onSelect: (tourName: TourName) => void + onSkip?: () => void +} + +export const TourSelector: React.FC = ({ open, onSelect, onSkip }) => { + const [confirmSkipModal, setConfirmSkipModal] = useState(false) + + const handleClose = () => { + setConfirmSkipModal(true) + } + + const handleConfirmSkip = () => { + setConfirmSkipModal(false) + if (onSkip) { + onSkip() + } + } + + return ( + <> + + + + Что вы хотели сгенерировать? + + + + + + + Пропустить обучение > + + + + + + Вы уверены, что хотите пропустить обучение? + + + + + + + + ) +} + @@ -0,0 +1,6 @@ +export { tourSteps, getTourSteps, type TourName } from './lib/constants' +export { TourSelector } from './ui/tour-selector' +export { TourManager } from './ui/tour-manager' +export { TourCard } from './ui/tour-card' +export { setTourCompleted, isTourCompleted } from './lib/tour-storage' + @@ -8,10 +8,14 @@ import type { AppProps } from 'next/app' import { Raleway } from 'next/font/google' import { getSession,SessionProvider } from 'next-auth/react' import { appWithTranslation } from 'next-i18next' +import { NextStep, NextStepProvider } from 'nextstepjs' +import { FlagProvider } from '@unleash/proxy-client-react' import { store } from '#/app/store/store' import { Error } from '#/shared' +import { TourManager, getTourSteps, TourCard, setTourCompleted } from '#/features/nextstep-tour' import { pingFangFont } from '#/shared/lib/constants/font/font' +import { getDeviceType } from '#/shared/lib/helpers' import { useBlockTelegram } from '#/shared/lib/hooks/use-block-telegram' import { Providers } from '#/widgets/providers' @@ -58,34 +62,66 @@ export interface AppPropsWithLayout extends AppProps { Component: NextPageWithLayout } -function App({ Component, pageProps: { session, ...pageProps } }: AppPropsWithLayout) { - useBlockTelegram() +function AppContent({ Component, pageProps }: { Component: NextPageWithLayout; pageProps: any }) { + const handleTourComplete = () => { + setTourCompleted() + } - const getLayout = Component.getLayout ?? ((page) => page) + const handleTourSkip = () => { + setTourCompleted() + } + + const tourSteps = React.useMemo(() => getTourSteps(getDeviceType()), [getDeviceType()]) return ( - <> - +
- - - - {getLayout()} - - - + + + + + {Component.getLayout ? Component.getLayout() : } + + + +
+
+ ) +} + +function App({ Component, pageProps: { session, ...pageProps } }: AppPropsWithLayout) { + useBlockTelegram() + + return ( + <> + + + + + + ) } @@ -1,2 +1,3 @@ export { useAutoScroll } from './use-auto-scroll' export { useThemeAndDevice } from './use-theme-and-device' +export { useFeatureFlag } from './use-feature-flag' \ No newline at end of file @@ -0,0 +1,5 @@ +import { useFlag } from '@unleash/proxy-client-react' + +export const useFeatureFlag = (flagName: string): boolean => { + return useFlag(flagName) +} \ No newline at end of file @@ -276,7 +276,7 @@ const Page: NextPageWithLayout = () => { {desktop && ( - + {botParams?.versions && botParams.versions?.length !== 0 && ( <> { > Чат-боты -
+
{bots ? ( bots.map((item, index) => ) ) : ( @@ -159,6 +159,7 @@ const ImageModelPage: NextPageWithLayout = () => { {desktop ? ( <> { - + {botParams && ( { {desktop && ( - + {botParams?.versions && botParams.versions.length !== 0 ? ( <> { )} setOpenFiltersMobile(false)}> - + {botParams?.versions && botParams.versions.length !== 0 ? ( <> ({ loading={loading} /> -
+