diff --git a/apps/web/src/components/WorkflowBuilder.tsx b/apps/web/src/components/WorkflowBuilder.tsx
index 7b373db..a52f7c4 100644
--- a/apps/web/src/components/WorkflowBuilder.tsx
+++ b/apps/web/src/components/WorkflowBuilder.tsx
@@ -12,7 +12,7 @@ import {
ReactFlow,
useEdgesState,
useNodesState,
- useReactFlow,
+ useReactFlow
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import type {WorkflowStep} from '@plunk/db';
@@ -702,7 +702,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
return (
<>
-
+
- {stepToDelete && (() => {
- const affectedSteps = getAffectedSteps(stepToDelete);
- const stepToDeleteData = steps.find(s => s.id === stepToDelete);
- const downstreamSteps = affectedSteps.filter(s => s.id !== stepToDelete);
+ {stepToDelete &&
+ (() => {
+ const affectedSteps = getAffectedSteps(stepToDelete);
+ const stepToDeleteData = steps.find(s => s.id === stepToDelete);
+ const downstreamSteps = affectedSteps.filter(s => s.id !== stepToDelete);
- return (
- 0 ? (
-
-
- Deleting "{stepToDeleteData?.name}" will also delete {downstreamSteps.length} downstream{' '}
- {downstreamSteps.length === 1 ? 'step' : 'steps'}:
-
-
- {downstreamSteps.map(step => (
- -
- {step.name} ({step.type})
-
- ))}
-
-
This action cannot be undone.
-
- ) : (
- `Are you sure you want to delete "${stepToDeleteData?.name}"? This action cannot be undone.`
- )
- }
- confirmText={downstreamSteps.length > 0 ? `Delete ${affectedSteps.length} Steps` : 'Delete'}
- variant="destructive"
- />
- );
- })()}
+ return (
+ 0 ? (
+
+
+ Deleting "{stepToDeleteData?.name}" will also delete {downstreamSteps.length} downstream{' '}
+ {downstreamSteps.length === 1 ? 'step' : 'steps'}:
+
+
+ {downstreamSteps.map(step => (
+ -
+ {step.name} ({step.type})
+
+ ))}
+
+
This action cannot be undone.
+
+ ) : (
+ `Are you sure you want to delete "${stepToDeleteData?.name}"? This action cannot be undone.`
+ )
+ }
+ confirmText={downstreamSteps.length > 0 ? `Delete ${affectedSteps.length} Steps` : 'Delete'}
+ variant="destructive"
+ />
+ );
+ })()}
>
);
}
diff --git a/apps/web/src/components/WorkflowVisualizer.tsx b/apps/web/src/components/WorkflowVisualizer.tsx
index d9c7295..2ab599d 100644
--- a/apps/web/src/components/WorkflowVisualizer.tsx
+++ b/apps/web/src/components/WorkflowVisualizer.tsx
@@ -10,7 +10,7 @@ import {
Position,
ReactFlow,
useEdgesState,
- useNodesState,
+ useNodesState
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import type {WorkflowStep} from '@plunk/db';
@@ -469,7 +469,7 @@ export function WorkflowVisualizer({steps}: WorkflowVisualizerProps) {
}
return (
-
+
diff --git a/apps/web/src/pages/contacts/index.tsx b/apps/web/src/pages/contacts/index.tsx
index 9f9fd01..d0c19f2 100644
--- a/apps/web/src/pages/contacts/index.tsx
+++ b/apps/web/src/pages/contacts/index.tsx
@@ -132,216 +132,220 @@ export default function ContactsPage() {
- {/* Header */}
-
-
-
Contacts
-
- Manage your email subscribers and their data.{' '}
- {totalCount > 0 ? `${totalCount.toLocaleString()} total contacts` : ''}
-
+ {/* Header */}
+
+
+
Contacts
+
+ Manage your email subscribers and their data.{' '}
+ {totalCount > 0 ? `${totalCount.toLocaleString()} total contacts` : ''}
+
+
+
+
+
+
-
-
-
-
-
- {/* Search & Filters */}
-
-
-
-
-
-
- {/* Contacts Table */}
-
-
- All Contacts
-
- View and manage your contact list.
- {totalCount > 0 && ` ${totalCount.toLocaleString()} total contacts`}
-
-
-
- {isLoading && contacts.length === 0 ? (
-
-
-
-
Loading contacts...
+ {/* Search & Filters */}
+
+
+
- ) : contacts.length === 0 ? (
-
-
-
No contacts found
-
- {search ? 'Try adjusting your search terms' : 'Get started by creating your first contact'}
-
- {!search && (
-
- ) : (
- <>
-
-
-
-
- |
- Email
- |
-
- Status
- |
-
- Created
- |
-
- Actions
- |
-
-
-
- {contacts.map(contact => (
-
- |
-
- {contact.subscribed ? (
-
- ) : (
-
- )}
- {contact.email}
-
- |
-
-
- {contact.subscribed ? 'Subscribed' : 'Unsubscribed'}
-
- |
-
- {new Date(contact.createdAt).toLocaleDateString()}
- |
-
-
-
-
-
-
-
- promptDelete(contact.id)}>
-
-
-
- |
-
- ))}
-
-
-
+
+
+
- {/* Pagination Controls */}
- {(currentPage > 0 || data?.hasMore) && (
-
-
- Showing {currentPage * pageSize + 1} to{' '}
- {currentPage * pageSize + contacts.length}
- {totalCount > 0 && (
- <>
- {' '}
- of {totalCount.toLocaleString()}
- >
- )}
-
-
-
-
- Previous
-
-
- Next
-
-
-
+ {/* Contacts Table */}
+
+
+ All Contacts
+
+ View and manage your contact list.
+ {totalCount > 0 && ` ${totalCount.toLocaleString()} total contacts`}
+
+
+
+ {isLoading && contacts.length === 0 ? (
+
+
+
+
Loading contacts...
+
+
+ ) : contacts.length === 0 ? (
+
+
+
No contacts found
+
+ {search ? 'Try adjusting your search terms' : 'Get started by creating your first contact'}
+
+ {!search && (
+
setShowCreateDialog(true)}>
+
+ Add Contact
+
+ )}
+
+ ) : (
+ <>
+
+
+
+
+ |
+ Email
+ |
+
+ Status
+ |
+
+ Created
+ |
+
+ Actions
+ |
+
+
+
+ {contacts.map(contact => (
+
+ |
+
+ {contact.subscribed ? (
+
+ ) : (
+
+ )}
+ {contact.email}
+
+ |
+
+
+ {contact.subscribed ? 'Subscribed' : 'Unsubscribed'}
+
+ |
+
+ {new Date(contact.createdAt).toLocaleDateString()}
+ |
+
+
+
+
+
+
+
+ promptDelete(contact.id)}>
+
+
+
+ |
+
+ ))}
+
+
- )}
- >
- )}
-
-
-
- {/* Create Contact Dialog */}
-
mutate()} />
+ {/* Pagination Controls */}
+ {(currentPage > 0 || data?.hasMore) && (
+
+
+ Showing {currentPage * pageSize + 1} to{' '}
+ {currentPage * pageSize + contacts.length}
+ {totalCount > 0 && (
+ <>
+ {' '}
+ of {totalCount.toLocaleString()}
+ >
+ )}
+
+
+
+
+ Previous
+
+
+ Next
+
+
+
+
+ )}
+ >
+ )}
+
+
+
- {/* Import Contacts Dialog */}
- mutate()} />
+ {/* Create Contact Dialog */}
+ mutate()} />
- {/* Delete Confirmation Dialog */}
-
-
+ {/* Import Contacts Dialog */}
+ mutate()} />
+
+ {/* Delete Confirmation Dialog */}
+
+
>
);
}
@@ -462,10 +466,24 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState<'idle' | 'uploading' | 'processing' | 'completed' | 'failed'>('idle');
const [result, setResult] = useState(null);
+ const [errorMessage, setErrorMessage] = useState(null);
const fileInputRef = useRef(null);
const pollIntervalRef = useRef(null);
const [showCloseConfirmDialog, setShowCloseConfirmDialog] = useState(false);
+ // Helper function to truncate long file names from the middle
+ const truncateFileName = (fileName: string, maxLength: number = 30) => {
+ if (fileName.length <= maxLength) return fileName;
+
+ const extension = fileName.substring(fileName.lastIndexOf('.'));
+ const nameWithoutExt = fileName.substring(0, fileName.lastIndexOf('.'));
+ const charsToShow = maxLength - extension.length - 3; // 3 for "..."
+ const frontChars = Math.ceil(charsToShow / 2);
+ const backChars = Math.floor(charsToShow / 2);
+
+ return `${nameWithoutExt.substring(0, frontChars)}...${nameWithoutExt.substring(nameWithoutExt.length - backChars)}${extension}`;
+ };
+
// Clean up polling on unmount or dialog close
useEffect(() => {
if (!open) {
@@ -480,6 +498,7 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
setProgress(0);
setStatus('idle');
setResult(null);
+ setErrorMessage(null);
}, 300);
}
}, [open]);
@@ -511,6 +530,7 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
state: string;
progress: number;
result: ImportResult | null;
+ failedReason?: string;
}>('GET', `/contacts/import/${jobId}`);
setProgress(response.progress || 0);
@@ -541,7 +561,10 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
- toast.error('Import failed. Please try again.');
+ // Store and show the specific error message if available, otherwise show generic error
+ const errorMsg = response.failedReason || 'Import failed. Please check your CSV file and try again.';
+ setErrorMessage(errorMsg);
+ toast.error(errorMsg);
} else if (response.state === 'active') {
setStatus('processing');
}
@@ -579,7 +602,9 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
void pollJobStatus(data.jobId);
}, 1000); // Poll every second
} catch (error) {
- toast.error(error instanceof Error ? error.message : 'Failed to upload file');
+ const errorMsg = error instanceof Error ? error.message : 'Failed to upload file';
+ setErrorMessage(errorMsg);
+ toast.error(errorMsg);
setStatus('failed');
} finally {
setIsUploading(false);
@@ -615,6 +640,9 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
Required column: email
+
+ Optional: subscribed (true/false, 1/0, yes/no)
+
Optional: Add any custom fields (e.g., firstName, lastName, plan)
Maximum file size: 5MB
@@ -640,7 +668,7 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
type="button"
>
- {file ? file.name : 'Choose CSV File'}
+ {file ? truncateFileName(file.name) : 'Choose CSV File'}
@@ -725,7 +753,9 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
Import failed
-
Please check your CSV file and try again.
+
+ {errorMessage || 'Please check your CSV file and try again.'}
+
)}
diff --git a/apps/web/src/pages/settings/index.tsx b/apps/web/src/pages/settings/index.tsx
index ac1fcfa..996c95d 100644
--- a/apps/web/src/pages/settings/index.tsx
+++ b/apps/web/src/pages/settings/index.tsx
@@ -467,7 +467,7 @@ export default function Settings() {
{/* Danger Zone - Separate Card */}
-
+