diff --git a/.npmrc b/.npmrc index 8477dbe..83aab43 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,3 @@ @ciphera-net:registry=https://npm.pkg.github.com //npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN} +legacy-peer-deps=true diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a68ccb..5a0cc3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Improved +- **Redesigned Search card on the dashboard.** The Search section of the dashboard has been completely refreshed to match the rest of Pulse. Search queries now show proportional bars so you can visually compare which queries get the most impressions. Hovering a row reveals the impression share percentage. Position badges are now color-coded — green for page 1 rankings, orange for page 2, and red for queries buried beyond page 5. You can switch between your top search queries and top pages using tabs, and expand the full list in a searchable popup without leaving the dashboard. - **Smaller, faster tracking script.** The tracking script is now about 20% smaller. Logic like page path cleaning, referrer filtering, error page detection, and input validation has been moved from your browser to the Pulse server. This means the script loads faster on every page, and Pulse can improve these features without needing you to update anything. - **Automatic 404 page detection.** Pulse now detects error pages (404 / "Page Not Found") automatically on the server by reading your page title — no extra setup needed. Previously this ran in the browser and couldn't be improved without updating the script. Now Pulse can recognize more error page patterns over time, including pages in other languages, without any changes on your end. - **Smarter bot filtering.** Pulse now catches more types of automated traffic that were slipping through — like headless browsers with default screen sizes, bot farms that rotate through different locations, and bots that fire duplicate events within milliseconds. Bot detection checks have also been moved from the tracking script to the server, making the script smaller and faster for real visitors. diff --git a/app/about/page.tsx b/app/about/page.tsx index f37e4af..6ffa4bd 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -15,23 +15,23 @@ function ComparisonTable({ title, competitors }: { title: string, competitors: { return (
-

{title}

-
+

{title}

+
- + {competitors.map((comp) => ( - ))} - + {allFeatures.map((feature) => ( - - + + {competitors.map((comp) => { const val = comp.features[feature] return ( @@ -41,7 +41,7 @@ function ComparisonTable({ title, competitors }: { title: string, competitors: { ) : val === false ? ( ) : ( - {val} + {val} )} ) @@ -60,10 +60,9 @@ export default function AboutPage() {
{/* * --- ATMOSPHERE (Background) --- */}
-
-
+
@@ -75,10 +74,10 @@ export default function AboutPage() { transition={{ duration: 0.5 }} className="text-center mb-16" > -

+

Why Pulse?

-

+

We built Pulse because we were tired of complex, invasive analytics tools. Here is how we stack up against the giants.

@@ -88,9 +87,9 @@ export default function AboutPage() { 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" + className="prose prose-invert max-w-none mb-16" > -

+

Most analytics tools are overkill. They track everything, slow down your site, and require annoying cookie banners. Pulse is different. We focus on the metrics that actually matter—visitors, pageviews, and sources—while respecting user privacy.

@@ -163,10 +162,10 @@ export default function AboutPage() { 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" + className="mt-8 p-6 bg-neutral-800/50 rounded-xl border border-neutral-800" > -

What about Plausible?

-

+

What about Plausible?

+

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. diff --git a/app/faq/page.tsx b/app/faq/page.tsx index f1bcdca..8917312 100644 --- a/app/faq/page.tsx +++ b/app/faq/page.tsx @@ -1,37 +1,18 @@ 'use client' import { motion } from 'framer-motion' -import { useState } from 'react' -import { ChevronDownIcon } from '@ciphera-net/ui' - -const faqs = [ - { - question: "Is Pulse GDPR compliant?", - answer: "Yes, Pulse is GDPR compliant by design. We don't use cookies, don't collect personal data, and process all data anonymously." - }, - { - question: "Do I need a cookie consent banner?", - answer: "No, you don't need a cookie consent banner. Pulse doesn't use cookies, so it's exempt from cookie consent requirements under GDPR." - }, - { - question: "How does Pulse track visitors?", - answer: "We use a lightweight JavaScript snippet that sends anonymous pageview events. No cookies, no cross-session identifiers (we use sessionStorage only to group events within a single visit), and no cross-site tracking." - }, - { - question: "What data does Pulse collect?", - answer: "We collect anonymous pageview data including page path, referrer, device type, browser, and country (derived from IP at request time; the IP itself is not stored). No personal information is collected." - }, - { - question: "How accurate is the data?", - answer: "Our data is highly accurate. We exclude bot traffic and data center visits. Since we don't use cookies, we count unique sessions rather than unique users." - }, - { - question: "Can I export my data?", - answer: "Yes, you can access all your analytics data through the dashboard. We're working on export functionality for bulk data downloads." - } -] +import PulseFAQ from '@/components/marketing/PulseFAQ' // * JSON-LD FAQ Schema for rich snippets +const faqs = [ + { question: "Is Pulse GDPR compliant?", answer: "Yes, Pulse is GDPR compliant by design. We don't use cookies, don't collect personal data, and process all data anonymously." }, + { question: "Do I need a cookie consent banner?", answer: "No, you don't need a cookie consent banner. Pulse doesn't use cookies, so it's exempt from cookie consent requirements under GDPR." }, + { question: "How does Pulse track visitors?", answer: "We use a lightweight JavaScript snippet that sends anonymous pageview events. No cookies, no cross-session identifiers (we use sessionStorage only to group events within a single visit), and no cross-site tracking." }, + { question: "What data does Pulse collect?", answer: "We collect anonymous pageview data including page path, referrer, device type, browser, and country (derived from IP at request time; the IP itself is not stored). No personal information is collected." }, + { question: "How accurate is the data?", answer: "Our data is highly accurate. We exclude bot traffic and data center visits. Since we don't use cookies, we count unique sessions rather than unique users." }, + { question: "Can I export my data?", answer: "Yes, you can access all your analytics data through the dashboard. We're working on export functionality for bulk data downloads." }, +] + const faqSchema = { '@context': 'https://schema.org', '@type': 'FAQPage', @@ -45,47 +26,6 @@ const faqSchema = { })), } -function FAQItem({ faq, index }: { faq: typeof faqs[0]; index: number }) { - const [isOpen, setIsOpen] = useState(false) - - return ( - - - {isOpen && ( - -

- {faq.answer} -

- - )} - - ) -} - export default function FAQPage() { return ( <> @@ -94,29 +34,9 @@ export default function FAQPage() { type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }} /> - -
- - FAQ -

- Frequently asked questions -

-

- Learn more about how Pulse respects your privacy and handles your data. -

-
-
- {faqs.map((faq, index) => ( - - ))} -
+
+ {/* * CTA */} -

+

Still have questions?

Contact us diff --git a/app/features/page.tsx b/app/features/page.tsx index 8431d58..39fd2db 100644 --- a/app/features/page.tsx +++ b/app/features/page.tsx @@ -109,10 +109,9 @@ export default function FeaturesPage() {
{/* * --- ATMOSPHERE (Background) --- */}
-
-
+
@@ -129,11 +128,11 @@ export default function FeaturesPage() { Product Tour -

+

Everything you need.
Nothing you don't.

-

+

Pulse gives you meaningful analytics without the complexity, the cookies, or the privacy trade-offs.

@@ -152,10 +151,10 @@ export default function FeaturesPage() {
-

+

{feature.title}

-

+

{feature.description}

@@ -171,10 +170,10 @@ export default function FeaturesPage() { className="mb-28" >
-

+

Powerful analytics, simplified

-

+

Everything from real-time dashboards to conversion funnels — without the bloat.

@@ -193,10 +192,10 @@ export default function FeaturesPage() { {cap.icon}
-

+

{cap.title}

-

+

{cap.description}

@@ -211,14 +210,14 @@ export default function FeaturesPage() { whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ duration: 0.5 }} - className="mb-28 p-10 md:p-14 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm border border-neutral-200 dark:border-neutral-800 rounded-2xl" + className="mb-28 p-10 md:p-14 bg-neutral-900/50 backdrop-blur-sm border border-neutral-800 rounded-2xl" >
-

+

Content that performs

-

+

See which pages drive the most traffic, where visitors enter your site, and where they leave. Use data to double down on what works.

Feature + {comp.name}
{feature}
{feature}
- - - - - - - - - {[ - { feature: "Cookie Banner Required", pulse: false, ga: true }, - { feature: "GDPR Compliant", pulse: true, ga: "Complex" }, - { feature: "Script Size", pulse: "< 1 KB", ga: "45 KB+" }, - { feature: "Data Ownership", pulse: "Yours", ga: "Google's" }, - ].map((row) => ( - - - - - - ))} - -
FeaturePulseGoogle Analytics
{row.feature} - {row.pulse === true ? ( - - ) : row.pulse === false ? ( - No - ) : ( - {row.pulse} - )} - - {row.ga === true ? ( - Yes - ) : ( - {row.ga} - )} -
-
-
- ) -} - - type SiteStatsMap = Record export default function HomePage() { @@ -234,130 +150,81 @@ export default function HomePage() { if (!user) { return ( -
- - {/* * --- 1. ATMOSPHERE (Background) --- */} -
- {/* * Top-left Orange Glow */} -
- {/* * Bottom-right Neutral Glow */} -
- {/* * Grid Pattern with Radial Mask */} -
-
- -
- - {/* * --- 2. BADGE --- */} - - - - Privacy-First Analytics - - - - {/* * --- 3. HEADLINE --- */} -
+ <> + {/* HERO — compact headline + live demo */} +
+
- Simple analytics for
+ Analytics without the{' '} - privacy-conscious - {/* * SVG Underline from Main Site */} + surveillance. - {' '}apps.
- 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. - {/* * --- 4. CTAs --- */} + + + + + - - + Cookie-free + | + Open source client + | + GDPR compliant + | + Under 2KB
- {/* * NEW: DASHBOARD PREVIEW */} - - - {/* * --- 5. GLASS CARDS --- */} -
- {[ - { icon: LockIcon, title: "Privacy First", desc: "We don't track personal data. No IP addresses, no fingerprints, no cookies." }, - { 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) => ( - -
- -
-

{feature.title}

-

- {feature.desc} -

-
- ))} -
- - {/* * NEW: COMPARISON SECTION */} - - - {/* * NEW: CTA BOTTOM */} + {/* Live Dashboard Demo */} -

Ready to switch?

- -

No credit card required • Cancel anytime

+
-
-
+ + + + + + ) } @@ -369,8 +236,8 @@ export default function HomePage() { return (
{showFinishSetupBanner && ( -
-

+

+

Finish setting up your workspace and add your first site.

@@ -385,7 +252,7 @@ export default function HomePage() { if (typeof window !== 'undefined') localStorage.setItem('pulse_welcome_completed', 'true') setShowFinishSetupBanner(false) }} - className="text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-400 p-1 rounded" + className="text-neutral-500 hover:text-neutral-400 p-1 rounded" aria-label="Dismiss" > @@ -396,8 +263,8 @@ export default function HomePage() {
-

Your Sites

-

Manage your analytics sites and view insights.

+

Your Sites

+

Manage your analytics sites and view insights.

{(() => { const siteLimit = getSitesLimitForPlan(subscription?.plan_id) @@ -405,7 +272,7 @@ export default function HomePage() { return atLimit ? (
- + Limit reached ({sites.length}/{siteLimit}) @@ -415,7 +282,7 @@ export default function HomePage() {
{deletedSites.length > 0 && ( -

+

You have a site pending deletion. Restore it or permanently delete it to free the slot.

)} @@ -432,26 +299,26 @@ export default function HomePage() { {/* * Global Overview - min-h ensures no layout shift when Plan & usage loads */}
-
-

Total Sites

-

{sites.length}

+
+

Total Sites

+

{sites.length}

-
-

Total Visitors (24h)

-

+

+

Total Visitors (24h)

+

{sites.length === 0 || Object.keys(siteStats).length < sites.length ? '--' : Object.values(siteStats).reduce((sum, { stats }) => sum + (stats?.visitors ?? 0), 0).toLocaleString()}

-
+

Plan & usage

{subscriptionLoading ? (
-
-
-
-
+
+
+
+
) : subscription ? ( <> @@ -468,7 +335,7 @@ export default function HomePage() { })()}

{(typeof subscription.sites_count === 'number' || (subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number') || (subscription.next_invoice_amount_due != null && subscription.next_invoice_currency && !subscription.cancel_at_period_end && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing'))) && ( -

+

{typeof subscription.sites_count === 'number' && ( Sites: {(() => { const limit = getSitesLimitForPlan(subscription.plan_id) @@ -516,12 +383,12 @@ export default function HomePage() {

{!sitesLoading && sites.length === 0 && ( -
+
-

Add your first site

-

+

Add your first site

+

Connect a domain to start collecting privacy-friendly analytics. You can add more sites later from the dashboard.

@@ -557,31 +424,31 @@ export default function HomePage() { {deletedSites.length > 0 && (
-

Scheduled for Deletion

+

Scheduled for Deletion

{deletedSites.map((site) => { const purgeAt = site.deleted_at ? new Date(new Date(site.deleted_at).getTime() + 7 * 24 * 60 * 60 * 1000) : null const daysLeft = purgeAt ? Math.max(0, Math.ceil((purgeAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24))) : 0 return ( -
+
- {site.name} + {site.name} {site.domain} - + Deleting in {daysLeft} day{daysLeft !== 1 ? 's' : ''}
diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx index f31aa18..5ff3cbe 100644 --- a/app/pricing/page.tsx +++ b/app/pricing/page.tsx @@ -19,8 +19,8 @@ export default function PricingPage() {
-
-
+
+
diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx index 125e030..780c17f 100644 --- a/app/share/[id]/page.tsx +++ b/app/share/[id]/page.tsx @@ -1,9 +1,9 @@ 'use client' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import Image from 'next/image' import { useParams, useSearchParams, useRouter } from 'next/navigation' -import { getPublicDashboard, getPublicStats, getPublicDailyStats, getPublicRealtime, type DashboardData, type Stats, type DailyStat } from '@/lib/api/stats' +import { getPublicDashboard, getPublicStats, getPublicDailyStats, getPublicRealtime, authenticatePublicDashboard, type DashboardData, type Stats, type DailyStat } from '@/lib/api/stats' import { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui' import { ApiError } from '@/lib/api/client' @@ -40,7 +40,9 @@ export default function PublicDashboardPage() { const [data, setData] = useState(null) const [password, setPassword] = useState(passwordParam || '') const [isPasswordProtected, setIsPasswordProtected] = useState(false) - + const [isAuthenticated, setIsAuthenticated] = useState(false) + const [authLoading, setAuthLoading] = useState(false) + // Captcha State const [captchaId, setCaptchaId] = useState('') const [captchaSolution, setCaptchaSolution] = useState('') @@ -91,81 +93,42 @@ export default function PublicDashboardPage() { const loadRealtime = useCallback(async () => { try { - const auth = { - password, - captcha: { - captcha_id: captchaId, - captcha_solution: captchaSolution, - captcha_token: captchaToken - } - } - const realtimeData = await getPublicRealtime(siteId, auth) + const realtimeData = await getPublicRealtime(siteId) if (data) { - setData({ - ...data, - realtime_visitors: realtimeData.visitors - }) + setData({ ...data, realtime_visitors: realtimeData.visitors }) } - } catch (error) { + } catch { // Silently fail for realtime updates } - }, [siteId, password, captchaId, captchaSolution, captchaToken, data]) + }, [siteId, data]) const loadDashboard = useCallback(async (silent = false) => { try { if (!silent) setLoading(true) - + const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval - const auth = { - password, - captcha: { - captcha_id: captchaId, - captcha_solution: captchaSolution, - captcha_token: captchaToken - } - } const [dashboardData, prevStatsData, prevDailyStatsData] = await Promise.all([ - getPublicDashboard( - siteId, - dateRange.start, - dateRange.end, - 10, - interval, - password, - auth.captcha - ), + getPublicDashboard(siteId, dateRange.start, dateRange.end, 10, interval), (async () => { const prevRange = getPreviousDateRange(dateRange.start, dateRange.end) - return getPublicStats(siteId, prevRange.start, prevRange.end, auth) + return getPublicStats(siteId, prevRange.start, prevRange.end) })(), (async () => { const prevRange = getPreviousDateRange(dateRange.start, dateRange.end) - return getPublicDailyStats(siteId, prevRange.start, prevRange.end, interval, auth) + return getPublicDailyStats(siteId, prevRange.start, prevRange.end, interval) })() ]) - + setData(dashboardData) setPrevStats(prevStatsData) setPrevDailyStats(prevDailyStatsData) setLastUpdatedAt(Date.now()) - setIsPasswordProtected(false) - // Reset captcha - setCaptchaId('') - setCaptchaSolution('') - setCaptchaToken('') } catch (error: unknown) { const apiErr = error instanceof ApiError ? error : null if (apiErr?.status === 401 && (apiErr.data as Record)?.is_protected) { setIsPasswordProtected(true) - if (password) { - toast.error('Invalid password or captcha') - // Reset captcha on failure - setCaptchaId('') - setCaptchaSolution('') - setCaptchaToken('') - } } else if (apiErr?.status === 404) { toast.error('Site not found') } else if (!silent) { @@ -174,7 +137,7 @@ export default function PublicDashboardPage() { } finally { if (!silent) setLoading(false) } - }, [siteId, dateRange, todayInterval, multiDayInterval, password, captchaId, captchaSolution, captchaToken]) + }, [siteId, dateRange, todayInterval, multiDayInterval]) // * Auto-refresh interval: chart, KPIs, and realtime count update every 30 seconds useEffect(() => { @@ -185,15 +148,36 @@ export default function PublicDashboardPage() { }, 30000) return () => clearInterval(interval) } - }, [data, isPasswordProtected, dateRange, todayInterval, multiDayInterval, password, loadDashboard, loadRealtime]) + }, [data, isPasswordProtected, dateRange, todayInterval, multiDayInterval, loadDashboard, loadRealtime]) useEffect(() => { loadDashboard() }, [siteId, dateRange, todayInterval, multiDayInterval, loadDashboard]) - const handlePasswordSubmit = (e: React.FormEvent) => { + const handlePasswordSubmit = async (e: React.FormEvent) => { e.preventDefault() - loadDashboard() + setAuthLoading(true) + try { + await authenticatePublicDashboard(siteId, password, captchaToken, captchaId, captchaSolution) + // Cookie is now set — load dashboard (cookie sent automatically) + setIsAuthenticated(true) + await loadDashboard() + } catch (error: unknown) { + const apiErr = error instanceof ApiError ? error : null + if (apiErr?.status === 401) { + const errData = apiErr.data as Record | undefined + const errMsg = errData?.error as string | undefined + toast.error(errMsg || 'Invalid password or captcha') + } else { + toast.error('Authentication failed') + } + // Reset captcha on failure + setCaptchaId('') + setCaptchaSolution('') + setCaptchaToken('') + } finally { + setAuthLoading(false) + } } const showSkeleton = useMinimumLoading(loading && !data && !isPasswordProtected) diff --git a/app/sites/[id]/funnels/[funnelId]/page.tsx b/app/sites/[id]/funnels/[funnelId]/page.tsx index 9fd7a5c..d8fe3c8 100644 --- a/app/sites/[id]/funnels/[funnelId]/page.tsx +++ b/app/sites/[id]/funnels/[funnelId]/page.tsx @@ -216,16 +216,19 @@ export default function FunnelReportPage() {
{/* Chart */} -
-

+
+

Funnel Visualization

diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx index f3d5f85..6e1520f 100644 --- a/app/sites/[id]/settings/page.tsx +++ b/app/sites/[id]/settings/page.tsx @@ -4,11 +4,12 @@ import { useEffect, useState, useRef } from 'react' import { useParams, useRouter, useSearchParams } from 'next/navigation' import { updateSite, resetSiteData, type Site, type GeoDataLevel } from '@/lib/api/sites' import { createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals' -import { createReportSchedule, updateReportSchedule, deleteReportSchedule, testReportSchedule, type ReportSchedule, type CreateReportScheduleRequest, type EmailConfig, type WebhookConfig } from '@/lib/api/report-schedules' +import { createReportSchedule, updateReportSchedule, deleteReportSchedule, testReportSchedule, listAlertSchedules, type ReportSchedule, type CreateReportScheduleRequest, type EmailConfig, type WebhookConfig } from '@/lib/api/report-schedules' +import { botFilterSessions, botUnfilterSessions } from '@/lib/api/bot-filter' import { getGSCAuthURL, disconnectGSC } from '@/lib/api/gsc' import { getBunnyPullZones, connectBunny, disconnectBunny } from '@/lib/api/bunny' import type { BunnyPullZone } from '@/lib/api/bunny' -import { toast } from '@ciphera-net/ui' +import { toast, getDateRange } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui' import { formatDateTime } from '@/lib/utils/formatDate' import { SettingsFormSkeleton, GoalsListSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' @@ -20,7 +21,7 @@ import { Select, Modal, Button } from '@ciphera-net/ui' import { APP_URL } from '@/lib/api/client' import { generatePrivacySnippet } from '@/lib/utils/privacySnippet' import { useUnsavedChanges } from '@/lib/hooks/useUnsavedChanges' -import { useSite, useGoals, useReportSchedules, useSubscription, useGSCStatus, useBunnyStatus } from '@/lib/swr/dashboard' +import { useSite, useGoals, useReportSchedules, useAlertSchedules, useSubscription, useGSCStatus, useBunnyStatus, useSessions, useBotFilterStats } from '@/lib/swr/dashboard' import { getRetentionOptionsForPlan, formatRetentionMonths } from '@/lib/plans' import { motion, AnimatePresence } from 'framer-motion' import { useAuth } from '@/lib/auth/context' @@ -31,7 +32,33 @@ import { AlertTriangleIcon, ZapIcon, } from '@ciphera-net/ui' -import { PaperPlaneTilt, Envelope, WebhooksLogo, SpinnerGap, Trash, PencilSimple, Play, Plugs, ShieldCheck } from '@phosphor-icons/react' +import { PaperPlaneTilt, Envelope, WebhooksLogo, SpinnerGap, Trash, PencilSimple, Play, Plugs, ShieldCheck, Bug, BellSimple } from '@phosphor-icons/react' +import { SiDiscord } from '@icons-pack/react-simple-icons' + +function SlackIcon({ size = 16 }: { size?: number }) { + return ( + + + + + + + ) +} + +const CHANNEL_ICONS: Record = { + email: , + slack: , + discord: , + webhook: , +} + +const CHANNEL_ICONS_LG: Record = { + email: , + slack: , + discord: , + webhook: , +} const TIMEZONES = [ 'UTC', @@ -61,7 +88,7 @@ export default function SiteSettingsPage() { const { data: site, isLoading: siteLoading, mutate: mutateSite } = useSite(siteId) const [saving, setSaving] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false) - const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data' | 'goals' | 'reports' | 'integrations'>('general') + const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data' | 'bot' | 'goals' | 'notifications' | 'integrations'>('general') const searchParams = useSearchParams() const [formData, setFormData] = useState({ @@ -97,6 +124,8 @@ export default function SiteSettingsPage() { // Report schedules const { data: reportSchedules = [], isLoading: reportLoading, mutate: mutateReportSchedules } = useReportSchedules(siteId) + // Alert schedules (uptime alerts) + const { data: alertSchedules = [], isLoading: alertLoading, mutate: mutateAlertSchedules } = useAlertSchedules(siteId) const { data: gscStatus, mutate: mutateGSCStatus } = useGSCStatus(siteId) const [gscConnecting, setGscConnecting] = useState(false) const [gscDisconnecting, setGscDisconnecting] = useState(false) @@ -122,6 +151,49 @@ export default function SiteSettingsPage() { sendDay: 1, }) + // Alert channel state + const [alertModalOpen, setAlertModalOpen] = useState(false) + const [editingAlert, setEditingAlert] = useState(null) + const [alertSaving, setAlertSaving] = useState(false) + const [alertTesting, setAlertTesting] = useState(null) + const [alertForm, setAlertForm] = useState({ + channel: 'email' as string, + recipients: '', + webhookUrl: '', + }) + + // Bot & Spam tab state + const [botDateRange, setBotDateRange] = useState(() => getDateRange(7)) + const [suspiciousOnly, setSuspiciousOnly] = useState(true) + const [selectedSessions, setSelectedSessions] = useState>(new Set()) + const [botView, setBotView] = useState<'review' | 'blocked'>('review') + const { data: sessions, mutate: mutateSessions } = useSessions(siteId, botDateRange.start, botDateRange.end, botView === 'review' ? suspiciousOnly : false) + const { data: botStats, mutate: mutateBotStats } = useBotFilterStats(siteId) + + const handleBotFilter = async (sessionIds: string[]) => { + try { + await botFilterSessions(siteId, sessionIds) + toast.success(`${sessionIds.length} session(s) flagged as bot`) + setSelectedSessions(new Set()) + mutateSessions() + mutateBotStats() + } catch { + toast.error('Failed to flag sessions') + } + } + + const handleBotUnfilter = async (sessionIds: string[]) => { + try { + await botUnfilterSessions(siteId, sessionIds) + toast.success(`${sessionIds.length} session(s) unblocked`) + setSelectedSessions(new Set()) + mutateSessions() + mutateBotStats() + } catch { + toast.error('Failed to unblock sessions') + } + } + useEffect(() => { if (!site) return setFormData({ @@ -278,6 +350,103 @@ export default function SiteSettingsPage() { } } + // Alert channel handlers + const resetAlertForm = () => { + setAlertForm({ + channel: 'email', + recipients: '', + webhookUrl: '', + }) + } + + const openEditAlert = (schedule: ReportSchedule) => { + setEditingAlert(schedule) + const isEmail = schedule.channel === 'email' + setAlertForm({ + channel: schedule.channel, + recipients: isEmail ? (schedule.channel_config as EmailConfig).recipients.join(', ') : '', + webhookUrl: !isEmail ? (schedule.channel_config as WebhookConfig).url : '', + }) + setAlertModalOpen(true) + } + + const handleAlertSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + let channelConfig: EmailConfig | WebhookConfig + if (alertForm.channel === 'email') { + const recipients = alertForm.recipients.split(',').map(r => r.trim()).filter(r => r.length > 0) + if (recipients.length === 0) { + toast.error('At least one recipient email is required') + return + } + channelConfig = { recipients } + } else { + if (!alertForm.webhookUrl.trim()) { + toast.error('Webhook URL is required') + return + } + channelConfig = { url: alertForm.webhookUrl.trim() } + } + + const payload: CreateReportScheduleRequest = { + channel: alertForm.channel, + channel_config: channelConfig, + frequency: 'daily', + purpose: 'alert', + } + + setAlertSaving(true) + try { + if (editingAlert) { + await updateReportSchedule(siteId, editingAlert.id, { ...payload, purpose: 'alert' }) + toast.success('Alert channel updated') + } else { + await createReportSchedule(siteId, payload) + toast.success('Alert channel created') + } + setAlertModalOpen(false) + mutateAlertSchedules() + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to save alert channel') + } finally { + setAlertSaving(false) + } + } + + const handleAlertDelete = async (schedule: ReportSchedule) => { + if (!confirm('Delete this alert channel?')) return + try { + await deleteReportSchedule(siteId, schedule.id) + toast.success('Alert channel deleted') + mutateAlertSchedules() + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to delete alert channel') + } + } + + const handleAlertToggle = async (schedule: ReportSchedule) => { + try { + await updateReportSchedule(siteId, schedule.id, { enabled: !schedule.enabled }) + toast.success(schedule.enabled ? 'Alert paused' : 'Alert enabled') + mutateAlertSchedules() + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to update alert channel') + } + } + + const handleAlertTest = async (schedule: ReportSchedule) => { + setAlertTesting(schedule.id) + try { + await testReportSchedule(siteId, schedule.id) + toast.success('Test alert sent successfully') + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to send test alert') + } finally { + setAlertTesting(null) + } + } + const getChannelLabel = (channel: string) => { switch (channel) { case 'email': return 'Email' @@ -615,6 +784,19 @@ export default function SiteSettingsPage() { Data & Privacy + + )} + {!site?.has_password && ( +

+ Visitors will need to enter this password to view the dashboard. +

+ )} +

)} @@ -1052,28 +1269,9 @@ export default function SiteSettingsPage() {
- {/* Bot and noise filtering */} + {/* Filtering */}

Filtering

-
-
-
-

Filter bots and referrer spam

-

- Exclude known crawlers, scrapers, and referrer spam domains from your stats -

-
- -
-
@@ -1224,6 +1422,165 @@ export default function SiteSettingsPage() {
)} + {activeTab === 'bot' && ( +
+
+

Bot & Spam

+

Manage automated and manual bot filtering.

+
+ + {/* Automated Filtering Section */} +
+

Automated Filtering

+
+
+

Filter bots and referrer spam

+

Automatically block known bots, crawlers, and spam referrers

+
+ +
+ {botStats && ( +
+
+ Auto-blocked this month: + {botStats.auto_blocked_this_month.toLocaleString()} +
+
+ Manually flagged: + {botStats.filtered_sessions} sessions ({botStats.filtered_events} events) +
+
+ )} +
+ + {/* Session Review Section */} +
+
+

Session Review

+
+ {/* Review / Blocked toggle */} +
+ + +
+ {botView === 'review' && ( + + )} +
+
+ + {/* Bulk actions */} + {selectedSessions.size > 0 && ( +
+ {selectedSessions.size} selected + {botView === 'review' ? ( + + ) : ( + + )} + +
+ )} + + {/* Session cards */} +
+ {(sessions?.sessions || []) + .filter(s => botView === 'blocked' ? s.bot_filtered : !s.bot_filtered) + .map((session) => ( +
+ { + const next = new Set(selectedSessions) + if (e.target.checked) next.add(session.session_id) + else next.delete(session.session_id) + setSelectedSessions(next) + }} + className="w-4 h-4 shrink-0 cursor-pointer" + style={{ accentColor: '#FD5E0F' }} + /> + +
+
+ {session.first_page} + = 5 ? 'bg-red-900/30 text-red-400' : + session.suspicion_score >= 3 ? 'bg-yellow-900/30 text-yellow-400' : + 'bg-neutral-800 text-neutral-400' + }`}> + {session.suspicion_score >= 5 ? 'High risk' : session.suspicion_score >= 3 ? 'Suspicious' : 'Low risk'} + +
+
+ {session.pageviews} page{session.pageviews !== 1 ? 's' : ''} + {session.duration != null ? `${Math.round(session.duration)}s` : 'No duration'} + {[session.city, session.country].filter(Boolean).join(', ') || 'Unknown location'} + {session.browser || 'Unknown browser'} + {session.referrer || 'Direct'} +
+
+ + {botView === 'review' ? ( + + ) : ( + + )} +
+ ))} + {(sessions?.sessions || []).filter(s => botView === 'blocked' ? s.bot_filtered : !s.bot_filtered).length === 0 && ( +
+ {botView === 'blocked' ? 'No blocked sessions' : 'No suspicious sessions found'} +
+ )} +
+
+
+ )} + {activeTab === 'goals' && (
@@ -1283,132 +1640,257 @@ export default function SiteSettingsPage() {
)} - {activeTab === 'reports' && ( -
-
-
-

Scheduled Reports

-

Automatically deliver analytics reports via email or webhooks.

+ {activeTab === 'notifications' && ( +
+
+

Notifications

+

Configure how you receive reports and alerts.

+
+ + {/* Reports subsection */} +
+
+
+

Reports

+

Automatically deliver analytics reports via email or webhooks.

+
+ {canEdit && ( + + )}
- {canEdit && ( - + + {reportLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ) : reportSchedules.length === 0 ? ( +
+ No scheduled reports yet. Add a report to automatically receive analytics summaries. +
+ ) : ( +
+ {reportSchedules.map((schedule) => ( +
+
+
+
+ {CHANNEL_ICONS_LG[schedule.channel] || } +
+
+
+ + {getChannelLabel(schedule.channel)} + + + {getFrequencyLabel(schedule.frequency)} + + + {getReportTypeLabel(schedule.report_type)} + +
+

+ {schedule.channel === 'email' + ? (schedule.channel_config as EmailConfig).recipients.join(', ') + : (schedule.channel_config as WebhookConfig).url} +

+

+ {getScheduleDescription(schedule)} +

+
+ + Last sent: {schedule.last_sent_at + ? formatDateTime(new Date(schedule.last_sent_at)) + : 'Never'} + +
+ {schedule.last_error && ( +

+ Error: {schedule.last_error} +

+ )} +
+
+ + {canEdit && ( +
+ + + + +
+ )} +
+
+ ))} +
)}
- {reportLoading ? ( -
- {Array.from({ length: 3 }).map((_, i) => ( -
- ))} -
- ) : reportSchedules.length === 0 ? ( -
- No scheduled reports yet. Add a report to automatically receive analytics summaries. -
- ) : ( -
- {reportSchedules.map((schedule) => ( -
-
-
-
- {schedule.channel === 'email' ? ( - - ) : ( - - )} -
-
-
- - {getChannelLabel(schedule.channel)} - - - {getFrequencyLabel(schedule.frequency)} - - - {getReportTypeLabel(schedule.report_type)} - -
-

- {schedule.channel === 'email' - ? (schedule.channel_config as EmailConfig).recipients.join(', ') - : (schedule.channel_config as WebhookConfig).url} -

-

- {getScheduleDescription(schedule)} -

-
- - Last sent: {schedule.last_sent_at - ? formatDateTime(new Date(schedule.last_sent_at)) - : 'Never'} - -
- {schedule.last_error && ( -

- Error: {schedule.last_error} -

- )} -
-
+ {/* Divider */} +
- {canEdit && ( -
- - - - -
- )} -
-
- ))} + {/* Alerts subsection */} +
+
+
+

Alerts

+

Get notified when your site goes down or recovers.

+
+ {canEdit && ( + + )}
- )} + + {alertLoading ? ( +
+ {Array.from({ length: 2 }).map((_, i) => ( +
+ ))} +
+ ) : alertSchedules.length === 0 ? ( +
+ No alert channels configured. Add a channel to receive uptime alerts when your site goes down or recovers. +
+ ) : ( +
+ {alertSchedules.map((schedule) => ( +
+
+
+
+ {CHANNEL_ICONS_LG[schedule.channel] || } +
+
+
+ + {getChannelLabel(schedule.channel)} + + + Uptime Alert + +
+

+ {schedule.channel === 'email' + ? (schedule.channel_config as EmailConfig).recipients.join(', ') + : (schedule.channel_config as WebhookConfig).url} +

+
+ + Last sent: {schedule.last_sent_at + ? formatDateTime(new Date(schedule.last_sent_at)) + : 'Never'} + +
+ {schedule.last_error && ( +

+ Error: {schedule.last_error} +

+ )} +
+
+ + {canEdit && ( +
+ + + + +
+ )} +
+
+ ))} +
+ )} +
)} @@ -1883,7 +2365,7 @@ export default function SiteSettingsPage() { : 'border-neutral-200 dark:border-neutral-700 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-800' }`} > - {ch === 'email' ? : } + {CHANNEL_ICONS[ch]} {getChannelLabel(ch)} ))} @@ -2022,6 +2504,79 @@ export default function SiteSettingsPage() { + setAlertModalOpen(false)} + title={editingAlert ? 'Edit alert channel' : 'Add alert channel'} + > +
+
+ +
+ {(['email', 'slack', 'discord', 'webhook'] as const).map((ch) => ( + + ))} +
+
+ + {alertForm.channel === 'email' ? ( +
+ + setAlertForm({ ...alertForm, recipients: e.target.value })} + placeholder="email1@example.com, email2@example.com" + 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 + /> +

Comma-separated email addresses.

+
+ ) : ( +
+ + setAlertForm({ ...alertForm, webhookUrl: e.target.value })} + placeholder="https://hooks.example.com/..." + 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 + /> +
+ )} + +
+

+ Alerts are sent automatically when your site goes down or recovers. No schedule configuration needed. +

+
+ +
+ + +
+
+
+ setShowVerificationModal(false)} diff --git a/app/sites/[id]/uptime/page.tsx b/app/sites/[id]/uptime/page.tsx index 497f95b..67dda0b 100644 --- a/app/sites/[id]/uptime/page.tsx +++ b/app/sites/[id]/uptime/page.tsx @@ -1,25 +1,19 @@ 'use client' import { useAuth } from '@/lib/auth/context' -import { useEffect, useState, useRef } from 'react' -import { useParams, useRouter } from 'next/navigation' -import { motion, AnimatePresence } from 'framer-motion' +import { useEffect, useState } from 'react' +import { useParams } from 'next/navigation' import { useSite, useUptimeStatus } from '@/lib/swr/dashboard' +import { updateSite, type Site } from '@/lib/api/sites' import { - createUptimeMonitor, - updateUptimeMonitor, - deleteUptimeMonitor, getMonitorChecks, type UptimeStatusResponse, type MonitorStatus, type UptimeCheck, type UptimeDailyStat, - type CreateMonitorRequest, } from '@/lib/api/uptime' import { toast } from '@ciphera-net/ui' -import { useTheme } from '@ciphera-net/ui' -import { getAuthErrorMessage } from '@ciphera-net/ui' -import { Button, Modal } from '@ciphera-net/ui' +import { Button } from '@ciphera-net/ui' import { UptimeSkeleton, ChecksSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' import { formatDateFull, formatTime, formatDateTimeShort } from '@/lib/utils/formatDate' import { @@ -335,300 +329,65 @@ function ResponseTimeChart({ checks }: { checks: UptimeCheck[] }) { ) } -// * Component: Monitor card (matches the reference image design) -function MonitorCard({ - monitorStatus, - expanded, - onToggle, - onEdit, - onDelete, - canEdit, - siteId, -}: { - monitorStatus: MonitorStatus - expanded: boolean - onToggle: () => void - onEdit: () => void - onDelete: () => void - canEdit: boolean - siteId: string -}) { - const { monitor, daily_stats, overall_uptime } = monitorStatus - const [checks, setChecks] = useState([]) - const [loadingChecks, setLoadingChecks] = useState(false) - - useEffect(() => { - if (expanded && checks.length === 0) { - const fetchChecks = async () => { - setLoadingChecks(true) - try { - const data = await getMonitorChecks(siteId, monitor.id, 50) - setChecks(data) - } catch { - // * Silent fail for check details - } finally { - setLoadingChecks(false) - } - } - fetchChecks() - } - }, [expanded, siteId, monitor.id, checks.length]) - - return ( -
- {/* Header */} - - - {/* Status bar */} -
- -
- 90 days ago - Today -
-
- - {/* Expanded details */} - - {expanded && ( - -
- {/* Monitor details grid */} -
-
-
- Status -
-
-
- - {getStatusLabel(monitor.last_status)} - -
-
-
-
- Response Time -
- - {formatMs(monitor.last_response_time_ms)} - -
-
-
- Check Interval -
- - {monitor.check_interval_seconds >= 60 - ? `${Math.floor(monitor.check_interval_seconds / 60)}m` - : `${monitor.check_interval_seconds}s`} - -
-
-
- Last Checked -
- - {formatTimeAgo(monitor.last_checked_at)} - -
-
- - {/* Response time chart */} - {loadingChecks ? ( - - ) : checks.length > 0 ? ( - <> - - - {/* Recent checks */} -
-

- Recent Checks -

-
- {checks.slice(0, 20).map((check) => ( -
-
-
- - {formatDateTimeShort(new Date(check.checked_at))} - -
-
- {check.status_code && ( - - {check.status_code} - - )} - - {formatMs(check.response_time_ms)} - -
-
- ))} -
-
- - ) : null} - - {/* Actions */} - {canEdit && ( -
- - -
- )} -
- - )} - -
- ) -} - // * Main uptime page export default function UptimePage() { const { user } = useAuth() const canEdit = user?.role === 'owner' || user?.role === 'admin' const params = useParams() - const router = useRouter() const siteId = params.id as string - const { data: site } = useSite(siteId) + const { data: site, mutate: mutateSite } = useSite(siteId) const { data: uptimeData, isLoading, mutate: mutateUptime } = useUptimeStatus(siteId) - const [expandedMonitor, setExpandedMonitor] = useState(null) - const [showAddModal, setShowAddModal] = useState(false) - const [showEditModal, setShowEditModal] = useState(false) - const [editingMonitor, setEditingMonitor] = useState(null) - const [formData, setFormData] = useState({ - name: '', - url: '', - check_interval_seconds: 300, - expected_status_code: 200, - timeout_seconds: 30, - }) - const [saving, setSaving] = useState(false) + const [toggling, setToggling] = useState(false) + const [checks, setChecks] = useState([]) + const [loadingChecks, setLoadingChecks] = useState(false) - const handleAddMonitor = async () => { - if (!formData.name || !formData.url) { - toast.error('Name and URL are required') + // * Single monitor from the auto-managed uptime system + const monitor = uptimeData?.monitors?.[0] ?? null + const overallUptime = uptimeData?.overall_uptime ?? 100 + const overallStatus = uptimeData?.status ?? 'operational' + + // * Fetch recent checks when we have a monitor + useEffect(() => { + if (!monitor) { + setChecks([]) return } - setSaving(true) - try { - await createUptimeMonitor(siteId, formData) - toast.success('Monitor created successfully') - setShowAddModal(false) - setFormData({ name: '', url: '', check_interval_seconds: 300, expected_status_code: 200, timeout_seconds: 30 }) - mutateUptime() - } catch (error: unknown) { - toast.error(getAuthErrorMessage(error) || 'Failed to create monitor') - } finally { - setSaving(false) + const fetchChecks = async () => { + setLoadingChecks(true) + try { + const data = await getMonitorChecks(siteId, monitor.monitor.id, 20) + setChecks(data) + } catch { + // * Silent fail for check details + } finally { + setLoadingChecks(false) + } } - } + fetchChecks() + }, [siteId, monitor?.monitor.id]) - const handleEditMonitor = async () => { - if (!editingMonitor || !formData.name || !formData.url) return - setSaving(true) + const handleToggleUptime = async (enabled: boolean) => { + if (!site) return + setToggling(true) try { - await updateUptimeMonitor(siteId, editingMonitor.monitor.id, { - name: formData.name, - url: formData.url, - check_interval_seconds: formData.check_interval_seconds, - expected_status_code: formData.expected_status_code, - timeout_seconds: formData.timeout_seconds, - enabled: editingMonitor.monitor.enabled, + await updateSite(site.id, { + name: site.name, + timezone: site.timezone, + is_public: site.is_public, + excluded_paths: site.excluded_paths, + uptime_enabled: enabled, }) - toast.success('Monitor updated successfully') - setShowEditModal(false) - setEditingMonitor(null) + mutateSite() mutateUptime() - } catch (error: unknown) { - toast.error(getAuthErrorMessage(error) || 'Failed to update monitor') + toast.success(enabled ? 'Uptime monitoring enabled' : 'Uptime monitoring disabled') + } catch { + toast.error('Failed to update uptime monitoring') } finally { - setSaving(false) + setToggling(false) } } - const handleDeleteMonitor = async (monitorId: string) => { - if (!window.confirm('Are you sure you want to delete this monitor? All historical data will be lost.')) return - try { - await deleteUptimeMonitor(siteId, monitorId) - toast.success('Monitor deleted') - mutateUptime() - } catch (error: unknown) { - toast.error(getAuthErrorMessage(error) || 'Failed to delete monitor') - } - } - - const openEditModal = (ms: MonitorStatus) => { - setEditingMonitor(ms) - setFormData({ - name: ms.monitor.name, - url: ms.monitor.url, - check_interval_seconds: ms.monitor.check_interval_seconds, - expected_status_code: ms.monitor.expected_status_code, - timeout_seconds: ms.monitor.timeout_seconds, - }) - setShowEditModal(true) - } - useEffect(() => { if (site?.domain) document.title = `Uptime · ${site.domain} | Pulse` }, [site?.domain]) @@ -639,10 +398,49 @@ export default function UptimePage() { if (showSkeleton) return if (!site) return
Site not found
- const monitors = Array.isArray(uptimeData?.monitors) ? uptimeData.monitors : [] - const overallUptime = uptimeData?.overall_uptime ?? 100 - const overallStatus = uptimeData?.status ?? 'operational' + const uptimeEnabled = site.uptime_enabled + // * Disabled state — show empty state with enable toggle + if (!uptimeEnabled) { + return ( +
+ {/* Header */} +
+

+ Uptime +

+

+ Monitor your site's availability and response time +

+
+ + {/* Empty state */} +
+
+ + + +
+

+ Uptime monitoring is disabled +

+

+ Enable uptime monitoring to track your site's availability and response time around the clock. +

+ {canEdit && ( + + )} +
+
+ ) + } + + // * Enabled state — show uptime dashboard return (
{/* Header */} @@ -652,327 +450,148 @@ export default function UptimePage() { Uptime

- Monitor your endpoints and track availability over time + Monitor your site's availability and response time

{canEdit && ( )}
{/* Overall status card */} - {monitors.length > 0 && ( -
-
-
-
-
- - {site.name} - - - {getOverallStatusText(overallStatus)} - -
-
-
- - {formatUptime(overallUptime)} uptime +
+
+
+
+
+ + {site.name} + + + {getOverallStatusText(overallStatus)} -
- {monitors.length} {monitors.length === 1 ? 'component' : 'components'} -
-
- )} - - {/* Monitor list */} - {monitors.length > 0 ? ( -
- {monitors.map((ms) => ( - setExpandedMonitor( - expandedMonitor === ms.monitor.id ? null : ms.monitor.id - )} - onEdit={() => openEditModal(ms)} - onDelete={() => handleDeleteMonitor(ms.monitor.id)} - canEdit={canEdit} - siteId={siteId} - /> - ))} -
- ) : ( - /* Empty state */ -
-
- - - -
-

- No monitors yet -

-

- Add a monitor to start tracking the uptime and response time of your endpoints. You can monitor APIs, websites, and any HTTP endpoint. -

- {canEdit && ( - - )} -
- )} - - {/* Add Monitor Modal */} - setShowAddModal(false)} title="Add Monitor"> - setShowAddModal(false)} - saving={saving} - submitLabel="Create Monitor" - siteDomain={site.domain} - /> - - - {/* Edit Monitor Modal */} - setShowEditModal(false)} title="Edit Monitor"> - setShowEditModal(false)} - saving={saving} - submitLabel="Save Changes" - siteDomain={site.domain} - /> - -
- ) -} - -// * Monitor creation/edit form -function MonitorForm({ - formData, - setFormData, - onSubmit, - onCancel, - saving, - submitLabel, - siteDomain, -}: { - formData: CreateMonitorRequest - setFormData: (data: CreateMonitorRequest) => void - onSubmit: () => void - onCancel: () => void - saving: boolean - submitLabel: string - siteDomain: string -}) { - // * Derive protocol from formData.url so edit modal shows the monitor's actual scheme (no desync) - const protocol: 'https://' | 'http://' = formData.url.startsWith('http://') ? 'http://' : 'https://' - const [showProtocolDropdown, setShowProtocolDropdown] = useState(false) - const dropdownRef = useRef(null) - - // * Extract the path portion from the full URL - const getPath = (): string => { - const url = formData.url - if (!url) return '' - try { - const parsed = new URL(url) - const pathAndRest = parsed.pathname + parsed.search + parsed.hash - return pathAndRest === '/' ? '' : pathAndRest - } catch { - // ? If not a valid full URL, try stripping the protocol prefix - if (url.startsWith('https://')) return url.slice(8 + siteDomain.length) - if (url.startsWith('http://')) return url.slice(7 + siteDomain.length) - return url - } - } - - const handlePathChange = (e: React.ChangeEvent) => { - const path = e.target.value - const safePath = path.startsWith('/') || path === '' ? path : `/${path}` - setFormData({ ...formData, url: `${protocol}${siteDomain}${safePath}` }) - } - - const handleProtocolChange = (proto: 'https://' | 'http://') => { - setShowProtocolDropdown(false) - const path = getPath() - setFormData({ ...formData, url: `${proto}${siteDomain}${path}` }) - } - - // * Initialize URL if empty - useEffect(() => { - if (!formData.url) { - setFormData({ ...formData, url: `${protocol}${siteDomain}` }) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - // * Close dropdown on outside click - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { - setShowProtocolDropdown(false) - } - } - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, []) - - return ( -
- {/* Name */} -
- - setFormData({ ...formData, name: e.target.value })} - placeholder="e.g. API, Website, CDN" - autoFocus - maxLength={100} - className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:border-transparent text-sm" - /> - {formData.name.length > 80 && ( - 90 ? 'text-amber-500' : 'text-neutral-400'}`}>{formData.name.length}/100 - )} -
- - {/* URL with protocol dropdown + domain prefix */} -
- -
- {/* Protocol dropdown */} -
- - {showProtocolDropdown && ( -
- - +
+ + {formatUptime(overallUptime)} uptime + + {monitor && ( +
+ Last checked {formatTimeAgo(monitor.monitor.last_checked_at)}
)}
- {/* Domain prefix */} - - {siteDomain} - - {/* Path input */} -
-

- Add a specific path (e.g. /api/health) or leave empty for the root domain -

- {/* Check interval */} -
- - -
+ {/* 90-day uptime bar */} + {monitor && ( +
+

+ 90-Day Availability +

+ +
+ 90 days ago + Today +
+
+ )} - {/* Expected status code */} -
- - setFormData({ ...formData, expected_status_code: parseInt(e.target.value) || 200 })} - min={100} - max={599} - className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-orange focus:border-transparent text-sm [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" - /> -
+ {/* Response time chart + Recent checks */} + {monitor && ( +
+ {/* Monitor details grid */} +
+
+
+ Status +
+
+
+ + {getStatusLabel(monitor.monitor.last_status)} + +
+
+
+
+ Response Time +
+ + {formatMs(monitor.monitor.last_response_time_ms)} + +
+
+
+ Check Interval +
+ + {monitor.monitor.check_interval_seconds >= 60 + ? `${Math.floor(monitor.monitor.check_interval_seconds / 60)}m` + : `${monitor.monitor.check_interval_seconds}s`} + +
+
+
+ Overall Uptime +
+ + {formatUptime(monitor.overall_uptime)} + +
+
- {/* Timeout */} -
- - setFormData({ ...formData, timeout_seconds: parseInt(e.target.value) || 30 })} - min={5} - max={60} - className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-orange focus:border-transparent text-sm [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" - /> -
+ {/* Response time chart */} + {loadingChecks ? ( + + ) : checks.length > 0 ? ( + <> + - {/* Actions */} -
- - -
+ {/* Recent checks */} +
+

+ Recent Checks +

+
+ {checks.slice(0, 20).map((check) => ( +
+
+
+ + {formatDateTimeShort(new Date(check.checked_at))} + +
+
+ {check.status_code && ( + + {check.status_code} + + )} + + {formatMs(check.response_time_ms)} + +
+
+ ))} +
+
+ + ) : null} +
+ )}
) } diff --git a/components/IntegrationGuide.tsx b/components/IntegrationGuide.tsx index fb53861..390ce92 100644 --- a/components/IntegrationGuide.tsx +++ b/components/IntegrationGuide.tsx @@ -39,10 +39,9 @@ export function IntegrationGuide({ integration, children }: IntegrationGuideProp
{/* * --- ATMOSPHERE (Background) --- */}
-
-
+
@@ -57,18 +56,18 @@ export function IntegrationGuide({ integration, children }: IntegrationGuideProp
-
+
{headerIcon}
-

+

{integration.name} Integration

-
+
{children} -
+

Optional: Frustration Tracking

Detect rage clicks and dead clicks by adding the frustration tracking @@ -83,8 +82,8 @@ export function IntegrationGuide({ integration, children }: IntegrationGuideProp {/* * --- Related Integrations --- */} {relatedIntegrations.length > 0 && ( -

-

+
+

Related Integrations

@@ -92,16 +91,16 @@ export function IntegrationGuide({ integration, children }: IntegrationGuideProp -
+
{related.icon}
- + {related.name} - + {related.description}
diff --git a/components/PricingSection.tsx b/components/PricingSection.tsx index 8205816..51f5ef8 100644 --- a/components/PricingSection.tsx +++ b/components/PricingSection.tsx @@ -219,10 +219,10 @@ export default function PricingSection() { transition={{ duration: 0.5 }} className="text-center mb-12" > -

+

Transparent Pricing

-

+

Scale with your traffic. No hidden fees.

@@ -232,13 +232,13 @@ export default function PricingSection() { 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-2xl bg-white/50 dark:bg-neutral-900/50 backdrop-blur-xl shadow-sm overflow-hidden mb-20" + className="max-w-6xl mx-auto border border-neutral-800 rounded-2xl bg-neutral-900/50 backdrop-blur-xl shadow-sm overflow-hidden mb-20" > {/* Top Toolbar */} -
+
-
+
10k Up to {currentTraffic.label} monthly pageviews @@ -254,23 +254,23 @@ export default function PricingSection() { onChange={(e) => setSliderIndex(parseInt(e.target.value))} aria-label="Monthly pageview limit" aria-valuetext={`${currentTraffic.label} pageviews per month`} - className="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer dark:bg-neutral-700 accent-brand-orange focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2" + className="w-full h-2 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-brand-orange focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2" />
- + Get 1 month free with yearly -
+
{/* Pricing Grid */} -
+
{/* Free Plan */} -
+
-

Free

-

For trying Pulse on a personal project

+

Free

+

For trying Pulse on a personal project

- €0 - /forever + €0 + /forever
@@ -320,7 +320,7 @@ export default function PricingSection() {
    {['1 site', '5k monthly pageviews', '6 months data retention', '100% Data ownership'].map((feature) => ( -
  • +
  • {feature}
  • @@ -333,7 +333,7 @@ export default function PricingSection() { const isTeam = plan.id === 'team' return ( -
    +
    {isTeam && ( <>
    @@ -344,17 +344,17 @@ export default function PricingSection() { )}
    -

    {plan.name}

    -

    {plan.description}

    +

    {plan.name}

    +

    {plan.description}

    {priceDetails ? ( isYearly ? (
    - + €{priceDetails.yearlyTotal} - /year + /year
    @@ -367,14 +367,14 @@ export default function PricingSection() {
    ) : (
    - + €{priceDetails.baseMonthly} - /mo + /mo
    ) ) : ( -
    +
    Custom
    )} @@ -391,7 +391,7 @@ export default function PricingSection() {
      {plan.features.map((feature) => ( -
    • +
    • {feature}
    • @@ -402,11 +402,11 @@ export default function PricingSection() { })} {/* Enterprise Section */} -
      +
      -

      Enterprise

      -

      For high volume sites and custom needs

      -
      +

      Enterprise

      +

      For high volume sites and custom needs

      +
      Custom
      @@ -428,7 +428,7 @@ export default function PricingSection() { 'Managed Proxy', 'Raw data export' ].map((feature) => ( -
    • +
    • {feature}
    • diff --git a/components/dashboard/AddFilterDropdown.tsx b/components/dashboard/AddFilterDropdown.tsx index 2ad4066..74c835c 100644 --- a/components/dashboard/AddFilterDropdown.tsx +++ b/components/dashboard/AddFilterDropdown.tsx @@ -111,7 +111,7 @@ export default function AddFilterDropdown({ onAdd, suggestions = {}, onFetchSugg className={`inline-flex items-center gap-2 px-3 py-1.5 text-xs font-medium rounded-lg transition-all cursor-pointer ${ isOpen ? 'bg-brand-orange/10 text-brand-orange border border-brand-orange/30' - : 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700 hover:text-neutral-900 dark:hover:text-white border border-transparent' + : 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700 hover:text-white border border-transparent' }`} > @@ -121,7 +121,7 @@ export default function AddFilterDropdown({ onAdd, suggestions = {}, onFetchSugg {isOpen && ( -
      +
      {!selectedDim ? ( /* Step 1: Dimension list */
      @@ -129,9 +129,9 @@ export default function AddFilterDropdown({ onAdd, suggestions = {}, onFetchSugg - + {DIMENSION_LABELS[selectedDim]}
      @@ -165,7 +165,7 @@ export default function AddFilterDropdown({ onAdd, suggestions = {}, onFetchSugg className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors cursor-pointer ${ operator === op ? 'bg-brand-orange text-white' - : 'bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700' + : 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700' }`} > {OPERATOR_LABELS[op]} @@ -189,24 +189,24 @@ export default function AddFilterDropdown({ onAdd, suggestions = {}, onFetchSugg } }} placeholder={`Search ${DIMENSION_LABELS[selectedDim]?.toLowerCase()}...`} - className="w-full px-3 py-2 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange/40 focus:border-brand-orange transition-colors" + className="w-full px-3 py-2 text-sm bg-neutral-800 border border-neutral-700 rounded-lg text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange/40 focus:border-brand-orange transition-colors" />
      {/* Values list */} {isFetching ? (
      -
      +
      ) : filtered.length > 0 ? ( -
      +
      {filtered.map(s => (
      ) : search.trim() ? ( -
      +
      @@ -262,12 +262,12 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
      { if (onFilter) { onFilter({ dimension: 'utm_source', operator: 'is', values: [item.source] }); setIsModalOpen(false) } }} - className={`flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`} + className={`flex items-center justify-between py-2 group hover:bg-neutral-800 rounded-lg px-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`} >
      {renderSourceIcon(item.source)}
      -
      +
      {getReferrerDisplayName(item.source)}
      @@ -281,7 +281,7 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp {modalTotal > 0 ? `${Math.round((item.visitors / modalTotal) * 100)}%` : ''} - + {formatNumber(item.visitors)} diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx index 7fc7bc8..4b47fc0 100644 --- a/components/dashboard/Chart.tsx +++ b/components/dashboard/Chart.tsx @@ -2,8 +2,7 @@ import { useState, useMemo, useRef, useCallback, useEffect } from 'react' import { useTheme } from '@ciphera-net/ui' -import { Area, CartesianGrid, ComposedChart, Line, XAxis, YAxis, ReferenceLine } from 'recharts' -import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/line-charts-6' +import { AreaChart as VisxAreaChart, Area as VisxArea, Grid as VisxGrid, XAxis as VisxXAxis, YAxis as VisxYAxis, ChartTooltip as VisxChartTooltip, type TooltipRow } from '@/components/ui/area-chart' import { Card, CardContent, CardHeader } from '@/components/ui/card' import { formatNumber, formatDuration, formatUpdatedAgo, DatePicker } from '@ciphera-net/ui' import { Select, DownloadIcon, PlusIcon, XIcon } from '@ciphera-net/ui' @@ -103,40 +102,11 @@ const METRIC_CONFIGS: { { key: 'avg_duration', label: 'Visit Duration', format: (v) => formatDuration(v) }, ] -const chartConfig = { - visitors: { label: 'Unique Visitors', color: '#FD5E0F' }, - pageviews: { label: 'Total Pageviews', color: '#FD5E0F' }, - bounce_rate: { label: 'Bounce Rate', color: '#FD5E0F' }, - avg_duration: { label: 'Visit Duration', color: '#FD5E0F' }, -} satisfies ChartConfig - -// ─── Custom Tooltip ───────────────────────────────────────────────── - -interface TooltipProps { - active?: boolean - payload?: Array<{ dataKey: string; value: number; color: string }> - label?: string - metric: MetricType -} - -function CustomTooltip({ active, payload, metric }: TooltipProps) { - if (active && payload && payload.length) { - const entry = payload[0] - const config = METRIC_CONFIGS.find((m) => m.key === metric) - - if (config) { - return ( -
      -
      -
      - {config.label}: - {config.format(entry.value)} -
      -
      - ) - } - } - return null +const CHART_COLORS: Record = { + visitors: '#FD5E0F', + pageviews: '#FD5E0F', + bounce_rate: '#FD5E0F', + avg_duration: '#FD5E0F', } // ─── Chart Component ───────────────────────────────────────────────── @@ -227,6 +197,7 @@ export default function Chart({ return { date: formattedDate, + dateObj: new Date(item.date), originalDate: item.date, pageviews: item.pageviews, visitors: item.visitors, @@ -450,103 +421,60 @@ export default function Chart({
      ) : (
      - []} + xDataKey="dateObj" + aspectRatio="2.5 / 1" + margin={{ top: 20, right: 20, bottom: 40, left: 50 }} > - - - - - - - - - - - - - - - - - - - { - const config = METRIC_CONFIGS.find((m) => m.key === metric) - return config ? config.format(value) : value.toString() - }} - /> - - } cursor={{ strokeDasharray: '3 3', stroke: '#9ca3af' }} /> - - - {/* Annotation reference lines */} - {visibleAnnotationMarkers.map((marker) => { - const primaryCategory = marker.annotations[0].category - const color = ANNOTATION_COLORS[primaryCategory] || ANNOTATION_COLORS.other + + + `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}` + : (d) => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) + } + /> + { + const config = METRIC_CONFIGS.find((m) => m.key === metric) + return config ? config.format(v) : v.toString() + }} + /> + { + const dateObj = point.dateObj instanceof Date ? point.dateObj : new Date(point.dateObj as string || Date.now()) + const config = METRIC_CONFIGS.find((m) => m.key === metric) + const value = point[metric] as number + const title = interval === 'minute' || interval === 'hour' + ? `${String(dateObj.getUTCHours()).padStart(2, '0')}:${String(dateObj.getUTCMinutes()).padStart(2, '0')}` + : dateObj.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' }) return ( - +
      +
      {title}
      +
      +
      + + {config?.label || metric} +
      + + {config ? config.format(value) : value} + +
      +
      ) - })} - - - -
      -
      + }} + /> +
      )} diff --git a/components/dashboard/ContentHeader.tsx b/components/dashboard/ContentHeader.tsx index 7f67b84..417fc13 100644 --- a/components/dashboard/ContentHeader.tsx +++ b/components/dashboard/ContentHeader.tsx @@ -8,10 +8,10 @@ export default function ContentHeader({ onMobileMenuOpen: () => void }) { return ( -
      +
      + )}
      -
      - {Array.from({ length: 5 }).map((_, i) => ( -
      +
      + {(['queries', 'pages'] as Tab[]).map((tab) => ( + ))}
      - ) : ( - <> - {/* Inline stats row */} -
      -
      - Clicks - - {(overview?.total_clicks ?? 0).toLocaleString()} - - -
      -
      - Impressions - - {(overview?.total_impressions ?? 0).toLocaleString()} - - -
      -
      - Avg Position - - {(overview?.avg_position ?? 0).toFixed(1)} - - -
      -
      - {/* Top 5 queries list */} -
      - {queries.length > 0 ? ( - queries.map((q) => ( -
      - - {q.query} - -
      - - {q.clicks.toLocaleString()} - - - {q.position.toFixed(1)} - -
      -
      - )) - ) : ( -
      -

      No search data yet

      -
      - )} + {isLoading ? ( +
      +
      +
      +
      +
      +
      +
      + +
      - - )} -
      + ) : ( + <> + {/* Inline stats row */} +
      +
      + Clicks + + {formatNumber(overview?.total_clicks ?? 0)} + + +
      +
      + Impressions + + {formatNumber(overview?.total_impressions ?? 0)} + + +
      +
      + Avg Position + + {(overview?.avg_position ?? 0).toFixed(1)} + + +
      +
      + + {/* Data list */} +
      + {displayedData.length > 0 ? ( + <> + {displayedData.map((row) => { + const maxImpressions = displayedData[0]?.impressions ?? 0 + const barWidth = maxImpressions > 0 ? (row.impressions / maxImpressions) * 75 : 0 + const label = getLabel(row) + return ( +
      +
      + + {label} + +
      + + {totalImpressions > 0 ? `${Math.round((row.impressions / totalImpressions) * 100)}%` : ''} + + + {formatNumber(row.clicks)} + + + {row.position.toFixed(1)} + +
      +
      + ) + })} + {Array.from({ length: emptySlots }).map((_, i) => ( + + + )} +
      + + {/* Expand modal */} + { setIsModalOpen(false); setModalSearch('') }} + title={`Search ${getTabLabel(activeTab)}`} + className="max-w-2xl" + > +
      + setModalSearch(e.target.value)} + placeholder={`Search ${activeTab}...`} + className="w-full px-3 py-2 mb-3 text-sm bg-neutral-800 border border-neutral-700 rounded-lg text-white placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50" + /> +
      +
      + {isLoadingFull ? ( +
      + +
      + ) : (() => { + const source = fullData.length > 0 ? fullData : data + const modalData = source.filter(row => { + if (!modalSearch) return true + return getLabel(row).toLowerCase().includes(modalSearch.toLowerCase()) + }) + const modalTotal = modalData.reduce((sum, r) => sum + r.impressions, 0) + return ( + { + const label = getLabel(row) + return ( +
      + + {label} + +
      + + {modalTotal > 0 ? `${Math.round((row.impressions / modalTotal) * 100)}%` : ''} + + + {formatNumber(row.clicks)} + + + {row.position.toFixed(1)} + +
      +
      + ) + }} + /> + ) + })()} +
      +
      + ) } diff --git a/components/dashboard/Sidebar.tsx b/components/dashboard/Sidebar.tsx index 7a2a271..042ac0a 100644 --- a/components/dashboard/Sidebar.tsx +++ b/components/dashboard/Sidebar.tsx @@ -162,12 +162,12 @@ function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollaps setOpen(!open) } }} - className="w-full flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800 overflow-hidden" + className="w-full flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-neutral-200 hover:bg-neutral-800 overflow-hidden" > {faviconUrl && !faviconFailed ? ( <> - {!faviconLoaded && } + {!faviconLoaded && } {open && ( -
      +
      setSearch(e.target.value)} - className="w-full px-3 py-1.5 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg outline-none focus:ring-2 focus:ring-brand-orange/40 text-neutral-900 dark:text-white placeholder:text-neutral-400" + className="w-full px-3 py-1.5 text-sm bg-neutral-800 border border-neutral-700 rounded-lg outline-none focus:ring-2 focus:ring-brand-orange/40 text-white placeholder:text-neutral-400" autoFocus />
      @@ -206,7 +206,7 @@ function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollaps className={`w-full flex items-center gap-2.5 px-4 py-2 text-sm text-left ${ site.id === siteId ? 'bg-brand-orange/10 text-brand-orange font-medium' - : 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-800' + : 'text-neutral-300 hover:bg-neutral-800' }`} > No sites found

      }
      -
      - setOpen(false)} className="flex items-center gap-2 px-3 py-1.5 text-sm text-brand-orange hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg"> +
      + setOpen(false)} className="flex items-center gap-2 px-3 py-1.5 text-sm text-brand-orange hover:bg-neutral-800 rounded-lg"> Add new site @@ -256,7 +256,7 @@ function NavLink({ className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden ${ active ? 'bg-brand-orange/10 text-brand-orange' - : 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800' + : 'text-neutral-400 hover:text-white hover:bg-neutral-800' }`} > @@ -357,7 +357,7 @@ export default function Sidebar({ Pulse - + Pulse @@ -387,7 +387,7 @@ export default function Sidebar({ {/* Bottom — utility items */} -
      +
      {/* Notifications, Profile — same layout as nav items */}
      @@ -418,7 +418,7 @@ export default function Sidebar({ {!isMobile && (
      diff --git a/components/dashboard/TechSpecs.tsx b/components/dashboard/TechSpecs.tsx index 6139ec9..f9f63ac 100644 --- a/components/dashboard/TechSpecs.tsx +++ b/components/dashboard/TechSpecs.tsx @@ -131,17 +131,17 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co return ( <> -
      +
      -

      +

      Technology

      {showViewAll && ( + + + +
      +
      + +
      + + ) +} diff --git a/components/marketing/ComparisonCards.tsx b/components/marketing/ComparisonCards.tsx new file mode 100644 index 0000000..e0ef960 --- /dev/null +++ b/components/marketing/ComparisonCards.tsx @@ -0,0 +1,111 @@ +'use client' + +import { motion } from 'framer-motion' +import Image from 'next/image' +import { Check, X } from '@phosphor-icons/react' + +const pulseFeatures = [ + { label: 'No cookies required', has: true }, + { label: 'GDPR compliant by default', has: true }, + { label: 'No consent banner needed', has: true }, + { label: 'Open source client', has: true }, + { label: 'Script under 2KB', has: true }, + { label: 'Swiss infrastructure', has: true }, + { label: 'No cross-site tracking', has: true }, + { label: 'Free tier available', has: true }, + { label: 'Real-time dashboard', has: true }, +] + +const gaFeatures = [ + { label: 'Requires cookies', has: false }, + { label: 'GDPR requires configuration', has: false }, + { label: 'Consent banner required', has: false }, + { label: 'Closed source', has: false }, + { label: 'Script over 45KB', has: false }, + { label: 'US infrastructure', has: false }, + { label: 'Cross-site tracking', has: false }, + { label: 'Free tier available', has: true }, + { label: 'Real-time dashboard', has: true }, +] + +export default function ComparisonCards() { + return ( +
      +
      + +

      + How Pulse compares. +

      +

      + Privacy-first analytics doesn't mean less insight. See how Pulse stacks up. +

      +
      + +
      + {/* Pulse — highlighted */} + +
      +
      + Pulse +
      +

      Pulse

      +

      Privacy-first analytics

      +
      +
      +
        + {pulseFeatures.map((f) => ( +
      • + + {f.label} +
      • + ))} +
      + + + {/* Google Analytics — muted */} + +
      +
      + 📊 +
      +
      +

      Google Analytics

      +

      Traditional tracking

      +
      +
      +
        + {gaFeatures.map((f) => ( +
      • + {f.has ? ( + + ) : ( + + )} + {f.label} +
      • + ))} +
      +
      +
      +
      +
      + ) +} diff --git a/components/marketing/DashboardDemo.tsx b/components/marketing/DashboardDemo.tsx new file mode 100644 index 0000000..7be2c9c --- /dev/null +++ b/components/marketing/DashboardDemo.tsx @@ -0,0 +1,273 @@ +'use client' + +import Chart from '@/components/dashboard/Chart' +import ContentStats from '@/components/dashboard/ContentStats' +import TopReferrers from '@/components/dashboard/TopReferrers' +import Locations from '@/components/dashboard/Locations' +import TechSpecs from '@/components/dashboard/TechSpecs' +import { useState } from 'react' + +// ─── Fake Data ─────────────────────────────────────────────────────── + +const FAKE_STATS = { pageviews: 8432, visitors: 2847, bounce_rate: 42, avg_duration: 154 } +const FAKE_PREV_STATS = { pageviews: 7821, visitors: 2543, bounce_rate: 45, avg_duration: 134 } + +const FAKE_DAILY_STATS = [ + { date: '2026-03-21 00:00:00', pageviews: 42, visitors: 26, bounce_rate: 46, avg_duration: 118 }, + { date: '2026-03-21 01:00:00', pageviews: 38, visitors: 24, bounce_rate: 47, avg_duration: 115 }, + { date: '2026-03-21 02:00:00', pageviews: 35, visitors: 22, bounce_rate: 47, avg_duration: 112 }, + { date: '2026-03-21 03:00:00', pageviews: 34, visitors: 21, bounce_rate: 48, avg_duration: 110 }, + { date: '2026-03-21 04:00:00', pageviews: 36, visitors: 23, bounce_rate: 47, avg_duration: 112 }, + { date: '2026-03-21 05:00:00', pageviews: 45, visitors: 29, bounce_rate: 46, avg_duration: 116 }, + { date: '2026-03-21 06:00:00', pageviews: 62, visitors: 40, bounce_rate: 45, avg_duration: 122 }, + { date: '2026-03-21 07:00:00', pageviews: 95, visitors: 62, bounce_rate: 43, avg_duration: 132 }, + { date: '2026-03-21 08:00:00', pageviews: 148, visitors: 98, bounce_rate: 41, avg_duration: 145 }, + { date: '2026-03-21 09:00:00', pageviews: 215, visitors: 145, bounce_rate: 39, avg_duration: 155 }, + { date: '2026-03-21 10:00:00', pageviews: 285, visitors: 192, bounce_rate: 38, avg_duration: 162 }, + { date: '2026-03-21 11:00:00', pageviews: 338, visitors: 228, bounce_rate: 37, avg_duration: 168 }, + { date: '2026-03-21 12:00:00', pageviews: 355, visitors: 240, bounce_rate: 38, avg_duration: 165 }, + { date: '2026-03-21 13:00:00', pageviews: 372, visitors: 252, bounce_rate: 37, avg_duration: 170 }, + { date: '2026-03-21 14:00:00', pageviews: 390, visitors: 265, bounce_rate: 36, avg_duration: 175 }, + { date: '2026-03-21 15:00:00', pageviews: 385, visitors: 260, bounce_rate: 36, avg_duration: 173 }, + { date: '2026-03-21 16:00:00', pageviews: 362, visitors: 245, bounce_rate: 37, avg_duration: 168 }, + { date: '2026-03-21 17:00:00', pageviews: 325, visitors: 218, bounce_rate: 38, avg_duration: 162 }, + { date: '2026-03-21 18:00:00', pageviews: 282, visitors: 190, bounce_rate: 40, avg_duration: 155 }, + { date: '2026-03-21 19:00:00', pageviews: 238, visitors: 160, bounce_rate: 41, avg_duration: 148 }, + { date: '2026-03-21 20:00:00', pageviews: 195, visitors: 132, bounce_rate: 42, avg_duration: 140 }, + { date: '2026-03-21 21:00:00', pageviews: 155, visitors: 105, bounce_rate: 43, avg_duration: 132 }, + { date: '2026-03-21 22:00:00', pageviews: 112, visitors: 75, bounce_rate: 44, avg_duration: 125 }, + { date: '2026-03-21 23:00:00', pageviews: 72, visitors: 46, bounce_rate: 45, avg_duration: 120 }, +] + +const FAKE_TOP_PAGES = [ + { path: '/', pageviews: 2341, visits: 1892 }, + { path: '/products/pulse', pageviews: 1567, visits: 1234 }, + { path: '/products/drop', pageviews: 987, visits: 812 }, + { path: '/pricing', pageviews: 876, visits: 723 }, + { path: '/blog/privacy-first-analytics', pageviews: 654, visits: 543 }, + { path: '/about', pageviews: 432, visits: 367 }, + { path: '/docs/getting-started', pageviews: 389, visits: 312 }, + { path: '/blog/end-to-end-encryption', pageviews: 345, visits: 289 }, + { path: '/contact', pageviews: 287, visits: 234 }, + { path: '/careers', pageviews: 198, visits: 167 }, +] + +const FAKE_ENTRY_PAGES = [ + { path: '/', pageviews: 1987, visits: 1654 }, + { path: '/products/pulse', pageviews: 1123, visits: 987 }, + { path: '/blog/privacy-first-analytics', pageviews: 567, visits: 489 }, + { path: '/products/drop', pageviews: 534, visits: 456 }, + { path: '/pricing', pageviews: 423, visits: 378 }, + { path: '/docs/getting-started', pageviews: 312, visits: 267 }, + { path: '/about', pageviews: 234, visits: 198 }, + { path: '/blog/end-to-end-encryption', pageviews: 198, visits: 167 }, + { path: '/careers', pageviews: 145, visits: 123 }, + { path: '/contact', pageviews: 112, visits: 98 }, +] + +const FAKE_EXIT_PAGES = [ + { path: '/pricing', pageviews: 1456, visits: 1234 }, + { path: '/', pageviews: 1234, visits: 1087 }, + { path: '/contact', pageviews: 876, visits: 756 }, + { path: '/products/drop', pageviews: 654, visits: 543 }, + { path: '/products/pulse', pageviews: 567, visits: 478 }, + { path: '/docs/getting-started', pageviews: 432, visits: 367 }, + { path: '/about', pageviews: 345, visits: 289 }, + { path: '/blog/privacy-first-analytics', pageviews: 298, visits: 245 }, + { path: '/careers', pageviews: 234, visits: 198 }, + { path: '/blog/end-to-end-encryption', pageviews: 178, visits: 145 }, +] + +const FAKE_REFERRERS = [ + { referrer: 'google.com', pageviews: 3421 }, + { referrer: '(direct)', pageviews: 2100 }, + { referrer: 'twitter.com', pageviews: 876 }, + { referrer: 'github.com', pageviews: 654 }, + { referrer: 'reddit.com', pageviews: 432 }, + { referrer: 'producthunt.com', pageviews: 312 }, + { referrer: 'news.ycombinator.com', pageviews: 267 }, + { referrer: 'linkedin.com', pageviews: 198 }, + { referrer: 'duckduckgo.com', pageviews: 112 }, + { referrer: 'dev.to', pageviews: 78 }, +] + +const FAKE_COUNTRIES = [ + { country: 'CH', pageviews: 2534 }, + { country: 'DE', pageviews: 1856 }, + { country: 'US', pageviews: 1234 }, + { country: 'FR', pageviews: 876 }, + { country: 'GB', pageviews: 654 }, + { country: 'NL', pageviews: 432 }, + { country: 'AT', pageviews: 312 }, + { country: 'SE', pageviews: 198 }, + { country: 'JP', pageviews: 156 }, + { country: 'CA', pageviews: 134 }, +] + +const FAKE_CITIES = [ + { city: 'Zurich', country: 'CH', pageviews: 1234 }, + { city: 'Geneva', country: 'CH', pageviews: 678 }, + { city: 'Berlin', country: 'DE', pageviews: 567 }, + { city: 'Munich', country: 'DE', pageviews: 432 }, + { city: 'San Francisco', country: 'US', pageviews: 345 }, + { city: 'Paris', country: 'FR', pageviews: 312 }, + { city: 'London', country: 'GB', pageviews: 289 }, + { city: 'Amsterdam', country: 'NL', pageviews: 234 }, + { city: 'Vienna', country: 'AT', pageviews: 198 }, + { city: 'New York', country: 'US', pageviews: 178 }, +] + +const FAKE_REGIONS = [ + { region: 'Zurich', country: 'CH', pageviews: 1567 }, + { region: 'Geneva', country: 'CH', pageviews: 734 }, + { region: 'Bavaria', country: 'DE', pageviews: 523 }, + { region: 'Berlin', country: 'DE', pageviews: 489 }, + { region: 'California', country: 'US', pageviews: 456 }, + { region: 'Ile-de-France', country: 'FR', pageviews: 345 }, + { region: 'England', country: 'GB', pageviews: 312 }, + { region: 'North Holland', country: 'NL', pageviews: 267 }, + { region: 'Bern', country: 'CH', pageviews: 234 }, + { region: 'New York', country: 'US', pageviews: 198 }, +] + +const FAKE_BROWSERS = [ + { browser: 'Chrome', pageviews: 5234 }, + { browser: 'Firefox', pageviews: 1518 }, + { browser: 'Safari', pageviews: 987 }, + { browser: 'Edge', pageviews: 456 }, + { browser: 'Brave', pageviews: 178 }, + { browser: 'Arc', pageviews: 59 }, +] + +const FAKE_OS = [ + { os: 'macOS', pageviews: 3421 }, + { os: 'Windows', pageviews: 2567 }, + { os: 'Linux', pageviews: 1234 }, + { os: 'iOS', pageviews: 756 }, + { os: 'Android', pageviews: 454 }, +] + +const FAKE_DEVICES = [ + { device: 'Desktop', pageviews: 5876 }, + { device: 'Mobile', pageviews: 1987 }, + { device: 'Tablet', pageviews: 569 }, +] + +const FAKE_SCREEN_RESOLUTIONS = [ + { screen_resolution: '1920x1080', pageviews: 2345 }, + { screen_resolution: '1440x900', pageviews: 1567 }, + { screen_resolution: '2560x1440', pageviews: 1234 }, + { screen_resolution: '1366x768', pageviews: 876 }, + { screen_resolution: '3840x2160', pageviews: 654 }, + { screen_resolution: '1536x864', pageviews: 432 }, + { screen_resolution: '390x844', pageviews: 312 }, + { screen_resolution: '393x873', pageviews: 234 }, +] + +// ─── Component ─────────────────────────────────────────────────────── + +export default function DashboardDemo() { + const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>('hour') + const [multiDayInterval, setMultiDayInterval] = useState<'hour' | 'day'>('day') + const today = new Date().toISOString().split('T')[0] + const dateRange = { start: today, end: today } + + const noop = () => {} + + return ( +
      + {/* Orange glow behind */} +
      + + {/* Outer frame with showcase bg */} +
      + +
      + + {/* Inner dashboard — solid background */} +
      + {/* Dashboard header */} +
      +
      +
      +

      Ciphera

      +

      ciphera.net

      +
      +
      + + + + + 12 current visitors +
      +
      +
      + Today +
      +
      + + {/* Chart with stats */} +
      + +
      + + {/* 2-col grid: Pages + Referrers */} +
      + + +
      + + {/* 2-col grid: Locations + Tech */} +
      + + +
      +
      +
      +
      + ) +} diff --git a/components/marketing/FAQ.tsx b/components/marketing/FAQ.tsx new file mode 100644 index 0000000..8707531 --- /dev/null +++ b/components/marketing/FAQ.tsx @@ -0,0 +1,169 @@ +'use client'; + +import React, { useState } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { Plus } from '@phosphor-icons/react'; +import { cn } from '@/lib/utils'; + +interface FAQItem { + question: string; + answer: string; +} + +interface FAQProps extends React.HTMLAttributes { + title?: string; + subtitle?: string; + categories: Record; + faqData: Record; +} + +export const FAQ = ({ + title = "FAQs", + subtitle = "Frequently Asked Questions", + categories, + faqData, + className, + ...props +}: FAQProps) => { + const categoryKeys = Object.keys(categories); + const [selectedCategory, setSelectedCategory] = useState(categoryKeys[0]); + + return ( +
      + + + +
      + ); +}; + +const FAQHeader = ({ title, subtitle }: { title: string; subtitle: string }) => ( +
      + + {subtitle} + +

      {title}

      +
      +); + +const FAQTabs = ({ categories, selected, setSelected }: { categories: Record; selected: string; setSelected: (key: string) => void }) => ( +
      + {Object.entries(categories).map(([key, label]) => ( + + ))} +
      +); + +const FAQList = ({ faqData, selected }: { faqData: Record; selected: string }) => ( +
      + + {Object.entries(faqData).map(([category, questions]) => { + if (selected === category) { + return ( + + {questions.map((faq, index) => ( + + ))} + + ); + } + return null; + })} + +
      +); + +const FAQItemComponent = ({ question, answer }: FAQItem) => { + const [isOpen, setIsOpen] = useState(false); + + return ( + + + +

      {answer}

      +
      +
      + ); +}; diff --git a/components/marketing/FeatureSections.tsx b/components/marketing/FeatureSections.tsx new file mode 100644 index 0000000..2891228 --- /dev/null +++ b/components/marketing/FeatureSections.tsx @@ -0,0 +1,210 @@ +'use client' + +import { motion } from 'framer-motion' +import { Check } from '@phosphor-icons/react' +import { PulseMockup } from './mockups/pulse-mockup' +import { PulseFeaturesCarousel } from './mockups/pulse-features-carousel' +import { FunnelMockup } from './mockups/funnel-mockup' +import { EmailReportMockup } from './mockups/email-report-mockup' + +// Section wrapper component for reuse +function FeatureSection({ + id, + heading, + description, + features, + mockup, + reverse = false, + showBg = true, +}: { + id: string + heading: string + description: string + features: string[] + mockup: React.ReactNode + reverse?: boolean + showBg?: boolean +}) { + return ( +
      +
      + {/* Text */} + +

      + {heading} +

      +

      + {description} +

      +
        + {features.map((item) => ( +
      • + + {item} +
      • + ))} +
      +
      + + {/* Mockup container */} + + {showBg &&
      } +
      + {showBg && ( + <> + +
      + + )} +
      + {mockup} +
      +
      + +
      +
      + ) +} + +export default function FeatureSections() { + return ( +
      + {/* Section 1: Dashboard — text left, mockup right */} + + +
      + } + /> + + {/* Section 2: Visitors — mockup left, text right */} + +
      + +
      +
      + } + /> + + {/* Section 3: Funnels — text left, mockup right */} + + +
      + } + /> + + {/* Section 4: Reports — mockup left, text right */} + + +
      + } + /> + + {/* Section 5: Script — text left, code block right (no showcase bg) */} + + {/* Code block with browser chrome */} +
      +
      +
      +
      +
      +
      + index.html +
      +
      +              
      +                {''}{'\n'}
      +                {'<'}
      +                script{'\n'}
      +                {'  '}defer{'\n'}
      +                {'  '}data-domain="yoursite.com"{'\n'}
      +                {'  '}src="https://pulse.ciphera.net/js/script.js"{'\n'}
      +                {'>'}
      +                {'
      +                script
      +                {'>'}
      +              
      +            
      +
      + 1.6 KB gzipped + + + Non-blocking, async + +
      +
      + } + /> +
      + ) +} diff --git a/components/marketing/Header.tsx b/components/marketing/Header.tsx new file mode 100644 index 0000000..80e45f3 --- /dev/null +++ b/components/marketing/Header.tsx @@ -0,0 +1,309 @@ +'use client'; +import React from 'react'; +import Link from 'next/link'; +import Image from 'next/image'; +import { Button } from '@/components/ui/button-website'; +import { cn } from '@/lib/utils'; +import { MenuToggleIcon } from '@/components/ui/menu-toggle-icon'; +import { createPortal } from 'react-dom'; +import { + NavigationMenu, + NavigationMenuContent, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, + NavigationMenuTrigger, +} from '@/components/ui/navigation-menu'; +import { LucideIcon } from 'lucide-react'; +import { + BarChart3, + Eye, + Funnel, + Send, + FileText, + Puzzle, + HelpCircle, +} from 'lucide-react'; + +type LinkItem = { + title: string; + href: string; + icon?: LucideIcon; + description?: string; +}; + +const featureLinks: LinkItem[] = [ + { + title: 'Dashboard', + href: '/features#dashboard', + icon: BarChart3, + description: 'Real-time traffic overview', + }, + { + title: 'Visitor Insights', + href: '/features#visitors', + icon: Eye, + description: 'Browser, device & geo data', + }, + { + title: 'Conversion Funnels', + href: '/features#funnels', + icon: Funnel, + description: 'Multi-step drop-off analysis', + }, + { + title: 'Email Reports', + href: '/features#reports', + icon: Send, + description: 'Scheduled inbox summaries', + }, +]; + +const resourceLinks: LinkItem[] = [ + { + title: 'Installation', + href: '/installation', + icon: FileText, + description: 'Setup guides & code snippets', + }, + { + title: 'Integrations', + href: '/integrations', + icon: Puzzle, + description: '75+ framework guides', + }, + { + title: 'FAQ', + href: '/faq', + icon: HelpCircle, + description: 'Common questions answered', + }, +]; + +export function Header() { + const [open, setOpen] = React.useState(false); + const scrolled = useScroll(10); + + React.useEffect(() => { + if (open) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + return () => { + document.body.style.overflow = ''; + }; + }, [open]); + + return ( +
      +
      + ); +} + +type MobileMenuProps = React.ComponentProps<'div'> & { + open: boolean; +}; + +function MobileMenu({ open, children, className, ...props }: MobileMenuProps) { + if (!open || typeof window === 'undefined') return null; + + return createPortal( +
      +
      + {children} +
      +
      , + document.body, + ); +} + +function ListItem({ + title, + description, + icon: Icon, + className, + href, + ...props +}: React.ComponentProps & LinkItem) { + return ( + + +
      + {Icon ? ( + + ) : null} +
      +
      + {title} + {description} +
      + +
      + ); +} + +function useScroll(threshold: number) { + const [scrolled, setScrolled] = React.useState(false); + + const onScroll = React.useCallback(() => { + setScrolled(window.scrollY > threshold); + }, [threshold]); + + React.useEffect(() => { + window.addEventListener('scroll', onScroll); + return () => window.removeEventListener('scroll', onScroll); + }, [onScroll]); + + React.useEffect(() => { + onScroll(); + }, [onScroll]); + + return scrolled; +} diff --git a/components/marketing/PulseFAQ.tsx b/components/marketing/PulseFAQ.tsx new file mode 100644 index 0000000..1433178 --- /dev/null +++ b/components/marketing/PulseFAQ.tsx @@ -0,0 +1,112 @@ +'use client' + +import { FAQ } from '@/components/marketing/FAQ' + +const categories: Record = { + general: "General", + setup: "Setup", + privacy: "Privacy & Compliance", + technical: "Technical", +} + +const faqData: Record = { + general: [ + { + question: "What is Pulse?", + answer: "Pulse is a privacy-first website analytics platform by Ciphera. It tracks pageviews, unique visitors, referrers, and geographic data without using cookies, fingerprinting, or collecting any personal data. It's a privacy-respecting alternative to Google Analytics.", + }, + { + question: "Is Pulse free?", + answer: "Yes, Pulse is free for personal websites. We plan to offer a paid Pro tier for teams and high-traffic sites in the future, but the free tier will always be available.", + }, + { + question: "Can I migrate from Google Analytics?", + answer: "Pulse is not a drop-in replacement for Google Analytics — it's fundamentally different by design. It doesn't track individual users or sessions, so historical GA data can't be imported. However, you can run both side by side during a transition period.", + }, + { + question: "Is Pulse open source?", + answer: "The Pulse client — dashboard and tracking script — are open source and available on GitHub. You can inspect every line of code that runs on your site and verify our privacy claims.", + }, + { + question: "How is Pulse different from Plausible or Fathom?", + answer: "Pulse shares the privacy-first philosophy with Plausible and Fathom, but it's built on Swiss infrastructure with Swiss data protection laws. The client — dashboard and tracking script — are open source, and Pulse is part of the Ciphera ecosystem, giving you a unified privacy-first stack.", + }, + ], + setup: [ + { + question: "How do I install Pulse?", + answer: "Add a single script tag to your site's section. That's it. No npm packages, no build steps, no configuration files. The script is under 2KB gzipped and loads asynchronously.", + }, + { + question: "Does Pulse work with my framework?", + answer: "Yes. Pulse works with any website or framework: plain HTML, React, Next.js, Vue, Nuxt, Svelte, WordPress, Shopify, and more. If it renders HTML, Pulse works with it.", + }, + { + question: "How do I verify Pulse is working?", + answer: "After adding the script tag, visit your site and check the Pulse dashboard. You should see your visit appear in real-time within seconds. The dashboard shows a live visitor count and updates every few seconds.", + }, + { + question: "Can I track multiple websites?", + answer: "Yes. Each website gets its own dashboard. You can add as many sites as you need from the Pulse dashboard by adding the script tag with a different data-domain attribute.", + }, + { + question: "Does Pulse slow down my website?", + answer: "No. The Pulse script is under 2KB gzipped — about 20x smaller than Google Analytics. It loads asynchronously with the defer attribute, meaning it never blocks page rendering or affects your Core Web Vitals scores.", + }, + ], + privacy: [ + { + question: "Do I need a cookie consent banner for Pulse?", + answer: "No. Because Pulse doesn't use cookies, fingerprinting, or any form of persistent identifier, it's exempt from ePrivacy cookie consent requirements. You can use Pulse without any consent banner.", + }, + { + question: "Is Pulse GDPR compliant?", + answer: "Yes, by architecture — not by configuration. Pulse doesn't collect any personal data as defined by GDPR Article 4. There are no data subjects in the dataset, so DSAR requests don't apply. No DPA is required.", + }, + { + question: "What happens to IP addresses?", + answer: "IP addresses are used only at the network edge for country-level geolocation. They are immediately discarded after the geo lookup — never stored, never logged, never written to disk. We can't retrieve them even if asked.", + }, + { + question: "Where is my analytics data stored?", + answer: "All data is processed and stored on Swiss infrastructure, protected by the Swiss Federal Act on Data Protection (FADP). Data never leaves Swiss jurisdiction.", + }, + { + question: "Can Pulse identify individual users?", + answer: "No. Pulse is architecturally incapable of identifying individual users. Each pageview is treated as an independent, anonymous event. There are no user IDs, session IDs, or any form of persistent tracking.", + }, + ], + technical: [ + { + question: "How does Pulse count unique visitors without cookies?", + answer: "Pulse uses a privacy-safe hashing method that generates a daily rotating identifier from non-personal data points. This allows approximate unique visitor counts without tracking individuals across sessions or days.", + }, + { + question: "Does Pulse have an API?", + answer: "Yes. Pulse provides a REST API for programmatic access to your analytics data. You can use it to build custom dashboards, integrate with other tools, or export your data.", + }, + { + question: "What metrics does Pulse track?", + answer: "Pulse tracks pageviews, unique visitors, bounce rate, visit duration, referrer sources, UTM parameters, device type, browser, operating system, and country-level geolocation.", + }, + { + question: "Can I export my data?", + answer: "Yes. The dashboard includes an export feature that lets you download your analytics data. You can also use the API for automated exports.", + }, + { + question: "Does Pulse support custom events?", + answer: "Custom event tracking is on our roadmap. Currently, Pulse focuses on pageview analytics. We plan to add lightweight custom event support that maintains our zero-personal-data architecture.", + }, + ], +} + +export default function PulseFAQ() { + return ( + + ) +} diff --git a/components/marketing/mockups/email-report-mockup.tsx b/components/marketing/mockups/email-report-mockup.tsx new file mode 100644 index 0000000..74fe7bc --- /dev/null +++ b/components/marketing/mockups/email-report-mockup.tsx @@ -0,0 +1,86 @@ +'use client' + +export function EmailReportMockup() { + return ( +
      +
      + {/* Pulse logo header */} +
      +
      + + Pulse +
      +
      +
      + + {/* Report content */} +
      +
      +

      ciphera.net

      +

      Daily summary report · 19 Mar 2026

      + +

      Traffic down 6% compared to yesterday

      + + {/* Stats grid */} +
      + {[ + { label: 'PAGEVIEWS', value: '323', change: '2%', down: true }, + { label: 'VISITORS', value: '207', change: '6%', down: true }, + { label: 'BOUNCE', value: '97%', change: '0%', down: false }, + { label: 'DURATION', value: '3m 18s', change: '7%', down: false }, + ].map((stat) => ( +
      +

      {stat.label}

      +

      {stat.value}

      +

      + {stat.down ? '\u25BC' : '\u25B2'} {stat.change} +

      +
      + ))} +
      + + {/* Divider */} +
      + + {/* Top Pages */} +

      Top Pages

      +
      + Page + Views +
      + +
      + {[ + { page: '/', views: 100 }, + { page: '/products/drop', views: 96 }, + { page: '/pricing', views: 42 }, + ].map((row) => ( +
      +
      +
      +
      +
      + {row.views} +
      + {row.page} +
      + ))} +
      +
      + + {/* Schedule indicator */} +
      + Delivered every day at 09:00 + +
      + Sent + +
      +
      +
      +
      + ) +} diff --git a/components/marketing/mockups/funnel-chart.tsx b/components/marketing/mockups/funnel-chart.tsx new file mode 100644 index 0000000..a234ad2 --- /dev/null +++ b/components/marketing/mockups/funnel-chart.tsx @@ -0,0 +1,935 @@ +"use client"; + +import { motion, useSpring, useTransform } from "motion/react"; +import { + type CSSProperties, + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +// ─── Utils ─────────────────────────────────────────────────────────────────── + +function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +// ─── PatternLines ──────────────────────────────────────────────────────────── + +export interface PatternLinesProps { + id: string; + width?: number; + height?: number; + stroke?: string; + strokeWidth?: number; + orientation?: ("diagonal" | "horizontal" | "vertical")[]; + background?: string; +} + +export function PatternLines({ + id, + width = 6, + height = 6, + stroke = "var(--chart-line-primary)", + strokeWidth = 1, + orientation = ["diagonal"], + background, +}: PatternLinesProps) { + const paths: string[] = []; + + for (const o of orientation) { + if (o === "diagonal") { + paths.push(`M0,${height}l${width},${-height}`); + paths.push(`M${-width / 4},${height / 4}l${width / 2},${-height / 2}`); + paths.push( + `M${(3 * width) / 4},${height + height / 4}l${width / 2},${-height / 2}` + ); + } else if (o === "horizontal") { + paths.push(`M0,${height / 2}l${width},0`); + } else if (o === "vertical") { + paths.push(`M${width / 2},0l0,${height}`); + } + } + + return ( + + {background && ( + + )} + + + ); +} + +PatternLines.displayName = "PatternLines"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface FunnelGradientStop { + offset: string | number; + color: string; +} + +export interface FunnelStage { + label: string; + value: number; + displayValue?: string; + color?: string; + gradient?: FunnelGradientStop[]; +} + +export interface FunnelChartProps { + data: FunnelStage[]; + orientation?: "horizontal" | "vertical"; + color?: string; + layers?: number; + className?: string; + style?: CSSProperties; + showPercentage?: boolean; + showValues?: boolean; + showLabels?: boolean; + hoveredIndex?: number | null; + onHoverChange?: (index: number | null) => void; + formatPercentage?: (pct: number) => string; + formatValue?: (value: number) => string; + staggerDelay?: number; + gap?: number; + renderPattern?: (id: string, color: string) => ReactNode; + edges?: "curved" | "straight"; + labelLayout?: "spread" | "grouped"; + labelOrientation?: "vertical" | "horizontal"; + labelAlign?: "center" | "start" | "end"; + grid?: + | boolean + | { + bands?: boolean; + bandColor?: string; + lines?: boolean; + lineColor?: string; + lineOpacity?: number; + lineWidth?: number; + }; +} + +// ─── Defaults ──────────────────────────────────────────────────────────────── + +const fmtPct = (p: number) => `${Math.round(p)}%`; +const fmtVal = (v: number) => v.toLocaleString("en-US"); + +const springConfig = { stiffness: 120, damping: 20, mass: 1 }; +const hoverSpring = { stiffness: 300, damping: 24 }; + +// ─── SVG Helpers ───────────────────────────────────────────────────────────── + +function hSegmentPath( + normStart: number, + normEnd: number, + segW: number, + H: number, + layerScale: number, + straight = false +) { + const my = H / 2; + const h0 = normStart * H * 0.44 * layerScale; + const h1 = normEnd * H * 0.44 * layerScale; + + if (straight) { + return `M 0 ${my - h0} L ${segW} ${my - h1} L ${segW} ${my + h1} L 0 ${my + h0} Z`; + } + + const cx = segW * 0.55; + const top = `M 0 ${my - h0} C ${cx} ${my - h0}, ${segW - cx} ${my - h1}, ${segW} ${my - h1}`; + const bot = `L ${segW} ${my + h1} C ${segW - cx} ${my + h1}, ${cx} ${my + h0}, 0 ${my + h0}`; + return `${top} ${bot} Z`; +} + +function vSegmentPath( + normStart: number, + normEnd: number, + segH: number, + W: number, + layerScale: number, + straight = false +) { + const mx = W / 2; + const w0 = normStart * W * 0.44 * layerScale; + const w1 = normEnd * W * 0.44 * layerScale; + + if (straight) { + return `M ${mx - w0} 0 L ${mx - w1} ${segH} L ${mx + w1} ${segH} L ${mx + w0} 0 Z`; + } + + const cy = segH * 0.55; + const left = `M ${mx - w0} 0 C ${mx - w0} ${cy}, ${mx - w1} ${segH - cy}, ${mx - w1} ${segH}`; + const right = `L ${mx + w1} ${segH} C ${mx + w1} ${segH - cy}, ${mx + w0} ${cy}, ${mx + w0} 0`; + return `${left} ${right} Z`; +} + +// ─── Animated Ring ─────────────────────────────────────────────────────────── + +function HRing({ + d, + color, + fill, + opacity, + hovered, + ringIndex, + totalRings, +}: { + d: string; + color: string; + fill?: string; + opacity: number; + hovered: boolean; + ringIndex: number; + totalRings: number; +}) { + const extraScale = 1 + (ringIndex / Math.max(totalRings - 1, 1)) * 0.12; + const ringSpring = { + stiffness: 300 - ringIndex * 60, + damping: 24 - ringIndex * 3, + }; + const scaleY = useSpring(1, ringSpring); + + useEffect(() => { + scaleY.set(hovered ? extraScale : 1); + }, [hovered, scaleY, extraScale]); + + return ( + + ); +} + +function VRing({ + d, + color, + fill, + opacity, + hovered, + ringIndex, + totalRings, +}: { + d: string; + color: string; + fill?: string; + opacity: number; + hovered: boolean; + ringIndex: number; + totalRings: number; +}) { + const extraScale = 1 + (ringIndex / Math.max(totalRings - 1, 1)) * 0.12; + const ringSpring = { + stiffness: 300 - ringIndex * 60, + damping: 24 - ringIndex * 3, + }; + const scaleX = useSpring(1, ringSpring); + + useEffect(() => { + scaleX.set(hovered ? extraScale : 1); + }, [hovered, scaleX, extraScale]); + + return ( + + ); +} + +// ─── Animated Segments ─────────────────────────────────────────────────────── + +function HSegment({ + index, + normStart, + normEnd, + segW, + fullH, + color, + layers, + staggerDelay, + hovered, + dimmed, + renderPattern, + straight, + gradientStops, +}: { + index: number; + normStart: number; + normEnd: number; + segW: number; + fullH: number; + color: string; + layers: number; + staggerDelay: number; + hovered: boolean; + dimmed: boolean; + renderPattern?: (id: string, color: string) => ReactNode; + straight: boolean; + gradientStops?: FunnelGradientStop[]; +}) { + const patternId = `funnel-h-pattern-${index}`; + const gradientId = `funnel-h-grad-${index}`; + const growProgress = useSpring(0, springConfig); + const entranceScaleX = useTransform(growProgress, [0, 1], [0, 1]); + const entranceScaleY = useTransform(growProgress, [0, 1], [0, 1]); + const dimOpacity = useSpring(1, hoverSpring); + + useEffect(() => { + dimOpacity.set(dimmed ? 0.4 : 1); + }, [dimmed, dimOpacity]); + + useEffect(() => { + const timeout = setTimeout( + () => growProgress.set(1), + index * staggerDelay * 1000 + ); + return () => clearTimeout(timeout); + }, [growProgress, index, staggerDelay]); + + const rings = Array.from({ length: layers }, (_, l) => { + const scale = 1 - (l / layers) * 0.35; + const opacity = 0.18 + (l / (layers - 1 || 1)) * 0.65; + return { + d: hSegmentPath(normStart, normEnd, segW, fullH, scale, straight), + opacity, + }; + }); + + return ( + + + + + + ); +} + +function VSegment({ + index, + normStart, + normEnd, + segH, + fullW, + color, + layers, + staggerDelay, + hovered, + dimmed, + renderPattern, + straight, + gradientStops, +}: { + index: number; + normStart: number; + normEnd: number; + segH: number; + fullW: number; + color: string; + layers: number; + staggerDelay: number; + hovered: boolean; + dimmed: boolean; + renderPattern?: (id: string, color: string) => ReactNode; + straight: boolean; + gradientStops?: FunnelGradientStop[]; +}) { + const patternId = `funnel-v-pattern-${index}`; + const gradientId = `funnel-v-grad-${index}`; + const growProgress = useSpring(0, springConfig); + const entranceScaleY = useTransform(growProgress, [0, 1], [0, 1]); + const entranceScaleX = useTransform(growProgress, [0, 1], [0, 1]); + const dimOpacity = useSpring(1, hoverSpring); + + useEffect(() => { + dimOpacity.set(dimmed ? 0.4 : 1); + }, [dimmed, dimOpacity]); + + useEffect(() => { + const timeout = setTimeout( + () => growProgress.set(1), + index * staggerDelay * 1000 + ); + return () => clearTimeout(timeout); + }, [growProgress, index, staggerDelay]); + + const rings = Array.from({ length: layers }, (_, l) => { + const scale = 1 - (l / layers) * 0.35; + const opacity = 0.18 + (l / (layers - 1 || 1)) * 0.65; + return { + d: vSegmentPath(normStart, normEnd, segH, fullW, scale, straight), + opacity, + }; + }); + + return ( + + + + + + ); +} + +// ─── Label Overlay ─────────────────────────────────────────────────────────── + +function SegmentLabel({ + stage, + pct, + isHorizontal, + showValues, + showPercentage, + showLabels, + formatPercentage, + formatValue, + index, + staggerDelay, + layout = "spread", + orientation, + align = "center", +}: { + stage: FunnelStage; + pct: number; + isHorizontal: boolean; + showValues: boolean; + showPercentage: boolean; + showLabels: boolean; + formatPercentage: (p: number) => string; + formatValue: (v: number) => string; + index: number; + staggerDelay: number; + layout?: "spread" | "grouped"; + orientation?: "vertical" | "horizontal"; + align?: "center" | "start" | "end"; +}) { + const display = stage.displayValue ?? formatValue(stage.value); + + const valueEl = showValues && ( + + {display} + + ); + const pctEl = showPercentage && ( + + {formatPercentage(pct)} + + ); + const labelEl = showLabels && ( + + {stage.label} + + ); + + if (layout === "spread") { + return ( + + {isHorizontal ? ( + <> +
      + {valueEl} +
      +
      + {pctEl} +
      +
      + {labelEl} +
      + + ) : ( + <> +
      + {valueEl} +
      +
      + {pctEl} +
      +
      + {labelEl} +
      + + )} +
      + ); + } + + // Grouped layout + const resolvedOrientation = + orientation ?? (isHorizontal ? "vertical" : "horizontal"); + const isVerticalStack = resolvedOrientation === "vertical"; + + const justifyMap = { + start: "justify-start", + center: "justify-center", + end: "justify-end", + } as const; + const itemsMap = { + start: "items-start", + center: "items-center", + end: "items-end", + } as const; + + return ( + +
      + {valueEl} + {pctEl} + {labelEl} +
      +
      + ); +} + +// ─── FunnelChart ───────────────────────────────────────────────────────────── + +export function FunnelChart({ + data, + orientation = "horizontal", + color = "var(--chart-1)", + layers = 3, + className, + style, + showPercentage = true, + showValues = true, + showLabels = true, + hoveredIndex: hoveredIndexProp, + onHoverChange, + formatPercentage = fmtPct, + formatValue = fmtVal, + staggerDelay = 0.12, + gap = 4, + renderPattern, + edges = "curved", + labelLayout = "spread", + labelOrientation, + labelAlign = "center", + grid: gridProp = false, +}: FunnelChartProps) { + const ref = useRef(null); + const [sz, setSz] = useState({ w: 0, h: 0 }); + const [internalHoveredIndex, setInternalHoveredIndex] = useState< + number | null + >(null); + + const isControlled = hoveredIndexProp !== undefined; + const hoveredIndex = isControlled ? hoveredIndexProp : internalHoveredIndex; + const setHoveredIndex = useCallback( + (index: number | null) => { + if (isControlled) { + onHoverChange?.(index); + } else { + setInternalHoveredIndex(index); + } + }, + [isControlled, onHoverChange] + ); + + const measure = useCallback(() => { + if (!ref.current) return; + const { width: w, height: h } = ref.current.getBoundingClientRect(); + if (w > 0 && h > 0) setSz({ w, h }); + }, []); + + useEffect(() => { + measure(); + const ro = new ResizeObserver(measure); + if (ref.current) ro.observe(ref.current); + return () => ro.disconnect(); + }, [measure]); + + if (!data.length) return null; + + const first = data[0]; + if (!first) return null; + + const max = first.value; + const n = data.length; + const norms = data.map((d) => d.value / max); + const horiz = orientation === "horizontal"; + const { w: W, h: H } = sz; + + const totalGap = gap * (n - 1); + const segW = (W - (horiz ? totalGap : 0)) / n; + const segH = (H - (horiz ? 0 : totalGap)) / n; + + // Grid config + const gridEnabled = gridProp !== false; + const gridCfg = typeof gridProp === "object" ? gridProp : {}; + const showBands = gridEnabled && (gridCfg.bands ?? true); + const bandColor = gridCfg.bandColor ?? "var(--color-muted)"; + const showGridLines = gridEnabled && (gridCfg.lines ?? true); + const gridLineColor = gridCfg.lineColor ?? "var(--chart-grid)"; + const gridLineOpacity = gridCfg.lineOpacity ?? 1; + const gridLineWidth = gridCfg.lineWidth ?? 1; + + return ( +
      + {W > 0 && H > 0 && ( + <> + {/* Grid background bands */} + {gridEnabled && ( + + )} + + {/* Segments */} +
      + {data.map((stage, i) => { + const normStart = norms[i] ?? 0; + const normEnd = norms[Math.min(i + 1, n - 1)] ?? 0; + const firstStop = stage.gradient?.[0]; + const segColor = firstStop + ? firstStop.color + : (stage.color ?? color); + + return horiz ? ( + + ) : ( + + ); + })} +
      + + {/* Grid lines */} + {gridEnabled && showGridLines && ( + + )} + + {/* Label overlays — hover triggers */} + {data.map((stage, i) => { + const pct = (stage.value / max) * 100; + const posStyle: CSSProperties = horiz + ? { left: (segW + gap) * i, width: segW, top: 0, height: H } + : { top: (segH + gap) * i, height: segH, left: 0, width: W }; + const isDimmed = hoveredIndex !== null && hoveredIndex !== i; + + return ( + setHoveredIndex(i)} + onMouseLeave={() => setHoveredIndex(null)} + style={{ ...posStyle, zIndex: 20 }} + transition={{ type: "spring", stiffness: 300, damping: 24 }} + > + + + ); + })} + + )} +
      + ); +} + +FunnelChart.displayName = "FunnelChart"; + +export default FunnelChart; diff --git a/components/marketing/mockups/funnel-mockup.tsx b/components/marketing/mockups/funnel-mockup.tsx new file mode 100644 index 0000000..169449e --- /dev/null +++ b/components/marketing/mockups/funnel-mockup.tsx @@ -0,0 +1,30 @@ +'use client' + +import { FunnelChart } from './funnel-chart' + +const funnelData = [ + { label: 'Homepage', value: 1240 }, + { label: 'Pricing', value: 438 }, + { label: 'Signup', value: 87 }, +] + +export function FunnelMockup() { + return ( +
      +
      +

      Funnel Visualization

      + +
      + Overall conversion: 7% + 7-day window +
      +
      +
      + ) +} diff --git a/components/marketing/mockups/modular-script-mockup.tsx b/components/marketing/mockups/modular-script-mockup.tsx new file mode 100644 index 0000000..4c8425a --- /dev/null +++ b/components/marketing/mockups/modular-script-mockup.tsx @@ -0,0 +1,119 @@ +'use client' + +function Toggle({ on = true }: { on?: boolean }) { + return ( +
      +
      +
      + ) +} + +export function ModularScriptMockup() { + return ( +
      +
      + {/* Features heading */} +

      Features

      + + {/* Feature toggles — 2 column grid */} +
      + {[ + { name: 'Scroll depth', desc: 'Track 25 / 50 / 75 / 100%', on: true }, + { name: '404 detection', desc: 'Auto-detect error pages', on: true }, + { name: 'Outbound links', desc: 'Track external link clicks', on: true }, + { name: 'File downloads', desc: 'Track PDF, ZIP, and more', on: true }, + ].map((feature) => ( +
      +
      +

      {feature.name}

      +

      {feature.desc}

      +
      + +
      + ))} +
      + + {/* Frustration tracking — full width, disabled */} +
      +
      +

      Frustration tracking

      +

      Rage clicks & dead clicks · Loads separate add-on script

      +
      + +
      + + {/* Visitor identity */} +
      +

      Visitor identity

      +

      + How returning visitors are recognized. Stricter settings increase privacy but may raise unique visitor counts. +

      +
      +
      +

      Recognition

      +
      + Across all tabs + +
      +
      +
      +

      Reset after

      +
      + 24 hours + +
      +
      +
      +
      + + {/* Setup guide */} +
      +
      +

      Setup guide

      + All integrations → +
      +
      + {[ + { name: 'Next.js', icon: }, + { name: 'React', icon: }, + { name: 'Vue.js', icon: }, + { name: 'Angular', icon: }, + { name: 'Svelte', icon: }, + { name: 'Nuxt', icon: }, + { name: 'Remix', icon: }, + { name: 'Astro', icon: }, + ].map((fw) => ( + + {fw.icon} + {fw.name} + + ))} +
      +
      + + {/* Verified status */} +
      + + + Verified + + Your site is sending data correctly. +
      +
      +
      + ) +} diff --git a/components/marketing/mockups/pulse-features-carousel.tsx b/components/marketing/mockups/pulse-features-carousel.tsx new file mode 100644 index 0000000..be2def2 --- /dev/null +++ b/components/marketing/mockups/pulse-features-carousel.tsx @@ -0,0 +1,544 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo, type CSSProperties } from 'react' +import { createMap } from 'svg-dotted-map' +import { + Files, + ArrowSquareOut, + MapPin, + Monitor, + Clock, + Globe, + GoogleLogo, + XLogo, + GithubLogo, + YoutubeLogo, + RedditLogo, + Link, +} from '@phosphor-icons/react' + +// ─── Dotted Map Setup (module-level, computed once) ────────────────────────── + +const MAP_WIDTH = 150 +const MAP_HEIGHT = 68 +const DOT_RADIUS = 0.25 + +const { points: MAP_POINTS, addMarkers } = createMap({ + width: MAP_WIDTH, + height: MAP_HEIGHT, + mapSamples: 8000, +}) + +const _stagger = (() => { + const sorted = [...MAP_POINTS].sort((a, b) => a.y - b.y || a.x - b.x) + const rowMap = new Map() + let step = 0 + let prevY = Number.NaN + let prevXInRow = Number.NaN + + for (const p of sorted) { + if (p.y !== prevY) { + prevY = p.y + prevXInRow = Number.NaN + if (!rowMap.has(p.y)) rowMap.set(p.y, rowMap.size) + } + if (!Number.isNaN(prevXInRow)) { + const delta = p.x - prevXInRow + if (delta > 0) step = step === 0 ? delta : Math.min(step, delta) + } + prevXInRow = p.x + } + + return { xStep: step || 1, yToRowIndex: rowMap } +})() + +const BASE_DOTS_PATH = (() => { + const r = DOT_RADIUS + const d = r * 2 + const parts: string[] = [] + for (const point of MAP_POINTS) { + const rowIndex = _stagger.yToRowIndex.get(point.y) ?? 0 + const offsetX = rowIndex % 2 === 1 ? _stagger.xStep / 2 : 0 + const cx = point.x + offsetX + const cy = point.y + parts.push(`M${cx - r},${cy}a${r},${r} 0 1,0 ${d},0a${r},${r} 0 1,0 ${-d},0`) + } + return parts.join('') +})() + +// Country centroids for marker placement (subset) +const COUNTRY_CENTROIDS: Record = { + CH: { lat: 46.8, lng: 8.2 }, + DE: { lat: 51.2, lng: 10.4 }, + US: { lat: 37.1, lng: -95.7 }, + GB: { lat: 55.4, lng: -3.4 }, + FR: { lat: 46.2, lng: 2.2 }, + IN: { lat: 20.6, lng: 78.9 }, + JP: { lat: 36.2, lng: 138.3 }, + AU: { lat: -25.3, lng: 133.8 }, + BR: { lat: -14.2, lng: -51.9 }, + CA: { lat: 56.1, lng: -106.3 }, +} + +// ─── Bar Row (shared by Pages, Referrers, Technology) ──────────────────────── + +function BarRow({ + label, + value, + maxValue, + icon, +}: { + label: string + value: number + maxValue: number + icon?: React.ReactNode +}) { + const pct = (value / maxValue) * 100 + return ( +
      +
      +
      +
      + {icon && {icon}} + {label} +
      +
      + {value} +
      + ) +} + +// ─── Card 1: Pages ─────────────────────────────────────────────────────────── + +function PagesCard() { + const data = [ + { label: '/', value: 142 }, + { label: '/products/drop', value: 68 }, + { label: '/pricing', value: 31 }, + { label: '/blog', value: 24 }, + { label: '/about', value: 12 }, + { label: '/products/pulse', value: 9 }, + ] + const max = data[0].value + + return ( +
      +
      +
      + +

      Pages

      +
      +
      + Top Pages + Entry + Exit +
      +
      +
      + {data.map((d) => ( + + ))} +
      +
      + ) +} + +// ─── Card 2: Referrers ─────────────────────────────────────────────────────── + +function getReferrerIcon(name: string, favicon?: string) { + // Use Google Favicon API for sites with domains (like real Pulse) + if (favicon) { + return ( + // eslint-disable-next-line @next/next/no-img-element + + ) + } + const lower = name.toLowerCase() + if (lower === 'direct') return + if (lower.includes('google')) return + if (lower.includes('twitter') || lower.includes('x')) return + if (lower.includes('github')) return + if (lower.includes('youtube')) return + if (lower.includes('reddit')) return + if (lower.includes('hacker') || lower.includes('hn')) return + return +} + +const FAVICON_URL = 'https://www.google.com/s2/favicons' + +function ReferrersCard() { + const data = [ + { label: 'Direct', value: 186 }, + { label: 'Google', value: 94, domain: 'google.com' }, + { label: 'Twitter / X', value: 47 }, + { label: 'GitHub', value: 32, domain: 'github.com' }, + { label: 'Hacker News', value: 18, domain: 'news.ycombinator.com' }, + { label: 'Reddit', value: 11, domain: 'reddit.com' }, + ] + const max = data[0].value + + return ( +
      +
      + +

      Referrers

      +
      +
      + {data.map((d) => ( + + ))} +
      +
      + ) +} + +// ─── Card 3: Locations (Real Dotted Map) ───────────────────────────────────── + +function LocationsCard() { + const mockData = [ + { country: 'CH', pageviews: 320 }, + { country: 'US', pageviews: 186 }, + { country: 'DE', pageviews: 142 }, + { country: 'GB', pageviews: 78 }, + { country: 'FR', pageviews: 54 }, + { country: 'IN', pageviews: 38 }, + { country: 'JP', pageviews: 22 }, + { country: 'AU', pageviews: 16 }, + { country: 'BR', pageviews: 12 }, + { country: 'CA', pageviews: 28 }, + ] + + const markerData = useMemo(() => { + const max = Math.max(...mockData.map((d) => d.pageviews)) + return mockData + .filter((d) => COUNTRY_CENTROIDS[d.country]) + .map((d) => ({ + lat: COUNTRY_CENTROIDS[d.country].lat, + lng: COUNTRY_CENTROIDS[d.country].lng, + size: 0.4 + (d.pageviews / max) * 0.8, + })) + }, []) + + const processedMarkers = useMemo(() => addMarkers(markerData), [markerData]) + + return ( +
      +
      +
      + +

      Locations

      +
      +
      + Map + Countries + Regions + Cities +
      +
      +
      + + + + + + + + + + + + + {processedMarkers.map((marker, index) => { + const rowIndex = _stagger.yToRowIndex.get(marker.y) ?? 0 + const offsetX = rowIndex % 2 === 1 ? _stagger.xStep / 2 : 0 + const cx = marker.x + offsetX + const cy = marker.y + return ( + + ) + })} + +
      +
      + ) +} + +// ─── Card 4: Technology ────────────────────────────────────────────────────── + +const BROWSER_ICONS: Record = { + Chrome: '/icons/browsers/chrome.svg', + Safari: '/icons/browsers/safari.svg', + Firefox: '/icons/browsers/firefox.svg', + Edge: '/icons/browsers/edge.svg', + Arc: '/icons/browsers/arc.png', + Opera: '/icons/browsers/opera.svg', +} + +function TechnologyCard() { + const data = [ + { label: 'Chrome', value: 412 }, + { label: 'Safari', value: 189 }, + { label: 'Firefox', value: 76 }, + { label: 'Edge', value: 34 }, + { label: 'Arc', value: 18 }, + { label: 'Opera', value: 7 }, + ] + const max = data[0].value + + return ( +
      +
      +
      + +

      Technology

      +
      +
      + Browsers + OS + Devices + Screens +
      +
      +
      + {data.map((d) => ( + + ) : undefined + } + /> + ))} +
      +
      + ) +} + +// ─── Card 5: Peak Hours (Exact Pulse Heatmap) ──────────────────────────────── + +const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] +const BUCKETS = 12 +const BUCKET_LABELS: Record = { 0: '00:00', 3: '06:00', 6: '12:00', 9: '18:00' } + +const HIGHLIGHT_COLORS = [ + 'transparent', + 'rgba(253,94,15,0.15)', + 'rgba(253,94,15,0.35)', + 'rgba(253,94,15,0.60)', + 'rgba(253,94,15,0.82)', + '#FD5E0F', +] + +// Pre-computed mock heatmap grid[day][bucket] with raw values +const MOCK_GRID = [ + [0, 0, 12, 28, 32, 45, 52, 48, 35, 24, 8, 0], // Mon + [0, 0, 8, 22, 38, 50, 58, 46, 40, 28, 12, 4], // Tue + [0, 0, 6, 18, 26, 42, 48, 56, 38, 22, 10, 0], // Wed + [0, 4, 10, 24, 42, 62, 86, 68, 44, 26, 12, 6], // Thu + [0, 6, 16, 34, 44, 58, 64, 48, 42, 28, 14, 0], // Fri + [4, 6, 8, 18, 22, 24, 26, 22, 32, 36, 20, 8], // Sat + [6, 4, 6, 10, 16, 20, 22, 14, 18, 24, 16, 8], // Sun +] + +function getHighlightColor(value: number, max: number): string { + if (value === 0) return HIGHLIGHT_COLORS[0] + if (value === max) return HIGHLIGHT_COLORS[5] + const ratio = value / max + if (ratio <= 0.25) return HIGHLIGHT_COLORS[1] + if (ratio <= 0.50) return HIGHLIGHT_COLORS[2] + if (ratio <= 0.75) return HIGHLIGHT_COLORS[3] + return HIGHLIGHT_COLORS[4] +} + +function PeakHoursCard() { + const max = Math.max(...MOCK_GRID.flat()) + + // Find best time + let bestDay = 0 + let bestBucket = 0 + let bestVal = 0 + for (let d = 0; d < 7; d++) { + for (let b = 0; b < BUCKETS; b++) { + if (MOCK_GRID[d][b] > bestVal) { + bestVal = MOCK_GRID[d][b] + bestDay = d + bestBucket = b + } + } + } + + return ( +
      +
      +
      + +

      Peak Hours

      +
      +
      +

      When your visitors are most active

      + +
      + {MOCK_GRID.map((buckets, dayIdx) => ( +
      + + {DAYS[dayIdx]} + +
      + {buckets.map((value, bucket) => { + const isBestCell = bestDay === dayIdx && bestBucket === bucket + return ( +
      + ) + })} +
      +
      + ))} +
      + + {/* Hour axis labels */} +
      + +
      + {Object.entries(BUCKET_LABELS).map(([b, label]) => ( + + {label} + + ))} + + 24:00 + +
      +
      + + {/* Intensity legend */} +
      + Less + {HIGHLIGHT_COLORS.map((color, i) => ( +
      + ))} + More +
      + +

      + Your busiest time is{' '} + + {['Mondays', 'Tuesdays', 'Wednesdays', 'Thursdays', 'Fridays', 'Saturdays', 'Sundays'][bestDay]} at {String(bestBucket * 2).padStart(2, '0')}:00 + +

      +
      + ) +} + +// ─── Carousel ──────────────────────────────────────────────────────────────── + +const cards = [ + { id: 'pages', Component: PagesCard, title: 'Top Pages' }, + { id: 'referrers', Component: ReferrersCard, title: 'Referrers' }, + { id: 'locations', Component: LocationsCard, title: 'Locations' }, + { id: 'technology', Component: TechnologyCard, title: 'Technology' }, + { id: 'peak-hours', Component: PeakHoursCard, title: 'Peak Hours' }, +] + +export function PulseFeaturesCarousel() { + const [active, setActive] = useState(0) + const [paused, setPaused] = useState(false) + + const next = useCallback(() => { + setActive((prev) => (prev + 1) % cards.length) + }, []) + + useEffect(() => { + if (paused) return + const interval = setInterval(next, 4000) + return () => clearInterval(interval) + }, [paused, next]) + + const ActiveComponent = cards[active].Component + + return ( +
      setPaused(true)} + onMouseLeave={() => setPaused(false)} + > +
      +
      + +
      +
      + + {/* Dot indicators */} +
      + {cards.map((card, i) => ( +
      +
      + ) +} diff --git a/components/marketing/mockups/pulse-mockup.tsx b/components/marketing/mockups/pulse-mockup.tsx new file mode 100644 index 0000000..fc834b4 --- /dev/null +++ b/components/marketing/mockups/pulse-mockup.tsx @@ -0,0 +1,197 @@ +'use client' + +export function PulseMockup() { + return ( +
      +
      + {/* Header row */} +
      +
      +
      +

      Ciphera

      +

      ciphera.net

      +
      +
      +
      + 4 current visitors +
      +
      +
      + +
      + Today + + + +
      +
      +
      + + {/* Filter button */} +
      + +
      + + {/* Stats row */} +
      + {/* Unique Visitors — selected/highlighted */} +
      +
      +

      Unique Visitors

      +
      +

      247

      + + + + + 12% + +
      +

      vs yesterday

      +
      + + {/* Total Pageviews */} +
      +

      Total Pageviews

      +
      +

      512

      + + + + + 23% + +
      +

      vs yesterday

      +
      + + {/* Bounce Rate */} +
      +

      Bounce Rate

      +
      +

      68%

      + + + + + 8% + +
      +

      vs yesterday

      +
      + + {/* Visit Duration */} +
      +

      Visit Duration

      +
      +

      3m 18s

      + + + + + 15% + +
      +

      vs yesterday

      +
      +
      + + {/* Chart area */} +
      + {/* Chart header */} +
      + Unique Visitors +
      +
      + 1 hour + + + +
      +
      +
      + Compare + + + + + + +
      +
      +
      + + {/* SVG Chart — step-style like the real dashboard */} +
      + {/* Y-axis labels */} +
      + 8 + 6 + 4 + 2 + 0 +
      + + {/* Chart */} + + {/* Grid lines */} + + + + + + + {/* Area fill — step-style chart */} + + + {/* Line — step-style */} + + + + + + + + + +
      + + {/* X-axis labels */} +
      + 01:00 + 04:00 + 07:00 + 10:00 + 13:00 + 16:00 + 19:00 +
      +
      + + {/* Live indicator */} +
      +
      + Live · 27 seconds ago +
      +
      +
      + ) +} diff --git a/components/sites/ScriptSetupBlock.tsx b/components/sites/ScriptSetupBlock.tsx index 339fbfb..6648d27 100644 --- a/components/sites/ScriptSetupBlock.tsx +++ b/components/sites/ScriptSetupBlock.tsx @@ -37,6 +37,7 @@ type FeatureKey = (typeof FEATURES)[number]['key'] | 'frustration' export interface ScriptSetupBlockSite { domain: string name?: string + script_features?: Record } interface ScriptSetupBlockProps { @@ -44,27 +45,39 @@ interface ScriptSetupBlockProps { site: ScriptSetupBlockSite /** Called when user copies the script (e.g. for analytics). */ onScriptCopy?: () => void + /** Called when features change so the parent can save to backend. */ + onFeaturesChange?: (features: Record) => void /** Show framework picker. Default true. */ showFrameworkPicker?: boolean /** Optional class for the root wrapper. */ className?: string } +const DEFAULT_FEATURES: Record = { + scroll: true, + '404': true, + outbound: true, + downloads: true, + frustration: false, +} + export default function ScriptSetupBlock({ site, onScriptCopy, + onFeaturesChange, showFrameworkPicker = true, className = '', }: ScriptSetupBlockProps) { + const sf = site.script_features || {} const [features, setFeatures] = useState>({ - scroll: true, - '404': true, - outbound: true, - downloads: true, - frustration: false, + scroll: sf.scroll != null ? Boolean(sf.scroll) : DEFAULT_FEATURES.scroll, + '404': sf['404'] != null ? Boolean(sf['404']) : DEFAULT_FEATURES['404'], + outbound: sf.outbound != null ? Boolean(sf.outbound) : DEFAULT_FEATURES.outbound, + downloads: sf.downloads != null ? Boolean(sf.downloads) : DEFAULT_FEATURES.downloads, + frustration: sf.frustration != null ? Boolean(sf.frustration) : DEFAULT_FEATURES.frustration, }) - const [storage, setStorage] = useState('local') - const [ttl, setTtl] = useState('24') + const [storage, setStorage] = useState(typeof sf.storage === 'string' ? sf.storage : 'local') + const [ttl, setTtl] = useState(typeof sf.ttl === 'string' ? sf.ttl : '24') const [framework, setFramework] = useState('') const [copied, setCopied] = useState(false) @@ -97,7 +110,11 @@ export default function ScriptSetupBlock({ }, [scriptSnippet, onScriptCopy]) const toggleFeature = (key: FeatureKey) => { - setFeatures((prev) => ({ ...prev, [key]: !prev[key] })) + setFeatures((prev) => { + const next = { ...prev, [key]: !prev[key] } + onFeaturesChange?.({ ...next, storage, ttl }) + return next + }) } const selectedIntegration = framework ? getIntegration(framework) : null @@ -201,7 +218,7 @@ export default function ScriptSetupBlock({ { setTtl(v); onFeaturesChange?.({ ...features, storage, ttl: v }) }} options={TTL_OPTIONS} />
      diff --git a/components/ui/area-chart.tsx b/components/ui/area-chart.tsx new file mode 100644 index 0000000..7fe4fd2 --- /dev/null +++ b/components/ui/area-chart.tsx @@ -0,0 +1,2293 @@ +"use client"; + +import { localPoint } from "@visx/event"; +import { curveMonotoneX } from "@visx/curve"; +import { GridColumns, GridRows } from "@visx/grid"; +import { ParentSize } from "@visx/responsive"; +import { scaleLinear, scaleTime, type scaleBand } from "@visx/scale"; +import { AreaClosed, LinePath } from "@visx/shape"; +import { bisector } from "d3-array"; +import { + AnimatePresence, + motion, + useMotionTemplate, + useSpring, +} from "motion/react"; +import { + Children, + createContext, + isValidElement, + useCallback, + useContext, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, + type Dispatch, + type ReactElement, + type ReactNode, + type RefObject, + type SetStateAction, +} from "react"; +import useMeasure from "react-use-measure"; +import { createPortal } from "react-dom"; +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +// ─── Utils ─────────────────────────────────────────────────────────────────── + +function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +// ─── Chart Context ─────────────────────────────────────────────────────────── + +// biome-ignore lint/suspicious/noExplicitAny: d3 curve factory type +type CurveFactory = any; + +type ScaleLinearType = ReturnType< + typeof scaleLinear +>; +type ScaleTimeType = ReturnType< + typeof scaleTime +>; +type ScaleBandType = ReturnType< + typeof scaleBand +>; + +export const chartCssVars = { + background: "var(--chart-background)", + foreground: "var(--chart-foreground)", + foregroundMuted: "var(--chart-foreground-muted)", + label: "var(--chart-label)", + linePrimary: "var(--chart-line-primary)", + lineSecondary: "var(--chart-line-secondary)", + crosshair: "var(--chart-crosshair)", + grid: "var(--chart-grid)", + indicatorColor: "var(--chart-indicator-color)", + indicatorSecondaryColor: "var(--chart-indicator-secondary-color)", + markerBackground: "var(--chart-marker-background)", + markerBorder: "var(--chart-marker-border)", + markerForeground: "var(--chart-marker-foreground)", + badgeBackground: "var(--chart-marker-badge-background)", + badgeForeground: "var(--chart-marker-badge-foreground)", + segmentBackground: "var(--chart-segment-background)", + segmentLine: "var(--chart-segment-line)", +}; + +export interface Margin { + top: number; + right: number; + bottom: number; + left: number; +} + +export interface TooltipData { + point: Record; + index: number; + x: number; + yPositions: Record; + xPositions?: Record; +} + +export interface LineConfig { + dataKey: string; + stroke: string; + strokeWidth: number; +} + +export interface ChartSelection { + startX: number; + endX: number; + startIndex: number; + endIndex: number; + active: boolean; +} + +export interface ChartContextValue { + data: Record[]; + xScale: ScaleTimeType; + yScale: ScaleLinearType; + width: number; + height: number; + innerWidth: number; + innerHeight: number; + margin: Margin; + columnWidth: number; + tooltipData: TooltipData | null; + setTooltipData: Dispatch>; + containerRef: RefObject; + lines: LineConfig[]; + isLoaded: boolean; + animationDuration: number; + xAccessor: (d: Record) => Date; + dateLabels: string[]; + selection?: ChartSelection | null; + clearSelection?: () => void; + barScale?: ScaleBandType; + bandWidth?: number; + hoveredBarIndex?: number | null; + setHoveredBarIndex?: (index: number | null) => void; + barXAccessor?: (d: Record) => string; + orientation?: "vertical" | "horizontal"; + stacked?: boolean; + stackOffsets?: Map>; +} + +const ChartContext = createContext(null); + +function ChartProvider({ + children, + value, +}: { + children: ReactNode; + value: ChartContextValue; +}) { + return ( + {children} + ); +} + +function useChart(): ChartContextValue { + const context = useContext(ChartContext); + if (!context) { + throw new Error( + "useChart must be used within a ChartProvider. " + + "Make sure your component is wrapped in ." + ); + } + return context; +} + +// ─── useChartInteraction ───────────────────────────────────────────────────── + +type ScaleTime = ReturnType>; +type ScaleLinear = ReturnType>; + +interface UseChartInteractionParams { + xScale: ScaleTime; + yScale: ScaleLinear; + data: Record[]; + lines: LineConfig[]; + margin: Margin; + xAccessor: (d: Record) => Date; + bisectDate: ( + data: Record[], + date: Date, + lo: number + ) => number; + canInteract: boolean; +} + +interface ChartInteractionResult { + tooltipData: TooltipData | null; + setTooltipData: Dispatch>; + selection: ChartSelection | null; + clearSelection: () => void; + interactionHandlers: { + onMouseMove?: (event: React.MouseEvent) => void; + onMouseLeave?: () => void; + onMouseDown?: (event: React.MouseEvent) => void; + onMouseUp?: () => void; + onTouchStart?: (event: React.TouchEvent) => void; + onTouchMove?: (event: React.TouchEvent) => void; + onTouchEnd?: () => void; + }; + interactionStyle: React.CSSProperties; +} + +function useChartInteraction({ + xScale, + yScale, + data, + lines, + margin, + xAccessor, + bisectDate, + canInteract, +}: UseChartInteractionParams): ChartInteractionResult { + const [tooltipData, setTooltipData] = useState(null); + const [selection, setSelection] = useState(null); + + const isDraggingRef = useRef(false); + const dragStartXRef = useRef(0); + + const resolveTooltipFromX = useCallback( + (pixelX: number): TooltipData | null => { + const x0 = xScale.invert(pixelX); + const index = bisectDate(data, x0, 1); + const d0 = data[index - 1]; + const d1 = data[index]; + + if (!d0) { + return null; + } + + let d = d0; + let finalIndex = index - 1; + if (d1) { + const d0Time = xAccessor(d0).getTime(); + const d1Time = xAccessor(d1).getTime(); + if (x0.getTime() - d0Time > d1Time - x0.getTime()) { + d = d1; + finalIndex = index; + } + } + + const yPositions: Record = {}; + for (const line of lines) { + const value = d[line.dataKey]; + if (typeof value === "number") { + yPositions[line.dataKey] = yScale(value) ?? 0; + } + } + + return { + point: d, + index: finalIndex, + x: xScale(xAccessor(d)) ?? 0, + yPositions, + }; + }, + [xScale, yScale, data, lines, xAccessor, bisectDate] + ); + + const resolveIndexFromX = useCallback( + (pixelX: number): number => { + const x0 = xScale.invert(pixelX); + const index = bisectDate(data, x0, 1); + const d0 = data[index - 1]; + const d1 = data[index]; + if (!d0) { + return 0; + } + if (d1) { + const d0Time = xAccessor(d0).getTime(); + const d1Time = xAccessor(d1).getTime(); + if (x0.getTime() - d0Time > d1Time - x0.getTime()) { + return index; + } + } + return index - 1; + }, + [xScale, data, xAccessor, bisectDate] + ); + + const getChartX = useCallback( + ( + event: React.MouseEvent | React.TouchEvent, + touchIndex = 0 + ): number | null => { + let point: { x: number; y: number } | null = null; + + if ("touches" in event) { + const touch = event.touches[touchIndex]; + if (!touch) { + return null; + } + const svg = event.currentTarget.ownerSVGElement; + if (!svg) { + return null; + } + point = localPoint(svg, touch as unknown as MouseEvent); + } else { + point = localPoint(event); + } + + if (!point) { + return null; + } + return point.x - margin.left; + }, + [margin.left] + ); + + const handleMouseMove = useCallback( + (event: React.MouseEvent) => { + const chartX = getChartX(event); + if (chartX === null) { + return; + } + + if (isDraggingRef.current) { + const startX = Math.min(dragStartXRef.current, chartX); + const endX = Math.max(dragStartXRef.current, chartX); + setSelection({ + startX, + endX, + startIndex: resolveIndexFromX(startX), + endIndex: resolveIndexFromX(endX), + active: true, + }); + return; + } + + const tooltip = resolveTooltipFromX(chartX); + if (tooltip) { + setTooltipData(tooltip); + } + }, + [getChartX, resolveTooltipFromX, resolveIndexFromX] + ); + + const handleMouseLeave = useCallback(() => { + setTooltipData(null); + if (isDraggingRef.current) { + isDraggingRef.current = false; + } + setSelection(null); + }, []); + + const handleMouseDown = useCallback( + (event: React.MouseEvent) => { + const chartX = getChartX(event); + if (chartX === null) { + return; + } + isDraggingRef.current = true; + dragStartXRef.current = chartX; + setTooltipData(null); + setSelection(null); + }, + [getChartX] + ); + + const handleMouseUp = useCallback(() => { + if (isDraggingRef.current) { + isDraggingRef.current = false; + } + setSelection(null); + }, []); + + const handleTouchStart = useCallback( + (event: React.TouchEvent) => { + if (event.touches.length === 1) { + event.preventDefault(); + const chartX = getChartX(event, 0); + if (chartX === null) { + return; + } + const tooltip = resolveTooltipFromX(chartX); + if (tooltip) { + setTooltipData(tooltip); + } + } else if (event.touches.length === 2) { + event.preventDefault(); + setTooltipData(null); + const x0 = getChartX(event, 0); + const x1 = getChartX(event, 1); + if (x0 === null || x1 === null) { + return; + } + const startX = Math.min(x0, x1); + const endX = Math.max(x0, x1); + setSelection({ + startX, + endX, + startIndex: resolveIndexFromX(startX), + endIndex: resolveIndexFromX(endX), + active: true, + }); + } + }, + [getChartX, resolveTooltipFromX, resolveIndexFromX] + ); + + const handleTouchMove = useCallback( + (event: React.TouchEvent) => { + if (event.touches.length === 1) { + event.preventDefault(); + const chartX = getChartX(event, 0); + if (chartX === null) { + return; + } + const tooltip = resolveTooltipFromX(chartX); + if (tooltip) { + setTooltipData(tooltip); + } + } else if (event.touches.length === 2) { + event.preventDefault(); + const x0 = getChartX(event, 0); + const x1 = getChartX(event, 1); + if (x0 === null || x1 === null) { + return; + } + const startX = Math.min(x0, x1); + const endX = Math.max(x0, x1); + setSelection({ + startX, + endX, + startIndex: resolveIndexFromX(startX), + endIndex: resolveIndexFromX(endX), + active: true, + }); + } + }, + [getChartX, resolveTooltipFromX, resolveIndexFromX] + ); + + const handleTouchEnd = useCallback(() => { + setTooltipData(null); + setSelection(null); + }, []); + + const clearSelection = useCallback(() => { + setSelection(null); + }, []); + + const interactionHandlers = canInteract + ? { + onMouseMove: handleMouseMove, + onMouseLeave: handleMouseLeave, + onMouseDown: handleMouseDown, + onMouseUp: handleMouseUp, + onTouchStart: handleTouchStart, + onTouchMove: handleTouchMove, + onTouchEnd: handleTouchEnd, + } + : {}; + + const interactionStyle: React.CSSProperties = { + cursor: canInteract ? "crosshair" : "default", + touchAction: "none", + }; + + return { + tooltipData, + setTooltipData, + selection, + clearSelection, + interactionHandlers, + interactionStyle, + }; +} + +// ─── Tooltip Components ────────────────────────────────────────────────────── + +// DateTicker + +const TICKER_ITEM_HEIGHT = 24; + +interface DateTickerProps { + currentIndex: number; + labels: string[]; + visible: boolean; +} + +function DateTicker({ currentIndex, labels, visible }: DateTickerProps) { + const parsedLabels = useMemo(() => { + return labels.map((label) => { + const parts = label.split(" "); + const month = parts[0] || ""; + const day = parts[1] || ""; + return { month, day, full: label }; + }); + }, [labels]); + + const monthIndices = useMemo(() => { + const uniqueMonths: string[] = []; + const indices: number[] = []; + + parsedLabels.forEach((label, index) => { + if (uniqueMonths.length === 0 || uniqueMonths.at(-1) !== label.month) { + uniqueMonths.push(label.month); + indices.push(index); + } + }); + + return { uniqueMonths, indices }; + }, [parsedLabels]); + + const currentMonthIndex = useMemo(() => { + if (currentIndex < 0 || currentIndex >= parsedLabels.length) { + return 0; + } + const currentMonth = parsedLabels[currentIndex]?.month; + return monthIndices.uniqueMonths.indexOf(currentMonth || ""); + }, [currentIndex, parsedLabels, monthIndices]); + + const prevMonthIndexRef = useRef(-1); + + const dayY = useSpring(0, { stiffness: 400, damping: 35 }); + const monthY = useSpring(0, { stiffness: 400, damping: 35 }); + + useEffect(() => { + dayY.set(-currentIndex * TICKER_ITEM_HEIGHT); + }, [currentIndex, dayY]); + + useEffect(() => { + if (currentMonthIndex >= 0) { + const isFirstRender = prevMonthIndexRef.current === -1; + const monthChanged = prevMonthIndexRef.current !== currentMonthIndex; + + if (isFirstRender || monthChanged) { + monthY.set(-currentMonthIndex * TICKER_ITEM_HEIGHT); + prevMonthIndexRef.current = currentMonthIndex; + } + } + }, [currentMonthIndex, monthY]); + + if (!visible || labels.length === 0) { + return null; + } + + return ( + +
      +
      +
      + + {monthIndices.uniqueMonths.map((month) => ( +
      + + {month} + +
      + ))} +
      +
      +
      + + {parsedLabels.map((label, index) => ( +
      + + {label.day} + +
      + ))} +
      +
      +
      +
      +
      + ); +} + +DateTicker.displayName = "DateTicker"; + +// TooltipDot + +interface TooltipDotProps { + x: number; + y: number; + visible: boolean; + color: string; + size?: number; + strokeColor?: string; + strokeWidth?: number; +} + +function TooltipDot({ + x, + y, + visible, + color, + size = 5, + strokeColor = chartCssVars.background, + strokeWidth = 2, +}: TooltipDotProps) { + const crosshairSpringConfig = { stiffness: 300, damping: 30 }; + const animatedX = useSpring(x, crosshairSpringConfig); + const animatedY = useSpring(y, crosshairSpringConfig); + + useEffect(() => { + animatedX.set(x); + animatedY.set(y); + }, [x, y, animatedX, animatedY]); + + if (!visible) { + return null; + } + + return ( + + ); +} + +TooltipDot.displayName = "TooltipDot"; + +// TooltipIndicator + +type IndicatorWidth = number | "line" | "thin" | "medium" | "thick"; + +interface TooltipIndicatorProps { + x: number; + height: number; + visible: boolean; + width?: IndicatorWidth; + span?: number; + columnWidth?: number; + colorEdge?: string; + colorMid?: string; + fadeEdges?: boolean; + gradientId?: string; +} + +function resolveWidth(width: IndicatorWidth): number { + if (typeof width === "number") { + return width; + } + switch (width) { + case "line": + return 1; + case "thin": + return 2; + case "medium": + return 4; + case "thick": + return 8; + default: + return 1; + } +} + +function TooltipIndicator({ + x, + height, + visible, + width = "line", + span, + columnWidth, + colorEdge = chartCssVars.crosshair, + colorMid = chartCssVars.crosshair, + fadeEdges = true, + gradientId = "tooltip-indicator-gradient", +}: TooltipIndicatorProps) { + const pixelWidth = + span !== undefined && columnWidth !== undefined + ? span * columnWidth + : resolveWidth(width); + + const crosshairSpringConfig = { stiffness: 300, damping: 30 }; + const animatedX = useSpring(x - pixelWidth / 2, crosshairSpringConfig); + + useEffect(() => { + animatedX.set(x - pixelWidth / 2); + }, [x, animatedX, pixelWidth]); + + if (!visible) { + return null; + } + + const edgeOpacity = fadeEdges ? 0 : 1; + + return ( + + + + + + + + + + + + + ); +} + +TooltipIndicator.displayName = "TooltipIndicator"; + +// TooltipContent + +export interface TooltipRow { + color: string; + label: string; + value: string | number; +} + +interface TooltipContentProps { + title?: string; + rows: TooltipRow[]; + children?: ReactNode; +} + +function TooltipContent({ title, rows, children }: TooltipContentProps) { + const [measureRef, bounds] = useMeasure({ debounce: 0, scroll: false }); + const [committedHeight, setCommittedHeight] = useState(null); + const committedChildrenStateRef = useRef(null); + const frameRef = useRef(null); + + const hasChildren = !!children; + const markerKey = hasChildren ? "has-marker" : "no-marker"; + + const isWaitingForSettlement = + committedChildrenStateRef.current !== null && + committedChildrenStateRef.current !== hasChildren; + + useEffect(() => { + if (bounds.height <= 0) { + return; + } + + if (frameRef.current) { + cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + + if (isWaitingForSettlement) { + frameRef.current = requestAnimationFrame(() => { + frameRef.current = requestAnimationFrame(() => { + setCommittedHeight(bounds.height); + committedChildrenStateRef.current = hasChildren; + }); + }); + } else { + setCommittedHeight(bounds.height); + committedChildrenStateRef.current = hasChildren; + } + + return () => { + if (frameRef.current) { + cancelAnimationFrame(frameRef.current); + } + }; + }, [bounds.height, hasChildren, isWaitingForSettlement]); + + const shouldAnimate = committedHeight !== null; + + return ( + +
      + {title && ( +
      + {title} +
      + )} +
      + {rows.map((row) => ( +
      +
      + + + {row.label} + +
      + + {typeof row.value === "number" + ? row.value.toLocaleString() + : row.value} + +
      + ))} +
      + + + {children && ( + + {children} + + )} + +
      +
      + ); +} + +TooltipContent.displayName = "TooltipContent"; + +// TooltipBox + +interface TooltipBoxProps { + x: number; + y: number; + visible: boolean; + containerRef: RefObject; + containerWidth: number; + containerHeight: number; + offset?: number; + className?: string; + children: ReactNode; + left?: number | ReturnType; + top?: number | ReturnType; + flipped?: boolean; +} + +function TooltipBox({ + x, + y, + visible, + containerRef, + containerWidth, + containerHeight, + offset = 16, + className = "", + children, + left: leftOverride, + top: topOverride, + flipped: flippedOverride, +}: TooltipBoxProps) { + const tooltipRef = useRef(null); + const [tooltipWidth, setTooltipWidth] = useState(180); + const [tooltipHeight, setTooltipHeight] = useState(80); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + useLayoutEffect(() => { + if (tooltipRef.current) { + const w = tooltipRef.current.offsetWidth; + const h = tooltipRef.current.offsetHeight; + if (w > 0 && w !== tooltipWidth) { + setTooltipWidth(w); + } + if (h > 0 && h !== tooltipHeight) { + setTooltipHeight(h); + } + } + }, [tooltipWidth, tooltipHeight]); + + const shouldFlipX = x + tooltipWidth + offset > containerWidth; + const targetX = shouldFlipX ? x - offset - tooltipWidth : x + offset; + + const targetY = Math.max( + offset, + Math.min(y - tooltipHeight / 2, containerHeight - tooltipHeight - offset) + ); + + const prevFlipRef = useRef(shouldFlipX); + const [flipKey, setFlipKey] = useState(0); + + useEffect(() => { + if (prevFlipRef.current !== shouldFlipX) { + setFlipKey((k) => k + 1); + prevFlipRef.current = shouldFlipX; + } + }, [shouldFlipX]); + + const springConfig = { stiffness: 100, damping: 20 }; + const animatedLeft = useSpring(targetX, springConfig); + const animatedTop = useSpring(targetY, springConfig); + + useEffect(() => { + animatedLeft.set(targetX); + }, [targetX, animatedLeft]); + + useEffect(() => { + animatedTop.set(targetY); + }, [targetY, animatedTop]); + + const finalLeft = leftOverride ?? animatedLeft; + const finalTop = topOverride ?? animatedTop; + const isFlipped = flippedOverride ?? shouldFlipX; + const transformOrigin = isFlipped ? "right top" : "left top"; + + const container = containerRef.current; + if (!(mounted && container)) { + return null; + } + + + if (!visible) { + return null; + } + + return createPortal( + + + {children} + + , + container + ); +} + +TooltipBox.displayName = "TooltipBox"; + +// ChartTooltip + +export interface ChartTooltipProps { + showDatePill?: boolean; + showCrosshair?: boolean; + showDots?: boolean; + content?: (props: { + point: Record; + index: number; + }) => ReactNode; + rows?: (point: Record) => TooltipRow[]; + children?: ReactNode; + className?: string; +} + +export function ChartTooltip({ + showDatePill = true, + showCrosshair = true, + showDots = true, + content, + rows: rowsRenderer, + children, + className = "", +}: ChartTooltipProps) { + const { + tooltipData, + width, + height, + innerHeight, + margin, + columnWidth, + lines, + xAccessor, + dateLabels, + containerRef, + orientation, + barXAccessor, + } = useChart(); + + const isHorizontal = orientation === "horizontal"; + + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const visible = tooltipData !== null; + const x = tooltipData?.x ?? 0; + const xWithMargin = x + margin.left; + + const firstLineDataKey = lines[0]?.dataKey; + const firstLineY = firstLineDataKey + ? (tooltipData?.yPositions[firstLineDataKey] ?? 0) + : 0; + const yWithMargin = firstLineY + margin.top; + + const crosshairSpringConfig = { stiffness: 300, damping: 30 }; + const animatedX = useSpring(xWithMargin, crosshairSpringConfig); + + useEffect(() => { + animatedX.set(xWithMargin); + }, [xWithMargin, animatedX]); + + const tooltipRows = useMemo(() => { + if (!tooltipData) { + return []; + } + + if (rowsRenderer) { + return rowsRenderer(tooltipData.point); + } + + return lines.map((line) => ({ + color: line.stroke, + label: line.dataKey, + value: (tooltipData.point[line.dataKey] as number) ?? 0, + })); + }, [tooltipData, lines, rowsRenderer]); + + const title = useMemo(() => { + if (!tooltipData) { + return undefined; + } + if (barXAccessor) { + return barXAccessor(tooltipData.point); + } + return xAccessor(tooltipData.point).toLocaleDateString("en-GB", { + weekday: "short", + month: "short", + day: "numeric", + }); + }, [tooltipData, barXAccessor, xAccessor]); + + const container = containerRef.current; + if (!(mounted && container)) { + return null; + } + + + const tooltipContent = ( + <> + {showCrosshair && ( + + )} + + {showDots && visible && !isHorizontal && ( + + )} + + + {content ? ( + content({ + point: tooltipData?.point ?? {}, + index: tooltipData?.index ?? 0, + }) + ) : ( + + {children} + + )} + + + {showDatePill && dateLabels.length > 0 && visible && !isHorizontal && ( + + + + )} + + ); + + return createPortal(tooltipContent, container); +} + +ChartTooltip.displayName = "ChartTooltip"; + +// ─── Grid ──────────────────────────────────────────────────────────────────── + +export interface GridProps { + horizontal?: boolean; + vertical?: boolean; + numTicksRows?: number; + numTicksColumns?: number; + rowTickValues?: number[]; + stroke?: string; + strokeOpacity?: number; + strokeWidth?: number; + strokeDasharray?: string; + fadeHorizontal?: boolean; + fadeVertical?: boolean; +} + +export function Grid({ + horizontal = true, + vertical = false, + numTicksRows = 5, + numTicksColumns = 10, + rowTickValues, + stroke = chartCssVars.grid, + strokeOpacity = 1, + strokeWidth = 1, + strokeDasharray = "4,4", + fadeHorizontal = true, + fadeVertical = false, +}: GridProps) { + const { xScale, yScale, innerWidth, innerHeight, orientation, barScale } = + useChart(); + + const isHorizontalBarChart = orientation === "horizontal" && barScale; + const columnScale = isHorizontalBarChart ? yScale : xScale; + const uniqueId = useId(); + + const hMaskId = `grid-rows-fade-${uniqueId}`; + const hGradientId = `${hMaskId}-gradient`; + const vMaskId = `grid-cols-fade-${uniqueId}`; + const vGradientId = `${vMaskId}-gradient`; + + return ( + + {horizontal && fadeHorizontal && ( + + + + + + + + + + + + )} + + {vertical && fadeVertical && ( + + + + + + + + + + + + )} + + {horizontal && ( + + + + )} + {vertical && columnScale && typeof columnScale === "function" && ( + + + + )} + + ); +} + +Grid.displayName = "Grid"; + +// ─── XAxis ─────────────────────────────────────────────────────────────────── + +export interface XAxisProps { + numTicks?: number; + tickerHalfWidth?: number; + formatLabel?: (date: Date) => string; +} + +interface XAxisLabelProps { + label: string; + x: number; + crosshairX: number | null; + isHovering: boolean; + tickerHalfWidth: number; +} + +function XAxisLabel({ + label, + x, + crosshairX, + isHovering, + tickerHalfWidth, +}: XAxisLabelProps) { + const fadeBuffer = 20; + const fadeRadius = tickerHalfWidth + fadeBuffer; + + let opacity = 1; + if (isHovering && crosshairX !== null) { + const distance = Math.abs(x - crosshairX); + if (distance < tickerHalfWidth) { + opacity = 0; + } else if (distance < fadeRadius) { + opacity = (distance - tickerHalfWidth) / fadeBuffer; + } + } + + return ( +
      + + {label} + +
      + ); +} + +export function XAxis({ numTicks = 5, tickerHalfWidth = 50, formatLabel }: XAxisProps) { + const { xScale, margin, tooltipData, containerRef } = useChart(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const labelsToShow = useMemo(() => { + const domain = xScale.domain(); + const startDate = domain[0]; + const endDate = domain[1]; + + if (!(startDate && endDate)) { + return []; + } + + const startTime = startDate.getTime(); + const endTime = endDate.getTime(); + const timeRange = endTime - startTime; + + const tickCount = Math.max(2, numTicks); + const dates: Date[] = []; + + for (let i = 0; i < tickCount; i++) { + const t = i / (tickCount - 1); + const time = startTime + t * timeRange; + dates.push(new Date(time)); + } + + const defaultFormat = (d: Date) => d.toLocaleDateString("en-GB", { month: "short", day: "numeric" }); + const fmt = formatLabel ?? defaultFormat; + + return dates.map((date) => ({ + date, + x: (xScale(date) ?? 0) + margin.left, + label: fmt(date), + })); + }, [xScale, margin.left, numTicks, formatLabel]); + + const isHovering = tooltipData !== null; + const crosshairX = tooltipData ? tooltipData.x + margin.left : null; + + const container = containerRef.current; + if (!(mounted && container)) { + return null; + } + + + return createPortal( +
      + {labelsToShow.map((item) => ( + + ))} +
      , + container + ); +} + +XAxis.displayName = "XAxis"; + +// ─── YAxis ─────────────────────────────────────────────────────────────────── + +export interface YAxisProps { + numTicks?: number; + formatValue?: (value: number) => string; +} + +export function YAxis({ + numTicks = 5, + formatValue, +}: YAxisProps) { + const { yScale, margin, containerRef } = useChart(); + const [container, setContainer] = useState(null); + + useEffect(() => { + setContainer(containerRef.current); + }, [containerRef]); + + const ticks = useMemo(() => { + const domain = yScale.domain() as [number, number]; + const min = domain[0]; + const max = domain[1]; + const step = (max - min) / (numTicks - 1); + + return Array.from({ length: numTicks }, (_, i) => { + const value = min + step * i; + return { + value, + y: (yScale(value) ?? 0) + margin.top, + label: formatValue + ? formatValue(value) + : value >= 1000 + ? `${(value / 1000).toFixed(value % 1000 === 0 ? 0 : 1)}k` + : value.toLocaleString(), + }; + }); + }, [yScale, margin.top, numTicks, formatValue]); + + if (!container) { + return null; + } + + return createPortal( +
      + {ticks.map((tick) => ( +
      + + {tick.label} + +
      + ))} +
      , + container + ); +} + +YAxis.displayName = "YAxis"; + +// ─── Area ──────────────────────────────────────────────────────────────────── + +export interface AreaProps { + dataKey: string; + fill?: string; + fillOpacity?: number; + stroke?: string; + strokeWidth?: number; + curve?: CurveFactory; + animate?: boolean; + showLine?: boolean; + showHighlight?: boolean; + gradientToOpacity?: number; + fadeEdges?: boolean; +} + +export function Area({ + dataKey, + fill = chartCssVars.linePrimary, + fillOpacity = 0.4, + stroke, + strokeWidth = 2, + curve = curveMonotoneX, + animate = true, + showLine = true, + showHighlight = true, + gradientToOpacity = 0, + fadeEdges = false, +}: AreaProps) { + const { + data, + xScale, + yScale, + innerHeight, + innerWidth, + tooltipData, + selection, + isLoaded, + animationDuration, + xAccessor, + } = useChart(); + + const pathRef = useRef(null); + const [pathLength, setPathLength] = useState(0); + const [clipWidth, setClipWidth] = useState(0); + + const uniqueId = useId(); + const gradientId = useMemo( + () => `area-gradient-${dataKey}-${Math.random().toString(36).slice(2, 9)}`, + [dataKey] + ); + const strokeGradientId = useMemo( + () => + `area-stroke-gradient-${dataKey}-${Math.random().toString(36).slice(2, 9)}`, + [dataKey] + ); + const edgeMaskId = `area-edge-mask-${dataKey}-${uniqueId}`; + const edgeGradientId = `${edgeMaskId}-gradient`; + + const resolvedStroke = stroke || fill; + + useEffect(() => { + if (pathRef.current && animate) { + const len = pathRef.current.getTotalLength(); + if (len > 0) { + setPathLength(len); + if (!isLoaded) { + requestAnimationFrame(() => { + setClipWidth(innerWidth); + }); + } + } + } + }, [animate, innerWidth, isLoaded]); + + const findLengthAtX = useCallback( + (targetX: number): number => { + const path = pathRef.current; + if (!path || pathLength === 0) { + return 0; + } + let low = 0; + let high = pathLength; + const tolerance = 0.5; + + while (high - low > tolerance) { + const mid = (low + high) / 2; + const point = path.getPointAtLength(mid); + if (point.x < targetX) { + low = mid; + } else { + high = mid; + } + } + return (low + high) / 2; + }, + [pathLength] + ); + + const segmentBounds = useMemo(() => { + if (!pathRef.current || pathLength === 0) { + return { startLength: 0, segmentLength: 0, isActive: false }; + } + + if (selection?.active) { + const startLength = findLengthAtX(selection.startX); + const endLength = findLengthAtX(selection.endX); + return { + startLength, + segmentLength: endLength - startLength, + isActive: true, + }; + } + + if (!tooltipData) { + return { startLength: 0, segmentLength: 0, isActive: false }; + } + + const idx = tooltipData.index; + const startIdx = Math.max(0, idx - 1); + const endIdx = Math.min(data.length - 1, idx + 1); + + const startPoint = data[startIdx]; + const endPoint = data[endIdx]; + if (!(startPoint && endPoint)) { + return { startLength: 0, segmentLength: 0, isActive: false }; + } + + const startX = xScale(xAccessor(startPoint)) ?? 0; + const endX = xScale(xAccessor(endPoint)) ?? 0; + + const startLength = findLengthAtX(startX); + const endLength = findLengthAtX(endX); + + return { + startLength, + segmentLength: endLength - startLength, + isActive: true, + }; + }, [ + tooltipData, + selection, + data, + xScale, + pathLength, + xAccessor, + findLengthAtX, + ]); + + const springConfig = { stiffness: 180, damping: 28 }; + const offsetSpring = useSpring(0, springConfig); + const segmentLengthSpring = useSpring(0, springConfig); + + const animatedDasharray = useMotionTemplate`${segmentLengthSpring} ${pathLength}`; + + useEffect(() => { + offsetSpring.set(-segmentBounds.startLength); + segmentLengthSpring.set(segmentBounds.segmentLength); + }, [ + segmentBounds.startLength, + segmentBounds.segmentLength, + offsetSpring, + segmentLengthSpring, + ]); + + const getY = useCallback( + (d: Record) => { + const value = d[dataKey]; + return typeof value === "number" ? (yScale(value) ?? 0) : 0; + }, + [dataKey, yScale] + ); + + const isHovering = tooltipData !== null || selection?.active === true; + const easing = "cubic-bezier(0.85, 0, 0.15, 1)"; + + return ( + <> + + + + + + + + + + + + + + {fadeEdges && ( + <> + + + + + + + + + + + )} + + + {animate && ( + + + 0 + ? `width ${animationDuration}ms ${easing}` + : "none", + }} + width={isLoaded ? innerWidth : clipWidth} + x={0} + y={0} + /> + + + )} + + + + + xScale(xAccessor(d)) ?? 0} + y={getY} + yScale={yScale} + /> + + + {showLine && ( + xScale(xAccessor(d)) ?? 0} + y={getY} + /> + )} + + + + {showHighlight && + showLine && + isHovering && + isLoaded && + pathRef.current && ( + + )} + + ); +} + +Area.displayName = "Area"; + +// ─── Segment Components ────────────────────────────────────────────────────── + +export function SegmentBackground() { + const { selection, innerHeight } = useChart(); + + if (!selection?.active) { + return null; + } + + const x = Math.min(selection.startX, selection.endX); + const width = Math.abs(selection.endX - selection.startX); + + return ( + + ); +} + +SegmentBackground.displayName = "SegmentBackground"; + +export function SegmentLineFrom() { + const { selection, innerHeight } = useChart(); + + if (!selection?.active) { + return null; + } + + const x = Math.min(selection.startX, selection.endX); + + return ( + + ); +} + +SegmentLineFrom.displayName = "SegmentLineFrom"; + +export function SegmentLineTo() { + const { selection, innerHeight } = useChart(); + + if (!selection?.active) { + return null; + } + + const x = Math.max(selection.startX, selection.endX); + + return ( + + ); +} + +SegmentLineTo.displayName = "SegmentLineTo"; + +// ─── Pattern Components ────────────────────────────────────────────────────── + +export interface PatternLinesProps { + id: string; + width?: number; + height?: number; + stroke?: string; + strokeWidth?: number; + orientation?: ("diagonal" | "horizontal" | "vertical")[]; +} + +export function PatternLines({ + id, + width = 6, + height = 6, + stroke = "var(--chart-line-primary)", + strokeWidth = 1, + orientation = ["diagonal"], +}: PatternLinesProps) { + const paths: string[] = []; + + for (const o of orientation) { + if (o === "diagonal") { + paths.push(`M0,${height}l${width},${-height}`); + paths.push(`M${-width / 4},${height / 4}l${width / 2},${-height / 2}`); + paths.push(`M${(3 * width) / 4},${height + height / 4}l${width / 2},${-height / 2}`); + } else if (o === "horizontal") { + paths.push(`M0,${height / 2}l${width},0`); + } else if (o === "vertical") { + paths.push(`M${width / 2},0l0,${height}`); + } + } + + return ( + + + + + + ); +} + +PatternLines.displayName = "PatternLines"; + +export interface PatternAreaProps { + dataKey: string; + fill?: string; + curve?: CurveFactory; +} + +export function PatternArea({ + dataKey, + fill = "url(#area-pattern)", + curve = curveMonotoneX, +}: PatternAreaProps) { + const { data, xScale, yScale, xAccessor } = useChart(); + + const getY = useCallback( + (d: Record) => { + const value = d[dataKey]; + return typeof value === "number" ? (yScale(value) ?? 0) : 0; + }, + [dataKey, yScale] + ); + + return ( + xScale(xAccessor(d)) ?? 0} + y={getY} + yScale={yScale} + /> + ); +} + +PatternArea.displayName = "PatternArea"; + +// ─── AreaChart ─────────────────────────────────────────────────────────────── + +function isPostOverlayComponent(child: ReactElement): boolean { + const childType = child.type as { + displayName?: string; + name?: string; + __isChartMarkers?: boolean; + }; + + if (childType.__isChartMarkers) { + return true; + } + + const componentName = + typeof child.type === "function" + ? childType.displayName || childType.name || "" + : ""; + + return componentName === "ChartMarkers" || componentName === "MarkerGroup"; +} + +function extractAreaConfigs(children: ReactNode): LineConfig[] { + const configs: LineConfig[] = []; + + Children.forEach(children, (child) => { + if (!isValidElement(child)) { + return; + } + + const childType = child.type as { + displayName?: string; + name?: string; + }; + const componentName = + typeof child.type === "function" + ? childType.displayName || childType.name || "" + : ""; + + const props = child.props as AreaProps | undefined; + const isAreaComponent = + componentName === "Area" || + child.type === Area || + (props && typeof props.dataKey === "string" && props.dataKey.length > 0); + + if (isAreaComponent && props?.dataKey) { + configs.push({ + dataKey: props.dataKey, + stroke: props.stroke || props.fill || "var(--chart-line-primary)", + strokeWidth: props.strokeWidth || 2, + }); + } + }); + + return configs; +} + +export interface AreaChartProps { + data: Record[]; + xDataKey?: string; + margin?: Partial; + animationDuration?: number; + aspectRatio?: string; + className?: string; + children: ReactNode; +} + +const DEFAULT_MARGIN: Margin = { top: 40, right: 40, bottom: 40, left: 40 }; + +interface ChartInnerProps { + width: number; + height: number; + data: Record[]; + xDataKey: string; + margin: Margin; + animationDuration: number; + children: ReactNode; + containerRef: RefObject; +} + +function ChartInner({ + width, + height, + data, + xDataKey, + margin, + animationDuration, + children, + containerRef, +}: ChartInnerProps) { + const [isLoaded, setIsLoaded] = useState(false); + + const lines = useMemo(() => extractAreaConfigs(children), [children]); + + const innerWidth = width - margin.left - margin.right; + const innerHeight = height - margin.top - margin.bottom; + + const xAccessor = useCallback( + (d: Record): Date => { + const value = d[xDataKey]; + return value instanceof Date ? value : new Date(value as string | number); + }, + [xDataKey] + ); + + const bisectDate = useMemo( + () => bisector, Date>((d) => xAccessor(d)).left, + [xAccessor] + ); + + const xScale = useMemo(() => { + const dates = data.map((d) => xAccessor(d)); + const minTime = Math.min(...dates.map((d) => d.getTime())); + const maxTime = Math.max(...dates.map((d) => d.getTime())); + + return scaleTime({ + range: [0, innerWidth], + domain: [minTime, maxTime], + }); + }, [innerWidth, data, xAccessor]); + + const columnWidth = useMemo(() => { + if (data.length < 2) { + return 0; + } + return innerWidth / (data.length - 1); + }, [innerWidth, data.length]); + + const yScale = useMemo(() => { + let maxValue = 0; + for (const line of lines) { + for (const d of data) { + const value = d[line.dataKey]; + if (typeof value === "number" && value > maxValue) { + maxValue = value; + } + } + } + + if (maxValue === 0) { + maxValue = 100; + } + + return scaleLinear({ + range: [innerHeight, 0], + domain: [0, maxValue * 1.1], + nice: true, + }); + }, [innerHeight, data, lines]); + + const dateLabels = useMemo( + () => + data.map((d) => + xAccessor(d).toLocaleDateString("en-GB", { + month: "short", + day: "numeric", + }) + ), + [data, xAccessor] + ); + + useEffect(() => { + const timer = setTimeout(() => { + setIsLoaded(true); + }, animationDuration); + return () => clearTimeout(timer); + }, [animationDuration]); + + const canInteract = isLoaded; + + const { + tooltipData, + setTooltipData, + selection, + clearSelection, + interactionHandlers, + interactionStyle, + } = useChartInteraction({ + xScale, + yScale, + data, + lines, + margin, + xAccessor, + bisectDate, + canInteract, + }); + + if (width < 10 || height < 10) { + return null; + } + + const preOverlayChildren: ReactElement[] = []; + const postOverlayChildren: ReactElement[] = []; + + Children.forEach(children, (child) => { + if (!isValidElement(child)) { + return; + } + + if (isPostOverlayComponent(child)) { + postOverlayChildren.push(child); + } else { + preOverlayChildren.push(child); + } + }); + + const contextValue = { + data, + xScale, + yScale, + width, + height, + innerWidth, + innerHeight, + margin, + columnWidth, + tooltipData, + setTooltipData, + containerRef, + lines, + isLoaded, + animationDuration, + xAccessor, + dateLabels, + selection, + clearSelection, + }; + + return ( + + + + ); +} + +export function AreaChart({ + data, + xDataKey = "date", + margin: marginProp, + animationDuration = 1100, + aspectRatio = "2 / 1", + className = "", + children, +}: AreaChartProps) { + const containerRef = useRef(null); + const margin = { ...DEFAULT_MARGIN, ...marginProp }; + + return ( +
      + + {({ width, height }) => ( + + {children} + + )} + +
      + ); +} + +export default AreaChart; diff --git a/components/ui/bar-chart.tsx b/components/ui/bar-chart.tsx new file mode 100644 index 0000000..160952c --- /dev/null +++ b/components/ui/bar-chart.tsx @@ -0,0 +1,911 @@ +"use client"; + +import { localPoint } from "@visx/event"; +import { LinearGradient as VisxLinearGradient } from "@visx/gradient"; +import { GridColumns, GridRows } from "@visx/grid"; +import { ParentSize } from "@visx/responsive"; +import { scaleBand, scaleLinear } from "@visx/scale"; +import { + AnimatePresence, + motion, + useSpring, +} from "motion/react"; +import { + Children, + createContext, + isValidElement, + useCallback, + useContext, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, + type Dispatch, + type ReactElement, + type ReactNode, + type RefObject, + type SetStateAction, +} from "react"; +import useMeasure from "react-use-measure"; +import { createPortal } from "react-dom"; +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +// ─── Utils ─────────────────────────────────────────────────────────────────── + +function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +// ─── CSS Vars ──────────────────────────────────────────────────────────────── + +export const chartCssVars = { + background: "var(--chart-background)", + foreground: "var(--chart-foreground)", + foregroundMuted: "var(--chart-foreground-muted)", + label: "var(--chart-label)", + linePrimary: "var(--chart-line-primary)", + lineSecondary: "var(--chart-line-secondary)", + crosshair: "var(--chart-crosshair)", + grid: "var(--chart-grid)", +}; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface Margin { + top: number; + right: number; + bottom: number; + left: number; +} + +export interface TooltipData { + point: Record; + index: number; + x: number; + yPositions: Record; + xPositions?: Record; +} + +export interface TooltipRow { + color: string; + label: string; + value: string | number; +} + +export interface LineConfig { + dataKey: string; + stroke: string; + strokeWidth: number; +} + +// ─── Bar Chart Context ─────────────────────────────────────────────────────── + +type ScaleLinearType = ReturnType>; +type ScaleBandType = ReturnType< + typeof scaleBand +>; + +export interface BarChartContextValue { + data: Record[]; + xScale: ScaleBandType; + yScale: ScaleLinearType; + width: number; + height: number; + innerWidth: number; + innerHeight: number; + margin: Margin; + bandWidth: number; + tooltipData: TooltipData | null; + setTooltipData: Dispatch>; + containerRef: RefObject; + bars: BarConfig[]; + isLoaded: boolean; + animationDuration: number; + xDataKey: string; + hoveredBarIndex: number | null; + setHoveredBarIndex: (index: number | null) => void; + orientation: "vertical" | "horizontal"; + stacked: boolean; + stackGap: number; + stackOffsets: Map>; + barGap: number; + barWidth?: number; +} + +interface BarConfig { + dataKey: string; + fill: string; + stroke?: string; +} + +const BarChartContext = createContext(null); + +function BarChartProvider({ + children, + value, +}: { + children: ReactNode; + value: BarChartContextValue; +}) { + return ( + + {children} + + ); +} + +export function useChart(): BarChartContextValue { + const context = useContext(BarChartContext); + if (!context) { + throw new Error( + "useChart must be used within a BarChartProvider. " + + "Make sure your component is wrapped in ." + ); + } + return context; +} + +// ─── Tooltip Components ────────────────────────────────────────────────────── + +interface TooltipDotProps { + x: number; + y: number; + visible: boolean; + color: string; + size?: number; + strokeColor?: string; + strokeWidth?: number; +} + +function TooltipDot({ + x, + y, + visible, + color, + size = 5, + strokeColor = chartCssVars.background, + strokeWidth = 2, +}: TooltipDotProps) { + const springConfig = { stiffness: 300, damping: 30 }; + const animatedX = useSpring(x, springConfig); + const animatedY = useSpring(y, springConfig); + + useEffect(() => { + animatedX.set(x); + animatedY.set(y); + }, [x, y, animatedX, animatedY]); + + if (!visible) return null; + + return ( + + ); +} + +TooltipDot.displayName = "TooltipDot"; + +interface TooltipIndicatorProps { + x: number; + height: number; + visible: boolean; + width?: number; + colorEdge?: string; + colorMid?: string; + fadeEdges?: boolean; + gradientId?: string; +} + +function TooltipIndicator({ + x, + height, + visible, + width = 1, + colorEdge = chartCssVars.crosshair, + colorMid = chartCssVars.crosshair, + fadeEdges = true, + gradientId = "bar-tooltip-indicator-gradient", +}: TooltipIndicatorProps) { + const springConfig = { stiffness: 300, damping: 30 }; + const animatedX = useSpring(x - width / 2, springConfig); + + useEffect(() => { + animatedX.set(x - width / 2); + }, [x, animatedX, width]); + + if (!visible) return null; + + const edgeOpacity = fadeEdges ? 0 : 1; + + return ( + + + + + + + + + + + + + ); +} + +TooltipIndicator.displayName = "TooltipIndicator"; + +interface TooltipContentProps { + title?: string; + rows: TooltipRow[]; + children?: ReactNode; +} + +function TooltipContent({ title, rows, children }: TooltipContentProps) { + const [measureRef, bounds] = useMeasure({ debounce: 0, scroll: false }); + const [committedHeight, setCommittedHeight] = useState(null); + const committedChildrenStateRef = useRef(null); + const frameRef = useRef(null); + + const hasChildren = !!children; + const markerKey = hasChildren ? "has-marker" : "no-marker"; + const isWaitingForSettlement = committedChildrenStateRef.current !== null && committedChildrenStateRef.current !== hasChildren; + + useEffect(() => { + if (bounds.height <= 0) return; + if (frameRef.current) { cancelAnimationFrame(frameRef.current); frameRef.current = null; } + if (isWaitingForSettlement) { + frameRef.current = requestAnimationFrame(() => { + frameRef.current = requestAnimationFrame(() => { + setCommittedHeight(bounds.height); + committedChildrenStateRef.current = hasChildren; + }); + }); + } else { + setCommittedHeight(bounds.height); + committedChildrenStateRef.current = hasChildren; + } + return () => { if (frameRef.current) cancelAnimationFrame(frameRef.current); }; + }, [bounds.height, hasChildren, isWaitingForSettlement]); + + const shouldAnimate = committedHeight !== null; + + return ( + +
      + {title &&
      {title}
      } +
      + {rows.map((row) => ( +
      +
      + + {row.label} +
      + + {typeof row.value === "number" ? row.value.toLocaleString() : row.value} + +
      + ))} +
      + + {children && ( + + {children} + + )} + +
      +
      + ); +} + +TooltipContent.displayName = "TooltipContent"; + +interface TooltipBoxProps { + x: number; + y: number; + visible: boolean; + containerRef: RefObject; + containerWidth: number; + containerHeight: number; + offset?: number; + className?: string; + children: ReactNode; + top?: number | ReturnType; +} + +function TooltipBox({ + x, y, visible, containerRef, containerWidth, containerHeight, offset = 16, className = "", children, top: topOverride, +}: TooltipBoxProps) { + const tooltipRef = useRef(null); + const [tooltipWidth, setTooltipWidth] = useState(180); + const [tooltipHeight, setTooltipHeight] = useState(80); + const [mounted, setMounted] = useState(false); + + useEffect(() => { setMounted(true); }, []); + + useLayoutEffect(() => { + if (tooltipRef.current) { + const w = tooltipRef.current.offsetWidth; + const h = tooltipRef.current.offsetHeight; + if (w > 0 && w !== tooltipWidth) setTooltipWidth(w); + if (h > 0 && h !== tooltipHeight) setTooltipHeight(h); + } + }, [tooltipWidth, tooltipHeight]); + + const shouldFlipX = x + tooltipWidth + offset > containerWidth; + const targetX = shouldFlipX ? x - offset - tooltipWidth : x + offset; + const targetY = Math.max(offset, Math.min(y - tooltipHeight / 2, containerHeight - tooltipHeight - offset)); + + const prevFlipRef = useRef(shouldFlipX); + const [flipKey, setFlipKey] = useState(0); + + useEffect(() => { + if (prevFlipRef.current !== shouldFlipX) { setFlipKey((k) => k + 1); prevFlipRef.current = shouldFlipX; } + }, [shouldFlipX]); + + const springConfig = { stiffness: 100, damping: 20 }; + const animatedLeft = useSpring(targetX, springConfig); + const animatedTop = useSpring(targetY, springConfig); + + useEffect(() => { animatedLeft.set(targetX); }, [targetX, animatedLeft]); + useEffect(() => { animatedTop.set(targetY); }, [targetY, animatedTop]); + + const finalTop = topOverride ?? animatedTop; + const transformOrigin = shouldFlipX ? "right top" : "left top"; + + const container = containerRef.current; + if (!(mounted && container)) return null; + if (!visible) return null; + + return createPortal( + + + {children} + + , + container + ); +} + +TooltipBox.displayName = "TooltipBox"; + +// ─── ChartTooltip ──────────────────────────────────────────────────────────── + +export interface ChartTooltipProps { + showCrosshair?: boolean; + showDots?: boolean; + content?: (props: { point: Record; index: number }) => ReactNode; + rows?: (point: Record) => TooltipRow[]; + children?: ReactNode; + className?: string; +} + +export function ChartTooltip({ showCrosshair = true, showDots = true, content, rows: rowsRenderer, children, className = "" }: ChartTooltipProps) { + const { tooltipData, width, height, innerHeight, margin, bars, xDataKey, containerRef, orientation, yScale } = useChart(); + const isHorizontal = orientation === "horizontal"; + const [mounted, setMounted] = useState(false); + useEffect(() => { setMounted(true); }, []); + + const visible = tooltipData !== null; + const x = tooltipData?.x ?? 0; + const xWithMargin = x + margin.left; + const firstBarDataKey = bars[0]?.dataKey; + const firstBarY = firstBarDataKey ? (tooltipData?.yPositions[firstBarDataKey] ?? 0) : 0; + const yWithMargin = firstBarY + margin.top; + + const springConfig = { stiffness: 300, damping: 30 }; + const animatedX = useSpring(xWithMargin, springConfig); + useEffect(() => { animatedX.set(xWithMargin); }, [xWithMargin, animatedX]); + + const tooltipRows = useMemo(() => { + if (!tooltipData) return []; + if (rowsRenderer) return rowsRenderer(tooltipData.point); + return bars.map((bar) => ({ color: bar.stroke || bar.fill, label: bar.dataKey, value: (tooltipData.point[bar.dataKey] as number) ?? 0 })); + }, [tooltipData, bars, rowsRenderer]); + + const title = useMemo(() => { + if (!tooltipData) return undefined; + return String(tooltipData.point[xDataKey] ?? ""); + }, [tooltipData, xDataKey]); + + const container = containerRef.current; + if (!(mounted && container)) return null; + + const tooltipContent = ( + <> + {showCrosshair && !isHorizontal && ( + + )} + {showDots && visible && !isHorizontal && ( + + )} + + {content ? content({ point: tooltipData?.point ?? {}, index: tooltipData?.index ?? 0 }) : ( + {children} + )} + + + ); + + return createPortal(tooltipContent, container); +} + +ChartTooltip.displayName = "ChartTooltip"; + +// ─── Grid ──────────────────────────────────────────────────────────────────── + +export interface GridProps { + horizontal?: boolean; + vertical?: boolean; + numTicksRows?: number; + numTicksColumns?: number; + rowTickValues?: number[]; + stroke?: string; + strokeOpacity?: number; + strokeWidth?: number; + strokeDasharray?: string; + fadeHorizontal?: boolean; + fadeVertical?: boolean; +} + +export function Grid({ + horizontal = true, vertical = false, numTicksRows = 5, numTicksColumns = 10, rowTickValues, + stroke = chartCssVars.grid, strokeOpacity = 1, strokeWidth = 1, strokeDasharray = "4,4", + fadeHorizontal = true, fadeVertical = false, +}: GridProps) { + const { xScale, yScale, innerWidth, innerHeight, orientation } = useChart(); + const isHorizontalBar = orientation === "horizontal"; + const columnScale = isHorizontalBar ? yScale : xScale; + const uniqueId = useId(); + const hMaskId = `grid-rows-fade-${uniqueId}`; + const hGradientId = `${hMaskId}-gradient`; + const vMaskId = `grid-cols-fade-${uniqueId}`; + const vGradientId = `${vMaskId}-gradient`; + + return ( + + {horizontal && fadeHorizontal && ( + + + + + + + + + + )} + {vertical && fadeVertical && ( + + + + + + + + + + )} + {horizontal && ( + + + + )} + {vertical && columnScale && typeof columnScale === "function" && ( + + + + )} + + ); +} + +Grid.displayName = "Grid"; + +// ─── BarXAxis ──────────────────────────────────────────────────────────────── + +export interface BarXAxisProps { + tickerHalfWidth?: number; + showAllLabels?: boolean; + maxLabels?: number; +} + +export function BarXAxis({ tickerHalfWidth = 50, showAllLabels = false, maxLabels = 12 }: BarXAxisProps) { + const { xScale, margin, tooltipData, containerRef, bandWidth } = useChart(); + const [mounted, setMounted] = useState(false); + useEffect(() => { setMounted(true); }, []); + + const labelsToShow = useMemo(() => { + const domain = xScale.domain(); + if (domain.length === 0) return []; + let labels = domain.map((label) => ({ label, x: (xScale(label) ?? 0) + bandWidth / 2 + margin.left })); + if (!showAllLabels && labels.length > maxLabels) { + const step = Math.ceil(labels.length / maxLabels); + labels = labels.filter((_, i) => i % step === 0); + } + return labels; + }, [xScale, margin.left, bandWidth, showAllLabels, maxLabels]); + + const isHovering = tooltipData !== null; + const crosshairX = tooltipData ? tooltipData.x + margin.left : null; + + const container = containerRef.current; + if (!(mounted && container)) return null; + + return createPortal( +
      + {labelsToShow.map((item) => { + let opacity = 1; + if (isHovering && crosshairX !== null) { + const fadeBuffer = 20; + const fadeRadius = tickerHalfWidth + fadeBuffer; + const distance = Math.abs(item.x - crosshairX); + if (distance < tickerHalfWidth) opacity = 0; + else if (distance < fadeRadius) opacity = (distance - tickerHalfWidth) / fadeBuffer; + } + return ( +
      + + {item.label} + +
      + ); + })} +
      , + container + ); +} + +BarXAxis.displayName = "BarXAxis"; + +// ─── BarValueAxis (numeric Y-axis for vertical bar charts) ─────────────── + +export interface BarValueAxisProps { + numTicks?: number; + formatValue?: (value: number) => string; +} + +export function BarValueAxis({ numTicks = 5, formatValue }: BarValueAxisProps) { + const { yScale, margin, containerRef } = useChart(); + const [container, setContainer] = useState(null); + + useEffect(() => { setContainer(containerRef.current); }, [containerRef]); + + const ticks = useMemo(() => { + const domain = yScale.domain() as [number, number]; + const min = domain[0]; + const max = domain[1]; + const step = (max - min) / (numTicks - 1); + return Array.from({ length: numTicks }, (_, i) => { + const value = min + step * i; + return { + value, + y: (yScale(value) ?? 0) + margin.top, + label: formatValue ? formatValue(value) : value >= 1000 ? `${(value / 1000).toFixed(value % 1000 === 0 ? 0 : 1)}k` : Math.round(value).toLocaleString(), + }; + }); + }, [yScale, margin.top, numTicks, formatValue]); + + if (!container) return null; + + return createPortal( +
      + {ticks.map((tick) => ( +
      + {tick.label} +
      + ))} +
      , + container + ); +} + +BarValueAxis.displayName = "BarValueAxis"; + +// ─── Bar ───────────────────────────────────────────────────────────────────── + +export interface BarProps { + dataKey: string; + fill?: string; + stroke?: string; + lineCap?: "round" | "butt" | number; + animate?: boolean; + animationType?: "grow" | "fade"; + fadedOpacity?: number; + staggerDelay?: number; + stackGap?: number; +} + +function resolveRadius(lineCap: "round" | "butt" | number, barWidth: number): number { + if (lineCap === "butt") return 0; + if (lineCap === "round") return barWidth / 2; + return lineCap; +} + +export function Bar({ + dataKey, fill = chartCssVars.linePrimary, stroke, lineCap = "round", animate = true, + animationType = "grow", fadedOpacity = 0.3, staggerDelay, stackGap = 0, +}: BarProps) { + const { + data, xScale, yScale, innerHeight, innerWidth, bandWidth, hoveredBarIndex, isLoaded, animationDuration, + xDataKey, orientation, stacked, stackOffsets, bars, barWidth: fixedBarWidth, + } = useChart(); + + const isHorizontal = orientation === "horizontal"; + const barIndex = bars.findIndex((b) => b.dataKey === dataKey); + const barCount = bars.length; + const singleBarWidth = stacked ? bandWidth : bandWidth / barCount; + const actualBarWidth = fixedBarWidth ?? singleBarWidth; + const radius = resolveRadius(lineCap, actualBarWidth); + const autoStagger = staggerDelay ?? Math.min(0.06, 0.8 / data.length); + + return ( + <> + {data.map((d, i) => { + const category = String(d[xDataKey] ?? ""); + const value = typeof d[dataKey] === "number" ? (d[dataKey] as number) : 0; + const bandStart = xScale(category) ?? 0; + const stackOffset = stacked ? stackOffsets.get(i)?.get(dataKey) ?? 0 : 0; + + let barX: number, barY: number, barW: number, barH: number; + + if (isHorizontal) { + const barLength = innerWidth - (yScale(value) ?? innerWidth); + barY = bandStart + (stacked ? 0 : barIndex * singleBarWidth); + barH = actualBarWidth; + barW = barLength; + barX = stacked ? stackOffset : 0; + if (stacked && stackGap > 0 && barIndex > 0) { barX += stackGap; barW = Math.max(0, barW - stackGap); } + } else { + const scaledY = yScale(value) ?? innerHeight; + barX = bandStart + (stacked ? 0 : barIndex * singleBarWidth); + barW = actualBarWidth; + barH = innerHeight - scaledY; + barY = stacked ? scaledY - stackOffset : scaledY; + if (stacked && stackGap > 0 && barIndex > 0) { barY += stackGap; barH = Math.max(0, barH - stackGap); } + } + + if (barW <= 0 || barH <= 0) return null; + + const isHovered = hoveredBarIndex === i; + const someoneHovered = hoveredBarIndex !== null; + const barOpacity = someoneHovered ? (isHovered ? 1 : fadedOpacity) : 1; + const delay = i * autoStagger; + const r = Math.min(radius, barW / 2, barH / 2); + + let path: string; + if (isHorizontal) { + path = `M${barX},${barY} L${barX + barW - r},${barY} Q${barX + barW},${barY} ${barX + barW},${barY + r} L${barX + barW},${barY + barH - r} Q${barX + barW},${barY + barH} ${barX + barW - r},${barY + barH} L${barX},${barY + barH}Z`; + } else { + path = `M${barX},${barY + barH} L${barX},${barY + r} Q${barX},${barY} ${barX + r},${barY} L${barX + barW - r},${barY} Q${barX + barW},${barY} ${barX + barW},${barY + r} L${barX + barW},${barY + barH}Z`; + } + + const originX = isHorizontal ? barX : barX + barW / 2; + const originY = isHorizontal ? barY + barH / 2 : innerHeight; + const shouldAnimateEntry = animate && !isLoaded; + + const growInitial = isHorizontal ? { scaleX: 0, opacity: 0 } : { scaleY: 0, opacity: 0 }; + const growAnimate = isHorizontal ? { scaleX: 1, opacity: barOpacity } : { scaleY: 1, opacity: barOpacity }; + const growTransition = { + [isHorizontal ? "scaleX" : "scaleY"]: { duration: animationDuration / 1000, ease: [0.85, 0, 0.15, 1] as [number, number, number, number], delay }, + opacity: { duration: 0.3, ease: "easeInOut" as const }, + }; + + return ( + + ); + })} + + ); +} + +Bar.displayName = "Bar"; + +// ─── Re-exports ────────────────────────────────────────────────────────────── + +export { VisxLinearGradient as LinearGradient }; + +// ─── BarChart ──────────────────────────────────────────────────────────────── + +function extractBarConfigs(children: ReactNode): BarConfig[] { + const configs: BarConfig[] = []; + Children.forEach(children, (child) => { + if (!isValidElement(child)) return; + const childType = child.type as { displayName?: string; name?: string }; + const componentName = typeof child.type === "function" ? childType.displayName || childType.name || "" : ""; + const props = child.props as BarProps | undefined; + const isBarComponent = componentName === "Bar" || child.type === Bar || (props && typeof props.dataKey === "string" && props.dataKey.length > 0); + if (isBarComponent && props?.dataKey) { + configs.push({ dataKey: props.dataKey, fill: props.fill || "var(--chart-line-primary)", stroke: props.stroke }); + } + }); + return configs; +} + +export interface BarChartProps { + data: Record[]; + xDataKey?: string; + margin?: Partial; + animationDuration?: number; + aspectRatio?: string; + barGap?: number; + barWidth?: number; + orientation?: "vertical" | "horizontal"; + stacked?: boolean; + stackGap?: number; + className?: string; + children: ReactNode; +} + +const DEFAULT_MARGIN: Margin = { top: 40, right: 40, bottom: 40, left: 40 }; + +interface BarChartInnerProps { + width: number; + height: number; + data: Record[]; + xDataKey: string; + margin: Margin; + animationDuration: number; + barGap: number; + barWidth?: number; + orientation: "vertical" | "horizontal"; + stacked: boolean; + stackGap: number; + children: ReactNode; + containerRef: RefObject; +} + +function BarChartInner({ + width, height, data, xDataKey, margin, animationDuration, barGap, barWidth, orientation, stacked, stackGap, children, containerRef, +}: BarChartInnerProps) { + const [isLoaded, setIsLoaded] = useState(false); + const [hoveredBarIndex, setHoveredBarIndex] = useState(null); + const bars = useMemo(() => extractBarConfigs(children), [children]); + const innerWidth = width - margin.left - margin.right; + const innerHeight = height - margin.top - margin.bottom; + const isHorizontal = orientation === "horizontal"; + + const xScale = useMemo(() => { + const domain = data.map((d) => String(d[xDataKey] ?? "")); + return scaleBand({ range: isHorizontal ? [0, innerHeight] : [0, innerWidth], domain, padding: barGap }); + }, [data, xDataKey, innerWidth, innerHeight, barGap, isHorizontal]); + + const bandWidth = xScale.bandwidth(); + + const yScale = useMemo(() => { + let maxValue = 0; + if (stacked) { + for (const d of data) { let sum = 0; for (const bar of bars) { const v = d[bar.dataKey]; if (typeof v === "number") sum += v; } if (sum > maxValue) maxValue = sum; } + } else { + for (const bar of bars) { for (const d of data) { const v = d[bar.dataKey]; if (typeof v === "number" && v > maxValue) maxValue = v; } } + } + if (maxValue === 0) maxValue = 100; + return scaleLinear({ range: isHorizontal ? [innerWidth, 0] : [innerHeight, 0], domain: [0, maxValue * 1.1], nice: true }); + }, [data, bars, innerWidth, innerHeight, stacked, isHorizontal]); + + const stackOffsets = useMemo(() => { + if (!stacked) return new Map>(); + const offsets = new Map>(); + for (let i = 0; i < data.length; i++) { + const d = data[i]!; + let cumulative = 0; + const barOffsets = new Map(); + for (const bar of bars) { + barOffsets.set(bar.dataKey, cumulative); + const v = d[bar.dataKey]; + if (typeof v === "number") { cumulative += isHorizontal ? innerWidth - (yScale(v) ?? innerWidth) : innerHeight - (yScale(v) ?? innerHeight); } + } + offsets.set(i, barOffsets); + } + return offsets; + }, [data, bars, stacked, yScale, innerHeight, innerWidth, isHorizontal]); + + const [tooltipData, setTooltipData] = useState(null); + + useEffect(() => { const timer = setTimeout(() => setIsLoaded(true), animationDuration); return () => clearTimeout(timer); }, [animationDuration]); + + const handleMouseMove = useCallback((event: React.MouseEvent) => { + const point = localPoint(event); + if (!point) return; + const chartX = point.x - margin.left; + const chartY = point.y - margin.top; + const domain = xScale.domain(); + let foundIndex = -1; + for (let i = 0; i < domain.length; i++) { + const cat = domain[i]!; + const bandStart = xScale(cat) ?? 0; + const bandEnd = bandStart + bandWidth; + if (isHorizontal ? (chartY >= bandStart && chartY <= bandEnd) : (chartX >= bandStart && chartX <= bandEnd)) { foundIndex = i; break; } + } + if (foundIndex >= 0) { + setHoveredBarIndex(foundIndex); + const d = data[foundIndex]!; + const yPositions: Record = {}; + const xPositions: Record = {}; + for (const bar of bars) { + const v = d[bar.dataKey]; + if (typeof v === "number") { + if (isHorizontal) { xPositions[bar.dataKey] = innerWidth - (yScale(v) ?? innerWidth); yPositions[bar.dataKey] = (xScale(domain[foundIndex]!) ?? 0) + bandWidth / 2; } + else { yPositions[bar.dataKey] = yScale(v) ?? 0; xPositions[bar.dataKey] = (xScale(domain[foundIndex]!) ?? 0) + bandWidth / 2; } + } + } + const tooltipX = isHorizontal ? innerWidth - (yScale(Number(d[bars[0]?.dataKey ?? ""] ?? 0)) ?? 0) : (xScale(domain[foundIndex]!) ?? 0) + bandWidth / 2; + setTooltipData({ point: d, index: foundIndex, x: tooltipX, yPositions, xPositions }); + } else { setHoveredBarIndex(null); setTooltipData(null); } + }, [xScale, yScale, data, bars, margin, bandWidth, isHorizontal, innerWidth]); + + const handleMouseLeave = useCallback(() => { setHoveredBarIndex(null); setTooltipData(null); }, []); + + if (width < 10 || height < 10) return null; + + const contextValue: BarChartContextValue = { + data, xScale, yScale, width, height, innerWidth, innerHeight, margin, bandWidth, tooltipData, setTooltipData, containerRef, bars, isLoaded, animationDuration, xDataKey, hoveredBarIndex, setHoveredBarIndex, orientation, stacked, stackGap, stackOffsets, barGap, barWidth, + }; + + return ( + + + + ); +} + +export function BarChart({ + data, xDataKey = "name", margin: marginProp, animationDuration = 1100, aspectRatio = "2 / 1", + barGap = 0.2, barWidth, orientation = "vertical", stacked = false, stackGap = 0, className = "", children, +}: BarChartProps) { + const containerRef = useRef(null); + const margin = { ...DEFAULT_MARGIN, ...marginProp }; + + return ( +
      + + {({ width, height }) => ( + + {children} + + )} + +
      + ); +} + +export default BarChart; diff --git a/components/ui/button-website.tsx b/components/ui/button-website.tsx new file mode 100644 index 0000000..ac472bb --- /dev/null +++ b/components/ui/button-website.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + }, +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/components/ui/card.tsx b/components/ui/card.tsx index fe06801..35989e9 100644 --- a/components/ui/card.tsx +++ b/components/ui/card.tsx @@ -26,7 +26,7 @@ const useCardContext = () => { const cardVariants = cva('flex flex-col items-stretch text-card-foreground rounded-xl', { variants: { variant: { - default: 'bg-card border border-border shadow-xs black/5', + default: 'bg-neutral-900/80 border border-white/[0.08] backdrop-blur-sm', accent: 'bg-muted shadow-xs p-1', }, }, diff --git a/components/ui/funnel-chart.tsx b/components/ui/funnel-chart.tsx index 78359da..7e926ac 100644 --- a/components/ui/funnel-chart.tsx +++ b/components/ui/funnel-chart.tsx @@ -145,8 +145,8 @@ function hSegmentPath( straight = false ) { const my = H / 2; - const h0 = normStart * H * 0.44 * layerScale; - const h1 = normEnd * H * 0.44 * layerScale; + const h0 = normStart * H * 0.3 * layerScale; + const h1 = normEnd * H * 0.3 * layerScale; if (straight) { return `M 0 ${my - h0} L ${segW} ${my - h1} L ${segW} ${my + h1} L 0 ${my + h0} Z`; diff --git a/components/ui/menu-toggle-icon.tsx b/components/ui/menu-toggle-icon.tsx new file mode 100644 index 0000000..945603b --- /dev/null +++ b/components/ui/menu-toggle-icon.tsx @@ -0,0 +1,54 @@ +'use client'; +import React from 'react'; +import { cn } from '@/lib/utils'; + +type MenuToggleProps = React.ComponentProps<'svg'> & { + open: boolean; + duration?: number; +}; + +export function MenuToggleIcon({ + open, + className, + fill = 'none', + stroke = 'currentColor', + strokeWidth = 2.5, + strokeLinecap = 'round', + strokeLinejoin = 'round', + duration = 500, + ...props +}: MenuToggleProps) { + return ( + + + + + ); +} diff --git a/components/ui/navigation-menu.tsx b/components/ui/navigation-menu.tsx new file mode 100644 index 0000000..dc3d1c8 --- /dev/null +++ b/components/ui/navigation-menu.tsx @@ -0,0 +1,128 @@ +import * as React from "react" +import { ChevronDownIcon } from "@radix-ui/react-icons" +import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu" +import { cva } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const NavigationMenu = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + + +)) +NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName + +const NavigationMenuList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName + +const NavigationMenuItem = NavigationMenuPrimitive.Item + +const navigationMenuTriggerStyle = cva( + "group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50" +) + +const NavigationMenuTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children}{" "} + +)) +NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName + +const NavigationMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName + +const NavigationMenuLink = NavigationMenuPrimitive.Link + +const NavigationMenuViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
      + +
      +)) +NavigationMenuViewport.displayName = + NavigationMenuPrimitive.Viewport.displayName + +const NavigationMenuIndicator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +
      + +)) +NavigationMenuIndicator.displayName = + NavigationMenuPrimitive.Indicator.displayName + +export { + navigationMenuTriggerStyle, + NavigationMenu, + NavigationMenuList, + NavigationMenuItem, + NavigationMenuContent, + NavigationMenuTrigger, + NavigationMenuLink, + NavigationMenuIndicator, + NavigationMenuViewport, +} diff --git a/lib/api/bot-filter.ts b/lib/api/bot-filter.ts new file mode 100644 index 0000000..3361fdb --- /dev/null +++ b/lib/api/bot-filter.ts @@ -0,0 +1,56 @@ +import apiRequest from './client' + +export interface SessionSummary { + session_id: string + pageviews: number + duration: number | null + first_page: string + referrer: string | null + country: string | null + city: string | null + region: string | null + browser: string | null + os: string | null + screen_resolution: string | null + first_seen: string + bot_filtered: boolean + suspicion_score: number +} + +export interface BotFilterStats { + filtered_sessions: number + filtered_events: number + auto_blocked_this_month: number +} + +function buildQuery(opts: { startDate?: string; endDate?: string; suspicious?: boolean; limit?: number }): string { + const params = new URLSearchParams() + if (opts.startDate) params.append('start_date', opts.startDate) + if (opts.endDate) params.append('end_date', opts.endDate) + if (opts.suspicious) params.append('suspicious', 'true') + if (opts.limit != null) params.append('limit', opts.limit.toString()) + const q = params.toString() + return q ? `?${q}` : '' +} + +export function listSessions(siteId: string, startDate: string, endDate: string, suspiciousOnly?: boolean, limit?: number): Promise<{ sessions: SessionSummary[] }> { + return apiRequest<{ sessions: SessionSummary[] }>(`/sites/${siteId}/sessions${buildQuery({ startDate, endDate, suspicious: suspiciousOnly, limit })}`) +} + +export function botFilterSessions(siteId: string, sessionIds: string[]): Promise<{ updated: number }> { + return apiRequest<{ updated: number }>(`/sites/${siteId}/bot-filter`, { + method: 'POST', + body: JSON.stringify({ session_ids: sessionIds }), + }) +} + +export function botUnfilterSessions(siteId: string, sessionIds: string[]): Promise<{ updated: number }> { + return apiRequest<{ updated: number }>(`/sites/${siteId}/bot-filter`, { + method: 'DELETE', + body: JSON.stringify({ session_ids: sessionIds }), + }) +} + +export function getBotFilterStats(siteId: string): Promise { + return apiRequest(`/sites/${siteId}/bot-filter/stats`) +} diff --git a/lib/api/client.ts b/lib/api/client.ts index 4d348e5..3919cb4 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -238,8 +238,9 @@ async function apiRequest( if (response.status === 401) { // * Attempt Token Refresh if 401 if (typeof window !== 'undefined') { - // * Prevent infinite loop: Don't refresh if the failed request WAS a refresh request (unlikely via apiRequest but safe to check) - if (!endpoint.includes('/auth/refresh')) { + // * Skip token refresh for public endpoints (they use password auth, not session tokens) + // * and for refresh requests themselves (prevent infinite loop) + if (!endpoint.includes('/auth/refresh') && !endpoint.includes('/public/')) { if (isRefreshing) { // * If refresh is already in progress, wait for it to complete (or fail) return new Promise((resolve, reject) => { diff --git a/lib/api/report-schedules.ts b/lib/api/report-schedules.ts index 685b06f..cc0cfdb 100644 --- a/lib/api/report-schedules.ts +++ b/lib/api/report-schedules.ts @@ -10,6 +10,7 @@ export interface ReportSchedule { timezone: string enabled: boolean report_type: 'summary' | 'pages' | 'sources' | 'goals' + purpose: 'report' | 'alert' send_hour: number send_day: number | null next_send_at: string | null @@ -33,6 +34,7 @@ export interface CreateReportScheduleRequest { frequency: string timezone?: string report_type?: string + purpose?: 'report' | 'alert' send_hour?: number send_day?: number } @@ -43,6 +45,7 @@ export interface UpdateReportScheduleRequest { frequency?: string timezone?: string report_type?: string + purpose?: 'report' | 'alert' enabled?: boolean send_hour?: number send_day?: number @@ -73,6 +76,11 @@ export async function deleteReportSchedule(siteId: string, scheduleId: string): }) } +export async function listAlertSchedules(siteId: string): Promise { + const res = await apiRequest<{ report_schedules: ReportSchedule[] }>(`/sites/${siteId}/report-schedules?purpose=alert`) + return res?.report_schedules ?? [] +} + export async function testReportSchedule(siteId: string, scheduleId: string): Promise { await apiRequest(`/sites/${siteId}/report-schedules/${scheduleId}/test`, { method: 'POST', diff --git a/lib/api/sites.ts b/lib/api/sites.ts index 7093b57..55992f9 100644 --- a/lib/api/sites.ts +++ b/lib/api/sites.ts @@ -23,6 +23,10 @@ export interface Site { hide_unknown_locations?: boolean // Data retention (months); 0 = keep forever data_retention_months?: number + // Script feature toggles + script_features?: Record + // Uptime monitoring toggle + uptime_enabled: boolean is_verified?: boolean created_at: string updated_at: string @@ -49,6 +53,10 @@ export interface UpdateSiteRequest { collect_screen_resolution?: boolean // Bot and noise filtering filter_bots?: boolean + // Script feature toggles + script_features?: Record + // Uptime monitoring toggle + uptime_enabled?: boolean // Hide unknown locations from stats hide_unknown_locations?: boolean // Data retention (months); 0 = keep forever diff --git a/lib/api/stats.ts b/lib/api/stats.ts index a26fa04..9897d6c 100644 --- a/lib/api/stats.ts +++ b/lib/api/stats.ts @@ -117,6 +117,21 @@ export interface FrustrationByPage { unique_elements: number } +// ─── Public Auth ───────────────────────────────────────────────────── + +export function authenticatePublicDashboard(siteId: string, password: string, captchaToken?: string, captchaId?: string, captchaSolution?: string): Promise<{ status: string }> { + return apiRequest<{ status: string }>(`/public/sites/${siteId}/auth`, { + method: 'POST', + body: JSON.stringify({ + password, + captcha_token: captchaToken || '', + captcha_id: captchaId || '', + captcha_solution: captchaSolution || '', + }), + credentials: 'include', + }) +} + // ─── Helpers ──────────────────────────────────────────────────────── function appendAuthParams(params: URLSearchParams, auth?: AuthParams) { diff --git a/lib/api/uptime.ts b/lib/api/uptime.ts index 5f4ec6f..f53e69d 100644 --- a/lib/api/uptime.ts +++ b/lib/api/uptime.ts @@ -54,23 +54,6 @@ export interface UptimeStatusResponse { total_monitors: number } -export interface CreateMonitorRequest { - name: string - url: string - check_interval_seconds?: number - expected_status_code?: number - timeout_seconds?: number -} - -export interface UpdateMonitorRequest { - name: string - url: string - check_interval_seconds?: number - expected_status_code?: number - timeout_seconds?: number - enabled?: boolean -} - /** * Fetches the uptime status overview for all monitors of a site */ @@ -82,43 +65,6 @@ export async function getUptimeStatus(siteId: string, startDate?: string, endDat return apiRequest(`/sites/${siteId}/uptime/status${query ? `?${query}` : ''}`) } -/** - * Lists all uptime monitors for a site - */ -export async function listUptimeMonitors(siteId: string): Promise { - const res = await apiRequest<{ monitors: UptimeMonitor[] }>(`/sites/${siteId}/uptime/monitors`) - return res?.monitors ?? [] -} - -/** - * Creates a new uptime monitor - */ -export async function createUptimeMonitor(siteId: string, data: CreateMonitorRequest): Promise { - return apiRequest(`/sites/${siteId}/uptime/monitors`, { - method: 'POST', - body: JSON.stringify(data), - }) -} - -/** - * Updates an existing uptime monitor - */ -export async function updateUptimeMonitor(siteId: string, monitorId: string, data: UpdateMonitorRequest): Promise { - return apiRequest(`/sites/${siteId}/uptime/monitors/${monitorId}`, { - method: 'PUT', - body: JSON.stringify(data), - }) -} - -/** - * Deletes an uptime monitor - */ -export async function deleteUptimeMonitor(siteId: string, monitorId: string): Promise { - await apiRequest(`/sites/${siteId}/uptime/monitors/${monitorId}`, { - method: 'DELETE', - }) -} - /** * Fetches recent checks for a specific monitor */ diff --git a/lib/swr/dashboard.ts b/lib/swr/dashboard.ts index 8b15a85..1962e67 100644 --- a/lib/swr/dashboard.ts +++ b/lib/swr/dashboard.ts @@ -32,7 +32,8 @@ import type { Site } from '@/lib/api/sites' import { listFunnels, type Funnel } from '@/lib/api/funnels' import { getUptimeStatus, type UptimeStatusResponse } from '@/lib/api/uptime' import { listGoals, type Goal } from '@/lib/api/goals' -import { listReportSchedules, type ReportSchedule } from '@/lib/api/report-schedules' +import { listReportSchedules, listAlertSchedules, type ReportSchedule } from '@/lib/api/report-schedules' +import { listSessions, getBotFilterStats, type SessionSummary, type BotFilterStats } from '@/lib/api/bot-filter' import { getGSCStatus, getGSCOverview, getGSCTopQueries, getGSCTopPages, getGSCDailyTotals, getGSCNewQueries } from '@/lib/api/gsc' import type { GSCStatus, GSCOverview, GSCQueryResponse, GSCPageResponse, GSCDailyTotal, GSCNewQueries } from '@/lib/api/gsc' import { getBunnyStatus, getBunnyOverview, getBunnyDailyStats, getBunnyTopCountries } from '@/lib/api/bunny' @@ -80,6 +81,7 @@ const fetchers = { uptimeStatus: (siteId: string) => getUptimeStatus(siteId), goals: (siteId: string) => listGoals(siteId), reportSchedules: (siteId: string) => listReportSchedules(siteId), + alertSchedules: (siteId: string) => listAlertSchedules(siteId), gscStatus: (siteId: string) => getGSCStatus(siteId), gscOverview: (siteId: string, start: string, end: string) => getGSCOverview(siteId, start, end), gscTopQueries: (siteId: string, start: string, end: string, limit: number, offset: number) => getGSCTopQueries(siteId, start, end, limit, offset), @@ -410,6 +412,19 @@ export function useReportSchedules(siteId: string) { ) } +// * Hook for alert schedules (uptime alerts) +export function useAlertSchedules(siteId: string) { + return useSWR( + siteId ? ['alertSchedules', siteId] : null, + () => fetchers.alertSchedules(siteId), + { + ...dashboardSWRConfig, + refreshInterval: 60 * 1000, + dedupingInterval: 10 * 1000, + } + ) +} + // * Hook for GSC connection status export function useGSCStatus(siteId: string) { return useSWR( @@ -517,5 +532,23 @@ export function useSubscription() { ) } +// * Hook for session list (bot review) +export function useSessions(siteId: string, startDate: string, endDate: string, suspiciousOnly: boolean) { + return useSWR<{ sessions: SessionSummary[] }>( + siteId && startDate && endDate ? ['sessions', siteId, startDate, endDate, suspiciousOnly] : null, + () => listSessions(siteId, startDate, endDate, suspiciousOnly), + { ...dashboardSWRConfig, refreshInterval: 0, dedupingInterval: 10 * 1000 } + ) +} + +// * Hook for bot filter stats +export function useBotFilterStats(siteId: string) { + return useSWR( + siteId ? ['botFilterStats', siteId] : null, + () => getBotFilterStats(siteId), + { ...dashboardSWRConfig, refreshInterval: 60 * 1000, dedupingInterval: 10 * 1000 } + ) +} + // * Re-export for convenience export { fetchers } diff --git a/lib/utils/icons.tsx b/lib/utils/icons.tsx index 14aee6e..e1c7d4f 100644 --- a/lib/utils/icons.tsx +++ b/lib/utils/icons.tsx @@ -5,22 +5,42 @@ import { DeviceMobile, DeviceTablet, Desktop, - GoogleLogo, - FacebookLogo, - XLogo, - LinkedinLogo, - InstagramLogo, - GithubLogo, - YoutubeLogo, - RedditLogo, - Robot, Link, - WhatsappLogo, - TelegramLogo, - SnapchatLogo, - PinterestLogo, - ThreadsLogo, } from '@phosphor-icons/react' +import { + SiGoogle, + SiFacebook, + SiInstagram, + SiGithub, + SiYoutube, + SiReddit, + SiWhatsapp, + SiTelegram, + SiSnapchat, + SiPinterest, + SiThreads, + SiDuckduckgo, + SiBrave, + SiPerplexity, + SiAnthropic, + SiGooglegemini, + SiGithubcopilot, + SiDiscord, +} from '@icons-pack/react-simple-icons' + +// Inline SVG icons for brands not in @icons-pack/react-simple-icons +function XIcon({ size = 16, color = '#fff' }: { size?: number; color?: string }) { + return +} +function LinkedInIcon({ size = 16, color = '#0A66C2' }: { size?: number; color?: string }) { + return +} +function OpenAIIcon({ size = 16, color = '#fff' }: { size?: number; color?: string }) { + return +} +function BingIcon({ size = 16, color = '#258FFA' }: { size?: number; color?: string }) { + return +} /** * Google's public favicon service base URL. @@ -97,33 +117,39 @@ export function getDeviceIcon(deviceName: string) { return } +const SI = { size: 16 } as const + export function getReferrerIcon(referrerName: string) { if (!referrerName) return const lower = referrerName.toLowerCase() - if (lower.includes('google')) return - if (lower.includes('facebook')) return - if (lower.includes('twitter') || lower.includes('t.co') || lower.includes('x.com')) return - if (lower.includes('linkedin')) return - if (lower.includes('instagram')) return - if (lower.includes('github')) return - if (lower.includes('youtube')) return - if (lower.includes('reddit')) return - if (lower.includes('whatsapp')) return - if (lower.includes('telegram')) return - if (lower.includes('snapchat')) return - if (lower.includes('pinterest')) return - if (lower.includes('threads')) return - // AI assistants and search tools - if (lower.includes('chatgpt') || lower.includes('openai')) return - if (lower.includes('perplexity')) return - if (lower.includes('claude') || lower.includes('anthropic')) return - if (lower.includes('gemini')) return - if (lower.includes('copilot')) return - if (lower.includes('deepseek')) return - if (lower.includes('grok') || lower.includes('x.ai')) return - if (lower.includes('phind')) return - if (lower.includes('you.com')) return - // Shared Link (unattributed deep-page traffic) + // Social / platforms + if (lower.includes('google') && !lower.includes('gemini')) return + if (lower.includes('facebook')) return + if (lower.includes('twitter') || lower.includes('t.co') || lower.includes('x.com')) return + if (lower.includes('linkedin')) return + if (lower.includes('instagram')) return + if (lower.includes('github')) return + if (lower.includes('youtube')) return + if (lower.includes('reddit')) return + if (lower.includes('whatsapp')) return + if (lower.includes('telegram')) return + if (lower.includes('snapchat')) return + if (lower.includes('pinterest')) return + if (lower.includes('threads')) return + if (lower.includes('discord')) return + // Search engines + if (lower.includes('bing')) return + if (lower.includes('duckduckgo')) return + if (lower.includes('brave')) return + // AI assistants + if (lower.includes('chatgpt') || lower.includes('openai')) return + if (lower.includes('perplexity')) return + if (lower.includes('claude') || lower.includes('anthropic')) return + if (lower.includes('gemini')) return + if (lower.includes('copilot')) return + if (lower.includes('deepseek')) return + if (lower.includes('grok') || lower.includes('x.ai')) return + // Shared Link if (lower === 'shared link') return return @@ -147,6 +173,8 @@ const REFERRER_DISPLAY_OVERRIDES: Record = { youtube: 'YouTube', reddit: 'Reddit', github: 'GitHub', + bing: 'Bing', + brave: 'Brave', duckduckgo: 'DuckDuckGo', whatsapp: 'WhatsApp', telegram: 'Telegram', @@ -246,12 +274,44 @@ export function mergeReferrersByDisplayName( .sort((a, b) => b.pageviews - a.pageviews) } -/** Domains that always use the custom X icon instead of favicon (avoids legacy bird). */ -const REFERRER_USE_X_ICON = new Set(['t.co', 'x.com', 'twitter.com', 'www.twitter.com']) +/** + * Domains/labels where the Phosphor icon is better than Google's favicon service. + * For these, getReferrerFavicon returns null so the caller falls back to getReferrerIcon. + */ +const REFERRER_PREFER_ICON = new Set([ + // Social / platforms + 't.co', 'x.com', 'twitter.com', 'www.twitter.com', + 'google.com', 'www.google.com', + 'facebook.com', 'www.facebook.com', 'm.facebook.com', 'l.facebook.com', + 'instagram.com', 'www.instagram.com', 'l.instagram.com', + 'linkedin.com', 'www.linkedin.com', + 'github.com', 'www.github.com', + 'youtube.com', 'www.youtube.com', 'm.youtube.com', + 'reddit.com', 'www.reddit.com', 'old.reddit.com', + 'whatsapp.com', 'www.whatsapp.com', 'web.whatsapp.com', + 'telegram.org', 'web.telegram.org', 't.me', + 'snapchat.com', 'www.snapchat.com', + 'pinterest.com', 'www.pinterest.com', + 'threads.net', 'www.threads.net', + // Search engines + 'bing.com', 'www.bing.com', + 'duckduckgo.com', 'www.duckduckgo.com', + 'search.brave.com', 'brave.com', + // AI assistants + 'chatgpt.com', 'chat.openai.com', 'openai.com', + 'perplexity.ai', 'www.perplexity.ai', + 'claude.ai', 'www.claude.ai', 'anthropic.com', + 'gemini.google.com', + 'copilot.microsoft.com', + 'deepseek.com', 'chat.deepseek.com', + 'grok.x.ai', 'x.ai', + 'phind.com', 'www.phind.com', + 'you.com', 'www.you.com', +]) /** * Returns a favicon URL for the referrer's domain, or null for non-URL referrers - * (e.g. "Direct", "Unknown") so callers can show an icon fallback instead. + * (e.g. "Direct", "Unknown") or known services where the Phosphor icon is better. */ export function getReferrerFavicon(referrer: string): string | null { if (!referrer || typeof referrer !== 'string') return null @@ -261,8 +321,13 @@ export function getReferrerFavicon(referrer: string): string | null { if (!normalized.includes('.')) return null try { const url = new URL(referrer.startsWith('http') ? referrer : `https://${referrer}`) - if (REFERRER_USE_X_ICON.has(url.hostname.toLowerCase())) return null - return `${FAVICON_SERVICE_URL}?domain=${url.hostname}&sz=32` + const hostname = url.hostname.toLowerCase() + // Use Phosphor icon for known services — Google favicons are unreliable for these + if (REFERRER_PREFER_ICON.has(hostname)) return null + // Also check if the label matches a known referrer (catches subdomains like search.google.com) + const label = getReferrerLabel(hostname) + if (REFERRER_DISPLAY_OVERRIDES[label]) return null + return `${FAVICON_SERVICE_URL}?domain=${hostname}&sz=32` } catch { return null } diff --git a/package-lock.json b/package-lock.json index 587ffc0..d128ea3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,31 +8,45 @@ "name": "pulse-frontend", "version": "0.15.0-alpha", "dependencies": { - "@ciphera-net/ui": "^0.2.15", + "@ciphera-net/ui": "^0.3.1", "@ducanh2912/next-pwa": "^10.2.9", + "@icons-pack/react-simple-icons": "^13.13.0", "@phosphor-icons/react": "^2.1.10", + "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-navigation-menu": "^1.2.14", + "@radix-ui/react-slot": "^1.2.4", "@simplewebauthn/browser": "^13.2.2", "@stripe/react-stripe-js": "^5.6.0", "@stripe/stripe-js": "^8.7.0", "@tanstack/react-virtual": "^3.13.21", "@types/d3": "^7.4.3", + "@visx/curve": "^3.12.0", + "@visx/event": "^3.12.0", + "@visx/gradient": "^3.12.0", + "@visx/grid": "^3.12.0", + "@visx/responsive": "^3.12.0", + "@visx/scale": "^3.12.0", + "@visx/shape": "^3.12.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cobe": "^0.6.5", "country-flag-icons": "^1.6.4", "d3": "^7.9.0", + "d3-array": "^3.2.4", "d3-scale": "^4.0.2", "framer-motion": "^12.23.26", "html-to-image": "^1.11.13", "iso-3166-2": "^1.0.0", "jspdf": "^4.0.0", "jspdf-autotable": "^5.0.7", + "lucide-react": "^0.577.0", "motion": "^12.35.2", "next": "^16.1.1", "radix-ui": "^1.4.3", "react": "^19.2.3", "react-dom": "^19.2.3", "react-markdown": "^10.1.0", + "react-use-measure": "^2.1.7", "recharts": "^2.15.0", "sonner": "^2.0.7", "svg-dotted-map": "^2.0.1", @@ -42,8 +56,10 @@ }, "devDependencies": { "@tailwindcss/typography": "^0.5.19", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@types/d3-array": "^3.2.2", "@types/d3-scale": "^4.0.9", "@types/node": "^20.14.12", "@types/react": "^19.2.14", @@ -172,7 +188,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1670,9 +1685,9 @@ } }, "node_modules/@ciphera-net/ui": { - "version": "0.2.15", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.15/ec35ffe3be80cb5deca6a05bd2d36d636333a4a9", - "integrity": "sha512-Y2snU21OFbcarVq6QbSkW/pbL3BL9SePf8dBzC36zUvDp5TuhIU7E/21ydVGxGH6Ye6wKw2G1Qsv3xsnsumyPA==", + "version": "0.3.1", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.3.1/5696ea330397dfdedfb12cff5dc20ed073ede0d2", + "integrity": "sha512-NJgpcKERXbsMLABAdUsLq1V76O3lDFikAlf8xL4yfk19Jsg11llGEqtiW5CxmuyHWJXUiZjfT0M4sbwui0FAdA==", "dependencies": { "@phosphor-icons/react": "^2.1.10", "class-variance-authority": "^0.7.1", @@ -1784,7 +1799,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -1825,7 +1839,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -2575,6 +2588,19 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@icons-pack/react-simple-icons": { + "version": "13.13.0", + "resolved": "https://registry.npmjs.org/@icons-pack/react-simple-icons/-/react-simple-icons-13.13.0.tgz", + "integrity": "sha512-B5HhQMIpcSH4z8IZ8HFhD59CboHceKYMpPC9kAwGyKntvPdyJJv26DLu4Z1wAjcCLyrJhf11tMhiQGom9Rxb9g==", + "license": "MIT", + "engines": { + "node": ">=24", + "pnpm": ">=10" + }, + "peerDependencies": { + "react": "^16.13 || ^17 || ^18 || ^19" + } + }, "node_modules/@img/colour": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", @@ -3435,6 +3461,24 @@ } } }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", @@ -3594,6 +3638,24 @@ } } }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -3688,6 +3750,24 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", @@ -3858,6 +3938,15 @@ } } }, + "node_modules/@radix-ui/react-icons": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz", + "integrity": "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==", + "license": "MIT", + "peerDependencies": { + "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/@radix-ui/react-id": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", @@ -3939,6 +4028,24 @@ } } }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-menubar": { "version": "1.1.16", "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz", @@ -4108,6 +4215,24 @@ } } }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", @@ -4211,6 +4336,24 @@ } } }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-progress": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", @@ -4372,6 +4515,24 @@ } } }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-separator": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", @@ -4429,9 +4590,9 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" @@ -4656,6 +4817,24 @@ } } }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -5380,7 +5559,6 @@ "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.7.0.tgz", "integrity": "sha512-tNUerSstwNC1KuHgX4CASGO0Md3CB26IJzSXmVlSuFvhsBP4ZaEPpY4jxWOn9tfdDscuVT4Kqb8cZ2o9nLCgRQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=12.16" } @@ -5553,9 +5731,8 @@ "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "devOptional": true, + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", @@ -5568,7 +5745,7 @@ "version": "7.27.0", "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.0.0" @@ -5578,7 +5755,7 @@ "version": "7.4.4", "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.1.0", @@ -5589,7 +5766,7 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.28.2" @@ -5875,26 +6052,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "license": "MIT", - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "license": "MIT", - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -5929,6 +6086,7 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, "license": "MIT" }, "node_modules/@types/json5": { @@ -5938,6 +6096,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -5957,6 +6121,7 @@ "version": "20.19.33", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -5980,7 +6145,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5989,9 +6153,8 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -6059,7 +6222,6 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -6572,6 +6734,311 @@ "win32" ] }, + "node_modules/@visx/curve": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@visx/curve/-/curve-3.12.0.tgz", + "integrity": "sha512-Ng1mefXIzoIoAivw7dJ+ZZYYUbfuwXgZCgQynShr6ZIVw7P4q4HeQfJP3W24ON+1uCSrzoycHSXRelhR9SBPcw==", + "license": "MIT", + "dependencies": { + "@types/d3-shape": "^1.3.1", + "d3-shape": "^1.0.6" + } + }, + "node_modules/@visx/curve/node_modules/@types/d3-path": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz", + "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==", + "license": "MIT" + }, + "node_modules/@visx/curve/node_modules/@types/d3-shape": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz", + "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "^1" + } + }, + "node_modules/@visx/curve/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/@visx/curve/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/@visx/event": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@visx/event/-/event-3.12.0.tgz", + "integrity": "sha512-9Lvw6qJ0Fi+y1vsC1WspfdIKCxHTb7oy59Uql1uBdPGT8zChP0vuxW0jQNQRDbKgoefj4pCXAFi8+MF1mEtVTw==", + "license": "MIT", + "dependencies": { + "@types/react": "*", + "@visx/point": "3.12.0" + } + }, + "node_modules/@visx/gradient": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@visx/gradient/-/gradient-3.12.0.tgz", + "integrity": "sha512-QRatjjdUEPbcp4pqRca1JlChpAnmmIAO3r3ZscLK7D1xEIANlIjzjl3uNgrmseYmBAYyPCcJH8Zru07R97ovOg==", + "license": "MIT", + "dependencies": { + "@types/react": "*", + "prop-types": "^15.5.7" + }, + "peerDependencies": { + "react": "^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0" + } + }, + "node_modules/@visx/grid": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@visx/grid/-/grid-3.12.0.tgz", + "integrity": "sha512-L4ex2ooSYhwNIxJ3XFIKRhoSvEGjPc2Y3YCrtNB4TV5Ofdj4q0UMOsxfrH23Pr8HSHuQhb6VGMgYoK0LuWqDmQ==", + "license": "MIT", + "dependencies": { + "@types/react": "*", + "@visx/curve": "3.12.0", + "@visx/group": "3.12.0", + "@visx/point": "3.12.0", + "@visx/scale": "3.12.0", + "@visx/shape": "3.12.0", + "classnames": "^2.3.1", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": "^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0" + } + }, + "node_modules/@visx/group": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@visx/group/-/group-3.12.0.tgz", + "integrity": "sha512-Dye8iS1alVXPv7nj/7M37gJe6sSKqJLH7x6sEWAsRQ9clI0kFvjbKcKgF+U3aAVQr0NCohheFV+DtR8trfK/Ag==", + "license": "MIT", + "dependencies": { + "@types/react": "*", + "classnames": "^2.3.1", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": "^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0" + } + }, + "node_modules/@visx/point": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@visx/point/-/point-3.12.0.tgz", + "integrity": "sha512-I6UrHoJAEVbx3RORQNupgTiX5EzjuZpiwLPxn8L2mI5nfERotPKi1Yus12Cq2WtXqEBR/WgqTnoImlqOXBykcA==", + "license": "MIT" + }, + "node_modules/@visx/responsive": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@visx/responsive/-/responsive-3.12.0.tgz", + "integrity": "sha512-GV1BTYwAGlk/K5c9vH8lS2syPnTuIqEacI7L6LRPbsuaLscXMNi+i9fZyzo0BWvAdtRV8v6Urzglo++lvAXT1Q==", + "license": "MIT", + "dependencies": { + "@types/lodash": "^4.14.172", + "@types/react": "*", + "lodash": "^4.17.21", + "prop-types": "^15.6.1" + }, + "peerDependencies": { + "react": "^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0" + } + }, + "node_modules/@visx/scale": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@visx/scale/-/scale-3.12.0.tgz", + "integrity": "sha512-+ubijrZ2AwWCsNey0HGLJ0YKNeC/XImEFsr9rM+Uef1CM3PNM43NDdNTrdBejSlzRq0lcfQPWYMYQFSlkLcPOg==", + "license": "MIT", + "dependencies": { + "@visx/vendor": "3.12.0" + } + }, + "node_modules/@visx/shape": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@visx/shape/-/shape-3.12.0.tgz", + "integrity": "sha512-/1l0lrpX9tPic6SJEalryBKWjP/ilDRnQA+BGJTI1tj7i23mJ/J0t4nJHyA1GrL4QA/bM/qTJ35eyz5dEhJc4g==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "^1.0.8", + "@types/d3-shape": "^1.3.1", + "@types/lodash": "^4.14.172", + "@types/react": "*", + "@visx/curve": "3.12.0", + "@visx/group": "3.12.0", + "@visx/scale": "3.12.0", + "classnames": "^2.3.1", + "d3-path": "^1.0.5", + "d3-shape": "^1.2.0", + "lodash": "^4.17.21", + "prop-types": "^15.5.10" + }, + "peerDependencies": { + "react": "^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0" + } + }, + "node_modules/@visx/shape/node_modules/@types/d3-path": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz", + "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==", + "license": "MIT" + }, + "node_modules/@visx/shape/node_modules/@types/d3-shape": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz", + "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "^1" + } + }, + "node_modules/@visx/shape/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/@visx/shape/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/@visx/vendor": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@visx/vendor/-/vendor-3.12.0.tgz", + "integrity": "sha512-SVO+G0xtnL9dsNpGDcjCgoiCnlB3iLSM9KLz1sLbSrV7RaVXwY3/BTm2X9OWN1jH2a9M+eHt6DJ6sE6CXm4cUg==", + "license": "MIT and ISC", + "dependencies": { + "@types/d3-array": "3.0.3", + "@types/d3-color": "3.1.0", + "@types/d3-delaunay": "6.0.1", + "@types/d3-format": "3.0.1", + "@types/d3-geo": "3.1.0", + "@types/d3-interpolate": "3.0.1", + "@types/d3-scale": "4.0.2", + "@types/d3-time": "3.0.0", + "@types/d3-time-format": "2.1.0", + "d3-array": "3.2.1", + "d3-color": "3.1.0", + "d3-delaunay": "6.0.2", + "d3-format": "3.1.0", + "d3-geo": "3.1.0", + "d3-interpolate": "3.0.1", + "d3-scale": "4.0.2", + "d3-time": "3.1.0", + "d3-time-format": "4.1.0", + "internmap": "2.0.3" + } + }, + "node_modules/@visx/vendor/node_modules/@types/d3-array": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz", + "integrity": "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==", + "license": "MIT" + }, + "node_modules/@visx/vendor/node_modules/@types/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==", + "license": "MIT" + }, + "node_modules/@visx/vendor/node_modules/@types/d3-delaunay": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz", + "integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==", + "license": "MIT" + }, + "node_modules/@visx/vendor/node_modules/@types/d3-format": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz", + "integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==", + "license": "MIT" + }, + "node_modules/@visx/vendor/node_modules/@types/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@visx/vendor/node_modules/@types/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@visx/vendor/node_modules/@types/d3-time": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz", + "integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==", + "license": "MIT" + }, + "node_modules/@visx/vendor/node_modules/@types/d3-time-format": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.1.0.tgz", + "integrity": "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==", + "license": "MIT" + }, + "node_modules/@visx/vendor/node_modules/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@visx/vendor/node_modules/d3-delaunay": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz", + "integrity": "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@visx/vendor/node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/@visx/vendor/node_modules/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@vitejs/plugin-react": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", @@ -6734,170 +7201,11 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@webassemblyjs/ast": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", - "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", - "license": "MIT", - "dependencies": { - "@webassemblyjs/helper-numbers": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2" - } - }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", - "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", - "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", - "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", - "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", - "license": "MIT", - "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.13.2", - "@webassemblyjs/helper-api-error": "1.13.2", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", - "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", - "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/wasm-gen": "1.14.1" - } - }, - "node_modules/@webassemblyjs/ieee754": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", - "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", - "license": "MIT", - "dependencies": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "node_modules/@webassemblyjs/leb128": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", - "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", - "license": "Apache-2.0", - "dependencies": { - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/utf8": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", - "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "license": "MIT" - }, - "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", - "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/helper-wasm-section": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-opt": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1", - "@webassemblyjs/wast-printer": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", - "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", - "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", - "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-api-error": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wast-printer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", - "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "license": "BSD-3-Clause" - }, - "node_modules/@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "license": "Apache-2.0" - }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6905,18 +7213,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-phases": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", - "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", - "license": "MIT", - "engines": { - "node": ">=10.13.0" - }, - "peerDependencies": { - "acorn": "^8.14.0" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -6963,45 +7259,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -7503,7 +7760,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7759,15 +8015,6 @@ "node": ">= 6" } }, - "node_modules/chrome-trace-event": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", - "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", - "license": "MIT", - "engines": { - "node": ">=6.0" - } - }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -7780,6 +8027,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -8343,7 +8596,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -8774,19 +9026,6 @@ "dev": true, "license": "MIT" }, - "node_modules/enhanced-resolve": { - "version": "5.19.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", - "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -8914,12 +9153,6 @@ "node": ">= 0.4" } }, - "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", - "license": "MIT" - }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -9047,7 +9280,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9233,7 +9465,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -9464,6 +9695,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" @@ -9476,6 +9708,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -9512,15 +9745,6 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -10000,12 +10224,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "license": "BSD-2-Clause" - }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -10069,6 +10287,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10940,42 +11159,12 @@ "node": ">=10" } }, - "node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -11097,12 +11286,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "license": "MIT" - }, "node_modules/json-schema": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", @@ -11161,7 +11344,6 @@ "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.1.0.tgz", "integrity": "sha512-xd1d/XRkwqnsq6FP3zH1Q+Ejqn2ULIJeDZ+FTKpaabVpZREjsJKRJwuokTNgdqOU+fl55KgbvgZ1pRTSWCP2kQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "fast-png": "^6.2.0", @@ -11272,19 +11454,6 @@ "dev": true, "license": "MIT" }, - "node_modules/loader-runner": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", - "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", - "license": "MIT", - "engines": { - "node": ">=6.11.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -11357,6 +11526,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", + "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -11545,12 +11723,6 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "license": "MIT" - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -12015,27 +12187,6 @@ "node": ">=8.6" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -12168,18 +12319,11 @@ "dev": true, "license": "MIT" }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "license": "MIT" - }, "node_modules/next": { "version": "16.1.6", "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", @@ -12696,7 +12840,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -13038,6 +13181,24 @@ } } }, + "node_modules/radix-ui/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/raf": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", @@ -13062,7 +13223,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13072,7 +13232,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13223,6 +13382,21 @@ "react-dom": ">=16.6.0" } }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -13513,7 +13687,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -13650,60 +13823,6 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, - "node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/schema-utils/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/schema-utils/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/schema-utils/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/semver": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", @@ -14379,7 +14498,6 @@ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -14426,19 +14544,6 @@ "node": ">=4" } }, - "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/temp-dir": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", @@ -14484,40 +14589,6 @@ "node": ">=10" } }, - "node_modules/terser-webpack-plugin": { - "version": "5.3.16", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", - "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "jest-worker": "^27.4.5", - "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", - "terser": "^5.31.1" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "uglify-js": { - "optional": true - } - } - }, "node_modules/terser/node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -14621,7 +14692,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -14870,7 +14940,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14935,6 +15004,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -15295,7 +15365,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -15389,7 +15458,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -15563,104 +15631,12 @@ "node": ">=18" } }, - "node_modules/watchpack": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", - "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", - "license": "MIT", - "dependencies": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", "license": "BSD-2-Clause" }, - "node_modules/webpack": { - "version": "5.105.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.2.tgz", - "integrity": "sha512-dRXm0a2qcHPUBEzVk8uph0xWSjV/xZxenQQbLwnwP7caQCYpqG1qddwlyEkIDkYn0K8tvmcrZ+bOrzoQ3HxCDw==", - "license": "MIT", - "dependencies": { - "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.8", - "@types/json-schema": "^7.0.15", - "@webassemblyjs/ast": "^1.14.1", - "@webassemblyjs/wasm-edit": "^1.14.1", - "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.15.0", - "acorn-import-phases": "^1.0.3", - "browserslist": "^4.28.1", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.19.0", - "es-module-lexer": "^2.0.0", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.3.1", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^4.3.3", - "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.16", - "watchpack": "^2.5.1", - "webpack-sources": "^3.3.3" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, - "node_modules/webpack-sources": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", - "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", - "license": "MIT", - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/webpack/node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/webpack/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, "node_modules/whatwg-mimetype": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", @@ -15917,7 +15893,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -16103,7 +16078,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -16271,7 +16245,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index c13b8aa..56c41a5 100644 --- a/package.json +++ b/package.json @@ -12,31 +12,45 @@ "test:watch": "vitest" }, "dependencies": { - "@ciphera-net/ui": "^0.2.15", + "@ciphera-net/ui": "^0.3.1", "@ducanh2912/next-pwa": "^10.2.9", + "@icons-pack/react-simple-icons": "^13.13.0", "@phosphor-icons/react": "^2.1.10", + "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-navigation-menu": "^1.2.14", + "@radix-ui/react-slot": "^1.2.4", "@simplewebauthn/browser": "^13.2.2", "@stripe/react-stripe-js": "^5.6.0", "@stripe/stripe-js": "^8.7.0", "@tanstack/react-virtual": "^3.13.21", "@types/d3": "^7.4.3", + "@visx/curve": "^3.12.0", + "@visx/event": "^3.12.0", + "@visx/gradient": "^3.12.0", + "@visx/grid": "^3.12.0", + "@visx/responsive": "^3.12.0", + "@visx/scale": "^3.12.0", + "@visx/shape": "^3.12.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cobe": "^0.6.5", "country-flag-icons": "^1.6.4", "d3": "^7.9.0", + "d3-array": "^3.2.4", "d3-scale": "^4.0.2", "framer-motion": "^12.23.26", "html-to-image": "^1.11.13", "iso-3166-2": "^1.0.0", "jspdf": "^4.0.0", "jspdf-autotable": "^5.0.7", + "lucide-react": "^0.577.0", "motion": "^12.35.2", "next": "^16.1.1", "radix-ui": "^1.4.3", "react": "^19.2.3", "react-dom": "^19.2.3", "react-markdown": "^10.1.0", + "react-use-measure": "^2.1.7", "recharts": "^2.15.0", "sonner": "^2.0.7", "svg-dotted-map": "^2.0.1", @@ -52,8 +66,10 @@ }, "devDependencies": { "@tailwindcss/typography": "^0.5.19", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@types/d3-array": "^3.2.2", "@types/d3-scale": "^4.0.9", "@types/node": "^20.14.12", "@types/react": "^19.2.14", diff --git a/public/pulse-showcase-bg.png b/public/pulse-showcase-bg.png new file mode 100644 index 0000000..4c82724 Binary files /dev/null and b/public/pulse-showcase-bg.png differ diff --git a/styles/globals.css b/styles/globals.css index a37e520..1937543 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -10,29 +10,6 @@ --color-error: #EF4444; /* * Chart colors */ - --chart-1: #FD5E0F; - --chart-2: #3b82f6; - --chart-3: #22c55e; - --chart-4: #a855f7; - --chart-5: #f59e0b; - --chart-grid: #f5f5f5; - --chart-axis: #a3a3a3; - - /* * shadcn-compatible semantic tokens (for 21st.dev components) */ - --background: 255 255 255; - --foreground: 23 23 23; - --card: 255 255 255; - --card-foreground: 23 23 23; - --popover: 255 255 255; - --popover-foreground: 23 23 23; - --primary: 253 94 15; - --primary-foreground: 255 255 255; - --secondary: 245 245 245; - --secondary-foreground: 23 23 23; - --destructive-foreground: 255 255 255; - } - - .dark { --chart-1: #FD5E0F; --chart-2: #60a5fa; --chart-3: #4ade80; @@ -41,7 +18,19 @@ --chart-grid: #262626; --chart-axis: #737373; - /* * shadcn-compatible dark mode overrides */ + /* * visx area chart tokens (dark-only) */ + --chart-background: #0a0a0a; + --chart-foreground: #404040; + --chart-foreground-muted: #a3a3a3; + --chart-line-primary: #FD5E0F; + --chart-line-secondary: #737373; + --chart-crosshair: #404040; + --chart-label: #a3a3a3; + --chart-marker-background: #262626; + --chart-marker-border: #404040; + --chart-marker-foreground: #fafafa; + + /* * shadcn-compatible semantic tokens (dark-only) */ --background: 10 10 10; --foreground: 250 250 250; --card: 23 23 23; @@ -52,15 +41,13 @@ --primary-foreground: 255 255 255; --secondary: 38 38 38; --secondary-foreground: 250 250 250; + --accent: 38 38 38; + --accent-foreground: 250 250 250; --destructive-foreground: 255 255 255; } - + body { - @apply bg-ciphera-gradient bg-fixed; - } - - .dark body { - @apply bg-ciphera-gradient-dark; + @apply bg-neutral-950 text-neutral-100 antialiased; } }