Remove Performance tab, PerformanceStats component, settings toggle, Web Vitals observers from tracking script, and all related API types and SWR hooks. Duration tracking is preserved.
2044 lines
114 KiB
TypeScript
2044 lines
114 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState, useRef } from 'react'
|
|
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
|
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 { 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 { getAuthErrorMessage } from '@ciphera-net/ui'
|
|
import { formatDateTime } from '@/lib/utils/formatDate'
|
|
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'
|
|
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 { getRetentionOptionsForPlan, formatRetentionMonths } from '@/lib/plans'
|
|
import { motion, AnimatePresence } from 'framer-motion'
|
|
import { useAuth } from '@/lib/auth/context'
|
|
import {
|
|
SettingsIcon,
|
|
GlobeIcon,
|
|
CheckIcon,
|
|
AlertTriangleIcon,
|
|
ZapIcon,
|
|
} from '@ciphera-net/ui'
|
|
import { PaperPlaneTilt, Envelope, WebhooksLogo, SpinnerGap, Trash, PencilSimple, Play, Plugs, ShieldCheck } from '@phosphor-icons/react'
|
|
|
|
const TIMEZONES = [
|
|
'UTC',
|
|
'America/New_York',
|
|
'America/Los_Angeles',
|
|
'America/Chicago',
|
|
'America/Toronto',
|
|
'Europe/London',
|
|
'Europe/Paris',
|
|
'Europe/Berlin',
|
|
'Europe/Amsterdam',
|
|
'Asia/Tokyo',
|
|
'Asia/Singapore',
|
|
'Asia/Dubai',
|
|
'Australia/Sydney',
|
|
'Pacific/Auckland',
|
|
]
|
|
|
|
export default function SiteSettingsPage() {
|
|
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, isLoading: siteLoading, mutate: mutateSite } = useSite(siteId)
|
|
const [saving, setSaving] = useState(false)
|
|
const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data' | 'goals' | 'reports' | 'integrations'>('general')
|
|
const searchParams = useSearchParams()
|
|
|
|
const [formData, setFormData] = useState({
|
|
name: '',
|
|
timezone: 'UTC',
|
|
is_public: false,
|
|
password: '',
|
|
excluded_paths: '',
|
|
// Data collection settings
|
|
collect_page_paths: true,
|
|
collect_referrers: true,
|
|
collect_device_info: true,
|
|
collect_geo_data: 'full' as GeoDataLevel,
|
|
collect_screen_resolution: true,
|
|
// Bot and noise filtering
|
|
filter_bots: true,
|
|
// Hide unknown locations
|
|
hide_unknown_locations: false,
|
|
// Data retention (6 = free-tier max; safe default)
|
|
data_retention_months: 6
|
|
})
|
|
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 { 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
|
|
const { data: reportSchedules = [], isLoading: reportLoading, mutate: mutateReportSchedules } = useReportSchedules(siteId)
|
|
const { data: gscStatus, mutate: mutateGSCStatus } = useGSCStatus(siteId)
|
|
const [gscConnecting, setGscConnecting] = useState(false)
|
|
const [gscDisconnecting, setGscDisconnecting] = useState(false)
|
|
const { data: bunnyStatus, mutate: mutateBunnyStatus } = useBunnyStatus(siteId)
|
|
const [bunnyApiKey, setBunnyApiKey] = useState('')
|
|
const [bunnyPullZones, setBunnyPullZones] = useState<BunnyPullZone[]>([])
|
|
const [bunnySelectedZone, setBunnySelectedZone] = useState<BunnyPullZone | null>(null)
|
|
const [bunnyLoadingZones, setBunnyLoadingZones] = useState(false)
|
|
const [bunnyConnecting, setBunnyConnecting] = useState(false)
|
|
const [bunnyDisconnecting, setBunnyDisconnecting] = useState(false)
|
|
const [reportModalOpen, setReportModalOpen] = useState(false)
|
|
const [editingSchedule, setEditingSchedule] = useState<ReportSchedule | null>(null)
|
|
const [reportSaving, setReportSaving] = useState(false)
|
|
const [reportTesting, setReportTesting] = useState<string | null>(null)
|
|
const [reportForm, setReportForm] = useState({
|
|
channel: 'email' as string,
|
|
recipients: '',
|
|
webhookUrl: '',
|
|
frequency: 'weekly' as string,
|
|
reportType: 'summary' as string,
|
|
timezone: '',
|
|
sendHour: 9,
|
|
sendDay: 1,
|
|
})
|
|
|
|
useEffect(() => {
|
|
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,
|
|
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,
|
|
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(() => {
|
|
if (!subscription) return
|
|
const opts = getRetentionOptionsForPlan(subscription.plan_id)
|
|
const values = opts.map(o => o.value)
|
|
const maxVal = Math.max(...values)
|
|
setFormData(prev => {
|
|
if (values.includes(prev.data_retention_months)) return prev
|
|
const bestFit = values.filter(v => v <= prev.data_retention_months).pop() ?? maxVal
|
|
return { ...prev, data_retention_months: Math.min(bestFit, maxVal) }
|
|
})
|
|
}, [subscription])
|
|
|
|
const resetReportForm = () => {
|
|
setReportForm({
|
|
channel: 'email',
|
|
recipients: '',
|
|
webhookUrl: '',
|
|
frequency: 'weekly',
|
|
reportType: 'summary',
|
|
timezone: site?.timezone || '',
|
|
sendHour: 9,
|
|
sendDay: 1,
|
|
})
|
|
}
|
|
|
|
const openEditSchedule = (schedule: ReportSchedule) => {
|
|
setEditingSchedule(schedule)
|
|
const isEmail = schedule.channel === 'email'
|
|
setReportForm({
|
|
channel: schedule.channel,
|
|
recipients: isEmail ? (schedule.channel_config as EmailConfig).recipients.join(', ') : '',
|
|
webhookUrl: !isEmail ? (schedule.channel_config as WebhookConfig).url : '',
|
|
frequency: schedule.frequency,
|
|
reportType: schedule.report_type,
|
|
timezone: schedule.timezone || site?.timezone || '',
|
|
sendHour: schedule.send_hour ?? 9,
|
|
sendDay: schedule.send_day ?? (schedule.frequency === 'monthly' ? 1 : 0),
|
|
})
|
|
setReportModalOpen(true)
|
|
}
|
|
|
|
const handleReportSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
|
|
let channelConfig: EmailConfig | WebhookConfig
|
|
if (reportForm.channel === 'email') {
|
|
const recipients = reportForm.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 (!reportForm.webhookUrl.trim()) {
|
|
toast.error('Webhook URL is required')
|
|
return
|
|
}
|
|
channelConfig = { url: reportForm.webhookUrl.trim() }
|
|
}
|
|
|
|
const payload: CreateReportScheduleRequest = {
|
|
channel: reportForm.channel,
|
|
channel_config: channelConfig,
|
|
frequency: reportForm.frequency,
|
|
timezone: reportForm.timezone || undefined,
|
|
report_type: reportForm.reportType,
|
|
send_hour: reportForm.sendHour,
|
|
...(reportForm.frequency !== 'daily' ? { send_day: reportForm.sendDay } : {}),
|
|
}
|
|
|
|
setReportSaving(true)
|
|
try {
|
|
if (editingSchedule) {
|
|
await updateReportSchedule(siteId, editingSchedule.id, payload)
|
|
toast.success('Report schedule updated')
|
|
} else {
|
|
await createReportSchedule(siteId, payload)
|
|
toast.success('Report schedule created')
|
|
}
|
|
setReportModalOpen(false)
|
|
mutateReportSchedules()
|
|
} catch (error: unknown) {
|
|
toast.error(getAuthErrorMessage(error) || 'Failed to save report schedule')
|
|
} finally {
|
|
setReportSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleReportDelete = async (schedule: ReportSchedule) => {
|
|
if (!confirm('Delete this report schedule?')) return
|
|
try {
|
|
await deleteReportSchedule(siteId, schedule.id)
|
|
toast.success('Report schedule deleted')
|
|
mutateReportSchedules()
|
|
} catch (error: unknown) {
|
|
toast.error(getAuthErrorMessage(error) || 'Failed to delete report schedule')
|
|
}
|
|
}
|
|
|
|
const handleReportToggle = async (schedule: ReportSchedule) => {
|
|
try {
|
|
await updateReportSchedule(siteId, schedule.id, { enabled: !schedule.enabled })
|
|
toast.success(schedule.enabled ? 'Report paused' : 'Report enabled')
|
|
mutateReportSchedules()
|
|
} catch (error: unknown) {
|
|
toast.error(getAuthErrorMessage(error) || 'Failed to update report schedule')
|
|
}
|
|
}
|
|
|
|
const handleReportTest = async (schedule: ReportSchedule) => {
|
|
setReportTesting(schedule.id)
|
|
try {
|
|
await testReportSchedule(siteId, schedule.id)
|
|
toast.success('Test report sent successfully')
|
|
} catch (error: unknown) {
|
|
toast.error(getAuthErrorMessage(error) || 'Failed to send test report')
|
|
} finally {
|
|
setReportTesting(null)
|
|
}
|
|
}
|
|
|
|
const getChannelLabel = (channel: string) => {
|
|
switch (channel) {
|
|
case 'email': return 'Email'
|
|
case 'slack': return 'Slack'
|
|
case 'discord': return 'Discord'
|
|
case 'webhook': return 'Webhook'
|
|
default: return channel
|
|
}
|
|
}
|
|
|
|
const getFrequencyLabel = (frequency: string) => {
|
|
switch (frequency) {
|
|
case 'daily': return 'Daily'
|
|
case 'weekly': return 'Weekly'
|
|
case 'monthly': return 'Monthly'
|
|
default: return frequency
|
|
}
|
|
}
|
|
|
|
const WEEKDAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
|
|
|
const formatHour = (hour: number) => {
|
|
if (hour === 0) return '12:00 AM'
|
|
if (hour === 12) return '12:00 PM'
|
|
return hour < 12 ? `${hour}:00 AM` : `${hour - 12}:00 PM`
|
|
}
|
|
|
|
const getScheduleDescription = (schedule: ReportSchedule) => {
|
|
const hour = formatHour(schedule.send_hour ?? 9)
|
|
const tz = schedule.timezone || 'UTC'
|
|
switch (schedule.frequency) {
|
|
case 'daily':
|
|
return `Every day at ${hour} (${tz})`
|
|
case 'weekly': {
|
|
const day = WEEKDAY_NAMES[schedule.send_day ?? 0] || 'Monday'
|
|
return `Every ${day} at ${hour} (${tz})`
|
|
}
|
|
case 'monthly': {
|
|
const d = schedule.send_day ?? 1
|
|
const suffix = d === 1 ? 'st' : d === 2 ? 'nd' : d === 3 ? 'rd' : 'th'
|
|
return `${d}${suffix} of each month at ${hour} (${tz})`
|
|
}
|
|
default:
|
|
return schedule.frequency
|
|
}
|
|
}
|
|
|
|
const getReportTypeLabel = (type: string) => {
|
|
switch (type) {
|
|
case 'summary': return 'Summary'
|
|
case 'pages': return 'Pages'
|
|
case 'sources': return 'Sources'
|
|
case 'goals': return 'Goals'
|
|
default: return type
|
|
}
|
|
}
|
|
|
|
const openAddGoal = () => {
|
|
setEditingGoal(null)
|
|
setGoalForm({ name: '', event_name: '' })
|
|
setGoalModalOpen(true)
|
|
}
|
|
|
|
const openEditGoal = (goal: Goal) => {
|
|
setEditingGoal(goal)
|
|
setGoalForm({ name: goal.name, event_name: goal.event_name })
|
|
setGoalModalOpen(true)
|
|
}
|
|
|
|
const handleGoalSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!goalForm.name.trim() || !goalForm.event_name.trim()) {
|
|
toast.error('Name and event name are required')
|
|
return
|
|
}
|
|
const eventName = goalForm.event_name.trim().toLowerCase().replace(/\s+/g, '_')
|
|
if (eventName.length > 64) {
|
|
toast.error('Event name must be 64 characters or less')
|
|
return
|
|
}
|
|
if (!/^[a-zA-Z0-9_]+$/.test(eventName)) {
|
|
toast.error('Event name can only contain letters, numbers, and underscores')
|
|
return
|
|
}
|
|
const duplicateEventName = editingGoal
|
|
? goals.some((g) => g.id !== editingGoal.id && g.event_name === eventName)
|
|
: goals.some((g) => g.event_name === eventName)
|
|
if (duplicateEventName) {
|
|
toast.error('A goal with this event name already exists')
|
|
return
|
|
}
|
|
setGoalSaving(true)
|
|
try {
|
|
if (editingGoal) {
|
|
await updateGoal(siteId, editingGoal.id, { name: goalForm.name.trim(), event_name: eventName })
|
|
toast.success('Goal updated')
|
|
} else {
|
|
await createGoal(siteId, { name: goalForm.name.trim(), event_name: eventName })
|
|
toast.success('Goal created')
|
|
}
|
|
setGoalModalOpen(false)
|
|
mutateGoals()
|
|
} catch (err) {
|
|
toast.error(getAuthErrorMessage(err as Error) || 'Failed to save goal')
|
|
} finally {
|
|
setGoalSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleDeleteGoal = async (goal: Goal) => {
|
|
if (!confirm(`Delete goal "${goal.name}"?`)) return
|
|
try {
|
|
await deleteGoal(siteId, goal.id)
|
|
toast.success('Goal deleted')
|
|
mutateGoals()
|
|
} catch (err) {
|
|
toast.error(getAuthErrorMessage(err as Error) || 'Failed to delete goal')
|
|
}
|
|
}
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
setSaving(true)
|
|
|
|
try {
|
|
const excludedPathsArray = formData.excluded_paths
|
|
.split('\n')
|
|
.map(p => p.trim())
|
|
.filter(p => p.length > 0)
|
|
|
|
await updateSite(siteId, {
|
|
name: formData.name,
|
|
timezone: formData.timezone,
|
|
is_public: formData.is_public,
|
|
password: isPasswordEnabled ? (formData.password || undefined) : undefined,
|
|
clear_password: !isPasswordEnabled,
|
|
excluded_paths: excludedPathsArray,
|
|
// Data collection settings
|
|
collect_page_paths: formData.collect_page_paths,
|
|
collect_referrers: formData.collect_referrers,
|
|
collect_device_info: formData.collect_device_info,
|
|
collect_geo_data: formData.collect_geo_data,
|
|
collect_screen_resolution: formData.collect_screen_resolution,
|
|
// Bot and noise filtering
|
|
filter_bots: formData.filter_bots,
|
|
// Hide unknown locations
|
|
hide_unknown_locations: formData.hide_unknown_locations,
|
|
// Data retention
|
|
data_retention_months: formData.data_retention_months
|
|
})
|
|
toast.success('Site updated successfully')
|
|
initialFormRef.current = JSON.stringify({
|
|
name: formData.name,
|
|
timezone: formData.timezone,
|
|
is_public: formData.is_public,
|
|
excluded_paths: formData.excluded_paths,
|
|
collect_page_paths: formData.collect_page_paths,
|
|
collect_referrers: formData.collect_referrers,
|
|
collect_device_info: formData.collect_device_info,
|
|
collect_geo_data: formData.collect_geo_data,
|
|
collect_screen_resolution: formData.collect_screen_resolution,
|
|
filter_bots: formData.filter_bots,
|
|
hide_unknown_locations: formData.hide_unknown_locations,
|
|
data_retention_months: formData.data_retention_months
|
|
})
|
|
mutateSite()
|
|
} catch (error: unknown) {
|
|
toast.error(getAuthErrorMessage(error) || 'Failed to save site settings')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleResetData = async () => {
|
|
if (!confirm('Are you sure you want to delete ALL data for this site? This action cannot be undone.')) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
await resetSiteData(siteId)
|
|
toast.success('All site data has been reset')
|
|
} catch (error: unknown) {
|
|
toast.error(getAuthErrorMessage(error) || 'Failed to reset site data')
|
|
}
|
|
}
|
|
|
|
const handleDeleteSite = async () => {
|
|
const confirmation = prompt('To confirm deletion, please type the site domain:')
|
|
if (confirmation !== site?.domain) {
|
|
if (confirmation) toast.error('Domain does not match')
|
|
return
|
|
}
|
|
|
|
try {
|
|
await deleteSite(siteId)
|
|
toast.success('Site deleted successfully')
|
|
router.push('/')
|
|
} catch (error: unknown) {
|
|
toast.error(getAuthErrorMessage(error) || 'Failed to delete site')
|
|
}
|
|
}
|
|
|
|
const copyLink = () => {
|
|
const link = `${APP_URL}/share/${siteId}`
|
|
navigator.clipboard.writeText(link)
|
|
setLinkCopied(true)
|
|
toast.success('Link copied to clipboard')
|
|
setTimeout(() => setLinkCopied(false), 2000)
|
|
}
|
|
|
|
const copySnippet = () => {
|
|
if (!site) return
|
|
navigator.clipboard.writeText(generatePrivacySnippet(site))
|
|
setSnippetCopied(true)
|
|
toast.success('Privacy snippet copied to clipboard')
|
|
setTimeout(() => setSnippetCopied(false), 2000)
|
|
}
|
|
|
|
const isFormDirty = initialFormRef.current !== '' && JSON.stringify({
|
|
name: formData.name,
|
|
timezone: formData.timezone,
|
|
is_public: formData.is_public,
|
|
excluded_paths: formData.excluded_paths,
|
|
collect_page_paths: formData.collect_page_paths,
|
|
collect_referrers: formData.collect_referrers,
|
|
collect_device_info: formData.collect_device_info,
|
|
collect_geo_data: formData.collect_geo_data,
|
|
collect_screen_resolution: formData.collect_screen_resolution,
|
|
filter_bots: formData.filter_bots,
|
|
hide_unknown_locations: formData.hide_unknown_locations,
|
|
data_retention_months: formData.data_retention_months
|
|
}) !== initialFormRef.current
|
|
|
|
useUnsavedChanges(isFormDirty)
|
|
|
|
useEffect(() => {
|
|
if (site?.domain) document.title = `Settings · ${site.domain} | Pulse`
|
|
}, [site?.domain])
|
|
|
|
// Handle GSC OAuth callback query params
|
|
useEffect(() => {
|
|
const gsc = searchParams.get('gsc')
|
|
if (!gsc) return
|
|
switch (gsc) {
|
|
case 'connected':
|
|
toast.success('Google Search Console connected successfully')
|
|
mutateGSCStatus()
|
|
break
|
|
case 'denied':
|
|
toast.error('Google authorization was denied')
|
|
break
|
|
case 'no_property':
|
|
toast.error('No matching Search Console property found for this site')
|
|
break
|
|
case 'error':
|
|
toast.error('Failed to connect Google Search Console')
|
|
break
|
|
}
|
|
setActiveTab('integrations')
|
|
window.history.replaceState({}, '', window.location.pathname)
|
|
}, [searchParams, mutateGSCStatus])
|
|
|
|
const showSkeleton = useMinimumLoading(siteLoading && !site)
|
|
const fadeClass = useSkeletonFade(showSkeleton)
|
|
|
|
if (showSkeleton) {
|
|
return (
|
|
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
|
<div className="space-y-8">
|
|
<div>
|
|
<div className="h-8 w-40 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800 mb-2" />
|
|
<div className="h-4 w-64 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
|
</div>
|
|
<div className="flex flex-col md:flex-row gap-8">
|
|
<nav className="w-full md:w-64 flex-shrink-0 space-y-1">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<div key={i} className="h-12 animate-pulse rounded-xl bg-neutral-100 dark:bg-neutral-800" />
|
|
))}
|
|
</nav>
|
|
<div className="flex-1 bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-6 md:p-8">
|
|
<SettingsFormSkeleton />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!site) {
|
|
return (
|
|
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
|
<p className="text-neutral-600 dark:text-neutral-400">Site not found</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
|
|
|
<div className="space-y-8">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">Site Settings</h1>
|
|
<p className="mt-2 text-neutral-600 dark:text-neutral-400">
|
|
Manage settings for <span className="font-medium text-neutral-900 dark:text-white">{site.domain}</span>
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-col md:flex-row gap-8">
|
|
{/* Sidebar Navigation */}
|
|
<nav className="w-full md:w-64 flex-shrink-0 space-y-1" role="tablist" aria-label="Site settings sections">
|
|
<button
|
|
onClick={() => setActiveTab('general')}
|
|
role="tab"
|
|
aria-selected={activeTab === 'general'}
|
|
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 ${
|
|
activeTab === 'general'
|
|
? 'bg-brand-orange/10 text-brand-orange'
|
|
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
|
}`}
|
|
>
|
|
<SettingsIcon className="w-5 h-5" />
|
|
General
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('visibility')}
|
|
role="tab"
|
|
aria-selected={activeTab === 'visibility'}
|
|
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 ${
|
|
activeTab === 'visibility'
|
|
? 'bg-brand-orange/10 text-brand-orange'
|
|
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
|
}`}
|
|
>
|
|
<GlobeIcon className="w-5 h-5" />
|
|
Visibility
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('data')}
|
|
role="tab"
|
|
aria-selected={activeTab === 'data'}
|
|
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 ${
|
|
activeTab === 'data'
|
|
? 'bg-brand-orange/10 text-brand-orange'
|
|
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
|
}`}
|
|
>
|
|
<ShieldCheck className="w-5 h-5" />
|
|
Data & Privacy
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('goals')}
|
|
role="tab"
|
|
aria-selected={activeTab === 'goals'}
|
|
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 ${
|
|
activeTab === 'goals'
|
|
? 'bg-brand-orange/10 text-brand-orange'
|
|
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
|
}`}
|
|
>
|
|
<ZapIcon className="w-5 h-5" />
|
|
Goals & Events
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('reports')}
|
|
role="tab"
|
|
aria-selected={activeTab === 'reports'}
|
|
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 ${
|
|
activeTab === 'reports'
|
|
? 'bg-brand-orange/10 text-brand-orange'
|
|
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
|
}`}
|
|
>
|
|
<PaperPlaneTilt className="w-5 h-5" />
|
|
Reports
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('integrations')}
|
|
role="tab"
|
|
aria-selected={activeTab === 'integrations'}
|
|
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 ${
|
|
activeTab === 'integrations'
|
|
? 'bg-brand-orange/10 text-brand-orange'
|
|
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
|
}`}
|
|
>
|
|
<Plugs className="w-5 h-5" />
|
|
Integrations
|
|
</button>
|
|
</nav>
|
|
|
|
{/* Content Area */}
|
|
<div className="flex-1 relative">
|
|
{!canEdit && (
|
|
<div className="mb-6 p-4 bg-amber-50 dark:bg-amber-900/10 text-amber-800 dark:text-amber-200 rounded-xl border border-amber-200 dark:border-amber-800 flex items-center gap-3">
|
|
<AlertTriangleIcon className="w-5 h-5" />
|
|
<p className="text-sm font-medium">You have read-only access to this site. Contact an admin to make changes.</p>
|
|
</div>
|
|
)}
|
|
|
|
<motion.div
|
|
key={activeTab}
|
|
initial={{ opacity: 0, x: 20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-6 md:p-8 shadow-sm"
|
|
>
|
|
{activeTab === 'general' && (
|
|
<div className="space-y-12">
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">General Configuration</h2>
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">Update your site details and tracking script.</p>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="space-y-1.5">
|
|
<label htmlFor="name" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
|
Site Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
id="name"
|
|
required
|
|
maxLength={100}
|
|
value={formData.name}
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-neutral-50/50 dark:bg-neutral-900/50 focus:bg-white dark:focus:bg-neutral-900
|
|
focus:border-brand-orange focus:ring-4 focus:ring-brand-orange/10 outline-none transition-all duration-200 dark:text-white"
|
|
/>
|
|
{formData.name.length > 80 && (
|
|
<span className={`text-xs tabular-nums ${formData.name.length > 90 ? 'text-amber-500' : 'text-neutral-400'}`}>{formData.name.length}/100</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<label htmlFor="timezone" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
|
Timezone
|
|
</label>
|
|
<Select
|
|
id="timezone"
|
|
value={formData.timezone}
|
|
onChange={(v) => setFormData({ ...formData, timezone: v })}
|
|
options={TIMEZONES.map((tz) => ({ value: tz, label: tz }))}
|
|
variant="input"
|
|
fullWidth
|
|
align="left"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
|
Domain
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={site.domain}
|
|
disabled
|
|
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-neutral-100 dark:bg-neutral-800/50 text-neutral-500 dark:text-neutral-400 cursor-not-allowed"
|
|
/>
|
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
|
Domain cannot be changed after creation
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-4 border-t border-neutral-100 dark:border-neutral-800">
|
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-2">Tracking Script</h3>
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
|
|
Add this script to your website to start tracking visitors. Choose your framework for setup instructions.
|
|
</p>
|
|
<ScriptSetupBlock
|
|
site={{ domain: site.domain, name: site.name }}
|
|
showFrameworkPicker
|
|
className="mb-4"
|
|
/>
|
|
|
|
<div className="flex items-center gap-4 mt-4">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowVerificationModal(true)}
|
|
className={`flex items-center gap-2 px-4 py-2 border rounded-xl transition-all text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 ${
|
|
site.is_verified
|
|
? 'bg-green-50 dark:bg-green-900/10 border-green-200 dark:border-green-900/30 text-green-700 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900/20 focus-visible:ring-green-500'
|
|
: 'bg-white dark:bg-neutral-800 border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700 focus-visible:ring-brand-orange'
|
|
}`}
|
|
>
|
|
{site.is_verified ? <CheckIcon className="w-4 h-4" /> : <ZapIcon className="w-4 h-4" />}
|
|
{site.is_verified ? 'Verified' : 'Verify Installation'}
|
|
</button>
|
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
|
{site.is_verified ? 'Your site is sending data correctly.' : 'Check if your site is sending data correctly.'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-4 border-t border-neutral-100 dark:border-neutral-800 flex justify-end">
|
|
{canEdit && (
|
|
<Button type="submit" disabled={saving} isLoading={saving}>
|
|
Save Changes
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
|
|
{canEdit && (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-red-600 dark:text-red-500 mb-1">Danger Zone</h2>
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">Irreversible actions for your site.</p>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="p-4 border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-900/10 rounded-2xl flex items-center justify-between">
|
|
<div>
|
|
<h3 className="font-medium text-red-900 dark:text-red-200">Reset Data</h3>
|
|
<p className="text-sm text-red-700 dark:text-red-300 mt-1">Delete all stats and events. This cannot be undone.</p>
|
|
</div>
|
|
<button
|
|
onClick={handleResetData}
|
|
className="px-4 py-2 bg-white dark:bg-neutral-900 border border-red-200 dark:border-red-900 text-red-600 dark:text-red-400 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2"
|
|
>
|
|
Reset Data
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-4 border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-900/10 rounded-2xl flex items-center justify-between">
|
|
<div>
|
|
<h3 className="font-medium text-red-900 dark:text-red-200">Delete Site</h3>
|
|
<p className="text-sm text-red-700 dark:text-red-300 mt-1">Permanently delete this site and all data.</p>
|
|
</div>
|
|
<button
|
|
onClick={handleDeleteSite}
|
|
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2"
|
|
>
|
|
Delete Site
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'visibility' && (
|
|
<div className="space-y-12">
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">Visibility Settings</h2>
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">Manage who can view your dashboard.</p>
|
|
</div>
|
|
|
|
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-white dark:bg-neutral-800 rounded-lg text-neutral-400">
|
|
<GlobeIcon className="w-6 h-6" />
|
|
</div>
|
|
<div>
|
|
<h3 className="font-medium text-neutral-900 dark:text-white">Public Dashboard</h3>
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
|
Allow anyone with the link to view this dashboard
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.is_public}
|
|
onChange={(e) => setFormData({ ...formData, is_public: e.target.checked })}
|
|
className="sr-only peer"
|
|
/>
|
|
<div className="w-11 h-6 bg-neutral-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-orange/20 dark:peer-focus:ring-brand-orange/20 rounded-full peer dark:bg-neutral-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-neutral-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-neutral-600 peer-checked:bg-brand-orange"></div>
|
|
</label>
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
{formData.is_public && (
|
|
<motion.div
|
|
initial={{ opacity: 0, height: 0 }}
|
|
animate={{ opacity: 1, height: 'auto' }}
|
|
exit={{ opacity: 0, height: 0 }}
|
|
className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-800 overflow-hidden space-y-6"
|
|
>
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2 text-neutral-900 dark:text-white">
|
|
Public Link
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
readOnly
|
|
value={`${APP_URL}/share/${siteId}`}
|
|
className="flex-1 px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 font-mono text-sm"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={copyLink}
|
|
className="px-4 py-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-neutral-900 dark:text-white rounded-xl font-medium hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
|
|
>
|
|
{linkCopied ? 'Copied!' : 'Copy Link'}
|
|
</button>
|
|
</div>
|
|
<p className="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
|
|
Share this link with others to view the dashboard.
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h3 className="text-sm font-medium text-neutral-900 dark:text-white">Password Protection</h3>
|
|
<p className="text-xs text-neutral-500 mt-1">Restrict access to this dashboard.</p>
|
|
</div>
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={isPasswordEnabled}
|
|
onChange={(e) => {
|
|
setIsPasswordEnabled(e.target.checked);
|
|
if (!e.target.checked) setFormData({...formData, password: ''});
|
|
}}
|
|
className="sr-only peer"
|
|
/>
|
|
<div className="w-11 h-6 bg-neutral-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-orange/20 dark:peer-focus:ring-brand-orange/20 rounded-full peer dark:bg-neutral-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-neutral-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-neutral-600 peer-checked:bg-brand-orange"></div>
|
|
</label>
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
{isPasswordEnabled && (
|
|
<motion.div
|
|
initial={{ opacity: 0, height: 0 }}
|
|
animate={{ opacity: 1, height: 'auto' }}
|
|
exit={{ opacity: 0, height: 0 }}
|
|
className="overflow-hidden"
|
|
>
|
|
<PasswordInput
|
|
id="password"
|
|
value={formData.password}
|
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
|
placeholder={site.has_password ? "Change password (leave empty to keep current)" : "Set a password"}
|
|
/>
|
|
<p className="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
|
|
Visitors will need to enter this password to view the dashboard.
|
|
</p>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
<div className="pt-4 border-t border-neutral-100 dark:border-neutral-800 flex justify-end">
|
|
{canEdit && (
|
|
<Button type="submit" disabled={saving} isLoading={saving}>
|
|
Save Changes
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'data' && (
|
|
<div className="space-y-12">
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">Data & Privacy</h2>
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">Control what visitor data is collected. Less data = more privacy.</p>
|
|
</div>
|
|
|
|
{/* Data Collection Controls */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Data Collection</h3>
|
|
|
|
{/* Page Paths Toggle */}
|
|
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h4 className="font-medium text-neutral-900 dark:text-white">Page Paths</h4>
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
|
|
Track which pages visitors view
|
|
</p>
|
|
</div>
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.collect_page_paths}
|
|
onChange={(e) => setFormData({ ...formData, collect_page_paths: e.target.checked })}
|
|
className="sr-only peer"
|
|
/>
|
|
<div className="w-11 h-6 bg-neutral-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-orange/20 dark:peer-focus:ring-brand-orange/20 rounded-full peer dark:bg-neutral-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-neutral-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-neutral-600 peer-checked:bg-brand-orange"></div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Referrers Toggle */}
|
|
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h4 className="font-medium text-neutral-900 dark:text-white">Referrers</h4>
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
|
|
Track where visitors come from
|
|
</p>
|
|
</div>
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.collect_referrers}
|
|
onChange={(e) => setFormData({ ...formData, collect_referrers: e.target.checked })}
|
|
className="sr-only peer"
|
|
/>
|
|
<div className="w-11 h-6 bg-neutral-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-orange/20 dark:peer-focus:ring-brand-orange/20 rounded-full peer dark:bg-neutral-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-neutral-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-neutral-600 peer-checked:bg-brand-orange"></div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Device Info Toggle */}
|
|
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h4 className="font-medium text-neutral-900 dark:text-white">Device Info</h4>
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
|
|
Track browser, OS, and device type
|
|
</p>
|
|
</div>
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.collect_device_info}
|
|
onChange={(e) => setFormData({ ...formData, collect_device_info: e.target.checked })}
|
|
className="sr-only peer"
|
|
/>
|
|
<div className="w-11 h-6 bg-neutral-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-orange/20 dark:peer-focus:ring-brand-orange/20 rounded-full peer dark:bg-neutral-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-neutral-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-neutral-600 peer-checked:bg-brand-orange"></div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Geographic Data Dropdown */}
|
|
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h4 className="font-medium text-neutral-900 dark:text-white">Geographic Data</h4>
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
|
|
Control location tracking granularity
|
|
</p>
|
|
</div>
|
|
<Select
|
|
value={formData.collect_geo_data}
|
|
onChange={(v) => setFormData({ ...formData, collect_geo_data: v as GeoDataLevel })}
|
|
options={[
|
|
{ value: 'full', label: 'Full (country, region, city)' },
|
|
{ value: 'country', label: 'Country only' },
|
|
{ value: 'none', label: 'None' },
|
|
]}
|
|
variant="input"
|
|
align="right"
|
|
className="min-w-[200px]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Screen Resolution Toggle */}
|
|
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h4 className="font-medium text-neutral-900 dark:text-white">Screen Resolution</h4>
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
|
|
Track visitor screen sizes
|
|
</p>
|
|
</div>
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.collect_screen_resolution}
|
|
onChange={(e) => setFormData({ ...formData, collect_screen_resolution: e.target.checked })}
|
|
className="sr-only peer"
|
|
/>
|
|
<div className="w-11 h-6 bg-neutral-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-orange/20 dark:peer-focus:ring-brand-orange/20 rounded-full peer dark:bg-neutral-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-neutral-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-neutral-600 peer-checked:bg-brand-orange"></div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bot and noise filtering */}
|
|
<div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800">
|
|
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Filtering</h3>
|
|
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h4 className="font-medium text-neutral-900 dark:text-white">Filter bots and referrer spam</h4>
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
|
|
Exclude known crawlers, scrapers, and referrer spam domains from your stats
|
|
</p>
|
|
</div>
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.filter_bots}
|
|
onChange={(e) => setFormData({ ...formData, filter_bots: e.target.checked })}
|
|
className="sr-only peer"
|
|
/>
|
|
<div className="w-11 h-6 bg-neutral-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-orange/20 dark:peer-focus:ring-brand-orange/20 rounded-full peer dark:bg-neutral-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-neutral-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-neutral-600 peer-checked:bg-brand-orange"></div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h4 className="font-medium text-neutral-900 dark:text-white">Hide unknown locations</h4>
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
|
|
Exclude entries where geographic data could not be resolved from location stats
|
|
</p>
|
|
</div>
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.hide_unknown_locations}
|
|
onChange={(e) => setFormData({ ...formData, hide_unknown_locations: e.target.checked })}
|
|
className="sr-only peer"
|
|
/>
|
|
<div className="w-11 h-6 bg-neutral-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-orange/20 dark:peer-focus:ring-brand-orange/20 rounded-full peer dark:bg-neutral-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-neutral-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-neutral-600 peer-checked:bg-brand-orange"></div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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>
|
|
{!!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={() => mutateSubscription()}
|
|
className="shrink-0 text-sm font-medium text-amber-800 dark:text-amber-200 hover:underline"
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
)}
|
|
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h4 className="font-medium text-neutral-900 dark:text-white">Keep raw event data for</h4>
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
|
|
Events older than this are automatically deleted. Aggregated daily stats are kept permanently.
|
|
</p>
|
|
</div>
|
|
<Select
|
|
value={String(formData.data_retention_months)}
|
|
onChange={(v) => setFormData({ ...formData, data_retention_months: Number(v) })}
|
|
options={getRetentionOptionsForPlan(subscription?.plan_id).map(opt => ({
|
|
value: String(opt.value),
|
|
label: opt.label,
|
|
}))}
|
|
variant="input"
|
|
align="right"
|
|
className="min-w-[160px]"
|
|
/>
|
|
</div>
|
|
{subscription?.plan_id && subscription.plan_id !== 'free' && (
|
|
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-3">
|
|
Your {subscription.plan_id} plan supports up to {formatRetentionMonths(
|
|
getRetentionOptionsForPlan(subscription.plan_id).at(-1)?.value ?? 6
|
|
)} of data retention.
|
|
</p>
|
|
)}
|
|
{(!subscription?.plan_id || subscription.plan_id === 'free') && (
|
|
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-3">
|
|
Free plan supports up to 6 months. <a href="/pricing" className="text-brand-orange hover:underline">Upgrade</a> for longer retention.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Excluded Paths */}
|
|
<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">Path Filtering</h3>
|
|
<div className="space-y-1.5">
|
|
<label htmlFor="excludedPaths" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
|
Excluded Paths
|
|
</label>
|
|
<div className="relative">
|
|
<textarea
|
|
id="excludedPaths"
|
|
rows={4}
|
|
value={formData.excluded_paths}
|
|
onChange={(e) => setFormData({ ...formData, excluded_paths: e.target.value })}
|
|
placeholder="/admin/* /staging/*"
|
|
className="w-full px-4 py-3 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-neutral-50/50 dark:bg-neutral-900/50 focus:bg-white dark:focus:bg-neutral-900
|
|
focus:border-brand-orange focus:ring-4 focus:ring-brand-orange/10 outline-none transition-all duration-200 dark:text-white font-mono text-sm"
|
|
/>
|
|
</div>
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-2">
|
|
Enter paths to exclude from tracking (one per line). Supports wildcards (e.g., /admin/*).
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* For your privacy policy */}
|
|
<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">
|
|
For your privacy policy
|
|
</h3>
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
|
Copy the text below into your site's Privacy Policy to describe your use of Pulse.
|
|
It updates automatically based on your saved settings above.
|
|
</p>
|
|
<p className="text-xs text-amber-600 dark:text-amber-500">
|
|
This is provided for convenience and is not legal advice. You are responsible for ensuring
|
|
your privacy policy is accurate and complies with applicable laws.
|
|
</p>
|
|
<div className="relative">
|
|
<textarea
|
|
readOnly
|
|
rows={6}
|
|
value={site ? generatePrivacySnippet(site) : ''}
|
|
className="w-full px-4 py-3 pr-12 border border-neutral-200 dark:border-neutral-800 rounded-xl
|
|
bg-neutral-50 dark:bg-neutral-900/50 font-sans text-sm text-neutral-700 dark:text-neutral-300
|
|
focus:outline-none resize-y"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={copySnippet}
|
|
className="absolute top-3 right-3 p-2 rounded-lg bg-neutral-200 dark:bg-neutral-700
|
|
hover:bg-neutral-300 dark:hover:bg-neutral-600 text-neutral-600 dark:text-neutral-300
|
|
transition-colors"
|
|
title="Copy snippet"
|
|
>
|
|
{snippetCopied ? (
|
|
<CheckIcon className="w-4 h-4 text-green-600" />
|
|
) : (
|
|
<svg className="w-4 h-4 text-neutral-500 dark:text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-4 border-t border-neutral-100 dark:border-neutral-800 flex justify-end">
|
|
{canEdit && (
|
|
<Button type="submit" disabled={saving} isLoading={saving}>
|
|
Save Changes
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'goals' && (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">Goals & Events</h2>
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
|
Define goals to label custom events (e.g. signup, purchase). Track with <code className="px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-xs">pulse.track('event_name')</code> in your snippet.
|
|
</p>
|
|
</div>
|
|
{goalsLoading ? (
|
|
<GoalsListSkeleton />
|
|
) : (
|
|
<>
|
|
{canEdit && (
|
|
<Button onClick={openAddGoal} variant="primary">
|
|
Add goal
|
|
</Button>
|
|
)}
|
|
<div className="space-y-2">
|
|
{goals.length === 0 ? (
|
|
<div className="p-6 rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/50 text-center text-neutral-500 dark:text-neutral-400 text-sm">
|
|
No goals yet. Add a goal to give custom events a display name in the dashboard.
|
|
</div>
|
|
) : (
|
|
goals.map((goal) => (
|
|
<div
|
|
key={goal.id}
|
|
className="flex items-center justify-between py-3 px-4 rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/50"
|
|
>
|
|
<div>
|
|
<span className="font-medium text-neutral-900 dark:text-white">{goal.name}</span>
|
|
<span className="text-neutral-500 dark:text-neutral-400 text-sm ml-2">({goal.event_name})</span>
|
|
</div>
|
|
{canEdit && (
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => openEditGoal(goal)}
|
|
className="text-sm text-brand-orange hover:underline"
|
|
>
|
|
Edit
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleDeleteGoal(goal)}
|
|
className="text-sm text-red-600 dark:text-red-400 hover:underline"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'reports' && (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">Scheduled Reports</h2>
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">Automatically deliver analytics reports via email or webhooks.</p>
|
|
</div>
|
|
{canEdit && (
|
|
<Button onClick={() => { setEditingSchedule(null); resetReportForm(); setReportModalOpen(true) }}>
|
|
Add Report
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{reportLoading ? (
|
|
<div className="space-y-2">
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
<div key={i} className="h-20 animate-pulse rounded-xl bg-neutral-100 dark:bg-neutral-800" />
|
|
))}
|
|
</div>
|
|
) : reportSchedules.length === 0 ? (
|
|
<div className="p-6 rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/50 text-center text-neutral-500 dark:text-neutral-400 text-sm">
|
|
No scheduled reports yet. Add a report to automatically receive analytics summaries.
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{reportSchedules.map((schedule) => (
|
|
<div
|
|
key={schedule.id}
|
|
className={`rounded-xl border p-4 transition-colors ${
|
|
schedule.enabled
|
|
? 'border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900/50'
|
|
: 'border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/30 opacity-60'
|
|
}`}
|
|
>
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex items-start gap-3 min-w-0">
|
|
<div className="p-2 bg-neutral-100 dark:bg-neutral-800 rounded-lg mt-0.5">
|
|
{schedule.channel === 'email' ? (
|
|
<Envelope className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
|
|
) : (
|
|
<WebhooksLogo className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
|
|
)}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="font-medium text-neutral-900 dark:text-white">
|
|
{getChannelLabel(schedule.channel)}
|
|
</span>
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-brand-orange/10 text-brand-orange">
|
|
{getFrequencyLabel(schedule.frequency)}
|
|
</span>
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400">
|
|
{getReportTypeLabel(schedule.report_type)}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1 truncate">
|
|
{schedule.channel === 'email'
|
|
? (schedule.channel_config as EmailConfig).recipients.join(', ')
|
|
: (schedule.channel_config as WebhookConfig).url}
|
|
</p>
|
|
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
|
|
{getScheduleDescription(schedule)}
|
|
</p>
|
|
<div className="flex items-center gap-3 mt-1 text-xs text-neutral-400 dark:text-neutral-500">
|
|
<span>
|
|
Last sent: {schedule.last_sent_at
|
|
? formatDateTime(new Date(schedule.last_sent_at))
|
|
: 'Never'}
|
|
</span>
|
|
</div>
|
|
{schedule.last_error && (
|
|
<p className="text-xs text-red-500 dark:text-red-400 mt-1">
|
|
Error: {schedule.last_error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{canEdit && (
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleReportTest(schedule)}
|
|
disabled={reportTesting === schedule.id}
|
|
className="p-2 text-neutral-500 hover:text-brand-orange hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors disabled:opacity-50"
|
|
title="Send test report"
|
|
>
|
|
{reportTesting === schedule.id ? (
|
|
<SpinnerGap className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Play className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => openEditSchedule(schedule)}
|
|
className="p-2 text-neutral-500 hover:text-brand-orange hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
|
|
title="Edit schedule"
|
|
>
|
|
<PencilSimple className="w-4 h-4" />
|
|
</button>
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={schedule.enabled}
|
|
onChange={() => handleReportToggle(schedule)}
|
|
className="sr-only peer"
|
|
/>
|
|
<div className="w-9 h-5 bg-neutral-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-orange/20 dark:peer-focus:ring-brand-orange/20 rounded-full peer dark:bg-neutral-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-neutral-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-neutral-600 peer-checked:bg-brand-orange"></div>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleReportDelete(schedule)}
|
|
className="p-2 text-neutral-500 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
|
title="Delete schedule"
|
|
>
|
|
<Trash className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'integrations' && (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">Integrations</h2>
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">Connect external services to enrich your analytics data.</p>
|
|
</div>
|
|
|
|
{/* Google Search Console */}
|
|
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/50 p-6">
|
|
{!gscStatus?.connected ? (
|
|
<div className="space-y-4">
|
|
<div className="flex items-start gap-4">
|
|
<div className="p-2.5 bg-white dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 flex-shrink-0">
|
|
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none">
|
|
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1Z" fill="#4285F4"/>
|
|
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23Z" fill="#34A853"/>
|
|
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62Z" fill="#FBBC05"/>
|
|
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53Z" fill="#EA4335"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">Google Search Console</h3>
|
|
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
|
|
See which search queries bring visitors to your site, with impressions, clicks, CTR, and ranking position.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-start gap-2 p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
|
|
<svg className="w-4 h-4 text-neutral-400 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" />
|
|
</svg>
|
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
|
Pulse only requests read-only access. Your tokens are encrypted at rest and all data can be fully removed at any time.
|
|
</p>
|
|
</div>
|
|
{canEdit && (
|
|
<button
|
|
onClick={async () => {
|
|
setGscConnecting(true)
|
|
try {
|
|
const { auth_url } = await getGSCAuthURL(siteId)
|
|
window.location.href = auth_url
|
|
} catch (error: unknown) {
|
|
toast.error(getAuthErrorMessage(error) || 'Failed to start Google authorization')
|
|
setGscConnecting(false)
|
|
}
|
|
}}
|
|
disabled={gscConnecting}
|
|
className="inline-flex items-center gap-2 px-4 py-2.5 bg-brand-orange text-white text-sm font-medium rounded-xl hover:bg-brand-orange/90 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 disabled:opacity-50"
|
|
>
|
|
{gscConnecting && <SpinnerGap className="w-4 h-4 animate-spin" />}
|
|
Connect Google Search Console
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex items-start gap-4">
|
|
<div className="p-2.5 bg-white dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 flex-shrink-0">
|
|
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none">
|
|
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1Z" fill="#4285F4"/>
|
|
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23Z" fill="#34A853"/>
|
|
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62Z" fill="#FBBC05"/>
|
|
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53Z" fill="#EA4335"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">Google Search Console</h3>
|
|
<div className="flex items-center gap-2 mt-1.5">
|
|
<span className={`inline-flex items-center gap-1.5 text-xs font-medium ${
|
|
gscStatus.status === 'active'
|
|
? 'text-green-600 dark:text-green-400'
|
|
: gscStatus.status === 'syncing'
|
|
? 'text-amber-600 dark:text-amber-400'
|
|
: 'text-red-600 dark:text-red-400'
|
|
}`}>
|
|
<span className={`w-2 h-2 rounded-full ${
|
|
gscStatus.status === 'active'
|
|
? 'bg-green-500'
|
|
: gscStatus.status === 'syncing'
|
|
? 'bg-amber-500 animate-pulse'
|
|
: 'bg-red-500'
|
|
}`} />
|
|
{gscStatus.status === 'active' ? 'Connected' : gscStatus.status === 'syncing' ? 'Syncing...' : 'Error'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
{gscStatus.google_email && (
|
|
<div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
|
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">Google Account</p>
|
|
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5 truncate">{gscStatus.google_email}</p>
|
|
</div>
|
|
)}
|
|
{gscStatus.gsc_property && (
|
|
<div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
|
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">Property</p>
|
|
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5 truncate">{gscStatus.gsc_property}</p>
|
|
</div>
|
|
)}
|
|
{gscStatus.last_synced_at && (
|
|
<div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
|
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">Last Synced</p>
|
|
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5">
|
|
{new Date(gscStatus.last_synced_at).toLocaleString('en-GB')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{gscStatus.created_at && (
|
|
<div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
|
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">Connected Since</p>
|
|
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5">
|
|
{new Date(gscStatus.created_at).toLocaleString('en-GB')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{gscStatus.status === 'error' && gscStatus.error_message && (
|
|
<div className="p-3 bg-red-50 dark:bg-red-900/10 rounded-lg border border-red-200 dark:border-red-900/30">
|
|
<p className="text-sm text-red-700 dark:text-red-300">{gscStatus.error_message}</p>
|
|
</div>
|
|
)}
|
|
|
|
{canEdit && (
|
|
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
|
|
<button
|
|
onClick={async () => {
|
|
if (!confirm('Disconnect Google Search Console? All search data will be removed from Pulse.')) return
|
|
setGscDisconnecting(true)
|
|
try {
|
|
await disconnectGSC(siteId)
|
|
mutateGSCStatus()
|
|
toast.success('Google Search Console disconnected')
|
|
} catch (error: unknown) {
|
|
toast.error(getAuthErrorMessage(error) || 'Failed to disconnect')
|
|
} finally {
|
|
setGscDisconnecting(false)
|
|
}
|
|
}}
|
|
disabled={gscDisconnecting}
|
|
className="inline-flex items-center gap-2 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 transition-colors disabled:opacity-50"
|
|
>
|
|
{gscDisconnecting && <SpinnerGap className="w-4 h-4 animate-spin" />}
|
|
Disconnect
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* BunnyCDN */}
|
|
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/50 p-6">
|
|
{!bunnyStatus?.connected ? (
|
|
<div className="space-y-4">
|
|
<div className="flex items-start gap-4">
|
|
<div className="p-2.5 bg-white dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 flex-shrink-0">
|
|
<svg className="w-6 h-6" viewBox="0 0 23 26" fill="none">
|
|
<path fillRule="evenodd" clipRule="evenodd" d="M9.94 7.77l5.106.883c-3.83-.663-4.065-3.85-9.218-6.653-.562 1.859.603 5.21 4.112 5.77z" fill="url(#b1)"/>
|
|
<path fillRule="evenodd" clipRule="evenodd" d="M5.828 2c5.153 2.803 5.388 5.99 9.218 6.653 1.922.332.186 3.612-1.864 3.266 3.684 1.252 7.044-2.085 5.122-3.132L5.828 2z" fill="url(#b2)"/>
|
|
<path fillRule="evenodd" clipRule="evenodd" d="M13.186 11.92c-.241-.041-.486-.131-.731-.284-1.542-.959-3.093-1.269-4.496-1.118 2.93.359 5.716 4.196 5.37 7.036.06.97-.281 1.958-1.021 2.699l-1.69 1.69c1.303.858 3.284-.037 3.889-1.281l3.41-7.014c.836-.198 6.176-1.583 3.767-3.024l-3.37-1.833c1.907 1.05-1.449 4.378-5.125 3.129z" fill="url(#b3)"/>
|
|
<path fillRule="evenodd" clipRule="evenodd" d="M7.953 10.518c-4.585.499-7.589 5.94-3.506 9.873l3.42 3.42c-2.243-2.243-2.458-5.525-1.073-7.806.149-.255.333-.495.551-.713 1.37-1.37 3.59-1.37 4.96 0 .629.628.969 1.436 1.02 2.26.346-2.84-2.439-6.675-5.367-7.035h-.005z" fill="url(#b4)"/>
|
|
<path fillRule="evenodd" clipRule="evenodd" d="M7.868 23.812l1.925 1.925c.643-.511 1.028-2.01.031-3.006l-2.48-2.48c-1.151-1.151-1.334-2.903-.55-4.246-1.385 2.281-1.17 5.563 1.074 7.807z" fill="url(#b5)"/>
|
|
<path fillRule="evenodd" clipRule="evenodd" d="M12.504 4.54l5.739 3.122L12.925.6c-.728.829-1.08 2.472-.421 3.94z" fill="url(#b6)"/>
|
|
<circle cx="9.825" cy="17.772" r="1.306" fill="url(#b7)"/>
|
|
<circle cx="1.507" cy="11.458" r="1.306" fill="url(#b8)"/>
|
|
<defs>
|
|
<linearGradient id="b1" x1="5.69" y1="8.5" x2="15.04" y2="8.5" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset=".69" stopColor="#FF7300"/><stop offset="1" stopColor="#F52900"/></linearGradient>
|
|
<linearGradient id="b2" x1="5.83" y1="12.65" x2="18.87" y2="12.65" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset=".69" stopColor="#FF7300"/><stop offset="1" stopColor="#F52900"/></linearGradient>
|
|
<linearGradient id="b3" x1="7.95" y1="22.04" x2="22.3" y2="22.04" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset="1" stopColor="#FF6200"/></linearGradient>
|
|
<linearGradient id="b4" x1="2.51" y1="22.59" x2="13.35" y2="22.59" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset=".69" stopColor="#FF7300"/><stop offset="1" stopColor="#F52900"/></linearGradient>
|
|
<linearGradient id="b5" x1="11.35" y1="20.74" x2="7.98" y2="17.71" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset=".69" stopColor="#FF7300"/><stop offset="1" stopColor="#F52900"/></linearGradient>
|
|
<linearGradient id="b6" x1="12.16" y1="7.48" x2="18.24" y2="7.48" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset="1" stopColor="#FF6200"/></linearGradient>
|
|
<linearGradient id="b7" x1="8.52" y1="19.08" x2="11.13" y2="19.08" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset="1" stopColor="#FF6200"/></linearGradient>
|
|
<linearGradient id="b8" x1=".2" y1="12.76" x2="2.81" y2="12.76" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset="1" stopColor="#FF6200"/></linearGradient>
|
|
</defs>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">BunnyCDN</h3>
|
|
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
|
|
Monitor CDN performance with bandwidth usage, cache hit rates, response times, and geographic distribution.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-start gap-2 p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
|
|
<svg className="w-4 h-4 text-neutral-400 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" />
|
|
</svg>
|
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
|
Your API key is encrypted at rest and only used to fetch read-only statistics. You can disconnect at any time.
|
|
</p>
|
|
</div>
|
|
{canEdit && (
|
|
<div className="space-y-3">
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="password"
|
|
value={bunnyApiKey}
|
|
onChange={(e) => {
|
|
setBunnyApiKey(e.target.value)
|
|
setBunnyPullZones([])
|
|
setBunnySelectedZone(null)
|
|
}}
|
|
placeholder="BunnyCDN API key"
|
|
className="flex-1 px-4 py-2.5 border border-neutral-200 dark:border-neutral-800 rounded-xl bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white text-sm placeholder:text-neutral-400"
|
|
/>
|
|
<button
|
|
onClick={async () => {
|
|
if (!bunnyApiKey.trim()) {
|
|
toast.error('Please enter your BunnyCDN API key')
|
|
return
|
|
}
|
|
setBunnyLoadingZones(true)
|
|
setBunnyPullZones([])
|
|
setBunnySelectedZone(null)
|
|
try {
|
|
const { pull_zones, message } = await getBunnyPullZones(siteId, bunnyApiKey)
|
|
if (pull_zones.length === 0) {
|
|
toast.error(message || 'No pull zones match this site\'s domain')
|
|
} else {
|
|
setBunnyPullZones(pull_zones)
|
|
setBunnySelectedZone(pull_zones[0])
|
|
}
|
|
} catch (error: unknown) {
|
|
toast.error(getAuthErrorMessage(error) || 'Failed to load pull zones')
|
|
} finally {
|
|
setBunnyLoadingZones(false)
|
|
}
|
|
}}
|
|
disabled={bunnyLoadingZones || !bunnyApiKey.trim()}
|
|
className="inline-flex items-center gap-2 px-4 py-2.5 bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 text-sm font-medium rounded-xl hover:bg-neutral-800 dark:hover:bg-neutral-100 transition-colors disabled:opacity-50"
|
|
>
|
|
{bunnyLoadingZones && <SpinnerGap className="w-4 h-4 animate-spin" />}
|
|
Load Zones
|
|
</button>
|
|
</div>
|
|
|
|
{bunnyPullZones.length > 0 && (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Pull Zone</label>
|
|
<select
|
|
value={bunnySelectedZone?.id ?? ''}
|
|
onChange={(e) => {
|
|
const zone = bunnyPullZones.find(z => z.id === Number(e.target.value))
|
|
setBunnySelectedZone(zone || null)
|
|
}}
|
|
className="w-full px-4 py-2.5 border border-neutral-200 dark:border-neutral-800 rounded-xl bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white text-sm"
|
|
>
|
|
{bunnyPullZones.map((zone) => (
|
|
<option key={zone.id} value={zone.id}>{zone.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<button
|
|
onClick={async () => {
|
|
if (!bunnySelectedZone) return
|
|
setBunnyConnecting(true)
|
|
try {
|
|
await connectBunny(siteId, bunnyApiKey, bunnySelectedZone.id, bunnySelectedZone.name)
|
|
mutateBunnyStatus()
|
|
setBunnyApiKey('')
|
|
setBunnyPullZones([])
|
|
setBunnySelectedZone(null)
|
|
toast.success('BunnyCDN connected successfully')
|
|
} catch (error: unknown) {
|
|
toast.error(getAuthErrorMessage(error) || 'Failed to connect BunnyCDN')
|
|
} finally {
|
|
setBunnyConnecting(false)
|
|
}
|
|
}}
|
|
disabled={bunnyConnecting || !bunnySelectedZone}
|
|
className="inline-flex items-center gap-2 px-4 py-2.5 bg-brand-orange text-white text-sm font-medium rounded-xl hover:bg-brand-orange/90 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 disabled:opacity-50"
|
|
>
|
|
{bunnyConnecting && <SpinnerGap className="w-4 h-4 animate-spin" />}
|
|
Connect BunnyCDN
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex items-start gap-4">
|
|
<div className="p-2.5 bg-white dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 flex-shrink-0">
|
|
<svg className="w-6 h-6" viewBox="0 0 23 26" fill="none">
|
|
<path fillRule="evenodd" clipRule="evenodd" d="M9.94 7.77l5.106.883c-3.83-.663-4.065-3.85-9.218-6.653-.562 1.859.603 5.21 4.112 5.77z" fill="url(#b1c)"/>
|
|
<path fillRule="evenodd" clipRule="evenodd" d="M5.828 2c5.153 2.803 5.388 5.99 9.218 6.653 1.922.332.186 3.612-1.864 3.266 3.684 1.252 7.044-2.085 5.122-3.132L5.828 2z" fill="url(#b2c)"/>
|
|
<path fillRule="evenodd" clipRule="evenodd" d="M13.186 11.92c-.241-.041-.486-.131-.731-.284-1.542-.959-3.093-1.269-4.496-1.118 2.93.359 5.716 4.196 5.37 7.036.06.97-.281 1.958-1.021 2.699l-1.69 1.69c1.303.858 3.284-.037 3.889-1.281l3.41-7.014c.836-.198 6.176-1.583 3.767-3.024l-3.37-1.833c1.907 1.05-1.449 4.378-5.125 3.129z" fill="url(#b3c)"/>
|
|
<path fillRule="evenodd" clipRule="evenodd" d="M7.953 10.518c-4.585.499-7.589 5.94-3.506 9.873l3.42 3.42c-2.243-2.243-2.458-5.525-1.073-7.806.149-.255.333-.495.551-.713 1.37-1.37 3.59-1.37 4.96 0 .629.628.969 1.436 1.02 2.26.346-2.84-2.439-6.675-5.367-7.035h-.005z" fill="url(#b4c)"/>
|
|
<path fillRule="evenodd" clipRule="evenodd" d="M7.868 23.812l1.925 1.925c.643-.511 1.028-2.01.031-3.006l-2.48-2.48c-1.151-1.151-1.334-2.903-.55-4.246-1.385 2.281-1.17 5.563 1.074 7.807z" fill="url(#b5c)"/>
|
|
<path fillRule="evenodd" clipRule="evenodd" d="M12.504 4.54l5.739 3.122L12.925.6c-.728.829-1.08 2.472-.421 3.94z" fill="url(#b6c)"/>
|
|
<circle cx="9.825" cy="17.772" r="1.306" fill="url(#b7c)"/>
|
|
<circle cx="1.507" cy="11.458" r="1.306" fill="url(#b8c)"/>
|
|
<defs>
|
|
<linearGradient id="b1c" x1="5.69" y1="8.5" x2="15.04" y2="8.5" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset=".69" stopColor="#FF7300"/><stop offset="1" stopColor="#F52900"/></linearGradient>
|
|
<linearGradient id="b2c" x1="5.83" y1="12.65" x2="18.87" y2="12.65" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset=".69" stopColor="#FF7300"/><stop offset="1" stopColor="#F52900"/></linearGradient>
|
|
<linearGradient id="b3c" x1="7.95" y1="22.04" x2="22.3" y2="22.04" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset="1" stopColor="#FF6200"/></linearGradient>
|
|
<linearGradient id="b4c" x1="2.51" y1="22.59" x2="13.35" y2="22.59" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset=".69" stopColor="#FF7300"/><stop offset="1" stopColor="#F52900"/></linearGradient>
|
|
<linearGradient id="b5c" x1="11.35" y1="20.74" x2="7.98" y2="17.71" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset=".69" stopColor="#FF7300"/><stop offset="1" stopColor="#F52900"/></linearGradient>
|
|
<linearGradient id="b6c" x1="12.16" y1="7.48" x2="18.24" y2="7.48" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset="1" stopColor="#FF6200"/></linearGradient>
|
|
<linearGradient id="b7c" x1="8.52" y1="19.08" x2="11.13" y2="19.08" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset="1" stopColor="#FF6200"/></linearGradient>
|
|
<linearGradient id="b8c" x1=".2" y1="12.76" x2="2.81" y2="12.76" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset="1" stopColor="#FF6200"/></linearGradient>
|
|
</defs>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">BunnyCDN</h3>
|
|
<div className="flex items-center gap-2 mt-1.5">
|
|
<span className={`inline-flex items-center gap-1.5 text-xs font-medium ${
|
|
bunnyStatus.status === 'active'
|
|
? 'text-green-600 dark:text-green-400'
|
|
: bunnyStatus.status === 'syncing'
|
|
? 'text-amber-600 dark:text-amber-400'
|
|
: 'text-red-600 dark:text-red-400'
|
|
}`}>
|
|
<span className={`w-2 h-2 rounded-full ${
|
|
bunnyStatus.status === 'active'
|
|
? 'bg-green-500'
|
|
: bunnyStatus.status === 'syncing'
|
|
? 'bg-amber-500 animate-pulse'
|
|
: 'bg-red-500'
|
|
}`} />
|
|
{bunnyStatus.status === 'active' ? 'Connected' : bunnyStatus.status === 'syncing' ? 'Syncing...' : 'Error'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
{bunnyStatus.pull_zone_name && (
|
|
<div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
|
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">Pull Zone</p>
|
|
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5 truncate">{bunnyStatus.pull_zone_name}</p>
|
|
</div>
|
|
)}
|
|
{bunnyStatus.last_synced_at && (
|
|
<div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
|
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">Last Synced</p>
|
|
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5">
|
|
{new Date(bunnyStatus.last_synced_at).toLocaleString('en-GB')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{bunnyStatus.created_at && (
|
|
<div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
|
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">Connected Since</p>
|
|
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5">
|
|
{new Date(bunnyStatus.created_at).toLocaleString('en-GB')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{bunnyStatus.status === 'error' && bunnyStatus.error_message && (
|
|
<div className="p-3 bg-red-50 dark:bg-red-900/10 rounded-lg border border-red-200 dark:border-red-900/30">
|
|
<p className="text-sm text-red-700 dark:text-red-300">{bunnyStatus.error_message}</p>
|
|
</div>
|
|
)}
|
|
|
|
{canEdit && (
|
|
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
|
|
<button
|
|
onClick={async () => {
|
|
if (!confirm('Disconnect BunnyCDN? All CDN analytics data will be removed from Pulse.')) return
|
|
setBunnyDisconnecting(true)
|
|
try {
|
|
await disconnectBunny(siteId)
|
|
mutateBunnyStatus()
|
|
toast.success('BunnyCDN disconnected')
|
|
} catch (error: unknown) {
|
|
toast.error(getAuthErrorMessage(error) || 'Failed to disconnect')
|
|
} finally {
|
|
setBunnyDisconnecting(false)
|
|
}
|
|
}}
|
|
disabled={bunnyDisconnecting}
|
|
className="inline-flex items-center gap-2 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 transition-colors disabled:opacity-50"
|
|
>
|
|
{bunnyDisconnecting && <SpinnerGap className="w-4 h-4 animate-spin" />}
|
|
Disconnect
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Modal
|
|
isOpen={goalModalOpen}
|
|
onClose={() => setGoalModalOpen(false)}
|
|
title={editingGoal ? 'Edit goal' : 'Add goal'}
|
|
>
|
|
<form onSubmit={handleGoalSubmit} className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Display name</label>
|
|
<input
|
|
type="text"
|
|
value={goalForm.name}
|
|
onChange={(e) => setGoalForm({ ...goalForm, name: e.target.value })}
|
|
placeholder="e.g. Signups"
|
|
autoFocus
|
|
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Event name</label>
|
|
<input
|
|
type="text"
|
|
value={goalForm.event_name}
|
|
onChange={(e) => setGoalForm({ ...goalForm, event_name: e.target.value })}
|
|
placeholder="e.g. signup_click (letters, numbers, underscores only)"
|
|
maxLength={64}
|
|
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white"
|
|
required
|
|
/>
|
|
<div className="flex justify-between mt-1">
|
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">Letters, numbers, and underscores only. Spaces become underscores.</p>
|
|
<span className={`text-xs tabular-nums ${goalForm.event_name.length > 56 ? 'text-amber-500' : 'text-neutral-400'}`}>{goalForm.event_name.length}/64</span>
|
|
</div>
|
|
{editingGoal && goalForm.event_name.trim().toLowerCase().replace(/\s+/g, '_') !== editingGoal.event_name && (
|
|
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">Changing event name does not reassign events already tracked under the previous name.</p>
|
|
)}
|
|
</div>
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<Button type="button" variant="secondary" onClick={() => setGoalModalOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" variant="primary" disabled={goalSaving}>
|
|
{goalSaving ? 'Saving…' : editingGoal ? 'Update' : 'Create'}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
|
|
<Modal
|
|
isOpen={reportModalOpen}
|
|
onClose={() => setReportModalOpen(false)}
|
|
title={editingSchedule ? 'Edit report schedule' : 'Add report schedule'}
|
|
>
|
|
<form onSubmit={handleReportSubmit} className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Channel</label>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{(['email', 'slack', 'discord', 'webhook'] as const).map((ch) => (
|
|
<button
|
|
key={ch}
|
|
type="button"
|
|
onClick={() => setReportForm({ ...reportForm, channel: ch })}
|
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
|
reportForm.channel === ch
|
|
? 'border-brand-orange bg-brand-orange/10 text-brand-orange'
|
|
: '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' ? <Envelope className="w-4 h-4" /> : <WebhooksLogo className="w-4 h-4" />}
|
|
{getChannelLabel(ch)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{reportForm.channel === 'email' ? (
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Recipients</label>
|
|
<input
|
|
type="text"
|
|
value={reportForm.recipients}
|
|
onChange={(e) => setReportForm({ ...reportForm, 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
|
|
/>
|
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">Comma-separated email addresses.</p>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
|
{reportForm.channel === 'slack' ? 'Slack Webhook URL' : reportForm.channel === 'discord' ? 'Discord Webhook URL' : 'Webhook URL'}
|
|
</label>
|
|
<input
|
|
type="url"
|
|
value={reportForm.webhookUrl}
|
|
onChange={(e) => setReportForm({ ...reportForm, 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
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Frequency</label>
|
|
<Select
|
|
value={reportForm.frequency}
|
|
onChange={(v) => setReportForm({ ...reportForm, frequency: v })}
|
|
options={[
|
|
{ value: 'daily', label: 'Daily' },
|
|
{ value: 'weekly', label: 'Weekly' },
|
|
{ value: 'monthly', label: 'Monthly' },
|
|
]}
|
|
variant="input"
|
|
fullWidth
|
|
align="left"
|
|
/>
|
|
</div>
|
|
|
|
{reportForm.frequency === 'weekly' && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Day of week</label>
|
|
<Select
|
|
value={String(reportForm.sendDay)}
|
|
onChange={(v) => setReportForm({ ...reportForm, sendDay: parseInt(v) })}
|
|
options={WEEKDAY_NAMES.map((name, i) => ({ value: String(i), label: name }))}
|
|
variant="input"
|
|
fullWidth
|
|
align="left"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{reportForm.frequency === 'monthly' && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Day of month</label>
|
|
<Select
|
|
value={String(reportForm.sendDay)}
|
|
onChange={(v) => setReportForm({ ...reportForm, sendDay: parseInt(v) })}
|
|
options={Array.from({ length: 28 }, (_, i) => {
|
|
const d = i + 1
|
|
const suffix = d === 1 ? 'st' : d === 2 ? 'nd' : d === 3 ? 'rd' : 'th'
|
|
return { value: String(d), label: `${d}${suffix}` }
|
|
})}
|
|
variant="input"
|
|
fullWidth
|
|
align="left"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Time</label>
|
|
<Select
|
|
value={String(reportForm.sendHour)}
|
|
onChange={(v) => setReportForm({ ...reportForm, sendHour: parseInt(v) })}
|
|
options={Array.from({ length: 24 }, (_, i) => ({
|
|
value: String(i),
|
|
label: formatHour(i),
|
|
}))}
|
|
variant="input"
|
|
fullWidth
|
|
align="left"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Timezone</label>
|
|
<Select
|
|
value={reportForm.timezone || 'UTC'}
|
|
onChange={(v) => setReportForm({ ...reportForm, timezone: v })}
|
|
options={TIMEZONES.map((tz) => ({ value: tz, label: tz }))}
|
|
variant="input"
|
|
fullWidth
|
|
align="left"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Report Type</label>
|
|
<Select
|
|
value={reportForm.reportType}
|
|
onChange={(v) => setReportForm({ ...reportForm, reportType: v })}
|
|
options={[
|
|
{ value: 'summary', label: 'Summary' },
|
|
{ value: 'pages', label: 'Pages' },
|
|
{ value: 'sources', label: 'Sources' },
|
|
{ value: 'goals', label: 'Goals' },
|
|
]}
|
|
variant="input"
|
|
fullWidth
|
|
align="left"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<Button type="button" variant="secondary" onClick={() => setReportModalOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" variant="primary" disabled={reportSaving}>
|
|
{reportSaving ? 'Saving...' : editingSchedule ? 'Update' : 'Create'}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
|
|
<VerificationModal
|
|
isOpen={showVerificationModal}
|
|
onClose={() => setShowVerificationModal(false)}
|
|
site={site}
|
|
onVerified={() => mutateSite()}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|