Seth commited on
Commit
c85593f
·
1 Parent(s): af27681
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 = isGenerating || !generationComplete;
 
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
- <div className="mb-2 rounded-2xl border border-slate-200 bg-white p-6">
117
- <div className="mb-4 flex items-center justify-between">
118
- <div className="flex items-center gap-3">
119
- {generationComplete ? (
120
- <div className="rounded-xl bg-green-100 p-3">
121
- <CheckCircle2 className="h-6 w-6 text-green-600" />
122
- </div>
123
- ) : (
124
- <div className="rounded-xl bg-violet-100 p-3">
125
- <Loader2 className="h-6 w-6 animate-spin text-violet-600" />
 
 
 
 
 
 
 
 
 
 
 
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
- {showProgress ? <Progress value={progress} className="h-2" /> : null}
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
- {!isGenerating && contacts.length === 0 && sequences.length === 0 && (
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 while generation runs in the
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 className="border-b border-slate-100 bg-slate-50/50 px-6 py-4 flex items-center justify-between">
1056
- <div className="flex items-center gap-3">
1057
- <div className="rounded-lg bg-violet-100 p-2">
1058
- {variant === 'linkedin' ? (
1059
- <Linkedin className="h-4 w-4 text-violet-600" />
1060
- ) : variant === 'campaign' ? (
1061
- <div className="flex gap-0.5">
1062
- <Sparkles className="h-4 w-4 text-violet-600" />
1063
- <Linkedin className="h-4 w-4 text-sky-600" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1064
  </div>
1065
- ) : (
1066
- <Sparkles className="h-4 w-4 text-violet-600" />
1067
- )}
1068
- </div>
1069
- <div>
1070
- <h4 className="font-semibold text-slate-800">
1071
- {variant === 'linkedin'
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
- </p>
1096
- </div>
1097
- </div>
1098
- <div className="flex items-center gap-2">
1099
- <Button
1100
- variant="ghost"
1101
- size="sm"
1102
- onClick={() => handleRestoreDefault(product.name)}
1103
- className="text-slate-500 hover:text-slate-700"
1104
- >
1105
- <RotateCcw className="h-4 w-4 mr-1" />
1106
- Restore Default Prompt
1107
- </Button>
1108
- <Button
1109
- size="sm"
1110
- onClick={() => handleSave(product.name)}
1111
- className="bg-violet-600 hover:bg-violet-700"
1112
- >
1113
- {savedStatus[product.name] ? (
1114
- <>
1115
- <CheckCircle2 className="h-4 w-4 mr-1" />
1116
- Saved!
1117
- </>
1118
- ) : (
1119
- <>
1120
- <Save className="h-4 w-4 mr-1" />
1121
- Save Template
1122
- </>
1123
- )}
1124
- </Button>
1125
- </div>
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
- ? 'Gmail prompt above the divider, then LinkedIn prompt below'
1142
- : 'Enter your Gmail / email generation prompt…'
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 mr-1" />
1161
- Saved!
1162
  </>
1163
  ) : (
1164
  <>
1165
- <Save className="h-4 w-4 mr-1" />
1166
- Save Template
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 existingContact = prev.find(
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
- let updatedContacts;
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
- updatedContacts = [...prev, base];
69
  }
70
- return updatedContacts;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
  }