Seth commited on
Commit
e016c4b
·
1 Parent(s): ab420b2
backend/app/__pycache__/main.cpython-314.pyc CHANGED
Binary files a/backend/app/__pycache__/main.cpython-314.pyc and b/backend/app/__pycache__/main.cpython-314.pyc differ
 
backend/app/main.py CHANGED
@@ -46,6 +46,21 @@ def _safe_str(value):
46
  return str(value).strip()
47
 
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  def _pick_field(row_dict: Dict, aliases: List[str]) -> str:
50
  lowered = {str(k).strip().lower(): v for k, v in row_dict.items()}
51
  for alias in aliases:
@@ -92,7 +107,7 @@ async def upload_csv(file: UploadFile = File(...), db: Session = Depends(get_db)
92
  # Persist uploaded contacts for CRM/contacts views
93
  for idx, row in df.iterrows():
94
  row_dict = row.to_dict()
95
- sanitized_raw_data = {str(k): _safe_str(v) for k, v in row_dict.items()}
96
  contact = Contact(
97
  file_id=file_id,
98
  row_index=idx + 1,
@@ -117,9 +132,24 @@ async def upload_csv(file: UploadFile = File(...), db: Session = Depends(get_db)
117
  raise HTTPException(status_code=500, detail=f"Error uploading file: {str(e)}")
118
 
119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  @app.get("/api/contacts")
121
  async def list_contacts(
122
  search: str = Query("", description="Search by name/email/company/title"),
 
 
123
  limit: int = Query(200, ge=1, le=1000),
124
  offset: int = Query(0, ge=0),
125
  db: Session = Depends(get_db)
@@ -134,13 +164,34 @@ async def list_contacts(
134
  (Contact.company.ilike(pattern)) |
135
  (Contact.title.ilike(pattern))
136
  )
137
- total = query.count()
138
- contacts = (
139
- query.order_by(Contact.created_at.desc(), Contact.id.desc())
140
- .offset(offset)
141
- .limit(limit)
142
- .all()
143
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  return {
145
  "total": total,
146
  "contacts": [
 
46
  return str(value).strip()
47
 
48
 
49
+ def _json_safe(value):
50
+ if value is None:
51
+ return None
52
+ if isinstance(value, float):
53
+ if math.isnan(value):
54
+ return None
55
+ return value
56
+ # Pandas may return numpy scalar types; use Python-native via json fallback
57
+ try:
58
+ json.dumps(value)
59
+ return value
60
+ except TypeError:
61
+ return _safe_str(value)
62
+
63
+
64
  def _pick_field(row_dict: Dict, aliases: List[str]) -> str:
65
  lowered = {str(k).strip().lower(): v for k, v in row_dict.items()}
66
  for alias in aliases:
 
107
  # Persist uploaded contacts for CRM/contacts views
108
  for idx, row in df.iterrows():
109
  row_dict = row.to_dict()
110
+ sanitized_raw_data = {str(k): _json_safe(v) for k, v in row_dict.items()}
111
  contact = Contact(
112
  file_id=file_id,
113
  row_index=idx + 1,
 
132
  raise HTTPException(status_code=500, detail=f"Error uploading file: {str(e)}")
133
 
134
 
135
+ @app.get("/api/contact-fields")
136
+ async def contact_fields(db: Session = Depends(get_db)):
137
+ """Return all available contact field names from uploaded Apollo rows."""
138
+ contacts = db.query(Contact.raw_data).all()
139
+ fields = set(["first_name", "last_name", "email", "company", "title", "file_id", "created_at"])
140
+ for item in contacts:
141
+ raw = item[0] or {}
142
+ if isinstance(raw, dict):
143
+ for key in raw.keys():
144
+ fields.add(str(key))
145
+ return {"fields": sorted(fields)}
146
+
147
+
148
  @app.get("/api/contacts")
149
  async def list_contacts(
150
  search: str = Query("", description="Search by name/email/company/title"),
151
+ field: str = Query("", description="Optional field name to filter"),
152
+ value: str = Query("", description="Optional field value to filter"),
153
  limit: int = Query(200, ge=1, le=1000),
154
  offset: int = Query(0, ge=0),
155
  db: Session = Depends(get_db)
 
164
  (Contact.company.ilike(pattern)) |
165
  (Contact.title.ilike(pattern))
166
  )
167
+ contacts_all = query.order_by(Contact.created_at.desc(), Contact.id.desc()).all()
168
+
169
+ # Dynamic field filtering for all imported Apollo attributes.
170
+ if field and value:
171
+ v = value.lower()
172
+
173
+ def match_dynamic(c: Contact):
174
+ if field == "first_name":
175
+ return v in (c.first_name or "").lower()
176
+ if field == "last_name":
177
+ return v in (c.last_name or "").lower()
178
+ if field == "email":
179
+ return v in (c.email or "").lower()
180
+ if field == "company":
181
+ return v in (c.company or "").lower()
182
+ if field == "title":
183
+ return v in (c.title or "").lower()
184
+ if field == "file_id":
185
+ return v in (c.file_id or "").lower()
186
+ if field == "created_at":
187
+ return v in ((c.created_at.isoformat() if c.created_at else "").lower())
188
+ raw_val = (c.raw_data or {}).get(field)
189
+ return v in _safe_str(raw_val).lower()
190
+
191
+ contacts_all = [c for c in contacts_all if match_dynamic(c)]
192
+
193
+ total = len(contacts_all)
194
+ contacts = contacts_all[offset:offset + limit]
195
  return {
196
  "total": total,
197
  "contacts": [
frontend/src/components/layout/AppShell.jsx ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { Link, useLocation } from 'react-router-dom';
3
+ import { Zap, LayoutDashboard, Users, History } from 'lucide-react';
4
+ import { Button } from "@/components/ui/button";
5
+
6
+ const NAV_ITEMS = [
7
+ { label: 'Generator', href: '/', icon: LayoutDashboard },
8
+ { label: 'Contacts', href: '/contacts', icon: Users },
9
+ { label: 'Run History', href: '/history', icon: History },
10
+ ];
11
+
12
+ export default function AppShell({ title, subtitle, rightContent, children }) {
13
+ const location = useLocation();
14
+
15
+ return (
16
+ <div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-violet-50">
17
+ <div className="flex min-h-screen">
18
+ <aside className="hidden md:flex w-72 border-r border-slate-200 bg-white/85 backdrop-blur-sm p-4 flex-col gap-6 sticky top-0 h-screen">
19
+ <div className="flex items-center gap-3 px-2 py-1">
20
+ <div className="h-10 w-10 rounded-xl bg-gradient-to-br from-violet-600 to-purple-600 flex items-center justify-center shadow-lg shadow-violet-200">
21
+ <Zap className="h-5 w-5 text-white" />
22
+ </div>
23
+ <div>
24
+ <h1 className="font-bold text-slate-800 text-lg">SequenceAI</h1>
25
+ <p className="text-xs text-slate-500">CRM-ready workspace</p>
26
+ </div>
27
+ </div>
28
+ <nav className="space-y-1">
29
+ {NAV_ITEMS.map((item) => {
30
+ const Icon = item.icon;
31
+ const active = location.pathname === item.href;
32
+ return (
33
+ <Button
34
+ asChild
35
+ key={item.href}
36
+ variant={active ? "default" : "ghost"}
37
+ className={`w-full justify-start ${active ? 'bg-violet-600 hover:bg-violet-700' : 'text-slate-700'}`}
38
+ >
39
+ <Link to={item.href}>
40
+ <Icon className="h-4 w-4 mr-2" />
41
+ {item.label}
42
+ </Link>
43
+ </Button>
44
+ );
45
+ })}
46
+ </nav>
47
+ </aside>
48
+
49
+ <div className="flex-1 min-w-0">
50
+ <header className="border-b border-slate-100 bg-white/80 backdrop-blur-sm sticky top-0 z-40">
51
+ <div className="max-w-6xl mx-auto px-6 py-4">
52
+ <div className="flex items-center justify-between gap-4">
53
+ <div>
54
+ <h2 className="text-xl font-bold text-slate-800">{title}</h2>
55
+ {subtitle && <p className="text-sm text-slate-500">{subtitle}</p>}
56
+ </div>
57
+ <div className="flex items-center gap-2">{rightContent}</div>
58
+ </div>
59
+ <nav className="md:hidden flex items-center gap-2 mt-3">
60
+ {NAV_ITEMS.map((item) => {
61
+ const active = location.pathname === item.href;
62
+ return (
63
+ <Button
64
+ asChild
65
+ key={item.href}
66
+ size="sm"
67
+ variant={active ? "default" : "outline"}
68
+ className={active ? "bg-violet-600 hover:bg-violet-700" : ""}
69
+ >
70
+ <Link to={item.href}>{item.label}</Link>
71
+ </Button>
72
+ );
73
+ })}
74
+ </nav>
75
+ </div>
76
+ </header>
77
+ <main className="max-w-6xl mx-auto px-6 py-8">{children}</main>
78
+ </div>
79
+ </div>
80
+ </div>
81
+ );
82
+ }
frontend/src/pages/Contacts.jsx CHANGED
@@ -1,26 +1,54 @@
1
- import React, { useEffect, useMemo, useState } from 'react';
2
- import { Users, Search, Mail, Building2, Briefcase, FileText } from 'lucide-react';
3
  import { Input } from "@/components/ui/input";
4
- import { Button } from "@/components/ui/button";
5
  import { Badge } from "@/components/ui/badge";
6
- import AppHeader from '@/components/layout/AppHeader';
 
7
 
8
  export default function Contacts() {
9
  const [contacts, setContacts] = useState([]);
 
10
  const [selectedContact, setSelectedContact] = useState(null);
11
  const [sequences, setSequences] = useState([]);
12
  const [loading, setLoading] = useState(true);
13
  const [seqLoading, setSeqLoading] = useState(false);
14
  const [searchQuery, setSearchQuery] = useState('');
 
 
15
 
16
  useEffect(() => {
 
17
  fetchContacts();
18
  }, []);
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  const fetchContacts = async () => {
21
  setLoading(true);
22
  try {
23
- const res = await fetch('/api/contacts?limit=1000');
 
 
 
 
 
 
 
24
  if (res.ok) {
25
  const data = await res.json();
26
  setContacts(data.contacts || []);
@@ -51,120 +79,128 @@ export default function Contacts() {
51
  }
52
  };
53
 
54
- const filteredContacts = useMemo(() => {
55
- const q = searchQuery.trim().toLowerCase();
56
- if (!q) return contacts;
57
- return contacts.filter((c) =>
58
- `${c.first_name} ${c.last_name}`.toLowerCase().includes(q) ||
59
- (c.email || '').toLowerCase().includes(q) ||
60
- (c.company || '').toLowerCase().includes(q) ||
61
- (c.title || '').toLowerCase().includes(q)
62
- );
63
- }, [contacts, searchQuery]);
64
-
65
  return (
66
- <div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-violet-50">
67
- <AppHeader />
68
- <main className="max-w-6xl mx-auto px-6 py-8">
69
- <div className="mb-8">
70
- <h2 className="text-2xl font-bold text-slate-800 mb-2">Contacts</h2>
71
- <p className="text-slate-500">Apollo CSV contacts stored in your local database.</p>
72
- </div>
73
-
74
- <div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
75
- <section className="lg:col-span-2 rounded-2xl border border-slate-200 bg-white p-4">
76
- <div className="relative mb-4">
77
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  <Input
79
- placeholder="Search contacts..."
80
- value={searchQuery}
81
- onChange={(e) => setSearchQuery(e.target.value)}
82
- className="pl-10"
83
  />
84
- </div>
85
- <div className="max-h-[70vh] overflow-y-auto space-y-2 pr-1">
86
- {loading ? (
87
- <p className="text-sm text-slate-500 p-3">Loading contacts...</p>
88
- ) : filteredContacts.length === 0 ? (
89
- <div className="text-center py-12 text-slate-400">
90
- <Users className="h-8 w-8 mx-auto mb-2" />
91
- <p>No contacts found</p>
92
- </div>
93
- ) : (
94
- filteredContacts.map((contact) => {
95
- const active = selectedContact?.id === contact.id;
96
- return (
97
- <button
98
- key={contact.id}
99
- onClick={() => openContact(contact)}
100
- className={`w-full text-left rounded-xl border p-3 transition ${
101
- active
102
- ? 'border-violet-300 bg-violet-50'
103
- : 'border-slate-200 bg-white hover:bg-slate-50'
104
- }`}
105
- >
106
- <div className="font-medium text-slate-800">
107
- {contact.first_name} {contact.last_name}
108
- </div>
109
- <div className="text-xs text-slate-500 truncate">{contact.email || 'No email'}</div>
110
- <div className="text-xs text-slate-400 truncate">{contact.company || 'No company'}</div>
111
- </button>
112
- );
113
- })
114
- )}
115
- </div>
116
- </section>
117
-
118
- <section className="lg:col-span-3 rounded-2xl border border-slate-200 bg-white p-6">
119
- {!selectedContact ? (
120
- <div className="h-full min-h-[300px] flex items-center justify-center text-slate-400">
121
- <div className="text-center">
122
- <FileText className="h-10 w-10 mx-auto mb-2" />
123
- <p>Select a contact to view generated sequences</p>
124
- </div>
125
  </div>
126
  ) : (
127
- <div>
128
- <div className="mb-6">
129
- <h3 className="text-xl font-semibold text-slate-800">
130
- {selectedContact.first_name} {selectedContact.last_name}
131
- </h3>
132
- <div className="mt-2 flex flex-wrap gap-2 text-xs text-slate-600">
133
- <Badge variant="outline" className="gap-1"><Mail className="h-3 w-3" /> {selectedContact.email || 'N/A'}</Badge>
134
- <Badge variant="outline" className="gap-1"><Building2 className="h-3 w-3" /> {selectedContact.company || 'N/A'}</Badge>
135
- <Badge variant="outline" className="gap-1"><Briefcase className="h-3 w-3" /> {selectedContact.title || 'N/A'}</Badge>
136
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  </div>
 
138
 
139
- {seqLoading ? (
140
- <p className="text-sm text-slate-500">Loading sequences...</p>
141
- ) : sequences.length === 0 ? (
142
- <p className="text-sm text-slate-500">No generated sequences found for this contact yet.</p>
143
- ) : (
144
- <div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1">
145
- {sequences.map((seq) => (
146
- <div key={seq.id} className="rounded-xl border border-slate-200 p-4 bg-slate-50/50">
147
- <div className="flex items-center justify-between mb-2">
148
- <div className="font-medium text-slate-800">
149
- Email {seq.email_number} {seq.product ? `• ${seq.product}` : ''}
150
- </div>
151
- </div>
152
- <div className="text-sm mb-2">
153
- <span className="font-semibold text-slate-700">Subject: </span>
154
- <span className="text-slate-700">{seq.subject}</span>
155
- </div>
156
- <div className="rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-700 whitespace-pre-wrap">
157
- {seq.email_content}
158
  </div>
159
  </div>
160
- ))}
161
- </div>
162
- )}
163
- </div>
164
- )}
165
- </section>
166
- </div>
167
- </main>
168
- </div>
 
 
 
 
 
 
 
169
  );
170
  }
 
1
+ import React, { useEffect, useState } from 'react';
2
+ import { Users, Search, Mail, Building2, Briefcase, FileText, SlidersHorizontal } from 'lucide-react';
3
  import { Input } from "@/components/ui/input";
 
4
  import { Badge } from "@/components/ui/badge";
5
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
6
+ import AppShell from '@/components/layout/AppShell';
7
 
8
  export default function Contacts() {
9
  const [contacts, setContacts] = useState([]);
10
+ const [fields, setFields] = useState([]);
11
  const [selectedContact, setSelectedContact] = useState(null);
12
  const [sequences, setSequences] = useState([]);
13
  const [loading, setLoading] = useState(true);
14
  const [seqLoading, setSeqLoading] = useState(false);
15
  const [searchQuery, setSearchQuery] = useState('');
16
+ const [filterField, setFilterField] = useState('none');
17
+ const [filterValue, setFilterValue] = useState('');
18
 
19
  useEffect(() => {
20
+ fetchFields();
21
  fetchContacts();
22
  }, []);
23
 
24
+ useEffect(() => {
25
+ const timer = setTimeout(() => fetchContacts(), 250);
26
+ return () => clearTimeout(timer);
27
+ }, [searchQuery, filterField, filterValue]);
28
+
29
+ const fetchFields = async () => {
30
+ try {
31
+ const res = await fetch('/api/contact-fields');
32
+ if (res.ok) {
33
+ const data = await res.json();
34
+ setFields(data.fields || []);
35
+ }
36
+ } catch (e) {
37
+ console.error('Failed to fetch contact fields:', e);
38
+ }
39
+ };
40
+
41
  const fetchContacts = async () => {
42
  setLoading(true);
43
  try {
44
+ const params = new URLSearchParams();
45
+ params.set('limit', '1000');
46
+ if (searchQuery.trim()) params.set('search', searchQuery.trim());
47
+ if (filterField !== 'none' && filterValue.trim()) {
48
+ params.set('field', filterField);
49
+ params.set('value', filterValue.trim());
50
+ }
51
+ const res = await fetch(`/api/contacts?${params.toString()}`);
52
  if (res.ok) {
53
  const data = await res.json();
54
  setContacts(data.contacts || []);
 
79
  }
80
  };
81
 
 
 
 
 
 
 
 
 
 
 
 
82
  return (
83
+ <AppShell
84
+ title="Contacts"
85
+ subtitle="Apollo CSV contacts stored in SQLite with full field payload."
86
+ >
87
+ <div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
88
+ <section className="lg:col-span-2 rounded-2xl border border-slate-200 bg-white p-4">
89
+ <div className="relative mb-3">
90
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
91
+ <Input
92
+ placeholder="Search contacts..."
93
+ value={searchQuery}
94
+ onChange={(e) => setSearchQuery(e.target.value)}
95
+ className="pl-10"
96
+ />
97
+ </div>
98
+ <div className="grid grid-cols-1 gap-2 mb-4">
99
+ <div className="flex items-center text-xs text-slate-500 gap-1">
100
+ <SlidersHorizontal className="h-3 w-3" />
101
+ Filter by any Apollo field
102
+ </div>
103
+ <Select value={filterField} onValueChange={setFilterField}>
104
+ <SelectTrigger>
105
+ <SelectValue placeholder="Choose field" />
106
+ </SelectTrigger>
107
+ <SelectContent>
108
+ <SelectItem value="none">No field filter</SelectItem>
109
+ {fields.map((field) => (
110
+ <SelectItem key={field} value={field}>{field}</SelectItem>
111
+ ))}
112
+ </SelectContent>
113
+ </Select>
114
+ {filterField !== 'none' && (
115
  <Input
116
+ placeholder={`Contains value for "${filterField}"`}
117
+ value={filterValue}
118
+ onChange={(e) => setFilterValue(e.target.value)}
 
119
  />
120
+ )}
121
+ </div>
122
+ <div className="max-h-[70vh] overflow-y-auto space-y-2 pr-1">
123
+ {loading ? (
124
+ <p className="text-sm text-slate-500 p-3">Loading contacts...</p>
125
+ ) : contacts.length === 0 ? (
126
+ <div className="text-center py-12 text-slate-400">
127
+ <Users className="h-8 w-8 mx-auto mb-2" />
128
+ <p>No contacts found</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  </div>
130
  ) : (
131
+ contacts.map((contact) => {
132
+ const active = selectedContact?.id === contact.id;
133
+ return (
134
+ <button
135
+ key={contact.id}
136
+ onClick={() => openContact(contact)}
137
+ className={`w-full text-left rounded-xl border p-3 transition ${
138
+ active
139
+ ? 'border-violet-300 bg-violet-50'
140
+ : 'border-slate-200 bg-white hover:bg-slate-50'
141
+ }`}
142
+ >
143
+ <div className="font-medium text-slate-800">
144
+ {contact.first_name} {contact.last_name}
145
+ </div>
146
+ <div className="text-xs text-slate-500 truncate">{contact.email || 'No email'}</div>
147
+ <div className="text-xs text-slate-400 truncate">{contact.company || 'No company'}</div>
148
+ </button>
149
+ );
150
+ })
151
+ )}
152
+ </div>
153
+ </section>
154
+
155
+ <section className="lg:col-span-3 rounded-2xl border border-slate-200 bg-white p-6">
156
+ {!selectedContact ? (
157
+ <div className="h-full min-h-[300px] flex items-center justify-center text-slate-400">
158
+ <div className="text-center">
159
+ <FileText className="h-10 w-10 mx-auto mb-2" />
160
+ <p>Select a contact to view generated sequences</p>
161
+ </div>
162
+ </div>
163
+ ) : (
164
+ <div>
165
+ <div className="mb-6">
166
+ <h3 className="text-xl font-semibold text-slate-800">
167
+ {selectedContact.first_name} {selectedContact.last_name}
168
+ </h3>
169
+ <div className="mt-2 flex flex-wrap gap-2 text-xs text-slate-600">
170
+ <Badge variant="outline" className="gap-1"><Mail className="h-3 w-3" /> {selectedContact.email || 'N/A'}</Badge>
171
+ <Badge variant="outline" className="gap-1"><Building2 className="h-3 w-3" /> {selectedContact.company || 'N/A'}</Badge>
172
+ <Badge variant="outline" className="gap-1"><Briefcase className="h-3 w-3" /> {selectedContact.title || 'N/A'}</Badge>
173
  </div>
174
+ </div>
175
 
176
+ {seqLoading ? (
177
+ <p className="text-sm text-slate-500">Loading sequences...</p>
178
+ ) : sequences.length === 0 ? (
179
+ <p className="text-sm text-slate-500">No generated sequences found for this contact yet.</p>
180
+ ) : (
181
+ <div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1">
182
+ {sequences.map((seq) => (
183
+ <div key={seq.id} className="rounded-xl border border-slate-200 p-4 bg-slate-50/50">
184
+ <div className="flex items-center justify-between mb-2">
185
+ <div className="font-medium text-slate-800">
186
+ Email {seq.email_number} {seq.product ? `• ${seq.product}` : ''}
 
 
 
 
 
 
 
 
187
  </div>
188
  </div>
189
+ <div className="text-sm mb-2">
190
+ <span className="font-semibold text-slate-700">Subject: </span>
191
+ <span className="text-slate-700">{seq.subject}</span>
192
+ </div>
193
+ <div className="rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-700 whitespace-pre-wrap">
194
+ {seq.email_content}
195
+ </div>
196
+ </div>
197
+ ))}
198
+ </div>
199
+ )}
200
+ </div>
201
+ )}
202
+ </section>
203
+ </div>
204
+ </AppShell>
205
  );
206
  }
frontend/src/pages/EmailSequenceGenerator.jsx CHANGED
@@ -7,7 +7,7 @@ import UploadStep from '@/components/upload/UploadStep';
7
  import ProductSelector from '@/components/products/ProductSelector';
8
  import PromptEditor from '@/components/prompts/PromptEditor';
9
  import SequenceViewer from '@/components/sequences/SequenceViewer';
10
- import AppHeader from '@/components/layout/AppHeader';
11
 
12
  export default function EmailSequenceGenerator() {
13
  const [step, setStep] = useState(1);
@@ -78,22 +78,21 @@ export default function EmailSequenceGenerator() {
78
  };
79
 
80
  return (
81
- <div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-violet-50">
82
- <AppHeader
83
- rightContent={
84
- step > 1 ? (
85
- <Button
86
- variant="ghost"
87
- onClick={handleReset}
88
- className="text-slate-500 hover:text-slate-700"
89
- >
90
- Start Over
91
- </Button>
92
- ) : null
93
- }
94
- />
95
-
96
- <main className="max-w-6xl mx-auto px-6 py-8">
97
  {/* Progress Steps */}
98
  <div className="mb-10">
99
  <div className="flex items-center justify-center gap-4">
@@ -278,8 +277,6 @@ export default function EmailSequenceGenerator() {
278
  </motion.div>
279
  )}
280
  </AnimatePresence>
281
- </main>
282
-
283
  {/* Footer */}
284
  <footer className="border-t border-slate-100 mt-16">
285
  <div className="max-w-6xl mx-auto px-6 py-6">
@@ -306,6 +303,6 @@ export default function EmailSequenceGenerator() {
306
  background: #94a3b8;
307
  }
308
  `}</style>
309
- </div>
310
  );
311
  }
 
7
  import ProductSelector from '@/components/products/ProductSelector';
8
  import PromptEditor from '@/components/prompts/PromptEditor';
9
  import SequenceViewer from '@/components/sequences/SequenceViewer';
10
+ import AppShell from '@/components/layout/AppShell';
11
 
12
  export default function EmailSequenceGenerator() {
13
  const [step, setStep] = useState(1);
 
78
  };
79
 
80
  return (
81
+ <AppShell
82
+ title="Sequence Generator"
83
+ subtitle="Import Apollo contacts, configure prompts, and generate sequences."
84
+ rightContent={
85
+ step > 1 ? (
86
+ <Button
87
+ variant="ghost"
88
+ onClick={handleReset}
89
+ className="text-slate-500 hover:text-slate-700"
90
+ >
91
+ Start Over
92
+ </Button>
93
+ ) : null
94
+ }
95
+ >
 
96
  {/* Progress Steps */}
97
  <div className="mb-10">
98
  <div className="flex items-center justify-center gap-4">
 
277
  </motion.div>
278
  )}
279
  </AnimatePresence>
 
 
280
  {/* Footer */}
281
  <footer className="border-t border-slate-100 mt-16">
282
  <div className="max-w-6xl mx-auto px-6 py-6">
 
303
  background: #94a3b8;
304
  }
305
  `}</style>
306
+ </AppShell>
307
  );
308
  }
frontend/src/pages/RunHistory.jsx CHANGED
@@ -5,7 +5,7 @@ import { Input } from "@/components/ui/input";
5
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
6
  import { Badge } from "@/components/ui/badge";
7
  import { motion } from 'framer-motion';
8
- import AppHeader from '@/components/layout/AppHeader';
9
 
10
  export default function RunHistory() {
11
  const [runs, setRuns] = useState([]);
@@ -68,10 +68,10 @@ export default function RunHistory() {
68
  };
69
 
70
  return (
71
- <div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-violet-50">
72
- <AppHeader />
73
-
74
- <main className="max-w-6xl mx-auto px-6 py-8">
75
  {/* Filters */}
76
  <div className="mb-6 flex flex-col sm:flex-row gap-3">
77
  <div className="relative flex-1">
@@ -215,7 +215,6 @@ export default function RunHistory() {
215
  ))}
216
  </div>
217
  )}
218
- </main>
219
- </div>
220
  );
221
  }
 
5
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
6
  import { Badge } from "@/components/ui/badge";
7
  import { motion } from 'framer-motion';
8
+ import AppShell from '@/components/layout/AppShell';
9
 
10
  export default function RunHistory() {
11
  const [runs, setRuns] = useState([]);
 
68
  };
69
 
70
  return (
71
+ <AppShell
72
+ title="Run History"
73
+ subtitle="Smartlead campaign push history"
74
+ >
75
  {/* Filters */}
76
  <div className="mb-6 flex flex-col sm:flex-row gap-3">
77
  <div className="relative flex-1">
 
215
  ))}
216
  </div>
217
  )}
218
+ </AppShell>
 
219
  );
220
  }