diff --git a/apps/api/src/jobs/import-processor.ts b/apps/api/src/jobs/import-processor.ts index 3722ad6..9160768 100644 --- a/apps/api/src/jobs/import-processor.ts +++ b/apps/api/src/jobs/import-processor.ts @@ -96,16 +96,26 @@ export function createImportWorker() { continue; } - // Extract custom data (all fields except email) - const {email: _, ...customData} = record; + // Extract subscribed field if present + const subscribedValue = record.subscribed; + let subscribed: boolean | undefined; + + if (subscribedValue !== undefined && subscribedValue !== '') { + // Handle various truthy/falsy values + const lowerValue = subscribedValue.toLowerCase().trim(); + subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes'; + } + + // Extract custom data (all fields except email and subscribed) + const {email: _, subscribed: __, ...customData} = record; const data = Object.keys(customData).length > 0 ? customData : undefined; // Check if contact exists before upserting const existingContact = await ContactService.findByEmail(projectId, email); const isUpdate = !!existingContact; - // Upsert contact - await ContactService.upsert(projectId, email, data, true); + // Upsert contact with subscribed value from CSV if provided, otherwise default to true + await ContactService.upsert(projectId, email, data, subscribed ?? true); result.successCount++; if (isUpdate) { diff --git a/apps/api/src/services/QueueService.ts b/apps/api/src/services/QueueService.ts index 448f322..9d42e00 100644 --- a/apps/api/src/services/QueueService.ts +++ b/apps/api/src/services/QueueService.ts @@ -316,6 +316,7 @@ export class QueueService { const state = await job.getState(); const progress = job.progress; const returnValue = job.returnvalue; + const failedReason = job.failedReason; return { id: job.id, @@ -323,6 +324,7 @@ export class QueueService { progress, result: returnValue, data: job.data, + failedReason, }; } diff --git a/apps/web/src/components/QuickStart.tsx b/apps/web/src/components/QuickStart.tsx index 365d363..26f6b36 100644 --- a/apps/web/src/components/QuickStart.tsx +++ b/apps/web/src/components/QuickStart.tsx @@ -181,7 +181,7 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) { Your project is fully set up -
+
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 */} - - -
-
- - setSearchInput(e.target.value)} - className="pl-10" - /> -
- - {search && ( - - )} -
-
-
- - {/* Contacts Table */} - - - All Contacts - - View and manage your contact list. - {totalCount > 0 && ` ${totalCount.toLocaleString()} total contacts`} - - - - {isLoading && contacts.length === 0 ? ( -
-
- - - - -

Loading contacts...

+ {/* Search & Filters */} + + +
+
+ + setSearchInput(e.target.value)} + className="pl-10" + />
-
- ) : contacts.length === 0 ? ( -
- -

No contacts found

-

- {search ? 'Try adjusting your search terms' : 'Get started by creating your first contact'} -

- {!search && ( - + {search && ( + )} -
- ) : ( - <> -
- - - - - - - - - - - {contacts.map(contact => ( - - - - - - - ))} - -
- Email - - Status - - Created - - Actions -
-
- {contact.subscribed ? ( - - ) : ( - - )} - {contact.email} -
-
- - {contact.subscribed ? 'Subscribed' : 'Unsubscribed'} - - - {new Date(contact.createdAt).toLocaleDateString()} - -
- - - - -
-
-
+ + + - {/* Pagination Controls */} - {(currentPage > 0 || data?.hasMore) && ( -
-
- Showing {currentPage * pageSize + 1} to{' '} - {currentPage * pageSize + contacts.length} - {totalCount > 0 && ( - <> - {' '} - of {totalCount.toLocaleString()} - - )} -
-
- - -
+ {/* 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 && ( + + )} +
+ ) : ( + <> +
+ + + + + + + + + + + {contacts.map(contact => ( + + + + + + + ))} + +
+ Email + + Status + + Created + + Actions +
+
+ {contact.subscribed ? ( + + ) : ( + + )} + {contact.email} +
+
+ + {contact.subscribed ? 'Subscribed' : 'Unsubscribed'} + + + {new Date(contact.createdAt).toLocaleDateString()} + +
+ + + + +
+
- )} - - )} -
-
-
- {/* Create Contact Dialog */} - mutate()} /> + {/* Pagination Controls */} + {(currentPage > 0 || data?.hasMore) && ( +
+
+ Showing {currentPage * pageSize + 1} to{' '} + {currentPage * pageSize + contacts.length} + {totalCount > 0 && ( + <> + {' '} + of {totalCount.toLocaleString()} + + )} +
+
+ + +
+
+ )} + + )} + + +
- {/* 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 */} - +