refactor: convert forwardRef components to function components for consistency
This commit is contained in:
@@ -241,7 +241,7 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
|
||||
<div className="space-y-4">
|
||||
{upcomingActivities.map((activity, index) => (
|
||||
<div key={`${activity.id}-${index}`}>
|
||||
<ActivityItem activity={activity} isUpcoming={true} />
|
||||
<ActivityItem activity={activity} status="upcoming" />
|
||||
{index < upcomingActivities.length - 1 && <div className="border-t border-neutral-100 my-4" />}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -110,7 +110,7 @@ function isEmailActivity(type: string): boolean {
|
||||
|
||||
interface ActivityItemProps {
|
||||
activity: Activity;
|
||||
isUpcoming?: boolean;
|
||||
status?: 'upcoming' | 'completed';
|
||||
}
|
||||
|
||||
interface ActivityConfig {
|
||||
@@ -342,11 +342,12 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
}
|
||||
}
|
||||
|
||||
export const ActivityItem = memo(function ActivityItem({activity, isUpcoming = false}: ActivityItemProps) {
|
||||
export const ActivityItem = memo(function ActivityItem({activity, status = 'completed'}: ActivityItemProps) {
|
||||
const [showPreviewModal, setShowPreviewModal] = useState(false);
|
||||
const config = getActivityConfig(activity);
|
||||
const Icon = config.icon;
|
||||
const timestamp = new Date(activity.timestamp);
|
||||
const isUpcoming = status === 'upcoming';
|
||||
const relativeTime = isUpcoming ? getUpcomingTime(timestamp) : getRelativeTime(timestamp);
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,7 +9,6 @@ interface ApiKeyDisplayProps {
|
||||
description?: string;
|
||||
isSecret?: boolean;
|
||||
onRegenerate?: () => Promise<void>;
|
||||
showRegenerate?: boolean;
|
||||
}
|
||||
|
||||
export function ApiKeyDisplay({
|
||||
@@ -18,7 +17,6 @@ export function ApiKeyDisplay({
|
||||
description,
|
||||
isSecret = false,
|
||||
onRegenerate,
|
||||
showRegenerate = false,
|
||||
}: ApiKeyDisplayProps) {
|
||||
const [showKey, setShowKey] = useState(!isSecret);
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -101,7 +99,7 @@ export function ApiKeyDisplay({
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Button>
|
||||
{showRegenerate && onRegenerate && (
|
||||
{onRegenerate && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -59,13 +59,13 @@ const formatEmailCost = (emailCount: number, currency: string | null): string =>
|
||||
|
||||
interface BillingLimitsProps {
|
||||
projectId: string;
|
||||
hasSubscription: boolean;
|
||||
tier: 'free' | 'paid';
|
||||
billingEnabled: boolean;
|
||||
}
|
||||
|
||||
type LimitsFormValues = z.infer<typeof BillingLimitSchemas.update>;
|
||||
|
||||
export function BillingLimits({projectId, hasSubscription, billingEnabled}: BillingLimitsProps) {
|
||||
export function BillingLimits({projectId, tier, billingEnabled}: BillingLimitsProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
@@ -132,7 +132,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
||||
};
|
||||
|
||||
// Free tier projects can view their usage but can't edit limits
|
||||
const canEditLimits = hasSubscription;
|
||||
const canEditLimits = tier === 'paid';
|
||||
|
||||
// If billing is not enabled, don't show the component
|
||||
if (!billingEnabled) {
|
||||
@@ -160,7 +160,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
||||
<CardHeader>
|
||||
<CardTitle>Billing Limits</CardTitle>
|
||||
<CardDescription>
|
||||
{hasSubscription
|
||||
{tier === 'paid'
|
||||
? 'Set monthly limits for each email category. Limits reset on the 1st of each month.'
|
||||
: 'Free tier projects have a total limit of 1,000 emails per month across all categories.'}
|
||||
</CardDescription>
|
||||
@@ -168,7 +168,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Free tier info banner */}
|
||||
{!hasSubscription && limitsData && (
|
||||
{tier !== 'paid' && limitsData && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<div className="ml-2">
|
||||
@@ -208,7 +208,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
||||
{!isEditing && limitsData && (
|
||||
<div className="space-y-4">
|
||||
{/* For free tier, show total usage across all categories */}
|
||||
{!hasSubscription ? (
|
||||
{tier !== 'paid' ? (
|
||||
<UsageDisplay
|
||||
category="Total Emails (All Categories)"
|
||||
usage={limitsData.workflows}
|
||||
|
||||
@@ -76,7 +76,6 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
|
||||
// Fetch contacts for preview using SWR
|
||||
const {contacts} = useContacts({limit: 50});
|
||||
|
||||
// Update available variables when fields change
|
||||
useEffect(() => {
|
||||
if (availableFields.length > 0) {
|
||||
setAvailableVariables(availableFields);
|
||||
|
||||
@@ -11,14 +11,9 @@ interface EmailSettingsProps {
|
||||
fromPlaceholder?: string;
|
||||
fromNamePlaceholder?: string;
|
||||
replyToPlaceholder?: string;
|
||||
showFromNameHelpText?: boolean;
|
||||
layout?: 'vertical' | 'grid';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable email settings component for from, fromName, and replyTo fields
|
||||
* Used in campaign and template forms
|
||||
*/
|
||||
export function EmailSettings({
|
||||
from,
|
||||
fromName,
|
||||
@@ -29,7 +24,6 @@ export function EmailSettings({
|
||||
fromPlaceholder = 'hello',
|
||||
fromNamePlaceholder = 'Your Company',
|
||||
replyToPlaceholder,
|
||||
showFromNameHelpText = false,
|
||||
layout = 'grid',
|
||||
}: EmailSettingsProps) {
|
||||
// Use from email's local part as the reply-to placeholder if not provided
|
||||
@@ -63,11 +57,9 @@ export function EmailSettings({
|
||||
onChange={e => onFromNameChange(e.target.value)}
|
||||
placeholder={fromNamePlaceholder}
|
||||
/>
|
||||
{showFromNameHelpText && (
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
The sender name that appears in the recipient's inbox. Defaults to your project name if not set.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
The sender name that appears in the recipient's inbox. Defaults to your project name if not set.
|
||||
</p>
|
||||
</div>
|
||||
</GridWrapper>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type {Project} from '@plunk/db';
|
||||
import {createContext, type ReactNode, useContext, useEffect, useState} from 'react';
|
||||
import {createContext, type ReactNode, use, useEffect, useState} from 'react';
|
||||
import {useSWRConfig} from 'swr';
|
||||
|
||||
import {useProjects} from '../hooks/useProject';
|
||||
@@ -74,7 +74,7 @@ export function ActiveProjectProvider({children}: {children: ReactNode}) {
|
||||
}
|
||||
|
||||
export function useActiveProject() {
|
||||
const context = useContext(ActiveProjectContext);
|
||||
const context = use(ActiveProjectContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useActiveProject must be used within an ActiveProjectProvider');
|
||||
}
|
||||
|
||||
@@ -106,14 +106,18 @@ export default function CampaignDetailsPage() {
|
||||
);
|
||||
|
||||
const [editedCampaign, setEditedCampaign] = useState<Partial<Campaign>>({});
|
||||
const [isScheduleDialogOpen, setIsScheduleDialogOpen] = useState(false);
|
||||
const [scheduledDateTime, setScheduledDateTime] = useState('');
|
||||
const [isTestEmailDialogOpen, setIsTestEmailDialogOpen] = useState(false);
|
||||
const [testEmailAddress, setTestEmailAddress] = useState('');
|
||||
const [sendingTestEmail, setSendingTestEmail] = useState(false);
|
||||
const [showCancelDialog, setShowCancelDialog] = useState(false);
|
||||
const [showSendDialog, setShowSendDialog] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
type CampaignDialog =
|
||||
| {type: 'none'}
|
||||
| {type: 'schedule'}
|
||||
| {type: 'testEmail'; sending: boolean}
|
||||
| {type: 'send'}
|
||||
| {type: 'cancel'}
|
||||
| {type: 'delete'};
|
||||
|
||||
const [dialog, setDialog] = useState<CampaignDialog>({type: 'none'});
|
||||
|
||||
// Automatically initialize edit fields when campaign is loaded and is a draft
|
||||
const isEditMode = campaign?.data.status === CampaignStatus.DRAFT;
|
||||
@@ -172,7 +176,7 @@ export default function CampaignDetailsPage() {
|
||||
// Show confirmation with user's local time
|
||||
const localTimeString = formatFullDateTime(scheduledDate);
|
||||
toast.success(`Campaign scheduled for ${localTimeString}`);
|
||||
setIsScheduleDialogOpen(false);
|
||||
setDialog({type: 'none'});
|
||||
setScheduledDateTime('');
|
||||
void mutate();
|
||||
} catch (error) {
|
||||
@@ -186,7 +190,7 @@ export default function CampaignDetailsPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
setSendingTestEmail(true);
|
||||
setDialog({type: 'testEmail', sending: true});
|
||||
|
||||
try {
|
||||
await network.fetch<{success: boolean; message: string}>('POST', `/campaigns/${id}/test`, {
|
||||
@@ -194,12 +198,12 @@ export default function CampaignDetailsPage() {
|
||||
} as any);
|
||||
|
||||
toast.success(`Test email sent to ${testEmailAddress}`);
|
||||
setIsTestEmailDialogOpen(false);
|
||||
setDialog({type: 'none'});
|
||||
setTestEmailAddress('');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to send test email');
|
||||
} finally {
|
||||
setSendingTestEmail(false);
|
||||
setDialog(d => (d.type === 'testEmail' ? {type: 'testEmail', sending: false} : d));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -370,7 +374,7 @@ export default function CampaignDetailsPage() {
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
onClick={() => setDialog({type: 'delete'})}
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
@@ -395,7 +399,7 @@ export default function CampaignDetailsPage() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-72">
|
||||
<DropdownMenuItem onClick={() => setIsTestEmailDialogOpen(true)} className="py-3 cursor-pointer">
|
||||
<DropdownMenuItem onClick={() => setDialog({type: 'testEmail', sending: false})} className="py-3 cursor-pointer">
|
||||
<div className="flex items-start gap-3">
|
||||
<TestTube className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||
<div className="flex flex-col gap-0.5 flex-1">
|
||||
@@ -406,7 +410,7 @@ export default function CampaignDetailsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setShowSendDialog(true)} className="py-3 cursor-pointer">
|
||||
<DropdownMenuItem onClick={() => setDialog({type: 'send'})} className="py-3 cursor-pointer">
|
||||
<div className="flex items-start gap-3">
|
||||
<Send className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||
<div className="flex flex-col gap-0.5 flex-1">
|
||||
@@ -417,7 +421,7 @@ export default function CampaignDetailsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setIsScheduleDialogOpen(true)} className="py-3 cursor-pointer">
|
||||
<DropdownMenuItem onClick={() => setDialog({type: 'schedule'})} className="py-3 cursor-pointer">
|
||||
<div className="flex items-start gap-3">
|
||||
<Calendar className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||
<div className="flex flex-col gap-0.5 flex-1">
|
||||
@@ -531,7 +535,6 @@ export default function CampaignDetailsPage() {
|
||||
onFromNameChange={value => setEditedCampaign({...editedCampaign, fromName: value})}
|
||||
onReplyToChange={value => setEditedCampaign({...editedCampaign, replyTo: value})}
|
||||
fromNamePlaceholder={activeProject?.name || 'Your Company'}
|
||||
showFromNameHelpText
|
||||
layout="vertical"
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -658,7 +661,7 @@ export default function CampaignDetailsPage() {
|
||||
</Card>
|
||||
|
||||
{/* Test Email Dialog */}
|
||||
<Dialog open={isTestEmailDialogOpen} onOpenChange={setIsTestEmailDialogOpen}>
|
||||
<Dialog open={dialog.type === 'testEmail'} onOpenChange={open => !open && setDialog({type: 'none'})}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Send Test Email</DialogTitle>
|
||||
@@ -695,21 +698,25 @@ export default function CampaignDetailsPage() {
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsTestEmailDialogOpen(false);
|
||||
setDialog({type: 'none'});
|
||||
setTestEmailAddress('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSendTestEmail} disabled={sendingTestEmail || !testEmailAddress}>
|
||||
{sendingTestEmail ? 'Sending...' : 'Send Test Email'}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSendTestEmail}
|
||||
disabled={(dialog.type === 'testEmail' && dialog.sending) || !testEmailAddress}
|
||||
>
|
||||
{dialog.type === 'testEmail' && dialog.sending ? 'Sending...' : 'Send Test Email'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Schedule Dialog */}
|
||||
<Dialog open={isScheduleDialogOpen} onOpenChange={setIsScheduleDialogOpen}>
|
||||
<Dialog open={dialog.type === 'schedule'} onOpenChange={open => !open && setDialog({type: 'none'})}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Schedule Campaign</DialogTitle>
|
||||
@@ -801,7 +808,7 @@ export default function CampaignDetailsPage() {
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsScheduleDialogOpen(false);
|
||||
setDialog({type: 'none'});
|
||||
setScheduledDateTime('');
|
||||
}}
|
||||
>
|
||||
@@ -816,11 +823,11 @@ export default function CampaignDetailsPage() {
|
||||
</form>
|
||||
|
||||
{/* Sticky Save Bar */}
|
||||
<StickySaveBar hasChanges={hasChanges} isSubmitting={isSubmitting} onSave={handleSave} />
|
||||
<StickySaveBar status={isSubmitting ? 'saving' : hasChanges ? 'dirty' : 'idle'} onSave={handleSave} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={showSendDialog}
|
||||
onOpenChange={setShowSendDialog}
|
||||
open={dialog.type === 'send'}
|
||||
onOpenChange={open => !open && setDialog({type: 'none'})}
|
||||
onConfirm={handleSend}
|
||||
title="Send Campaign"
|
||||
description="Are you sure you want to send this campaign now? This action cannot be undone."
|
||||
@@ -829,8 +836,8 @@ export default function CampaignDetailsPage() {
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
open={dialog.type === 'delete'}
|
||||
onOpenChange={open => !open && setDialog({type: 'none'})}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Campaign"
|
||||
description="Are you sure you want to delete this draft campaign? This action cannot be undone."
|
||||
@@ -864,7 +871,7 @@ export default function CampaignDetailsPage() {
|
||||
{/* Actions */}
|
||||
{(c.status === CampaignStatus.SCHEDULED || c.status === CampaignStatus.SENDING) && (
|
||||
<div className="flex justify-end">
|
||||
<Button variant="destructive" onClick={() => setShowCancelDialog(true)} className="w-full sm:w-auto">
|
||||
<Button variant="destructive" onClick={() => setDialog({type: 'cancel'})} className="w-full sm:w-auto">
|
||||
<XCircle className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Cancel Campaign</span>
|
||||
<span className="sm:hidden">Cancel</span>
|
||||
@@ -1069,8 +1076,8 @@ export default function CampaignDetailsPage() {
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showCancelDialog}
|
||||
onOpenChange={setShowCancelDialog}
|
||||
open={dialog.type === 'cancel'}
|
||||
onOpenChange={open => !open && setDialog({type: 'none'})}
|
||||
onConfirm={handleCancel}
|
||||
title="Cancel Campaign"
|
||||
description="Are you sure you want to cancel this campaign?"
|
||||
|
||||
@@ -92,11 +92,11 @@ export default function Settings() {
|
||||
const {data: user} = useUser();
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [showRegenerateDialog, setShowRegenerateDialog] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showResetDialog, setShowResetDialog] = useState(false);
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState('');
|
||||
const [resetConfirmText, setResetConfirmText] = useState('');
|
||||
|
||||
type SettingsDialog = {type: 'none'} | {type: 'regenerate'} | {type: 'delete'} | {type: 'reset'};
|
||||
const [dialog, setDialog] = useState<SettingsDialog>({type: 'none'});
|
||||
const [isLoadingBilling, setIsLoadingBilling] = useState(false);
|
||||
const [selectedCurrency, setSelectedCurrency] = useState<string>('auto');
|
||||
const [showCurrencySelector, setShowCurrencySelector] = useState(false);
|
||||
@@ -235,18 +235,18 @@ export default function Settings() {
|
||||
await projectsMutate();
|
||||
|
||||
setSuccessMessage('API keys regenerated successfully');
|
||||
setShowRegenerateDialog(false);
|
||||
setDialog({type: 'none'});
|
||||
|
||||
// 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);
|
||||
setDialog({type: 'none'});
|
||||
}
|
||||
};
|
||||
|
||||
const promptRegenerateKeys = () => {
|
||||
setShowRegenerateDialog(true);
|
||||
setDialog({type: 'regenerate'});
|
||||
};
|
||||
|
||||
const handleStartSubscription = async (currency: string = 'auto') => {
|
||||
@@ -314,7 +314,7 @@ export default function Settings() {
|
||||
await network.fetch('POST', `/users/@me/projects/${activeProject.id}/reset`);
|
||||
|
||||
setSuccessMessage('Project reset successfully. All data has been cleared.');
|
||||
setShowResetDialog(false);
|
||||
setDialog({type: 'none'});
|
||||
setResetConfirmText('');
|
||||
|
||||
// Refresh the page to reload data
|
||||
@@ -323,7 +323,7 @@ export default function Settings() {
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Failed to reset project');
|
||||
setShowResetDialog(false);
|
||||
setDialog({type: 'none'});
|
||||
setResetConfirmText('');
|
||||
}
|
||||
};
|
||||
@@ -338,7 +338,7 @@ export default function Settings() {
|
||||
await network.fetch('DELETE', `/users/@me/projects/${activeProject.id}`);
|
||||
|
||||
setSuccessMessage('Project deleted successfully. Redirecting...');
|
||||
setShowDeleteDialog(false);
|
||||
setDialog({type: 'none'});
|
||||
setDeleteConfirmText('');
|
||||
|
||||
// Refresh projects list and redirect to dashboard
|
||||
@@ -350,7 +350,7 @@ export default function Settings() {
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Failed to delete project');
|
||||
setShowDeleteDialog(false);
|
||||
setDialog({type: 'none'});
|
||||
setDeleteConfirmText('');
|
||||
}
|
||||
};
|
||||
@@ -587,7 +587,7 @@ export default function Settings() {
|
||||
<span>API keys, domains, billing information</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={() => setShowResetDialog(true)} className="shrink-0">
|
||||
<Button type="button" variant="outline" onClick={() => setDialog({type: 'reset'})} className="shrink-0">
|
||||
Reset Data
|
||||
</Button>
|
||||
</div>
|
||||
@@ -614,7 +614,7 @@ export default function Settings() {
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
onClick={() => setDialog({type: 'delete'})}
|
||||
className="shrink-0"
|
||||
>
|
||||
Delete Project
|
||||
@@ -753,7 +753,7 @@ export default function Settings() {
|
||||
{/* Billing Limits */}
|
||||
<BillingLimits
|
||||
projectId={activeProject.id}
|
||||
hasSubscription={!!activeProject.subscription}
|
||||
tier={activeProject.subscription ? 'paid' : 'free'}
|
||||
billingEnabled={billingEnabled}
|
||||
/>
|
||||
|
||||
@@ -814,7 +814,7 @@ export default function Settings() {
|
||||
</div>
|
||||
|
||||
{/* Regenerate Keys Confirmation Dialog */}
|
||||
<Dialog open={showRegenerateDialog} onOpenChange={setShowRegenerateDialog}>
|
||||
<Dialog open={dialog.type === 'regenerate'} onOpenChange={open => !open && setDialog({type: 'none'})}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
@@ -833,7 +833,7 @@ export default function Settings() {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowRegenerateDialog(false)}>
|
||||
<Button variant="outline" onClick={() => setDialog({type: 'none'})}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleRegenerateKeys}>
|
||||
@@ -844,7 +844,7 @@ export default function Settings() {
|
||||
</Dialog>
|
||||
|
||||
{/* Reset Project Confirmation Dialog */}
|
||||
<Dialog open={showResetDialog} onOpenChange={setShowResetDialog}>
|
||||
<Dialog open={dialog.type === 'reset'} onOpenChange={open => !open && setDialog({type: 'none'})}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="space-y-3">
|
||||
<div className="mx-auto w-12 h-12 bg-amber-100 rounded-full flex items-center justify-center">
|
||||
@@ -876,7 +876,7 @@ export default function Settings() {
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowResetDialog(false);
|
||||
setDialog({type: 'none'});
|
||||
setResetConfirmText('');
|
||||
}}
|
||||
className="w-full"
|
||||
@@ -896,7 +896,7 @@ export default function Settings() {
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Project Confirmation Dialog */}
|
||||
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<Dialog open={dialog.type === 'delete'} onOpenChange={open => !open && setDialog({type: 'none'})}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="space-y-3">
|
||||
<div className="mx-auto w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
|
||||
@@ -934,7 +934,7 @@ export default function Settings() {
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowDeleteDialog(false);
|
||||
setDialog({type: 'none'});
|
||||
setDeleteConfirmText('');
|
||||
}}
|
||||
className="w-full"
|
||||
|
||||
@@ -21,7 +21,7 @@ import {ArrowLeft, Save, Trash2, TriangleAlert} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
import {TemplateSchemas, detectUnsubscribeSignal} from '@plunk/shared';
|
||||
@@ -38,7 +38,6 @@ export default function TemplateEditorPage() {
|
||||
|
||||
const [editedTemplate, setEditedTemplate] = useState<Partial<Template>>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
// Initialize edit fields when template loads
|
||||
@@ -54,16 +53,12 @@ export default function TemplateEditorPage() {
|
||||
replyTo: template.replyTo || '',
|
||||
type: template.type,
|
||||
});
|
||||
// Reset hasChanges when loading fresh data
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [template, editedTemplate]);
|
||||
|
||||
// Track changes
|
||||
useEffect(() => {
|
||||
if (!template || Object.keys(editedTemplate).length === 0) return;
|
||||
|
||||
const changed =
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!template || Object.keys(editedTemplate).length === 0) return false;
|
||||
return (
|
||||
editedTemplate.name !== template.name ||
|
||||
(editedTemplate.description || '') !== (template.description || '') ||
|
||||
editedTemplate.subject !== template.subject ||
|
||||
@@ -71,9 +66,8 @@ export default function TemplateEditorPage() {
|
||||
editedTemplate.from !== template.from ||
|
||||
(editedTemplate.fromName || '') !== (template.fromName || '') ||
|
||||
(editedTemplate.replyTo || '') !== (template.replyTo || '') ||
|
||||
editedTemplate.type !== template.type;
|
||||
|
||||
setHasChanges(changed);
|
||||
editedTemplate.type !== template.type
|
||||
);
|
||||
}, [editedTemplate, template]);
|
||||
|
||||
// Warn before leaving page with unsaved changes
|
||||
@@ -96,7 +90,6 @@ export default function TemplateEditorPage() {
|
||||
});
|
||||
|
||||
// Silent save - no toast notification
|
||||
setHasChanges(false);
|
||||
void mutate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to save template');
|
||||
@@ -267,7 +260,6 @@ export default function TemplateEditorPage() {
|
||||
onFromNameChange={value => setEditedTemplate({...editedTemplate, fromName: value})}
|
||||
onReplyToChange={value => setEditedTemplate({...editedTemplate, replyTo: value})}
|
||||
fromNamePlaceholder={activeProject?.name || 'Your Company'}
|
||||
showFromNameHelpText
|
||||
layout="vertical"
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -290,7 +282,7 @@ export default function TemplateEditorPage() {
|
||||
</form>
|
||||
|
||||
{/* Sticky Save Bar */}
|
||||
<StickySaveBar hasChanges={hasChanges} isSubmitting={isSubmitting} onSave={handleSave} />
|
||||
<StickySaveBar status={isSubmitting ? 'saving' : hasChanges ? 'dirty' : 'idle'} onSave={handleSave} />
|
||||
|
||||
{/* Delete Template Confirmation */}
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -74,12 +74,16 @@ export default function WorkflowEditorPage() {
|
||||
const router = useRouter();
|
||||
const {id} = router.query;
|
||||
const [activeTab, setActiveTab] = useState<'builder' | 'executions'>('builder');
|
||||
const [showSettingsDialog, setShowSettingsDialog] = useState(false);
|
||||
const [editingStep, setEditingStep] = useState<WorkflowStep | null>(null);
|
||||
const [showCancelAllDialog, setShowCancelAllDialog] = useState(false);
|
||||
const [executionToCancel, setExecutionToCancel] = useState<string | null>(null);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
type WorkflowDialog =
|
||||
| {type: 'none'}
|
||||
| {type: 'settings'}
|
||||
| {type: 'cancelAll'; cancelling: boolean}
|
||||
| {type: 'cancelOne'; executionId: string; cancelling: boolean}
|
||||
| {type: 'editStep'; step: WorkflowStep}
|
||||
| {type: 'delete'};
|
||||
|
||||
const [dialog, setDialog] = useState<WorkflowDialog>({type: 'none'});
|
||||
|
||||
const {data: workflow, mutate} = useSWR<WorkflowWithDetails>(id ? `/workflows/${id}` : null, {
|
||||
revalidateOnFocus: false,
|
||||
@@ -106,31 +110,29 @@ export default function WorkflowEditorPage() {
|
||||
|
||||
// Handler for cancelling a single execution
|
||||
const handleCancelExecution = async (executionId: string) => {
|
||||
setIsCancelling(true);
|
||||
setDialog({type: 'cancelOne', executionId, cancelling: true});
|
||||
try {
|
||||
await network.fetch('DELETE', `/workflows/${id}/executions/${executionId}`);
|
||||
toast.success('Execution cancelled successfully');
|
||||
setDialog({type: 'none'});
|
||||
void mutate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to cancel execution');
|
||||
} finally {
|
||||
setIsCancelling(false);
|
||||
setExecutionToCancel(null);
|
||||
setDialog({type: 'cancelOne', executionId, cancelling: false});
|
||||
}
|
||||
};
|
||||
|
||||
// Handler for cancelling all executions
|
||||
const handleCancelAllExecutions = async () => {
|
||||
setIsCancelling(true);
|
||||
setDialog(d => (d.type === 'cancelAll' ? {...d, cancelling: true} : d));
|
||||
try {
|
||||
const result = await network.fetch<{cancelled: number}>('POST', `/workflows/${id}/executions/cancel-all`);
|
||||
toast.success(`Successfully cancelled ${result.cancelled} execution(s)`);
|
||||
setDialog({type: 'none'});
|
||||
void mutate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to cancel executions');
|
||||
} finally {
|
||||
setIsCancelling(false);
|
||||
setShowCancelAllDialog(false);
|
||||
setDialog(d => (d.type === 'cancelAll' ? {...d, cancelling: false} : d));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -312,7 +314,7 @@ export default function WorkflowEditorPage() {
|
||||
await network.fetch<Workflow, typeof WorkflowSchemas.update>('PATCH', `/workflows/${id}`, data);
|
||||
toast.success('Workflow updated successfully');
|
||||
void mutate();
|
||||
setShowSettingsDialog(false);
|
||||
setDialog({type: 'none'});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to update workflow');
|
||||
}
|
||||
@@ -336,13 +338,13 @@ export default function WorkflowEditorPage() {
|
||||
if (stepId && workflow) {
|
||||
const step = workflow.steps.find(s => s.id === stepId);
|
||||
if (step) {
|
||||
setEditingStep(step);
|
||||
setDialog({type: 'editStep', step});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenSettingsEvent = () => {
|
||||
setShowSettingsDialog(true);
|
||||
setDialog({type: 'settings'});
|
||||
};
|
||||
|
||||
window.addEventListener('workflow-edit-step', handleEditStepEvent);
|
||||
@@ -398,13 +400,13 @@ export default function WorkflowEditorPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<Button variant="ghost" size="icon" onClick={() => setShowSettingsDialog(true)} aria-label="Settings">
|
||||
<Button variant="ghost" size="icon" onClick={() => setDialog({type: 'settings'})} aria-label="Settings">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
onClick={() => setDialog({type: 'delete'})}
|
||||
aria-label="Delete workflow"
|
||||
className="text-neutral-400 hover:text-red-600 hover:bg-red-50"
|
||||
>
|
||||
@@ -542,7 +544,7 @@ export default function WorkflowEditorPage() {
|
||||
<CardDescription>View and manage all executions of this workflow</CardDescription>
|
||||
</div>
|
||||
{activeExecutionsCount > 0 && (
|
||||
<Button variant="outline" onClick={() => setShowCancelAllDialog(true)}>
|
||||
<Button variant="outline" onClick={() => setDialog({type: 'cancelAll', cancelling: false})}>
|
||||
Cancel All Active ({activeExecutionsCount})
|
||||
</Button>
|
||||
)}
|
||||
@@ -616,8 +618,8 @@ export default function WorkflowEditorPage() {
|
||||
<Button
|
||||
variant="destructiveGhost"
|
||||
size="sm"
|
||||
onClick={() => setExecutionToCancel(execution.id)}
|
||||
disabled={isCancelling}
|
||||
onClick={() => setDialog({type: 'cancelOne', executionId: execution.id, cancelling: false})}
|
||||
disabled={dialog.type === 'cancelOne' && dialog.cancelling}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -639,37 +641,38 @@ export default function WorkflowEditorPage() {
|
||||
<>
|
||||
<SettingsDialog
|
||||
workflow={workflow}
|
||||
open={showSettingsDialog}
|
||||
onOpenChange={setShowSettingsDialog}
|
||||
open={dialog.type === 'settings'}
|
||||
onOpenChange={open => !open && setDialog({type: 'none'})}
|
||||
onSave={handleUpdateSettings}
|
||||
/>
|
||||
{editingStep && (
|
||||
{dialog.type === 'editStep' && (
|
||||
<EditStepDialog
|
||||
step={editingStep}
|
||||
step={dialog.step}
|
||||
workflowId={id as string}
|
||||
open={!!editingStep}
|
||||
onOpenChange={open => !open && setEditingStep(null)}
|
||||
open={true}
|
||||
onOpenChange={open => !open && setDialog({type: 'none'})}
|
||||
onSuccess={() => mutate()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Cancel Single Execution Confirmation */}
|
||||
<ConfirmDialog
|
||||
open={!!executionToCancel}
|
||||
onOpenChange={open => !open && setExecutionToCancel(null)}
|
||||
open={dialog.type === 'cancelOne'}
|
||||
onOpenChange={open => !open && setDialog({type: 'none'})}
|
||||
onConfirm={() => {
|
||||
if (executionToCancel) {
|
||||
return handleCancelExecution(executionToCancel);
|
||||
if (dialog.type === 'cancelOne') {
|
||||
return handleCancelExecution(dialog.executionId);
|
||||
}
|
||||
}}
|
||||
title="Cancel Execution"
|
||||
description={
|
||||
executionToCancel && executionsData?.executions ? (
|
||||
dialog.type === 'cancelOne' && executionsData?.executions ? (
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
Are you sure you want to cancel the workflow execution for{' '}
|
||||
<strong>
|
||||
{executionsData.executions.find(e => e.id === executionToCancel)?.contact.email || 'this contact'}
|
||||
{executionsData.executions.find(e => e.id === dialog.executionId)?.contact.email ||
|
||||
'this contact'}
|
||||
</strong>
|
||||
?
|
||||
</p>
|
||||
@@ -685,13 +688,13 @@ export default function WorkflowEditorPage() {
|
||||
confirmText="Cancel Execution"
|
||||
cancelText="Keep Running"
|
||||
variant="destructive"
|
||||
isLoading={isCancelling}
|
||||
status={dialog.type === 'cancelOne' && dialog.cancelling ? 'loading' : 'idle'}
|
||||
/>
|
||||
|
||||
{/* Cancel All Executions Confirmation */}
|
||||
<ConfirmDialog
|
||||
open={showCancelAllDialog}
|
||||
onOpenChange={setShowCancelAllDialog}
|
||||
open={dialog.type === 'cancelAll'}
|
||||
onOpenChange={open => !open && setDialog({type: 'none'})}
|
||||
onConfirm={handleCancelAllExecutions}
|
||||
title="Cancel All Active Executions"
|
||||
description={
|
||||
@@ -709,13 +712,13 @@ export default function WorkflowEditorPage() {
|
||||
confirmText={`Cancel ${activeExecutionsCount} Execution${activeExecutionsCount !== 1 ? 's' : ''}`}
|
||||
cancelText="Keep Running"
|
||||
variant="destructive"
|
||||
isLoading={isCancelling}
|
||||
status={dialog.type === 'cancelAll' && dialog.cancelling ? 'loading' : 'idle'}
|
||||
/>
|
||||
|
||||
{/* Delete Workflow Confirmation */}
|
||||
<ConfirmDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
open={dialog.type === 'delete'}
|
||||
onOpenChange={open => !open && setDialog({type: 'none'})}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Workflow"
|
||||
description="Are you sure you want to delete this workflow? This action cannot be undone."
|
||||
|
||||
Reference in New Issue
Block a user