New unified settings modal accessible via `,` keyboard shortcut. Three-context switcher: Site (with site dropdown), Workspace, Account. Horizontal tabs per context with animated transitions. Phase 1 tabs implemented: - Site → General (name, timezone, domain, tracking script with copy) - Site → Goals (CRUD with inline create/edit) - Workspace → General (org name, slug, danger zone) - Workspace → Billing (plan card, usage, cancel/resume, portal) - Account → Profile (wraps existing ProfileSettings) Phase 2 tabs show "Coming soon" placeholder: - Site: Visibility, Privacy, Bot & Spam, Reports, Integrations - Workspace: Members, Notifications, Audit Log - Account: Security, Devices Old settings pages and profile modal remain functional.
171 lines
5.8 KiB
TypeScript
171 lines
5.8 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import { Input, Button, toast } from '@ciphera-net/ui'
|
|
import { Plus, Pencil, Trash, X } from '@phosphor-icons/react'
|
|
import { Spinner } from '@ciphera-net/ui'
|
|
import { useGoals } from '@/lib/swr/dashboard'
|
|
import { createGoal, updateGoal, deleteGoal } from '@/lib/api/goals'
|
|
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
|
|
|
export default function SiteGoalsTab({ siteId }: { siteId: string }) {
|
|
const { data: goals = [], mutate, isLoading } = useGoals(siteId)
|
|
const [editing, setEditing] = useState<string | null>(null)
|
|
const [creating, setCreating] = useState(false)
|
|
const [name, setName] = useState('')
|
|
const [eventName, setEventName] = useState('')
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
const startCreate = () => {
|
|
setCreating(true)
|
|
setEditing(null)
|
|
setName('')
|
|
setEventName('')
|
|
}
|
|
|
|
const startEdit = (goal: { id: string; name: string; event_name: string }) => {
|
|
setEditing(goal.id)
|
|
setCreating(false)
|
|
setName(goal.name)
|
|
setEventName(goal.event_name)
|
|
}
|
|
|
|
const cancel = () => {
|
|
setCreating(false)
|
|
setEditing(null)
|
|
setName('')
|
|
setEventName('')
|
|
}
|
|
|
|
const handleSave = async () => {
|
|
if (!name.trim() || !eventName.trim()) {
|
|
toast.error('Name and event name are required')
|
|
return
|
|
}
|
|
if (!/^[a-zA-Z0-9_]+$/.test(eventName)) {
|
|
toast.error('Event name can only contain letters, numbers, and underscores')
|
|
return
|
|
}
|
|
|
|
setSaving(true)
|
|
try {
|
|
if (editing) {
|
|
await updateGoal(siteId, editing, { name, event_name: eventName })
|
|
toast.success('Goal updated')
|
|
} else {
|
|
await createGoal(siteId, { name, event_name: eventName })
|
|
toast.success('Goal created')
|
|
}
|
|
await mutate()
|
|
cancel()
|
|
} catch (err) {
|
|
toast.error(getAuthErrorMessage(err as Error) || 'Failed to save goal')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleDelete = async (goalId: string) => {
|
|
try {
|
|
await deleteGoal(siteId, goalId)
|
|
toast.success('Goal deleted')
|
|
await mutate()
|
|
} catch (err) {
|
|
toast.error(getAuthErrorMessage(err as Error) || 'Failed to delete goal')
|
|
}
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Spinner className="w-6 h-6 text-neutral-500" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-base font-semibold text-white mb-1">Goals</h3>
|
|
<p className="text-sm text-neutral-400">Track custom events as conversion goals.</p>
|
|
</div>
|
|
{!creating && !editing && (
|
|
<Button onClick={startCreate} variant="primary" className="text-sm gap-1.5">
|
|
<Plus weight="bold" className="w-3.5 h-3.5" /> Add Goal
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Create/Edit form */}
|
|
{(creating || editing) && (
|
|
<div className="rounded-xl border border-neutral-800 bg-neutral-800/30 p-4 space-y-3">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-xs font-medium text-neutral-400 mb-1">Display Name</label>
|
|
<Input
|
|
value={name}
|
|
onChange={e => setName(e.target.value)}
|
|
placeholder="e.g. Sign Up"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-neutral-400 mb-1">Event Name</label>
|
|
<Input
|
|
value={eventName}
|
|
onChange={e => setEventName(e.target.value)}
|
|
placeholder="e.g. signup_click"
|
|
disabled={!!editing}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 justify-end">
|
|
<Button onClick={cancel} variant="secondary" className="text-sm">Cancel</Button>
|
|
<Button onClick={handleSave} variant="primary" className="text-sm" disabled={saving}>
|
|
{saving ? 'Saving...' : editing ? 'Update' : 'Create'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Goals list */}
|
|
{goals.length === 0 && !creating ? (
|
|
<div className="text-center py-10">
|
|
<p className="text-sm text-neutral-500 mb-3">No goals yet. Add a goal to track custom events.</p>
|
|
<Button onClick={startCreate} variant="primary" className="text-sm gap-1.5">
|
|
<Plus weight="bold" className="w-3.5 h-3.5" /> Add your first goal
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1">
|
|
{goals.map(goal => (
|
|
<div
|
|
key={goal.id}
|
|
className="flex items-center justify-between px-4 py-3 rounded-xl hover:bg-neutral-800/40 transition-colors group"
|
|
>
|
|
<div>
|
|
<p className="text-sm font-medium text-white">{goal.name}</p>
|
|
<p className="text-xs text-neutral-500 font-mono">{goal.event_name}</p>
|
|
</div>
|
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<button
|
|
onClick={() => startEdit(goal)}
|
|
className="p-1.5 rounded-lg text-neutral-500 hover:text-white hover:bg-neutral-800 transition-colors"
|
|
>
|
|
<Pencil weight="bold" className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(goal.id)}
|
|
className="p-1.5 rounded-lg text-neutral-500 hover:text-red-400 hover:bg-red-900/20 transition-colors"
|
|
>
|
|
<Trash weight="bold" className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|