Merge pull request #57 from ciphera-net/staging
Sidebar redesign, dropdown fixes, and soft-delete UI
This commit is contained in:
@@ -8,6 +8,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **Funnels now track actions, not just pages.** When creating or editing a funnel, you can now choose between "Page Visit" and "Custom Event" for each step. Page Visit steps work as before — matching URLs. Custom Event steps let you track specific actions like signups, purchases, or button clicks. You can also add property filters to event steps (e.g., "purchase where plan is pro") to get even more specific about what you're measuring.
|
||||||
|
- **Edit your funnels.** You can now edit existing funnels — change the name, description, steps, or conversion window without having to delete and recreate them. Click the pencil icon on any funnel's detail page.
|
||||||
|
- **Conversion window.** Funnels now have a configurable time limit. Visitors must complete all steps within your chosen window (e.g., 7 days, 24 hours) to count as converted. Set it when creating or editing a funnel — quick presets for common windows, or type your own. Default is 7 days.
|
||||||
|
- **Filter your funnels.** Apply the same filters you use on the dashboard — by device, country, browser, UTM source, and more — directly on your funnel stats. See how your funnel performs for mobile visitors vs desktop, or for traffic from a specific campaign.
|
||||||
|
- **See where visitors go after dropping off.** Each funnel step now shows the top pages visitors navigated to after leaving the funnel. A quick preview appears inline, and you can expand to see the full list. Helps you understand why visitors aren't converting.
|
||||||
|
- **Conversion trends over time.** A new chart below your funnel shows how conversion rates change day by day. See at a glance whether your funnel is improving or degrading. Toggle individual steps on or off to pinpoint which step is changing.
|
||||||
|
- **Step-level breakdowns.** Click any step in your funnel stats to open a breakdown panel showing who converts at that step — split by device, country, browser, or traffic source. Useful for spotting segments that convert better or worse than average.
|
||||||
|
- **Up to 8 steps per funnel.** The step limit has been increased from 5 to 8, so you can track longer user journeys like multi-page onboarding flows or detailed checkout processes.
|
||||||
- **BunnyCDN integration.** Connect your BunnyCDN account in Settings > Integrations to monitor your CDN performance right alongside your analytics. A new "CDN" tab on your dashboard shows total bandwidth served, request volume, cache hit rate, origin response time, and error counts — each with percentage changes compared to the previous period. Charts show bandwidth trends (total vs cached), daily request volume, and error breakdowns over time. A geographic breakdown shows which countries consume the most bandwidth. When connecting, Pulse automatically filters your pull zones to only show ones matching your site's domain. Pulse only stores your API key encrypted and only reads statistics — it never modifies anything in your BunnyCDN account. You can disconnect and fully remove all CDN data at any time.
|
- **BunnyCDN integration.** Connect your BunnyCDN account in Settings > Integrations to monitor your CDN performance right alongside your analytics. A new "CDN" tab on your dashboard shows total bandwidth served, request volume, cache hit rate, origin response time, and error counts — each with percentage changes compared to the previous period. Charts show bandwidth trends (total vs cached), daily request volume, and error breakdowns over time. A geographic breakdown shows which countries consume the most bandwidth. When connecting, Pulse automatically filters your pull zones to only show ones matching your site's domain. Pulse only stores your API key encrypted and only reads statistics — it never modifies anything in your BunnyCDN account. You can disconnect and fully remove all CDN data at any time.
|
||||||
- **Google Search Console integration.** Connect your Google Search Console account in Settings > Integrations to see which search queries bring visitors to your site. A new "Search" tab on your dashboard shows total clicks, impressions, average CTR, and average ranking position — with percentage changes compared to the previous period. Browse your top search queries and top pages in sortable, paginated tables. Click any query to see which pages rank for it, or click any page to see which queries drive traffic to it. A trend chart shows how clicks and impressions change over time, and a green badge highlights new queries that appeared this period. Pulse only requests read-only access to your Search Console data, encrypts your Google credentials, and lets you disconnect and fully remove all search data at any time.
|
- **Google Search Console integration.** Connect your Google Search Console account in Settings > Integrations to see which search queries bring visitors to your site. A new "Search" tab on your dashboard shows total clicks, impressions, average CTR, and average ranking position — with percentage changes compared to the previous period. Browse your top search queries and top pages in sortable, paginated tables. Click any query to see which pages rank for it, or click any page to see which queries drive traffic to it. A trend chart shows how clicks and impressions change over time, and a green badge highlights new queries that appeared this period. Pulse only requests read-only access to your Search Console data, encrypts your Google credentials, and lets you disconnect and fully remove all search data at any time.
|
||||||
- **Free plan now visible on the Pricing page.** The free tier is no longer hidden — it's displayed as the first option on the Pricing page so you can see exactly what you get before signing up: 1 site, 5,000 monthly pageviews, and 6 months of data retention, completely free.
|
- **Free plan now visible on the Pricing page.** The free tier is no longer hidden — it's displayed as the first option on the Pricing page so you can see exactly what you get before signing up: 1 site, 5,000 monthly pageviews, and 6 months of data retention, completely free.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useAuth } from '@/lib/auth/context'
|
|||||||
import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus'
|
import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
import { logger } from '@/lib/utils/logger'
|
import { logger } from '@/lib/utils/logger'
|
||||||
import { getUserOrganizations, switchContext, type OrganizationMember } from '@/lib/api/organization'
|
import { getUserOrganizations, switchContext, type OrganizationMember } from '@/lib/api/organization'
|
||||||
import { setSessionAction } from '@/app/actions/auth'
|
import { setSessionAction } from '@/app/actions/auth'
|
||||||
@@ -18,7 +19,6 @@ import SettingsModalWrapper from '@/components/settings/SettingsModalWrapper'
|
|||||||
|
|
||||||
const ORG_SWITCH_KEY = 'pulse_switching_org'
|
const ORG_SWITCH_KEY = 'pulse_switching_org'
|
||||||
|
|
||||||
// * Available Ciphera apps for the app switcher
|
|
||||||
const CIPHERA_APPS: CipheraApp[] = [
|
const CIPHERA_APPS: CipheraApp[] = [
|
||||||
{
|
{
|
||||||
id: 'pulse',
|
id: 'pulse',
|
||||||
@@ -26,7 +26,7 @@ const CIPHERA_APPS: CipheraApp[] = [
|
|||||||
description: 'Your current app — Privacy-first analytics',
|
description: 'Your current app — Privacy-first analytics',
|
||||||
icon: 'https://ciphera.net/pulse_icon_no_margins.png',
|
icon: 'https://ciphera.net/pulse_icon_no_margins.png',
|
||||||
href: 'https://pulse.ciphera.net',
|
href: 'https://pulse.ciphera.net',
|
||||||
isAvailable: false, // * Current app
|
isAvailable: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'drop',
|
id: 'drop',
|
||||||
@@ -49,6 +49,7 @@ const CIPHERA_APPS: CipheraApp[] = [
|
|||||||
function LayoutInner({ children }: { children: React.ReactNode }) {
|
function LayoutInner({ children }: { children: React.ReactNode }) {
|
||||||
const auth = useAuth()
|
const auth = useAuth()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const pathname = usePathname()
|
||||||
const isOnline = useOnlineStatus()
|
const isOnline = useOnlineStatus()
|
||||||
const { openSettings } = useSettingsModal()
|
const { openSettings } = useSettingsModal()
|
||||||
const [orgs, setOrgs] = useState<OrganizationMember[]>([])
|
const [orgs, setOrgs] = useState<OrganizationMember[]>([])
|
||||||
@@ -57,7 +58,6 @@ function LayoutInner({ children }: { children: React.ReactNode }) {
|
|||||||
return sessionStorage.getItem(ORG_SWITCH_KEY) === 'true'
|
return sessionStorage.getItem(ORG_SWITCH_KEY) === 'true'
|
||||||
})
|
})
|
||||||
|
|
||||||
// * Clear the switching flag once the page has settled after reload
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSwitchingOrg) {
|
if (isSwitchingOrg) {
|
||||||
sessionStorage.removeItem(ORG_SWITCH_KEY)
|
sessionStorage.removeItem(ORG_SWITCH_KEY)
|
||||||
@@ -66,7 +66,6 @@ function LayoutInner({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, [isSwitchingOrg])
|
}, [isSwitchingOrg])
|
||||||
|
|
||||||
// * Fetch organizations for the header organization switcher
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (auth.user) {
|
if (auth.user) {
|
||||||
getUserOrganizations()
|
getUserOrganizations()
|
||||||
@@ -76,7 +75,7 @@ function LayoutInner({ children }: { children: React.ReactNode }) {
|
|||||||
}, [auth.user])
|
}, [auth.user])
|
||||||
|
|
||||||
const handleSwitchOrganization = async (orgId: string | null) => {
|
const handleSwitchOrganization = async (orgId: string | null) => {
|
||||||
if (!orgId) return // Pulse doesn't support personal organization context
|
if (!orgId) return
|
||||||
try {
|
try {
|
||||||
const { access_token } = await switchContext(orgId)
|
const { access_token } = await switchContext(orgId)
|
||||||
await setSessionAction(access_token)
|
await setSessionAction(access_token)
|
||||||
@@ -87,66 +86,98 @@ function LayoutInner({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreateOrganization = () => {
|
const isAuthenticated = !!auth.user
|
||||||
router.push('/onboarding')
|
const showOfflineBar = Boolean(auth.user && !isOnline)
|
||||||
}
|
// Site pages use DashboardShell with full sidebar — no Header needed
|
||||||
|
const isSitePage = pathname.startsWith('/sites/') && pathname !== '/sites/new'
|
||||||
const showOfflineBar = Boolean(auth.user && !isOnline);
|
|
||||||
const barHeightRem = 2.5;
|
|
||||||
const headerHeightRem = 6;
|
|
||||||
const mainTopPaddingRem = barHeightRem + headerHeightRem;
|
|
||||||
|
|
||||||
if (isSwitchingOrg) {
|
if (isSwitchingOrg) {
|
||||||
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" portal={false} />
|
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" portal={false} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// While auth is loading on a site page, render nothing to prevent flash of public header
|
||||||
|
if (auth.loading && isSitePage) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticated site pages: full sidebar layout
|
||||||
|
// DashboardShell inside children handles everything
|
||||||
|
if (isAuthenticated && isSitePage) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{auth.user && <OfflineBanner isOnline={isOnline} />}
|
{showOfflineBar && <OfflineBanner isOnline={isOnline} />}
|
||||||
|
{children}
|
||||||
|
<SettingsModalWrapper />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticated non-site pages (sites list, onboarding, etc.): static header
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col min-h-screen">
|
||||||
|
{showOfflineBar && <OfflineBanner isOnline={isOnline} />}
|
||||||
<Header
|
<Header
|
||||||
auth={auth}
|
auth={auth}
|
||||||
LinkComponent={Link}
|
LinkComponent={Link}
|
||||||
logoSrc="/pulse_icon_no_margins.png"
|
logoSrc="/pulse_icon_no_margins.png"
|
||||||
appName="Pulse"
|
appName="Pulse"
|
||||||
|
variant="static"
|
||||||
orgs={orgs}
|
orgs={orgs}
|
||||||
activeOrgId={auth.user?.org_id}
|
activeOrgId={auth.user?.org_id}
|
||||||
onSwitchOrganization={handleSwitchOrganization}
|
onSwitchOrganization={handleSwitchOrganization}
|
||||||
onCreateOrganization={handleCreateOrganization}
|
onCreateOrganization={() => router.push('/onboarding')}
|
||||||
allowPersonalOrganization={false}
|
allowPersonalOrganization={false}
|
||||||
showFaq={false}
|
showFaq={false}
|
||||||
showSecurity={false}
|
showSecurity={false}
|
||||||
showPricing={true}
|
showPricing={false}
|
||||||
topOffset={showOfflineBar ? `${barHeightRem}rem` : undefined}
|
rightSideActions={<NotificationCenter />}
|
||||||
rightSideActions={auth.user ? <NotificationCenter /> : null}
|
|
||||||
apps={CIPHERA_APPS}
|
apps={CIPHERA_APPS}
|
||||||
currentAppId="pulse"
|
currentAppId="pulse"
|
||||||
onOpenSettings={openSettings}
|
onOpenSettings={openSettings}
|
||||||
|
/>
|
||||||
|
<main className="flex-1 pb-8">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
<SettingsModalWrapper />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public/marketing: floating header + footer
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col min-h-screen">
|
||||||
|
<Header
|
||||||
|
auth={auth}
|
||||||
|
LinkComponent={Link}
|
||||||
|
logoSrc="/pulse_icon_no_margins.png"
|
||||||
|
appName="Pulse"
|
||||||
|
variant="floating"
|
||||||
|
showFaq={false}
|
||||||
|
showSecurity={false}
|
||||||
|
showPricing={true}
|
||||||
|
topOffset={showOfflineBar ? '2.5rem' : undefined}
|
||||||
|
apps={CIPHERA_APPS}
|
||||||
|
currentAppId="pulse"
|
||||||
customNavItems={
|
customNavItems={
|
||||||
<>
|
|
||||||
{!auth.user && (
|
|
||||||
<Link
|
<Link
|
||||||
href="/features"
|
href="/features"
|
||||||
className="px-4 py-2 text-sm font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg hover:bg-neutral-100/50 dark:hover:bg-neutral-800/50 transition-all duration-200"
|
className="px-4 py-2 text-sm font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg hover:bg-neutral-100/50 dark:hover:bg-neutral-800/50 transition-all duration-200"
|
||||||
>
|
>
|
||||||
Features
|
Features
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<main
|
<main className="flex-1 pb-8 pt-24">
|
||||||
className={`flex-1 pb-8 ${showOfflineBar ? '' : 'pt-24'}`}
|
|
||||||
style={showOfflineBar ? { paddingTop: `${mainTopPaddingRem}rem` } : undefined}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
<Footer
|
<Footer
|
||||||
LinkComponent={Link}
|
LinkComponent={Link}
|
||||||
appName="Pulse"
|
appName="Pulse"
|
||||||
isAuthenticated={!!auth.user}
|
isAuthenticated={false}
|
||||||
/>
|
/>
|
||||||
<SettingsModalWrapper />
|
<SettingsModalWrapper />
|
||||||
</>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import SiteNav from '@/components/dashboard/SiteNav'
|
import DashboardShell from '@/components/dashboard/DashboardShell'
|
||||||
|
|
||||||
export default function SiteLayoutShell({
|
export default function SiteLayoutShell({
|
||||||
siteId,
|
siteId,
|
||||||
@@ -10,11 +10,8 @@ export default function SiteLayoutShell({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<DashboardShell siteId={siteId}>
|
||||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pt-8">
|
|
||||||
<SiteNav siteId={siteId} />
|
|
||||||
</div>
|
|
||||||
{children}
|
{children}
|
||||||
</>
|
</DashboardShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
58
app/sites/[id]/funnels/[funnelId]/edit/page.tsx
Normal file
58
app/sites/[id]/funnels/[funnelId]/edit/page.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import { useSWRConfig } from 'swr'
|
||||||
|
import { getFunnel, updateFunnel, type Funnel, type CreateFunnelRequest } from '@/lib/api/funnels'
|
||||||
|
import { toast } from '@ciphera-net/ui'
|
||||||
|
import FunnelForm from '@/components/funnels/FunnelForm'
|
||||||
|
import { FunnelDetailSkeleton } from '@/components/skeletons'
|
||||||
|
|
||||||
|
export default function EditFunnelPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const router = useRouter()
|
||||||
|
const { mutate } = useSWRConfig()
|
||||||
|
const siteId = params.id as string
|
||||||
|
const funnelId = params.funnelId as string
|
||||||
|
const [funnel, setFunnel] = useState<Funnel | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getFunnel(siteId, funnelId).then(setFunnel).catch(() => {
|
||||||
|
toast.error('Failed to load funnel')
|
||||||
|
router.push(`/sites/${siteId}/funnels`)
|
||||||
|
})
|
||||||
|
}, [siteId, funnelId, router])
|
||||||
|
|
||||||
|
const handleSubmit = async (data: CreateFunnelRequest) => {
|
||||||
|
try {
|
||||||
|
setSaving(true)
|
||||||
|
await updateFunnel(siteId, funnelId, data)
|
||||||
|
await mutate(['funnels', siteId])
|
||||||
|
toast.success('Funnel updated')
|
||||||
|
router.push(`/sites/${siteId}/funnels/${funnelId}`)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to update funnel. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!funnel) return <FunnelDetailSkeleton />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FunnelForm
|
||||||
|
siteId={siteId}
|
||||||
|
initialData={{
|
||||||
|
name: funnel.name,
|
||||||
|
description: funnel.description,
|
||||||
|
steps: funnel.steps.map(({ order, ...rest }) => rest),
|
||||||
|
conversion_window_value: funnel.conversion_window_value,
|
||||||
|
conversion_window_unit: funnel.conversion_window_unit,
|
||||||
|
}}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
submitLabel={saving ? 'Saving...' : 'Save Changes'}
|
||||||
|
cancelHref={`/sites/${siteId}/funnels/${funnelId}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import { ApiError } from '@/lib/api/client'
|
import { ApiError } from '@/lib/api/client'
|
||||||
import { getFunnel, getFunnelStats, deleteFunnel, type Funnel, type FunnelStats } from '@/lib/api/funnels'
|
import { getFunnel, getFunnelStats, getFunnelTrends, deleteFunnel, type Funnel, type FunnelStats, type FunnelTrends } from '@/lib/api/funnels'
|
||||||
|
import FilterBar from '@/components/dashboard/FilterBar'
|
||||||
|
import AddFilterDropdown from '@/components/dashboard/AddFilterDropdown'
|
||||||
|
import { type DimensionFilter, serializeFilters } from '@/lib/filters'
|
||||||
import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, Button } from '@ciphera-net/ui'
|
import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, Button } from '@ciphera-net/ui'
|
||||||
|
import { PencilSimple } from '@phosphor-icons/react'
|
||||||
import { FunnelDetailSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
import { FunnelDetailSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { FunnelChart } from '@/components/ui/funnel-chart'
|
import { FunnelChart } from '@/components/ui/funnel-chart'
|
||||||
import { getDateRange } from '@ciphera-net/ui'
|
import { getDateRange } from '@ciphera-net/ui'
|
||||||
|
import BreakdownDrawer from '@/components/funnels/BreakdownDrawer'
|
||||||
|
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts'
|
||||||
|
|
||||||
export default function FunnelReportPage() {
|
export default function FunnelReportPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@@ -23,17 +29,25 @@ export default function FunnelReportPage() {
|
|||||||
const [datePreset, setDatePreset] = useState<'7' | '30' | 'custom'>('30')
|
const [datePreset, setDatePreset] = useState<'7' | '30' | 'custom'>('30')
|
||||||
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
|
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
|
||||||
const [loadError, setLoadError] = useState<'not_found' | 'forbidden' | 'error' | null>(null)
|
const [loadError, setLoadError] = useState<'not_found' | 'forbidden' | 'error' | null>(null)
|
||||||
|
const [filters, setFilters] = useState<DimensionFilter[]>([])
|
||||||
|
const [expandedExitStep, setExpandedExitStep] = useState<number | null>(null)
|
||||||
|
const [trends, setTrends] = useState<FunnelTrends | null>(null)
|
||||||
|
const [visibleSteps, setVisibleSteps] = useState<Set<string>>(new Set())
|
||||||
|
const [breakdownStep, setBreakdownStep] = useState<number | null>(null)
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoadError(null)
|
setLoadError(null)
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const [funnelData, statsData] = await Promise.all([
|
const filterStr = serializeFilters(filters) || undefined
|
||||||
|
const [funnelData, statsData, trendsData] = await Promise.all([
|
||||||
getFunnel(siteId, funnelId),
|
getFunnel(siteId, funnelId),
|
||||||
getFunnelStats(siteId, funnelId, dateRange.start, dateRange.end)
|
getFunnelStats(siteId, funnelId, dateRange.start, dateRange.end, filterStr),
|
||||||
|
getFunnelTrends(siteId, funnelId, dateRange.start, dateRange.end, 'day', filterStr)
|
||||||
])
|
])
|
||||||
setFunnel(funnelData)
|
setFunnel(funnelData)
|
||||||
setStats(statsData)
|
setStats(statsData)
|
||||||
|
setTrends(trendsData)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = error instanceof ApiError ? error.status : 0
|
const status = error instanceof ApiError ? error.status : 0
|
||||||
if (status === 404) setLoadError('not_found')
|
if (status === 404) setLoadError('not_found')
|
||||||
@@ -43,7 +57,7 @@ export default function FunnelReportPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [siteId, funnelId, dateRange])
|
}, [siteId, funnelId, dateRange, filters])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
@@ -113,6 +127,21 @@ export default function FunnelReportPage() {
|
|||||||
value: s.visitors,
|
value: s.visitors,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const STEP_COLORS = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4', '#84CC16']
|
||||||
|
|
||||||
|
const trendsChartData = trends ? trends.dates.map((date, idx) => {
|
||||||
|
const point: Record<string, any> = {
|
||||||
|
date: new Date(date).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }),
|
||||||
|
overall: Math.round(trends.overall[idx] * 10) / 10,
|
||||||
|
}
|
||||||
|
for (const [stepKey, values] of Object.entries(trends.steps)) {
|
||||||
|
if (visibleSteps.has(stepKey)) {
|
||||||
|
point[`step_${stepKey}`] = Math.round(values[idx] * 10) / 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return point
|
||||||
|
}) : []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
@@ -157,6 +186,13 @@ export default function FunnelReportPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`/sites/${siteId}/funnels/${funnelId}/edit`}
|
||||||
|
className="p-2 text-neutral-400 hover:text-brand-orange hover:bg-orange-50 dark:hover:bg-orange-900/20 rounded-xl transition-colors"
|
||||||
|
aria-label="Edit funnel"
|
||||||
|
>
|
||||||
|
<PencilSimple className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
className="p-2 text-neutral-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-xl transition-colors"
|
className="p-2 text-neutral-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-xl transition-colors"
|
||||||
@@ -167,6 +203,18 @@ export default function FunnelReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-6">
|
||||||
|
<AddFilterDropdown
|
||||||
|
onAdd={(f) => setFilters(prev => [...prev, f])}
|
||||||
|
/>
|
||||||
|
<FilterBar
|
||||||
|
filters={filters}
|
||||||
|
onRemove={(i) => setFilters(prev => prev.filter((_, idx) => idx !== i))}
|
||||||
|
onClear={() => setFilters([])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Chart */}
|
{/* Chart */}
|
||||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden shadow-sm p-6 mb-8">
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden shadow-sm p-6 mb-8">
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-6">
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-6">
|
||||||
@@ -181,6 +229,90 @@ export default function FunnelReportPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Conversion Trends */}
|
||||||
|
{trends && trends.dates.length > 1 && (
|
||||||
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden shadow-sm p-6 mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||||
|
Conversion Trends
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{stats?.steps.map((s, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setVisibleSteps(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(String(i))) next.delete(String(i))
|
||||||
|
else next.add(String(i))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
className={`px-2 py-1 text-xs rounded-md transition-colors ${
|
||||||
|
visibleSteps.has(String(i))
|
||||||
|
? 'bg-brand-orange/10 text-brand-orange border border-brand-orange/30'
|
||||||
|
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-500 border border-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.step.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={trendsChartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-neutral-200 dark:text-neutral-700" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
className="text-neutral-500"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
domain={[0, 100]}
|
||||||
|
tickFormatter={(v) => `${v}%`}
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
className="text-neutral-500"
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => [`${value}%`]}
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: 'var(--color-neutral-900, #171717)',
|
||||||
|
border: '1px solid var(--color-neutral-700, #404040)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="overall"
|
||||||
|
name="Overall"
|
||||||
|
stroke="#F97316"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
/>
|
||||||
|
{Array.from(visibleSteps).map((stepKey) => (
|
||||||
|
<Line
|
||||||
|
key={stepKey}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={`step_${stepKey}`}
|
||||||
|
name={stats?.steps[Number(stepKey)]?.step.name || `Step ${stepKey}`}
|
||||||
|
stroke={STEP_COLORS[Number(stepKey) % STEP_COLORS.length]}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Detailed Stats Table */}
|
{/* Detailed Stats Table */}
|
||||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden">
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -195,7 +327,8 @@ export default function FunnelReportPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
|
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||||
{stats.steps.map((step, i) => (
|
{stats.steps.map((step, i) => (
|
||||||
<tr key={step.step.name} className="hover:bg-neutral-50 dark:hover:bg-neutral-800/30 transition-colors">
|
<React.Fragment key={step.step.name}>
|
||||||
|
<tr className="hover:bg-neutral-50 dark:hover:bg-neutral-800/30 transition-colors cursor-pointer" onClick={() => setBreakdownStep(i)}>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="w-6 h-6 rounded-full bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-xs font-medium text-neutral-600 dark:text-neutral-400">
|
<span className="w-6 h-6 rounded-full bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-xs font-medium text-neutral-600 dark:text-neutral-400">
|
||||||
@@ -231,6 +364,35 @@ export default function FunnelReportPage() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{step.exit_pages && step.exit_pages.length > 0 && (
|
||||||
|
<tr className="bg-neutral-50/50 dark:bg-neutral-800/20">
|
||||||
|
<td colSpan={4} className="px-6 py-3">
|
||||||
|
<div className="ml-9">
|
||||||
|
<p className="text-xs font-medium text-neutral-500 mb-2">
|
||||||
|
Where visitors went after dropping off:
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(expandedExitStep === i ? step.exit_pages : step.exit_pages.slice(0, 3)).map(ep => (
|
||||||
|
<span key={ep.path} className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-xs">
|
||||||
|
<span className="font-mono text-neutral-600 dark:text-neutral-300">{ep.path}</span>
|
||||||
|
<span className="text-neutral-400">{ep.visitors}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{step.exit_pages.length > 3 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpandedExitStep(expandedExitStep === i ? null : i)}
|
||||||
|
className="mt-2 text-xs text-brand-orange hover:underline"
|
||||||
|
>
|
||||||
|
{expandedExitStep === i ? 'Show less' : `See all ${step.exit_pages.length} exit pages`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -238,6 +400,19 @@ export default function FunnelReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{breakdownStep !== null && stats && (
|
||||||
|
<BreakdownDrawer
|
||||||
|
siteId={siteId}
|
||||||
|
funnelId={funnelId}
|
||||||
|
stepIndex={breakdownStep}
|
||||||
|
stepName={stats.steps[breakdownStep].step.name}
|
||||||
|
startDate={dateRange.start}
|
||||||
|
endDate={dateRange.end}
|
||||||
|
filters={serializeFilters(filters) || undefined}
|
||||||
|
onClose={() => setBreakdownStep(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<DatePicker
|
<DatePicker
|
||||||
isOpen={isDatePickerOpen}
|
isOpen={isDatePickerOpen}
|
||||||
onClose={() => setIsDatePickerOpen(false)}
|
onClose={() => setIsDatePickerOpen(false)}
|
||||||
|
|||||||
@@ -3,90 +3,25 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import { useSWRConfig } from 'swr'
|
import { useSWRConfig } from 'swr'
|
||||||
import { createFunnel, type CreateFunnelRequest, type FunnelStep } from '@/lib/api/funnels'
|
import { createFunnel, type CreateFunnelRequest } from '@/lib/api/funnels'
|
||||||
import { toast, Input, Button, ChevronLeftIcon, PlusIcon, TrashIcon } from '@ciphera-net/ui'
|
import { toast } from '@ciphera-net/ui'
|
||||||
import Link from 'next/link'
|
import FunnelForm from '@/components/funnels/FunnelForm'
|
||||||
|
|
||||||
function isValidRegex(pattern: string): boolean {
|
|
||||||
try {
|
|
||||||
new RegExp(pattern)
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CreateFunnelPage() {
|
export default function CreateFunnelPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { mutate } = useSWRConfig()
|
const { mutate } = useSWRConfig()
|
||||||
const siteId = params.id as string
|
const siteId = params.id as string
|
||||||
|
|
||||||
const [name, setName] = useState('')
|
|
||||||
const [description, setDescription] = useState('')
|
|
||||||
// * Backend requires at least one step (API binding min=1, DB rejects empty steps)
|
|
||||||
const [steps, setSteps] = useState<Omit<FunnelStep, 'order'>[]>([
|
|
||||||
{ name: 'Step 1', value: '/', type: 'exact' },
|
|
||||||
{ name: 'Step 2', value: '', type: 'exact' }
|
|
||||||
])
|
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
const handleAddStep = () => {
|
const handleSubmit = async (data: CreateFunnelRequest) => {
|
||||||
setSteps([...steps, { name: `Step ${steps.length + 1}`, value: '', type: 'exact' }])
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRemoveStep = (index: number) => {
|
|
||||||
if (steps.length <= 1) return
|
|
||||||
const newSteps = steps.filter((_, i) => i !== index)
|
|
||||||
setSteps(newSteps)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUpdateStep = (index: number, field: keyof Omit<FunnelStep, 'order'>, value: string) => {
|
|
||||||
const newSteps = [...steps]
|
|
||||||
newSteps[index] = { ...newSteps[index], [field]: value }
|
|
||||||
setSteps(newSteps)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
if (!name.trim()) {
|
|
||||||
toast.error('Please enter a funnel name')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (steps.some(s => !s.name.trim())) {
|
|
||||||
toast.error('Please enter a name for all steps')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (steps.some(s => !s.value.trim())) {
|
|
||||||
toast.error('Please enter a path for all steps')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const invalidRegexStep = steps.find(s => s.type === 'regex' && !isValidRegex(s.value))
|
|
||||||
if (invalidRegexStep) {
|
|
||||||
toast.error(`Invalid regex pattern in step: ${invalidRegexStep.name}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
const funnelSteps = steps.map((s, i) => ({
|
await createFunnel(siteId, data)
|
||||||
...s,
|
|
||||||
order: i
|
|
||||||
}))
|
|
||||||
|
|
||||||
await createFunnel(siteId, {
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
steps: funnelSteps
|
|
||||||
})
|
|
||||||
|
|
||||||
await mutate(['funnels', siteId])
|
await mutate(['funnels', siteId])
|
||||||
toast.success('Funnel created')
|
toast.success('Funnel created')
|
||||||
router.push(`/sites/${siteId}/funnels`)
|
router.push(`/sites/${siteId}/funnels`)
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('Failed to create funnel. Please try again.')
|
toast.error('Failed to create funnel. Please try again.')
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
@@ -94,149 +29,11 @@ export default function CreateFunnelPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-3xl mx-auto px-4 sm:px-6 pb-8">
|
<FunnelForm
|
||||||
<div className="mb-8">
|
siteId={siteId}
|
||||||
<Link
|
onSubmit={handleSubmit}
|
||||||
href={`/sites/${siteId}/funnels`}
|
submitLabel={saving ? 'Creating...' : 'Create Funnel'}
|
||||||
className="inline-flex items-center gap-2 text-sm text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white mb-6 rounded-xl hover:bg-neutral-100 dark:hover:bg-neutral-800 px-2 py-1.5 -ml-2 transition-colors"
|
cancelHref={`/sites/${siteId}/funnels`}
|
||||||
>
|
|
||||||
<ChevronLeftIcon className="w-4 h-4" />
|
|
||||||
Back to Funnels
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
|
|
||||||
Create New Funnel
|
|
||||||
</h1>
|
|
||||||
<p className="text-neutral-600 dark:text-neutral-400">
|
|
||||||
Define the steps users take to complete a goal.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 mb-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
|
||||||
Funnel Name
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="e.g. Signup Flow"
|
|
||||||
autoFocus
|
|
||||||
required
|
|
||||||
maxLength={100}
|
|
||||||
/>
|
/>
|
||||||
{name.length > 80 && (
|
|
||||||
<span className={`text-xs tabular-nums mt-1 ${name.length > 90 ? 'text-amber-500' : 'text-neutral-400'}`}>{name.length}/100</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
|
||||||
Description (Optional)
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder="Tracks users from landing page to signup"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4 mb-8">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
|
||||||
Funnel Steps
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{steps.map((step, index) => (
|
|
||||||
<div key={`step-${index}`} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-4">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="mt-3 text-neutral-400">
|
|
||||||
<div className="w-6 h-6 rounded-full bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
|
||||||
{index + 1}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 grid gap-4 md:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-neutral-500 uppercase mb-1">
|
|
||||||
Step Name
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={step.name}
|
|
||||||
onChange={(e) => handleUpdateStep(index, 'name', e.target.value)}
|
|
||||||
placeholder="e.g. Landing Page"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-neutral-500 uppercase mb-1">
|
|
||||||
Path / URL
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<select
|
|
||||||
value={step.type}
|
|
||||||
onChange={(e) => handleUpdateStep(index, 'type', e.target.value)}
|
|
||||||
className="w-24 px-2 py-2 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-lg text-sm focus:ring-2 focus:ring-brand-orange/20 focus:border-brand-orange outline-none"
|
|
||||||
>
|
|
||||||
<option value="exact">Exact</option>
|
|
||||||
<option value="contains">Contains</option>
|
|
||||||
<option value="regex">Regex</option>
|
|
||||||
</select>
|
|
||||||
<Input
|
|
||||||
value={step.value}
|
|
||||||
onChange={(e) => handleUpdateStep(index, 'value', e.target.value)}
|
|
||||||
placeholder={step.type === 'exact' ? '/pricing' : 'pricing'}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleRemoveStep(index)}
|
|
||||||
disabled={steps.length <= 1}
|
|
||||||
aria-label="Remove step"
|
|
||||||
className={`mt-3 p-2 rounded-xl transition-colors ${
|
|
||||||
steps.length <= 1
|
|
||||||
? 'text-neutral-300 cursor-not-allowed'
|
|
||||||
: 'text-neutral-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<TrashIcon className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleAddStep}
|
|
||||||
className="w-full py-3 border-2 border-dashed border-neutral-200 dark:border-neutral-800 rounded-xl text-neutral-500 hover:text-neutral-900 dark:hover:text-white hover:border-neutral-300 dark:hover:border-neutral-700 transition-colors flex items-center justify-center gap-2 font-medium"
|
|
||||||
>
|
|
||||||
<PlusIcon className="w-4 h-4" />
|
|
||||||
Add Step
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-4">
|
|
||||||
<Link href={`/sites/${siteId}/funnels`}>
|
|
||||||
<Button variant="secondary">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={saving}
|
|
||||||
variant="primary"
|
|
||||||
>
|
|
||||||
{saving ? 'Creating...' : 'Create Funnel'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
21
components/dashboard/ContentHeader.tsx
Normal file
21
components/dashboard/ContentHeader.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { MenuIcon } from '@ciphera-net/ui'
|
||||||
|
|
||||||
|
export default function ContentHeader({
|
||||||
|
onMobileMenuOpen,
|
||||||
|
}: {
|
||||||
|
onMobileMenuOpen: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="shrink-0 flex items-center border-b border-neutral-200/60 dark:border-neutral-800/60 bg-white/90 dark:bg-neutral-900/90 backdrop-blur-xl px-4 py-3.5 md:hidden">
|
||||||
|
<button
|
||||||
|
onClick={onMobileMenuOpen}
|
||||||
|
className="p-2 -ml-2 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white"
|
||||||
|
aria-label="Open navigation"
|
||||||
|
>
|
||||||
|
<MenuIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
components/dashboard/DashboardShell.tsx
Normal file
47
components/dashboard/DashboardShell.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
import ContentHeader from './ContentHeader'
|
||||||
|
|
||||||
|
// Load sidebar only on the client — prevents SSR flash
|
||||||
|
const Sidebar = dynamic(() => import('./Sidebar'), {
|
||||||
|
ssr: false,
|
||||||
|
// Placeholder reserves the sidebar's space in the server HTML
|
||||||
|
// so page content never occupies the sidebar zone
|
||||||
|
loading: () => (
|
||||||
|
<div
|
||||||
|
className="hidden md:block shrink-0 border-r border-neutral-200/60 dark:border-neutral-800/60 bg-white/90 dark:bg-neutral-900/90 backdrop-blur-xl"
|
||||||
|
style={{ width: 64 }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function DashboardShell({
|
||||||
|
siteId,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
siteId: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
|
const closeMobile = useCallback(() => setMobileOpen(false), [])
|
||||||
|
const openMobile = useCallback(() => setMobileOpen(true), [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden">
|
||||||
|
<Sidebar
|
||||||
|
siteId={siteId}
|
||||||
|
mobileOpen={mobileOpen}
|
||||||
|
onMobileClose={closeMobile}
|
||||||
|
onMobileOpen={openMobile}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
|
<ContentHeader onMobileMenuOpen={openMobile} />
|
||||||
|
<main className="flex-1 overflow-y-auto pt-6">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
463
components/dashboard/Sidebar.tsx
Normal file
463
components/dashboard/Sidebar.tsx
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { usePathname, useRouter } from 'next/navigation'
|
||||||
|
import { listSites, type Site } from '@/lib/api/sites'
|
||||||
|
import { useAuth } from '@/lib/auth/context'
|
||||||
|
import { useSettingsModal } from '@/lib/settings-modal-context'
|
||||||
|
import { getUserOrganizations, switchContext, type OrganizationMember } from '@/lib/api/organization'
|
||||||
|
import { setSessionAction } from '@/app/actions/auth'
|
||||||
|
import { logger } from '@/lib/utils/logger'
|
||||||
|
import { FAVICON_SERVICE_URL } from '@/lib/utils/icons'
|
||||||
|
import {
|
||||||
|
LayoutDashboardIcon,
|
||||||
|
PathIcon,
|
||||||
|
FunnelIcon,
|
||||||
|
CursorClickIcon,
|
||||||
|
SearchIcon,
|
||||||
|
CloudUploadIcon,
|
||||||
|
HeartbeatIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
CollapseLeftIcon,
|
||||||
|
CollapseRightIcon,
|
||||||
|
ChevronUpDownIcon,
|
||||||
|
PlusIcon,
|
||||||
|
XIcon,
|
||||||
|
AppLauncher,
|
||||||
|
UserMenu,
|
||||||
|
type CipheraApp,
|
||||||
|
} from '@ciphera-net/ui'
|
||||||
|
import NotificationCenter from '@/components/notifications/NotificationCenter'
|
||||||
|
|
||||||
|
const CIPHERA_APPS: CipheraApp[] = [
|
||||||
|
{
|
||||||
|
id: 'pulse',
|
||||||
|
name: 'Pulse',
|
||||||
|
description: 'Your current app — Privacy-first analytics',
|
||||||
|
icon: 'https://ciphera.net/pulse_icon_no_margins.png',
|
||||||
|
href: 'https://pulse.ciphera.net',
|
||||||
|
isAvailable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'drop',
|
||||||
|
name: 'Drop',
|
||||||
|
description: 'Secure file sharing',
|
||||||
|
icon: 'https://ciphera.net/drop_icon_no_margins.png',
|
||||||
|
href: 'https://drop.ciphera.net',
|
||||||
|
isAvailable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'auth',
|
||||||
|
name: 'Auth',
|
||||||
|
description: 'Your Ciphera account settings',
|
||||||
|
icon: 'https://ciphera.net/auth_icon_no_margins.png',
|
||||||
|
href: 'https://auth.ciphera.net',
|
||||||
|
isAvailable: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const SIDEBAR_KEY = 'pulse_sidebar_collapsed'
|
||||||
|
const EXPANDED = 256
|
||||||
|
const COLLAPSED = 64
|
||||||
|
|
||||||
|
type IconWeight = 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone'
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
label: string
|
||||||
|
href: (siteId: string) => string
|
||||||
|
icon: React.ComponentType<{ className?: string; weight?: IconWeight }>
|
||||||
|
matchPrefix?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavGroup { label: string; items: NavItem[] }
|
||||||
|
|
||||||
|
const NAV_GROUPS: NavGroup[] = [
|
||||||
|
{
|
||||||
|
label: 'Analytics',
|
||||||
|
items: [
|
||||||
|
{ label: 'Dashboard', href: (id) => `/sites/${id}`, icon: LayoutDashboardIcon },
|
||||||
|
{ label: 'Journeys', href: (id) => `/sites/${id}/journeys`, icon: PathIcon, matchPrefix: true },
|
||||||
|
{ label: 'Funnels', href: (id) => `/sites/${id}/funnels`, icon: FunnelIcon, matchPrefix: true },
|
||||||
|
{ label: 'Behavior', href: (id) => `/sites/${id}/behavior`, icon: CursorClickIcon, matchPrefix: true },
|
||||||
|
{ label: 'Search', href: (id) => `/sites/${id}/search`, icon: SearchIcon, matchPrefix: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Infrastructure',
|
||||||
|
items: [
|
||||||
|
{ label: 'CDN', href: (id) => `/sites/${id}/cdn`, icon: CloudUploadIcon, matchPrefix: true },
|
||||||
|
{ label: 'Uptime', href: (id) => `/sites/${id}/uptime`, icon: HeartbeatIcon, matchPrefix: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const SETTINGS_ITEM: NavItem = {
|
||||||
|
label: 'Site Settings', href: (id) => `/sites/${id}/settings`, icon: SettingsIcon, matchPrefix: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label that fades with the sidebar — always in the DOM, never removed
|
||||||
|
function Label({ children, collapsed }: { children: React.ReactNode; collapsed: boolean }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="whitespace-nowrap overflow-hidden transition-opacity duration-150"
|
||||||
|
style={{ opacity: collapsed ? 0 : 1 }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Site Picker ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollapsed }: {
|
||||||
|
sites: Site[]; siteId: string; collapsed: boolean
|
||||||
|
onExpand: () => void; onCollapse: () => void; wasCollapsed: React.MutableRefObject<boolean>
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [faviconFailed, setFaviconFailed] = useState(false)
|
||||||
|
const [faviconLoaded, setFaviconLoaded] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const pathname = usePathname()
|
||||||
|
const router = useRouter()
|
||||||
|
const currentSite = sites.find((s) => s.id === siteId)
|
||||||
|
const faviconUrl = currentSite?.domain ? `${FAVICON_SERVICE_URL}?domain=${currentSite.domain}&sz=64` : null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
|
if (open) {
|
||||||
|
setOpen(false); setSearch('')
|
||||||
|
// Re-collapse if we auto-expanded
|
||||||
|
if (wasCollapsed.current) { onCollapse(); wasCollapsed.current = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handler)
|
||||||
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
|
}, [open, onCollapse, wasCollapsed])
|
||||||
|
|
||||||
|
const switchSite = (id: string) => {
|
||||||
|
router.push(`/sites/${id}${pathname.replace(/^\/sites\/[^/]+/, '')}`)
|
||||||
|
setOpen(false); setSearch('')
|
||||||
|
// Re-collapse if we auto-expanded
|
||||||
|
if (wasCollapsed.current) { onCollapse(); wasCollapsed.current = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = sites.filter(
|
||||||
|
(s) => s.name.toLowerCase().includes(search.toLowerCase()) || s.domain.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative mb-4 px-2" ref={ref}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (collapsed) {
|
||||||
|
wasCollapsed.current = true
|
||||||
|
onExpand()
|
||||||
|
// Open picker after sidebar expands
|
||||||
|
setTimeout(() => setOpen(true), 220)
|
||||||
|
} else {
|
||||||
|
setOpen(!open)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800 overflow-hidden"
|
||||||
|
>
|
||||||
|
<span className="w-7 h-7 rounded-md bg-brand-orange/10 flex items-center justify-center shrink-0 overflow-hidden">
|
||||||
|
{faviconUrl && !faviconFailed ? (
|
||||||
|
<>
|
||||||
|
{!faviconLoaded && <span className="w-5 h-5 rounded animate-pulse bg-neutral-100 dark:bg-neutral-800" />}
|
||||||
|
<img
|
||||||
|
src={faviconUrl}
|
||||||
|
alt=""
|
||||||
|
className={`w-5 h-5 object-contain ${faviconLoaded ? '' : 'hidden'}`}
|
||||||
|
onLoad={() => setFaviconLoaded(true)}
|
||||||
|
onError={() => setFaviconFailed(true)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<Label collapsed={collapsed}>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="truncate">{currentSite?.name || ''}</span>
|
||||||
|
<ChevronUpDownIcon className="w-4 h-4 text-neutral-400 shrink-0" />
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute left-3 top-full mt-1 z-50 w-[240px] bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden">
|
||||||
|
<div className="p-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search sites..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full px-3 py-1.5 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg outline-none focus:ring-2 focus:ring-brand-orange/40 text-neutral-900 dark:text-white placeholder:text-neutral-400"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-64 overflow-y-auto">
|
||||||
|
{filtered.map((site) => (
|
||||||
|
<button
|
||||||
|
key={site.id}
|
||||||
|
onClick={() => switchSite(site.id)}
|
||||||
|
className={`w-full flex items-center gap-2.5 px-4 py-2 text-sm text-left ${
|
||||||
|
site.id === siteId
|
||||||
|
? 'bg-brand-orange/10 text-brand-orange font-medium'
|
||||||
|
: 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`${FAVICON_SERVICE_URL}?domain=${site.domain}&sz=64`}
|
||||||
|
alt=""
|
||||||
|
className="w-5 h-5 rounded object-contain shrink-0"
|
||||||
|
/>
|
||||||
|
<span className="flex flex-col min-w-0">
|
||||||
|
<span className="truncate">{site.name}</span>
|
||||||
|
<span className="text-xs text-neutral-400 truncate">{site.domain}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && <p className="px-4 py-3 text-sm text-neutral-400">No sites found</p>}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-neutral-200 dark:border-neutral-700 p-2">
|
||||||
|
<Link href="/sites/new" onClick={() => setOpen(false)} className="flex items-center gap-2 px-3 py-1.5 text-sm text-brand-orange hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg">
|
||||||
|
<PlusIcon className="w-4 h-4" />
|
||||||
|
Add new site
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Nav Item ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function NavLink({
|
||||||
|
item, siteId, collapsed, onClick, pendingHref, onNavigate,
|
||||||
|
}: {
|
||||||
|
item: NavItem; siteId: string; collapsed: boolean; onClick?: () => void
|
||||||
|
pendingHref: string | null; onNavigate: (href: string) => void
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const href = item.href(siteId)
|
||||||
|
const matchesPathname = item.matchPrefix ? pathname.startsWith(href) : pathname === href
|
||||||
|
const matchesPending = pendingHref !== null && (item.matchPrefix ? pendingHref.startsWith(href) : pendingHref === href)
|
||||||
|
const active = matchesPathname || matchesPending
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
onClick={() => { onNavigate(href); onClick?.() }}
|
||||||
|
title={collapsed ? item.label : undefined}
|
||||||
|
className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden ${
|
||||||
|
active
|
||||||
|
? 'bg-brand-orange/10 text-brand-orange'
|
||||||
|
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-7 h-7 flex items-center justify-center shrink-0">
|
||||||
|
<item.icon className="w-[18px] h-[18px]" weight={active ? 'fill' : 'regular'} />
|
||||||
|
</span>
|
||||||
|
<Label collapsed={collapsed}>{item.label}</Label>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Sidebar ───────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function Sidebar({
|
||||||
|
siteId, mobileOpen, onMobileClose, onMobileOpen,
|
||||||
|
}: {
|
||||||
|
siteId: string; mobileOpen: boolean; onMobileClose: () => void; onMobileOpen: () => void
|
||||||
|
}) {
|
||||||
|
const auth = useAuth()
|
||||||
|
const { user } = auth
|
||||||
|
const canEdit = user?.role === 'owner' || user?.role === 'admin'
|
||||||
|
const pathname = usePathname()
|
||||||
|
const router = useRouter()
|
||||||
|
const { openSettings } = useSettingsModal()
|
||||||
|
const [sites, setSites] = useState<Site[]>([])
|
||||||
|
const [orgs, setOrgs] = useState<OrganizationMember[]>([])
|
||||||
|
const [pendingHref, setPendingHref] = useState<string | null>(null)
|
||||||
|
const wasCollapsedRef = useRef(false)
|
||||||
|
// Safe to read localStorage directly — this component is loaded with ssr:false
|
||||||
|
const [collapsed, setCollapsed] = useState(() => {
|
||||||
|
return localStorage.getItem(SIDEBAR_KEY) !== 'false'
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => { listSites().then(setSites).catch(() => {}) }, [])
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
getUserOrganizations()
|
||||||
|
.then((organizations) => setOrgs(Array.isArray(organizations) ? organizations : []))
|
||||||
|
.catch(err => logger.error('Failed to fetch orgs', err))
|
||||||
|
}
|
||||||
|
}, [user])
|
||||||
|
|
||||||
|
const handleSwitchOrganization = async (orgId: string | null) => {
|
||||||
|
if (!orgId) return
|
||||||
|
try {
|
||||||
|
const { access_token } = await switchContext(orgId)
|
||||||
|
await setSessionAction(access_token)
|
||||||
|
sessionStorage.setItem('pulse_switching_org', 'true')
|
||||||
|
window.location.reload()
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to switch organization', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(() => { setPendingHref(null); onMobileClose() }, [pathname, onMobileClose])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === '[' && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||||
|
const tag = (e.target as HTMLElement)?.tagName
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
|
||||||
|
e.preventDefault(); toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handler)
|
||||||
|
return () => document.removeEventListener('keydown', handler)
|
||||||
|
}, [collapsed])
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setCollapsed((prev) => { const next = !prev; localStorage.setItem(SIDEBAR_KEY, String(next)); return next })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const expand = useCallback(() => {
|
||||||
|
setCollapsed(false); localStorage.setItem(SIDEBAR_KEY, 'false')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const collapse = useCallback(() => {
|
||||||
|
setCollapsed(true); localStorage.setItem(SIDEBAR_KEY, 'true')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleNavigate = useCallback((href: string) => { setPendingHref(href) }, [])
|
||||||
|
|
||||||
|
const sidebarContent = (isMobile: boolean) => {
|
||||||
|
const c = isMobile ? false : collapsed
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
{/* App Switcher — top of sidebar (scope-level switch) */}
|
||||||
|
<div className="flex items-center gap-2.5 px-[14px] pt-3 pb-1 shrink-0 overflow-hidden">
|
||||||
|
<span className="w-9 h-9 flex items-center justify-center shrink-0">
|
||||||
|
<AppLauncher apps={CIPHERA_APPS} currentAppId="pulse" anchor="right" />
|
||||||
|
</span>
|
||||||
|
<Label collapsed={c}>
|
||||||
|
<span className="text-xs font-medium text-neutral-400 dark:text-neutral-500 uppercase tracking-wider">Ciphera</span>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logo — fixed layout, text fades */}
|
||||||
|
<Link href="/" className="flex items-center gap-3 px-[14px] py-4 shrink-0 group overflow-hidden">
|
||||||
|
<span className="w-9 h-9 flex items-center justify-center shrink-0">
|
||||||
|
<img src="/pulse_icon_no_margins.png" alt="Pulse" className="w-9 h-9 shrink-0 object-contain group-hover:scale-105 transition-transform duration-200" />
|
||||||
|
</span>
|
||||||
|
<span className={`text-xl font-bold text-neutral-900 dark:text-white tracking-tight group-hover:text-brand-orange whitespace-nowrap transition-opacity duration-150 ${c ? 'opacity-0' : 'opacity-100'}`}>
|
||||||
|
Pulse
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Site Picker */}
|
||||||
|
<SitePicker sites={sites} siteId={siteId} collapsed={c} onExpand={expand} onCollapse={collapse} wasCollapsed={wasCollapsedRef} />
|
||||||
|
|
||||||
|
{/* Nav Groups */}
|
||||||
|
<nav className="flex-1 overflow-y-auto overflow-x-hidden px-2 space-y-4">
|
||||||
|
{NAV_GROUPS.map((group) => (
|
||||||
|
<div key={group.label}>
|
||||||
|
<div className="h-5 flex items-center overflow-hidden">
|
||||||
|
<p className={`px-2.5 text-[11px] font-semibold text-neutral-400 dark:text-neutral-500 uppercase tracking-wider whitespace-nowrap transition-opacity duration-150 ${c ? 'opacity-0' : 'opacity-100'}`}>
|
||||||
|
{group.label}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<NavLink key={item.label} item={item} siteId={siteId} collapsed={c} onClick={isMobile ? onMobileClose : undefined} pendingHref={pendingHref} onNavigate={handleNavigate} />
|
||||||
|
))}
|
||||||
|
{group.label === 'Infrastructure' && canEdit && (
|
||||||
|
<NavLink item={SETTINGS_ITEM} siteId={siteId} collapsed={c} onClick={isMobile ? onMobileClose : undefined} pendingHref={pendingHref} onNavigate={handleNavigate} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Bottom — utility items */}
|
||||||
|
<div className="border-t border-neutral-200/60 dark:border-neutral-800/60 px-2 py-3 shrink-0">
|
||||||
|
{/* Notifications, Profile — same layout as nav items */}
|
||||||
|
<div className="space-y-0.5 mb-1">
|
||||||
|
<span title={c ? 'Notifications' : undefined}>
|
||||||
|
<NotificationCenter anchor="right" variant="sidebar">
|
||||||
|
<Label collapsed={c}>Notifications</Label>
|
||||||
|
</NotificationCenter>
|
||||||
|
</span>
|
||||||
|
<span title={c ? (user?.display_name?.trim() || 'Profile') : undefined}>
|
||||||
|
<UserMenu
|
||||||
|
auth={auth}
|
||||||
|
LinkComponent={Link}
|
||||||
|
orgs={orgs}
|
||||||
|
activeOrgId={auth.user?.org_id}
|
||||||
|
onSwitchOrganization={handleSwitchOrganization}
|
||||||
|
onCreateOrganization={() => router.push('/onboarding')}
|
||||||
|
allowPersonalOrganization={false}
|
||||||
|
onOpenSettings={openSettings}
|
||||||
|
compact
|
||||||
|
anchor="right"
|
||||||
|
>
|
||||||
|
<Label collapsed={c}>{user?.display_name?.trim() || 'Profile'}</Label>
|
||||||
|
</UserMenu>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settings + Collapse */}
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{!isMobile && (
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
className="flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 w-full overflow-hidden"
|
||||||
|
title={collapsed ? 'Expand sidebar (press [)' : 'Collapse sidebar (press [)'}
|
||||||
|
>
|
||||||
|
<span className="w-7 h-7 flex items-center justify-center shrink-0">
|
||||||
|
<CollapseLeftIcon className={`w-[18px] h-[18px] transition-transform duration-200 ${c ? 'rotate-180' : ''}`} />
|
||||||
|
</span>
|
||||||
|
<Label collapsed={c}>Collapse</Label>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Desktop — ssr:false means this only renders on client, no hydration flash */}
|
||||||
|
<aside
|
||||||
|
className="hidden md:flex flex-col shrink-0 border-r border-neutral-200/60 dark:border-neutral-800/60 bg-white/90 dark:bg-neutral-900/90 backdrop-blur-xl overflow-hidden relative z-10"
|
||||||
|
style={{ width: collapsed ? COLLAPSED : EXPANDED, transition: 'width 200ms cubic-bezier(0.4, 0, 0.2, 1)' }}
|
||||||
|
>
|
||||||
|
{sidebarContent(false)}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Mobile overlay */}
|
||||||
|
{mobileOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40 bg-black/30 md:hidden" onClick={onMobileClose} />
|
||||||
|
<aside className="fixed inset-y-0 left-0 z-50 w-72 bg-white dark:bg-neutral-900 border-r border-neutral-200 dark:border-neutral-800 shadow-xl md:hidden animate-in slide-in-from-left duration-200">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-200 dark:border-neutral-800">
|
||||||
|
<span className="text-sm font-semibold text-neutral-900 dark:text-white">Navigation</span>
|
||||||
|
<button onClick={onMobileClose} className="p-1.5 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300">
|
||||||
|
<XIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{sidebarContent(true)}
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
111
components/funnels/BreakdownDrawer.tsx
Normal file
111
components/funnels/BreakdownDrawer.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { getFunnelBreakdown, type FunnelBreakdown } from '@/lib/api/funnels'
|
||||||
|
import { DIMENSION_LABELS } from '@/lib/filters'
|
||||||
|
|
||||||
|
const BREAKDOWN_DIMENSIONS = [
|
||||||
|
'device', 'country', 'browser', 'os',
|
||||||
|
'utm_source', 'utm_medium', 'utm_campaign'
|
||||||
|
]
|
||||||
|
|
||||||
|
interface BreakdownDrawerProps {
|
||||||
|
siteId: string
|
||||||
|
funnelId: string
|
||||||
|
stepIndex: number
|
||||||
|
stepName: string
|
||||||
|
startDate: string
|
||||||
|
endDate: string
|
||||||
|
filters?: string
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BreakdownDrawer({ siteId, funnelId, stepIndex, stepName, startDate, endDate, filters, onClose }: BreakdownDrawerProps) {
|
||||||
|
const [activeDimension, setActiveDimension] = useState('device')
|
||||||
|
const [breakdown, setBreakdown] = useState<FunnelBreakdown | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const loadBreakdown = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await getFunnelBreakdown(siteId, funnelId, stepIndex, activeDimension, startDate, endDate, filters)
|
||||||
|
setBreakdown(data)
|
||||||
|
} catch {
|
||||||
|
setBreakdown(null)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [siteId, funnelId, stepIndex, activeDimension, startDate, endDate, filters])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadBreakdown()
|
||||||
|
}, [loadBreakdown])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div className="fixed inset-0 z-40 bg-black/20" onClick={onClose} />
|
||||||
|
|
||||||
|
<div className="fixed inset-y-0 right-0 z-50 w-96 max-w-full bg-white dark:bg-neutral-900 border-l border-neutral-200 dark:border-neutral-800 shadow-xl flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-neutral-900 dark:text-white">Step Breakdown</h3>
|
||||||
|
<p className="text-sm text-neutral-500">{stepName}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-2 text-neutral-400 hover:text-neutral-600 rounded-lg">
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dimension tabs */}
|
||||||
|
<div className="flex overflow-x-auto gap-1 px-6 py-3 border-b border-neutral-200 dark:border-neutral-800">
|
||||||
|
{BREAKDOWN_DIMENSIONS.map(dim => (
|
||||||
|
<button
|
||||||
|
key={dim}
|
||||||
|
onClick={() => setActiveDimension(dim)}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-lg whitespace-nowrap transition-colors ${
|
||||||
|
activeDimension === dim
|
||||||
|
? 'bg-brand-orange text-white'
|
||||||
|
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-500 hover:bg-neutral-200 dark:hover:bg-neutral-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{DIMENSION_LABELS[dim] || dim}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-10 bg-neutral-100 dark:bg-neutral-800 rounded-lg animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : !breakdown || breakdown.entries.length === 0 ? (
|
||||||
|
<p className="text-sm text-neutral-500">No data for this dimension</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{breakdown.entries.map(entry => (
|
||||||
|
<div key={entry.value} className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-800/50">
|
||||||
|
<span className="text-sm text-neutral-900 dark:text-white truncate mr-4">
|
||||||
|
{entry.value || '(unknown)'}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-4 text-sm shrink-0">
|
||||||
|
<span className="text-neutral-500">{entry.visitors}</span>
|
||||||
|
<span className="text-green-600 dark:text-green-400 font-medium w-16 text-right">
|
||||||
|
{Math.round(entry.conversion)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
519
components/funnels/FunnelForm.tsx
Normal file
519
components/funnels/FunnelForm.tsx
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { Input, Button, ChevronLeftIcon, ChevronDownIcon, PlusIcon, TrashIcon } from '@ciphera-net/ui'
|
||||||
|
import { CaretUp } from '@phosphor-icons/react'
|
||||||
|
import type { FunnelStep, StepPropertyFilter, CreateFunnelRequest } from '@/lib/api/funnels'
|
||||||
|
|
||||||
|
type StepWithoutOrder = Omit<FunnelStep, 'order'>
|
||||||
|
|
||||||
|
interface FunnelFormProps {
|
||||||
|
siteId: string
|
||||||
|
initialData?: {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
steps: StepWithoutOrder[]
|
||||||
|
conversion_window_value: number
|
||||||
|
conversion_window_unit: 'hours' | 'days'
|
||||||
|
}
|
||||||
|
onSubmit: (data: CreateFunnelRequest) => Promise<void>
|
||||||
|
submitLabel: string
|
||||||
|
cancelHref: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidRegex(pattern: string): boolean {
|
||||||
|
try {
|
||||||
|
new RegExp(pattern)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const WINDOW_PRESETS = [
|
||||||
|
{ label: '1h', value: 1, unit: 'hours' as const },
|
||||||
|
{ label: '24h', value: 24, unit: 'hours' as const },
|
||||||
|
{ label: '7d', value: 7, unit: 'days' as const },
|
||||||
|
{ label: '14d', value: 14, unit: 'days' as const },
|
||||||
|
{ label: '30d', value: 30, unit: 'days' as const },
|
||||||
|
]
|
||||||
|
|
||||||
|
const OPERATOR_OPTIONS: { value: StepPropertyFilter['operator']; label: string }[] = [
|
||||||
|
{ value: 'is', label: 'is' },
|
||||||
|
{ value: 'is_not', label: 'is not' },
|
||||||
|
{ value: 'contains', label: 'contains' },
|
||||||
|
{ value: 'not_contains', label: 'does not contain' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const MAX_STEPS = 8
|
||||||
|
const MAX_FILTERS = 10
|
||||||
|
|
||||||
|
export default function FunnelForm({ siteId, initialData, onSubmit, submitLabel, cancelHref }: FunnelFormProps) {
|
||||||
|
const [name, setName] = useState(initialData?.name ?? '')
|
||||||
|
const [description, setDescription] = useState(initialData?.description ?? '')
|
||||||
|
const [steps, setSteps] = useState<StepWithoutOrder[]>(
|
||||||
|
initialData?.steps ?? [
|
||||||
|
{ name: 'Step 1', value: '/', type: 'exact' },
|
||||||
|
{ name: 'Step 2', value: '', type: 'exact' },
|
||||||
|
]
|
||||||
|
)
|
||||||
|
const [windowValue, setWindowValue] = useState(initialData?.conversion_window_value ?? 7)
|
||||||
|
const [windowUnit, setWindowUnit] = useState<'hours' | 'days'>(initialData?.conversion_window_unit ?? 'days')
|
||||||
|
|
||||||
|
const handleAddStep = () => {
|
||||||
|
if (steps.length >= MAX_STEPS) return
|
||||||
|
setSteps([...steps, { name: `Step ${steps.length + 1}`, value: '', type: 'exact' }])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveStep = (index: number) => {
|
||||||
|
if (steps.length <= 1) return
|
||||||
|
setSteps(steps.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateStep = (index: number, field: string, value: string) => {
|
||||||
|
const newSteps = [...steps]
|
||||||
|
const step = { ...newSteps[index] }
|
||||||
|
|
||||||
|
if (field === 'category') {
|
||||||
|
step.category = value as 'page' | 'event'
|
||||||
|
// Reset fields when switching category
|
||||||
|
if (value === 'event') {
|
||||||
|
step.type = 'exact'
|
||||||
|
step.value = ''
|
||||||
|
} else {
|
||||||
|
step.value = ''
|
||||||
|
step.property_filters = undefined
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
;(step as Record<string, unknown>)[field] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
newSteps[index] = step
|
||||||
|
setSteps(newSteps)
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveStep = (index: number, direction: -1 | 1) => {
|
||||||
|
const targetIndex = index + direction
|
||||||
|
if (targetIndex < 0 || targetIndex >= steps.length) return
|
||||||
|
const newSteps = [...steps]
|
||||||
|
const temp = newSteps[index]
|
||||||
|
newSteps[index] = newSteps[targetIndex]
|
||||||
|
newSteps[targetIndex] = temp
|
||||||
|
setSteps(newSteps)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Property filter handlers
|
||||||
|
const addPropertyFilter = (stepIndex: number) => {
|
||||||
|
const newSteps = [...steps]
|
||||||
|
const step = { ...newSteps[stepIndex] }
|
||||||
|
const filters = [...(step.property_filters || [])]
|
||||||
|
if (filters.length >= MAX_FILTERS) return
|
||||||
|
filters.push({ key: '', operator: 'is', value: '' })
|
||||||
|
step.property_filters = filters
|
||||||
|
newSteps[stepIndex] = step
|
||||||
|
setSteps(newSteps)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePropertyFilter = (stepIndex: number, filterIndex: number, field: keyof StepPropertyFilter, value: string) => {
|
||||||
|
const newSteps = [...steps]
|
||||||
|
const step = { ...newSteps[stepIndex] }
|
||||||
|
const filters = [...(step.property_filters || [])]
|
||||||
|
filters[filterIndex] = { ...filters[filterIndex], [field]: value }
|
||||||
|
step.property_filters = filters
|
||||||
|
newSteps[stepIndex] = step
|
||||||
|
setSteps(newSteps)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removePropertyFilter = (stepIndex: number, filterIndex: number) => {
|
||||||
|
const newSteps = [...steps]
|
||||||
|
const step = { ...newSteps[stepIndex] }
|
||||||
|
const filters = [...(step.property_filters || [])]
|
||||||
|
filters.splice(filterIndex, 1)
|
||||||
|
step.property_filters = filters.length > 0 ? filters : undefined
|
||||||
|
newSteps[stepIndex] = step
|
||||||
|
setSteps(newSteps)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!name.trim()) {
|
||||||
|
const { toast } = await import('@ciphera-net/ui')
|
||||||
|
toast.error('Please enter a funnel name')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (steps.some(s => !s.name.trim())) {
|
||||||
|
const { toast } = await import('@ciphera-net/ui')
|
||||||
|
toast.error('Please enter a name for all steps')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate based on category
|
||||||
|
for (const step of steps) {
|
||||||
|
const category = step.category || 'page'
|
||||||
|
|
||||||
|
if (!step.value.trim()) {
|
||||||
|
const { toast } = await import('@ciphera-net/ui')
|
||||||
|
toast.error(category === 'event'
|
||||||
|
? `Please enter an event name for step: ${step.name}`
|
||||||
|
: `Please enter a path for step: ${step.name}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category === 'page' && step.type === 'regex' && !isValidRegex(step.value)) {
|
||||||
|
const { toast } = await import('@ciphera-net/ui')
|
||||||
|
toast.error(`Invalid regex pattern in step: ${step.name}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category === 'event' && step.property_filters) {
|
||||||
|
for (const filter of step.property_filters) {
|
||||||
|
if (!filter.key.trim()) {
|
||||||
|
const { toast } = await import('@ciphera-net/ui')
|
||||||
|
toast.error(`Property filter key is required in step: ${step.name}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const funnelSteps = steps.map((s, i) => ({
|
||||||
|
...s,
|
||||||
|
order: i,
|
||||||
|
}))
|
||||||
|
|
||||||
|
await onSubmit({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
steps: funnelSteps,
|
||||||
|
conversion_window_value: windowValue,
|
||||||
|
conversion_window_unit: windowUnit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectClass = 'px-2 py-2 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-lg text-sm focus:ring-2 focus:ring-brand-orange/20 focus:border-brand-orange outline-none'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-3xl mx-auto px-4 sm:px-6 pb-8">
|
||||||
|
<div className="mb-8">
|
||||||
|
<Link
|
||||||
|
href={cancelHref}
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white mb-6 rounded-xl hover:bg-neutral-100 dark:hover:bg-neutral-800 px-2 py-1.5 -ml-2 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="w-4 h-4" />
|
||||||
|
Back to Funnels
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
|
||||||
|
{initialData ? 'Edit Funnel' : 'Create New Funnel'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-neutral-600 dark:text-neutral-400">
|
||||||
|
Define the steps users take to complete a goal.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{/* Name & Description */}
|
||||||
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 mb-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
|
Funnel Name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g. Signup Flow"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
{name.length > 80 && (
|
||||||
|
<span className={`text-xs tabular-nums mt-1 ${name.length > 90 ? 'text-amber-500' : 'text-neutral-400'}`}>
|
||||||
|
{name.length}/100
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
|
Description (Optional)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Tracks users from landing page to signup"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Steps */}
|
||||||
|
<div className="space-y-4 mb-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||||
|
Funnel Steps
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{steps.map((step, index) => {
|
||||||
|
const category = step.category || 'page'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`step-${index}`} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-4">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{/* Step number + reorder */}
|
||||||
|
<div className="mt-3 text-neutral-400 flex items-center gap-1.5">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => moveStep(index, -1)}
|
||||||
|
disabled={index === 0}
|
||||||
|
className="p-0.5 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 disabled:opacity-30 transition-colors"
|
||||||
|
>
|
||||||
|
<CaretUp className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => moveStep(index, 1)}
|
||||||
|
disabled={index === steps.length - 1}
|
||||||
|
className="p-0.5 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 disabled:opacity-30 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
{/* Category toggle */}
|
||||||
|
<div className="flex gap-1 mb-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleUpdateStep(index, 'category', 'page')}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||||
|
category === 'page'
|
||||||
|
? 'bg-brand-orange text-white'
|
||||||
|
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Page Visit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleUpdateStep(index, 'category', 'event')}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||||
|
category === 'event'
|
||||||
|
? 'bg-brand-orange text-white'
|
||||||
|
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Custom Event
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-500 uppercase mb-1">
|
||||||
|
Step Name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={step.name}
|
||||||
|
onChange={(e) => handleUpdateStep(index, 'name', e.target.value)}
|
||||||
|
placeholder="e.g. Landing Page"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{category === 'page' ? (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-500 uppercase mb-1">
|
||||||
|
Path / URL
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
value={step.type}
|
||||||
|
onChange={(e) => handleUpdateStep(index, 'type', e.target.value)}
|
||||||
|
className={`w-24 ${selectClass}`}
|
||||||
|
>
|
||||||
|
<option value="exact">Exact</option>
|
||||||
|
<option value="contains">Contains</option>
|
||||||
|
<option value="regex">Regex</option>
|
||||||
|
</select>
|
||||||
|
<Input
|
||||||
|
value={step.value}
|
||||||
|
onChange={(e) => handleUpdateStep(index, 'value', e.target.value)}
|
||||||
|
placeholder={step.type === 'exact' ? '/pricing' : 'pricing'}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-500 uppercase mb-1">
|
||||||
|
Event Name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={step.value}
|
||||||
|
onChange={(e) => handleUpdateStep(index, 'value', e.target.value)}
|
||||||
|
placeholder="e.g. signup, purchase"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Property filters (event steps only) */}
|
||||||
|
{category === 'event' && (
|
||||||
|
<div className="mt-3">
|
||||||
|
{step.property_filters && step.property_filters.length > 0 && (
|
||||||
|
<div className="space-y-2 mb-2">
|
||||||
|
{step.property_filters.map((filter, filterIndex) => (
|
||||||
|
<div key={filterIndex} className="flex gap-2 items-center">
|
||||||
|
<Input
|
||||||
|
value={filter.key}
|
||||||
|
onChange={(e) => updatePropertyFilter(index, filterIndex, 'key', e.target.value)}
|
||||||
|
placeholder="key"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={filter.operator}
|
||||||
|
onChange={(e) => updatePropertyFilter(index, filterIndex, 'operator', e.target.value)}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
{OPERATOR_OPTIONS.map(op => (
|
||||||
|
<option key={op.value} value={op.value}>{op.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Input
|
||||||
|
value={filter.value}
|
||||||
|
onChange={(e) => updatePropertyFilter(index, filterIndex, 'value', e.target.value)}
|
||||||
|
placeholder="value"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removePropertyFilter(index, filterIndex)}
|
||||||
|
className="p-1.5 text-neutral-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<TrashIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(!step.property_filters || step.property_filters.length < MAX_FILTERS) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addPropertyFilter(index)}
|
||||||
|
className="text-xs text-neutral-500 hover:text-neutral-900 dark:hover:text-white flex items-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
<PlusIcon className="w-3.5 h-3.5" />
|
||||||
|
Add property filter
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveStep(index)}
|
||||||
|
disabled={steps.length <= 1}
|
||||||
|
aria-label="Remove step"
|
||||||
|
className={`mt-3 p-2 rounded-xl transition-colors ${
|
||||||
|
steps.length <= 1
|
||||||
|
? 'text-neutral-300 cursor-not-allowed'
|
||||||
|
: 'text-neutral-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<TrashIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{steps.length < MAX_STEPS ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddStep}
|
||||||
|
className="w-full py-3 border-2 border-dashed border-neutral-200 dark:border-neutral-800 rounded-xl text-neutral-500 hover:text-neutral-900 dark:hover:text-white hover:border-neutral-300 dark:hover:border-neutral-700 transition-colors flex items-center justify-center gap-2 font-medium"
|
||||||
|
>
|
||||||
|
<PlusIcon className="w-4 h-4" />
|
||||||
|
Add Step
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-sm text-neutral-400">Maximum 8 steps</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conversion Window */}
|
||||||
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 mb-6">
|
||||||
|
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-3">
|
||||||
|
Conversion Window
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-neutral-500 mb-4">
|
||||||
|
Visitors must complete all steps within this time to count as converted.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Quick presets */}
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
{WINDOW_PRESETS.map(preset => (
|
||||||
|
<button
|
||||||
|
key={preset.label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setWindowValue(preset.value)
|
||||||
|
setWindowUnit(preset.unit)
|
||||||
|
}}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||||
|
windowValue === preset.value && windowUnit === preset.unit
|
||||||
|
? 'bg-brand-orange text-white'
|
||||||
|
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom input */}
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={2160}
|
||||||
|
value={windowValue}
|
||||||
|
onChange={(e) => setWindowValue(Math.max(1, parseInt(e.target.value) || 1))}
|
||||||
|
className="w-20"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={windowUnit}
|
||||||
|
onChange={(e) => setWindowUnit(e.target.value as 'hours' | 'days')}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="hours">hours</option>
|
||||||
|
<option value="days">days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<Link href={cancelHref}>
|
||||||
|
<Button variant="secondary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
{submitLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
* @file Notification center: bell icon with dropdown of recent notifications.
|
* @file Notification center: bell icon with dropdown of recent notifications.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState, useRef } from 'react'
|
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { listNotifications, markNotificationRead, markAllNotificationsRead, type Notification } from '@/lib/api/notifications'
|
import { listNotifications, markNotificationRead, markAllNotificationsRead, type Notification } from '@/lib/api/notifications'
|
||||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||||
@@ -37,13 +38,37 @@ function BellIcon({ className }: { className?: string }) {
|
|||||||
const LOADING_DELAY_MS = 250
|
const LOADING_DELAY_MS = 250
|
||||||
const POLL_INTERVAL_MS = 90_000
|
const POLL_INTERVAL_MS = 90_000
|
||||||
|
|
||||||
export default function NotificationCenter() {
|
interface NotificationCenterProps {
|
||||||
|
/** Where the dropdown opens. 'right' uses fixed positioning to escape overflow:hidden containers. */
|
||||||
|
anchor?: 'bottom' | 'right'
|
||||||
|
/** Render variant. 'sidebar' matches NavLink styling. */
|
||||||
|
variant?: 'default' | 'sidebar'
|
||||||
|
/** Optional label content rendered after the icon (useful for sidebar variant with fading labels). */
|
||||||
|
children?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotificationCenter({ anchor = 'bottom', variant = 'default', children }: NotificationCenterProps) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||||
const [unreadCount, setUnreadCount] = useState(0)
|
const [unreadCount, setUnreadCount] = useState(0)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const [fixedPos, setFixedPos] = useState<{ left: number; top?: number; bottom?: number } | null>(null)
|
||||||
|
|
||||||
|
const updatePosition = useCallback(() => {
|
||||||
|
if (anchor === 'right' && buttonRef.current) {
|
||||||
|
const rect = buttonRef.current.getBoundingClientRect()
|
||||||
|
const left = rect.right + 8
|
||||||
|
if (rect.top > window.innerHeight / 2) {
|
||||||
|
setFixedPos({ left, bottom: window.innerHeight - rect.bottom })
|
||||||
|
} else {
|
||||||
|
setFixedPos({ left, top: rect.top })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [anchor])
|
||||||
|
|
||||||
const fetchUnreadCount = async () => {
|
const fetchUnreadCount = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -74,8 +99,9 @@ export default function NotificationCenter() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
fetchNotifications()
|
fetchNotifications()
|
||||||
|
updatePosition()
|
||||||
}
|
}
|
||||||
}, [open])
|
}, [open, updatePosition])
|
||||||
|
|
||||||
// * Poll unread count in background (when authenticated)
|
// * Poll unread count in background (when authenticated)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -88,7 +114,11 @@ export default function NotificationCenter() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
function handleClickOutside(e: MouseEvent) {
|
function handleClickOutside(e: MouseEvent) {
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
const target = e.target as Node
|
||||||
|
if (
|
||||||
|
dropdownRef.current && !dropdownRef.current.contains(target) &&
|
||||||
|
(!panelRef.current || !panelRef.current.contains(target))
|
||||||
|
) {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,29 +160,56 @@ export default function NotificationCenter() {
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isSidebar = variant === 'sidebar'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-controls={open ? 'notification-dropdown' : undefined}
|
aria-controls={open ? 'notification-dropdown' : undefined}
|
||||||
className="relative p-2 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg hover:bg-neutral-100/50 dark:hover:bg-neutral-800/50 transition-colors"
|
className={isSidebar
|
||||||
|
? 'relative flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 w-full overflow-hidden transition-colors'
|
||||||
|
: 'relative p-2 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg hover:bg-neutral-100/50 dark:hover:bg-neutral-800/50 transition-colors'
|
||||||
|
}
|
||||||
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : 'Notifications'}
|
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : 'Notifications'}
|
||||||
>
|
>
|
||||||
|
{isSidebar ? (
|
||||||
|
<>
|
||||||
|
<span className="w-7 h-7 flex items-center justify-center shrink-0 relative">
|
||||||
|
<BellIcon className="h-[18px] w-[18px]" />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-brand-orange rounded-full" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<BellIcon />
|
<BellIcon />
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<span className="absolute top-1 right-1 w-2 h-2 bg-brand-orange rounded-full" aria-hidden="true" />
|
<span className="absolute top-1 right-1 w-2 h-2 bg-brand-orange rounded-full" aria-hidden="true" />
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{(() => {
|
||||||
|
const panel = open ? (
|
||||||
<div
|
<div
|
||||||
|
ref={panelRef}
|
||||||
id="notification-dropdown"
|
id="notification-dropdown"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label="Notifications"
|
aria-label="Notifications"
|
||||||
className="fixed left-4 right-4 top-16 sm:absolute sm:left-auto sm:right-0 sm:top-full sm:mt-2 sm:w-96 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden z-[100]"
|
className={`bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden z-[100] ${
|
||||||
|
anchor === 'right'
|
||||||
|
? `fixed w-96 ${fixedPos?.bottom !== undefined ? 'origin-bottom-left' : 'origin-top-left'}`
|
||||||
|
: 'fixed left-4 right-4 top-16 sm:absolute sm:left-auto sm:right-0 sm:top-full sm:mt-2 sm:w-96'
|
||||||
|
}`}
|
||||||
|
style={anchor === 'right' && fixedPos ? { left: fixedPos.left, top: fixedPos.top, bottom: fixedPos.bottom } : undefined}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-200 dark:border-neutral-700">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-200 dark:border-neutral-700">
|
||||||
<h3 className="font-semibold text-neutral-900 dark:text-white">Notifications</h3>
|
<h3 className="font-semibold text-neutral-900 dark:text-white">Notifications</h3>
|
||||||
@@ -265,7 +322,12 @@ export default function NotificationCenter() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null
|
||||||
|
|
||||||
|
return anchor === 'right' && panel && typeof document !== 'undefined'
|
||||||
|
? createPortal(panel, document.body)
|
||||||
|
: panel
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import apiRequest from './client'
|
import apiRequest from './client'
|
||||||
|
|
||||||
|
export interface StepPropertyFilter {
|
||||||
|
key: string
|
||||||
|
operator: 'is' | 'is_not' | 'contains' | 'not_contains'
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface FunnelStep {
|
export interface FunnelStep {
|
||||||
order: number
|
order: number
|
||||||
name: string
|
name: string
|
||||||
value: string
|
value: string
|
||||||
type: string // "exact", "contains", "regex"
|
type: string // "exact", "contains", "regex"
|
||||||
|
category?: 'page' | 'event'
|
||||||
|
property_filters?: StepPropertyFilter[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Funnel {
|
export interface Funnel {
|
||||||
@@ -13,15 +21,23 @@ export interface Funnel {
|
|||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
steps: FunnelStep[]
|
steps: FunnelStep[]
|
||||||
|
conversion_window_value: number
|
||||||
|
conversion_window_unit: 'hours' | 'days'
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExitPage {
|
||||||
|
path: string
|
||||||
|
visitors: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface FunnelStepStats {
|
export interface FunnelStepStats {
|
||||||
step: FunnelStep
|
step: FunnelStep
|
||||||
visitors: number
|
visitors: number
|
||||||
dropoff: number
|
dropoff: number
|
||||||
conversion: number
|
conversion: number
|
||||||
|
exit_pages: ExitPage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FunnelStats {
|
export interface FunnelStats {
|
||||||
@@ -32,7 +48,27 @@ export interface FunnelStats {
|
|||||||
export interface CreateFunnelRequest {
|
export interface CreateFunnelRequest {
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
steps: FunnelStep[]
|
steps: Omit<FunnelStep, 'order'>[]
|
||||||
|
conversion_window_value?: number
|
||||||
|
conversion_window_unit?: 'hours' | 'days'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FunnelTrends {
|
||||||
|
dates: string[]
|
||||||
|
overall: number[]
|
||||||
|
steps: Record<string, number[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FunnelBreakdownEntry {
|
||||||
|
value: string
|
||||||
|
visitors: number
|
||||||
|
conversion: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FunnelBreakdown {
|
||||||
|
step: number
|
||||||
|
dimension: string
|
||||||
|
entries: FunnelBreakdownEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listFunnels(siteId: string): Promise<Funnel[]> {
|
export async function listFunnels(siteId: string): Promise<Funnel[]> {
|
||||||
@@ -64,10 +100,41 @@ export async function deleteFunnel(siteId: string, funnelId: string): Promise<vo
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFunnelStats(siteId: string, funnelId: string, startDate?: string, endDate?: string): Promise<FunnelStats> {
|
export async function getFunnelStats(siteId: string, funnelId: string, startDate?: string, endDate?: string, filters?: string): Promise<FunnelStats> {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (startDate) params.append('start_date', startDate)
|
if (startDate) params.append('start_date', startDate)
|
||||||
if (endDate) params.append('end_date', endDate)
|
if (endDate) params.append('end_date', endDate)
|
||||||
|
if (filters) params.append('filters', filters)
|
||||||
const queryString = params.toString() ? `?${params.toString()}` : ''
|
const queryString = params.toString() ? `?${params.toString()}` : ''
|
||||||
return apiRequest<FunnelStats>(`/sites/${siteId}/funnels/${funnelId}/stats${queryString}`)
|
return apiRequest<FunnelStats>(`/sites/${siteId}/funnels/${funnelId}/stats${queryString}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getFunnelTrends(
|
||||||
|
siteId: string, funnelId: string,
|
||||||
|
startDate?: string, endDate?: string,
|
||||||
|
interval: string = 'day', filters?: string
|
||||||
|
): Promise<FunnelTrends> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (startDate) params.append('start_date', startDate)
|
||||||
|
if (endDate) params.append('end_date', endDate)
|
||||||
|
params.append('interval', interval)
|
||||||
|
if (filters) params.append('filters', filters)
|
||||||
|
const queryString = params.toString() ? `?${params.toString()}` : ''
|
||||||
|
return apiRequest<FunnelTrends>(`/sites/${siteId}/funnels/${funnelId}/trends${queryString}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFunnelBreakdown(
|
||||||
|
siteId: string, funnelId: string,
|
||||||
|
step: number, dimension: string,
|
||||||
|
startDate?: string, endDate?: string,
|
||||||
|
filters?: string
|
||||||
|
): Promise<FunnelBreakdown> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.append('step', step.toString())
|
||||||
|
params.append('dimension', dimension)
|
||||||
|
if (startDate) params.append('start_date', startDate)
|
||||||
|
if (endDate) params.append('end_date', endDate)
|
||||||
|
if (filters) params.append('filters', filters)
|
||||||
|
const queryString = params.toString() ? `?${params.toString()}` : ''
|
||||||
|
return apiRequest<FunnelBreakdown>(`/sites/${siteId}/funnels/${funnelId}/breakdown${queryString}`)
|
||||||
|
}
|
||||||
|
|||||||
26
package-lock.json
generated
26
package-lock.json
generated
@@ -8,7 +8,7 @@
|
|||||||
"name": "pulse-frontend",
|
"name": "pulse-frontend",
|
||||||
"version": "0.15.0-alpha",
|
"version": "0.15.0-alpha",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ciphera-net/ui": "^0.2.8",
|
"@ciphera-net/ui": "^0.2.15",
|
||||||
"@ducanh2912/next-pwa": "^10.2.9",
|
"@ducanh2912/next-pwa": "^10.2.9",
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@simplewebauthn/browser": "^13.2.2",
|
"@simplewebauthn/browser": "^13.2.2",
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
"@tanstack/react-virtual": "^3.13.21",
|
"@tanstack/react-virtual": "^3.13.21",
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"cobe": "^0.6.5",
|
"cobe": "^0.6.5",
|
||||||
"country-flag-icons": "^1.6.4",
|
"country-flag-icons": "^1.6.4",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"svg-dotted-map": "^2.0.1",
|
"svg-dotted-map": "^2.0.1",
|
||||||
"swr": "^2.3.3",
|
"swr": "^2.3.3",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1668,9 +1670,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ciphera-net/ui": {
|
"node_modules/@ciphera-net/ui": {
|
||||||
"version": "0.2.8",
|
"version": "0.2.15",
|
||||||
"resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.8/3a78342207ee2351625b9469ec6030033df183cc",
|
"resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.15/ec35ffe3be80cb5deca6a05bd2d36d636333a4a9",
|
||||||
"integrity": "sha512-I6B7fE2YXjJaipmcVS60q2pzhsy/NKM4sfvHIv4awi6mcrXjGag8FznW0sI1SbsplFWpoT5iSMtWIi/lZdFhbA==",
|
"integrity": "sha512-Y2snU21OFbcarVq6QbSkW/pbL3BL9SePf8dBzC36zUvDp5TuhIU7E/21ydVGxGH6Ye6wKw2G1Qsv3xsnsumyPA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -1684,6 +1686,16 @@
|
|||||||
"react-dom": ">=18"
|
"react-dom": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@ciphera-net/ui/node_modules/tailwind-merge": {
|
||||||
|
"version": "2.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
|
||||||
|
"integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/dcastil"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@csstools/color-helpers": {
|
"node_modules/@csstools/color-helpers": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
||||||
@@ -14352,9 +14364,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tailwind-merge": {
|
"node_modules/tailwind-merge": {
|
||||||
"version": "2.6.1",
|
"version": "3.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
|
||||||
"integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==",
|
"integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ciphera-net/ui": "^0.2.8",
|
"@ciphera-net/ui": "^0.2.15",
|
||||||
"@ducanh2912/next-pwa": "^10.2.9",
|
"@ducanh2912/next-pwa": "^10.2.9",
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@simplewebauthn/browser": "^13.2.2",
|
"@simplewebauthn/browser": "^13.2.2",
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
"@tanstack/react-virtual": "^3.13.21",
|
"@tanstack/react-virtual": "^3.13.21",
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"cobe": "^0.6.5",
|
"cobe": "^0.6.5",
|
||||||
"country-flag-icons": "^1.6.4",
|
"country-flag-icons": "^1.6.4",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"svg-dotted-map": "^2.0.1",
|
"svg-dotted-map": "^2.0.1",
|
||||||
"swr": "^2.3.3",
|
"swr": "^2.3.3",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
|||||||
Reference in New Issue
Block a user