Import error handling and subscribe state

This commit is contained in:
Dries Augustyns
2025-12-03 21:52:17 +01:00
parent a28be9adc9
commit e4a43dfb57
8 changed files with 308 additions and 252 deletions
+14 -4
View File
@@ -96,16 +96,26 @@ export function createImportWorker() {
continue; continue;
} }
// Extract custom data (all fields except email) // Extract subscribed field if present
const {email: _, ...customData} = record; 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; const data = Object.keys(customData).length > 0 ? customData : undefined;
// Check if contact exists before upserting // Check if contact exists before upserting
const existingContact = await ContactService.findByEmail(projectId, email); const existingContact = await ContactService.findByEmail(projectId, email);
const isUpdate = !!existingContact; const isUpdate = !!existingContact;
// Upsert contact // Upsert contact with subscribed value from CSV if provided, otherwise default to true
await ContactService.upsert(projectId, email, data, true); await ContactService.upsert(projectId, email, data, subscribed ?? true);
result.successCount++; result.successCount++;
if (isUpdate) { if (isUpdate) {
+2
View File
@@ -316,6 +316,7 @@ export class QueueService {
const state = await job.getState(); const state = await job.getState();
const progress = job.progress; const progress = job.progress;
const returnValue = job.returnvalue; const returnValue = job.returnvalue;
const failedReason = job.failedReason;
return { return {
id: job.id, id: job.id,
@@ -323,6 +324,7 @@ export class QueueService {
progress, progress,
result: returnValue, result: returnValue,
data: job.data, data: job.data,
failedReason,
}; };
} }
+1 -1
View File
@@ -181,7 +181,7 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
<CardDescription>Your project is fully set up</CardDescription> <CardDescription>Your project is fully set up</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-start gap-4 p-4 bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg border border-green-200"> <div className="flex items-start gap-4 p-4 bg-green-50 rounded-lg border border-green-200">
<div className="h-10 w-10 rounded-lg bg-green-100 border border-green-200 flex items-center justify-center flex-shrink-0"> <div className="h-10 w-10 rounded-lg bg-green-100 border border-green-200 flex items-center justify-center flex-shrink-0">
<CheckCircle2 className="h-5 w-5 text-green-700" /> <CheckCircle2 className="h-5 w-5 text-green-700" />
</div> </div>
+38 -37
View File
@@ -12,7 +12,7 @@ import {
ReactFlow, ReactFlow,
useEdgesState, useEdgesState,
useNodesState, useNodesState,
useReactFlow, useReactFlow
} from '@xyflow/react'; } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import type {WorkflowStep} from '@plunk/db'; import type {WorkflowStep} from '@plunk/db';
@@ -702,7 +702,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
return ( return (
<> <>
<div className="w-full h-[800px] bg-gradient-to-br from-neutral-50 to-neutral-100 rounded-lg border border-neutral-200 shadow-inner relative"> <div className="w-full h-[800px] bg-neutral-50 rounded-lg border border-neutral-200 shadow-inner relative">
<ReactFlow <ReactFlow
nodes={nodes} nodes={nodes}
edges={edges} edges={edges}
@@ -821,42 +821,43 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{stepToDelete && (() => { {stepToDelete &&
const affectedSteps = getAffectedSteps(stepToDelete); (() => {
const stepToDeleteData = steps.find(s => s.id === stepToDelete); const affectedSteps = getAffectedSteps(stepToDelete);
const downstreamSteps = affectedSteps.filter(s => s.id !== stepToDelete); const stepToDeleteData = steps.find(s => s.id === stepToDelete);
const downstreamSteps = affectedSteps.filter(s => s.id !== stepToDelete);
return ( return (
<ConfirmDialog <ConfirmDialog
open={showDeleteDialog} open={showDeleteDialog}
onOpenChange={setShowDeleteDialog} onOpenChange={setShowDeleteDialog}
onConfirm={handleDeleteStep} onConfirm={handleDeleteStep}
title="Delete Step" title="Delete Step"
description={ description={
downstreamSteps.length > 0 ? ( downstreamSteps.length > 0 ? (
<div className="space-y-3"> <div className="space-y-3">
<p> <p>
Deleting &quot;{stepToDeleteData?.name}&quot; will also delete {downstreamSteps.length} downstream{' '} Deleting &quot;{stepToDeleteData?.name}&quot; will also delete {downstreamSteps.length} downstream{' '}
{downstreamSteps.length === 1 ? 'step' : 'steps'}: {downstreamSteps.length === 1 ? 'step' : 'steps'}:
</p> </p>
<ul className="list-disc list-inside text-sm text-neutral-600 max-h-32 overflow-y-auto bg-neutral-50 p-3 rounded border border-neutral-200"> <ul className="list-disc list-inside text-sm text-neutral-600 max-h-32 overflow-y-auto bg-neutral-50 p-3 rounded border border-neutral-200">
{downstreamSteps.map(step => ( {downstreamSteps.map(step => (
<li key={step.id}> <li key={step.id}>
{step.name} ({step.type}) {step.name} ({step.type})
</li> </li>
))} ))}
</ul> </ul>
<p className="text-sm font-medium text-red-600">This action cannot be undone.</p> <p className="text-sm font-medium text-red-600">This action cannot be undone.</p>
</div> </div>
) : ( ) : (
`Are you sure you want to delete "${stepToDeleteData?.name}"? 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'} confirmText={downstreamSteps.length > 0 ? `Delete ${affectedSteps.length} Steps` : 'Delete'}
variant="destructive" variant="destructive"
/> />
); );
})()} })()}
</> </>
); );
} }
@@ -10,7 +10,7 @@ import {
Position, Position,
ReactFlow, ReactFlow,
useEdgesState, useEdgesState,
useNodesState, useNodesState
} from '@xyflow/react'; } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import type {WorkflowStep} from '@plunk/db'; import type {WorkflowStep} from '@plunk/db';
@@ -469,7 +469,7 @@ export function WorkflowVisualizer({steps}: WorkflowVisualizerProps) {
} }
return ( return (
<div className="w-full h-[700px] bg-gradient-to-br from-neutral-50 to-neutral-100 rounded-lg border border-neutral-200 shadow-inner"> <div className="w-full h-[700px] bg-neutral-50 rounded-lg border border-neutral-200 shadow-inner">
<ReactFlow <ReactFlow
nodes={nodes} nodes={nodes}
edges={edges} edges={edges}
+16 -3
View File
@@ -25,7 +25,7 @@ import {
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
StickySaveBar, StickySaveBar
} from '@plunk/ui'; } from '@plunk/ui';
import type {Campaign, Segment} from '@plunk/db'; import type {Campaign, Segment} from '@plunk/db';
import {CampaignAudienceType, CampaignStatus} from '@plunk/db'; import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
@@ -36,7 +36,20 @@ import {EmailEditor} from '../../components/EmailEditor';
import {network} from '../../lib/network'; import {network} from '../../lib/network';
import {formatFullDateTime, formatUTCDateTime, getUserTimezone, schedulePresets} from '../../lib/dateUtils'; import {formatFullDateTime, formatUTCDateTime, getUserTimezone, schedulePresets} from '../../lib/dateUtils';
import {useChangeTracking} from '../../lib/hooks/useChangeTracking'; import {useChangeTracking} from '../../lib/hooks/useChangeTracking';
import {ArrowLeft, Calendar, ChevronDown, Mail, MousePointer, Save, Send, TestTube, Trash2, TrendingUp, Users, XCircle} from 'lucide-react'; import {
ArrowLeft,
Calendar,
ChevronDown,
Mail,
MousePointer,
Save,
Send,
TestTube,
Trash2,
TrendingUp,
Users,
XCircle
} from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import {useRouter} from 'next/router'; import {useRouter} from 'next/router';
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
@@ -775,7 +788,7 @@ export default function CampaignDetailsPage() {
</div> </div>
<div className="w-full bg-neutral-200 rounded-full h-3"> <div className="w-full bg-neutral-200 rounded-full h-3">
<div <div
className="bg-gradient-to-r from-blue-500 to-indigo-500 h-3 rounded-full transition-all duration-500" className="bg-blue-500 h-3 rounded-full transition-all duration-500"
style={{width: `${(s.sentCount / s.totalRecipients) * 100}%`}} style={{width: `${(s.sentCount / s.totalRecipients) * 100}%`}}
/> />
</div> </div>
+234 -204
View File
@@ -132,216 +132,220 @@ export default function ContactsPage() {
<NextSeo title="Contacts" /> <NextSeo title="Contacts" />
<DashboardLayout> <DashboardLayout>
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-neutral-900">Contacts</h1> <h1 className="text-3xl font-bold text-neutral-900">Contacts</h1>
<p className="text-neutral-500 mt-2"> <p className="text-neutral-500 mt-2">
Manage your email subscribers and their data.{' '} Manage your email subscribers and their data.{' '}
{totalCount > 0 ? `${totalCount.toLocaleString()} total contacts` : ''} {totalCount > 0 ? `${totalCount.toLocaleString()} total contacts` : ''}
</p> </p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setShowImportDialog(true)}>
<Upload className="h-4 w-4" />
Import CSV
</Button>
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4" />
Add Contact
</Button>
</div>
</div> </div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setShowImportDialog(true)}>
<Upload className="h-4 w-4" />
Import CSV
</Button>
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4" />
Add Contact
</Button>
</div>
</div>
{/* Search & Filters */} {/* Search & Filters */}
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
<form onSubmit={handleSearch} className="flex gap-2"> <form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-500" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-500" />
<Input <Input
type="text" type="text"
placeholder="Search by email..." placeholder="Search by email..."
value={searchInput} value={searchInput}
onChange={e => setSearchInput(e.target.value)} onChange={e => setSearchInput(e.target.value)}
className="pl-10" className="pl-10"
/> />
</div>
<Button type="submit">Search</Button>
{search && (
<Button
type="button"
variant="outline"
onClick={() => {
setSearch('');
setSearchInput('');
setCursor(undefined);
setCursorHistory([undefined]);
setCurrentPage(0);
setContacts([]);
}}
>
Clear
</Button>
)}
</form>
</CardContent>
</Card>
{/* Contacts Table */}
<Card>
<CardHeader>
<CardTitle>All Contacts</CardTitle>
<CardDescription>
View and manage your contact list.
{totalCount > 0 && ` ${totalCount.toLocaleString()} total contacts`}
</CardDescription>
</CardHeader>
<CardContent>
{isLoading && contacts.length === 0 ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<svg
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="mt-2 text-sm text-neutral-500">Loading contacts...</p>
</div> </div>
</div> <Button type="submit">Search</Button>
) : contacts.length === 0 ? ( {search && (
<div className="text-center py-12"> <Button
<Mail className="h-12 w-12 text-neutral-400 mx-auto mb-4" /> type="button"
<h3 className="text-lg font-medium text-neutral-900 mb-2">No contacts found</h3> variant="outline"
<p className="text-neutral-500 mb-6"> onClick={() => {
{search ? 'Try adjusting your search terms' : 'Get started by creating your first contact'} setSearch('');
</p> setSearchInput('');
{!search && ( setCursor(undefined);
<Button onClick={() => setShowCreateDialog(true)}> setCursorHistory([undefined]);
<Plus className="h-4 w-4" /> setCurrentPage(0);
Add Contact setContacts([]);
}}
>
Clear
</Button> </Button>
)} )}
</div> </form>
) : ( </CardContent>
<> </Card>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-neutral-200">
{contacts.map(contact => (
<tr key={contact.id} className="hover:bg-neutral-50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
{contact.subscribed ? (
<MailCheck className="h-4 w-4 text-green-600" />
) : (
<MailX className="h-4 w-4 text-red-600" />
)}
<span className="text-sm font-medium text-neutral-900">{contact.email}</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
contact.subscribed ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}
>
{contact.subscribed ? 'Subscribed' : 'Unsubscribed'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-neutral-500">
{new Date(contact.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end gap-2">
<Link href={`/contacts/${contact.id}`}>
<Button variant="ghost" size="sm">
<Edit className="h-4 w-4" />
</Button>
</Link>
<Button variant="ghost" size="sm" onClick={() => promptDelete(contact.id)}>
<Trash2 className="h-4 w-4 text-red-600" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination Controls */} {/* Contacts Table */}
{(currentPage > 0 || data?.hasMore) && ( <Card>
<div className="flex items-center justify-between mt-6 pt-6 border-t border-neutral-200"> <CardHeader>
<div className="text-sm text-neutral-600"> <CardTitle>All Contacts</CardTitle>
Showing <span className="font-medium text-neutral-900">{currentPage * pageSize + 1}</span> to{' '} <CardDescription>
<span className="font-medium text-neutral-900">{currentPage * pageSize + contacts.length}</span> View and manage your contact list.
{totalCount > 0 && ( {totalCount > 0 && ` ${totalCount.toLocaleString()} total contacts`}
<> </CardDescription>
{' '} </CardHeader>
of <span className="font-medium text-neutral-900">{totalCount.toLocaleString()}</span> <CardContent>
</> {isLoading && contacts.length === 0 ? (
)} <div className="flex items-center justify-center py-12">
</div> <div className="text-center">
<div className="flex gap-2"> <svg
<Button variant="outline" onClick={handlePreviousPage} disabled={currentPage === 0 || isLoading}> className="h-8 w-8 animate-spin mx-auto text-neutral-900"
<ChevronLeft className="h-4 w-4" /> xmlns="http://www.w3.org/2000/svg"
Previous fill="none"
</Button> viewBox="0 0 24 24"
<Button variant="outline" onClick={handleNextPage} disabled={!data?.hasMore || isLoading}> >
Next <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<ChevronRight className="h-4 w-4" /> <path
</Button> className="opacity-75"
</div> fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="mt-2 text-sm text-neutral-500">Loading contacts...</p>
</div>
</div>
) : contacts.length === 0 ? (
<div className="text-center py-12">
<Mail className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-neutral-900 mb-2">No contacts found</h3>
<p className="text-neutral-500 mb-6">
{search ? 'Try adjusting your search terms' : 'Get started by creating your first contact'}
</p>
{!search && (
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4" />
Add Contact
</Button>
)}
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-neutral-200">
{contacts.map(contact => (
<tr key={contact.id} className="hover:bg-neutral-50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
{contact.subscribed ? (
<MailCheck className="h-4 w-4 text-green-600" />
) : (
<MailX className="h-4 w-4 text-red-600" />
)}
<span className="text-sm font-medium text-neutral-900">{contact.email}</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
contact.subscribed ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}
>
{contact.subscribed ? 'Subscribed' : 'Unsubscribed'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-neutral-500">
{new Date(contact.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end gap-2">
<Link href={`/contacts/${contact.id}`}>
<Button variant="ghost" size="sm">
<Edit className="h-4 w-4" />
</Button>
</Link>
<Button variant="ghost" size="sm" onClick={() => promptDelete(contact.id)}>
<Trash2 className="h-4 w-4 text-red-600" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div> </div>
)}
</>
)}
</CardContent>
</Card>
</div>
{/* Create Contact Dialog */} {/* Pagination Controls */}
<CreateContactDialog open={showCreateDialog} onOpenChange={setShowCreateDialog} onSuccess={() => mutate()} /> {(currentPage > 0 || data?.hasMore) && (
<div className="flex items-center justify-between mt-6 pt-6 border-t border-neutral-200">
<div className="text-sm text-neutral-600">
Showing <span className="font-medium text-neutral-900">{currentPage * pageSize + 1}</span> to{' '}
<span className="font-medium text-neutral-900">{currentPage * pageSize + contacts.length}</span>
{totalCount > 0 && (
<>
{' '}
of <span className="font-medium text-neutral-900">{totalCount.toLocaleString()}</span>
</>
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handlePreviousPage}
disabled={currentPage === 0 || isLoading}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button variant="outline" onClick={handleNextPage} disabled={!data?.hasMore || isLoading}>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
</div>
{/* Import Contacts Dialog */} {/* Create Contact Dialog */}
<ImportContactsDialog open={showImportDialog} onOpenChange={setShowImportDialog} onSuccess={() => mutate()} /> <CreateContactDialog open={showCreateDialog} onOpenChange={setShowCreateDialog} onSuccess={() => mutate()} />
{/* Delete Confirmation Dialog */} {/* Import Contacts Dialog */}
<ConfirmDialog <ImportContactsDialog open={showImportDialog} onOpenChange={setShowImportDialog} onSuccess={() => mutate()} />
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog} {/* Delete Confirmation Dialog */}
onConfirm={handleDelete} <ConfirmDialog
title="Delete Contact" open={showDeleteDialog}
description="Are you sure you want to delete this contact? This action cannot be undone." onOpenChange={setShowDeleteDialog}
confirmText="Delete" onConfirm={handleDelete}
variant="destructive" title="Delete Contact"
/> description="Are you sure you want to delete this contact? This action cannot be undone."
</DashboardLayout> confirmText="Delete"
variant="destructive"
/>
</DashboardLayout>
</> </>
); );
} }
@@ -462,10 +466,24 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [status, setStatus] = useState<'idle' | 'uploading' | 'processing' | 'completed' | 'failed'>('idle'); const [status, setStatus] = useState<'idle' | 'uploading' | 'processing' | 'completed' | 'failed'>('idle');
const [result, setResult] = useState<ImportResult | null>(null); const [result, setResult] = useState<ImportResult | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null); const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const [showCloseConfirmDialog, setShowCloseConfirmDialog] = useState(false); 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 // Clean up polling on unmount or dialog close
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
@@ -480,6 +498,7 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
setProgress(0); setProgress(0);
setStatus('idle'); setStatus('idle');
setResult(null); setResult(null);
setErrorMessage(null);
}, 300); }, 300);
} }
}, [open]); }, [open]);
@@ -511,6 +530,7 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
state: string; state: string;
progress: number; progress: number;
result: ImportResult | null; result: ImportResult | null;
failedReason?: string;
}>('GET', `/contacts/import/${jobId}`); }>('GET', `/contacts/import/${jobId}`);
setProgress(response.progress || 0); setProgress(response.progress || 0);
@@ -541,7 +561,10 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
clearInterval(pollIntervalRef.current); clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null; 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') { } else if (response.state === 'active') {
setStatus('processing'); setStatus('processing');
} }
@@ -579,7 +602,9 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
void pollJobStatus(data.jobId); void pollJobStatus(data.jobId);
}, 1000); // Poll every second }, 1000); // Poll every second
} catch (error) { } 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'); setStatus('failed');
} finally { } finally {
setIsUploading(false); setIsUploading(false);
@@ -615,6 +640,9 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
<li> <li>
Required column: <code className="bg-blue-100 px-1 rounded">email</code> Required column: <code className="bg-blue-100 px-1 rounded">email</code>
</li> </li>
<li>
Optional: <code className="bg-blue-100 px-1 rounded">subscribed</code> (true/false, 1/0, yes/no)
</li>
<li>Optional: Add any custom fields (e.g., firstName, lastName, plan)</li> <li>Optional: Add any custom fields (e.g., firstName, lastName, plan)</li>
<li>Maximum file size: 5MB</li> <li>Maximum file size: 5MB</li>
</ul> </ul>
@@ -640,7 +668,7 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
type="button" type="button"
> >
<FileUp className="h-4 w-4 mr-2" /> <FileUp className="h-4 w-4 mr-2" />
{file ? file.name : 'Choose CSV File'} {file ? truncateFileName(file.name) : 'Choose CSV File'}
</Button> </Button>
</div> </div>
</div> </div>
@@ -725,7 +753,9 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
<XCircle className="h-5 w-5" /> <XCircle className="h-5 w-5" />
<span className="font-medium">Import failed</span> <span className="font-medium">Import failed</span>
</div> </div>
<p className="text-sm text-red-800 mt-1">Please check your CSV file and try again.</p> <p className="text-sm text-red-800 mt-1">
{errorMessage || 'Please check your CSV file and try again.'}
</p>
</div> </div>
)} )}
</div> </div>
+1 -1
View File
@@ -467,7 +467,7 @@ export default function Settings() {
{/* Danger Zone - Separate Card */} {/* Danger Zone - Separate Card */}
<Card className="border-red-200 mt-6"> <Card className="border-red-200 mt-6">
<CardHeader className="border-b border-red-100 bg-gradient-to-r from-red-50 to-orange-50"> <CardHeader className="border-b border-red-100 bg-red-50">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 bg-white rounded-lg shadow-sm border border-red-200"> <div className="p-2 bg-white rounded-lg shadow-sm border border-red-200">
<AlertTriangle className="h-5 w-5 text-red-600" /> <AlertTriangle className="h-5 w-5 text-red-600" />