@growthub/cli 0.3.58 → 0.3.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/assets/worker-kits/growthub-zernio-social-v1/.env.example +5 -0
  2. package/assets/worker-kits/growthub-zernio-social-v1/QUICKSTART.md +36 -4
  3. package/assets/worker-kits/growthub-zernio-social-v1/bundles/growthub-zernio-social-v1.json +30 -1
  4. package/assets/worker-kits/growthub-zernio-social-v1/docs/growthub-agentic-social-platform-ui-shell.md +134 -0
  5. package/assets/worker-kits/growthub-zernio-social-v1/docs/local-adapters.md +2 -2
  6. package/assets/worker-kits/growthub-zernio-social-v1/growthub-meta/README.md +5 -8
  7. package/assets/worker-kits/growthub-zernio-social-v1/growthub-meta/kit-standard.md +1 -1
  8. package/assets/worker-kits/growthub-zernio-social-v1/kit.json +33 -1
  9. package/assets/worker-kits/growthub-zernio-social-v1/skills.md +1 -1
  10. package/assets/worker-kits/growthub-zernio-social-v1/studio/.env.example +3 -0
  11. package/assets/worker-kits/growthub-zernio-social-v1/studio/dist/assets/index-DTmBMuXr.js +78 -0
  12. package/assets/worker-kits/growthub-zernio-social-v1/studio/dist/assets/index-gHr-nTMF.css +1 -0
  13. package/assets/worker-kits/growthub-zernio-social-v1/studio/dist/index.html +14 -0
  14. package/assets/worker-kits/growthub-zernio-social-v1/studio/index.html +13 -0
  15. package/assets/worker-kits/growthub-zernio-social-v1/studio/package-lock.json +1677 -0
  16. package/assets/worker-kits/growthub-zernio-social-v1/studio/package.json +20 -0
  17. package/assets/worker-kits/growthub-zernio-social-v1/studio/serve.mjs +60 -0
  18. package/assets/worker-kits/growthub-zernio-social-v1/studio/src/App.jsx +130 -0
  19. package/assets/worker-kits/growthub-zernio-social-v1/studio/src/api.js +146 -0
  20. package/assets/worker-kits/growthub-zernio-social-v1/studio/src/app.css +558 -0
  21. package/assets/worker-kits/growthub-zernio-social-v1/studio/src/lib/rules.js +64 -0
  22. package/assets/worker-kits/growthub-zernio-social-v1/studio/src/lib/templates.js +207 -0
  23. package/assets/worker-kits/growthub-zernio-social-v1/studio/src/main.jsx +10 -0
  24. package/assets/worker-kits/growthub-zernio-social-v1/studio/src/views/Accounts.jsx +57 -0
  25. package/assets/worker-kits/growthub-zernio-social-v1/studio/src/views/Agent.jsx +167 -0
  26. package/assets/worker-kits/growthub-zernio-social-v1/studio/src/views/Analytics.jsx +164 -0
  27. package/assets/worker-kits/growthub-zernio-social-v1/studio/src/views/ApiKeys.jsx +143 -0
  28. package/assets/worker-kits/growthub-zernio-social-v1/studio/src/views/Automations.jsx +122 -0
  29. package/assets/worker-kits/growthub-zernio-social-v1/studio/src/views/CommentRules.jsx +592 -0
  30. package/assets/worker-kits/growthub-zernio-social-v1/studio/src/views/Compose.jsx +185 -0
  31. package/assets/worker-kits/growthub-zernio-social-v1/studio/src/views/Dashboard.jsx +87 -0
  32. package/assets/worker-kits/growthub-zernio-social-v1/studio/src/views/Inbox.jsx +144 -0
  33. package/assets/worker-kits/growthub-zernio-social-v1/studio/src/views/Queues.jsx +167 -0
  34. package/assets/worker-kits/growthub-zernio-social-v1/studio/src/views/Scheduled.jsx +85 -0
  35. package/assets/worker-kits/growthub-zernio-social-v1/studio/src/views/Sequences.jsx +160 -0
  36. package/assets/worker-kits/growthub-zernio-social-v1/studio/src/views/Templates.jsx +275 -0
  37. package/assets/worker-kits/growthub-zernio-social-v1/studio/vite.config.js +7 -0
  38. package/assets/worker-kits/growthub-zernio-social-v1/workers/zernio-social-operator/CLAUDE.md +3 -3
  39. package/dist/index.js +8541 -850
  40. package/package.json +1 -1
  41. package/assets/worker-kits/growthub-zernio-social-v1/docs/postiz-ui-shell-integration.md +0 -166
@@ -0,0 +1,592 @@
1
+ /**
2
+ * CommentRules — live /api/v1/comment-automations
3
+ *
4
+ * Real Zernio API shape (from docs):
5
+ * POST /api/v1/comment-automations
6
+ * { name, profileId, accountId, platformPostId,
7
+ * keywords?, // comma-separated string — omit to trigger on ALL comments
8
+ * dmMessage, // required — private DM sent to commenter
9
+ * commentReply?, // optional — public reply to the comment
10
+ * isActive? // default true
11
+ * }
12
+ *
13
+ * Constraint: Instagram and Facebook ONLY.
14
+ * Other platforms → 400 "Comment-to-DM automations are only supported on Instagram and Facebook"
15
+ */
16
+ import { useState, useEffect, useCallback } from 'react';
17
+ import { api, PROFILE_ID } from '../api.js';
18
+ import { useApp } from '../App.jsx';
19
+ import { getTemplates, previewTemplate, seedIfEmpty } from '../lib/templates.js';
20
+
21
+ const IG_FB = ['instagram', 'facebook'];
22
+ const PLT_BG = { instagram: '#e1306c', facebook: '#1877f2' };
23
+ const PLT_ICON = { instagram: '📸', facebook: 'f' };
24
+
25
+ const ST = {
26
+ published: { cls: 'badge-green', label: 'Published' },
27
+ scheduled: { cls: 'badge-blue', label: 'Scheduled' },
28
+ draft: { cls: 'badge-neutral', label: 'Draft' },
29
+ };
30
+
31
+ const EMPTY = { name: '', keywords: '', dmMessage: '', commentReply: '', platformPostId: '', accountId: '' };
32
+
33
+ function tplBody(id, sub) {
34
+ const t = getTemplates().find(x => x.id === id);
35
+ if (!t) return '';
36
+ return t.type === 'both' ? (sub === 'reply' ? t.replyBody || '' : t.dmBody || '') : (t.body || '');
37
+ }
38
+
39
+ export default function CommentRules({ onNavigate }) {
40
+ const { accounts, showToast } = useApp();
41
+
42
+ // IG/FB accounts only
43
+ const eligibleAccounts = accounts.filter(a => IG_FB.includes(a.platform));
44
+ const hasEligible = eligibleAccounts.length > 0;
45
+
46
+ const [posts, setPosts] = useState([]);
47
+ const [postsLoading, setPostsLoading] = useState(true);
48
+ const [statusFilter, setStatusFilter] = useState('all');
49
+ const [search, setSearch] = useState('');
50
+ const [selectedPost, setSelectedPost] = useState(null);
51
+
52
+ const [automations, setAutomations] = useState([]);
53
+ const [autoLoading, setAutoLoading] = useState(false);
54
+ const [logs, setLogs] = useState({});
55
+ const [logsOpen, setLogsOpen] = useState(null);
56
+
57
+ const [form, setForm] = useState(EMPTY);
58
+ const [formOpen, setFormOpen] = useState(false);
59
+ const [editingId, setEditingId] = useState(null);
60
+ const [submitting, setSubmitting] = useState(false);
61
+ const [deleting, setDeleting] = useState(null);
62
+
63
+ const [connectUrls, setConnectUrls] = useState({});
64
+ const [templates, setTemplates] = useState([]);
65
+ const [dmTplId, setDmTplId] = useState('');
66
+ const [replyTplId, setReplyTplId] = useState('');
67
+
68
+ useEffect(() => { seedIfEmpty(); setTemplates(getTemplates()); }, []);
69
+
70
+ // Prefetch connect URLs for IG + FB
71
+ useEffect(() => {
72
+ if (!PROFILE_ID) return;
73
+ Promise.all(
74
+ ['instagram', 'facebook'].map(p =>
75
+ api.getConnectUrl(p, PROFILE_ID)
76
+ .then(d => ({ p, url: d.authUrl || d.url }))
77
+ .catch(() => null)
78
+ )
79
+ ).then(results => {
80
+ const map = {};
81
+ results.forEach(r => r && (map[r.p] = r.url));
82
+ setConnectUrls(map);
83
+ });
84
+ }, []);
85
+
86
+ // Load all posts (published + scheduled)
87
+ const loadPosts = useCallback(async () => {
88
+ if (!PROFILE_ID) { setPostsLoading(false); return; }
89
+ setPostsLoading(true);
90
+ try {
91
+ const [sch, pub] = await Promise.allSettled([
92
+ api.getPosts(PROFILE_ID, 'scheduled'),
93
+ api.getPosts(PROFILE_ID, 'published'),
94
+ ]);
95
+ const all = [...(sch.value?.posts || []), ...(pub.value?.posts || [])];
96
+ const seen = new Set();
97
+ const unique = all.filter(p => {
98
+ const k = p._id || p.id;
99
+ if (seen.has(k)) return false;
100
+ seen.add(k);
101
+ return true;
102
+ });
103
+ unique.sort((a, b) => ({ published: 0, scheduled: 1, draft: 2 }[a.status] - ({ published: 0, scheduled: 1, draft: 2 }[b.status] ?? 3)));
104
+ setPosts(unique);
105
+ } catch (e) {
106
+ showToast(e.message, false);
107
+ } finally {
108
+ setPostsLoading(false);
109
+ }
110
+ }, []);
111
+
112
+ useEffect(() => { loadPosts(); }, [loadPosts]);
113
+
114
+ // Load all automations for profile (then filter per post)
115
+ const loadAutomations = useCallback(async (post) => {
116
+ setAutoLoading(true);
117
+ setAutomations([]);
118
+ try {
119
+ const data = await api.getCommentAutomations(PROFILE_ID);
120
+ const all = data.automations || data.data || [];
121
+ const postId = post._id || post.id;
122
+ // Filter by post._id match OR platformPostId match
123
+ setAutomations(all.filter(a =>
124
+ a.postId === postId || a._id === postId ||
125
+ a.platformPostId === (post.platformPostId || postId)
126
+ ));
127
+ } catch (e) {
128
+ showToast(e.message, false);
129
+ } finally {
130
+ setAutoLoading(false);
131
+ }
132
+ }, []);
133
+
134
+ const selectPost = (p) => {
135
+ setSelectedPost(p);
136
+ setFormOpen(false);
137
+ setEditingId(null);
138
+ setForm(EMPTY);
139
+ setDmTplId('');
140
+ setReplyTplId('');
141
+ loadAutomations(p);
142
+ };
143
+
144
+ // Template helpers
145
+ const dmTemplates = templates.filter(t => t.type === 'send_dm' || t.type === 'both');
146
+ const replyTemplates = templates.filter(t => t.type === 'reply_comment' || t.type === 'both');
147
+
148
+ const applyDmTpl = (id) => {
149
+ setDmTplId(id);
150
+ const body = tplBody(id, 'dm');
151
+ if (body) setForm(f => ({ ...f, dmMessage: previewTemplate(body) }));
152
+ };
153
+ const applyReplyTpl = (id) => {
154
+ setReplyTplId(id);
155
+ const body = tplBody(id, 'reply');
156
+ if (body) setForm(f => ({ ...f, commentReply: previewTemplate(body) }));
157
+ };
158
+
159
+ const openNew = () => {
160
+ const firstEligible = eligibleAccounts[0];
161
+ setForm({
162
+ ...EMPTY,
163
+ accountId: firstEligible?._id || '',
164
+ name: selectedPost ? `Comment Rule — ${(selectedPost.content || '').slice(0, 40)}` : '',
165
+ });
166
+ setDmTplId(''); setReplyTplId('');
167
+ setEditingId(null);
168
+ setFormOpen(true);
169
+ };
170
+
171
+ const openEdit = (auto) => {
172
+ setForm({
173
+ name: auto.name || '',
174
+ keywords: Array.isArray(auto.keywords) ? auto.keywords.join(', ') : (auto.keywords || ''),
175
+ dmMessage: auto.dmMessage || '',
176
+ commentReply: auto.commentReply || '',
177
+ platformPostId: auto.platformPostId || '',
178
+ accountId: auto.accountId || eligibleAccounts[0]?._id || '',
179
+ });
180
+ setDmTplId(''); setReplyTplId('');
181
+ setEditingId(auto._id || auto.id);
182
+ setFormOpen(true);
183
+ };
184
+
185
+ const resetForm = () => { setFormOpen(false); setEditingId(null); setForm(EMPTY); setDmTplId(''); setReplyTplId(''); };
186
+
187
+ const submit = async () => {
188
+ if (!form.name.trim()) { showToast('Enter a name', false); return; }
189
+ if (!form.accountId) { showToast('Select an Instagram or Facebook account', false); return; }
190
+ if (!form.platformPostId.trim()) { showToast('Enter the platform post ID / URL', false); return; }
191
+ if (!form.dmMessage.trim()) { showToast('DM message is required', false); return; }
192
+
193
+ const acct = eligibleAccounts.find(a => a._id === form.accountId);
194
+ const body = {
195
+ name: form.name.trim(),
196
+ profileId: PROFILE_ID,
197
+ accountId: form.accountId,
198
+ platformPostId: form.platformPostId.trim(),
199
+ dmMessage: form.dmMessage.trim(),
200
+ };
201
+ if (form.keywords.trim()) body.keywords = form.keywords.trim();
202
+ if (form.commentReply.trim()) body.commentReply = form.commentReply.trim();
203
+
204
+ setSubmitting(true);
205
+ try {
206
+ if (editingId) {
207
+ await api.updateCommentAutomation(editingId, body);
208
+ showToast('Automation updated ✓');
209
+ } else {
210
+ await api.createCommentAutomation(body);
211
+ showToast('Automation created ✓ — live on Zernio!');
212
+ }
213
+ resetForm();
214
+ if (selectedPost) loadAutomations(selectedPost);
215
+ else {
216
+ const all = await api.getCommentAutomations(PROFILE_ID);
217
+ setAutomations(all.automations || []);
218
+ }
219
+ } catch (e) {
220
+ showToast(e.message, false);
221
+ } finally {
222
+ setSubmitting(false);
223
+ }
224
+ };
225
+
226
+ const deleteAuto = async (id) => {
227
+ if (!confirm('Delete this automation? All logs will be deleted.')) return;
228
+ setDeleting(id);
229
+ try {
230
+ await api.deleteCommentAutomation(id);
231
+ showToast('Automation deleted');
232
+ if (selectedPost) loadAutomations(selectedPost);
233
+ } catch (e) {
234
+ showToast(e.message, false);
235
+ } finally {
236
+ setDeleting(null);
237
+ }
238
+ };
239
+
240
+ const toggleActive = async (auto) => {
241
+ try {
242
+ await api.updateCommentAutomation(auto._id || auto.id, { isActive: !auto.isActive });
243
+ showToast(auto.isActive ? 'Paused' : 'Activated ✓');
244
+ if (selectedPost) loadAutomations(selectedPost);
245
+ } catch (e) {
246
+ showToast(e.message, false);
247
+ }
248
+ };
249
+
250
+ const loadLogs = async (id) => {
251
+ if (logsOpen === id) { setLogsOpen(null); return; }
252
+ setLogsOpen(id);
253
+ if (logs[id]) return;
254
+ try {
255
+ const d = await api.getCommentAutomationLogs(id);
256
+ setLogs(l => ({ ...l, [id]: d.logs || d.data || [] }));
257
+ } catch (e) {
258
+ showToast(e.message, false);
259
+ }
260
+ };
261
+
262
+ // All automations across all posts (for overview when no post selected)
263
+ const [allAutos, setAllAutos] = useState([]);
264
+ useEffect(() => {
265
+ if (!PROFILE_ID) return;
266
+ api.getCommentAutomations(PROFILE_ID)
267
+ .then(d => setAllAutos(d.automations || []))
268
+ .catch(() => {});
269
+ }, []);
270
+
271
+ const visiblePosts = posts.filter(p => {
272
+ const sOk = statusFilter === 'all' || p.status === statusFilter;
273
+ const qOk = !search || (p.content || '').toLowerCase().includes(search.toLowerCase());
274
+ return sOk && qOk;
275
+ });
276
+
277
+ // ── Connect required warning ─────────────────────────────────────────────
278
+ if (!hasEligible) {
279
+ return (
280
+ <div>
281
+ <div style={{ background: '#1a0a0a', border: '1px solid #7f1d1d', borderRadius: 10, padding: '20px 24px', marginBottom: 20 }}>
282
+ <div style={{ fontSize: 15, fontWeight: 700, color: '#fca5a5', marginBottom: 8 }}>
283
+ ⚠️ Instagram or Facebook required
284
+ </div>
285
+ <div style={{ fontSize: 13, color: 'var(--dim)', marginBottom: 14, lineHeight: 1.6 }}>
286
+ Comment-to-DM automations only work on <strong>Instagram</strong> and <strong>Facebook</strong>.<br />
287
+ Your current profile has Twitter + LinkedIn — connect Instagram or Facebook first.
288
+ </div>
289
+ <div className="row" style={{ gap: 10 }}>
290
+ {connectUrls.instagram && (
291
+ <a href={connectUrls.instagram} target="_blank" rel="noreferrer" className="btn btn-primary">
292
+ 📸 Connect Instagram
293
+ </a>
294
+ )}
295
+ {connectUrls.facebook && (
296
+ <a href={connectUrls.facebook} target="_blank" rel="noreferrer" className="btn btn-secondary">
297
+ f Connect Facebook
298
+ </a>
299
+ )}
300
+ {!connectUrls.instagram && !connectUrls.facebook && (
301
+ <span style={{ fontSize: 12, color: 'var(--muted)' }}>Loading connect URLs…</span>
302
+ )}
303
+ </div>
304
+ </div>
305
+
306
+ <div style={{ fontSize: 13, color: 'var(--muted)', marginBottom: 16 }}>
307
+ Once connected, you can set up automations like:
308
+ </div>
309
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxWidth: 560 }}>
310
+ {[
311
+ { kw: 'FREE', desc: 'Auto-DM the Winning Ads Playbook to anyone who comments "FREE"' },
312
+ { kw: 'GUIDE', desc: 'Auto-DM the SEO Mastersheet to anyone who comments "GUIDE"' },
313
+ { kw: 'HOOKS', desc: 'Auto-DM the 500+ Hooks library to anyone who comments "HOOKS"' },
314
+ { kw: '—', desc: 'Trigger on ALL comments — DM everyone who engages' },
315
+ ].map((ex, i) => (
316
+ <div key={i} style={{ display: 'flex', gap: 12, alignItems: 'flex-start', padding: '10px 14px', background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8 }}>
317
+ <span className="rule-keyword" style={{ flexShrink: 0 }}>{ex.kw}</span>
318
+ <span style={{ fontSize: 12, color: 'var(--muted)', lineHeight: 1.5 }}>{ex.desc}</span>
319
+ </div>
320
+ ))}
321
+ </div>
322
+ </div>
323
+ );
324
+ }
325
+
326
+ return (
327
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
328
+
329
+ {/* ── All automations overview ─────────────────────────────────── */}
330
+ {allAutos.length > 0 && !selectedPost && (
331
+ <div className="card">
332
+ <div className="section-title mb12">Active Automations ({allAutos.length})</div>
333
+ {allAutos.map(a => (
334
+ <div key={a._id || a.id} className={`rule-card ${a.isActive ? 'active' : 'paused'}`} style={{ marginBottom: 8 }}>
335
+ <div className="rule-header">
336
+ <span className="rule-keyword">{a.keywords || 'ALL COMMENTS'}</span>
337
+ <span style={{ fontSize: 12, color: 'var(--dim)' }}>📸/f {a.platform || ''}</span>
338
+ <span className={`badge ${a.isActive ? 'badge-green' : 'badge-neutral'}`} style={{ marginLeft: 'auto' }}>
339
+ {a.isActive ? 'Active' : 'Paused'}
340
+ </span>
341
+ </div>
342
+ <div style={{ fontSize: 12, color: 'var(--muted)', margin: '4px 0 8px' }}>{a.name}</div>
343
+ <div className="rule-footer">
344
+ <button className="btn btn-ghost btn-xs" onClick={() => loadLogs(a._id || a.id)}>
345
+ {logsOpen === (a._id || a.id) ? 'Hide Logs' : 'Logs'}
346
+ </button>
347
+ <div className="toggle-wrap" onClick={() => toggleActive(a)}>
348
+ <div className={`toggle ${a.isActive ? 'on' : ''}`} />
349
+ <span className="toggle-label" style={{ fontSize: 11 }}>{a.isActive ? 'On' : 'Off'}</span>
350
+ </div>
351
+ <button className="btn btn-ghost btn-xs" onClick={() => { setSelectedPost(null); openEdit(a); }}>Edit</button>
352
+ <button className="btn btn-danger btn-xs" style={{ marginLeft: 'auto' }} onClick={() => deleteAuto(a._id || a.id)} disabled={deleting === (a._id || a.id)}>
353
+ {deleting === (a._id || a.id) ? '…' : 'Delete'}
354
+ </button>
355
+ </div>
356
+ {logsOpen === (a._id || a.id) && (
357
+ <div style={{ marginTop: 10 }}>
358
+ {(logs[a._id || a.id] || []).length === 0
359
+ ? <div style={{ fontSize: 12, color: 'var(--muted)' }}>No triggers yet.</div>
360
+ : (logs[a._id || a.id] || []).slice(0, 10).map((l, i) => (
361
+ <div key={i} style={{ fontSize: 11, padding: '4px 8px', borderRadius: 4, marginBottom: 3, background: '#09090b', display: 'flex', gap: 8 }}>
362
+ <span style={{ color: l.status === 'sent' ? 'var(--greenl)' : 'var(--redl)' }}>●</span>
363
+ <span style={{ color: 'var(--dim)' }}>@{l.username || l.commenterUsername || '?'}</span>
364
+ <span style={{ color: 'var(--muted)' }}>"{l.comment?.slice(0, 40) || '?'}"</span>
365
+ <span style={{ color: 'var(--muted)', marginLeft: 'auto' }}>{l.createdAt ? new Date(l.createdAt).toLocaleTimeString() : ''}</span>
366
+ </div>
367
+ ))}
368
+ </div>
369
+ )}
370
+ </div>
371
+ ))}
372
+ </div>
373
+ )}
374
+
375
+ <div className="cr-layout" style={{ flex: 1 }}>
376
+
377
+ {/* ── LEFT: Post list ──────────────────────────────────────────── */}
378
+ <div className="cr-posts">
379
+ <div className="cr-posts-header">
380
+ <div className="row mb8" style={{ justifyContent: 'space-between' }}>
381
+ <span style={{ fontWeight: 600, fontSize: 13 }}>Posts</span>
382
+ <button className="btn btn-ghost" style={{ fontSize: 11, padding: '3px 8px' }} onClick={loadPosts}>↻</button>
383
+ </div>
384
+ <div className="row mb8" style={{ gap: 5 }}>
385
+ {[['all','All'], ['published','Pub'], ['scheduled','Sched']].map(([v,l]) => (
386
+ <button key={v} className={`filter-btn ${statusFilter === v ? 'active' : ''}`} style={{ fontSize: 11, padding: '3px 9px' }} onClick={() => setStatusFilter(v)}>{l}</button>
387
+ ))}
388
+ </div>
389
+ <input className="input" style={{ fontSize: 12, padding: '7px 10px' }} placeholder="Search…" value={search} onChange={e => setSearch(e.target.value)} />
390
+ </div>
391
+ <div className="cr-posts-list">
392
+ {postsLoading && <div className="loading-row" style={{ padding: 14 }}><span className="spinner" />Loading…</div>}
393
+ {!postsLoading && !visiblePosts.length && (
394
+ <div style={{ padding: '16px', textAlign: 'center', color: 'var(--muted)', fontSize: 12 }}>
395
+ No posts yet. Compose one first.
396
+ </div>
397
+ )}
398
+ {visiblePosts.map(p => {
399
+ const id = p._id || p.id;
400
+ const st = ST[p.status] || ST.draft;
401
+ const active = selectedPost && (selectedPost._id || selectedPost.id) === id;
402
+ return (
403
+ <div key={id} className={`cr-post-item ${active ? 'selected' : ''}`} onClick={() => selectPost(p)}>
404
+ <div className="row mb4" style={{ gap: 5 }}>
405
+ {(p.platforms || []).slice(0, 3).map((pl, i) => (
406
+ <span key={i} style={{ fontSize: 10, fontWeight: 700, background: PLT_BG[pl.platform] || '#3f3f46', color: '#fff', padding: '1px 5px', borderRadius: 4 }}>
407
+ {PLT_ICON[pl.platform] || pl.platform}
408
+ </span>
409
+ ))}
410
+ <span className={`badge ${st.cls}`} style={{ fontSize: 9, padding: '1px 6px', marginLeft: 'auto' }}>{st.label}</span>
411
+ </div>
412
+ <div className="cr-post-preview">{p.content || id}</div>
413
+ {p.scheduledFor && <div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 3 }}>{new Date(p.scheduledFor).toLocaleDateString()}</div>}
414
+ </div>
415
+ );
416
+ })}
417
+ </div>
418
+ </div>
419
+
420
+ {/* ── RIGHT: Automations + form ────────────────────────────────── */}
421
+ <div className="cr-rules">
422
+ <div className="cr-rules-header">
423
+ <div style={{ flex: 1, minWidth: 0 }}>
424
+ {selectedPost ? (
425
+ <>
426
+ <div style={{ fontWeight: 600, fontSize: 13, marginBottom: 2 }}>Comment Automations</div>
427
+ <div style={{ fontSize: 11, color: 'var(--muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
428
+ {(selectedPost.content || '').slice(0, 80)}
429
+ </div>
430
+ </>
431
+ ) : (
432
+ <span style={{ fontSize: 13, color: 'var(--muted)' }}>← Select a post, or use "+ New" for any post</span>
433
+ )}
434
+ </div>
435
+ <button className="btn btn-primary btn-sm" onClick={openNew}>+ New Automation</button>
436
+ </div>
437
+
438
+ <div className="cr-rules-body">
439
+
440
+ {/* ── Inline form ──────────────────────────────────────────── */}
441
+ {formOpen && (
442
+ <div className="rule-form mb16">
443
+ <div style={{ fontWeight: 600, fontSize: 13, marginBottom: 14 }}>
444
+ {editingId ? 'Edit Automation' : '+ New Comment-to-DM Automation'}
445
+ </div>
446
+
447
+ <div className="field">
448
+ <label>Name</label>
449
+ <input className="input" placeholder="e.g. FREE keyword — Ads Playbook"
450
+ value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} />
451
+ </div>
452
+
453
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
454
+ <div className="field" style={{ marginBottom: 0 }}>
455
+ <label>📸 Account (IG / FB only)</label>
456
+ <select className="select" value={form.accountId} onChange={e => setForm(f => ({ ...f, accountId: e.target.value }))}>
457
+ <option value="">— select —</option>
458
+ {eligibleAccounts.map(a => (
459
+ <option key={a._id} value={a._id}>{a.platform} @{a.username || a.displayName}</option>
460
+ ))}
461
+ </select>
462
+ </div>
463
+ <div className="field" style={{ marginBottom: 0 }}>
464
+ <label>Trigger Keywords <span style={{ color: 'var(--muted)', fontWeight: 400 }}>(leave blank = ALL)</span></label>
465
+ <input className="input" placeholder="FREE, GUIDE, LINK — comma-separated"
466
+ value={form.keywords} onChange={e => setForm(f => ({ ...f, keywords: e.target.value }))} />
467
+ </div>
468
+ </div>
469
+
470
+ <div className="field mt12">
471
+ <label>Platform Post ID <span style={{ color: 'var(--muted)', fontWeight: 400 }}>(IG/FB native post ID or URL)</span></label>
472
+ <input className="input" placeholder="e.g. 17846368219941196 or paste the post URL"
473
+ value={form.platformPostId} onChange={e => setForm(f => ({ ...f, platformPostId: e.target.value }))} />
474
+ <div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 4 }}>
475
+ Find this in your IG/FB post URL or Zernio dashboard → Posts → copy the platform post ID
476
+ </div>
477
+ </div>
478
+
479
+ {/* DM template selector */}
480
+ <div className="field">
481
+ <label>📩 DM Message <span style={{ color: 'var(--redl)', fontWeight: 400 }}>*required</span></label>
482
+ {dmTemplates.length > 0 && (
483
+ <div className="row mb8" style={{ gap: 6 }}>
484
+ <select className="select" style={{ flex: 1 }} value={dmTplId}
485
+ onChange={e => applyDmTpl(e.target.value)}>
486
+ <option value="">— load from template —</option>
487
+ {dmTemplates.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
488
+ </select>
489
+ </div>
490
+ )}
491
+ <textarea className="textarea" style={{ minHeight: 90 }}
492
+ placeholder="Hey {{firstName}}! Here's your free guide 👉 https://..."
493
+ value={form.dmMessage} onChange={e => setForm(f => ({ ...f, dmMessage: e.target.value }))} />
494
+ <div className="char-count">{form.dmMessage.length} chars</div>
495
+ </div>
496
+
497
+ {/* Comment reply (optional) */}
498
+ <div className="field">
499
+ <label>💬 Comment Reply <span style={{ color: 'var(--muted)', fontWeight: 400 }}>optional — public reply to the comment</span></label>
500
+ {replyTemplates.length > 0 && (
501
+ <div className="row mb8" style={{ gap: 6 }}>
502
+ <select className="select" style={{ flex: 1 }} value={replyTplId}
503
+ onChange={e => applyReplyTpl(e.target.value)}>
504
+ <option value="">— load from template —</option>
505
+ {replyTemplates.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
506
+ </select>
507
+ </div>
508
+ )}
509
+ <input className="input" placeholder="Check your DMs! 📩"
510
+ value={form.commentReply} onChange={e => setForm(f => ({ ...f, commentReply: e.target.value }))} />
511
+ </div>
512
+
513
+ <div className="row-end">
514
+ <button className="btn btn-ghost btn-sm" onClick={resetForm}>Cancel</button>
515
+ <button className="btn btn-primary btn-sm" onClick={submit} disabled={submitting}>
516
+ {submitting ? <><span className="spinner" style={{ marginRight: 7 }} />Saving…</> : (editingId ? 'Update' : '🚀 Create Automation')}
517
+ </button>
518
+ </div>
519
+ </div>
520
+ )}
521
+
522
+ {/* ── Automations for selected post ────────────────────────── */}
523
+ {selectedPost && autoLoading && <div className="loading-row"><span className="spinner" />Loading…</div>}
524
+ {selectedPost && !autoLoading && !automations.length && !formOpen && (
525
+ <div className="empty" style={{ marginTop: 24 }}>
526
+ <div className="empty-icon" style={{ fontSize: 26 }}>💬</div>
527
+ <div className="empty-msg">No automations for this post yet.</div>
528
+ <button className="btn btn-primary btn-sm" style={{ marginTop: 12 }} onClick={openNew}>+ Add First Automation</button>
529
+ </div>
530
+ )}
531
+
532
+ {automations.map(a => (
533
+ <div key={a._id || a.id} className={`rule-card ${a.isActive ? 'active' : 'paused'}`}>
534
+ <div className="rule-header">
535
+ <span className="rule-keyword">{a.keywords || 'ALL COMMENTS'}</span>
536
+ <span className={`badge ${a.isActive ? 'badge-green' : 'badge-neutral'}`} style={{ marginLeft: 'auto' }}>
537
+ {a.isActive ? 'Active' : 'Paused'}
538
+ </span>
539
+ </div>
540
+ <div className="rule-actions-row">
541
+ {a.commentReply && (
542
+ <div className="rule-action-line">
543
+ <span className="rule-action-icon">💬</span>
544
+ <div style={{ fontSize: 12, color: 'var(--dim)' }}>{a.commentReply}</div>
545
+ </div>
546
+ )}
547
+ <div className="rule-action-line">
548
+ <span className="rule-action-icon">📩</span>
549
+ <div style={{ fontSize: 12, color: 'var(--dim)' }}>{a.dmMessage}</div>
550
+ </div>
551
+ </div>
552
+ {(a.totalTriggers !== undefined || a.successCount !== undefined) && (
553
+ <div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 6 }}>
554
+ {a.totalTriggers ?? 0} triggers · {a.successCount ?? 0} DMs sent
555
+ </div>
556
+ )}
557
+ <div className="rule-footer">
558
+ <button className="btn btn-ghost btn-xs" onClick={() => loadLogs(a._id || a.id)}>
559
+ {logsOpen === (a._id || a.id) ? 'Hide Logs' : 'Logs'}
560
+ </button>
561
+ <div className="toggle-wrap" onClick={() => toggleActive(a)}>
562
+ <div className={`toggle ${a.isActive ? 'on' : ''}`} />
563
+ <span className="toggle-label" style={{ fontSize: 11 }}>{a.isActive ? 'On' : 'Off'}</span>
564
+ </div>
565
+ <button className="btn btn-ghost btn-xs" onClick={() => openEdit(a)}>Edit</button>
566
+ <button className="btn btn-danger btn-xs" style={{ marginLeft: 'auto' }}
567
+ onClick={() => deleteAuto(a._id || a.id)} disabled={deleting === (a._id || a.id)}>
568
+ {deleting === (a._id || a.id) ? '…' : 'Delete'}
569
+ </button>
570
+ </div>
571
+ {logsOpen === (a._id || a.id) && (
572
+ <div style={{ marginTop: 10 }}>
573
+ {(logs[a._id || a.id] || []).length === 0
574
+ ? <div style={{ fontSize: 12, color: 'var(--muted)' }}>No triggers logged yet.</div>
575
+ : (logs[a._id || a.id] || []).slice(0, 20).map((l, i) => (
576
+ <div key={i} style={{ fontSize: 11, padding: '4px 8px', borderRadius: 4, marginBottom: 3, background: '#09090b', display: 'flex', gap: 8 }}>
577
+ <span style={{ color: l.status === 'sent' ? 'var(--greenl)' : 'var(--redl)' }}>●</span>
578
+ <span style={{ color: 'var(--dim)' }}>@{l.username || l.commenterUsername || '?'}</span>
579
+ <span style={{ color: 'var(--muted)' }}>"{(l.comment || '').slice(0, 50)}"</span>
580
+ <span style={{ color: 'var(--muted)', marginLeft: 'auto' }}>{l.createdAt ? new Date(l.createdAt).toLocaleString() : ''}</span>
581
+ </div>
582
+ ))}
583
+ </div>
584
+ )}
585
+ </div>
586
+ ))}
587
+ </div>
588
+ </div>
589
+ </div>
590
+ </div>
591
+ );
592
+ }