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 REPORT_REASONS = [
'Malware or malicious code',
'Stolen or plagiarised content',
'Misleading description',
'Broken / non-functional',
'Inappropriate content',
'Spam or fake listing',
'Other',
];
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 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 PluginDetail = () => {
const { id } = useParams();
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 [selectedLanguage] = useState('en');
const [currentDescription, setCurrentDescription] = useState('');
const [setLoading] = useState(true);
// 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);
// ── 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);
setLoading(false);
if (res.data.screenshots?.length > 0) {
setMainScreenshot(res.data.screenshots[0].path);
}
});
};
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]);
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 (plugin.type === 'paid') {
if (!user) return navigate('/login', { state: { from: `/plugins/${id}` } });
// 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 {
setLoading(true);
const res = await api.post(`/plugins/${id}/checkout`);
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 {
setLoading(false);
}
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();
if (!reviewForm.comment.trim()) return;
await api.post(`/plugins/${id}/reviews`, reviewForm);
fetchPlugin();
setReviewForm({ rating: 5, comment: '' });
};
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('');
};
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 productSchema = {
"@context": "https://schema.org/",
"@type": "SoftwareApplication",
"name": plugin.name,
"operatingSystem": "Minecraft",
"applicationCategory": "GameApplication",
"offers": {
"@type": "Offer",
"price": plugin.type === 'paid' ? (plugin.price_cents / 100).toFixed(2) : "0.00",
"priceCurrency": "EUR"
},
"aggregateRating": stats ? {
"@type": "AggregateRating",
"ratingValue": stats.avg,
"reviewCount": stats.total
} : undefined,
"author": {
"@type": "Person",
"name": plugin.dev_name
}
};
return (
{/* ── 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 {plugin.dev_name}
{/* SCREENSHOTS */}
{Array.isArray(plugin.screenshots) && plugin.screenshots.length > 0 && (
{plugin.screenshots.map((shot: any) => (

setMainScreenshot(shot.path)}
/>
))}
)}
{/* 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}
Verified User
{[1, 2, 3, 4, 5].map(n => (
★
))}
{r.comment}
))}
{user ? (
hasReviewed ? (
✓
Review posted
You've already reviewed this plugin. Thanks for the feedback!
) : (
)
) : (
Please login to rate this plugin.
)}
{/* SIDEBAR */}
);
};
export default PluginDetail;