diff --git a/app/page.tsx b/app/page.tsx index 4501e98..b4ac71d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -5,12 +5,13 @@ import Link from 'next/link' import { motion } from 'framer-motion' import { useAuth } from '@/lib/auth/context' import { initiateOAuthFlow, initiateSignupFlow } from '@/lib/api/oauth' -import { listSites, deleteSite, type Site } from '@/lib/api/sites' +import { listSites, listDeletedSites, restoreSite, type Site } from '@/lib/api/sites' import { getStats } from '@/lib/api/stats' import type { Stats } from '@/lib/api/stats' import { getSubscription, type SubscriptionDetails } from '@/lib/api/billing' import { LoadingOverlay } from '@ciphera-net/ui' import SiteList from '@/components/sites/SiteList' +import DeleteSiteModal from '@/components/sites/DeleteSiteModal' import { Button } from '@ciphera-net/ui' import Image from 'next/image' import { BarChartIcon, LockIcon, ZapIcon, CheckCircleIcon, XIcon, GlobeIcon } from '@ciphera-net/ui' @@ -118,6 +119,9 @@ export default function HomePage() { const [subscription, setSubscription] = useState(null) const [subscriptionLoading, setSubscriptionLoading] = useState(false) const [showFinishSetupBanner, setShowFinishSetupBanner] = useState(true) + const [deleteModalSite, setDeleteModalSite] = useState(null) + const [deletedSites, setDeletedSites] = useState([]) + const [permanentDeleteSiteModal, setPermanentDeleteSiteModal] = useState(null) useEffect(() => { if (user?.org_id) { @@ -178,6 +182,12 @@ export default function HomePage() { setSitesLoading(true) const data = await listSites() setSites(Array.isArray(data) ? data : []) + try { + const deleted = await listDeletedSites() + setDeletedSites(deleted) + } catch { + setDeletedSites([]) + } } catch (error: unknown) { toast.error(getAuthErrorMessage(error) || 'Failed to load your sites') setSites([]) @@ -198,20 +208,26 @@ export default function HomePage() { } } - const handleDelete = async (id: string) => { - if (!confirm('Are you sure you want to delete this site? This action cannot be undone.')) { - return - } + const handleDelete = (id: string) => { + const site = sites.find((s) => s.id === id) + if (site) setDeleteModalSite(site) + } + const handleRestore = async (id: string) => { try { - await deleteSite(id) - toast.success('Site deleted successfully') + await restoreSite(id) + toast.success('Site restored successfully') loadSites() } catch (error: unknown) { - toast.error(getAuthErrorMessage(error) || 'Failed to delete site') + toast.error(getAuthErrorMessage(error) || 'Failed to restore site') } } + const handlePermanentDelete = (id: string) => { + const site = deletedSites.find((s) => s.id === id) + if (site) setPermanentDeleteSiteModal(site) + } + if (authLoading) { return } @@ -387,15 +403,22 @@ export default function HomePage() { const siteLimit = getSitesLimitForPlan(subscription?.plan_id) const atLimit = siteLimit != null && sites.length >= siteLimit return atLimit ? ( -
- - Limit reached ({sites.length}/{siteLimit}) - - - - +
+
+ + Limit reached ({sites.length}/{siteLimit}) + + + + +
+ {deletedSites.length > 0 && ( +

+ You have a site pending deletion. Restore it or permanently delete it to free the slot. +

+ )}
) : null })() ?? ( @@ -512,6 +535,63 @@ export default function HomePage() { {(sitesLoading || sites.length > 0) && ( )} + + setDeleteModalSite(null)} + onDeleted={loadSites} + siteName={deleteModalSite?.name || ''} + siteDomain={deleteModalSite?.domain || ''} + siteId={deleteModalSite?.id || ''} + /> + + setPermanentDeleteSiteModal(null)} + onDeleted={loadSites} + siteName={permanentDeleteSiteModal?.name || ''} + siteDomain={permanentDeleteSiteModal?.domain || ''} + siteId={permanentDeleteSiteModal?.id || ''} + permanentOnly + /> + + {deletedSites.length > 0 && ( +
+

Scheduled for Deletion

+
+ {deletedSites.map((site) => { + const purgeAt = site.deleted_at ? new Date(new Date(site.deleted_at).getTime() + 7 * 24 * 60 * 60 * 1000) : null + const daysLeft = purgeAt ? Math.max(0, Math.ceil((purgeAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24))) : 0 + + return ( +
+
+ {site.name} + {site.domain} + + Deleting in {daysLeft} day{daysLeft !== 1 ? 's' : ''} + +
+
+ + +
+
+ ) + })} +
+
+ )}
) } diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx index b5dc03c..f3d5f85 100644 --- a/app/sites/[id]/settings/page.tsx +++ b/app/sites/[id]/settings/page.tsx @@ -2,7 +2,7 @@ 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 { updateSite, resetSiteData, 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' @@ -13,6 +13,7 @@ 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 DeleteSiteModal from '@/components/sites/DeleteSiteModal' import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock' import { PasswordInput } from '@ciphera-net/ui' import { Select, Modal, Button } from '@ciphera-net/ui' @@ -59,6 +60,7 @@ export default function SiteSettingsPage() { const { data: site, isLoading: siteLoading, mutate: mutateSite } = useSite(siteId) const [saving, setSaving] = useState(false) + const [showDeleteModal, setShowDeleteModal] = useState(false) const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data' | 'goals' | 'reports' | 'integrations'>('general') const searchParams = useSearchParams() @@ -462,20 +464,8 @@ export default function SiteSettingsPage() { } } - 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 handleDeleteSite = () => { + setShowDeleteModal(true) } const copyLink = () => { @@ -804,13 +794,13 @@ export default function SiteSettingsPage() {

Delete Site

-

Permanently delete this site and all data.

+

Schedule this site for deletion with a 7-day grace period.

@@ -2038,6 +2028,15 @@ export default function SiteSettingsPage() { site={site} onVerified={() => mutateSite()} /> + + setShowDeleteModal(false)} + onDeleted={() => router.push('/')} + siteName={site?.name || ''} + siteDomain={site?.domain || ''} + siteId={siteId} + /> ) } diff --git a/components/sites/DeleteSiteModal.tsx b/components/sites/DeleteSiteModal.tsx new file mode 100644 index 0000000..b8f7bb7 --- /dev/null +++ b/components/sites/DeleteSiteModal.tsx @@ -0,0 +1,228 @@ +'use client' + +import { useState, useEffect } from 'react' +import { createPortal } from 'react-dom' +import { motion, AnimatePresence } from 'framer-motion' +import { toast, getAuthErrorMessage, AlertTriangleIcon, XIcon } from '@ciphera-net/ui' +import { deleteSite, permanentDeleteSite } from '@/lib/api/sites' + +interface DeleteSiteModalProps { + open: boolean + onClose: () => void + onDeleted: () => void + siteName: string + siteDomain: string + siteId: string + permanentOnly?: boolean +} + +export default function DeleteSiteModal({ open, onClose, onDeleted, siteName, siteDomain, siteId, permanentOnly }: DeleteSiteModalProps) { + const [deleteConfirm, setDeleteConfirm] = useState('') + const [isDeleting, setIsDeleting] = useState(false) + const [showPermanent, setShowPermanent] = useState(!!permanentOnly) + const [permanentConfirm, setPermanentConfirm] = useState('') + const [isPermanentDeleting, setIsPermanentDeleting] = useState(false) + + useEffect(() => { + if (open && permanentOnly) { + setShowPermanent(true) + } + }, [open, permanentOnly]) + + const handleClose = () => { + setDeleteConfirm('') + setShowPermanent(false) + setPermanentConfirm('') + setIsDeleting(false) + setIsPermanentDeleting(false) + onClose() + } + + const handleSoftDelete = async () => { + if (deleteConfirm !== 'DELETE') return + setIsDeleting(true) + try { + await deleteSite(siteId) + toast.success('Site scheduled for deletion. You have 7 days to restore it.') + handleClose() + onDeleted() + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to delete site') + setIsDeleting(false) + } + } + + const handlePermanentDelete = async () => { + if (permanentConfirm !== siteDomain) return + setIsPermanentDeleting(true) + try { + await permanentDeleteSite(siteId) + toast.success('Site permanently deleted') + handleClose() + onDeleted() + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to permanently delete site') + setIsPermanentDeleting(false) + } + } + + if (typeof document === 'undefined') return null + + return createPortal( + + {open && ( + + +
+

Delete {siteName || 'Site'}?

+ +
+ + {!showPermanent ? ( + <> +

+ This site will be scheduled for deletion with a 7-day grace period. You can restore it at any time during this period. +

+ +
+
+ + + All events and analytics data + +
+
+ + + Report schedules and goals + +
+
+ +
+
+ + setDeleteConfirm(e.target.value)} + autoComplete="off" + className="w-full px-3 py-2 text-sm border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-red-500 dark:focus:ring-red-400" + placeholder="DELETE" + /> +
+ +
+ + +
+ + +
+ + ) : ( + <> +

+ This action is irreversible. The site and all its data will be permanently deleted immediately. +

+ +
+
+ + + All analytics data will be permanently lost + +
+
+ + + This cannot be undone + +
+
+ +
+
+ + setPermanentConfirm(e.target.value)} + autoComplete="off" + className="w-full px-3 py-2 text-sm border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-red-500 dark:focus:ring-red-400" + placeholder={siteDomain} + /> +
+ +
+ + +
+
+ + )} +
+
+ )} +
, + document.body + ) +} diff --git a/lib/api/sites.ts b/lib/api/sites.ts index 60d2f4b..7093b57 100644 --- a/lib/api/sites.ts +++ b/lib/api/sites.ts @@ -26,6 +26,7 @@ export interface Site { is_verified?: boolean created_at: string updated_at: string + deleted_at?: string | null } export interface CreateSiteRequest { @@ -77,8 +78,8 @@ export async function updateSite(id: string, data: UpdateSiteRequest): Promise { - await apiRequest(`/sites/${id}`, { +export async function deleteSite(id: string): Promise<{ message: string; purge_at: string }> { + return apiRequest<{ message: string; purge_at: string }>(`/sites/${id}`, { method: 'DELETE', }) } @@ -94,3 +95,20 @@ export async function verifySite(id: string): Promise { method: 'POST', }) } + +export async function restoreSite(id: string): Promise { + await apiRequest(`/sites/${id}/restore`, { + method: 'POST', + }) +} + +export async function permanentDeleteSite(id: string): Promise { + await apiRequest(`/sites/${id}/permanent`, { + method: 'DELETE', + }) +} + +export async function listDeletedSites(): Promise { + const response = await apiRequest<{ sites: Site[] }>('/sites/deleted') + return response?.sites || [] +}