'use client' import { useEffect, useState, useRef } from 'react' import { useParams, useRouter } 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 { 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 } 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 } 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'>('general') 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, // Performance insights setting enable_performance_insights: false, // 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(null) const [goalForm, setGoalForm] = useState({ name: '', event_name: '' }) const [goalSaving, setGoalSaving] = useState(false) const initialFormRef = useRef('') // Report schedules const { data: reportSchedules = [], isLoading: reportLoading, mutate: mutateReportSchedules } = useReportSchedules(siteId) const [reportModalOpen, setReportModalOpen] = useState(false) const [editingSchedule, setEditingSchedule] = useState(null) const [reportSaving, setReportSaving] = useState(false) const [reportTesting, setReportTesting] = useState(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, 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(() => { 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, // Performance insights setting enable_performance_insights: formData.enable_performance_insights, // 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, enable_performance_insights: formData.enable_performance_insights, 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, enable_performance_insights: formData.enable_performance_insights, 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]) const showSkeleton = useMinimumLoading(siteLoading && !site) const fadeClass = useSkeletonFade(showSkeleton) if (showSkeleton) { return (
) } if (!site) { return (

Site not found

) } return (

Site Settings

Manage settings for {site.domain}

{/* Sidebar Navigation */} {/* Content Area */}
{!canEdit && (

You have read-only access to this site. Contact an admin to make changes.

)} {activeTab === 'general' && (

General Configuration

Update your site details and tracking script.

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 && ( 90 ? 'text-amber-500' : 'text-neutral-400'}`}>{formData.name.length}/100 )}

Domain cannot be changed after creation

Tracking Script

Add this script to your website to start tracking visitors. Choose your framework for setup instructions.

{site.is_verified ? 'Your site is sending data correctly.' : 'Check if your site is sending data correctly.'}

{canEdit && ( )}
{canEdit && (

Danger Zone

Irreversible actions for your site.

Reset Data

Delete all stats and events. This cannot be undone.

Delete Site

Permanently delete this site and all data.

)}
)} {activeTab === 'visibility' && (

Visibility Settings

Manage who can view your dashboard.

Public Dashboard

Allow anyone with the link to view this dashboard

{formData.is_public && (

Share this link with others to view the dashboard.

Password Protection

Restrict access to this dashboard.

{isPasswordEnabled && ( setFormData({ ...formData, password: e.target.value })} placeholder={site.has_password ? "Change password (leave empty to keep current)" : "Set a password"} />

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

)}
)}
{canEdit && ( )}
)} {activeTab === 'data' && (

Data & Privacy

Control what visitor data is collected. Less data = more privacy.

{/* Data Collection Controls */}

Data Collection

{/* Page Paths Toggle */}

Page Paths

Track which pages visitors view

{/* Referrers Toggle */}

Referrers

Track where visitors come from

{/* Device Info Toggle */}

Device Info

Track browser, OS, and device type

{/* Geographic Data Dropdown */}

Geographic Data

Control location tracking granularity

setFormData({ ...formData, collect_screen_resolution: e.target.checked })} className="sr-only peer" />
{/* Bot and noise filtering */}

Filtering

Filter bots and referrer spam

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

Hide unknown locations

Exclude entries where geographic data could not be resolved from location stats

{/* Performance Insights Toggle */}

Performance Insights

Performance Insights (Add-on)

Track Core Web Vitals (LCP, CLS, INP) to monitor site performance

{/* Data Retention */}

Data Retention

{!!subscriptionError && (

Plan limits could not be loaded. Options shown may be limited.

)}

Keep raw event data for

Events older than this are automatically deleted. Aggregated daily stats are kept permanently.