feat: public review flow for posts (like artefacts)
All checks were successful
Deploy / deploy (push) Successful in 12s
All checks were successful
Deploy / deploy (push) Successful in 12s
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
</div>
|
||||
|
||||
{!isCreateMode && (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<>
|
||||
{/* Submit for Review */}
|
||||
{(form.status === 'draft' || form.status === 'rejected') && (
|
||||
<button
|
||||
onClick={() => handleStatusAction('in_review')}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-amber-600 text-white rounded-lg text-xs font-medium hover:bg-amber-700 disabled:opacity-50"
|
||||
onClick={handleSubmitReview}
|
||||
disabled={submittingReview}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-colors font-medium text-sm disabled:opacity-50"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
{t('posts.sendToReview')}
|
||||
<Send className="w-4 h-4" />
|
||||
{submittingReview ? t('posts.submitting') : t('posts.sendToReview')}
|
||||
</button>
|
||||
)}
|
||||
{form.status === 'in_review' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleStatusAction('approved')}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-600 text-white rounded-lg text-xs font-medium hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
{t('posts.approve')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStatusAction('rejected')}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-600 text-white rounded-lg text-xs font-medium hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
{t('posts.reject')}
|
||||
</button>
|
||||
</>
|
||||
|
||||
{/* Review Link */}
|
||||
{reviewUrl && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<div className="text-xs font-semibold text-blue-900 mb-2">{t('posts.reviewLinkTitle')}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="text" value={reviewUrl} readOnly className="flex-1 px-3 py-1.5 text-xs bg-white border border-border rounded" />
|
||||
<button onClick={copyReviewLink} className="p-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors">
|
||||
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Waiting for review */}
|
||||
{form.status === 'in_review' && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-center">
|
||||
<p className="text-sm font-medium text-amber-800">{t('posts.awaitingReview')}</p>
|
||||
<p className="text-xs text-amber-600 mt-1">{t('posts.awaitingReviewDesc')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Approved info */}
|
||||
{form.status === 'approved' && post.approved_by_name && (
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
|
||||
<span className="text-sm font-medium text-emerald-800">{t('posts.approvedBy')} {post.approved_by_name}</span>
|
||||
</div>
|
||||
{post.feedback && <p className="text-xs text-emerald-700 mt-1">{post.feedback}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rejected info */}
|
||||
{form.status === 'rejected' && post.approved_by_name && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<XCircle className="w-4 h-4 text-red-600" />
|
||||
<span className="text-sm font-medium text-red-800">{t('posts.rejectedBy')} {post.approved_by_name}</span>
|
||||
</div>
|
||||
{post.feedback && <p className="text-xs text-red-700 mt-1">{post.feedback}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Schedule button after approval */}
|
||||
{form.status === 'approved' && (
|
||||
<button
|
||||
onClick={() => handleStatusAction('scheduled')}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-purple-600 text-white rounded-lg text-xs font-medium hover:bg-purple-700 disabled:opacity-50"
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium text-sm disabled:opacity-50"
|
||||
>
|
||||
{t('posts.schedule')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
Reference in New Issue
Block a user