From 0e948cbf37975b62132963c10109b521af48d88f Mon Sep 17 00:00:00 2001 From: fahed Date: Thu, 5 Mar 2026 15:16:13 +0300 Subject: [PATCH] feat: public review flow for posts (like artefacts) - Token-based public review page at /review-post/:token - Submit for Review button generates shareable link - External reviewers can approve/reject with comments - Approval gate prevents skipping review (superadmin override) - i18n keys for review flow in en + ar Co-Authored-By: Claude Opus 4.6 --- client/src/App.jsx | 2 + client/src/components/PostDetailPanel.jsx | 114 ++++++-- client/src/i18n/ar.json | 15 +- client/src/i18n/en.json | 15 +- client/src/pages/PublicPostReview.jsx | 341 ++++++++++++++++++++++ server/server.js | 169 ++++++++++- 6 files changed, 625 insertions(+), 31 deletions(-) create mode 100644 client/src/pages/PublicPostReview.jsx diff --git a/client/src/App.jsx b/client/src/App.jsx index 769dea7..9ad83e4 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -31,6 +31,7 @@ const Login = lazy(() => import('./pages/Login')) const Artefacts = lazy(() => import('./pages/Artefacts')) const PostCalendar = lazy(() => import('./pages/PostCalendar')) const PublicReview = lazy(() => import('./pages/PublicReview')) +const PublicPostReview = lazy(() => import('./pages/PublicPostReview')) const Issues = lazy(() => import('./pages/Issues')) const PublicIssueSubmit = lazy(() => import('./pages/PublicIssueSubmit')) const PublicIssueTracker = lazy(() => import('./pages/PublicIssueTracker')) @@ -291,6 +292,7 @@ function AppContent() { : } /> : } /> } /> + } /> } /> } /> : }> diff --git a/client/src/components/PostDetailPanel.jsx b/client/src/components/PostDetailPanel.jsx index f06ec24..3e4cbcc 100644 --- a/client/src/components/PostDetailPanel.jsx +++ b/client/src/components/PostDetailPanel.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from 'react' -import { X, Trash2, Upload, FileText, Link2, ExternalLink, FolderOpen, Image as ImageIcon, Music, Film, Send, CheckCircle2, XCircle } from 'lucide-react' +import { X, Trash2, Upload, FileText, Link2, ExternalLink, FolderOpen, Image as ImageIcon, Music, Film, Send, CheckCircle2, XCircle, Copy, Check } from 'lucide-react' import { useLanguage } from '../i18n/LanguageContext' import { api, PLATFORMS, getBrandColor } from '../utils/api' import ApproverMultiSelect from './ApproverMultiSelect' @@ -7,9 +7,11 @@ import CommentsSection from './CommentsSection' import Modal from './Modal' import SlidePanel from './SlidePanel' import CollapsibleSection from './CollapsibleSection' +import { useToast } from './ToastContainer' export default function PostDetailPanel({ post, onClose, onSave, onDelete, brands, teamMembers, campaigns }) { const { t, lang } = useLanguage() + const toast = useToast() const imageInputRef = useRef(null) const audioInputRef = useRef(null) const videoInputRef = useRef(null) @@ -19,6 +21,11 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand const [publishError, setPublishError] = useState('') const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + // Review state + const [submittingReview, setSubmittingReview] = useState(false) + const [reviewUrl, setReviewUrl] = useState('') + const [copied, setCopied] = useState(false) + // Attachments state const [attachments, setAttachments] = useState([]) const [uploading, setUploading] = useState(false) @@ -136,6 +143,31 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand } } + const handleSubmitReview = async () => { + if (!postId || submittingReview) return + // Save pending changes first + if (dirty) await handleSave() + setSubmittingReview(true) + try { + const res = await api.post(`/posts/${postId}/submit-review`) + setReviewUrl(res.reviewUrl || '') + setForm(f => ({ ...f, status: 'in_review' })) + toast.success(t('posts.submittedForReview')) + onSave(postId, {}) // reload parent + } catch (err) { + toast.error(err.message || t('posts.failedSubmitReview')) + } finally { + setSubmittingReview(false) + } + } + + const copyReviewLink = () => { + navigator.clipboard.writeText(reviewUrl) + setCopied(true) + toast.success(t('posts.reviewLinkCopied')) + setTimeout(() => setCopied(false), 2000) + } + const confirmDelete = async () => { setShowDeleteConfirm(false) await onDelete(postId) @@ -403,47 +435,73 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand {!isCreateMode && ( -
+ <> + {/* Submit for Review */} {(form.status === 'draft' || form.status === 'rejected') && ( )} - {form.status === 'in_review' && ( - <> - - - + + {/* Review Link */} + {reviewUrl && ( +
+
{t('posts.reviewLinkTitle')}
+
+ + +
+
)} + + {/* Waiting for review */} + {form.status === 'in_review' && ( +
+

{t('posts.awaitingReview')}

+

{t('posts.awaitingReviewDesc')}

+
+ )} + + {/* Approved info */} + {form.status === 'approved' && post.approved_by_name && ( +
+
+ + {t('posts.approvedBy')} {post.approved_by_name} +
+ {post.feedback &&

{post.feedback}

} +
+ )} + + {/* Rejected info */} + {form.status === 'rejected' && post.approved_by_name && ( +
+
+ + {t('posts.rejectedBy')} {post.approved_by_name} +
+ {post.feedback &&

{post.feedback}

} +
+ )} + + {/* Schedule button after approval */} {form.status === 'approved' && ( )} -
+ )} diff --git a/client/src/i18n/ar.json b/client/src/i18n/ar.json index 4c4db22..ec34753 100644 --- a/client/src/i18n/ar.json +++ b/client/src/i18n/ar.json @@ -889,5 +889,18 @@ "posts.selectApprovers": "اختر المعتمدين...", "posts.scheduling": "الجدولة والتعيين", "posts.content": "المحتوى", - "posts.reject": "رفض" + "posts.reject": "رفض", + "posts.submittedForReview": "تم إرسال المنشور للمراجعة", + "posts.failedSubmitReview": "فشل إرسال المراجعة", + "posts.reviewLinkCopied": "تم نسخ رابط المراجعة!", + "posts.reviewLinkTitle": "رابط المراجعة", + "posts.awaitingReview": "بانتظار المراجعة", + "posts.awaitingReviewDesc": "هذا المنشور بانتظار الموافقة الخارجية.", + "posts.approvedBy": "تمت الموافقة من", + "posts.rejectedBy": "تم الرفض من", + "posts.submitting": "جارٍ الإرسال...", + "posts.submitForReview": "إرسال للمراجعة", + "posts.schedulePost": "جدولة المنشور", + "review.postReview": "مراجعة المنشور", + "review.createdBy": "أنشئ بواسطة" } \ No newline at end of file diff --git a/client/src/i18n/en.json b/client/src/i18n/en.json index 58792ef..974d025 100644 --- a/client/src/i18n/en.json +++ b/client/src/i18n/en.json @@ -889,5 +889,18 @@ "posts.selectApprovers": "Select approvers...", "posts.scheduling": "Scheduling & Assignment", "posts.content": "Content", - "posts.reject": "Reject" + "posts.reject": "Reject", + "posts.submittedForReview": "Post submitted for review", + "posts.failedSubmitReview": "Failed to submit for review", + "posts.reviewLinkCopied": "Review link copied!", + "posts.reviewLinkTitle": "Review Link", + "posts.awaitingReview": "Awaiting Review", + "posts.awaitingReviewDesc": "This post is waiting for external approval.", + "posts.approvedBy": "Approved by", + "posts.rejectedBy": "Rejected by", + "posts.submitting": "Submitting...", + "posts.submitForReview": "Submit for Review", + "posts.schedulePost": "Schedule Post", + "review.postReview": "Post Review", + "review.createdBy": "Created by" } \ No newline at end of file diff --git a/client/src/pages/PublicPostReview.jsx b/client/src/pages/PublicPostReview.jsx new file mode 100644 index 0000000..e4a9cae --- /dev/null +++ b/client/src/pages/PublicPostReview.jsx @@ -0,0 +1,341 @@ +import { useState, useEffect } from 'react' +import { useParams } from 'react-router-dom' +import { CheckCircle, XCircle, FileText, Image as ImageIcon, Film, Music, User, Sparkles } from 'lucide-react' +import { useLanguage } from '../i18n/LanguageContext' +import { useToast } from '../components/ToastContainer' +import Modal from '../components/Modal' + +export default function PublicPostReview() { + const { token } = useParams() + const { t } = useLanguage() + const toast = useToast() + const [post, setPost] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [submitting, setSubmitting] = useState(false) + const [success, setSuccess] = useState('') + const [reviewerName, setReviewerName] = useState('') + const [feedback, setFeedback] = useState('') + const [pendingAction, setPendingAction] = useState(null) + + useEffect(() => { loadPost() }, [token]) + + const loadPost = async () => { + try { + const res = await fetch(`/api/public/review-post/${token}`) + if (!res.ok) { + const err = await res.json() + setError(err.error || t('review.loadFailed')) + setLoading(false) + return + } + const data = await res.json() + setPost(data) + if (data.approvers?.length === 1 && data.approvers[0].name) { + setReviewerName(data.approvers[0].name) + } + } catch { + setError(t('review.loadFailed')) + } finally { + setLoading(false) + } + } + + const handleAction = (action) => { + if (!reviewerName.trim()) { + toast.error(t('review.enterName')) + return + } + setPendingAction(action) + } + + const executeAction = async (action) => { + setSubmitting(true) + try { + const res = await fetch(`/api/public/review-post/${token}/${action}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ approved_by_name: reviewerName, feedback: feedback || undefined }), + }) + if (!res.ok) { + const err = await res.json() + setError(err.error || t('review.actionFailed')) + setSubmitting(false) + return + } + const data = await res.json() + setSuccess(data.message || t('review.actionCompleted')) + setTimeout(() => loadPost(), 1500) + } catch { + setError(t('review.actionFailed')) + } finally { + setSubmitting(false) + } + } + + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+ ) + } + + if (error) { + return ( +
+
+
+ +
+

{t('review.notAvailable')}

+

{error}

+
+
+ ) + } + + if (success) { + return ( +
+
+
+ +
+

{t('review.thankYou')}

+

{success}

+
+
+ ) + } + + if (!post) return null + + const images = (post.attachments || []).filter(a => (a.mime_type || '').startsWith('image/')) + const audio = (post.attachments || []).filter(a => (a.mime_type || '').startsWith('audio/')) + const videos = (post.attachments || []).filter(a => (a.mime_type || '').startsWith('video/')) + const others = (post.attachments || []).filter(a => { + const m = a.mime_type || '' + return !m.startsWith('image/') && !m.startsWith('audio/') && !m.startsWith('video/') + }) + + const platforms = Array.isArray(post.platforms) ? post.platforms : [] + + return ( +
+
+ {/* Header */} +
+
+
+
+ +
+
+

{t('review.postReview')}

+

Samaya Digital Hub

+
+
+
+ +
+ {/* Post Info */} +
+

{post.title}

+ {post.description && ( +

{post.description}

+ )} +
+ {post.brand_name && ( + {post.brand_name} + )} + {platforms.length > 0 && ( + {platforms.join(', ')} + )} + {post.creator_name && {t('review.createdBy')} {post.creator_name}} + {post.scheduled_date && • {new Date(post.scheduled_date).toLocaleDateString()}} +
+
+ + {/* Images */} + {images.length > 0 && ( +
+
+ +

{t('posts.images')}

+
+ +
+ )} + + {/* Videos */} + {videos.length > 0 && ( +
+
+ +

{t('posts.videos')}

+
+
+ {videos.map((att, idx) => ( +
+ {att.original_name && ( +
+ {att.original_name} +
+ )} +
+ ))} +
+
+ )} + + {/* Audio */} + {audio.length > 0 && ( +
+
+ +

{t('posts.audio')}

+
+
+ {audio.map((att, idx) => ( +
+ + {att.original_name} +
+ ))} +
+
+ )} + + {/* Other files */} + {others.length > 0 && ( +
+

{t('posts.otherFiles')}

+ +
+ )} + + {/* Review Form */} + {post.status === 'in_review' && ( +
+

{t('review.yourReview')}

+ +
+
+ + {post.approvers?.length === 1 ? ( +
+ + {post.approvers[0].name} +
+ ) : post.approvers?.length > 1 ? ( + + ) : ( + setReviewerName(e.target.value)} + placeholder={t('review.enterYourName')} + className="w-full px-4 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" /> + )} +
+ +
+ +