import {useEffect, useState} from 'react'; import {useForm} from 'react-hook-form'; import {zodResolver} from '@hookform/resolvers/zod'; import {ProjectSchemas, SUPPORTED_LANGUAGES} from '@plunk/shared'; import {TrackingMode} from '@plunk/db'; import { Alert, AlertDescription, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, Input, Select, SelectContent, SelectItem, SelectItemWithDescription, SelectTrigger, SelectValue, Tabs, TabsContent, TabsList, TabsTrigger, } from '@plunk/ui'; import {AnimatePresence, motion} from 'framer-motion'; import {NextSeo} from 'next-seo'; import {AlertTriangle, CreditCard, Database, Globe, Mail, Settings as SettingsIcon, Shield, Users} from 'lucide-react'; import type {z} from 'zod'; import {useRouter} from 'next/router'; import {DashboardLayout} from '../../components/DashboardLayout'; import {DomainsSettings} from '../../components/DomainsSettings'; import {BillingLimits} from '../../components/BillingLimits'; import {BillingConsumption} from '../../components/BillingConsumption'; import {BillingInvoices} from '../../components/BillingInvoices'; import {UnpaidInvoiceBanner} from '../../components/UnpaidInvoiceBanner'; import {ApiKeyDisplay} from '../../components/ApiKeyDisplay'; import {SmtpSettings} from '../../components/SmtpSettings'; import {DataManagementSettings} from '../../components/DataManagementSettings'; import {TeamSettings} from '../../components/TeamSettings'; import {SecuritySettings} from '../../components/SecuritySettings'; import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider'; import {network} from '../../lib/network'; import {useProjects} from '../../lib/hooks/useProject'; import {useConfig} from '../../lib/hooks/useConfig'; import {useUser} from '../../lib/hooks/useUser'; import {useProjectSecurity} from '../../lib/hooks/useProjectSecurity'; import useSWR from 'swr'; type TabId = 'general' | 'billing' | 'domains' | 'smtp' | 'data' | 'team' | 'security'; interface Tab { id: TabId; label: string; icon: typeof SettingsIcon; condition?: boolean; } const buildTabs = (options: {billingEnabled: boolean; smtpEnabled: boolean}): Tab[] => { const {billingEnabled, smtpEnabled} = options; const allTabs: Tab[] = [ {id: 'general', label: 'General', icon: SettingsIcon}, {id: 'team', label: 'Team', icon: Users}, {id: 'security', label: 'Security', icon: Shield}, {id: 'billing', label: 'Billing', icon: CreditCard, condition: billingEnabled}, {id: 'domains', label: 'Domains', icon: Globe}, {id: 'smtp', label: 'SMTP', icon: Mail, condition: smtpEnabled}, {id: 'data', label: 'Data', icon: Database}, ]; return allTabs.filter(tab => tab.condition !== false); }; export default function Settings() { const router = useRouter(); const {activeProject, setActiveProject, updateActiveProject} = useActiveProject(); const {mutate: projectsMutate} = useProjects(); const {data: config} = useConfig(); const {data: user} = useUser(); const [successMessage, setSuccessMessage] = useState(null); const [errorMessage, setErrorMessage] = useState(null); const [showRegenerateDialog, setShowRegenerateDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showResetDialog, setShowResetDialog] = useState(false); const [deleteConfirmText, setDeleteConfirmText] = useState(''); const [resetConfirmText, setResetConfirmText] = useState(''); const [isLoadingBilling, setIsLoadingBilling] = useState(false); const [selectedCurrency, setSelectedCurrency] = useState('auto'); const [showCurrencySelector, setShowCurrencySelector] = useState(false); // Fetch current user's membership for the active project const {data: membershipData} = useSWR<{ success: boolean; data: Array<{userId: string; email: string; role: 'OWNER' | 'ADMIN' | 'MEMBER'}>; }>(activeProject?.id ? `/projects/${activeProject.id}/members` : null, {revalidateOnFocus: false}); const currentUserMembership = membershipData?.data.find(m => m.userId === user?.id); const currentUserRole = currentUserMembership?.role || 'MEMBER'; const {securityMetrics, isLoading: isLoadingSecurityMetrics} = useProjectSecurity(activeProject?.id); const billingEnabled = config?.features.billing.enabled ?? false; const smtpEnabled = config?.features.smtp.enabled ?? false; const trackingToggleEnabled = config?.features.email.trackingToggleEnabled ?? false; const smtpConfig = smtpEnabled ? { enabled: true as const, domain: config?.features.smtp.domain ?? undefined, portSecure: config?.features.smtp.ports?.secure, portSubmission: config?.features.smtp.ports?.submission, } : {enabled: false as const}; // Get current tab from URL or default to 'general' const currentTab = (router.query.tab as TabId) || 'general'; // Set default tab in URL if none is present useEffect(() => { if (!router.query.tab && router.isReady) { router.replace('/settings?tab=general', undefined, {shallow: true}); } }, [router]); // Handler to change tabs and update URL const handleTabChange = (newTab: string) => { router.push(`/settings?tab=${newTab}`, undefined, {shallow: true}); }; // Handle Stripe redirect success/cancel messages useEffect(() => { if (!router.isReady) return; if (router.query.success === 'true') { // Use setTimeout to defer state update, avoiding synchronous setState in effect const timer = setTimeout(() => { setSuccessMessage('Subscription activated successfully! It may take a moment to update.'); // Clear message and URL after 5 seconds setTimeout(() => { setSuccessMessage(null); router.replace('/settings?tab=billing', undefined, {shallow: true}); }, 5000); }, 0); return () => clearTimeout(timer); } else if (router.query.canceled === 'true') { // Use setTimeout to defer state update, avoiding synchronous setState in effect const timer = setTimeout(() => { setErrorMessage('Checkout was canceled. You can try again anytime.'); // Clear message and URL after 5 seconds setTimeout(() => { setErrorMessage(null); router.replace('/settings?tab=billing', undefined, {shallow: true}); }, 5000); }, 0); return () => clearTimeout(timer); } }, [router]); const form = useForm>({ resolver: zodResolver(ProjectSchemas.update), defaultValues: { name: activeProject?.name || '', tracking: activeProject?.tracking ?? TrackingMode.ENABLED, language: activeProject?.language || 'en', }, }); // Update form when active project changes useEffect(() => { if (activeProject) { form.reset({ name: activeProject.name, tracking: activeProject.tracking ?? TrackingMode.ENABLED, language: activeProject.language || 'en', }); } }, [activeProject, form]); const onSubmit = async (values: z.infer) => { if (!activeProject) return; try { setErrorMessage(null); setSuccessMessage(null); const updatedProject = await network.fetch( 'PATCH', `/users/@me/projects/${activeProject.id}`, values, ); // Update the active project in context without invalidating the full SWR cache updateActiveProject(updatedProject); // Refresh projects list await projectsMutate(); setSuccessMessage('Project settings updated successfully'); // Clear success message after 3 seconds setTimeout(() => setSuccessMessage(null), 3000); } catch (error) { setErrorMessage(error instanceof Error ? error.message : 'Failed to update project settings'); } }; const handleRegenerateKeys = async () => { if (!activeProject) return; try { setErrorMessage(null); setSuccessMessage(null); const updatedProject = await network.fetch( 'POST', `/users/@me/projects/${activeProject.id}/regenerate-keys`, ); // Update the active project in context setActiveProject(updatedProject); // Refresh projects list await projectsMutate(); setSuccessMessage('API keys regenerated successfully'); setShowRegenerateDialog(false); // Clear success message after 3 seconds setTimeout(() => setSuccessMessage(null), 3000); } catch (error) { setErrorMessage(error instanceof Error ? error.message : 'Failed to regenerate API keys'); setShowRegenerateDialog(false); } }; const promptRegenerateKeys = () => { setShowRegenerateDialog(true); }; const handleStartSubscription = async (currency: string = 'auto') => { if (!activeProject) return; if (!billingEnabled) { setErrorMessage('Billing is disabled on this instance.'); return; } try { setIsLoadingBilling(true); setErrorMessage(null); // Build URL with optional currency parameter const url = currency === 'auto' ? `/users/@me/projects/${activeProject.id}/checkout` : `/users/@me/projects/${activeProject.id}/checkout?currency=${currency}`; const response = await network.fetch<{url: string}>('POST', url); // Redirect to Stripe checkout if (response.url) { window.location.href = response.url; } } catch (error) { setErrorMessage(error instanceof Error ? error.message : 'Failed to start checkout'); setIsLoadingBilling(false); } }; const handleManageBilling = async () => { if (!activeProject) return; if (!billingEnabled) { setErrorMessage('Billing is disabled on this instance.'); return; } try { setIsLoadingBilling(true); setErrorMessage(null); const response = await network.fetch<{url: string}>( 'POST', `/users/@me/projects/${activeProject.id}/billing-portal`, ); // Redirect to Stripe billing portal if (response.url) { window.location.href = response.url; } } catch (error) { setErrorMessage(error instanceof Error ? error.message : 'Failed to open billing portal'); setIsLoadingBilling(false); } }; const handleResetProject = async () => { if (!activeProject || resetConfirmText !== 'RESET') return; try { setErrorMessage(null); setSuccessMessage(null); await network.fetch('POST', `/users/@me/projects/${activeProject.id}/reset`); setSuccessMessage('Project reset successfully. All data has been cleared.'); setShowResetDialog(false); setResetConfirmText(''); // Refresh the page to reload data setTimeout(() => { window.location.reload(); }, 1500); } catch (error) { setErrorMessage(error instanceof Error ? error.message : 'Failed to reset project'); setShowResetDialog(false); setResetConfirmText(''); } }; const handleDeleteProject = async () => { if (!activeProject || deleteConfirmText !== 'DELETE') return; try { setErrorMessage(null); setSuccessMessage(null); await network.fetch('DELETE', `/users/@me/projects/${activeProject.id}`); setSuccessMessage('Project deleted successfully. Redirecting...'); setShowDeleteDialog(false); setDeleteConfirmText(''); // Refresh projects list and redirect to dashboard await projectsMutate(); // Redirect to dashboard after a short delay setTimeout(() => { window.location.href = '/'; }, 1500); } catch (error) { setErrorMessage(error instanceof Error ? error.message : 'Failed to delete project'); setShowDeleteDialog(false); setDeleteConfirmText(''); } }; if (!activeProject) { return ( <>

No project selected

); } return ( <>
{/* Header */}

Settings

Manage your project settings and preferences

{/* Tabs */} {buildTabs({billingEnabled, smtpEnabled}).map(tab => { const Icon = tab.icon; return ( {tab.label} ); })} {/* General Tab */}
Project Settings Update your project name and basic information
( Project Name )} /> {/* Email Tracking Mode - only show if feature is available */} {trackingToggleEnabled && ( ( Email Tracking Control how email opens and clicks are tracked for this project. )} /> )} {/* Language Selection */} ( Customer Language Language for customer-facing pages (unsubscribe, preferences) and email footers. )} /> {/* Success/Error Messages */} {successMessage && ( {successMessage} )} {errorMessage && ( {errorMessage} )}
{/* API Keys - Separate Card */}
API Credentials Use these keys to integrate with the Plunk API
{/* Danger Zone - Separate Card */}
Danger Zone Irreversible actions that affect your project data
{/* Reset Project */}

Reset Project Data

Clear all campaigns, contacts, workflows, templates, and events. This gives you a blank project to start fresh.

Preserved: API keys, domains, billing information
{/* Delete Project */}

Delete Project Permanently

Permanently delete this project and all associated data. This action{' '} cannot be undone. {activeProject?.subscription && ' Your subscription will be canceled.'}

Permanent Deletion All data will be lost
{/* Billing Tab */}
{/* Unpaid Invoice Banner */} Billing & Subscription Manage your subscription and billing information
{/* Success/Error Messages */} {successMessage && ( {successMessage} )} {errorMessage && ( {errorMessage} )} {activeProject.subscription ? ( // Has subscription - show billing portal button
Active Subscription

Your subscription is active. Manage your billing details, update payment methods, or cancel your subscription through the billing portal.

) : ( // No subscription - show start subscription button
No Active Subscription

Start a subscription and support the development of Plunk. You will be charged a one-time onboarding fee which will be credited to your first invoice.

{showCurrencySelector && (
)}
)}
{/* Billing Limits */} {/* Current Month Consumption */} {/* Past Invoices */}
{/* Team Tab */} {/* Security Tab */} {securityMetrics ? ( ) : ( Security Overview

{isLoadingSecurityMetrics ? 'Loading...' : 'Unable to load security metrics'}

)}
{/* Domains Tab */} {/* SMTP Tab */} {/* Data Management Tab */}
{/* Regenerate Keys Confirmation Dialog */} Regenerate API Keys

Are you sure you want to regenerate your API keys?

Current keys will be immediately invalidated. Any integrations using the old keys will stop working until updated.
{/* Reset Project Confirmation Dialog */}
Reset Project Data? All your campaigns, contacts, workflows, and templates will be permanently deleted. Your API keys and settings will remain intact.
setResetConfirmText(e.target.value)} placeholder="Type RESET here" className="text-center" autoFocus />
{/* Delete Project Confirmation Dialog */}
Delete Project Permanently?

This will permanently delete your entire project and all data. This action cannot be undone.

{activeProject?.subscription && (

Your active subscription will be canceled

)}
setDeleteConfirmText(e.target.value)} placeholder="Type DELETE here" className="text-center" autoFocus />
); }