Seth commited on
Commit ·
c85593f
1
Parent(s): af27681
update
Browse files
frontend/src/components/campaigns/CreateCampaignWizard.jsx
CHANGED
|
@@ -79,6 +79,7 @@ function WizardSequencePreview({
|
|
| 79 |
sequences,
|
| 80 |
isGenerating,
|
| 81 |
generationComplete,
|
|
|
|
| 82 |
progress,
|
| 83 |
contactCount,
|
| 84 |
selectedProducts,
|
|
@@ -105,7 +106,8 @@ function WizardSequencePreview({
|
|
| 105 |
});
|
| 106 |
const displayedContacts = filteredContacts.slice(0, displayedCount);
|
| 107 |
const hasMore = filteredContacts.length > displayedCount;
|
| 108 |
-
const showProgress =
|
|
|
|
| 109 |
const phaseLabel =
|
| 110 |
sequenceHasLinkedin && genPhase === 'linkedin' && isGenerating
|
| 111 |
? 'LinkedIn sequences'
|
|
@@ -113,31 +115,33 @@ function WizardSequencePreview({
|
|
| 113 |
|
| 114 |
return (
|
| 115 |
<div className="w-full space-y-4">
|
| 116 |
-
|
| 117 |
-
<div className="mb-
|
| 118 |
-
<div className="flex items-center
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
<
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
</div>
|
| 127 |
-
)}
|
| 128 |
-
<div>
|
| 129 |
-
<h3 className="font-semibold text-slate-800">
|
| 130 |
-
{generationComplete ? 'Generation complete!' : `Generating ${phaseLabel}…`}
|
| 131 |
-
</h3>
|
| 132 |
-
<p className="text-sm text-slate-500">
|
| 133 |
-
{contacts.length} contacts · {sequences.length} generated rows
|
| 134 |
-
{contactCount ? ` · ~${contactCount} contacts in file` : ''}
|
| 135 |
-
</p>
|
| 136 |
</div>
|
| 137 |
</div>
|
|
|
|
| 138 |
</div>
|
| 139 |
-
|
| 140 |
-
</div>
|
| 141 |
|
| 142 |
{sequences.length > 0 && (
|
| 143 |
<div className="flex flex-col gap-3 sm:flex-row">
|
|
@@ -188,10 +192,10 @@ function WizardSequencePreview({
|
|
| 188 |
)}
|
| 189 |
</div>
|
| 190 |
|
| 191 |
-
{!
|
| 192 |
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50/80 py-12 text-center text-sm text-slate-500">
|
| 193 |
-
Generated messages will appear here. You can continue to the next step
|
| 194 |
-
background.
|
| 195 |
</div>
|
| 196 |
)}
|
| 197 |
</div>
|
|
@@ -766,6 +770,7 @@ export default function CreateCampaignWizard({ open, onOpenChange, onComplete })
|
|
| 766 |
sequences={genSequences}
|
| 767 |
isGenerating={genRunning}
|
| 768 |
generationComplete={genComplete}
|
|
|
|
| 769 |
progress={genProgress}
|
| 770 |
contactCount={wizardUpload?.contactCount}
|
| 771 |
selectedProducts={selectedProducts}
|
|
|
|
| 79 |
sequences,
|
| 80 |
isGenerating,
|
| 81 |
generationComplete,
|
| 82 |
+
generationStarted,
|
| 83 |
progress,
|
| 84 |
contactCount,
|
| 85 |
selectedProducts,
|
|
|
|
| 106 |
});
|
| 107 |
const displayedContacts = filteredContacts.slice(0, displayedCount);
|
| 108 |
const hasMore = filteredContacts.length > displayedCount;
|
| 109 |
+
const showProgress =
|
| 110 |
+
generationStarted && (isGenerating || !generationComplete);
|
| 111 |
const phaseLabel =
|
| 112 |
sequenceHasLinkedin && genPhase === 'linkedin' && isGenerating
|
| 113 |
? 'LinkedIn sequences'
|
|
|
|
| 115 |
|
| 116 |
return (
|
| 117 |
<div className="w-full space-y-4">
|
| 118 |
+
{generationStarted ? (
|
| 119 |
+
<div className="mb-2 rounded-2xl border border-slate-200 bg-white p-6">
|
| 120 |
+
<div className="mb-4 flex items-center justify-between">
|
| 121 |
+
<div className="flex items-center gap-3">
|
| 122 |
+
{generationComplete ? (
|
| 123 |
+
<div className="rounded-xl bg-green-100 p-3">
|
| 124 |
+
<CheckCircle2 className="h-6 w-6 text-green-600" />
|
| 125 |
+
</div>
|
| 126 |
+
) : (
|
| 127 |
+
<div className="rounded-xl bg-violet-100 p-3">
|
| 128 |
+
<Loader2 className="h-6 w-6 animate-spin text-violet-600" />
|
| 129 |
+
</div>
|
| 130 |
+
)}
|
| 131 |
+
<div>
|
| 132 |
+
<h3 className="font-semibold text-slate-800">
|
| 133 |
+
{generationComplete ? 'Generation complete!' : `Generating ${phaseLabel}…`}
|
| 134 |
+
</h3>
|
| 135 |
+
<p className="text-sm text-slate-500">
|
| 136 |
+
{contacts.length} contacts · {sequences.length} generated rows
|
| 137 |
+
{contactCount ? ` · ~${contactCount} contacts in file` : ''}
|
| 138 |
+
</p>
|
| 139 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
</div>
|
| 141 |
</div>
|
| 142 |
+
{showProgress ? <Progress value={progress} className="h-2" /> : null}
|
| 143 |
</div>
|
| 144 |
+
) : null}
|
|
|
|
| 145 |
|
| 146 |
{sequences.length > 0 && (
|
| 147 |
<div className="flex flex-col gap-3 sm:flex-row">
|
|
|
|
| 192 |
)}
|
| 193 |
</div>
|
| 194 |
|
| 195 |
+
{!generationStarted && contacts.length === 0 && sequences.length === 0 && (
|
| 196 |
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50/80 py-12 text-center text-sm text-slate-500">
|
| 197 |
+
Generated messages will appear here after you click Generate. You can continue to the next step
|
| 198 |
+
while generation runs in the background.
|
| 199 |
</div>
|
| 200 |
)}
|
| 201 |
</div>
|
|
|
|
| 770 |
sequences={genSequences}
|
| 771 |
isGenerating={genRunning}
|
| 772 |
generationComplete={genComplete}
|
| 773 |
+
generationStarted={genRunId > 0}
|
| 774 |
progress={genProgress}
|
| 775 |
contactCount={wizardUpload?.contactCount}
|
| 776 |
selectedProducts={selectedProducts}
|
frontend/src/components/prompts/PromptEditor.jsx
CHANGED
|
@@ -1052,77 +1052,98 @@ const PromptEditor = forwardRef(function PromptEditor(
|
|
| 1052 |
transition={{ duration: 0.2 }}
|
| 1053 |
className="rounded-2xl border border-slate-200 bg-white overflow-hidden"
|
| 1054 |
>
|
| 1055 |
-
<div
|
| 1056 |
-
|
| 1057 |
-
|
| 1058 |
-
|
| 1059 |
-
|
| 1060 |
-
|
| 1061 |
-
|
| 1062 |
-
|
| 1063 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1064 |
</div>
|
| 1065 |
-
|
| 1066 |
-
|
| 1067 |
-
|
| 1068 |
-
|
| 1069 |
-
|
| 1070 |
-
|
| 1071 |
-
|
| 1072 |
-
? 'LinkedIn sequence prompt'
|
| 1073 |
-
: variant === 'campaign'
|
| 1074 |
-
? includeLinkedinInCampaign
|
| 1075 |
-
? 'Gmail + LinkedIn prompts'
|
| 1076 |
-
: 'Gmail prompts'
|
| 1077 |
-
: 'Email Template'}
|
| 1078 |
-
</h4>
|
| 1079 |
-
<p className="text-xs text-slate-500">
|
| 1080 |
-
{variant === 'campaign' && includeLinkedinInCampaign ? (
|
| 1081 |
-
<>
|
| 1082 |
-
Above the line{' '}
|
| 1083 |
-
<code className="rounded bg-slate-100 px-1 text-[10px]">
|
| 1084 |
-
{`<<<CAMPAIGN_LINKEDIN_PROMPT>>>`}
|
| 1085 |
-
</code>{' '}
|
| 1086 |
-
= Gmail (email generation). Below = LinkedIn system prompt.
|
| 1087 |
-
Keep that divider on its own line.
|
| 1088 |
-
</>
|
| 1089 |
-
) : (
|
| 1090 |
-
<>
|
| 1091 |
Use variables: {"{{first_name}}"}, {"{{company}}"},{" "}
|
| 1092 |
{"{{sender_name}}"}
|
| 1093 |
-
</>
|
| 1094 |
-
|
| 1095 |
-
</
|
| 1096 |
-
|
| 1097 |
-
|
| 1098 |
-
|
| 1099 |
-
|
| 1100 |
-
|
| 1101 |
-
|
| 1102 |
-
|
| 1103 |
-
|
| 1104 |
-
|
| 1105 |
-
|
| 1106 |
-
|
| 1107 |
-
|
| 1108 |
-
|
| 1109 |
-
|
| 1110 |
-
|
| 1111 |
-
|
| 1112 |
-
|
| 1113 |
-
|
| 1114 |
-
|
| 1115 |
-
|
| 1116 |
-
|
| 1117 |
-
|
| 1118 |
-
|
| 1119 |
-
|
| 1120 |
-
|
| 1121 |
-
|
| 1122 |
-
</>
|
| 1123 |
-
|
| 1124 |
-
</
|
| 1125 |
-
|
| 1126 |
</div>
|
| 1127 |
<div className="p-6">
|
| 1128 |
<Textarea
|
|
@@ -1138,8 +1159,8 @@ const PromptEditor = forwardRef(function PromptEditor(
|
|
| 1138 |
? 'Enter your LinkedIn sequence system prompt…'
|
| 1139 |
: variant === 'campaign'
|
| 1140 |
? includeLinkedinInCampaign
|
| 1141 |
-
? '
|
| 1142 |
-
: '
|
| 1143 |
: 'Enter your email template here...'
|
| 1144 |
}
|
| 1145 |
className={`${
|
|
@@ -1157,13 +1178,13 @@ const PromptEditor = forwardRef(function PromptEditor(
|
|
| 1157 |
>
|
| 1158 |
{savedStatus[product.name] ? (
|
| 1159 |
<>
|
| 1160 |
-
<CheckCircle2 className="h-4 w-4
|
| 1161 |
-
Saved
|
| 1162 |
</>
|
| 1163 |
) : (
|
| 1164 |
<>
|
| 1165 |
-
<Save className="h-4 w-4
|
| 1166 |
-
Save
|
| 1167 |
</>
|
| 1168 |
)}
|
| 1169 |
</Button>
|
|
|
|
| 1052 |
transition={{ duration: 0.2 }}
|
| 1053 |
className="rounded-2xl border border-slate-200 bg-white overflow-hidden"
|
| 1054 |
>
|
| 1055 |
+
<div
|
| 1056 |
+
className={`flex items-center justify-between border-b border-slate-100 bg-slate-50/50 px-6 ${
|
| 1057 |
+
variant === 'campaign' ? 'py-3' : 'py-4'
|
| 1058 |
+
}`}
|
| 1059 |
+
>
|
| 1060 |
+
{variant === 'campaign' ? (
|
| 1061 |
+
<>
|
| 1062 |
+
<div>
|
| 1063 |
+
<h4 className="text-sm font-semibold text-slate-800">Prompt</h4>
|
| 1064 |
+
<p className="text-xs text-slate-500">Edit the prompt as desired.</p>
|
| 1065 |
+
</div>
|
| 1066 |
+
<div className="flex shrink-0 items-center gap-1">
|
| 1067 |
+
<Button
|
| 1068 |
+
variant="ghost"
|
| 1069 |
+
size="sm"
|
| 1070 |
+
onClick={() => handleRestoreDefault(product.name)}
|
| 1071 |
+
className="text-slate-600 hover:text-slate-900"
|
| 1072 |
+
>
|
| 1073 |
+
<RotateCcw className="mr-1 h-4 w-4" />
|
| 1074 |
+
Restore Default
|
| 1075 |
+
</Button>
|
| 1076 |
+
<Button
|
| 1077 |
+
size="sm"
|
| 1078 |
+
onClick={() => handleSave(product.name)}
|
| 1079 |
+
className="bg-violet-600 hover:bg-violet-700"
|
| 1080 |
+
>
|
| 1081 |
+
{savedStatus[product.name] ? (
|
| 1082 |
+
<>
|
| 1083 |
+
<CheckCircle2 className="mr-1 h-4 w-4" />
|
| 1084 |
+
Saved
|
| 1085 |
+
</>
|
| 1086 |
+
) : (
|
| 1087 |
+
<>
|
| 1088 |
+
<Save className="mr-1 h-4 w-4" />
|
| 1089 |
+
Save
|
| 1090 |
+
</>
|
| 1091 |
+
)}
|
| 1092 |
+
</Button>
|
| 1093 |
+
</div>
|
| 1094 |
+
</>
|
| 1095 |
+
) : (
|
| 1096 |
+
<>
|
| 1097 |
+
<div className="flex items-center gap-3">
|
| 1098 |
+
<div className="rounded-lg bg-violet-100 p-2">
|
| 1099 |
+
{variant === 'linkedin' ? (
|
| 1100 |
+
<Linkedin className="h-4 w-4 text-violet-600" />
|
| 1101 |
+
) : (
|
| 1102 |
+
<Sparkles className="h-4 w-4 text-violet-600" />
|
| 1103 |
+
)}
|
| 1104 |
</div>
|
| 1105 |
+
<div>
|
| 1106 |
+
<h4 className="font-semibold text-slate-800">
|
| 1107 |
+
{variant === 'linkedin'
|
| 1108 |
+
? 'LinkedIn sequence prompt'
|
| 1109 |
+
: 'Email Template'}
|
| 1110 |
+
</h4>
|
| 1111 |
+
<p className="text-xs text-slate-500">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1112 |
Use variables: {"{{first_name}}"}, {"{{company}}"},{" "}
|
| 1113 |
{"{{sender_name}}"}
|
| 1114 |
+
</p>
|
| 1115 |
+
</div>
|
| 1116 |
+
</div>
|
| 1117 |
+
<div className="flex items-center gap-2">
|
| 1118 |
+
<Button
|
| 1119 |
+
variant="ghost"
|
| 1120 |
+
size="sm"
|
| 1121 |
+
onClick={() => handleRestoreDefault(product.name)}
|
| 1122 |
+
className="text-slate-500 hover:text-slate-700"
|
| 1123 |
+
>
|
| 1124 |
+
<RotateCcw className="mr-1 h-4 w-4" />
|
| 1125 |
+
Restore Default
|
| 1126 |
+
</Button>
|
| 1127 |
+
<Button
|
| 1128 |
+
size="sm"
|
| 1129 |
+
onClick={() => handleSave(product.name)}
|
| 1130 |
+
className="bg-violet-600 hover:bg-violet-700"
|
| 1131 |
+
>
|
| 1132 |
+
{savedStatus[product.name] ? (
|
| 1133 |
+
<>
|
| 1134 |
+
<CheckCircle2 className="mr-1 h-4 w-4" />
|
| 1135 |
+
Saved
|
| 1136 |
+
</>
|
| 1137 |
+
) : (
|
| 1138 |
+
<>
|
| 1139 |
+
<Save className="mr-1 h-4 w-4" />
|
| 1140 |
+
Save
|
| 1141 |
+
</>
|
| 1142 |
+
)}
|
| 1143 |
+
</Button>
|
| 1144 |
+
</div>
|
| 1145 |
+
</>
|
| 1146 |
+
)}
|
| 1147 |
</div>
|
| 1148 |
<div className="p-6">
|
| 1149 |
<Textarea
|
|
|
|
| 1159 |
? 'Enter your LinkedIn sequence system prompt…'
|
| 1160 |
: variant === 'campaign'
|
| 1161 |
? includeLinkedinInCampaign
|
| 1162 |
+
? 'Email content above the divider; LinkedIn below.'
|
| 1163 |
+
: 'Your email generation prompt…'
|
| 1164 |
: 'Enter your email template here...'
|
| 1165 |
}
|
| 1166 |
className={`${
|
|
|
|
| 1178 |
>
|
| 1179 |
{savedStatus[product.name] ? (
|
| 1180 |
<>
|
| 1181 |
+
<CheckCircle2 className="mr-1 h-4 w-4" />
|
| 1182 |
+
Saved
|
| 1183 |
</>
|
| 1184 |
) : (
|
| 1185 |
<>
|
| 1186 |
+
<Save className="mr-1 h-4 w-4" />
|
| 1187 |
+
Save
|
| 1188 |
</>
|
| 1189 |
)}
|
| 1190 |
</Button>
|
frontend/src/lib/mergeSequenceIntoContacts.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
| 1 |
/**
|
| 2 |
* Merge one generated sequence row (email or LinkedIn) into grouped contacts for previews.
|
| 3 |
* When stepOrder is present (campaign wizard), dedupe by stepOrder + channel so merged timelines stay stable.
|
|
|
|
| 4 |
*/
|
| 5 |
export function mergeSequenceIntoContacts(prev, sequence) {
|
| 6 |
const ch = sequence.channel || 'email';
|
| 7 |
-
const
|
| 8 |
(c) =>
|
| 9 |
c.firstName === sequence.firstName &&
|
| 10 |
c.lastName === sequence.lastName &&
|
| 11 |
c.email === sequence.email
|
| 12 |
);
|
|
|
|
| 13 |
const stepOrder =
|
| 14 |
sequence.stepOrder != null && sequence.stepOrder !== undefined ? sequence.stepOrder : null;
|
| 15 |
const step = {
|
|
@@ -22,33 +24,7 @@ export function mergeSequenceIntoContacts(prev, sequence) {
|
|
| 22 |
const dedupeKey =
|
| 23 |
stepOrder != null ? `ord-${stepOrder}-${ch}` : `${ch}-${step.emailNumber}`;
|
| 24 |
|
| 25 |
-
|
| 26 |
-
if (existingContact) {
|
| 27 |
-
if (ch === 'linkedin') {
|
| 28 |
-
if (!existingContact.linkedin) existingContact.linkedin = [];
|
| 29 |
-
if (
|
| 30 |
-
!existingContact.linkedin.some((e) => {
|
| 31 |
-
const k =
|
| 32 |
-
e.stepOrder != null ? `ord-${e.stepOrder}-${ch}` : `${ch}-${e.emailNumber}`;
|
| 33 |
-
return k === dedupeKey;
|
| 34 |
-
})
|
| 35 |
-
) {
|
| 36 |
-
existingContact.linkedin.push(step);
|
| 37 |
-
}
|
| 38 |
-
} else {
|
| 39 |
-
if (!existingContact.emails) existingContact.emails = [];
|
| 40 |
-
if (
|
| 41 |
-
!existingContact.emails.some((e) => {
|
| 42 |
-
const k =
|
| 43 |
-
e.stepOrder != null ? `ord-${e.stepOrder}-${ch}` : `${ch}-${e.emailNumber}`;
|
| 44 |
-
return k === dedupeKey;
|
| 45 |
-
})
|
| 46 |
-
) {
|
| 47 |
-
existingContact.emails.push(step);
|
| 48 |
-
}
|
| 49 |
-
}
|
| 50 |
-
updatedContacts = [...prev];
|
| 51 |
-
} else {
|
| 52 |
const base = {
|
| 53 |
id: sequence.id,
|
| 54 |
firstName: sequence.firstName,
|
|
@@ -65,7 +41,39 @@ export function mergeSequenceIntoContacts(prev, sequence) {
|
|
| 65 |
} else {
|
| 66 |
base.emails = [step];
|
| 67 |
}
|
| 68 |
-
|
| 69 |
}
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
}
|
|
|
|
| 1 |
/**
|
| 2 |
* Merge one generated sequence row (email or LinkedIn) into grouped contacts for previews.
|
| 3 |
* When stepOrder is present (campaign wizard), dedupe by stepOrder + channel so merged timelines stay stable.
|
| 4 |
+
* Always returns new references (immutable) so React re-renders when LinkedIn rows stream in after emails.
|
| 5 |
*/
|
| 6 |
export function mergeSequenceIntoContacts(prev, sequence) {
|
| 7 |
const ch = sequence.channel || 'email';
|
| 8 |
+
const matchIdx = prev.findIndex(
|
| 9 |
(c) =>
|
| 10 |
c.firstName === sequence.firstName &&
|
| 11 |
c.lastName === sequence.lastName &&
|
| 12 |
c.email === sequence.email
|
| 13 |
);
|
| 14 |
+
|
| 15 |
const stepOrder =
|
| 16 |
sequence.stepOrder != null && sequence.stepOrder !== undefined ? sequence.stepOrder : null;
|
| 17 |
const step = {
|
|
|
|
| 24 |
const dedupeKey =
|
| 25 |
stepOrder != null ? `ord-${stepOrder}-${ch}` : `${ch}-${step.emailNumber}`;
|
| 26 |
|
| 27 |
+
if (matchIdx === -1) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
const base = {
|
| 29 |
id: sequence.id,
|
| 30 |
firstName: sequence.firstName,
|
|
|
|
| 41 |
} else {
|
| 42 |
base.emails = [step];
|
| 43 |
}
|
| 44 |
+
return [...prev, base];
|
| 45 |
}
|
| 46 |
+
|
| 47 |
+
const existing = prev[matchIdx];
|
| 48 |
+
const emails = [...(existing.emails || [])];
|
| 49 |
+
const linkedin = [...(existing.linkedin || [])];
|
| 50 |
+
|
| 51 |
+
if (ch === 'linkedin') {
|
| 52 |
+
if (
|
| 53 |
+
!linkedin.some((e) => {
|
| 54 |
+
const k =
|
| 55 |
+
e.stepOrder != null ? `ord-${e.stepOrder}-${ch}` : `${ch}-${e.emailNumber}`;
|
| 56 |
+
return k === dedupeKey;
|
| 57 |
+
})
|
| 58 |
+
) {
|
| 59 |
+
linkedin.push(step);
|
| 60 |
+
}
|
| 61 |
+
} else {
|
| 62 |
+
if (
|
| 63 |
+
!emails.some((e) => {
|
| 64 |
+
const k =
|
| 65 |
+
e.stepOrder != null ? `ord-${e.stepOrder}-${ch}` : `${ch}-${e.emailNumber}`;
|
| 66 |
+
return k === dedupeKey;
|
| 67 |
+
})
|
| 68 |
+
) {
|
| 69 |
+
emails.push(step);
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
const updated = {
|
| 74 |
+
...existing,
|
| 75 |
+
emails,
|
| 76 |
+
linkedin,
|
| 77 |
+
};
|
| 78 |
+
return prev.map((c, i) => (i === matchIdx ? updated : c));
|
| 79 |
}
|