@@ -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 (
+
+ )
+}
+
@@ -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}
/>
-