Merge pull request #11 from ciphera-net/staging
[PULSE-43] Design system standardization and branding alignment
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { CheckCircleIcon, XIcon } from '@ciphera-net/ui'
|
||||
|
||||
function ComparisonTable({ title, competitors }: { title: string, competitors: { name: string, isPulse: boolean, features: Record<string, boolean | string> }[] }) {
|
||||
@@ -68,7 +69,12 @@ export default function AboutPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-20 pb-10 z-10">
|
||||
<div className="text-center mb-16">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white mb-6">
|
||||
Why Pulse?
|
||||
</h1>
|
||||
@@ -76,14 +82,19 @@ export default function AboutPage() {
|
||||
We built Pulse because we were tired of complex, invasive analytics tools.
|
||||
Here is how we stack up against the giants.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="prose prose-neutral dark:prose-invert max-w-none mb-16">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="prose prose-neutral dark:prose-invert max-w-none mb-16"
|
||||
>
|
||||
<p className="text-lg text-neutral-600 dark:text-neutral-400">
|
||||
Most analytics tools are overkill. They track everything, slow down your site, and require annoying cookie banners.
|
||||
Pulse is different. We focus on the metrics that actually matter—visitors, pageviews, and sources—while respecting user privacy.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* * Comparison: Pulse vs Google Analytics */}
|
||||
<ComparisonTable
|
||||
@@ -147,14 +158,20 @@ export default function AboutPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mt-8 p-6 bg-neutral-100 dark:bg-neutral-800/50 rounded-xl border border-neutral-200 dark:border-neutral-800">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mt-8 p-6 bg-neutral-100 dark:bg-neutral-800/50 rounded-xl border border-neutral-200 dark:border-neutral-800"
|
||||
>
|
||||
<h3 className="text-lg font-bold mb-2 text-neutral-900 dark:text-white">What about Plausible?</h3>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 text-sm">
|
||||
We love Plausible! They paved the way for privacy-friendly analytics.
|
||||
Pulse offers a similar philosophy but with a focus on even deeper integration with the Ciphera ecosystem
|
||||
and more flexible pricing for developers.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -131,7 +131,7 @@ export default function FAQPage() {
|
||||
</p>
|
||||
<a
|
||||
href="mailto:support@ciphera.net"
|
||||
className="btn-secondary inline-flex"
|
||||
className="inline-flex items-center justify-center gap-2 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 text-neutral-900 dark:text-white px-5 py-2.5 rounded-xl font-medium hover:bg-neutral-50 dark:hover:bg-neutral-800 shadow-sm hover:shadow-md dark:shadow-none transition-all duration-200"
|
||||
>
|
||||
Contact us
|
||||
</a>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ArrowRightIcon } from '@ciphera-net/ui'
|
||||
|
||||
const integrations = [
|
||||
@@ -62,22 +63,33 @@ export default function IntegrationsPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex-grow w-full max-w-6xl mx-auto px-4 pt-20 pb-10 z-10">
|
||||
<div className="text-center mb-16">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white mb-6">
|
||||
Integrations
|
||||
</h1>
|
||||
<p className="text-xl text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto leading-relaxed">
|
||||
Connect Pulse with your favorite frameworks and platforms in minutes.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{integrations.map((integration) => (
|
||||
<Link
|
||||
key={integration.id}
|
||||
href={`/integrations/${integration.id}`}
|
||||
className="group relative p-8 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm border border-neutral-200 dark:border-neutral-800 rounded-2xl hover:border-brand-orange/50 dark:hover:border-brand-orange/50 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl"
|
||||
{integrations.map((integration, i) => (
|
||||
<motion.div
|
||||
key={integration.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: i * 0.1 }}
|
||||
>
|
||||
<Link
|
||||
href={`/integrations/${integration.id}`}
|
||||
className="group relative p-8 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm border border-neutral-200 dark:border-neutral-800 rounded-2xl hover:border-brand-orange/50 dark:hover:border-brand-orange/50 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl block"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div className="p-3 bg-neutral-100 dark:bg-neutral-800 rounded-xl group-hover:scale-110 transition-transform duration-300">
|
||||
{integration.icon}
|
||||
@@ -95,10 +107,17 @@ export default function IntegrationsPage() {
|
||||
View Guide <span aria-hidden="true">→</span>
|
||||
</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{/* * Request Integration Card */}
|
||||
<div className="p-8 border border-dashed border-neutral-300 dark:border-neutral-700 rounded-2xl flex flex-col items-center justify-center text-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: integrations.length * 0.1 }}
|
||||
className="p-8 border border-dashed border-neutral-300 dark:border-neutral-700 rounded-2xl flex flex-col items-center justify-center text-center"
|
||||
>
|
||||
<h3 className="text-lg font-bold text-neutral-900 dark:text-white mb-2">
|
||||
Missing something?
|
||||
</h3>
|
||||
@@ -111,7 +130,7 @@ export default function IntegrationsPage() {
|
||||
>
|
||||
Request Integration
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { OfflineBanner } from '@/components/OfflineBanner'
|
||||
import { Header, Footer, GridIcon } from '@ciphera-net/ui'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { Header, GridIcon } from '@ciphera-net/ui'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus'
|
||||
import Link from 'next/link'
|
||||
@@ -82,8 +83,7 @@ export default function LayoutContent({ children }: { children: React.ReactNode
|
||||
<Footer
|
||||
LinkComponent={Link}
|
||||
appName="Pulse"
|
||||
showPricing={true}
|
||||
showSecurity={false}
|
||||
isAuthenticated={!!auth.user}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -28,12 +28,12 @@ export default function NotFound() {
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
<Link href="/">
|
||||
<Button className="btn-primary px-8 py-3 shadow-lg shadow-brand-orange/20">
|
||||
<Button variant="primary" className="px-8 py-3 shadow-lg shadow-brand-orange/20">
|
||||
Go back home
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/faq">
|
||||
<Button variant="secondary" className="btn-secondary px-8 py-3 backdrop-blur-sm">
|
||||
<Button variant="secondary" className="px-8 py-3">
|
||||
View FAQ
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
82
app/page.tsx
82
app/page.tsx
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { initiateOAuthFlow, initiateSignupFlow } from '@/lib/api/oauth'
|
||||
import { listSites, deleteSite, type Site } from '@/lib/api/sites'
|
||||
@@ -172,16 +173,26 @@ export default function HomePage() {
|
||||
<div className="flex-grow w-full max-w-6xl mx-auto px-4 pt-20 pb-10 z-10">
|
||||
|
||||
{/* * --- 2. BADGE --- */}
|
||||
<div className="inline-flex justify-center mb-8 animate-fade-in w-full">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="inline-flex justify-center mb-8 w-full"
|
||||
>
|
||||
<span className="badge-primary">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-brand-orange animate-pulse" />
|
||||
Privacy-First Analytics
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* * --- 3. HEADLINE --- */}
|
||||
<div className="text-center mb-20">
|
||||
<h1 className="text-5xl md:text-7xl font-bold tracking-tight text-neutral-900 dark:text-white mb-6">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="text-5xl md:text-7xl font-bold tracking-tight text-neutral-900 dark:text-white mb-6"
|
||||
>
|
||||
Simple analytics for <br />
|
||||
<span className="relative inline-block">
|
||||
<span className="gradient-text">privacy-conscious</span>
|
||||
@@ -191,22 +202,32 @@ export default function HomePage() {
|
||||
</svg>
|
||||
</span>
|
||||
{' '}apps.
|
||||
</h1>
|
||||
</motion.h1>
|
||||
|
||||
<p className="text-xl text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto mb-10 leading-relaxed">
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="text-xl text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto mb-10 leading-relaxed"
|
||||
>
|
||||
Respect your users' privacy while getting the insights you need.
|
||||
No cookies, no IP tracking, fully GDPR compliant.
|
||||
</p>
|
||||
</motion.p>
|
||||
|
||||
{/* * --- 4. CTAs --- */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center mb-20">
|
||||
<Button onClick={() => initiateOAuthFlow()} className="btn-primary px-8 py-4 text-lg shadow-lg shadow-brand-orange/20">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="flex flex-col sm:flex-row gap-4 justify-center items-center mb-20"
|
||||
>
|
||||
<Button onClick={() => initiateOAuthFlow()} variant="primary" className="px-8 py-4 text-lg shadow-lg shadow-brand-orange/20">
|
||||
Get Started
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => initiateSignupFlow()} className="btn-secondary px-8 py-4 text-lg backdrop-blur-sm">
|
||||
<Button onClick={() => initiateSignupFlow()} variant="secondary" className="px-8 py-4 text-lg">
|
||||
Create Account
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* * NEW: DASHBOARD PREVIEW */}
|
||||
@@ -219,7 +240,14 @@ export default function HomePage() {
|
||||
{ icon: BarChartIcon, title: "Simple Insights", desc: "Get the metrics that matter without the clutter. Page views, visitors, and sources." },
|
||||
{ icon: ZapIcon, title: "Lightweight", desc: "Our script is less than 1kb. It won't slow down your site or affect your SEO." }
|
||||
].map((feature, i) => (
|
||||
<div key={i} className="card-glass p-8 hover:-translate-y-1 hover:shadow-xl transition-all duration-300 group">
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: i * 0.1 }}
|
||||
className="card-glass p-8 hover:-translate-y-1 hover:shadow-xl transition-all duration-300 group"
|
||||
>
|
||||
<div className="w-12 h-12 rounded-xl bg-brand-orange/10 flex items-center justify-center mb-6 text-brand-orange group-hover:scale-110 transition-transform duration-300">
|
||||
<feature.icon className="w-6 h-6" />
|
||||
</div>
|
||||
@@ -227,7 +255,7 @@ export default function HomePage() {
|
||||
<p className="text-neutral-600 dark:text-neutral-400 leading-relaxed">
|
||||
{feature.desc}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -235,13 +263,19 @@ export default function HomePage() {
|
||||
<ComparisonSection />
|
||||
|
||||
{/* * NEW: CTA BOTTOM */}
|
||||
<div className="text-center mb-20">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-center mb-20"
|
||||
>
|
||||
<h2 className="text-3xl font-bold text-neutral-900 dark:text-white mb-6">Ready to switch?</h2>
|
||||
<Button onClick={() => initiateOAuthFlow()} className="btn-primary px-8 py-4 text-lg shadow-lg shadow-brand-orange/20">
|
||||
<Button onClick={() => initiateOAuthFlow()} variant="primary" className="px-8 py-4 text-lg shadow-lg shadow-brand-orange/20">
|
||||
Start your free trial
|
||||
</Button>
|
||||
<p className="mt-4 text-sm text-neutral-500">No credit card required • Cancel anytime</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -265,26 +299,32 @@ export default function HomePage() {
|
||||
<span className="text-sm font-medium text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800 px-3 py-1.5 rounded-lg border border-neutral-200 dark:border-neutral-700">
|
||||
Limit reached (1/1)
|
||||
</span>
|
||||
<Link href="/pricing" className="btn-primary text-sm bg-brand-orange hover:bg-brand-orange/90 border-transparent text-white shadow-lg shadow-brand-orange/20">
|
||||
Upgrade
|
||||
<Link href="/pricing">
|
||||
<Button variant="primary" className="text-sm">
|
||||
Upgrade
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<Link href="/sites/new" className="btn-primary text-sm">Add New Site</Link>
|
||||
<Link href="/sites/new">
|
||||
<Button variant="primary" className="text-sm">
|
||||
Add New Site
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* * Global Overview */}
|
||||
<div className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<div className="rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">Total Sites</p>
|
||||
<p className="text-2xl font-bold text-neutral-900 dark:text-white">{sites.length}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<div className="rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">Total Visitors (24h)</p>
|
||||
<p className="text-2xl font-bold text-neutral-900 dark:text-white">--</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-neutral-200 bg-brand-orange/10 p-4 dark:border-neutral-800">
|
||||
<div className="rounded-2xl border border-neutral-200 bg-brand-orange/10 p-4 dark:border-neutral-800">
|
||||
<p className="text-sm text-brand-orange">Plan & usage</p>
|
||||
{subscriptionLoading ? (
|
||||
<p className="text-lg font-bold text-brand-orange">...</p>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useParams, useSearchParams, useRouter } from 'next/navigation'
|
||||
import { getPublicDashboard, getPublicStats, getPublicDailyStats, getPublicRealtime, getPublicPerformanceByPage, type DashboardData, type Stats, type DailyStat, type PerformanceByPageStat } from '@/lib/api/stats'
|
||||
import { toast } from '@ciphera-net/ui'
|
||||
import { getAuthErrorMessage } from '@/lib/utils/authErrors'
|
||||
import { LoadingOverlay } from '@ciphera-net/ui'
|
||||
import { LoadingOverlay, Button } from '@ciphera-net/ui'
|
||||
import Chart from '@/components/dashboard/Chart'
|
||||
import TopPages from '@/components/dashboard/ContentStats'
|
||||
import TopReferrers from '@/components/dashboard/TopReferrers'
|
||||
@@ -191,7 +191,7 @@ export default function PublicDashboardPage() {
|
||||
if (isPasswordProtected && !data) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-4">
|
||||
<div className="max-w-md w-full bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-8 shadow-lg">
|
||||
<div className="max-w-md w-full bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-8 shadow-lg">
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-12 h-12 bg-brand-orange/10 rounded-xl flex items-center justify-center mx-auto mb-4 text-brand-orange">
|
||||
<ZapIcon className="w-6 h-6" />
|
||||
@@ -225,12 +225,13 @@ export default function PublicDashboardPage() {
|
||||
apiUrl={process.env.NEXT_PUBLIC_CAPTCHA_API_URL}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full btn-primary"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
>
|
||||
Access Dashboard
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { ApiError } from '@/lib/api/client'
|
||||
import { getFunnel, getFunnelStats, deleteFunnel, type Funnel, type FunnelStats } from '@/lib/api/funnels'
|
||||
import { toast, LoadingOverlay, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, useTheme } from '@ciphera-net/ui'
|
||||
import { toast, LoadingOverlay, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, useTheme, Button } from '@ciphera-net/ui'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
BarChart,
|
||||
@@ -107,8 +107,10 @@ export default function FunnelReportPage() {
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||
<p className="text-neutral-600 dark:text-neutral-400">Access denied</p>
|
||||
<Link href={`/sites/${siteId}/funnels`} className="btn-primary mt-4 inline-block">
|
||||
Back to Funnels
|
||||
<Link href={`/sites/${siteId}/funnels`}>
|
||||
<Button variant="primary" className="mt-4">
|
||||
Back to Funnels
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
@@ -118,9 +120,9 @@ export default function FunnelReportPage() {
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||
<p className="text-neutral-600 dark:text-neutral-400 mb-4">Unable to load funnel</p>
|
||||
<button type="button" onClick={() => loadData()} className="btn-primary">
|
||||
<Button type="button" onClick={() => loadData()} variant="primary">
|
||||
Try again
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -195,7 +197,7 @@ export default function FunnelReportPage() {
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl 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">
|
||||
Funnel Visualization
|
||||
</h3>
|
||||
@@ -260,7 +262,7 @@ export default function FunnelReportPage() {
|
||||
</div>
|
||||
|
||||
{/* Detailed Stats Table */}
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl 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">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-neutral-50 dark:bg-neutral-800/50 border-b border-neutral-200 dark:border-neutral-800">
|
||||
|
||||
@@ -110,7 +110,7 @@ export default function CreateFunnelPage() {
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6 mb-6">
|
||||
<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">
|
||||
@@ -144,7 +144,7 @@ export default function CreateFunnelPage() {
|
||||
</div>
|
||||
|
||||
{steps.map((step, index) => (
|
||||
<div key={index} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-4">
|
||||
<div key={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">
|
||||
@@ -171,7 +171,7 @@ export default function CreateFunnelPage() {
|
||||
<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-xl text-sm focus:ring-2 focus:ring-brand-orange/20 focus:border-brand-orange outline-none"
|
||||
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>
|
||||
@@ -215,16 +215,15 @@ export default function CreateFunnelPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Link
|
||||
href={`/sites/${siteId}/funnels`}
|
||||
className="btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
<Link href={`/sites/${siteId}/funnels`}>
|
||||
<Button variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="btn-primary"
|
||||
variant="primary"
|
||||
>
|
||||
{saving ? 'Creating...' : 'Create Funnel'}
|
||||
</Button>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { listFunnels, deleteFunnel, type Funnel } from '@/lib/api/funnels'
|
||||
import { toast, LoadingOverlay, PlusIcon, ArrowRightIcon, ChevronLeftIcon, TrashIcon } from '@ciphera-net/ui'
|
||||
import { toast, LoadingOverlay, PlusIcon, ArrowRightIcon, ChevronLeftIcon, TrashIcon, Button } from '@ciphera-net/ui'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function FunnelsPage() {
|
||||
@@ -66,18 +66,17 @@ export default function FunnelsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<Link
|
||||
href={`/sites/${siteId}/funnels/new`}
|
||||
className="btn-primary inline-flex items-center gap-2"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
<span>Create Funnel</span>
|
||||
<Link href={`/sites/${siteId}/funnels/new`}>
|
||||
<Button variant="primary" className="inline-flex items-center gap-2">
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
<span>Create Funnel</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{funnels.length === 0 ? (
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-12 text-center">
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-12 text-center">
|
||||
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4 mx-auto mb-4 w-fit">
|
||||
<ArrowRightIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||
</div>
|
||||
@@ -87,12 +86,11 @@ export default function FunnelsPage() {
|
||||
<p className="text-neutral-600 dark:text-neutral-400 mb-6 max-w-md mx-auto">
|
||||
Create a funnel to track how users move through your site and where they drop off.
|
||||
</p>
|
||||
<Link
|
||||
href={`/sites/${siteId}/funnels/new`}
|
||||
className="btn-primary inline-flex items-center gap-2"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
<span>Create Funnel</span>
|
||||
<Link href={`/sites/${siteId}/funnels/new`}>
|
||||
<Button variant="primary" className="inline-flex items-center gap-2">
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
<span>Create Funnel</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
@@ -103,7 +101,7 @@ export default function FunnelsPage() {
|
||||
href={`/sites/${siteId}/funnels/${funnel.id}`}
|
||||
className="block group"
|
||||
>
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6 hover:border-brand-orange/50 transition-colors">
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 hover:border-brand-orange/50 transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-neutral-900 dark:text-white group-hover:text-brand-orange transition-colors">
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { motion } from 'framer-motion'
|
||||
import { getSite, type Site } from '@/lib/api/sites'
|
||||
import { getStats, getRealtime, getDailyStats, getTopPages, getTopReferrers, getCountries, getCities, getRegions, getBrowsers, getOS, getDevices, getScreenResolutions, getEntryPages, getExitPages, getDashboard, getPerformanceByPage, type Stats, type DailyStat, type PerformanceByPageStat } from '@/lib/api/stats'
|
||||
import { formatNumber, formatDuration, getDateRange } from '@/lib/utils/format'
|
||||
import { toast } from '@ciphera-net/ui'
|
||||
import { getAuthErrorMessage } from '@/lib/utils/authErrors'
|
||||
import { LoadingOverlay } from '@ciphera-net/ui'
|
||||
import { LoadingOverlay, Button } from '@ciphera-net/ui'
|
||||
import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui'
|
||||
import ExportModal from '@/components/dashboard/ExportModal'
|
||||
import ContentStats from '@/components/dashboard/ContentStats'
|
||||
@@ -225,7 +226,12 @@ export default function SiteDashboardPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8"
|
||||
>
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -239,7 +245,10 @@ export default function SiteDashboardPage() {
|
||||
</div>
|
||||
|
||||
{/* Realtime Indicator */}
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-green-500/10 rounded-full border border-green-500/20">
|
||||
<button
|
||||
onClick={() => router.push(`/sites/${siteId}/realtime`)}
|
||||
className="flex items-center gap-2 px-3 py-1 bg-green-500/10 rounded-full border border-green-500/20 hover:bg-green-500/20 transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||
@@ -247,7 +256,7 @@ export default function SiteDashboardPage() {
|
||||
<span className="text-sm font-medium text-green-700 dark:text-green-400">
|
||||
{realtime} current visitors
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
@@ -297,19 +306,21 @@ export default function SiteDashboardPage() {
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
]}
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
onClick={() => router.push(`/sites/${siteId}/funnels`)}
|
||||
className="btn-secondary text-sm"
|
||||
variant="secondary"
|
||||
className="text-sm"
|
||||
>
|
||||
Funnels
|
||||
</button>
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<button
|
||||
<Button
|
||||
onClick={() => router.push(`/sites/${siteId}/settings`)}
|
||||
className="btn-secondary text-sm"
|
||||
variant="secondary"
|
||||
className="text-sm"
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -412,6 +423,6 @@ export default function SiteDashboardPage() {
|
||||
topPages={topPages}
|
||||
topReferrers={topReferrers}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ import { getSite, type Site } from '@/lib/api/sites'
|
||||
import { getRealtimeVisitors, getSessionDetails, type Visitor, type SessionEvent } from '@/lib/api/realtime'
|
||||
import { toast } from '@ciphera-net/ui'
|
||||
import { getAuthErrorMessage } from '@/lib/utils/authErrors'
|
||||
import { LoadingOverlay } from '@ciphera-net/ui'
|
||||
import { LoadingOverlay, UserIcon } from '@ciphera-net/ui'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
function formatTimeAgo(dateString: string) {
|
||||
const date = new Date(dateString)
|
||||
@@ -93,7 +94,7 @@ export default function RealtimePage() {
|
||||
if (!site) return <div className="p-8">Site not found</div>
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 py-8 h-[calc(100vh-64px)] flex flex-col">
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8 h-[calc(100vh-64px)] flex flex-col">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
@@ -116,25 +117,38 @@ export default function RealtimePage() {
|
||||
|
||||
<div className="flex flex-1 gap-6 min-h-0">
|
||||
{/* Visitors List */}
|
||||
<div className="w-1/3 border border-neutral-200 dark:border-neutral-800 rounded-xl overflow-hidden flex flex-col bg-white dark:bg-neutral-900">
|
||||
<div className="w-1/3 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden flex flex-col bg-white dark:bg-neutral-900">
|
||||
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/50">
|
||||
<h2 className="font-semibold text-neutral-900 dark:text-white">Active Sessions</h2>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{visitors.length === 0 ? (
|
||||
<div className="p-8 text-center text-neutral-500">
|
||||
No active visitors right now.
|
||||
<div className="p-8 flex flex-col items-center justify-center text-center gap-3">
|
||||
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-3">
|
||||
<UserIcon className="w-6 h-6 text-neutral-500 dark:text-neutral-400" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-neutral-900 dark:text-white">
|
||||
No active visitors right now
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
New visitors will appear here in real-time
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
|
||||
{visitors.map((visitor) => (
|
||||
<button
|
||||
key={visitor.session_id}
|
||||
onClick={() => handleSelectVisitor(visitor)}
|
||||
className={`w-full text-left p-4 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors ${
|
||||
selectedVisitor?.session_id === visitor.session_id ? 'bg-neutral-50 dark:bg-neutral-800/50 ring-1 ring-inset ring-neutral-200 dark:ring-neutral-700' : ''
|
||||
}`}
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{visitors.map((visitor) => (
|
||||
<motion.button
|
||||
key={visitor.session_id}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={() => handleSelectVisitor(visitor)}
|
||||
className={`w-full text-left p-4 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors ${
|
||||
selectedVisitor?.session_id === visitor.session_id ? 'bg-neutral-50 dark:bg-neutral-800/50 ring-1 ring-inset ring-neutral-200 dark:ring-neutral-700' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<div className="font-medium text-neutral-900 dark:text-white truncate pr-2">
|
||||
{visitor.country ? `${getFlagEmoji(visitor.country)} ${visitor.city || 'Unknown City'}` : 'Unknown Location'}
|
||||
@@ -156,15 +170,16 @@ export default function RealtimePage() {
|
||||
{visitor.pageviews} views
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</motion.button>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session Details */}
|
||||
<div className="flex-1 border border-neutral-200 dark:border-neutral-800 rounded-xl overflow-hidden flex flex-col bg-white dark:bg-neutral-900">
|
||||
<div className="flex-1 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden flex flex-col bg-white dark:bg-neutral-900">
|
||||
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/50 flex justify-between items-center">
|
||||
<h2 className="font-semibold text-neutral-900 dark:text-white">
|
||||
{selectedVisitor ? 'Session Journey' : 'Select a visitor'}
|
||||
|
||||
@@ -396,7 +396,7 @@ export default function SiteSettingsPage() {
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-xl bg-neutral-50/50 dark:bg-neutral-900/50 focus:bg-white dark:focus:bg-neutral-900
|
||||
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-neutral-50/50 dark:bg-neutral-900/50 focus:bg-white dark:focus:bg-neutral-900
|
||||
focus:border-brand-orange focus:ring-4 focus:ring-brand-orange/10 outline-none transition-all duration-200 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
@@ -424,7 +424,7 @@ export default function SiteSettingsPage() {
|
||||
type="text"
|
||||
value={site.domain}
|
||||
disabled
|
||||
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-xl bg-neutral-100 dark:bg-neutral-800/50 text-neutral-500 dark:text-neutral-400 cursor-not-allowed"
|
||||
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-neutral-100 dark:bg-neutral-800/50 text-neutral-500 dark:text-neutral-400 cursor-not-allowed"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Domain cannot be changed after creation
|
||||
@@ -495,7 +495,7 @@ export default function SiteSettingsPage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-900/10 rounded-xl flex items-center justify-between">
|
||||
<div className="p-4 border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-900/10 rounded-2xl flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-red-900 dark:text-red-200">Reset Data</h3>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 mt-1">Delete all stats and events. This cannot be undone.</p>
|
||||
@@ -508,7 +508,7 @@ export default function SiteSettingsPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-900/10 rounded-xl flex items-center justify-between">
|
||||
<div className="p-4 border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-900/10 rounded-2xl flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-red-900 dark:text-red-200">Delete Site</h3>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 mt-1">Permanently delete this site and all data.</p>
|
||||
@@ -534,7 +534,7 @@ export default function SiteSettingsPage() {
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">Manage who can view your dashboard.</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-xl border border-neutral-100 dark:border-neutral-800">
|
||||
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-white dark:bg-neutral-800 rounded-lg text-neutral-400">
|
||||
@@ -576,7 +576,7 @@ export default function SiteSettingsPage() {
|
||||
type="text"
|
||||
readOnly
|
||||
value={`${APP_URL}/share/${siteId}`}
|
||||
className="flex-1 px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-xl bg-white dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 font-mono text-sm"
|
||||
className="flex-1 px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -673,7 +673,7 @@ export default function SiteSettingsPage() {
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Data Collection</h3>
|
||||
|
||||
{/* Page Paths Toggle */}
|
||||
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-xl border border-neutral-100 dark:border-neutral-800">
|
||||
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-neutral-900 dark:text-white">Page Paths</h4>
|
||||
@@ -694,7 +694,7 @@ export default function SiteSettingsPage() {
|
||||
</div>
|
||||
|
||||
{/* Referrers Toggle */}
|
||||
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-xl border border-neutral-100 dark:border-neutral-800">
|
||||
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-neutral-900 dark:text-white">Referrers</h4>
|
||||
@@ -715,7 +715,7 @@ export default function SiteSettingsPage() {
|
||||
</div>
|
||||
|
||||
{/* Device Info Toggle */}
|
||||
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-xl border border-neutral-100 dark:border-neutral-800">
|
||||
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-neutral-900 dark:text-white">Device Info</h4>
|
||||
@@ -736,7 +736,7 @@ export default function SiteSettingsPage() {
|
||||
</div>
|
||||
|
||||
{/* Geographic Data Dropdown */}
|
||||
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-xl border border-neutral-100 dark:border-neutral-800">
|
||||
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-neutral-900 dark:text-white">Geographic Data</h4>
|
||||
@@ -760,7 +760,7 @@ export default function SiteSettingsPage() {
|
||||
</div>
|
||||
|
||||
{/* Screen Resolution Toggle */}
|
||||
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-xl border border-neutral-100 dark:border-neutral-800">
|
||||
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-neutral-900 dark:text-white">Screen Resolution</h4>
|
||||
@@ -784,7 +784,7 @@ export default function SiteSettingsPage() {
|
||||
{/* Bot and noise filtering */}
|
||||
<div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800">
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Filtering</h3>
|
||||
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-xl border border-neutral-100 dark:border-neutral-800">
|
||||
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-neutral-900 dark:text-white">Filter bots and referrer spam</h4>
|
||||
@@ -808,7 +808,7 @@ export default function SiteSettingsPage() {
|
||||
{/* Performance Insights Toggle */}
|
||||
<div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800">
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Performance Insights</h3>
|
||||
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-xl border border-neutral-100 dark:border-neutral-800">
|
||||
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-neutral-900 dark:text-white">Performance Insights (Add-on)</h4>
|
||||
@@ -843,7 +843,7 @@ export default function SiteSettingsPage() {
|
||||
value={formData.excluded_paths}
|
||||
onChange={(e) => setFormData({ ...formData, excluded_paths: e.target.value })}
|
||||
placeholder="/admin/* /staging/*"
|
||||
className="w-full px-4 py-3 border border-neutral-200 dark:border-neutral-800 rounded-xl bg-neutral-50/50 dark:bg-neutral-900/50 focus:bg-white dark:focus:bg-neutral-900
|
||||
className="w-full px-4 py-3 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-neutral-50/50 dark:bg-neutral-900/50 focus:bg-white dark:focus:bg-neutral-900
|
||||
focus:border-brand-orange focus:ring-4 focus:ring-brand-orange/10 outline-none transition-all duration-200 dark:text-white font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -937,14 +937,14 @@ export default function SiteSettingsPage() {
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{goals.length === 0 ? (
|
||||
<div className="p-6 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/50 text-center text-neutral-500 dark:text-neutral-400 text-sm">
|
||||
<div className="p-6 rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/50 text-center text-neutral-500 dark:text-neutral-400 text-sm">
|
||||
No goals yet. Add a goal to give custom events a display name in the dashboard.
|
||||
</div>
|
||||
) : (
|
||||
goals.map((goal) => (
|
||||
<div
|
||||
key={goal.id}
|
||||
className="flex items-center justify-between py-3 px-4 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/50"
|
||||
className="flex items-center justify-between py-3 px-4 rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/50"
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium text-neutral-900 dark:text-white">{goal.name}</span>
|
||||
@@ -994,7 +994,7 @@ export default function SiteSettingsPage() {
|
||||
value={goalForm.name}
|
||||
onChange={(e) => setGoalForm({ ...goalForm, name: e.target.value })}
|
||||
placeholder="e.g. Signups"
|
||||
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-xl bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white"
|
||||
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -1005,7 +1005,7 @@ export default function SiteSettingsPage() {
|
||||
value={goalForm.event_name}
|
||||
onChange={(e) => setGoalForm({ ...goalForm, event_name: e.target.value })}
|
||||
placeholder="e.g. signup_click (letters, numbers, underscores only)"
|
||||
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-xl bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white"
|
||||
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">Only letters, numbers, and underscores; spaces become underscores. Invalid characters cannot be used. Max 64 characters after formatting.</p>
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function NewSitePage() {
|
||||
Create New Site
|
||||
</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6">
|
||||
<form onSubmit={handleSubmit} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
|
||||
<div className="mb-4">
|
||||
<label htmlFor="name" className="block text-sm font-medium mb-2 text-neutral-900 dark:text-white">
|
||||
Site Name
|
||||
|
||||
@@ -1,29 +1,239 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import React from 'react'
|
||||
import Image from 'next/image'
|
||||
import { GithubIcon, TwitterIcon } from '@ciphera-net/ui'
|
||||
import SwissFlagIcon from './SwissFlagIcon'
|
||||
|
||||
interface FooterProps {
|
||||
LinkComponent?: any
|
||||
appName?: string
|
||||
isAuthenticated?: boolean
|
||||
}
|
||||
|
||||
export function Footer({ LinkComponent = Link, appName = 'Pulse' }: FooterProps) {
|
||||
const Component = LinkComponent
|
||||
const footerLinks = {
|
||||
products: [
|
||||
{ name: 'Drop', href: 'https://drop.ciphera.net', external: true },
|
||||
{ name: 'Pulse', href: 'https://pulse.ciphera.net', external: true },
|
||||
{ name: 'Ciphera Auth', href: 'https://ciphera.net/products#auth', external: true },
|
||||
{ name: 'Ciphera Captcha', href: 'https://ciphera.net/products#captcha', external: true },
|
||||
{ name: 'Ciphera Relay', href: 'https://ciphera.net/products#relay', external: true },
|
||||
],
|
||||
company: [
|
||||
{ name: 'About', href: '/about', external: false },
|
||||
{ name: 'Pricing', href: '/pricing', external: false },
|
||||
{ name: 'Contact', href: 'https://ciphera.net/contact', external: true },
|
||||
],
|
||||
resources: [
|
||||
{ name: 'Installation', href: '/installation', external: false },
|
||||
{ name: 'Integrations', href: '/integrations', external: false },
|
||||
{ name: 'Documentation', href: 'https://docs.ciphera.net', external: true },
|
||||
{ name: 'Status', href: 'https://status.ciphera.net', external: true },
|
||||
{ name: 'GitHub', href: 'https://github.com/ciphera-net', external: true },
|
||||
],
|
||||
legal: [
|
||||
{ name: 'Privacy Policy', href: 'https://ciphera.net/#privacy', external: true },
|
||||
{ name: 'Terms of Service', href: 'https://ciphera.net/#terms', external: true },
|
||||
],
|
||||
}
|
||||
|
||||
export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticated = false }: FooterProps) {
|
||||
const Component = LinkComponent
|
||||
const year = new Date().getFullYear()
|
||||
|
||||
// * Simple footer for authenticated users
|
||||
if (isAuthenticated) {
|
||||
return (
|
||||
<footer className="w-full py-8 mt-auto border-t border-neutral-100 dark:border-neutral-800 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
© 2024-{year} Ciphera. All rights reserved.
|
||||
</div>
|
||||
<div className="flex gap-6 text-sm font-medium text-neutral-600 dark:text-neutral-300">
|
||||
<Component href="/about" className="hover:text-brand-orange transition-colors">
|
||||
Why {appName}
|
||||
</Component>
|
||||
<Component href="/pricing" className="hover:text-brand-orange transition-colors">
|
||||
Pricing
|
||||
</Component>
|
||||
<Component href="/faq" className="hover:text-brand-orange transition-colors">
|
||||
FAQ
|
||||
</Component>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
// * Comprehensive footer for unauthenticated users
|
||||
return (
|
||||
<footer className="border-t border-neutral-200 dark:border-neutral-800 mt-auto bg-white dark:bg-neutral-950">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
© {new Date().getFullYear()} Ciphera. All rights reserved.
|
||||
<footer className="w-full mt-auto border-t border-neutral-100 dark:border-neutral-800 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm">
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6 py-12 lg:py-16">
|
||||
{/* * Main footer content */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-6 sm:gap-8 lg:gap-12">
|
||||
{/* * Brand column */}
|
||||
<div className="col-span-1 sm:col-span-2 md:col-span-4 lg:col-span-1 lg:pr-8">
|
||||
<Link href="/" className="flex items-center gap-3 mb-4 group">
|
||||
<Image
|
||||
src="/pulse_icon_no_margins.png"
|
||||
alt="Pulse privacy-first analytics logo"
|
||||
width={36}
|
||||
height={36}
|
||||
loading="lazy"
|
||||
className="w-9 h-9 object-contain group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
<span className="text-xl font-bold text-neutral-900 dark:text-white group-hover:text-brand-orange transition-colors duration-300">
|
||||
Pulse
|
||||
</span>
|
||||
</Link>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4 leading-relaxed">
|
||||
Simple analytics for privacy-conscious apps.
|
||||
</p>
|
||||
<div className="inline-flex items-center gap-2.5 text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
<span className="flex items-center justify-center w-8 h-8 rounded-lg bg-neutral-100 dark:bg-neutral-800 shrink-0 overflow-hidden ring-1 ring-neutral-200 dark:ring-neutral-700" aria-hidden>
|
||||
<SwissFlagIcon className="w-5 h-5" />
|
||||
</span>
|
||||
<span>Swiss infrastructure</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="https://github.com/ciphera-net"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-9 h-9 rounded-lg bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<GithubIcon className="w-5 h-5" />
|
||||
</a>
|
||||
<a
|
||||
href="https://x.com/cipheranet"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-9 h-9 rounded-lg bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors"
|
||||
aria-label="X (Twitter)"
|
||||
>
|
||||
<TwitterIcon className="w-5 h-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-6 text-sm font-medium text-neutral-600 dark:text-neutral-300">
|
||||
<Component href="/about" className="hover:text-brand-orange transition-colors">
|
||||
Why {appName}
|
||||
</Component>
|
||||
<Component href="/faq" className="hover:text-brand-orange transition-colors">
|
||||
FAQ
|
||||
</Component>
|
||||
|
||||
{/* * Products */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white mb-4">Products</h4>
|
||||
<ul className="space-y-3">
|
||||
{footerLinks.products.map((link) => (
|
||||
<li key={link.name}>
|
||||
{link.external ? (
|
||||
<a
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors"
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
) : (
|
||||
<Component
|
||||
href={link.href}
|
||||
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors"
|
||||
>
|
||||
{link.name}
|
||||
</Component>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* * Company */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white mb-4">Company</h4>
|
||||
<ul className="space-y-3">
|
||||
{footerLinks.company.map((link) => (
|
||||
<li key={link.name}>
|
||||
{link.external ? (
|
||||
<a
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors"
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
) : (
|
||||
<Component
|
||||
href={link.href}
|
||||
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors"
|
||||
>
|
||||
{link.name}
|
||||
</Component>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* * Resources */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white mb-4">Resources</h4>
|
||||
<ul className="space-y-3">
|
||||
{footerLinks.resources.map((link) => (
|
||||
<li key={link.name}>
|
||||
{link.external ? (
|
||||
<a
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors"
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
) : (
|
||||
<Component
|
||||
href={link.href}
|
||||
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors"
|
||||
>
|
||||
{link.name}
|
||||
</Component>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* * Legal */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white mb-4">Legal</h4>
|
||||
<ul className="space-y-3">
|
||||
{footerLinks.legal.map((link) => (
|
||||
<li key={link.name}>
|
||||
<a
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors"
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* * Divider */}
|
||||
<div className="h-px w-full bg-gradient-to-r from-transparent via-neutral-200 dark:via-neutral-800 to-transparent my-8" />
|
||||
|
||||
{/* * Bottom bar */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
© 2024-{year} Ciphera. All rights reserved.
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Where Privacy Still Exists
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function PasswordInput({
|
||||
onBlur={onBlur}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
className={`w-full pl-11 pr-12 py-3 border rounded-xl bg-neutral-50/50 dark:bg-neutral-900/50 focus:bg-white dark:focus:bg-neutral-900
|
||||
className={`w-full pl-11 pr-12 py-3 border rounded-lg bg-neutral-50/50 dark:bg-neutral-900/50 focus:bg-white dark:focus:bg-neutral-900
|
||||
transition-all duration-200 outline-none disabled:opacity-50 disabled:cursor-not-allowed dark:text-white
|
||||
${error
|
||||
? 'border-red-300 dark:border-red-800 focus:border-red-500 focus:ring-4 focus:ring-red-500/10'
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Button, CheckCircleIcon } from '@ciphera-net/ui'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { initiateOAuthFlow } from '@/lib/api/oauth'
|
||||
@@ -212,17 +213,27 @@ export default function PricingSection() {
|
||||
|
||||
return (
|
||||
<section className="py-24 px-4 max-w-6xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h2 className="text-4xl md:text-5xl font-bold text-neutral-900 dark:text-white mb-6 tracking-tight">
|
||||
Transparent Pricing
|
||||
</h2>
|
||||
<p className="text-xl text-neutral-600 dark:text-neutral-400">
|
||||
Scale with your traffic. No hidden fees.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Unified Container */}
|
||||
<div className="max-w-6xl mx-auto border border-neutral-200 dark:border-neutral-800 rounded-3xl bg-white/50 dark:bg-neutral-900/50 backdrop-blur-xl shadow-sm overflow-hidden mb-20">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="max-w-6xl mx-auto border border-neutral-200 dark:border-neutral-800 rounded-3xl bg-white/50 dark:bg-neutral-900/50 backdrop-blur-xl shadow-sm overflow-hidden mb-20"
|
||||
>
|
||||
|
||||
{/* Top Toolbar */}
|
||||
<div className="p-8 border-b border-neutral-200 dark:border-neutral-800 flex flex-col md:flex-row items-center justify-between gap-8 bg-neutral-50/50 dark:bg-neutral-900/50">
|
||||
@@ -252,7 +263,7 @@ export default function PricingSection() {
|
||||
<div className="bg-neutral-200 dark:bg-neutral-800 p-1 rounded-lg flex">
|
||||
<button
|
||||
onClick={() => setIsYearly(false)}
|
||||
className={`min-w-[88px] px-4 py-2 rounded-md text-sm font-medium transition-all ${
|
||||
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
!isYearly
|
||||
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
||||
: 'text-neutral-500 hover:text-neutral-900 dark:hover:text-white'
|
||||
@@ -262,7 +273,7 @@ export default function PricingSection() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsYearly(true)}
|
||||
className={`min-w-[88px] px-4 py-2 rounded-md text-sm font-medium transition-all ${
|
||||
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
isYearly
|
||||
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
||||
: 'text-neutral-500 hover:text-neutral-900 dark:hover:text-white'
|
||||
@@ -285,9 +296,9 @@ export default function PricingSection() {
|
||||
{isTeam && (
|
||||
<>
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-brand-orange" />
|
||||
<div className="absolute top-4 right-4 bg-brand-orange/10 text-brand-orange text-[10px] font-bold px-2 py-1 rounded-full uppercase tracking-wide">
|
||||
<span className="absolute top-4 right-4 badge-primary">
|
||||
Most Popular
|
||||
</div>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -331,11 +342,8 @@ export default function PricingSection() {
|
||||
<Button
|
||||
onClick={() => handleSubscribe(plan.id)}
|
||||
disabled={loadingPlan === plan.id || !!loadingPlan || !priceDetails}
|
||||
className={`w-full mb-8 ${
|
||||
isTeam
|
||||
? 'bg-brand-orange hover:bg-brand-orange/90 text-white shadow-lg shadow-brand-orange/20'
|
||||
: 'bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 hover:bg-neutral-800 dark:hover:bg-neutral-100'
|
||||
}`}
|
||||
variant={isTeam ? 'primary' : 'secondary'}
|
||||
className="w-full mb-8"
|
||||
>
|
||||
{loadingPlan === plan.id ? 'Loading...' : !priceDetails ? 'Contact us' : 'Start free trial'}
|
||||
</Button>
|
||||
@@ -383,7 +391,7 @@ export default function PricingSection() {
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function WorkspaceSwitcher({ orgs, activeOrgId }: { orgs: Organiz
|
||||
{/*
|
||||
<button
|
||||
onClick={() => handleSwitch(null)}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 text-sm rounded-md transition-colors group ${
|
||||
className={`w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg transition-colors group ${
|
||||
!activeOrgId ? 'bg-neutral-100 dark:bg-neutral-800' : 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50'
|
||||
}`}
|
||||
>
|
||||
@@ -75,7 +75,7 @@ export default function WorkspaceSwitcher({ orgs, activeOrgId }: { orgs: Organiz
|
||||
<button
|
||||
key={org.organization_id}
|
||||
onClick={() => handleSwitch(org.organization_id)}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 text-sm rounded-md transition-colors mt-1 ${
|
||||
className={`w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg transition-colors mt-1 ${
|
||||
activeOrgId === org.organization_id ? 'bg-neutral-100 dark:bg-neutral-800' : 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50'
|
||||
}`}
|
||||
>
|
||||
@@ -97,7 +97,7 @@ export default function WorkspaceSwitcher({ orgs, activeOrgId }: { orgs: Organiz
|
||||
{/* Create New */}
|
||||
<Link
|
||||
href="/onboarding"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-neutral-500 hover:text-blue-600 dark:text-neutral-400 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/10 rounded-md transition-colors mt-1"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-neutral-500 hover:text-blue-600 dark:text-neutral-400 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/10 rounded-lg transition-colors mt-1"
|
||||
>
|
||||
<div className="h-5 w-5 rounded border border-dashed border-neutral-300 dark:border-neutral-600 flex items-center justify-center">
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6 h-full flex flex-col">
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Campaigns
|
||||
@@ -92,8 +92,9 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2 flex-1 min-h-[270px] flex flex-col items-center justify-center">
|
||||
<p className="text-neutral-500">Loading...</p>
|
||||
<div className="space-y-2 flex-1 min-h-[270px] flex flex-col items-center justify-center gap-2">
|
||||
<div className="animate-spin w-6 h-6 border-2 border-neutral-300 dark:border-neutral-700 border-t-brand-orange rounded-full" />
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
|
||||
</div>
|
||||
) : hasData ? (
|
||||
<div className="space-y-2 flex-1 min-h-[270px]">
|
||||
@@ -152,7 +153,10 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
|
||||
>
|
||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
|
||||
{isLoadingFull ? (
|
||||
<div className="py-4 text-center text-neutral-500">Loading...</div>
|
||||
<div className="py-8 flex flex-col items-center gap-2">
|
||||
<div className="animate-spin w-6 h-6 border-2 border-neutral-300 dark:border-neutral-700 border-t-brand-orange rounded-full" />
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-12 gap-2 text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-2 px-2 sticky top-0 bg-white dark:bg-neutral-900 py-2 z-10">
|
||||
|
||||
@@ -290,7 +290,7 @@ export default function Chart({
|
||||
const dayTicks = interval === 'day' && chartData.length > 0 ? chartData.map((c) => c.date) : undefined
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl overflow-hidden shadow-sm">
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden shadow-sm">
|
||||
{/* Stats Header (Interactive Tabs) */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 divide-x divide-neutral-200 dark:divide-neutral-800 border-b border-neutral-200 dark:border-neutral-800">
|
||||
{metrics.map((item) => (
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { formatNumber } from '@/lib/utils/format'
|
||||
import { TopPage, getTopPages, getEntryPages, getExitPages } from '@/lib/api/stats'
|
||||
import { Modal, ArrowUpRightIcon } from '@ciphera-net/ui'
|
||||
import { Modal, ArrowUpRightIcon, LayoutDashboardIcon } from '@ciphera-net/ui'
|
||||
|
||||
interface ContentStatsProps {
|
||||
topPages: TopPage[]
|
||||
@@ -87,7 +87,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6 h-full flex flex-col">
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
@@ -107,7 +107,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors ${
|
||||
activeTab === tab
|
||||
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
|
||||
@@ -149,8 +149,16 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center">
|
||||
<p className="text-neutral-600 dark:text-neutral-400">No data available</p>
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
||||
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
||||
<LayoutDashboardIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
No page data yet
|
||||
</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
||||
Your most visited pages will appear here as traffic arrives.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -163,7 +171,10 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
|
||||
>
|
||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
|
||||
{isLoadingFull ? (
|
||||
<div className="py-4 text-center text-neutral-500">Loading...</div>
|
||||
<div className="py-8 flex flex-col items-center gap-2">
|
||||
<div className="animate-spin w-6 h-6 border-2 border-neutral-300 dark:border-neutral-700 border-t-brand-orange rounded-full" />
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
|
||||
</div>
|
||||
) : (
|
||||
(fullData.length > 0 ? fullData : data).map((page, index) => (
|
||||
<div key={index} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react'
|
||||
import { formatNumber } from '@/lib/utils/format'
|
||||
import * as Flags from 'country-flag-icons/react/3x2'
|
||||
import WorldMap from './WorldMap'
|
||||
import { GlobeIcon } from '@ciphera-net/ui'
|
||||
|
||||
interface LocationProps {
|
||||
countries: Array<{ country: string; pageviews: number }>
|
||||
@@ -36,7 +37,19 @@ export default function Locations({ countries, cities }: LocationProps) {
|
||||
const renderContent = () => {
|
||||
if (activeTab === 'countries') {
|
||||
if (!countries || countries.length === 0) {
|
||||
return <p className="text-neutral-600 dark:text-neutral-400">No data available</p>
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
||||
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
||||
<GlobeIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
No location data yet
|
||||
</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
||||
Visitor locations will appear here based on anonymous geographic data.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -60,7 +73,19 @@ export default function Locations({ countries, cities }: LocationProps) {
|
||||
|
||||
if (activeTab === 'cities') {
|
||||
if (!cities || cities.length === 0) {
|
||||
return <p className="text-neutral-600 dark:text-neutral-400">No data available</p>
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
||||
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
||||
<GlobeIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
No city data yet
|
||||
</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
||||
City-level visitor data will appear as traffic grows.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -81,7 +106,7 @@ export default function Locations({ countries, cities }: LocationProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6 h-full">
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Locations
|
||||
@@ -89,7 +114,7 @@ export default function Locations({ countries, cities }: LocationProps) {
|
||||
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg">
|
||||
<button
|
||||
onClick={() => setActiveTab('countries')}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors ${
|
||||
activeTab === 'countries'
|
||||
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
|
||||
@@ -99,7 +124,7 @@ export default function Locations({ countries, cities }: LocationProps) {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('cities')}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors ${
|
||||
activeTab === 'cities'
|
||||
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function GoalStats({ goalCounts }: GoalStatsProps) {
|
||||
const hasData = list.length > 0
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6 h-full flex flex-col">
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Goals & Events
|
||||
|
||||
@@ -187,7 +187,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6 h-full flex flex-col">
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
@@ -207,7 +207,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors capitalize ${
|
||||
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors capitalize ${
|
||||
activeTab === tab
|
||||
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
|
||||
@@ -226,8 +226,16 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
||||
</div>
|
||||
) : activeTab === 'map' ? (
|
||||
hasData ? <WorldMap data={filterUnknown(countries)} /> : (
|
||||
<div className="h-full flex flex-col items-center justify-center">
|
||||
<p className="text-neutral-600 dark:text-neutral-400">No data available</p>
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
||||
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
||||
<GlobeIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
No location data yet
|
||||
</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
||||
Visitor locations will appear here based on anonymous geographic data.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
@@ -252,14 +260,22 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
||||
))}
|
||||
{Array.from({ length: emptySlots }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center">
|
||||
<p className="text-neutral-600 dark:text-neutral-400">No data available</p>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
||||
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
||||
<GlobeIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
No location data yet
|
||||
</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
||||
Visitor locations will appear here based on anonymous geographic data.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -270,7 +286,10 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
||||
>
|
||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
|
||||
{isLoadingFull ? (
|
||||
<div className="py-4 text-center text-neutral-500">Loading...</div>
|
||||
<div className="py-8 flex flex-col items-center gap-2">
|
||||
<div className="animate-spin w-6 h-6 border-2 border-neutral-300 dark:border-neutral-700 border-t-brand-orange rounded-full" />
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
|
||||
</div>
|
||||
) : (
|
||||
(fullData.length > 0 ? fullData : data as any[]).map((item, index) => (
|
||||
<div key={index} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
||||
|
||||
@@ -108,7 +108,7 @@ export default function PerformanceStats({ stats, performanceByPage, siteId, sta
|
||||
const summaryText = `LCP ${Math.round(stats.lcp)} ms · CLS ${Number(stats.cls.toFixed(3))} · INP ${Math.round(stats.inp)} ms`
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-4">
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-4">
|
||||
{/* * One-line summary: Performance score + metric summary. Click to expand. */}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function RealtimeVisitors({ count, siteId }: RealtimeVisitorsProp
|
||||
return (
|
||||
<div
|
||||
onClick={() => siteId && router.push(`/sites/${siteId}/realtime`)}
|
||||
className={`bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6 ${siteId ? 'cursor-pointer hover:border-neutral-300 dark:hover:border-neutral-700 transition-colors' : ''}`}
|
||||
className={`bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 ${siteId ? 'cursor-pointer hover:border-neutral-300 dark:hover:border-neutral-700 transition-colors' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react'
|
||||
import { formatNumber } from '@/lib/utils/format'
|
||||
import { getBrowserIcon, getOSIcon, getDeviceIcon } from '@/lib/utils/icons'
|
||||
import { MdMonitor } from 'react-icons/md'
|
||||
import { Modal } from '@ciphera-net/ui'
|
||||
import { Modal, GridIcon } from '@ciphera-net/ui'
|
||||
import { getBrowsers, getOS, getDevices, getScreenResolutions } from '@/lib/api/stats'
|
||||
|
||||
interface TechSpecsProps {
|
||||
@@ -110,7 +110,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6 h-full flex flex-col">
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
@@ -130,7 +130,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors capitalize ${
|
||||
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors capitalize ${
|
||||
activeTab === tab
|
||||
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
|
||||
@@ -165,8 +165,16 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center">
|
||||
<p className="text-neutral-600 dark:text-neutral-400">No data available</p>
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
||||
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
||||
<GridIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
No technology data yet
|
||||
</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
||||
Browser, OS, and device information will appear as visitors arrive.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -179,7 +187,10 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
|
||||
>
|
||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
|
||||
{isLoadingFull ? (
|
||||
<div className="py-4 text-center text-neutral-500">Loading...</div>
|
||||
<div className="py-8 flex flex-col items-center gap-2">
|
||||
<div className="animate-spin w-6 h-6 border-2 border-neutral-300 dark:border-neutral-700 border-t-brand-orange rounded-full" />
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
|
||||
</div>
|
||||
) : (
|
||||
(fullData.length > 0 ? fullData : data).map((item, index) => (
|
||||
<div key={index} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { formatNumber } from '@/lib/utils/format'
|
||||
import { LayoutDashboardIcon } from '@ciphera-net/ui'
|
||||
|
||||
interface TopPagesProps {
|
||||
pages: Array<{ path: string; pageviews: number }>
|
||||
@@ -9,17 +10,27 @@ interface TopPagesProps {
|
||||
export default function TopPages({ pages }: TopPagesProps) {
|
||||
if (!pages || pages.length === 0) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6">
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 flex flex-col">
|
||||
<h3 className="text-lg font-semibold mb-4 text-neutral-900 dark:text-white">
|
||||
Top Pages
|
||||
</h3>
|
||||
<p className="text-neutral-600 dark:text-neutral-400">No data available</p>
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
||||
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
||||
<LayoutDashboardIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
No page data yet
|
||||
</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
||||
Your most visited pages will appear here as traffic arrives.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6">
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
|
||||
<h3 className="text-lg font-semibold mb-4 text-neutral-900 dark:text-white">
|
||||
Top Pages
|
||||
</h3>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { formatNumber } from '@/lib/utils/format'
|
||||
import { getReferrerIcon } from '@/lib/utils/icons'
|
||||
import { Modal } from '@ciphera-net/ui'
|
||||
import { Modal, GlobeIcon } from '@ciphera-net/ui'
|
||||
import { getTopReferrers, TopReferrer } from '@/lib/api/stats'
|
||||
|
||||
interface TopReferrersProps {
|
||||
@@ -55,7 +55,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6 h-full flex flex-col">
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Top Referrers
|
||||
@@ -93,8 +93,16 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center">
|
||||
<p className="text-neutral-600 dark:text-neutral-400">No data available</p>
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
||||
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
||||
<GlobeIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||
No referrers yet
|
||||
</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
||||
Traffic sources will appear here when visitors come from external sites.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -107,7 +115,10 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
||||
>
|
||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
|
||||
{isLoadingFull ? (
|
||||
<div className="py-4 text-center text-neutral-500">Loading...</div>
|
||||
<div className="py-8 flex flex-col items-center gap-2">
|
||||
<div className="animate-spin w-6 h-6 border-2 border-neutral-300 dark:border-neutral-700 border-t-brand-orange rounded-full" />
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
|
||||
</div>
|
||||
) : (
|
||||
(fullData.length > 0 ? fullData : filteredReferrers).map((ref, index) => (
|
||||
<div key={index} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
||||
|
||||
@@ -501,7 +501,7 @@ export default function OrganizationSettings() {
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">Irreversible actions for this organization.</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-900/10 rounded-xl flex items-center justify-between">
|
||||
<div className="p-4 border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-900/10 rounded-2xl flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-red-900 dark:text-red-200">Delete Organization</h3>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 mt-1">Permanently delete this organization and all its data.</p>
|
||||
@@ -524,7 +524,7 @@ export default function OrganizationSettings() {
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white mb-1">Organization Members</h2>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-6">Manage who has access to this organization.</p>
|
||||
|
||||
<div className="bg-neutral-50 dark:bg-neutral-900/50 border border-neutral-200 dark:border-neutral-800 rounded-xl p-4">
|
||||
<div className="bg-neutral-50 dark:bg-neutral-900/50 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-4">
|
||||
<h3 className="text-sm font-medium text-neutral-900 dark:text-white mb-3">Invite New Member</h3>
|
||||
<form onSubmit={handleSendInvite} className="flex gap-3 items-end">
|
||||
<div className="flex-1">
|
||||
@@ -569,7 +569,7 @@ export default function OrganizationSettings() {
|
||||
{/* Members List */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-neutral-500 uppercase tracking-wider">Active Members</h3>
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl overflow-hidden divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||
{isLoadingMembers ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="w-6 h-6 border-2 border-brand-orange/30 border-t-brand-orange rounded-full animate-spin" />
|
||||
@@ -613,7 +613,7 @@ export default function OrganizationSettings() {
|
||||
{invitations.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-neutral-500 uppercase tracking-wider">Pending Invitations</h3>
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl overflow-hidden divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||
{invitations.map((invite) => (
|
||||
<div key={invite.id} className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -656,7 +656,7 @@ export default function OrganizationSettings() {
|
||||
<div className="w-6 h-6 border-2 border-brand-orange/30 border-t-brand-orange rounded-full animate-spin" />
|
||||
</div>
|
||||
) : !subscription ? (
|
||||
<div className="p-8 text-center bg-neutral-50 dark:bg-neutral-900/50 rounded-xl border border-neutral-200 dark:border-neutral-800">
|
||||
<div className="p-8 text-center bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-200 dark:border-neutral-800">
|
||||
<p className="text-neutral-500">Could not load subscription details.</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -669,7 +669,7 @@ export default function OrganizationSettings() {
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{/* Current Plan */}
|
||||
<div className="bg-neutral-50 dark:bg-neutral-900/50 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6">
|
||||
<div className="bg-neutral-50 dark:bg-neutral-900/50 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-neutral-500 uppercase tracking-wider mb-1">Current Plan</h3>
|
||||
@@ -747,7 +747,7 @@ export default function OrganizationSettings() {
|
||||
</div>
|
||||
|
||||
{!subscription.has_payment_method && (
|
||||
<div className="p-6 bg-brand-orange/5 border border-brand-orange/20 rounded-xl">
|
||||
<div className="p-6 bg-brand-orange/5 border border-brand-orange/20 rounded-2xl">
|
||||
<h3 className="font-medium text-brand-orange mb-2">Upgrade to Pro</h3>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
Get higher limits, more data retention, and priority support.
|
||||
@@ -761,7 +761,7 @@ export default function OrganizationSettings() {
|
||||
{/* Invoice History */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-neutral-500 uppercase tracking-wider mb-4">Invoice History</h3>
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl overflow-hidden">
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden">
|
||||
{isLoadingInvoices ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="w-6 h-6 border-2 border-brand-orange/30 border-t-brand-orange rounded-full animate-spin" />
|
||||
@@ -837,7 +837,7 @@ export default function OrganizationSettings() {
|
||||
</div>
|
||||
|
||||
{/* Advanced Filters */}
|
||||
<div className="bg-neutral-50 dark:bg-neutral-900/50 border border-neutral-200 dark:border-neutral-800 rounded-xl p-4 mb-6">
|
||||
<div className="bg-neutral-50 dark:bg-neutral-900/50 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-4 mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase">Log ID</label>
|
||||
@@ -897,7 +897,7 @@ export default function OrganizationSettings() {
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl overflow-hidden">
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden">
|
||||
{isLoadingAudit ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-6 h-6 border-2 border-brand-orange/30 border-t-brand-orange rounded-full animate-spin" />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Site } from '@/lib/api/sites'
|
||||
import { BarChartIcon, SettingsIcon, BookOpenIcon, ExternalLinkIcon } from '@ciphera-net/ui'
|
||||
import { BarChartIcon, SettingsIcon, BookOpenIcon, ExternalLinkIcon, Button } from '@ciphera-net/ui'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
|
||||
interface SiteListProps {
|
||||
@@ -18,7 +18,7 @@ export default function SiteList({ sites, loading, onDelete }: SiteListProps) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-48 animate-pulse rounded-xl bg-neutral-100 dark:bg-neutral-800" />
|
||||
<div key={i} className="h-48 animate-pulse rounded-2xl bg-neutral-100 dark:bg-neutral-800" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -38,7 +38,7 @@ export default function SiteList({ sites, loading, onDelete }: SiteListProps) {
|
||||
{sites.map((site) => (
|
||||
<div
|
||||
key={site.id}
|
||||
className="group relative flex flex-col rounded-xl border border-neutral-200 bg-white p-6 shadow-sm transition-all hover:shadow-md dark:border-neutral-800 dark:bg-neutral-900"
|
||||
className="group relative flex flex-col rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm transition-all hover:shadow-md dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
{/* Header: Icon + Name + Live Status */}
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
@@ -94,10 +94,12 @@ export default function SiteList({ sites, loading, onDelete }: SiteListProps) {
|
||||
<div className="mt-auto flex gap-2">
|
||||
<Link
|
||||
href={`/sites/${site.id}`}
|
||||
className="btn-primary flex-1 justify-center text-center text-sm inline-flex items-center gap-2"
|
||||
className="flex-1"
|
||||
>
|
||||
<BarChartIcon className="w-4 h-4" />
|
||||
View Dashboard
|
||||
<Button variant="primary" className="w-full justify-center text-sm">
|
||||
<BarChartIcon className="w-4 h-4" />
|
||||
View Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
{(user?.role === 'owner' || user?.role === 'admin') && (
|
||||
<button
|
||||
@@ -114,7 +116,7 @@ export default function SiteList({ sites, loading, onDelete }: SiteListProps) {
|
||||
))}
|
||||
|
||||
{/* Resources Card */}
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-neutral-300 bg-neutral-50 p-6 text-center dark:border-neutral-700 dark:bg-neutral-900/50">
|
||||
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-neutral-300 bg-neutral-50 p-6 text-center dark:border-neutral-700 dark:bg-neutral-900/50">
|
||||
<div className="mb-3 rounded-full bg-neutral-200 p-3 dark:bg-neutral-800">
|
||||
<BookOpenIcon className="h-6 w-6 text-neutral-500" />
|
||||
</div>
|
||||
|
||||
@@ -196,7 +196,7 @@ export default function UtmBuilder({ initialSiteId }: UtmBuilderProps) {
|
||||
</div>
|
||||
|
||||
{generatedUrl && (
|
||||
<div className="mt-6 p-4 bg-neutral-50 dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 flex items-center justify-between group">
|
||||
<div className="mt-6 p-4 bg-neutral-50 dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 flex items-center justify-between group">
|
||||
<code className="text-sm break-all text-brand-orange font-mono">{generatedUrl}</code>
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* * TODO: Move these shared utilities to @ciphera-net/ui to avoid duplication with website */
|
||||
|
||||
/* * Glass Card Effect - Crucial for the "Premium" feel */
|
||||
.card-glass {
|
||||
@apply bg-white/80 dark:bg-neutral-900/80;
|
||||
|
||||
Reference in New Issue
Block a user