Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion web/app/modals/NewFunnel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useCurrentProject } from '~/providers/CurrentProjectProvider'
import Input from '~/ui/Input'
import Modal from '~/ui/Modal'
import Select from '~/ui/Select'
import { Text } from '~/ui/Text'

interface NewFunnelProps {
onClose: () => void
Expand Down Expand Up @@ -88,7 +89,9 @@ const NewFunnel = ({ onClose, onSubmit, isOpened, funnel, loading }: NewFunnelPr
onChange={(e) => setName(e.target.value)}
disabled={!allowedToManage}
/>
<p className='mt-5 text-sm font-medium text-gray-700 dark:text-gray-200'>{t('modals.funnels.steps')}</p>
<Text as='p' size='sm' weight='medium' colour='secondary' className='mt-5'>
{t('modals.funnels.steps')}
</Text>
{_map(steps, (step, index) => (
<div key={index} className='mt-1 flex items-center space-x-2'>
<Select
Expand Down
13 changes: 5 additions & 8 deletions web/app/pages/Dashboard/AddProject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,9 @@ import { FolderPlusIcon } from '@heroicons/react/24/outline'
import cx from 'clsx'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'

import routes from '~/utils/routes'

interface AddProjectProps {
onClick: (e: React.MouseEvent<HTMLAnchorElement>) => void
onClick: () => void
sitesCount: number
viewMode: 'grid' | 'list'
}
Expand All @@ -16,15 +13,15 @@ export const AddProject = ({ onClick, sitesCount, viewMode }: AddProjectProps) =
const { t } = useTranslation('common')

return (
<Link
to={routes.new_project}
<button
type='button'
onClick={onClick}
className={cx(
'group cursor-pointer border-2 border-dashed border-gray-300 hover:border-gray-400',
viewMode === 'list'
? 'flex h-[72px] items-center justify-center rounded-lg'
: cx('flex h-auto min-h-[153.1px] items-center justify-center rounded-lg', {
'lg:min-h-[auto]': sitesCount % 3 !== 0,
'lg:min-h-auto': sitesCount % 3 !== 0,
}),
)}
>
Expand All @@ -39,6 +36,6 @@ export const AddProject = ({ onClick, sitesCount, viewMode }: AddProjectProps) =
{t('dashboard.newProject')}
</span>
</div>
</Link>
</button>
)
}
165 changes: 155 additions & 10 deletions web/app/pages/Dashboard/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import cx from 'clsx'
import _isEmpty from 'lodash/isEmpty'
import _keys from 'lodash/keys'
import _map from 'lodash/map'
import _size from 'lodash/size'
import { StretchHorizontalIcon, LayoutGridIcon, SearchIcon, XIcon, FolderPlusIcon, CircleXIcon } from 'lucide-react'
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useLoaderData, useNavigate, useSearchParams } from 'react-router'
import { ClientOnly } from 'remix-utils/client-only'
import { toast } from 'sonner'

import { getProjects, getLiveVisitors, getOverallStats, getOverallStatsCaptcha } from '~/api'
import { getProjects, getLiveVisitors, getOverallStats, getOverallStatsCaptcha, createProject } from '~/api'
import DashboardLockedBanner from '~/components/DashboardLockedBanner'
import EventsRunningOutBanner from '~/components/EventsRunningOutBanner'
import { withAuthentication, auth } from '~/hoc/protected'
Expand All @@ -19,8 +21,12 @@ import { isSelfhosted, LIVE_VISITORS_UPDATE_INTERVAL, tbPeriodPairs } from '~/li
import { Overall, Project } from '~/lib/models/Project'
import { FeatureFlag } from '~/lib/models/User'
import { useAuth } from '~/providers/AuthProvider'
import Input from '~/ui/Input'
import Modal from '~/ui/Modal'
import Pagination from '~/ui/Pagination'
import Select from '~/ui/Select'
import { Text } from '~/ui/Text'
import { trackCustom } from '~/utils/analytics'
import { setCookie } from '~/utils/cookie'
import routes from '~/utils/routes'

Expand All @@ -38,6 +44,9 @@ const DASHBOARD_VIEW = {
LIST: 'list',
} as const

const MAX_PROJECT_NAME_LENGTH = 50
const DEFAULT_PROJECT_NAME = 'Untitled Project'

const Dashboard = () => {
const { viewMode: defaultViewMode } = useLoaderData<any>()
const { user, isLoading: authLoading } = useAuth()
Expand Down Expand Up @@ -99,6 +108,27 @@ const Dashboard = () => {
return sortParam && Object.values(SORT_OPTIONS).includes(sortParam as any) ? sortParam : SORT_OPTIONS.ALPHA_ASC
})

// New project modal state
const [newProjectModalOpen, setNewProjectModalOpen] = useState(false)
const [newProjectName, setNewProjectName] = useState('')
const [newProjectOrganisationId, setNewProjectOrganisationId] = useState<string | undefined>(undefined)
const [isNewProjectLoading, setIsNewProjectLoading] = useState(false)
const [newProjectError, setNewProjectError] = useState<string | null>(null)
const [newProjectBeenSubmitted, setNewProjectBeenSubmitted] = useState(false)

const organisations = useMemo(
() => [
{
id: undefined as string | undefined,
name: t('common.notSet'),
},
...(user?.organisationMemberships || [])
.filter((om) => om.confirmed && (om.role === 'admin' || om.role === 'owner'))
.map((om) => om.organisation),
],
[user?.organisationMemberships, t],
)

const pageAmount = Math.ceil(paginationTotal / pageSize)

// This search represents what's inside the search input
Expand Down Expand Up @@ -156,13 +186,84 @@ const Dashboard = () => {

const _viewMode = isAboveLgBreakpoint ? viewMode : 'grid'

const onNewProject = (e: React.MouseEvent<HTMLAnchorElement>) => {
if (user?.isActive || isSelfhosted) {
const onNewProject = () => {
if (!user?.isActive && !isSelfhosted) {
setShowActivateEmailModal(true)
return
}

setNewProjectModalOpen(true)
}

const validateProjectName = () => {
const errors: { name?: string } = {}

if (_isEmpty(newProjectName)) {
errors.name = t('project.settings.noNameError')
}

if (_size(newProjectName) > MAX_PROJECT_NAME_LENGTH) {
errors.name = t('project.settings.pxCharsError', { amount: MAX_PROJECT_NAME_LENGTH })
}

return { errors, valid: _isEmpty(_keys(errors)) }
}

const onCreateProject = async () => {
if (isNewProjectLoading) {
return
}

setNewProjectBeenSubmitted(true)
const { errors, valid } = validateProjectName()

if (!valid) {
setNewProjectError(errors.name || null)
return
}

e.preventDefault()
setShowActivateEmailModal(true)
setIsNewProjectLoading(true)

try {
await createProject({
name: newProjectName || DEFAULT_PROJECT_NAME,
organisationId: newProjectOrganisationId,
})
trackCustom('PROJECT_CREATED', {
from: 'dashboard-modal',
})

await refetchProjects()

toast.success(t('project.settings.created'))
closeNewProjectModal()
} catch (reason: unknown) {
console.error('[ERROR] Error while creating project:', reason)

const normalizedMessage =
typeof reason === 'string'
? reason
: (reason as any)?.response?.data?.message ||
(reason as any)?.message ||
t('apiNotifications.somethingWentWrong')

setNewProjectError(normalizedMessage)
toast.error(normalizedMessage)
} finally {
setIsNewProjectLoading(false)
}
}

const closeNewProjectModal = () => {
if (isNewProjectLoading) {
return
}

setNewProjectModalOpen(false)
setNewProjectError(null)
setNewProjectName('')
setNewProjectOrganisationId(undefined)
setNewProjectBeenSubmitted(false)
}

const refetchProjects = async () => {
Expand Down Expand Up @@ -330,7 +431,7 @@ const Dashboard = () => {
<div className='mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8'>
<div className={cx('flex flex-wrap justify-between gap-2', showTabs ? 'mb-2' : 'mb-4')}>
<div className='flex items-end justify-between'>
<h2 className='mt-2 flex items-baseline gap-2 text-3xl font-bold text-gray-900 dark:text-gray-50'>
<Text as='h2' size='3xl' weight='bold' className='mt-2 flex items-baseline gap-2'>
<span>{t('titles.dashboard')}</span>
{isSearchActive ? (
<button
Expand Down Expand Up @@ -365,7 +466,7 @@ const Dashboard = () => {
/>
</button>
)}
</h2>
</Text>
{isSearchActive ? (
<div className='hidden w-full max-w-md items-center px-2 pb-1 sm:ml-2 sm:flex'>
<label htmlFor='project-search' className='sr-only'>
Expand Down Expand Up @@ -431,14 +532,14 @@ const Dashboard = () => {
</button>
) : null}
</div>
<Link
to={routes.new_project}
<button
type='button'
onClick={onNewProject}
className='ml-3 inline-flex cursor-pointer items-center justify-center rounded-md border border-transparent bg-slate-900 p-2 text-center text-sm font-medium text-white transition-colors hover:bg-slate-700 dark:border-gray-800 dark:bg-slate-800 dark:text-gray-50 dark:hover:bg-slate-700'
>
<FolderPlusIcon className='mr-1 h-5 w-5' strokeWidth={1.5} />
{t('dashboard.newProject')}
</Link>
</button>
</div>
</div>
{isSearchActive ? (
Expand Down Expand Up @@ -547,6 +648,50 @@ const Dashboard = () => {
message={t('dashboard.verifyEmailDesc')}
isOpened={showActivateEmailModal}
/>
<Modal
isLoading={isNewProjectLoading}
onClose={closeNewProjectModal}
onSubmit={onCreateProject}
submitText={t('common.continue')}
overflowVisible
message={
<div>
<Input
name='project-name-input'
label={t('project.settings.name')}
value={newProjectName}
placeholder='My awesome website'
onChange={(e) => setNewProjectName(e.target.value)}
error={newProjectBeenSubmitted ? newProjectError : null}
/>
{organisations.length > 1 ? (
<div className='mt-4'>
<Select
items={organisations}
keyExtractor={(item) => item.id || 'not-set'}
labelExtractor={(item) => {
if (item.id === undefined) {
return <span className='italic'>{t('common.notSet')}</span>
}

return item.name
}}
onSelect={(item) => {
setNewProjectOrganisationId(item.id)
}}
label={t('project.settings.organisation')}
title={organisations.find((org) => org.id === newProjectOrganisationId)?.name}
selectedItem={organisations.find((org) => org.id === newProjectOrganisationId)}
/>
</div>
) : null}
<p className='mt-2 text-sm text-gray-500 italic dark:text-gray-300'>{t('project.settings.createHint')}</p>
</div>
}
title={t('project.settings.create')}
isOpened={newProjectModalOpen}
submitDisabled={!newProjectName}
/>
</>
)
}
Expand Down
27 changes: 16 additions & 11 deletions web/app/pages/Dashboard/NoProjects.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@ import { FolderPlusIcon } from '@heroicons/react/24/outline'
import { FolderInputIcon } from 'lucide-react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'

import routes from '~/utils/routes'
import { Text } from '~/ui/Text'

import { DASHBOARD_TABS } from './Tabs'

interface NoProjectsProps {
onClick: (e: React.MouseEvent<HTMLAnchorElement>) => void
onClick: () => void
activeTab: (typeof DASHBOARD_TABS)[number]['id']
search: string
}
Expand All @@ -20,29 +19,35 @@ export const NoProjects = ({ onClick, activeTab, search }: NoProjectsProps) => {
if (activeTab !== 'default' || search) {
return (
<div className='mt-5 flex flex-col py-6 sm:px-6 lg:px-8'>
<div className='mx-auto w-full max-w-6xl text-gray-900 dark:text-gray-50'>
<h3 className='mb-8 text-center text-xl leading-snug'>{t('dashboard.noProjectsForCriteria')}</h3>
<div className='mx-auto w-full max-w-6xl'>
<Text as='h3' size='xl' className='mb-8 text-center leading-snug'>
{t('dashboard.noProjectsForCriteria')}
</Text>
</div>
</div>
)
}

return (
<div className='mx-auto w-full max-w-2xl py-16 text-center text-gray-900 dark:text-gray-50'>
<div className='mx-auto w-full max-w-2xl py-16 text-center'>
<div className='mx-auto mb-6 flex size-14 items-center justify-center rounded-xl bg-gray-100 dark:bg-slate-800'>
<FolderInputIcon className='size-7 text-gray-700 dark:text-gray-200' strokeWidth={1.5} />
</div>
<h3 className='text-xl font-medium tracking-tight'>{t('dashboard.noProjects')}</h3>
<p className='mx-auto mt-2 max-w-md text-sm text-gray-800 dark:text-gray-200'>{t('dashboard.createProject')}</p>
<Text as='h3' size='xl' weight='medium' className='tracking-tight'>
{t('dashboard.noProjects')}
</Text>
<Text as='p' size='sm' colour='secondary' className='mx-auto mt-2 max-w-md'>
{t('dashboard.createProject')}
</Text>
<div className='mt-6'>
<Link
to={routes.new_project}
<button
type='button'
onClick={onClick}
className='inline-flex items-center justify-center rounded-md border border-transparent bg-slate-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-slate-700 dark:border-gray-800 dark:bg-slate-800 dark:text-gray-50 dark:hover:bg-slate-700'
>
<FolderPlusIcon className='mr-2 h-5 w-5' />
{t('dashboard.newProject')}
</Link>
</button>
</div>
</div>
)
Expand Down
Loading