'use client' import { useEffect, useState } from 'react' import { useParams, useRouter } from 'next/navigation' import { getSite, updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } from '@/lib/api/sites' import { listGoals, createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals' import { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui' import { LoadingOverlay } from '@ciphera-net/ui' 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 { getSubscription, type SubscriptionDetails } from '@/lib/api/billing' 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' 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 [site, setSite] = useState(null) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data' | 'goals'>('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, // Data retention (6 = free-tier max; safe default) data_retention_months: 6 }) const [subscription, setSubscription] = useState(null) const [subscriptionLoadFailed, setSubscriptionLoadFailed] = useState(false) const [linkCopied, setLinkCopied] = useState(false) const [snippetCopied, setSnippetCopied] = useState(false) const [showVerificationModal, setShowVerificationModal] = useState(false) const [isPasswordEnabled, setIsPasswordEnabled] = useState(false) const [goals, setGoals] = useState([]) const [goalsLoading, setGoalsLoading] = useState(false) const [goalModalOpen, setGoalModalOpen] = useState(false) const [editingGoal, setEditingGoal] = useState(null) const [goalForm, setGoalForm] = useState({ name: '', event_name: '' }) const [goalSaving, setGoalSaving] = useState(false) useEffect(() => { loadSite() loadSubscription() }, [siteId]) useEffect(() => { if (activeTab === 'goals' && siteId) { loadGoals() } }, [activeTab, siteId]) const loadSubscription = async () => { try { setSubscriptionLoadFailed(false) const sub = await getSubscription() setSubscription(sub) } catch (e) { setSubscriptionLoadFailed(true) toast.error(getAuthErrorMessage(e as Error) || 'Could not load plan limits. Showing default options.') } } // * 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 loadSite = async () => { try { setLoading(true) const data = await getSite(siteId) setSite(data) setFormData({ name: data.name, timezone: data.timezone || 'UTC', is_public: data.is_public || false, password: '', // Don't show existing password excluded_paths: (data.excluded_paths || []).join('\n'), // Data collection settings (default to true/full for backwards compatibility) collect_page_paths: data.collect_page_paths ?? true, collect_referrers: data.collect_referrers ?? true, collect_device_info: data.collect_device_info ?? true, collect_geo_data: data.collect_geo_data || 'full', collect_screen_resolution: data.collect_screen_resolution ?? true, // Performance insights setting (default to false) enable_performance_insights: data.enable_performance_insights ?? false, // Bot and noise filtering (default to true) filter_bots: data.filter_bots ?? true, // Data retention (default 6 = free-tier max; avoids flash-then-clamp for existing sites) data_retention_months: data.data_retention_months ?? 6 }) if (data.has_password) { setIsPasswordEnabled(true) } else { setIsPasswordEnabled(false) } } catch (error: any) { toast.error(getAuthErrorMessage(error) || 'Failed to load site: ' + ((error as Error)?.message || 'Unknown error')) } finally { setLoading(false) } } const loadGoals = async () => { try { setGoalsLoading(true) const data = await listGoals(siteId) setGoals(data ?? []) } catch (e) { toast.error(getAuthErrorMessage(e as Error) || 'Failed to load goals') } finally { setGoalsLoading(false) } } const 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) loadGoals() } 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') loadGoals() } 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, // Data retention data_retention_months: formData.data_retention_months }) toast.success('Site updated successfully') loadSite() } catch (error: any) { toast.error(getAuthErrorMessage(error) || 'Failed to update site: ' + ((error as Error)?.message || 'Unknown error')) } 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: any) { toast.error(getAuthErrorMessage(error) || 'Failed to reset data: ' + ((error as Error)?.message || 'Unknown error')) } } 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: any) { toast.error(getAuthErrorMessage(error) || 'Failed to delete site: ' + ((error as Error)?.message || 'Unknown error')) } } 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) } if (loading) { 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" />

Domain cannot be changed after creation

Tracking Script

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

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

{/* Performance Insights Toggle */}

Performance Insights

Performance Insights (Add-on)

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

{/* Data Retention */}

Data Retention

{subscriptionLoadFailed && (

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.