import React, { useState, useEffect, useMemo } from 'react'; 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'; 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 getYoutubeEmbedUrl = (url: string) => { if (!url) return ''; const videoId = getYoutubeVideoId(url); return videoId ? `https://www.youtube.com/embed/${videoId}` : url; }; 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 StarRating = ({ rating, max = 5, onRate }: { rating: number; max?: number; onRate?: (n: number) => void }) => (
{[...Array(max)].map((_, i) => ( onRate && onRate(i + 1)} > {i < rating ? '★' : '☆'} ))}
); 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 { 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 [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 [selectedLanguage] = useState('en'); 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 = () => { 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); } } }); }; 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); setCurrentDescription( translations[selectedLanguage] || translations['en'] || Object.values(translations)[0] as string || '' ); } catch { setCurrentDescription('Description not available.'); } } else { setCurrentDescription('Description not available.'); } }, [plugin, selectedLanguage]); // ── 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) { 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); 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 (err) { console.error('Failed to delete review', err); 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) { console.error('Failed to submit reply', err); alert('Failed to submit reply'); } finally { setReplySubmitting(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); setReportSelected(''); setReportCustom(''); } catch { alert('Failed to submit report. Please try again.'); } finally { setReportSubmitting(false); } }; const resetReport = () => { setReportOpen(false); setReportSelected(''); setReportCustom(''); }; 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(); } catch { alert('Failed to submit report. Please try again.'); } finally { setReviewReportSubmitting(false); } }; const resetReviewReport = () => { setReviewReportOpen(null); setReviewReportSelected(''); setReviewReportCustom(''); }; if (!plugin) return

LOADING...

; 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)`; }; const downloadLabel = (() => { const sizeStr = plugin.file_size ? ` ${formatSize(plugin.file_size)}` : ''; if (plugin.type === 'paid') { if (!ownershipChecked) return 'Loading…'; return ownsPlugin ? `Download ${sizeStr}` : '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 (
{/* ── 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' ? (