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:
341
client/src/pages/PublicPostReview.jsx
Normal file
341
client/src/pages/PublicPostReview.jsx
Normal file
@@ -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 (
|
||||
<div className="min-h-screen bg-surface-secondary flex items-center justify-center">
|
||||
<div className="max-w-3xl w-full mx-auto px-4 space-y-6 animate-pulse">
|
||||
<div className="bg-surface rounded-2xl overflow-hidden">
|
||||
<div className="h-24 bg-surface-tertiary" />
|
||||
<div className="p-8 space-y-4">
|
||||
<div className="h-6 bg-surface-tertiary rounded w-2/3" />
|
||||
<div className="h-4 bg-surface-tertiary rounded w-1/2" />
|
||||
<div className="h-32 bg-surface-tertiary rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-secondary flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full bg-surface rounded-2xl shadow-sm p-8 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
|
||||
<XCircle className="w-8 h-8 text-red-600" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-text-primary mb-2">{t('review.notAvailable')}</h2>
|
||||
<p className="text-text-secondary">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-secondary flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full bg-surface rounded-2xl shadow-sm p-8 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-emerald-100 flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle className="w-8 h-8 text-emerald-600" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-text-primary mb-2">{t('review.thankYou')}</h2>
|
||||
<p className="text-text-secondary">{success}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen bg-surface-secondary py-12 px-4">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="bg-surface rounded-2xl shadow-sm overflow-hidden mb-6">
|
||||
<div className="bg-brand-primary px-8 py-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-xl bg-white/20 flex items-center justify-center">
|
||||
<Sparkles className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">{t('review.postReview')}</h1>
|
||||
<p className="text-white/80 text-sm">Samaya Digital Hub</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
{/* Post Info */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-text-primary mb-2">{post.title}</h2>
|
||||
{post.description && (
|
||||
<p className="text-text-secondary whitespace-pre-wrap mb-3">{post.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-sm text-text-tertiary flex-wrap">
|
||||
{post.brand_name && (
|
||||
<span className="px-2 py-0.5 bg-brand-primary/10 text-brand-primary rounded-full text-xs font-medium">{post.brand_name}</span>
|
||||
)}
|
||||
{platforms.length > 0 && (
|
||||
<span className="px-2 py-0.5 bg-surface-tertiary rounded-full text-xs">{platforms.join(', ')}</span>
|
||||
)}
|
||||
{post.creator_name && <span>{t('review.createdBy')} {post.creator_name}</span>}
|
||||
{post.scheduled_date && <span>• {new Date(post.scheduled_date).toLocaleDateString()}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Images */}
|
||||
{images.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<ImageIcon className="w-4 h-4 text-text-tertiary" />
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase">{t('posts.images')}</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{images.map((att, idx) => (
|
||||
<a key={idx} href={att.url} target="_blank" rel="noopener noreferrer"
|
||||
className="block rounded-xl overflow-hidden border-2 border-border hover:border-brand-primary transition-colors shadow-sm">
|
||||
<img src={att.url} alt={att.original_name || `Image ${idx + 1}`} className="w-full h-64 object-cover" />
|
||||
{att.original_name && (
|
||||
<div className="bg-surface-secondary px-4 py-2 border-t border-border">
|
||||
<p className="text-sm text-text-secondary truncate">{att.original_name}</p>
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Videos */}
|
||||
{videos.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Film className="w-4 h-4 text-text-tertiary" />
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase">{t('posts.videos')}</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{videos.map((att, idx) => (
|
||||
<div key={idx} className="bg-surface-secondary rounded-xl overflow-hidden border border-border">
|
||||
{att.original_name && (
|
||||
<div className="px-4 py-2 bg-surface border-b border-border">
|
||||
<span className="text-sm font-medium text-text-secondary">{att.original_name}</span>
|
||||
</div>
|
||||
)}
|
||||
<video src={att.url} controls className="w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audio */}
|
||||
{audio.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Music className="w-4 h-4 text-text-tertiary" />
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase">{t('posts.audio')}</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{audio.map((att, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3 p-3 bg-surface-secondary rounded-xl border border-border">
|
||||
<Music className="w-5 h-5 text-text-tertiary shrink-0" />
|
||||
<span className="text-sm text-text-secondary truncate flex-1">{att.original_name}</span>
|
||||
<audio src={att.url} controls className="h-8 max-w-[200px]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other files */}
|
||||
{others.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">{t('posts.otherFiles')}</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{others.map((att, idx) => (
|
||||
<a key={idx} href={att.url} target="_blank" rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-4 bg-surface-secondary rounded-xl border border-border hover:border-brand-primary transition-colors">
|
||||
<FileText className="w-8 h-8 text-text-tertiary shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{att.original_name}</p>
|
||||
{att.size && <p className="text-xs text-text-tertiary">{(att.size / 1024).toFixed(1)} KB</p>}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Review Form */}
|
||||
{post.status === 'in_review' && (
|
||||
<div className="border-t border-border pt-6">
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-4">{t('review.yourReview')}</h3>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.reviewer')}</label>
|
||||
{post.approvers?.length === 1 ? (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-surface-secondary border border-border rounded-lg">
|
||||
<User className="w-4 h-4 text-text-tertiary" />
|
||||
<span className="text-sm text-text-primary">{post.approvers[0].name}</span>
|
||||
</div>
|
||||
) : post.approvers?.length > 1 ? (
|
||||
<select value={reviewerName} onChange={e => setReviewerName(e.target.value)}
|
||||
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">
|
||||
<option value="">{t('review.selectYourName')}</option>
|
||||
{post.approvers.map(a => <option key={a.id} value={a.name}>{a.name}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input type="text" value={reviewerName} onChange={e => 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" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.feedbackOptional')}</label>
|
||||
<textarea value={feedback} onChange={e => setFeedback(e.target.value)} rows={4}
|
||||
placeholder={t('review.feedbackPlaceholder')}
|
||||
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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<button onClick={() => handleAction('approve')} disabled={submitting}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-emerald-600 text-white rounded-xl font-medium hover:bg-emerald-700 transition-colors disabled:opacity-50 shadow-sm">
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
{t('review.approve')}
|
||||
</button>
|
||||
<button onClick={() => handleAction('reject')} disabled={submitting}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-red-600 text-white rounded-xl font-medium hover:bg-red-700 transition-colors disabled:opacity-50 shadow-sm">
|
||||
<XCircle className="w-5 h-5" />
|
||||
{t('review.reject')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Already Reviewed */}
|
||||
{post.status !== 'in_review' && (
|
||||
<div className="border-t border-border pt-6">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 text-center">
|
||||
<p className="text-blue-900 font-medium">{t('review.alreadyReviewed')}</p>
|
||||
<p className="text-blue-700 text-sm mt-1">
|
||||
{t('review.statusLabel')}: <span className="font-semibold capitalize">{post.status.replace('_', ' ')}</span>
|
||||
</p>
|
||||
{post.approved_by_name && (
|
||||
<p className="text-blue-700 text-sm mt-1">
|
||||
{t('review.reviewedBy')}: <span className="font-semibold">{post.approved_by_name}</span>
|
||||
</p>
|
||||
)}
|
||||
{post.feedback && (
|
||||
<p className="text-blue-700 text-sm mt-2 italic">"{post.feedback}"</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-text-tertiary text-sm">
|
||||
<p>{t('review.poweredBy')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={!!pendingAction}
|
||||
onClose={() => setPendingAction(null)}
|
||||
title={pendingAction === 'approve' ? t('review.confirmApprove') : t('review.confirmReject')}
|
||||
isConfirm
|
||||
danger={pendingAction === 'reject'}
|
||||
onConfirm={() => { const a = pendingAction; setPendingAction(null); executeAction(a) }}
|
||||
confirmText={pendingAction === 'approve' ? t('review.approve') : t('review.reject')}
|
||||
>
|
||||
{pendingAction === 'approve' ? t('review.confirmApproveDesc') : t('review.confirmRejectDesc')}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user