import React, { useState, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams, useLocation, Link } from 'react-router-dom'; import api from '../api'; import { useAuth } from '../App'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { useNavigate } from 'react-router-dom'; import SEO from '../components/SEO'; import { PluginVideoPlayer } from '../components/PluginVideoPlayer'; import { Sparkles, History, ChevronRight, Star, Check, CheckCircle2, Trash2, Flag, MessageSquare, Clock } from 'lucide-react'; const getYoutubeVideoId = (url: string) => { if (!url) return null; try { if (url.includes('youtu.be/')) { return url.split('youtu.be/')[1].split(/[?#]/)[0]; } else if (url.includes('youtube.com/embed/')) { return url.split('youtube.com/embed/')[1].split(/[?#]/)[0]; } else if (url.includes('youtube.com/shorts/')) { return url.split('youtube.com/shorts/')[1].split(/[?#]/)[0]; } else if (url.includes('v=')) { return url.split('v=')[1].split(/[&?#]/)[0]; } return null; } catch { return null; } }; const REPORT_REASONS = [ 'Malware or malicious code', 'Stolen or plagiarised content', 'Misleading description', 'Broken / non-functional', 'Inappropriate content', 'Spam or fake listing', 'Other', ]; const REVIEW_REPORT_REASONS = [ 'Inappropriate language', 'Spam or advertising', 'Hate speech or harassment', 'Off-topic or irrelevant', 'Other', ]; const MAX_REVIEW_LENGTH = 2000; const safeMarkdownComponents = { a: ({ href, children, ...props }: any) => { const isSafe = typeof href === 'string' && (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('/')); if (!isSafe) { return {children}; } const isExternal = href.startsWith('http://') || href.startsWith('https://'); return ( {children} ); }, }; const StarRating = ({ rating, max = 5, onRate }: { rating: number; max?: number; onRate?: (n: number) => void }) => (
{[...Array(max)].map((_, i) => ( onRate && onRate(i + 1)} style={{ display: 'inline-flex', alignItems: 'center' }} > ))}
); const ExpandableText = ({ text, limit = 500, className, pStyle }: { text: string; limit?: number; className?: string; pStyle?: React.CSSProperties }) => { const [isExpanded, setIsExpanded] = useState(false); const shouldTruncate = text.length > limit; const displayText = isExpanded ? text : text.slice(0, limit) + (shouldTruncate ? '...' : ''); return (

{displayText}

{shouldTruncate && ( )}
); }; const PriceDisplay = ({ cents }: { cents: number }) => { if (!cents && cents !== 0) return null; const euros = Math.floor(cents / 100); const remainder = (cents % 100).toString().padStart(2, '0'); return (
€ {euros} . {remainder}
); }; const getAvatarClass = (username: string) => { const classes = ['av-a', 'av-b', 'av-c', 'av-d', 'av-e']; let hash = 0; for (let i = 0; i < username.length; i++) { hash = username.charCodeAt(i) + ((hash << 5) - hash); } return classes[Math.abs(hash) % classes.length]; }; const getInitials = (username: string) => { const parts = username.trim().split(/[\s_\-\.]+/); if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase(); return username.slice(0, 2).toUpperCase(); }; const formatDate = (dateStr: string) => { const d = new Date(dateStr); const day = d.getDate(); const month = d.toLocaleString('en-US', { month: 'short' }); const year = d.getFullYear(); return `${day} ${month}, ${year}`; }; const PluginDetail = () => { const { t, i18n } = useTranslation(); const { id: rawId, slug } = useParams(); const id = rawId ? rawId.split('-')[0] : rawId; const { user } = useAuth(); const navigate = useNavigate(); const location = useLocation(); const [plugin, setPlugin] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [reviewForm, setReviewForm] = useState({ rating: 5, comment: '' }); const [adShown, setAdShown] = useState(false); const [mainScreenshot, setMainScreenshot] = useState(null); const [lightboxImage, setLightboxImage] = useState(null); const [lightboxIndex, setLightboxIndex] = useState(0); const [currentDescription, setCurrentDescription] = useState(''); // Coupon state const [couponCode, setCouponCode] = useState(''); const [appliedCoupon, setAppliedCoupon] = useState(null); const [couponDiscount, setCouponDiscount] = useState(0); const [couponMessage, setCouponMessage] = useState<{ text: string; isError: boolean } | null>(null); const [validatingCoupon, setValidatingCoupon] = useState(false); const handleApplyCoupon = async () => { if (!couponCode.trim()) return; setValidatingCoupon(true); setCouponMessage(null); try { const res = await api.get(`/plugins/${id}/validate-coupon?code=${couponCode.trim()}`); if (res.data.valid) { setAppliedCoupon(couponCode.trim().toUpperCase()); setCouponDiscount(res.data.discount_percent); setCouponMessage({ text: res.data.message, isError: false }); } else { setAppliedCoupon(null); setCouponDiscount(0); setCouponMessage({ text: res.data.message || 'Invalid coupon code', isError: true }); } } catch { setAppliedCoupon(null); setCouponDiscount(0); setCouponMessage({ text: 'Failed to validate coupon.', isError: true }); } finally { setValidatingCoupon(false); } }; const handleClearCoupon = () => { setCouponCode(''); setAppliedCoupon(null); setCouponDiscount(0); setCouponMessage(null); }; // Ownership const [ownsPlugin, setOwnsPlugin] = useState(false); const [ownershipChecked, setOwnershipChecked] = useState(false); // Payment success banner const [showPaymentSuccess, setShowPaymentSuccess] = useState(false); // Report const [reportOpen, setReportOpen] = useState(false); const [reportSelected, setReportSelected] = useState(''); const [reportCustom, setReportCustom] = useState(''); const [reportSubmitting, setReportSubmitting] = useState(false); const [reportDone, setReportDone] = useState(false); const [changelogOpen, setChangelogOpen] = useState(false); // Review Report State const [reviewReportOpen, setReviewReportOpen] = useState(null); const [reviewReportSelected, setReviewReportSelected] = useState(''); const [reviewReportCustom, setReviewReportCustom] = useState(''); const [reviewReportSubmitting, setReviewReportSubmitting] = useState(false); const [replyOpen, setReplyOpen] = useState(null); const [replyText, setReplyText] = useState(''); const [replySubmitting, setReplySubmitting] = useState(false); const isOwner = user && plugin && user.id === plugin.dev_id; const [reportedReviews, setReportedReviews] = useState>(new Set()); // ── Detect ?payment=success and strip from URL ─────────────────────────── useEffect(() => { const params = new URLSearchParams(location.search); if (params.get('payment') === 'success') { setShowPaymentSuccess(true); window.history.replaceState({}, '', location.pathname); } }, []); // ── Fetch plugin ───────────────────────────────────────────────────────── const fetchPlugin = () => { setLoading(true); setError(null); api.get(`/plugins/${id}`).then(res => { setPlugin(res.data); if (res.data.youtube_video_url) { setMainScreenshot('video'); } else if (res.data.screenshots?.length > 0) { setMainScreenshot(res.data.screenshots[0].path); } // Canonical URL check & replace if (res.data.name) { const expectedSlug = res.data.name.toLowerCase().trim().replace(/[\s_\.\-]+/g, '-').replace(/[^\w\-]+/g, '').replace(/\-\-+/g, '-').replace(/^-+/, '').replace(/-+$/, ''); const targetPath = expectedSlug ? `/plugin/${res.data.id}-${expectedSlug}` : `/plugin/${res.data.id}`; if (location.pathname !== targetPath || slug !== expectedSlug) { window.history.replaceState({}, '', targetPath + location.search); } } }) .catch(err => { console.error('Failed to load plugin:', err); setError('Plugin not found or failed to load.'); }) .finally(() => { setLoading(false); }); }; useEffect(() => { fetchPlugin(); }, [id]); // ── Check ownership ────────────────────────────────────────────────────── useEffect(() => { if (!user || !id) { setOwnershipChecked(true); return; } api.get(`/plugins/${id}/owned`) .then(res => setOwnsPlugin(res.data.owned === true)) .catch(() => setOwnsPlugin(false)) .finally(() => setOwnershipChecked(true)); }, [user, id]); // ── Translations ───────────────────────────────────────────────────────── useEffect(() => { if (plugin?.translated_descriptions) { try { const translations = JSON.parse(plugin.translated_descriptions); const activeLang = (i18n.language || 'en').slice(0, 2); setCurrentDescription( translations[activeLang] || translations['en'] || (Object.values(translations)[0] as string) || '' ); } catch { setCurrentDescription('Description not available.'); } } else if (plugin?.description) { setCurrentDescription(plugin.description); } }, [plugin, i18n.language]); // ── Lightbox helpers ───────────────────────────────────────────────────── const screenshots = plugin?.screenshots ?? []; const openLightbox = (index: number) => { setLightboxIndex(index); setLightboxImage(screenshots[index]?.path ?? null); }; const lightboxPrev = () => { if (!screenshots.length) return; const newIndex = (lightboxIndex - 1 + screenshots.length) % screenshots.length; setLightboxIndex(newIndex); setLightboxImage(screenshots[newIndex].path); }; const lightboxNext = () => { if (!screenshots.length) return; const newIndex = (lightboxIndex + 1) % screenshots.length; setLightboxIndex(newIndex); setLightboxImage(screenshots[newIndex].path); }; const closeLightbox = () => setLightboxImage(null); // ── Keyboard navigation for lightbox ──────────────────────────────────── useEffect(() => { if (!lightboxImage) return; const handleKey = (e: KeyboardEvent) => { if (e.key === 'ArrowLeft') lightboxPrev(); else if (e.key === 'ArrowRight') lightboxNext(); else if (e.key === 'Escape') closeLightbox(); }; window.addEventListener('keydown', handleKey); return () => window.removeEventListener('keydown', handleKey); }, [lightboxImage, lightboxIndex, screenshots]); const hasReviewed = useMemo(() => { if (!user || !plugin?.reviews || !Array.isArray(plugin.reviews)) return false; return plugin.reviews.some((r: any) => r.username === user.username); }, [plugin?.reviews, user]); const stats = useMemo(() => { if (!plugin?.reviews || !Array.isArray(plugin.reviews) || plugin.reviews.length === 0) return null; const total = plugin.reviews.length; const distribution = { 5: 0, 4: 0, 3: 0, 2: 0, 1: 0 }; let sum = 0; plugin.reviews.forEach((r: any) => { distribution[r.rating as keyof typeof distribution]++; sum += r.rating; }); return { avg: (sum / total).toFixed(1), total, distribution }; }, [plugin?.reviews]); // ── Trigger a blob download ────────────────────────────────────────────── const triggerDownload = async (endpoint: string) => { const res = await api.get(endpoint, { responseType: 'blob' }); const url = URL.createObjectURL(res.data); const a = document.createElement('a'); a.href = url; a.download = `${plugin.name.replace(/\s+/g, '_')}.wasm`; a.click(); URL.revokeObjectURL(url); }; // ── Download / purchase handler ────────────────────────────────────────── const handleDownload = async () => { if (!user) return navigate('/login', { state: { from: `/plugins/${id}` } }); if (plugin.type === 'paid') { // Already owns it — download directly, no checkout if (ownsPlugin) { if (plugin.is_preorder) { alert('You have successfully pre-ordered this plugin! You will receive an automated email notification the moment the developer releases the downloadable version.'); return; } try { await triggerDownload(`/plugins/${id}/download/premium`); } catch { alert('Download failed. Please try again.'); } return; } // Does not own — go to Stripe checkout try { const url = appliedCoupon ? `/plugins/${id}/checkout?coupon=${appliedCoupon}` : `/plugins/${id}/checkout`; const res = await api.post(url); if (res.data.url) window.location.href = res.data.url; } catch (err: any) { if (err?.response?.status === 409) { // Webhook already recorded purchase before redirect resolved setOwnsPlugin(true); if (plugin.is_preorder) { alert('You have pre-ordered this plugin! You will receive an email as soon as it releases.'); } else { alert('You already own this plugin — downloading now.'); await triggerDownload(`/plugins/${id}/download/premium`); } } else { alert('Checkout failed. Please try again.'); } } finally { } return; } // Adwall if (plugin.type === 'adwall' && !adShown) { alert('Wait 5 seconds to support the developer...'); setTimeout(() => setAdShown(true), 5000); return; } // Free / adwall (after ad) try { await triggerDownload(`/plugins/${id}/download`); } catch { alert('Download failed. Please try again.'); } }; const submitReview = async (e: React.FormEvent) => { e.preventDefault(); const comment = reviewForm.comment.trim(); if (!comment || comment.length > MAX_REVIEW_LENGTH) { return; } await api.post(`/plugins/${id}/reviews`, reviewForm); fetchPlugin(); setReviewForm({ rating: 5, comment: '' }); }; const deleteReview = async () => { if (!window.confirm('Are you sure you want to delete your review?')) return; try { await api.delete(`/plugins/${id}/reviews`); fetchPlugin(); } catch { alert('Failed to delete review.'); } }; const submitReply = async (e: React.FormEvent, reviewId: number) => { e.preventDefault(); if (!replyText.trim()) return; setReplySubmitting(true); try { await api.post(`/plugins/${id}/reviews/${reviewId}/reply`, { reply: replyText.trim() }); setReplyOpen(null); setReplyText(''); fetchPlugin(); } catch (err: any) { alert(err.response?.data?.error || 'Failed to post reply.'); } finally { setReplySubmitting(false); } }; const resetReport = () => { setReportOpen(false); setReportSelected(''); setReportCustom(''); setReportDone(false); }; const submitReport = async (e: React.FormEvent) => { e.preventDefault(); const reason = reportSelected === 'Other' ? reportCustom.trim() : reportSelected; if (!reason) return; setReportSubmitting(true); try { await api.post(`/plugins/${id}/report`, { reason }); setReportDone(true); } catch { alert('Failed to submit report. Please try again.'); } finally { setReportSubmitting(false); } }; const resetReviewReport = () => { setReviewReportOpen(null); setReviewReportSelected(''); setReviewReportCustom(''); }; const submitReviewReport = async (e: React.FormEvent, reviewId: number) => { e.preventDefault(); const reason = reviewReportSelected === 'Other' ? reviewReportCustom.trim() : reviewReportSelected; if (!reason) return; setReviewReportSubmitting(true); try { await api.post(`/plugins/${id}/reviews/${reviewId}/report`, { reason }); setReportedReviews(prev => new Set(prev).add(reviewId)); resetReviewReport(); alert('Thank you. The report has been submitted to moderators.'); } catch { alert('Failed to report review. Please try again.'); } finally { setReviewReportSubmitting(false); } }; const formatSize = (bytes?: number) => { if (!bytes) return ''; if (bytes >= 1048576) return `(${(bytes / 1048576).toFixed(2)} MB)`; if (bytes >= 1024) return `(${(bytes / 1024).toFixed(1)} KB)`; return `(${bytes} B)`; }; if (loading) { return (

{t('common.loading')}

); } if (error || !plugin) { return (

Plugin Not Found

{error || 'The requested plugin could not be found.'}

{t('common.back')}
); } const downloadLabel = (() => { const sizeStr = plugin?.file_size ? ` ${formatSize(plugin.file_size)}` : ''; if (plugin.type === 'paid') { if (!ownershipChecked) return 'Loading…'; if (ownsPlugin) { if (plugin.is_preorder) { return 'Pre-Ordered (Awaiting Release)'; } return `Download ${sizeStr}`; } if (plugin.is_preorder) { return 'Pre-Order Now'; } return 'Purchase Plugin'; } if (plugin.type === 'adwall' && !adShown) return 'Watch Ad to Download'; return `Download ${sizeStr}`; })(); const cleanDesc = currentDescription.replace(/[#*`_\[\]]/g, '').slice(0, 160).trim(); const productSchema = { "@context": "https://schema.org", "@graph": [ { "@type": "SoftwareApplication", "name": plugin.name, "description": cleanDesc, "operatingSystem": "Minecraft", "applicationCategory": "GameApplication", "softwareVersion": plugin.version || "1.0.0", "fileSize": plugin.file_size ? `${plugin.file_size} B` : undefined, "image": plugin.preview_path || "https://market.pumpkinmc.org/icon.png", "offers": { "@type": "Offer", "price": plugin.type === 'paid' ? (plugin.price_cents / 100).toFixed(2) : "0.00", "priceCurrency": "EUR", "availability": "https://schema.org/InStock" }, "aggregateRating": stats ? { "@type": "AggregateRating", "ratingValue": stats.avg, "ratingCount": stats.total, "bestRating": "5", "worstRating": "1" } : undefined, "author": { "@type": "Person", "name": plugin.dev_name, "url": `https://market.pumpkinmc.org/profile/${plugin.dev_name}` } }, { "@type": "BreadcrumbList", "itemListElement": [ { "@type": "ListItem", "position": 1, "name": "Home", "item": "https://market.pumpkinmc.org" }, { "@type": "ListItem", "position": 2, "name": plugin.category || "Plugins", "item": `https://market.pumpkinmc.org/search?category=${encodeURIComponent(plugin.category || '')}` }, { "@type": "ListItem", "position": 3, "name": plugin.name, "item": `https://market.pumpkinmc.org/plugin/${plugin.id}` } ] } ] }; return (
{/* ── Pre-Order Banner ── */} {plugin.is_preorder && (
Available for Pre-Order {plugin.preorder_release_date && ( Expected: {formatDate(plugin.preorder_release_date)} )}
{ownsPlugin ? ( You have successfully pre-ordered this plugin! Your license key is reserved, and you will receive an automatic email notification the moment the download is released. ) : ( Pre-order now to secure your license key and early access. You will receive an automatic email notification the moment the developer releases the downloadable version. )}
)} {/* ── Early Access Banner ── */} {plugin.is_early_access && (
Early Access Plugin
This plugin is currently in Early Access. It is still under active development and may contain bugs or incomplete features. Downloading it now helps support the developer and shape the future of the plugin!
)} {/* ── Payment success banner ── */} {showPaymentSuccess && (
Purchase successful!
You now own {plugin.name}. Click the download button to get your WASM file.
)} {/* HEADER */}
{!plugin.preview_path && plugin.name.charAt(0)}

{plugin.name}

by (e.currentTarget.style.textDecoration = 'underline')} onMouseLeave={e => (e.currentTarget.style.textDecoration = 'none')}>{plugin.dev_name}

{/* SCREENSHOTS / MEDIA GALLERY */} {((Array.isArray(plugin.screenshots) && plugin.screenshots.length > 0) || plugin.youtube_video_url) && (
{mainScreenshot === 'video' ? ( ) : ( Main { const idx = plugin.screenshots.findIndex((s: any) => s.path === (mainScreenshot || plugin.screenshots[0]?.path)); openLightbox(idx >= 0 ? idx : 0); }} /> )}
{plugin.youtube_video_url && (
setMainScreenshot('video')} style={{ position: 'relative', overflow: 'hidden' }} > {getYoutubeVideoId(plugin.youtube_video_url) ? ( Video Thumbnail ) : (
Video
)}
)} {plugin.screenshots.map((shot: any) => ( Thumbnail setMainScreenshot(shot.path)} /> ))}
)} {Array.isArray(plugin.versions) && plugin.versions.length > 0 && (() => { const latest = plugin.versions[0]; const isLong = Boolean(latest.release_notes && (latest.release_notes.length > 200 || latest.release_notes.split('\n').length > 4)); return (

What's New

v{latest.version} {latest.created_at && ( {formatDate(latest.created_at)} )}
{plugin.versions.length > 1 && ( )}
{latest.release_notes ? ( {latest.release_notes} ) : (

Bug fixes and performance improvements.

)}
{isLong && ( )}
); })()} {/* DESCRIPTION */}
{currentDescription}

{/* REVIEWS */}
Ratings & Reviews {stats ? (
{stats.avg}
{[1, 2, 3, 4, 5].map(n => ( ))}
{stats.total.toLocaleString()} Ratings
{[5, 4, 3, 2, 1].map(num => (
{num}
{stats.distribution[num as keyof typeof stats.distribution]}
))}
) : (
No reviews yet. Be the first to leave one!
)}
{Array.isArray(plugin.reviews) && plugin.reviews.map((r: any) => (
{getInitials(r.username)}
{r.username} {formatDate(r.created_at)}
{[1, 2, 3, 4, 5].map(n => ( ))}
{/* Developer Reply */} {r.developer_reply && (
Developer Response {formatDate(r.replied_at)}
)} {/* Review Report & Reply Buttons */}
{reportedReviews.has(r.id) ? (

Report submitted

) : reviewReportOpen === r.id ? (
submitReviewReport(e, r.id)} className="report-form" style={{ maxWidth: '400px', width: '100%' }}>
{REVIEW_REPORT_REASONS.map(reason => ( ))}
{reviewReportSelected === 'Other' && (