Merge pull request #45 from ciphera-net/staging
Polish dashboard UX, loading states, and tracking accuracy
This commit is contained in:
@@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Improved
|
||||
|
||||
- **Cleaner page paths in your reports.** Pages like `/products?_t=123456` or `/about?session=abc` now correctly show as `/products` and `/about`. Only marketing attribution parameters (like UTM tags) are preserved for traffic source tracking — all other junk parameters are automatically removed, so your Top Pages and Journeys stay clean without us having to chase down every new parameter format.
|
||||
- **Refreshed chart background.** The dashboard chart now has subtle horizontal lines instead of the old dotted background, giving the chart area a cleaner look with soft faded edges.
|
||||
- **Smoother loading transitions.** When your data finishes loading, the page now fades in smoothly instead of appearing all at once. This applies across Dashboard, Journeys, Funnels, Uptime, Settings, Notifications, and shared dashboards. If your data was already cached from a previous visit, it still loads instantly with no animation — the fade only kicks in when you're actually waiting for fresh data.
|
||||
- **Faster tab switching across the board.** Switching between Settings, Funnels, Uptime, and other tabs now shows your data instantly instead of flashing a loading skeleton every time. Previously visited tabs remember their data and show it right away, while quietly refreshing in the background so you always see the latest numbers without the wait.
|
||||
- **Smoother loading on the Journeys page.** The Journeys tab now shows a polished skeleton placeholder while data loads, matching the loading experience on other tabs.
|
||||
- **Consistent chart colors.** All dashboard charts — Unique Visitors, Total Pageviews, Bounce Rate, and Visit Duration — now use the same brand orange color for a cleaner, more cohesive look.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **No more random errors when switching tabs.** Navigating between Dashboard, Funnels, Uptime, and Settings no longer shows "Invalid credentials", "Something went wrong", or "Site not found" errors. This was caused by a timing issue when your login session refreshed in the background while multiple pages were loading at the same time — all those requests now wait for the refresh to finish and retry cleanly.
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||
import { formatTimeAgo, getTypeIcon } from '@/lib/utils/notifications'
|
||||
import { Button, ArrowLeftIcon } from '@ciphera-net/ui'
|
||||
import { NotificationsListSkeleton, useMinimumLoading } from '@/components/skeletons'
|
||||
import { NotificationsListSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||
import { toast } from '@ciphera-net/ui'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
@@ -31,6 +31,7 @@ export default function NotificationsPage() {
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const showSkeleton = useMinimumLoading(loading)
|
||||
const fadeClass = useSkeletonFade(showSkeleton)
|
||||
|
||||
const fetchPage = async (pageOffset: number, append: boolean) => {
|
||||
if (append) setLoadingMore(true)
|
||||
@@ -104,7 +105,7 @@ export default function NotificationsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 py-8 ${fadeClass}`}>
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Link
|
||||
|
||||
@@ -15,7 +15,7 @@ import Locations from '@/components/dashboard/Locations'
|
||||
import TechSpecs from '@/components/dashboard/TechSpecs'
|
||||
import PerformanceStats from '@/components/dashboard/PerformanceStats'
|
||||
import { Select, DatePicker as DatePickerModal, Captcha, DownloadIcon, ZapIcon } from '@ciphera-net/ui'
|
||||
import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons'
|
||||
import { DashboardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||
import ExportModal from '@/components/dashboard/ExportModal'
|
||||
import { FAVICON_SERVICE_URL } from '@/lib/utils/icons'
|
||||
|
||||
@@ -198,6 +198,7 @@ export default function PublicDashboardPage() {
|
||||
}
|
||||
|
||||
const showSkeleton = useMinimumLoading(loading && !data && !isPasswordProtected)
|
||||
const fadeClass = useSkeletonFade(showSkeleton)
|
||||
|
||||
if (showSkeleton) {
|
||||
return <DashboardSkeleton />
|
||||
@@ -274,7 +275,7 @@ export default function PublicDashboardPage() {
|
||||
const safeScreenResolutions = screen_resolutions || []
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className={`min-h-screen ${fadeClass}`}>
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useParams, useRouter } from 'next/navigation'
|
||||
import { ApiError } from '@/lib/api/client'
|
||||
import { getFunnel, getFunnelStats, deleteFunnel, type Funnel, type FunnelStats } from '@/lib/api/funnels'
|
||||
import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, Button } from '@ciphera-net/ui'
|
||||
import { FunnelDetailSkeleton, useMinimumLoading } from '@/components/skeletons'
|
||||
import { FunnelDetailSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||
import Link from 'next/link'
|
||||
import { FunnelChart } from '@/components/ui/funnel-chart'
|
||||
import { getDateRange } from '@ciphera-net/ui'
|
||||
@@ -62,6 +62,7 @@ export default function FunnelReportPage() {
|
||||
}
|
||||
|
||||
const showSkeleton = useMinimumLoading(loading && !funnel)
|
||||
const fadeClass = useSkeletonFade(showSkeleton)
|
||||
|
||||
if (showSkeleton) {
|
||||
return <FunnelDetailSkeleton />
|
||||
@@ -113,7 +114,7 @@ export default function FunnelReportPage() {
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
||||
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { listFunnels, deleteFunnel, type Funnel } from '@/lib/api/funnels'
|
||||
import { deleteFunnel, type Funnel } from '@/lib/api/funnels'
|
||||
import { useFunnels } from '@/lib/swr/dashboard'
|
||||
import { toast, PlusIcon, ArrowRightIcon, ChevronLeftIcon, TrashIcon, Button } from '@ciphera-net/ui'
|
||||
import { FunnelsListSkeleton, useMinimumLoading } from '@/components/skeletons'
|
||||
import { FunnelsListSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function FunnelsPage() {
|
||||
@@ -12,24 +12,7 @@ export default function FunnelsPage() {
|
||||
const router = useRouter()
|
||||
const siteId = params.id as string
|
||||
|
||||
const [funnels, setFunnels] = useState<Funnel[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadFunnels = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await listFunnels(siteId)
|
||||
setFunnels(data)
|
||||
} catch (error) {
|
||||
toast.error('Failed to load your funnels')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [siteId])
|
||||
|
||||
useEffect(() => {
|
||||
loadFunnels()
|
||||
}, [loadFunnels])
|
||||
const { data: funnels = [], isLoading, mutate } = useFunnels(siteId)
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent, funnelId: string) => {
|
||||
e.preventDefault() // Prevent navigation
|
||||
@@ -38,20 +21,21 @@ export default function FunnelsPage() {
|
||||
try {
|
||||
await deleteFunnel(siteId, funnelId)
|
||||
toast.success('Funnel deleted')
|
||||
loadFunnels()
|
||||
mutate()
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete funnel')
|
||||
}
|
||||
}
|
||||
|
||||
const showSkeleton = useMinimumLoading(loading)
|
||||
const showSkeleton = useMinimumLoading(isLoading && !funnels.length)
|
||||
const fadeClass = useSkeletonFade(showSkeleton)
|
||||
|
||||
if (showSkeleton) {
|
||||
return <FunnelsListSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
||||
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getDateRange, formatDate } from '@ciphera-net/ui'
|
||||
import { Select, DatePicker } from '@ciphera-net/ui'
|
||||
import SankeyDiagram from '@/components/journeys/SankeyDiagram'
|
||||
import TopPathsTable from '@/components/journeys/TopPathsTable'
|
||||
import { SkeletonCard } from '@/components/skeletons'
|
||||
import { JourneysSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||
import {
|
||||
useDashboard,
|
||||
useJourneyTransitions,
|
||||
@@ -52,6 +52,9 @@ export default function JourneysPage() {
|
||||
document.title = domain ? `Journeys \u00b7 ${domain} | Pulse` : 'Journeys | Pulse'
|
||||
}, [dashboard?.site?.domain])
|
||||
|
||||
const showSkeleton = useMinimumLoading(transitionsLoading && !transitionsData)
|
||||
const fadeClass = useSkeletonFade(showSkeleton)
|
||||
|
||||
const entryPointOptions = [
|
||||
{ value: '', label: 'All entry points' },
|
||||
...(entryPoints ?? []).map((ep) => ({
|
||||
@@ -60,8 +63,10 @@ export default function JourneysPage() {
|
||||
})),
|
||||
]
|
||||
|
||||
if (showSkeleton) return <JourneysSkeleton />
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
||||
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
@@ -146,18 +151,12 @@ export default function JourneysPage() {
|
||||
|
||||
{/* Sankey Diagram */}
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 mb-6">
|
||||
{transitionsLoading ? (
|
||||
<div className="h-[400px] flex items-center justify-center">
|
||||
<SkeletonCard className="w-full h-full" />
|
||||
</div>
|
||||
) : (
|
||||
<SankeyDiagram
|
||||
transitions={transitionsData?.transitions ?? []}
|
||||
totalSessions={transitionsData?.total_sessions ?? 0}
|
||||
depth={depth}
|
||||
onNodeClick={(path) => setEntryPath(path)}
|
||||
/>
|
||||
)}
|
||||
<SankeyDiagram
|
||||
transitions={transitionsData?.transitions ?? []}
|
||||
totalSessions={transitionsData?.total_sessions ?? 0}
|
||||
depth={depth}
|
||||
onNodeClick={(path) => setEntryPath(path)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Top Paths */}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { toast } from '@ciphera-net/ui'
|
||||
import { Button } from '@ciphera-net/ui'
|
||||
import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons'
|
||||
import { DashboardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||
import FilterBar from '@/components/dashboard/FilterBar'
|
||||
import AddFilterDropdown, { type FilterSuggestion, type FilterSuggestions } from '@/components/dashboard/AddFilterDropdown'
|
||||
import Chart from '@/components/dashboard/Chart'
|
||||
@@ -423,6 +423,7 @@ export default function SiteDashboardPage() {
|
||||
// Skip the minimum-loading skeleton when SWR already has cached data
|
||||
// (prevents the 300ms flash when navigating back to the dashboard)
|
||||
const showSkeleton = useMinimumLoading(dashboardLoading && !dashboard)
|
||||
const fadeClass = useSkeletonFade(showSkeleton)
|
||||
|
||||
if (showSkeleton) {
|
||||
return <DashboardSkeleton />
|
||||
@@ -437,7 +438,7 @@ export default function SiteDashboardPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
||||
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { getSite, updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } from '@/lib/api/sites'
|
||||
import { listGoals, createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals'
|
||||
import { listReportSchedules, createReportSchedule, updateReportSchedule, deleteReportSchedule, testReportSchedule, type ReportSchedule, type CreateReportScheduleRequest, type EmailConfig, type WebhookConfig } from '@/lib/api/report-schedules'
|
||||
import { updateSite, resetSiteData, deleteSite, 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 { toast } from '@ciphera-net/ui'
|
||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||
import { SettingsFormSkeleton, GoalsListSkeleton, useMinimumLoading } from '@/components/skeletons'
|
||||
import { SettingsFormSkeleton, GoalsListSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||
import VerificationModal from '@/components/sites/VerificationModal'
|
||||
import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock'
|
||||
import { PasswordInput } from '@ciphera-net/ui'
|
||||
@@ -15,7 +15,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 { getSubscription, type SubscriptionDetails } from '@/lib/api/billing'
|
||||
import { useSite, useGoals, useReportSchedules, useSubscription } from '@/lib/swr/dashboard'
|
||||
import { getRetentionOptionsForPlan, formatRetentionMonths } from '@/lib/plans'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
@@ -53,8 +53,7 @@ export default function SiteSettingsPage() {
|
||||
const router = useRouter()
|
||||
const siteId = params.id as string
|
||||
|
||||
const [site, setSite] = useState<Site | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { data: site, isLoading: siteLoading, mutate: mutateSite } = useSite(siteId)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data' | 'goals' | 'reports'>('general')
|
||||
|
||||
@@ -79,23 +78,20 @@ export default function SiteSettingsPage() {
|
||||
// Data retention (6 = free-tier max; safe default)
|
||||
data_retention_months: 6
|
||||
})
|
||||
const [subscription, setSubscription] = useState<SubscriptionDetails | null>(null)
|
||||
const [subscriptionLoadFailed, setSubscriptionLoadFailed] = useState(false)
|
||||
const { data: subscription, error: subscriptionError, mutate: mutateSubscription } = useSubscription()
|
||||
const [linkCopied, setLinkCopied] = useState(false)
|
||||
const [snippetCopied, setSnippetCopied] = useState(false)
|
||||
const [showVerificationModal, setShowVerificationModal] = useState(false)
|
||||
const [isPasswordEnabled, setIsPasswordEnabled] = useState(false)
|
||||
const [goals, setGoals] = useState<Goal[]>([])
|
||||
const [goalsLoading, setGoalsLoading] = useState(false)
|
||||
const { data: goals = [], isLoading: goalsLoading, mutate: mutateGoals } = useGoals(siteId)
|
||||
const [goalModalOpen, setGoalModalOpen] = useState(false)
|
||||
const [editingGoal, setEditingGoal] = useState<Goal | null>(null)
|
||||
const [goalForm, setGoalForm] = useState({ name: '', event_name: '' })
|
||||
const [goalSaving, setGoalSaving] = useState(false)
|
||||
const initialFormRef = useRef<string>('')
|
||||
|
||||
// Report schedules state
|
||||
const [reportSchedules, setReportSchedules] = useState<ReportSchedule[]>([])
|
||||
const [reportLoading, setReportLoading] = useState(false)
|
||||
// Report schedules
|
||||
const { data: reportSchedules = [], isLoading: reportLoading, mutate: mutateReportSchedules } = useReportSchedules(siteId)
|
||||
const [reportModalOpen, setReportModalOpen] = useState(false)
|
||||
const [editingSchedule, setEditingSchedule] = useState<ReportSchedule | null>(null)
|
||||
const [reportSaving, setReportSaving] = useState(false)
|
||||
@@ -112,32 +108,40 @@ export default function SiteSettingsPage() {
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
loadSite()
|
||||
loadSubscription()
|
||||
}, [siteId])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'goals' && siteId) {
|
||||
loadGoals()
|
||||
}
|
||||
}, [activeTab, siteId])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'reports' && siteId) {
|
||||
loadReportSchedules()
|
||||
}
|
||||
}, [activeTab, siteId])
|
||||
|
||||
const loadSubscription = async () => {
|
||||
try {
|
||||
setSubscriptionLoadFailed(false)
|
||||
const sub = await getSubscription()
|
||||
setSubscription(sub)
|
||||
} catch (e) {
|
||||
setSubscriptionLoadFailed(true)
|
||||
toast.error(getAuthErrorMessage(e as Error) || 'Could not load plan limits. Showing default options.')
|
||||
}
|
||||
}
|
||||
if (!site) return
|
||||
setFormData({
|
||||
name: site.name,
|
||||
timezone: site.timezone || 'UTC',
|
||||
is_public: site.is_public || false,
|
||||
password: '',
|
||||
excluded_paths: (site.excluded_paths || []).join('\n'),
|
||||
collect_page_paths: site.collect_page_paths ?? true,
|
||||
collect_referrers: site.collect_referrers ?? true,
|
||||
collect_device_info: site.collect_device_info ?? true,
|
||||
collect_geo_data: site.collect_geo_data || 'full',
|
||||
collect_screen_resolution: site.collect_screen_resolution ?? true,
|
||||
enable_performance_insights: site.enable_performance_insights ?? false,
|
||||
filter_bots: site.filter_bots ?? true,
|
||||
hide_unknown_locations: site.hide_unknown_locations ?? false,
|
||||
data_retention_months: site.data_retention_months ?? 6
|
||||
})
|
||||
initialFormRef.current = JSON.stringify({
|
||||
name: site.name,
|
||||
timezone: site.timezone || 'UTC',
|
||||
is_public: site.is_public || false,
|
||||
excluded_paths: (site.excluded_paths || []).join('\n'),
|
||||
collect_page_paths: site.collect_page_paths ?? true,
|
||||
collect_referrers: site.collect_referrers ?? true,
|
||||
collect_device_info: site.collect_device_info ?? true,
|
||||
collect_geo_data: site.collect_geo_data || 'full',
|
||||
collect_screen_resolution: site.collect_screen_resolution ?? true,
|
||||
enable_performance_insights: site.enable_performance_insights ?? false,
|
||||
filter_bots: site.filter_bots ?? true,
|
||||
hide_unknown_locations: site.hide_unknown_locations ?? false,
|
||||
data_retention_months: site.data_retention_months ?? 6
|
||||
})
|
||||
setIsPasswordEnabled(!!site.has_password)
|
||||
}, [site])
|
||||
|
||||
// * Snap data_retention_months to nearest valid option when subscription loads
|
||||
useEffect(() => {
|
||||
@@ -152,83 +156,6 @@ export default function SiteSettingsPage() {
|
||||
})
|
||||
}, [subscription])
|
||||
|
||||
const loadSite = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await getSite(siteId)
|
||||
setSite(data)
|
||||
setFormData({
|
||||
name: data.name,
|
||||
timezone: data.timezone || 'UTC',
|
||||
is_public: data.is_public || false,
|
||||
password: '', // Don't show existing password
|
||||
excluded_paths: (data.excluded_paths || []).join('\n'),
|
||||
// Data collection settings (default to true/full for backwards compatibility)
|
||||
collect_page_paths: data.collect_page_paths ?? true,
|
||||
collect_referrers: data.collect_referrers ?? true,
|
||||
collect_device_info: data.collect_device_info ?? true,
|
||||
collect_geo_data: data.collect_geo_data || 'full',
|
||||
collect_screen_resolution: data.collect_screen_resolution ?? true,
|
||||
// Performance insights setting (default to false)
|
||||
enable_performance_insights: data.enable_performance_insights ?? false,
|
||||
// Bot and noise filtering (default to true)
|
||||
filter_bots: data.filter_bots ?? true,
|
||||
// Hide unknown locations (default to false)
|
||||
hide_unknown_locations: data.hide_unknown_locations ?? false,
|
||||
// Data retention (default 6 = free-tier max; avoids flash-then-clamp for existing sites)
|
||||
data_retention_months: data.data_retention_months ?? 6
|
||||
})
|
||||
initialFormRef.current = JSON.stringify({
|
||||
name: data.name,
|
||||
timezone: data.timezone || 'UTC',
|
||||
is_public: data.is_public || false,
|
||||
excluded_paths: (data.excluded_paths || []).join('\n'),
|
||||
collect_page_paths: data.collect_page_paths ?? true,
|
||||
collect_referrers: data.collect_referrers ?? true,
|
||||
collect_device_info: data.collect_device_info ?? true,
|
||||
collect_geo_data: data.collect_geo_data || 'full',
|
||||
collect_screen_resolution: data.collect_screen_resolution ?? true,
|
||||
enable_performance_insights: data.enable_performance_insights ?? false,
|
||||
filter_bots: data.filter_bots ?? true,
|
||||
hide_unknown_locations: data.hide_unknown_locations ?? false,
|
||||
data_retention_months: data.data_retention_months ?? 6
|
||||
})
|
||||
if (data.has_password) {
|
||||
setIsPasswordEnabled(true)
|
||||
} else {
|
||||
setIsPasswordEnabled(false)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(getAuthErrorMessage(error) || 'Failed to load site settings')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadGoals = async () => {
|
||||
try {
|
||||
setGoalsLoading(true)
|
||||
const data = await listGoals(siteId)
|
||||
setGoals(data ?? [])
|
||||
} catch (e) {
|
||||
toast.error(getAuthErrorMessage(e as Error) || 'Failed to load goals')
|
||||
} finally {
|
||||
setGoalsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadReportSchedules = async () => {
|
||||
try {
|
||||
setReportLoading(true)
|
||||
const data = await listReportSchedules(siteId)
|
||||
setReportSchedules(data)
|
||||
} catch (error: unknown) {
|
||||
toast.error(getAuthErrorMessage(error) || 'Failed to load report schedules')
|
||||
} finally {
|
||||
setReportLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetReportForm = () => {
|
||||
setReportForm({
|
||||
channel: 'email',
|
||||
@@ -297,7 +224,7 @@ export default function SiteSettingsPage() {
|
||||
toast.success('Report schedule created')
|
||||
}
|
||||
setReportModalOpen(false)
|
||||
loadReportSchedules()
|
||||
mutateReportSchedules()
|
||||
} catch (error: unknown) {
|
||||
toast.error(getAuthErrorMessage(error) || 'Failed to save report schedule')
|
||||
} finally {
|
||||
@@ -310,7 +237,7 @@ export default function SiteSettingsPage() {
|
||||
try {
|
||||
await deleteReportSchedule(siteId, schedule.id)
|
||||
toast.success('Report schedule deleted')
|
||||
loadReportSchedules()
|
||||
mutateReportSchedules()
|
||||
} catch (error: unknown) {
|
||||
toast.error(getAuthErrorMessage(error) || 'Failed to delete report schedule')
|
||||
}
|
||||
@@ -320,7 +247,7 @@ export default function SiteSettingsPage() {
|
||||
try {
|
||||
await updateReportSchedule(siteId, schedule.id, { enabled: !schedule.enabled })
|
||||
toast.success(schedule.enabled ? 'Report paused' : 'Report enabled')
|
||||
loadReportSchedules()
|
||||
mutateReportSchedules()
|
||||
} catch (error: unknown) {
|
||||
toast.error(getAuthErrorMessage(error) || 'Failed to update report schedule')
|
||||
}
|
||||
@@ -439,7 +366,7 @@ export default function SiteSettingsPage() {
|
||||
toast.success('Goal created')
|
||||
}
|
||||
setGoalModalOpen(false)
|
||||
loadGoals()
|
||||
mutateGoals()
|
||||
} catch (err) {
|
||||
toast.error(getAuthErrorMessage(err as Error) || 'Failed to save goal')
|
||||
} finally {
|
||||
@@ -452,7 +379,7 @@ export default function SiteSettingsPage() {
|
||||
try {
|
||||
await deleteGoal(siteId, goal.id)
|
||||
toast.success('Goal deleted')
|
||||
loadGoals()
|
||||
mutateGoals()
|
||||
} catch (err) {
|
||||
toast.error(getAuthErrorMessage(err as Error) || 'Failed to delete goal')
|
||||
}
|
||||
@@ -506,7 +433,7 @@ export default function SiteSettingsPage() {
|
||||
hide_unknown_locations: formData.hide_unknown_locations,
|
||||
data_retention_months: formData.data_retention_months
|
||||
})
|
||||
loadSite()
|
||||
mutateSite()
|
||||
} catch (error: unknown) {
|
||||
toast.error(getAuthErrorMessage(error) || 'Failed to save site settings')
|
||||
} finally {
|
||||
@@ -581,7 +508,8 @@ export default function SiteSettingsPage() {
|
||||
if (site?.domain) document.title = `Settings · ${site.domain} | Pulse`
|
||||
}, [site?.domain])
|
||||
|
||||
const showSkeleton = useMinimumLoading(loading)
|
||||
const showSkeleton = useMinimumLoading(siteLoading && !site)
|
||||
const fadeClass = useSkeletonFade(showSkeleton)
|
||||
|
||||
if (showSkeleton) {
|
||||
return (
|
||||
@@ -615,7 +543,7 @@ export default function SiteSettingsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
||||
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
||||
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
@@ -1157,14 +1085,14 @@ export default function SiteSettingsPage() {
|
||||
{/* Data Retention */}
|
||||
<div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800">
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Data Retention</h3>
|
||||
{subscriptionLoadFailed && (
|
||||
{!!subscriptionError && (
|
||||
<div className="p-3 rounded-xl border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
Plan limits could not be loaded. Options shown may be limited.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadSubscription}
|
||||
onClick={() => mutateSubscription()}
|
||||
className="shrink-0 text-sm font-medium text-amber-800 dark:text-amber-200 hover:underline"
|
||||
>
|
||||
Retry
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { getSite, type Site } from '@/lib/api/sites'
|
||||
import { useSite, useUptimeStatus } from '@/lib/swr/dashboard'
|
||||
import {
|
||||
getUptimeStatus,
|
||||
createUptimeMonitor,
|
||||
updateUptimeMonitor,
|
||||
deleteUptimeMonitor,
|
||||
@@ -21,7 +20,7 @@ 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 { UptimeSkeleton, ChecksSkeleton, useMinimumLoading } from '@/components/skeletons'
|
||||
import { UptimeSkeleton, ChecksSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
@@ -561,9 +560,8 @@ export default function UptimePage() {
|
||||
const router = useRouter()
|
||||
const siteId = params.id as string
|
||||
|
||||
const [site, setSite] = useState<Site | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [uptimeData, setUptimeData] = useState<UptimeStatusResponse | null>(null)
|
||||
const { data: site } = useSite(siteId)
|
||||
const { data: uptimeData, isLoading, mutate: mutateUptime } = useUptimeStatus(siteId)
|
||||
const [expandedMonitor, setExpandedMonitor] = useState<string | null>(null)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
@@ -577,38 +575,6 @@ export default function UptimePage() {
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [siteData, statusData] = await Promise.all([
|
||||
getSite(siteId),
|
||||
getUptimeStatus(siteId),
|
||||
])
|
||||
setSite(siteData)
|
||||
setUptimeData(statusData)
|
||||
} catch (error: unknown) {
|
||||
toast.error(getAuthErrorMessage(error) || 'Failed to load uptime monitors')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [siteId])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
// * Auto-refresh every 30 seconds; show toast on failure (e.g. network loss or auth expiry)
|
||||
useEffect(() => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const statusData = await getUptimeStatus(siteId)
|
||||
setUptimeData(statusData)
|
||||
} catch {
|
||||
toast.error('Could not refresh uptime data. Check your connection or sign in again.')
|
||||
}
|
||||
}, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [siteId])
|
||||
|
||||
const handleAddMonitor = async () => {
|
||||
if (!formData.name || !formData.url) {
|
||||
toast.error('Name and URL are required')
|
||||
@@ -620,7 +586,7 @@ export default function UptimePage() {
|
||||
toast.success('Monitor created successfully')
|
||||
setShowAddModal(false)
|
||||
setFormData({ name: '', url: '', check_interval_seconds: 300, expected_status_code: 200, timeout_seconds: 30 })
|
||||
await loadData()
|
||||
mutateUptime()
|
||||
} catch (error: unknown) {
|
||||
toast.error(getAuthErrorMessage(error) || 'Failed to create monitor')
|
||||
} finally {
|
||||
@@ -643,7 +609,7 @@ export default function UptimePage() {
|
||||
toast.success('Monitor updated successfully')
|
||||
setShowEditModal(false)
|
||||
setEditingMonitor(null)
|
||||
await loadData()
|
||||
mutateUptime()
|
||||
} catch (error: unknown) {
|
||||
toast.error(getAuthErrorMessage(error) || 'Failed to update monitor')
|
||||
} finally {
|
||||
@@ -656,7 +622,7 @@ export default function UptimePage() {
|
||||
try {
|
||||
await deleteUptimeMonitor(siteId, monitorId)
|
||||
toast.success('Monitor deleted')
|
||||
await loadData()
|
||||
mutateUptime()
|
||||
} catch (error: unknown) {
|
||||
toast.error(getAuthErrorMessage(error) || 'Failed to delete monitor')
|
||||
}
|
||||
@@ -678,7 +644,8 @@ export default function UptimePage() {
|
||||
if (site?.domain) document.title = `Uptime · ${site.domain} | Pulse`
|
||||
}, [site?.domain])
|
||||
|
||||
const showSkeleton = useMinimumLoading(loading)
|
||||
const showSkeleton = useMinimumLoading(isLoading && !uptimeData)
|
||||
const fadeClass = useSkeletonFade(showSkeleton)
|
||||
|
||||
if (showSkeleton) return <UptimeSkeleton />
|
||||
if (!site) return <div className="p-8 text-neutral-500">Site not found</div>
|
||||
@@ -688,7 +655,7 @@ export default function UptimePage() {
|
||||
const overallStatus = uptimeData?.status ?? 'operational'
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
||||
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
|
||||
import { useTheme } from '@ciphera-net/ui'
|
||||
import { Line, LineChart, XAxis, YAxis, ReferenceLine } from 'recharts'
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis, ReferenceLine } from 'recharts'
|
||||
import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/line-charts-6'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { formatNumber, formatDuration, formatUpdatedAgo, DatePicker } from '@ciphera-net/ui'
|
||||
@@ -104,9 +104,9 @@ const METRIC_CONFIGS: {
|
||||
|
||||
const chartConfig = {
|
||||
visitors: { label: 'Unique Visitors', color: '#FD5E0F' },
|
||||
pageviews: { label: 'Total Pageviews', color: '#3b82f6' },
|
||||
bounce_rate: { label: 'Bounce Rate', color: '#a855f7' },
|
||||
avg_duration: { label: 'Visit Duration', color: '#10b981' },
|
||||
pageviews: { label: 'Total Pageviews', color: '#FD5E0F' },
|
||||
bounce_rate: { label: 'Bounce Rate', color: '#FD5E0F' },
|
||||
avg_duration: { label: 'Visit Duration', color: '#FD5E0F' },
|
||||
} satisfies ChartConfig
|
||||
|
||||
// ─── Custom Tooltip ─────────────────────────────────────────────────
|
||||
@@ -351,7 +351,7 @@ export default function Chart({
|
||||
metric === m.key && 'bg-neutral-50 dark:bg-neutral-800/40',
|
||||
)}
|
||||
>
|
||||
<div className={cn('text-[10px] font-semibold uppercase tracking-widest mb-2', metric === m.key ? 'text-white' : 'text-neutral-400 dark:text-neutral-500')}>{m.label}</div>
|
||||
<div className={cn('text-[10px] font-semibold uppercase tracking-widest mb-2', metric === m.key ? 'text-brand-orange' : 'text-neutral-400 dark:text-neutral-500')}>{m.label}</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-2xl font-bold text-neutral-900 dark:text-white">{m.format(m.value)}</span>
|
||||
{m.change !== null && (
|
||||
@@ -462,9 +462,6 @@ export default function Chart({
|
||||
style={{ overflow: 'visible' }}
|
||||
>
|
||||
<defs>
|
||||
<pattern id="dotGrid" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||
<circle cx="10" cy="10" r="1" fill="var(--chart-grid)" fillOpacity="1" />
|
||||
</pattern>
|
||||
<filter id="lineShadow" x="-100%" y="-100%" width="300%" height="300%">
|
||||
<feDropShadow
|
||||
dx="4"
|
||||
@@ -478,6 +475,13 @@ export default function Chart({
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<CartesianGrid
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
stroke="var(--chart-grid)"
|
||||
strokeOpacity={0.7}
|
||||
/>
|
||||
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
axisLine={false}
|
||||
@@ -501,15 +505,6 @@ export default function Chart({
|
||||
|
||||
<ChartTooltip content={<CustomTooltip metric={metric} />} cursor={{ strokeDasharray: '3 3', stroke: '#9ca3af' }} />
|
||||
|
||||
{/* Background dot grid pattern */}
|
||||
<rect
|
||||
x="60px"
|
||||
y="-20px"
|
||||
width="calc(100% - 75px)"
|
||||
height="calc(100% - 10px)"
|
||||
fill="url(#dotGrid)"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
|
||||
{/* Annotation reference lines */}
|
||||
{visibleAnnotationMarkers.map((marker) => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
const SK = 'animate-pulse bg-neutral-100 dark:bg-neutral-800'
|
||||
|
||||
export { useMinimumLoading } from './useMinimumLoading'
|
||||
export { useMinimumLoading, useSkeletonFade } from './useMinimumLoading'
|
||||
|
||||
// ─── Primitives ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -32,3 +32,19 @@ export function useMinimumLoading(loading: boolean, minMs = 300): boolean {
|
||||
|
||||
return show
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns 'animate-fade-in' when transitioning from skeleton to content,
|
||||
* empty string otherwise. Prevents the jarring visual "pop" when skeletons
|
||||
* are replaced by real content, without adding unnecessary animation when
|
||||
* data loads from cache (no skeleton shown).
|
||||
*/
|
||||
export function useSkeletonFade(showSkeleton: boolean): string {
|
||||
const wasEverLoading = useRef(false)
|
||||
|
||||
if (showSkeleton) {
|
||||
wasEverLoading.current = true
|
||||
}
|
||||
|
||||
return !showSkeleton && wasEverLoading.current ? 'animate-fade-in' : ''
|
||||
}
|
||||
|
||||
@@ -29,6 +29,11 @@ import { listAnnotations } from '@/lib/api/annotations'
|
||||
import type { Annotation } from '@/lib/api/annotations'
|
||||
import { getSite } from '@/lib/api/sites'
|
||||
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 { getSubscription, type SubscriptionDetails } from '@/lib/api/billing'
|
||||
import type {
|
||||
Stats,
|
||||
DailyStat,
|
||||
@@ -69,6 +74,11 @@ const fetchers = {
|
||||
getJourneyTopPaths(siteId, start, end, { limit, minSessions, entryPath }),
|
||||
journeyEntryPoints: (siteId: string, start: string, end: string) =>
|
||||
getJourneyEntryPoints(siteId, start, end),
|
||||
funnels: (siteId: string) => listFunnels(siteId),
|
||||
uptimeStatus: (siteId: string) => getUptimeStatus(siteId),
|
||||
goals: (siteId: string) => listGoals(siteId),
|
||||
reportSchedules: (siteId: string) => listReportSchedules(siteId),
|
||||
subscription: () => getSubscription(),
|
||||
}
|
||||
|
||||
// * Standard SWR config for dashboard data
|
||||
@@ -334,5 +344,71 @@ export function useJourneyEntryPoints(siteId: string, start: string, end: string
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for funnels list
|
||||
export function useFunnels(siteId: string) {
|
||||
return useSWR<Funnel[]>(
|
||||
siteId ? ['funnels', siteId] : null,
|
||||
() => fetchers.funnels(siteId),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for uptime status (refreshes every 30s to match original polling)
|
||||
export function useUptimeStatus(siteId: string) {
|
||||
return useSWR<UptimeStatusResponse>(
|
||||
siteId ? ['uptimeStatus', siteId] : null,
|
||||
() => fetchers.uptimeStatus(siteId),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 30 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for goals list
|
||||
export function useGoals(siteId: string) {
|
||||
return useSWR<Goal[]>(
|
||||
siteId ? ['goals', siteId] : null,
|
||||
() => fetchers.goals(siteId),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for report schedules
|
||||
export function useReportSchedules(siteId: string) {
|
||||
return useSWR<ReportSchedule[]>(
|
||||
siteId ? ['reportSchedules', siteId] : null,
|
||||
() => fetchers.reportSchedules(siteId),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for subscription details (changes rarely)
|
||||
export function useSubscription() {
|
||||
return useSWR<SubscriptionDetails>(
|
||||
'subscription',
|
||||
() => fetchers.subscription(),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 5 * 60 * 1000,
|
||||
dedupingInterval: 30 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Re-export for convenience
|
||||
export { fetchers }
|
||||
|
||||
@@ -230,25 +230,29 @@
|
||||
return cachedSessionId;
|
||||
}
|
||||
|
||||
// * Normalize path: strip trailing slash and ad-platform click/tracking IDs.
|
||||
// * UTM params (utm_source, utm_medium, etc.) are intentionally kept in the path
|
||||
// * because the backend extracts them for attribution before cleaning the path.
|
||||
var STRIP_PARAMS = ['fbclid', 'gclid', 'gad_source', 'msclkid', 'twclid', 'dclid', 'mc_cid', 'mc_eid', 'ad_id', 'adset_id', 'campaign_id', 'ad_name', 'adset_name', 'campaign_name', 'placement', 'site_source_name', 'utm_id'];
|
||||
// * Normalize path: strip trailing slash and all query params except UTM/attribution.
|
||||
// * Allowlist approach — only UTM params pass through because the backend extracts
|
||||
// * them for attribution before cleaning the stored path. Everything else (cache-busters,
|
||||
// * ad click IDs, filter params, etc.) is stripped to prevent path fragmentation.
|
||||
var KEEP_PARAMS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'source', 'ref'];
|
||||
function cleanPath() {
|
||||
var pathname = window.location.pathname;
|
||||
// * Strip trailing slash (but keep root /)
|
||||
if (pathname.length > 1 && pathname.charAt(pathname.length - 1) === '/') {
|
||||
pathname = pathname.slice(0, -1);
|
||||
}
|
||||
// * Strip UTM/marketing params, keep other query params
|
||||
// * Only keep allowlisted params, strip everything else
|
||||
var search = window.location.search;
|
||||
if (search) {
|
||||
try {
|
||||
var params = new URLSearchParams(search);
|
||||
for (var i = 0; i < STRIP_PARAMS.length; i++) {
|
||||
params.delete(STRIP_PARAMS[i]);
|
||||
var kept = new URLSearchParams();
|
||||
for (var i = 0; i < KEEP_PARAMS.length; i++) {
|
||||
if (params.has(KEEP_PARAMS[i])) {
|
||||
kept.set(KEEP_PARAMS[i], params.get(KEEP_PARAMS[i]));
|
||||
}
|
||||
}
|
||||
var remaining = params.toString();
|
||||
var remaining = kept.toString();
|
||||
if (remaining) pathname += '?' + remaining;
|
||||
} catch (e) {
|
||||
// * URLSearchParams not supported — send path without query
|
||||
|
||||
@@ -23,10 +23,15 @@ const config: Config = {
|
||||
'50%': { backgroundColor: 'var(--highlight)' },
|
||||
'100%': { backgroundColor: 'transparent' },
|
||||
},
|
||||
'fade-in': {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'cell-highlight': 'cell-highlight 0.5s ease forwards',
|
||||
'cell-flash': 'cell-flash 0.6s ease forwards',
|
||||
'fade-in': 'fade-in 150ms ease-out',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['var(--font-plus-jakarta-sans)', 'system-ui', 'sans-serif'],
|
||||
|
||||
Reference in New Issue
Block a user