[PULSE-43] Design system standardization and branding alignment #11

Merged
uz1mani merged 16 commits from staging into main 2026-02-05 17:18:07 +00:00
35 changed files with 635 additions and 219 deletions

View File

@@ -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 mattervisitors, pageviews, and sourceswhile 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>

View File

@@ -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>

View File

@@ -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">&rarr;</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>

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>
)
}

View File

@@ -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'}

View File

@@ -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/*&#10;/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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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'

View File

@@ -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>
)
}

View File

@@ -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" />

View File

@@ -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">

View File

@@ -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) => (

View File

@@ -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">

View File

@@ -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'

View File

@@ -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

View File

@@ -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">

View File

@@ -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"

View File

@@ -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">

View File

@@ -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">

View File

@@ -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>

View File

@@ -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">

View File

@@ -490,7 +490,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>
@@ -513,7 +513,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">
@@ -558,7 +558,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="p-8 text-center text-neutral-500">
<div className="animate-spin w-5 h-5 border-2 border-neutral-400 border-t-transparent rounded-full mx-auto mb-2"></div>
@@ -603,7 +603,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">
@@ -647,7 +647,7 @@ export default function OrganizationSettings() {
Loading subscription details...
</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"
@@ -660,7 +660,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>
@@ -738,7 +738,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.
@@ -752,7 +752,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="p-8 text-center text-neutral-500">
<div className="animate-spin w-5 h-5 border-2 border-neutral-400 border-t-transparent rounded-full mx-auto mb-2"></div>
@@ -829,7 +829,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>
@@ -889,7 +889,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="p-12 text-center text-neutral-500">
<div className="animate-spin w-6 h-6 border-2 border-neutral-400 border-t-transparent rounded-full mx-auto mb-3"></div>

View File

@@ -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>

View File

@@ -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"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -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;