@nextsparkjs/plugin-social-media-publisher 0.1.0-beta.1

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.
@@ -0,0 +1,668 @@
1
+ # Content Publishing Workflows
2
+
3
+ ## Overview
4
+
5
+ This guide covers various content publishing workflows using the Social Media Publisher plugin, from simple single-post publishing to advanced batch operations and content management systems.
6
+
7
+ **Covered Workflows:**
8
+ - Single post publishing
9
+ - Batch publishing (cross-posting)
10
+ - Content approval workflows
11
+ - Scheduled publishing
12
+ - Template-based publishing
13
+ - Media management integration
14
+
15
+ ## Single Post Publishing
16
+
17
+ ### Basic Publishing Flow
18
+
19
+ **Scenario:** Publish one image to one Instagram account
20
+
21
+ **React Component:**
22
+ ```typescript
23
+ 'use client'
24
+
25
+ import { useState } from 'react'
26
+ import { toast } from 'sonner'
27
+
28
+ export function SinglePostPublisher({
29
+ accountId,
30
+ platform
31
+ }: {
32
+ accountId: string
33
+ platform: 'instagram_business' | 'facebook_page'
34
+ }) {
35
+ const [imageUrl, setImageUrl] = useState('')
36
+ const [caption, setCaption] = useState('')
37
+ const [loading, setLoading] = useState(false)
38
+
39
+ const handlePublish = async () => {
40
+ // Validation
41
+ if (!imageUrl.startsWith('https://')) {
42
+ toast.error('Image URL must use HTTPS')
43
+ return
44
+ }
45
+
46
+ if (platform === 'instagram_business' && caption.length > 2200) {
47
+ toast.error('Instagram captions must be under 2,200 characters')
48
+ return
49
+ }
50
+
51
+ setLoading(true)
52
+
53
+ try {
54
+ const response = await fetch('/api/v1/plugin/social-media-publisher/social/publish', {
55
+ method: 'POST',
56
+ headers: { 'Content-Type': 'application/json' },
57
+ body: JSON.stringify({
58
+ accountId,
59
+ platform,
60
+ imageUrl,
61
+ caption
62
+ })
63
+ })
64
+
65
+ const result = await response.json()
66
+
67
+ if (result.success) {
68
+ toast.success('Posted successfully!')
69
+ // Open post in new tab
70
+ window.open(result.postUrl, '_blank')
71
+ // Reset form
72
+ setImageUrl('')
73
+ setCaption('')
74
+ } else {
75
+ toast.error(result.error || 'Publishing failed')
76
+ }
77
+ } catch (error) {
78
+ toast.error('Network error')
79
+ } finally {
80
+ setLoading(false)
81
+ }
82
+ }
83
+
84
+ return (
85
+ <div className="max-w-2xl mx-auto p-6">
86
+ <h2 className="text-2xl font-bold mb-6">Publish Post</h2>
87
+
88
+ {/* Image URL Input */}
89
+ <div className="mb-4">
90
+ <label className="block text-sm font-medium mb-2">
91
+ Image URL
92
+ </label>
93
+ <input
94
+ type="url"
95
+ placeholder="https://example.com/image.jpg"
96
+ value={imageUrl}
97
+ onChange={(e) => setImageUrl(e.target.value)}
98
+ className="w-full px-4 py-2 border rounded"
99
+ />
100
+ {imageUrl && (
101
+ <div className="mt-2">
102
+ <img
103
+ src={imageUrl}
104
+ alt="Preview"
105
+ className="max-w-xs rounded"
106
+ onError={() => toast.error('Failed to load image')}
107
+ />
108
+ </div>
109
+ )}
110
+ </div>
111
+
112
+ {/* Caption Input */}
113
+ <div className="mb-4">
114
+ <label className="block text-sm font-medium mb-2">
115
+ Caption
116
+ <span className="text-gray-500 text-xs ml-2">
117
+ {caption.length}/{platform === 'instagram_business' ? '2,200' : '63,206'}
118
+ </span>
119
+ </label>
120
+ <textarea
121
+ placeholder="Write your caption..."
122
+ value={caption}
123
+ onChange={(e) => setCaption(e.target.value)}
124
+ maxLength={platform === 'instagram_business' ? 2200 : 63206}
125
+ rows={6}
126
+ className="w-full px-4 py-2 border rounded"
127
+ />
128
+ <p className="text-xs text-gray-500 mt-1">
129
+ Tip: Use emojis 🎉 and hashtags #instagram for better engagement
130
+ </p>
131
+ </div>
132
+
133
+ {/* Publish Button */}
134
+ <button
135
+ onClick={handlePublish}
136
+ disabled={loading || !imageUrl || !caption}
137
+ className="w-full bg-blue-600 text-white py-3 rounded font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
138
+ >
139
+ {loading ? 'Publishing...' : 'Publish Now'}
140
+ </button>
141
+ </div>
142
+ )
143
+ }
144
+ ```
145
+
146
+ ## Batch Publishing (Cross-Posting)
147
+
148
+ ### Publish to Multiple Accounts
149
+
150
+ **Scenario:** Post same content to 5 different client accounts
151
+
152
+ **Multi-Select Component:**
153
+ ```typescript
154
+ 'use client'
155
+
156
+ import { useState, useEffect } from 'react'
157
+
158
+ export function BatchPublisher() {
159
+ const [clients, setClients] = useState([])
160
+ const [selectedAccounts, setSelectedAccounts] = useState<string[]>([])
161
+ const [imageUrl, setImageUrl] = useState('')
162
+ const [caption, setCaption] = useState('')
163
+ const [publishing, setPublishing] = useState(false)
164
+ const [results, setResults] = useState<any[]>([])
165
+
166
+ useEffect(() => {
167
+ // Fetch clients and their accounts
168
+ fetch('/api/v1/custom/clients-with-accounts')
169
+ .then(r => r.json())
170
+ .then(data => setClients(data.clients))
171
+ }, [])
172
+
173
+ const handlePublish = async () => {
174
+ if (selectedAccounts.length === 0) {
175
+ toast.error('Select at least one account')
176
+ return
177
+ }
178
+
179
+ setPublishing(true)
180
+ setResults([])
181
+
182
+ const response = await fetch('/api/v1/custom/cross-post', {
183
+ method: 'POST',
184
+ headers: { 'Content-Type': 'application/json' },
185
+ body: JSON.stringify({
186
+ accountIds: selectedAccounts,
187
+ imageUrl,
188
+ caption
189
+ })
190
+ })
191
+
192
+ const data = await response.json()
193
+ setResults(data.results)
194
+ setPublishing(false)
195
+
196
+ toast.success(`Published to ${data.successful}/${data.total} accounts`)
197
+ }
198
+
199
+ const toggleAccount = (accountId: string) => {
200
+ setSelectedAccounts(prev =>
201
+ prev.includes(accountId)
202
+ ? prev.filter(id => id !== accountId)
203
+ : [...prev, accountId]
204
+ )
205
+ }
206
+
207
+ return (
208
+ <div className="max-w-4xl mx-auto p-6">
209
+ <h2 className="text-2xl font-bold mb-6">Batch Publishing</h2>
210
+
211
+ <div className="grid grid-cols-2 gap-6">
212
+ {/* Left: Account Selection */}
213
+ <div>
214
+ <h3 className="font-medium mb-4">Select Accounts</h3>
215
+ <div className="border rounded p-4 max-h-96 overflow-y-auto">
216
+ {clients.map((client: any) => (
217
+ <div key={client.id} className="mb-4">
218
+ <p className="font-medium text-sm text-gray-600 mb-2">
219
+ {client.name}
220
+ </p>
221
+ {client.accounts.map((account: any) => (
222
+ <label
223
+ key={account.id}
224
+ className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded cursor-pointer"
225
+ >
226
+ <input
227
+ type="checkbox"
228
+ checked={selectedAccounts.includes(account.id)}
229
+ onChange={() => toggleAccount(account.id)}
230
+ />
231
+ <span className="text-sm">
232
+ {account.platformAccountName}
233
+ <span className="text-xs text-gray-500 ml-1">
234
+ ({account.platform === 'instagram_business' ? 'Instagram' : 'Facebook'})
235
+ </span>
236
+ </span>
237
+ </label>
238
+ ))}
239
+ </div>
240
+ ))}
241
+ </div>
242
+ <p className="text-sm text-gray-600 mt-2">
243
+ {selectedAccounts.length} account(s) selected
244
+ </p>
245
+ </div>
246
+
247
+ {/* Right: Content */}
248
+ <div>
249
+ <h3 className="font-medium mb-4">Content</h3>
250
+
251
+ <div className="mb-4">
252
+ <input
253
+ type="url"
254
+ placeholder="Image URL (https://...)"
255
+ value={imageUrl}
256
+ onChange={(e) => setImageUrl(e.target.value)}
257
+ className="w-full px-4 py-2 border rounded"
258
+ />
259
+ {imageUrl && (
260
+ <img
261
+ src={imageUrl}
262
+ alt="Preview"
263
+ className="mt-2 max-w-full h-48 object-cover rounded"
264
+ />
265
+ )}
266
+ </div>
267
+
268
+ <textarea
269
+ placeholder="Caption (max 2,200 chars for Instagram)"
270
+ value={caption}
271
+ onChange={(e) => setCaption(e.target.value)}
272
+ maxLength={2200}
273
+ rows={8}
274
+ className="w-full px-4 py-2 border rounded mb-4"
275
+ />
276
+
277
+ <button
278
+ onClick={handlePublish}
279
+ disabled={publishing || selectedAccounts.length === 0 || !imageUrl}
280
+ className="w-full bg-blue-600 text-white py-3 rounded font-medium hover:bg-blue-700 disabled:opacity-50"
281
+ >
282
+ {publishing
283
+ ? `Publishing to ${selectedAccounts.length} account(s)...`
284
+ : `Publish to ${selectedAccounts.length} account(s)`
285
+ }
286
+ </button>
287
+ </div>
288
+ </div>
289
+
290
+ {/* Results */}
291
+ {results.length > 0 && (
292
+ <div className="mt-8">
293
+ <h3 className="font-medium mb-4">Results</h3>
294
+ <div className="border rounded divide-y">
295
+ {results.map((result: any, i) => (
296
+ <div key={i} className="p-4 flex justify-between items-center">
297
+ <div>
298
+ <p className="font-medium">{result.accountName}</p>
299
+ <p className="text-sm text-gray-600">
300
+ {result.status === 'published' ? (
301
+ <span className="text-green-600">✓ Published</span>
302
+ ) : (
303
+ <span className="text-red-600">✗ Failed: {result.error}</span>
304
+ )}
305
+ </p>
306
+ </div>
307
+ {result.postUrl && (
308
+ <a
309
+ href={result.postUrl}
310
+ target="_blank"
311
+ className="text-blue-600 text-sm"
312
+ >
313
+ View Post →
314
+ </a>
315
+ )}
316
+ </div>
317
+ ))}
318
+ </div>
319
+ </div>
320
+ )}
321
+ </div>
322
+ )
323
+ }
324
+ ```
325
+
326
+ ## Content Approval Workflow
327
+
328
+ ### 3-Step Approval Process
329
+
330
+ **Flow:** Creator → Manager → Publish
331
+
332
+ **Database Schema:**
333
+ ```sql
334
+ CREATE TABLE "pending_posts" (
335
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
336
+ "accountId" UUID NOT NULL REFERENCES "clients_social_platforms"(id),
337
+ "imageUrl" TEXT NOT NULL,
338
+ caption TEXT,
339
+ status TEXT DEFAULT 'pending_approval', -- 'pending_approval', 'approved', 'rejected', 'published'
340
+ "createdBy" TEXT NOT NULL,
341
+ "approvedBy" TEXT,
342
+ "rejectedBy" TEXT,
343
+ "rejectionReason" TEXT,
344
+ "publishedAt" TIMESTAMPTZ,
345
+ "postId" TEXT,
346
+ "postUrl" TEXT,
347
+ "createdAt" TIMESTAMPTZ DEFAULT now(),
348
+ "updatedAt" TIMESTAMPTZ DEFAULT now()
349
+ );
350
+ ```
351
+
352
+ **Submit for Approval:**
353
+ ```typescript
354
+ // app/actions/submit-for-approval.ts
355
+ 'use server'
356
+
357
+ export async function submitForApproval(data: {
358
+ accountId: string
359
+ imageUrl: string
360
+ caption: string
361
+ }) {
362
+ const session = await auth.api.getSession({ headers: await headers() })
363
+
364
+ const result = await query(`
365
+ INSERT INTO "pending_posts" (
366
+ "accountId", "imageUrl", caption, "createdBy", status
367
+ ) VALUES ($1, $2, $3, $4, 'pending_approval')
368
+ RETURNING id
369
+ `, [data.accountId, data.imageUrl, data.caption, session.user.id])
370
+
371
+ // Notify managers
372
+ await notifyManagers(data.accountId, result.rows[0].id)
373
+
374
+ return { success: true, postId: result.rows[0].id }
375
+ }
376
+ ```
377
+
378
+ **Approval Interface:**
379
+ ```typescript
380
+ // app/dashboard/approvals/page.tsx
381
+ export default async function ApprovalsPage() {
382
+ const pendingPosts = await query(`
383
+ SELECT
384
+ pp.*,
385
+ csp."platformAccountName",
386
+ c.name as "clientName"
387
+ FROM "pending_posts" pp
388
+ JOIN "clients_social_platforms" csp ON csp.id = pp."accountId"
389
+ JOIN "clients" c ON c.id = csp."parentId"
390
+ WHERE pp.status = 'pending_approval'
391
+ ORDER BY pp."createdAt" ASC
392
+ `)
393
+
394
+ return (
395
+ <div>
396
+ <h1>Pending Approvals</h1>
397
+
398
+ {pendingPosts.rows.map(post => (
399
+ <div key={post.id} className="border p-4 mb-4">
400
+ <div className="flex gap-4">
401
+ <img
402
+ src={post.imageUrl}
403
+ alt=""
404
+ className="w-32 h-32 object-cover rounded"
405
+ />
406
+ <div className="flex-1">
407
+ <p className="font-medium">{post.clientName} - {post.platformAccountName}</p>
408
+ <p className="text-sm text-gray-600 mt-2">{post.caption}</p>
409
+ <p className="text-xs text-gray-500 mt-2">
410
+ Submitted by {post.createdBy} on {new Date(post.createdAt).toLocaleString()}
411
+ </p>
412
+ </div>
413
+ </div>
414
+
415
+ <div className="mt-4 flex gap-2">
416
+ <form action={approvePost}>
417
+ <input type="hidden" name="postId" value={post.id} />
418
+ <button
419
+ type="submit"
420
+ className="bg-green-600 text-white px-4 py-2 rounded"
421
+ >
422
+ Approve & Publish
423
+ </button>
424
+ </form>
425
+
426
+ <form action={rejectPost}>
427
+ <input type="hidden" name="postId" value={post.id} />
428
+ <button
429
+ type="submit"
430
+ className="bg-red-600 text-white px-4 py-2 rounded"
431
+ >
432
+ Reject
433
+ </button>
434
+ </form>
435
+ </div>
436
+ </div>
437
+ ))}
438
+ </div>
439
+ )
440
+ }
441
+ ```
442
+
443
+ ## Scheduled Publishing
444
+
445
+ ### Content Calendar
446
+
447
+ **Calendar View:**
448
+ ```typescript
449
+ 'use client'
450
+
451
+ import { Calendar } from '@/components/ui/calendar'
452
+ import { useState } from 'react'
453
+
454
+ export function ContentCalendar() {
455
+ const [selectedDate, setSelectedDate] = useState(new Date())
456
+ const [scheduledPosts, setScheduledPosts] = useState([])
457
+
458
+ useEffect(() => {
459
+ // Fetch scheduled posts for selected month
460
+ fetch(`/api/v1/custom/scheduled-posts?month=${selectedDate.toISOString()}`)
461
+ .then(r => r.json())
462
+ .then(data => setScheduledPosts(data.posts))
463
+ }, [selectedDate])
464
+
465
+ return (
466
+ <div className="grid grid-cols-2 gap-6">
467
+ {/* Calendar */}
468
+ <div>
469
+ <Calendar
470
+ mode="single"
471
+ selected={selectedDate}
472
+ onSelect={setSelectedDate}
473
+ className="rounded border"
474
+ />
475
+ </div>
476
+
477
+ {/* Posts for Selected Date */}
478
+ <div>
479
+ <h3 className="font-medium mb-4">
480
+ Scheduled for {selectedDate.toLocaleDateString()}
481
+ </h3>
482
+
483
+ {scheduledPosts
484
+ .filter(post =>
485
+ new Date(post.scheduledFor).toDateString() === selectedDate.toDateString()
486
+ )
487
+ .map(post => (
488
+ <div key={post.id} className="border p-3 mb-2 rounded">
489
+ <p className="text-sm font-medium">{post.accountName}</p>
490
+ <p className="text-xs text-gray-600">
491
+ {new Date(post.scheduledFor).toLocaleTimeString()}
492
+ </p>
493
+ <p className="text-sm mt-1">
494
+ {post.caption.substring(0, 60)}...
495
+ </p>
496
+ </div>
497
+ ))
498
+ }
499
+
500
+ <button
501
+ onClick={() => openScheduleModal(selectedDate)}
502
+ className="w-full border-2 border-dashed rounded p-4 text-gray-600 hover:border-blue-500 hover:text-blue-600"
503
+ >
504
+ + Schedule Post
505
+ </button>
506
+ </div>
507
+ </div>
508
+ )
509
+ }
510
+ ```
511
+
512
+ ## Template-Based Publishing
513
+
514
+ ### Save and Reuse Templates
515
+
516
+ **Create Template:**
517
+ ```typescript
518
+ export async function saveTemplate(data: {
519
+ name: string
520
+ imageUrl: string
521
+ caption: string
522
+ tags: string[]
523
+ }) {
524
+ const session = await auth.api.getSession({ headers: await headers() })
525
+
526
+ const result = await query(`
527
+ INSERT INTO "post_templates" (
528
+ name, "imageUrl", caption, tags, "createdBy"
529
+ ) VALUES ($1, $2, $3, $4, $5)
530
+ RETURNING id
531
+ `, [data.name, data.imageUrl, data.caption, JSON.stringify(data.tags), session.user.id])
532
+
533
+ return { templateId: result.rows[0].id }
534
+ }
535
+ ```
536
+
537
+ **Template Library:**
538
+ ```typescript
539
+ export function TemplateLibrary() {
540
+ const [templates, setTemplates] = useState([])
541
+
542
+ const useTemplate = async (template: any, accountId: string) => {
543
+ const response = await fetch(`/api/v1/custom/templates/publish/${template.id}`, {
544
+ method: 'POST',
545
+ headers: { 'Content-Type': 'application/json' },
546
+ body: JSON.stringify({ accountId })
547
+ })
548
+
549
+ const result = await response.json()
550
+
551
+ if (result.success) {
552
+ toast.success('Published from template!')
553
+ }
554
+ }
555
+
556
+ return (
557
+ <div className="grid grid-cols-3 gap-4">
558
+ {templates.map(template => (
559
+ <div key={template.id} className="border rounded p-4">
560
+ <img
561
+ src={template.imageUrl}
562
+ alt={template.name}
563
+ className="w-full h-48 object-cover rounded mb-2"
564
+ />
565
+ <p className="font-medium">{template.name}</p>
566
+ <p className="text-sm text-gray-600 line-clamp-2 mt-1">
567
+ {template.caption}
568
+ </p>
569
+ <div className="flex gap-1 mt-2">
570
+ {template.tags.map((tag: string) => (
571
+ <span key={tag} className="text-xs bg-gray-100 px-2 py-1 rounded">
572
+ {tag}
573
+ </span>
574
+ ))}
575
+ </div>
576
+ <button
577
+ onClick={() => openUseTemplateModal(template)}
578
+ className="w-full mt-3 bg-blue-600 text-white py-2 rounded text-sm"
579
+ >
580
+ Use Template
581
+ </button>
582
+ </div>
583
+ ))}
584
+ </div>
585
+ )
586
+ }
587
+ ```
588
+
589
+ ## Media Management Integration
590
+
591
+ ### Upload to CDN Before Publishing
592
+
593
+ ```typescript
594
+ export async function uploadAndPublish(
595
+ file: File,
596
+ accountId: string,
597
+ caption: string
598
+ ) {
599
+ // 1. Upload to CDN
600
+ const formData = new FormData()
601
+ formData.append('file', file)
602
+
603
+ const uploadResponse = await fetch('/api/upload', {
604
+ method: 'POST',
605
+ body: formData
606
+ })
607
+
608
+ const { url: imageUrl } = await uploadResponse.json()
609
+
610
+ // 2. Publish with CDN URL
611
+ const publishResponse = await fetch('/api/v1/plugin/social-media-publisher/social/publish', {
612
+ method: 'POST',
613
+ headers: { 'Content-Type': 'application/json' },
614
+ body: JSON.stringify({
615
+ accountId,
616
+ platform: 'instagram_business',
617
+ imageUrl, // CDN URL
618
+ caption
619
+ })
620
+ })
621
+
622
+ return await publishResponse.json()
623
+ }
624
+ ```
625
+
626
+ ## Best Practices
627
+
628
+ ### Content Validation
629
+
630
+ ```typescript
631
+ function validateContent(
632
+ imageUrl: string,
633
+ caption: string,
634
+ platform: string
635
+ ): { valid: boolean; errors: string[] } {
636
+ const errors: string[] = []
637
+
638
+ // Image URL
639
+ if (!imageUrl.startsWith('https://')) {
640
+ errors.push('Image URL must use HTTPS')
641
+ }
642
+
643
+ // Caption length
644
+ const maxLength = platform === 'instagram_business' ? 2200 : 63206
645
+ if (caption.length > maxLength) {
646
+ errors.push(`Caption too long (max ${maxLength} chars)`)
647
+ }
648
+
649
+ // Instagram hashtag limit
650
+ if (platform === 'instagram_business') {
651
+ const hashtags = caption.match(/#\w+/g) || []
652
+ if (hashtags.length > 30) {
653
+ errors.push('Too many hashtags (max 30 for Instagram)')
654
+ }
655
+ }
656
+
657
+ return {
658
+ valid: errors.length === 0,
659
+ errors
660
+ }
661
+ }
662
+ ```
663
+
664
+ ## Next Steps
665
+
666
+ - **[Agency Management](./01-agency-management.md)** - Multi-client workflows
667
+ - **[Analytics Reporting](./03-analytics-reporting.md)** - Track performance
668
+ - **[Custom Integrations](../03-advanced-usage/02-custom-integrations.md)** - Extend publishing