Phase 5: Animations & Final Polish

This commit is contained in:
Usman Baig
2026-02-05 17:54:04 +01:00
parent db4d7f6cde
commit b47f3a2986
11 changed files with 152 additions and 49 deletions

View File

@@ -1,5 +1,6 @@
'use client' 'use client'
import { motion } from 'framer-motion'
import { CheckCircleIcon, XIcon } from '@ciphera-net/ui' import { CheckCircleIcon, XIcon } from '@ciphera-net/ui'
function ComparisonTable({ title, competitors }: { title: string, competitors: { name: string, isPulse: boolean, features: Record<string, boolean | string> }[] }) { 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>
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-20 pb-10 z-10"> <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"> <h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white mb-6">
Why Pulse? Why Pulse?
</h1> </h1>
@@ -76,14 +82,19 @@ export default function AboutPage() {
We built Pulse because we were tired of complex, invasive analytics tools. We built Pulse because we were tired of complex, invasive analytics tools.
Here is how we stack up against the giants. Here is how we stack up against the giants.
</p> </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"> <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. 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. Pulse is different. We focus on the metrics that actually mattervisitors, pageviews, and sourceswhile respecting user privacy.
</p> </p>
</div> </motion.div>
{/* * Comparison: Pulse vs Google Analytics */} {/* * Comparison: Pulse vs Google Analytics */}
<ComparisonTable <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> <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"> <p className="text-neutral-600 dark:text-neutral-400 text-sm">
We love Plausible! They paved the way for privacy-friendly analytics. 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 Pulse offers a similar philosophy but with a focus on even deeper integration with the Ciphera ecosystem
and more flexible pricing for developers. and more flexible pricing for developers.
</p> </p>
</div> </motion.div>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import Link from 'next/link' import Link from 'next/link'
import { motion } from 'framer-motion'
import { ArrowRightIcon } from '@ciphera-net/ui' import { ArrowRightIcon } from '@ciphera-net/ui'
const integrations = [ const integrations = [
@@ -62,22 +63,33 @@ export default function IntegrationsPage() {
</div> </div>
<div className="flex-grow w-full max-w-6xl mx-auto px-4 pt-20 pb-10 z-10"> <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"> <h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white mb-6">
Integrations Integrations
</h1> </h1>
<p className="text-xl text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto leading-relaxed"> <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. Connect Pulse with your favorite frameworks and platforms in minutes.
</p> </p>
</div> </motion.div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{integrations.map((integration) => ( {integrations.map((integration, i) => (
<Link <motion.div
key={integration.id} key={integration.id}
href={`/integrations/${integration.id}`} initial={{ opacity: 0, y: 20 }}
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" 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="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"> <div className="p-3 bg-neutral-100 dark:bg-neutral-800 rounded-xl group-hover:scale-110 transition-transform duration-300">
{integration.icon} {integration.icon}
@@ -111,7 +123,7 @@ export default function IntegrationsPage() {
> >
Request Integration Request Integration
</a> </a>
</div> </motion.div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { motion } from 'framer-motion'
import { useAuth } from '@/lib/auth/context' import { useAuth } from '@/lib/auth/context'
import { initiateOAuthFlow, initiateSignupFlow } from '@/lib/api/oauth' import { initiateOAuthFlow, initiateSignupFlow } from '@/lib/api/oauth'
import { listSites, deleteSite, type Site } from '@/lib/api/sites' 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"> <div className="flex-grow w-full max-w-6xl mx-auto px-4 pt-20 pb-10 z-10">
{/* * --- 2. BADGE --- */} {/* * --- 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="badge-primary">
<span className="w-1.5 h-1.5 rounded-full bg-brand-orange animate-pulse" /> <span className="w-1.5 h-1.5 rounded-full bg-brand-orange animate-pulse" />
Privacy-First Analytics Privacy-First Analytics
</span> </span>
</div> </motion.div>
{/* * --- 3. HEADLINE --- */} {/* * --- 3. HEADLINE --- */}
<div className="text-center mb-20"> <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 /> Simple analytics for <br />
<span className="relative inline-block"> <span className="relative inline-block">
<span className="gradient-text">privacy-conscious</span> <span className="gradient-text">privacy-conscious</span>
@@ -191,22 +202,32 @@ export default function HomePage() {
</svg> </svg>
</span> </span>
{' '}apps. {' '}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. Respect your users' privacy while getting the insights you need.
No cookies, no IP tracking, fully GDPR compliant. No cookies, no IP tracking, fully GDPR compliant.
</p> </motion.p>
{/* * --- 4. CTAs --- */} {/* * --- 4. CTAs --- */}
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center mb-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"> <Button onClick={() => initiateOAuthFlow()} variant="primary" className="px-8 py-4 text-lg shadow-lg shadow-brand-orange/20">
Get Started Get Started
</Button> </Button>
<Button onClick={() => initiateSignupFlow()} variant="secondary" className="px-8 py-4 text-lg"> <Button onClick={() => initiateSignupFlow()} variant="secondary" className="px-8 py-4 text-lg">
Create Account Create Account
</Button> </Button>
</div> </motion.div>
</div> </div>
{/* * NEW: DASHBOARD PREVIEW */} {/* * 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: 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." } { 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) => ( ].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"> <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" /> <feature.icon className="w-6 h-6" />
</div> </div>
@@ -227,7 +255,7 @@ export default function HomePage() {
<p className="text-neutral-600 dark:text-neutral-400 leading-relaxed"> <p className="text-neutral-600 dark:text-neutral-400 leading-relaxed">
{feature.desc} {feature.desc}
</p> </p>
</div> </motion.div>
))} ))}
</div> </div>
@@ -235,13 +263,19 @@ export default function HomePage() {
<ComparisonSection /> <ComparisonSection />
{/* * NEW: CTA BOTTOM */} {/* * 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> <h2 className="text-3xl font-bold text-neutral-900 dark:text-white mb-6">Ready to switch?</h2>
<Button onClick={() => initiateOAuthFlow()} variant="primary" className="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 Start your free trial
</Button> </Button>
<p className="mt-4 text-sm text-neutral-500">No credit card required • Cancel anytime</p> <p className="mt-4 text-sm text-neutral-500">No credit card required • Cancel anytime</p>
</div> </motion.div>
</div> </div>
</div> </div>

View File

@@ -3,6 +3,7 @@
import { useAuth } from '@/lib/auth/context' import { useAuth } from '@/lib/auth/context'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import { motion } from 'framer-motion'
import { getSite, type Site } from '@/lib/api/sites' 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 { 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 { formatNumber, formatDuration, getDateRange } from '@/lib/utils/format'
@@ -225,7 +226,12 @@ export default function SiteDashboardPage() {
} }
return ( 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="mb-8">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -417,6 +423,6 @@ export default function SiteDashboardPage() {
topPages={topPages} topPages={topPages}
topReferrers={topReferrers} topReferrers={topReferrers}
/> />
</div> </motion.div>
) )
} }

View File

@@ -7,6 +7,7 @@ import { getRealtimeVisitors, getSessionDetails, type Visitor, type SessionEvent
import { toast } from '@ciphera-net/ui' import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { getAuthErrorMessage } from '@/lib/utils/authErrors'
import { LoadingOverlay, UserIcon } from '@ciphera-net/ui' import { LoadingOverlay, UserIcon } from '@ciphera-net/ui'
import { motion, AnimatePresence } from 'framer-motion'
function formatTimeAgo(dateString: string) { function formatTimeAgo(dateString: string) {
const date = new Date(dateString) const date = new Date(dateString)
@@ -135,14 +136,19 @@ export default function RealtimePage() {
</div> </div>
) : ( ) : (
<div className="divide-y divide-neutral-100 dark:divide-neutral-800"> <div className="divide-y divide-neutral-100 dark:divide-neutral-800">
{visitors.map((visitor) => ( <AnimatePresence mode="popLayout">
<button {visitors.map((visitor) => (
key={visitor.session_id} <motion.button
onClick={() => handleSelectVisitor(visitor)} key={visitor.session_id}
className={`w-full text-left p-4 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors ${ initial={{ opacity: 0, x: -10 }}
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' : '' 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="flex justify-between items-start mb-1">
<div className="font-medium text-neutral-900 dark:text-white truncate pr-2"> <div className="font-medium text-neutral-900 dark:text-white truncate pr-2">
{visitor.country ? `${getFlagEmoji(visitor.country)} ${visitor.city || 'Unknown City'}` : 'Unknown Location'} {visitor.country ? `${getFlagEmoji(visitor.country)} ${visitor.city || 'Unknown City'}` : 'Unknown Location'}
@@ -164,8 +170,9 @@ export default function RealtimePage() {
{visitor.pageviews} views {visitor.pageviews} views
</span> </span>
</div> </div>
</button> </motion.button>
))} ))}
</AnimatePresence>
</div> </div>
)} )}
</div> </div>

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useSearchParams } from 'next/navigation' import { useSearchParams } from 'next/navigation'
import { motion } from 'framer-motion'
import { Button, CheckCircleIcon } from '@ciphera-net/ui' import { Button, CheckCircleIcon } from '@ciphera-net/ui'
import { useAuth } from '@/lib/auth/context' import { useAuth } from '@/lib/auth/context'
import { initiateOAuthFlow } from '@/lib/api/oauth' import { initiateOAuthFlow } from '@/lib/api/oauth'
@@ -212,17 +213,27 @@ export default function PricingSection() {
return ( return (
<section className="py-24 px-4 max-w-6xl mx-auto"> <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"> <h2 className="text-4xl md:text-5xl font-bold text-neutral-900 dark:text-white mb-6 tracking-tight">
Transparent Pricing Transparent Pricing
</h2> </h2>
<p className="text-xl text-neutral-600 dark:text-neutral-400"> <p className="text-xl text-neutral-600 dark:text-neutral-400">
Scale with your traffic. No hidden fees. Scale with your traffic. No hidden fees.
</p> </p>
</div> </motion.div>
{/* Unified Container */} {/* 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 */} {/* 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"> <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">
@@ -380,7 +391,7 @@ export default function PricingSection() {
</ul> </ul>
</div> </div>
</div> </div>
</div> </motion.div>
</section> </section>
) )
} }

View File

@@ -92,8 +92,9 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
</div> </div>
{isLoading ? ( {isLoading ? (
<div className="space-y-2 flex-1 min-h-[270px] flex flex-col items-center justify-center"> <div className="space-y-2 flex-1 min-h-[270px] flex flex-col items-center justify-center gap-2">
<p className="text-neutral-500">Loading...</p> <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>
) : hasData ? ( ) : hasData ? (
<div className="space-y-2 flex-1 min-h-[270px]"> <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"> <div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
{isLoadingFull ? ( {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"> <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

@@ -171,7 +171,10 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
> >
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2"> <div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
{isLoadingFull ? ( {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) => ( (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"> <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

@@ -286,7 +286,10 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
> >
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2"> <div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
{isLoadingFull ? ( {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) => ( (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"> <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

@@ -187,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"> <div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
{isLoadingFull ? ( {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) => ( (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"> <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

@@ -115,7 +115,10 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
> >
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2"> <div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
{isLoadingFull ? ( {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) => ( (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"> <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">