diff --git a/src/components/Auth/__tests__/index.tsx b/src/components/Auth/__tests__/index.tsx
index cf0076ef1..7ce03854a 100644
--- a/src/components/Auth/__tests__/index.tsx
+++ b/src/components/Auth/__tests__/index.tsx
@@ -176,6 +176,37 @@ describe('Auth Component', () => {
})
})
+ test('should show onboarding modal if admin and no title is set', async () => {
+ const [setBudget, setIsAdmin, setPubKey, setIsAuthenticated] = [jest.fn(), jest.fn(), jest.fn(), jest.fn()]
+
+ useUserStoreMock.mockReturnValue({
+ isAdmin: true,
+ setBudget,
+ setIsAdmin,
+ setPubKey,
+ setIsAuthenticated,
+ })
+
+ getIsAdminMock.mockResolvedValue({ data: { isAdmin: true, title: null } })
+ getSignedMessageFromRelayMock.mockResolvedValue({ message: 'testMessage', signature: 'testSignature' })
+
+ render(
+
+
+
+
+
+
+
+
+ ,
+ )
+
+ waitFor(() => {
+ expect(screen.getByText('Welcome to SecondBrain')).toBeInTheDocument()
+ })
+ })
+
test.skip('the unauthorized state is correctly set when the user lacks proper credentials', async () => {
const [setBudget, setIsAdmin, setPubKey, setIsAuthenticated] = [jest.fn(), jest.fn(), jest.fn(), jest.fn()]
diff --git a/src/components/Auth/index.tsx b/src/components/Auth/index.tsx
index 540514c5e..5fce6b550 100644
--- a/src/components/Auth/index.tsx
+++ b/src/components/Auth/index.tsx
@@ -3,6 +3,7 @@ import * as sphinx from 'sphinx-bridge'
import styled from 'styled-components'
import { Flex } from '~/components/common/Flex'
import { Text } from '~/components/common/Text'
+import { OnboardingModal } from '~/components/ModalsContainer/OnboardingFlow'
import { isDevelopment, isE2E } from '~/constants'
import { getIsAdmin } from '~/network/auth'
import { useDataStore } from '~/stores/useDataStore'
@@ -18,6 +19,7 @@ export const AuthGuard = ({ children }: PropsWithChildren) => {
const { setBudget, setIsAdmin, setPubKey, setIsAuthenticated, setSwarmUiUrl } = useUserStore((s) => s)
const { splashDataLoading } = useDataStore((s) => s)
const [renderMainPage, setRenderMainPage] = useState(false)
+ const [showOnboarding, setShowOnboarding] = useState(false)
const {
setTrendingTopicsFeatureFlag,
@@ -75,6 +77,10 @@ export const AuthGuard = ({ children }: PropsWithChildren) => {
setRealtimeGraphFeatureFlag(res.data.realtimeGraph || false)
setChatInterfaceFeatureFlag(res.data.chatInterface || false)
setFastFiltersFeatureFlag(res.data.fastFilters || false)
+
+ if (isAdmin && !res.data.title) {
+ setShowOnboarding(true)
+ }
}
setIsAuthenticated(true)
@@ -131,6 +137,7 @@ export const AuthGuard = ({ children }: PropsWithChildren) => {
return (
<>
+ {showOnboarding && }
{splashDataLoading && }
{renderMainPage && children}
>
diff --git a/src/components/ModalsContainer/OnboardingFlow/GraphDetailsStep/index.tsx b/src/components/ModalsContainer/OnboardingFlow/GraphDetailsStep/index.tsx
new file mode 100644
index 000000000..007ba80ac
--- /dev/null
+++ b/src/components/ModalsContainer/OnboardingFlow/GraphDetailsStep/index.tsx
@@ -0,0 +1,164 @@
+import { Button } from '@mui/material'
+import { FC, useEffect } from 'react'
+import { useFormContext } from 'react-hook-form'
+import { MdError } from 'react-icons/md'
+import styled from 'styled-components'
+import { noSpacePattern } from '~/components/AddItemModal/SourceTypeStep/constants'
+import { Flex } from '~/components/common/Flex'
+import { Text } from '~/components/common/Text'
+import { TextInput } from '~/components/common/TextInput'
+import { requiredRule } from '~/constants'
+import { colors } from '~/utils'
+
+type Props = {
+ onSubmit: () => void
+ error?: string
+}
+
+export const GraphDetailsStep: FC = ({ onSubmit, error }) => {
+ const {
+ formState: { isSubmitting },
+ watch,
+ } = useFormContext()
+
+ const title = watch('title')
+ const description = watch('description')
+
+ const isFormValid = !!title?.trim() && !!description?.trim()
+
+ useEffect(() => {
+ const titleInput = document.getElementById('graph-title') as HTMLInputElement
+
+ if (titleInput) {
+ titleInput.focus()
+ }
+ }, [])
+
+ return (
+
+
+ Welcome to SecondBrain
+ Set a name and short description for your graph.
+
+
+
+
+
+
+
+
+
+
+
+
+ {error ? (
+
+
+
+ {error}
+
+
+ ) : null}
+
+ )
+}
+
+const StyledText = styled(Text)`
+ font-size: 22px;
+ font-weight: 600;
+ font-family: 'Barlow';
+ margin-bottom: 10px;
+`
+
+const StyledSubText = styled(Text)`
+ font-size: 14px;
+ font-family: 'Barlow';
+ margin-bottom: 20px;
+`
+
+const StyledWrapper = styled(Flex)`
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ gap: 10px;
+ margin: 0 0 15px 0;
+
+ .input__wrapper {
+ display: flex;
+ gap: 23px;
+ max-height: 225px;
+ overflow-y: auto;
+ padding-right: 20px;
+ width: calc(100% + 20px);
+ }
+`
+
+const StyledErrorText = styled(Flex)`
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ gap: 2px;
+
+ .errorIcon {
+ display: block;
+ font-size: 13px;
+ min-height: 13px;
+ min-width: 13px;
+ }
+
+ span {
+ display: -webkit-box;
+ -webkit-line-clamp: 1;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ white-space: normal;
+ letter-spacing: 0.2px;
+ padding-left: 4px;
+ font-size: 13px;
+ font-family: Barlow;
+ line-height: 18px;
+ }
+`
+
+const StyledError = styled(Flex)`
+ display: flex;
+ align-items: center;
+ color: ${colors.primaryRed};
+ position: relative;
+ margin-top: 20px;
+`
diff --git a/src/components/ModalsContainer/OnboardingFlow/__tests__/index.tsx b/src/components/ModalsContainer/OnboardingFlow/__tests__/index.tsx
new file mode 100644
index 000000000..520e1c7c2
--- /dev/null
+++ b/src/components/ModalsContainer/OnboardingFlow/__tests__/index.tsx
@@ -0,0 +1,120 @@
+import '@testing-library/jest-dom'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import React from 'react'
+import { postAboutData } from '~/network/fetchSourcesData'
+import { useModal } from '~/stores/useModalStore'
+import { OnboardingModal } from '../index'
+
+jest.mock('~/network/fetchSourcesData', () => ({
+ postAboutData: jest.fn(),
+}))
+
+jest.mock('~/stores/useModalStore', () => ({
+ useModal: jest.fn(),
+}))
+
+const useModalMock = useModal as jest.MockedFunction
+const postAboutDataMock = postAboutData as jest.MockedFunction
+
+describe('OnboardingModal Component', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+
+ useModalMock.mockReturnValue({
+ close: jest.fn(),
+ visible: true,
+ })
+ })
+
+ test('renders the onboarding modal', () => {
+ render()
+ expect(screen.getByText('Welcome to SecondBrain')).toBeInTheDocument()
+ expect(screen.getByText('Set a name and short description for your graph.')).toBeInTheDocument()
+ })
+
+ test('submits form successfully', async () => {
+ postAboutDataMock.mockResolvedValue({ status: 'success' })
+
+ render()
+
+ fireEvent.change(screen.getByPlaceholderText('Type graph title here...'), { target: { value: 'Test Title' } })
+
+ fireEvent.change(screen.getByPlaceholderText('Type graph description here...'), {
+ target: { value: 'Test Description' },
+ })
+
+ fireEvent.click(screen.getByText('Confirm'))
+
+ await waitFor(() => {
+ expect(postAboutDataMock).toHaveBeenCalledWith({
+ title: 'Test Title',
+ description: 'Test Description',
+ })
+ })
+ })
+
+ test('displays error on form submission failure', async () => {
+ postAboutDataMock.mockRejectedValue({ status: 400, json: async () => ({ errorCode: 'Error occurred' }) })
+
+ render()
+
+ fireEvent.change(screen.getByPlaceholderText('Type graph title here...'), { target: { value: 'Test Title' } })
+
+ fireEvent.change(screen.getByPlaceholderText('Type graph description here...'), {
+ target: { value: 'Test Description' },
+ })
+
+ fireEvent.click(screen.getByText('Confirm'))
+
+ await waitFor(() => {
+ expect(screen.getByText('Error occurred')).toBeInTheDocument()
+ })
+ })
+
+ test('closes modal on successful submission', async () => {
+ const closeMock = jest.fn()
+
+ useModalMock.mockReturnValue({
+ close: closeMock,
+ visible: true,
+ })
+
+ postAboutDataMock.mockResolvedValue({ status: 'success' })
+
+ render()
+
+ fireEvent.change(screen.getByPlaceholderText('Type graph title here...'), { target: { value: 'Test Title' } })
+
+ fireEvent.change(screen.getByPlaceholderText('Type graph description here...'), {
+ target: { value: 'Test Description' },
+ })
+
+ fireEvent.click(screen.getByText('Confirm'))
+
+ await waitFor(() => {
+ expect(closeMock).toHaveBeenCalled()
+ })
+ })
+
+ test('resets form and error on modal close', async () => {
+ const { rerender } = render()
+
+ fireEvent.change(screen.getByPlaceholderText('Type graph title here...'), { target: { value: 'Test Title' } })
+
+ fireEvent.change(screen.getByPlaceholderText('Type graph description here...'), {
+ target: { value: 'Test Description' },
+ })
+
+ useModalMock.mockReturnValue({
+ close: jest.fn(),
+ visible: false,
+ })
+
+ rerender()
+
+ waitFor(() => {
+ expect(screen.getByPlaceholderText('Type graph title here...')).toHaveValue('')
+ expect(screen.getByPlaceholderText('Type graph description here...')).toHaveValue('')
+ })
+ })
+})
diff --git a/src/components/ModalsContainer/OnboardingFlow/index.tsx b/src/components/ModalsContainer/OnboardingFlow/index.tsx
new file mode 100644
index 000000000..2d5123381
--- /dev/null
+++ b/src/components/ModalsContainer/OnboardingFlow/index.tsx
@@ -0,0 +1,75 @@
+import { useEffect, useState } from 'react'
+import { FormProvider, useForm } from 'react-hook-form'
+import { SuccessNotify } from '~/components/common/SuccessToast'
+import { BaseModal } from '~/components/Modal'
+import { NODE_ADD_ERROR } from '~/constants'
+import { postAboutData, TAboutParams } from '~/network/fetchSourcesData'
+import { useModal } from '~/stores/useModalStore'
+import { GraphDetailsStep } from './GraphDetailsStep'
+
+export type FormData = {
+ title: string
+ description: string
+}
+
+export const OnboardingModal = () => {
+ const { close, visible } = useModal('onboardingFlow')
+ const form = useForm({ mode: 'onChange' })
+ const { reset } = form
+ const [error, setError] = useState('')
+
+ useEffect(() => {
+ if (!visible) {
+ reset()
+ setError('')
+ }
+ }, [visible, reset])
+
+ const submitGraphDetails = async (
+ data: TAboutParams,
+ successCallback: () => void,
+ onError: (error: string) => void,
+ ) => {
+ try {
+ const res = (await postAboutData(data)) as Awaited<{ status: string }>
+
+ if (res.status === 'success') {
+ SuccessNotify('Graph details saved')
+ successCallback()
+ }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } catch (err: any) {
+ let errorMessage = NODE_ADD_ERROR
+
+ if (err?.status === 400) {
+ const errorRes = await err.json()
+
+ errorMessage = errorRes.errorCode || errorRes?.status || NODE_ADD_ERROR
+ } else if (err instanceof Error) {
+ errorMessage = err.message
+ }
+
+ onError(String(errorMessage))
+ }
+ }
+
+ const onSubmit = form.handleSubmit(async (data) => {
+ await submitGraphDetails(
+ data,
+ () => {
+ close()
+ },
+ setError,
+ )
+ })
+
+ return (
+
+
+
+
+
+ )
+}
diff --git a/src/components/ModalsContainer/index.tsx b/src/components/ModalsContainer/index.tsx
index 664489b77..c40fd3a60 100644
--- a/src/components/ModalsContainer/index.tsx
+++ b/src/components/ModalsContainer/index.tsx
@@ -46,6 +46,10 @@ const LazyCreateBountyModal = lazy(() =>
import('./CreateBountyModal').then(({ CreateBountyModal }) => ({ default: CreateBountyModal })),
)
+const LazyOnboardingModal = lazy(() =>
+ import('./OnboardingFlow').then(({ OnboardingModal }) => ({ default: OnboardingModal })),
+)
+
export const ModalsContainer = () => (
<>
@@ -60,5 +64,6 @@ export const ModalsContainer = () => (
+
>
)
diff --git a/src/stores/useModalStore/index.ts b/src/stores/useModalStore/index.ts
index 5dbf17182..b9efb3681 100644
--- a/src/stores/useModalStore/index.ts
+++ b/src/stores/useModalStore/index.ts
@@ -21,6 +21,7 @@ export type AvailableModals =
| 'changeNodeType'
| 'feedback'
| 'createBounty'
+ | 'onboardingFlow'
type ModalStore = {
currentModals: Record
@@ -51,6 +52,7 @@ const defaultData = {
changeNodeType: false,
feedback: false,
createBounty: false,
+ onboardingFlow: false,
},
}
diff --git a/src/types/index.ts b/src/types/index.ts
index bab9fe772..152948948 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -242,6 +242,7 @@ export type IsAdminResponse = {
chatInterface: boolean
swarmUiUrl: string
fastFilters: boolean
+ title: string
}
success: boolean
message: string