1694 lines
63 KiB
TypeScript
1694 lines
63 KiB
TypeScript
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, Terminal, Copy, Key, ChevronDown, ChevronUp } from 'lucide-react';
|
||
import type { PluginDetail, PluginCommand } from '../types/plugin';
|
||
|
||
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 <span>{children}</span>;
|
||
}
|
||
const isExternal = href.startsWith('http://') || href.startsWith('https://');
|
||
return (
|
||
<a
|
||
href={href}
|
||
target={isExternal ? '_blank' : undefined}
|
||
rel={isExternal ? 'noopener noreferrer nofollow' : undefined}
|
||
{...props}
|
||
>
|
||
{children}
|
||
</a>
|
||
);
|
||
},
|
||
};
|
||
|
||
const StarRating = ({ rating, max = 5, onRate }: { rating: number; max?: number; onRate?: (n: number) => void }) => (
|
||
<div className="star-rating-container" style={{ display: 'flex', gap: 2 }}>
|
||
{[...Array(max)].map((_, i) => (
|
||
<span
|
||
key={i}
|
||
className={`star-icon ${i < rating ? 'filled' : 'empty'} ${onRate ? 'interactive' : ''}`}
|
||
onClick={() => onRate && onRate(i + 1)}
|
||
style={{ display: 'inline-flex', alignItems: 'center' }}
|
||
>
|
||
<Star size={16} fill={i < rating ? 'currentColor' : 'none'} />
|
||
</span>
|
||
))}
|
||
</div>
|
||
);
|
||
|
||
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 (
|
||
<div className={className}>
|
||
<p style={{ margin: 0, whiteSpace: 'pre-wrap', overflowWrap: 'break-word', wordBreak: 'break-word', ...pStyle }}>
|
||
{displayText}
|
||
</p>
|
||
{shouldTruncate && (
|
||
<button
|
||
onClick={() => setIsExpanded(!isExpanded)}
|
||
style={{
|
||
background: 'none',
|
||
border: 'none',
|
||
color: 'var(--primary)',
|
||
padding: 0,
|
||
marginTop: '8px',
|
||
cursor: 'pointer',
|
||
fontSize: '0.85rem',
|
||
fontWeight: 700,
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.02em'
|
||
}}
|
||
>
|
||
{isExpanded ? 'Show Less' : 'Expand'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
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 (
|
||
<div className="price-container">
|
||
<span className="currency-symbol">€</span>
|
||
<span className="price-dollars">{euros}</span>
|
||
<span className="price-decimal">.</span>
|
||
<span className="price-cents">{remainder}</span>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
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 getLocalizedCommandDescription = (
|
||
rawDescription: string | Record<string, string> | undefined | null,
|
||
activeLanguage: string
|
||
): string => {
|
||
if (!rawDescription) return '';
|
||
if (typeof rawDescription === 'object') {
|
||
const full = activeLanguage;
|
||
const short = activeLanguage.slice(0, 2);
|
||
return (
|
||
rawDescription[full] ||
|
||
rawDescription[short] ||
|
||
rawDescription['en-US'] ||
|
||
rawDescription['en'] ||
|
||
rawDescription['en-GB'] ||
|
||
Object.values(rawDescription)[0] ||
|
||
''
|
||
);
|
||
}
|
||
if (typeof rawDescription === 'string') {
|
||
try {
|
||
const parsed = JSON.parse(rawDescription);
|
||
if (parsed && typeof parsed === 'object') {
|
||
return getLocalizedCommandDescription(parsed, activeLanguage);
|
||
}
|
||
return rawDescription;
|
||
} catch {
|
||
return rawDescription;
|
||
}
|
||
}
|
||
return '';
|
||
};
|
||
|
||
const PluginCommandsSidebarWidget = ({ commands }: { commands?: PluginCommand[] }) => {
|
||
const { t, i18n } = useTranslation();
|
||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||
const [expanded, setExpanded] = useState(false);
|
||
|
||
if (!commands || commands.length === 0) return null;
|
||
|
||
const handleCopy = (text: string, id: string) => {
|
||
navigator.clipboard.writeText(text);
|
||
setCopiedId(id);
|
||
setTimeout(() => setCopiedId(null), 2000);
|
||
};
|
||
|
||
const activeLang = i18n.language || 'en';
|
||
const visibleCommands = expanded ? commands : commands.slice(0, 4);
|
||
|
||
return (
|
||
<div className="sidebar-widget commands-widget" style={{ marginTop: '1.25rem', position: 'static' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.85rem' }}>
|
||
<h3 style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '0.45rem', fontSize: '0.82rem' }}>
|
||
<Terminal size={14} color="var(--primary)" />
|
||
{t('plugin.commands.title', 'Commands & Permissions')}
|
||
</h3>
|
||
<span style={{
|
||
fontSize: '0.7rem',
|
||
fontWeight: 700,
|
||
background: 'rgba(255, 255, 255, 0.07)',
|
||
padding: '2px 7px',
|
||
borderRadius: '999px',
|
||
color: 'var(--text-muted)'
|
||
}}>
|
||
{commands.length}
|
||
</span>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||
{visibleCommands.map((cmd: any, index: number) => {
|
||
const desc = getLocalizedCommandDescription(cmd.description, activeLang);
|
||
const cmdCopyKey = `cmd-${cmd.id || index}`;
|
||
const permCopyKey = `perm-${cmd.id || index}`;
|
||
const formattedName = cmd.name?.startsWith('/') ? cmd.name : `/${cmd.name}`;
|
||
|
||
return (
|
||
<div
|
||
key={cmd.id || index}
|
||
style={{
|
||
background: 'rgba(0, 0, 0, 0.25)',
|
||
border: '1px solid var(--border)',
|
||
borderRadius: '8px',
|
||
padding: '0.75rem',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '0.4rem',
|
||
}}
|
||
>
|
||
{/* Command row */}
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
|
||
<code style={{
|
||
fontFamily: 'var(--font-mono)',
|
||
fontSize: '0.82rem',
|
||
fontWeight: 700,
|
||
color: 'var(--primary, #4ade80)',
|
||
wordBreak: 'break-all',
|
||
}}>
|
||
{formattedName}
|
||
</code>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleCopy(formattedName, cmdCopyKey)}
|
||
title={t('plugin.commands.copy_command', 'Copy command')}
|
||
style={{
|
||
background: copiedId === cmdCopyKey ? 'rgba(74, 222, 128, 0.15)' : 'rgba(255, 255, 255, 0.06)',
|
||
border: `1px solid ${copiedId === cmdCopyKey ? 'rgba(74, 222, 128, 0.4)' : 'rgba(255, 255, 255, 0.1)'}`,
|
||
borderRadius: '4px',
|
||
color: copiedId === cmdCopyKey ? 'var(--success, #4ade80)' : 'var(--text-muted)',
|
||
cursor: 'pointer',
|
||
padding: '3px 6px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '3px',
|
||
fontSize: '0.68rem',
|
||
fontWeight: 600,
|
||
transition: 'all 0.15s ease',
|
||
}}
|
||
>
|
||
{copiedId === cmdCopyKey ? (
|
||
<>
|
||
<Check size={11} />
|
||
<span>{t('plugin.commands.copied', 'Copied')}</span>
|
||
</>
|
||
) : (
|
||
<Copy size={11} />
|
||
)}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Permission row */}
|
||
{cmd.permission ? (
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', minWidth: 0 }}>
|
||
<Key size={10} color="var(--text-muted)" style={{ flexShrink: 0 }} />
|
||
<span style={{
|
||
fontSize: '0.7rem',
|
||
fontFamily: 'var(--font-mono)',
|
||
color: 'var(--text-muted)',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
}}>
|
||
{cmd.permission}
|
||
</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleCopy(cmd.permission, permCopyKey)}
|
||
title={t('plugin.commands.copy_permission', 'Copy permission')}
|
||
style={{
|
||
background: 'none',
|
||
border: 'none',
|
||
color: copiedId === permCopyKey ? 'var(--success, #4ade80)' : 'var(--text-muted)',
|
||
cursor: 'pointer',
|
||
padding: '2px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
{copiedId === permCopyKey ? <Check size={11} /> : <Copy size={10} />}
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* Description */}
|
||
{desc ? (
|
||
<p style={{
|
||
margin: '0.15rem 0 0 0',
|
||
fontSize: '0.78rem',
|
||
color: 'var(--text-2, #94a3b8)',
|
||
lineHeight: 1.4,
|
||
}}>
|
||
{desc}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{commands.length > 4 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setExpanded(!expanded)}
|
||
style={{
|
||
width: '100%',
|
||
marginTop: '0.75rem',
|
||
background: 'rgba(255, 255, 255, 0.04)',
|
||
border: '1px solid var(--border)',
|
||
borderRadius: '6px',
|
||
color: 'var(--text)',
|
||
padding: '6px 10px',
|
||
fontSize: '0.75rem',
|
||
fontWeight: 600,
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: '4px',
|
||
transition: 'background 0.15s ease',
|
||
}}
|
||
>
|
||
{expanded ? (
|
||
<>
|
||
<ChevronUp size={13} />
|
||
{t('plugin.commands.show_less', 'Show less')}
|
||
</>
|
||
) : (
|
||
<>
|
||
<ChevronDown size={13} />
|
||
{t('plugin.commands.show_all', 'Show all {{count}} commands', { count: commands.length })}
|
||
</>
|
||
)}
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
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<PluginDetail | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [reviewForm, setReviewForm] = useState({ rating: 5, comment: '' });
|
||
const [adShown, setAdShown] = useState(false);
|
||
const [mainScreenshot, setMainScreenshot] = useState<string | null>(null);
|
||
const [lightboxImage, setLightboxImage] = useState<string | null>(null);
|
||
const [lightboxIndex, setLightboxIndex] = useState<number>(0);
|
||
const [currentDescription, setCurrentDescription] = useState('');
|
||
|
||
// Coupon state
|
||
const [couponCode, setCouponCode] = useState('');
|
||
const [appliedCoupon, setAppliedCoupon] = useState<string | null>(null);
|
||
const [couponDiscount, setCouponDiscount] = useState<number>(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<string>('');
|
||
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<number | null>(null);
|
||
const [reviewReportSelected, setReviewReportSelected] = useState('');
|
||
const [reviewReportCustom, setReviewReportCustom] = useState('');
|
||
const [reviewReportSubmitting, setReviewReportSubmitting] = useState(false);
|
||
|
||
const [replyOpen, setReplyOpen] = useState<number | null>(null);
|
||
const [replyText, setReplyText] = useState('');
|
||
const [replySubmitting, setReplySubmitting] = useState(false);
|
||
|
||
const isOwner = user && plugin && user.id === plugin.dev_id;
|
||
const [reportedReviews, setReportedReviews] = useState<Set<number>>(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 = typeof plugin.translated_descriptions === 'string'
|
||
? JSON.parse(plugin.translated_descriptions)
|
||
: plugin.translated_descriptions;
|
||
const activeLang = (i18n.language || 'en').slice(0, 2);
|
||
setCurrentDescription(
|
||
translations[activeLang] ||
|
||
translations['en'] ||
|
||
translations['en-US'] ||
|
||
(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) => {
|
||
if (!plugin) return;
|
||
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) return;
|
||
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 (
|
||
<div className="container">
|
||
<div className="loading-state" style={{ minHeight: '60vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||
<div className="spinner" />
|
||
<p>{t('common.loading')}</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error || !plugin) {
|
||
return (
|
||
<div className="container">
|
||
<div className="empty-state" style={{ minHeight: '60vh', textAlign: 'center', padding: '4rem 1rem' }}>
|
||
<h2>Plugin Not Found</h2>
|
||
<p style={{ color: 'var(--text-muted)', margin: '1rem 0 1.5rem' }}>
|
||
{error || 'The requested plugin could not be found.'}
|
||
</p>
|
||
<Link to="/" className="btn btn-secondary">
|
||
{t('common.back')}
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 ? (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 (
|
||
<div className="container">
|
||
<SEO
|
||
title={plugin.name}
|
||
description={cleanDesc || `Download ${plugin.name} by ${plugin.dev_name} on Pumpkin Market.`}
|
||
keywords={`${plugin.name}, minecraft plugin, ${plugin.category || ''}, ${plugin.keywords || ''}`}
|
||
ogType="product"
|
||
ogImage={plugin.preview_path || "/icon.png"}
|
||
/>
|
||
<script type="application/ld+json">
|
||
{JSON.stringify(productSchema)}
|
||
</script>
|
||
|
||
{/* ── Pre-Order Banner ── */}
|
||
{plugin.is_preorder && (
|
||
<div style={{
|
||
marginBottom: '1.5rem',
|
||
padding: '1.25rem 1.5rem',
|
||
background: 'linear-gradient(135deg, rgba(249,115,22,0.12) 0%, rgba(249,115,22,0.04) 100%)',
|
||
border: '1px solid rgba(249,115,22,0.35)',
|
||
borderRadius: 12,
|
||
display: 'flex',
|
||
alignItems: 'flex-start',
|
||
gap: '1rem',
|
||
boxShadow: '0 4px 20px rgba(249,115,22,0.08)'
|
||
}}>
|
||
<div style={{
|
||
width: 38,
|
||
height: 38,
|
||
borderRadius: 8,
|
||
background: 'rgba(249,115,22,0.2)',
|
||
color: '#f97316',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
flexShrink: 0
|
||
}}>
|
||
<Clock size={20} />
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ fontWeight: 700, fontSize: '1rem', color: '#f97316', marginBottom: '0.25rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||
Available for Pre-Order
|
||
{plugin.preorder_release_date && (
|
||
<span style={{ fontSize: '0.75rem', fontWeight: 600, background: 'rgba(249,115,22,0.2)', color: '#f97316', padding: '2px 8px', borderRadius: 4 }}>
|
||
Expected: {formatDate(plugin.preorder_release_date)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted, #94a3b8)', lineHeight: 1.5 }}>
|
||
{ownsPlugin ? (
|
||
<span style={{ color: '#4ade80', fontWeight: 600, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<Check size={16} color="#4ade80" /> 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.
|
||
</span>
|
||
) : (
|
||
<span>
|
||
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.
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Early Access Banner ── */}
|
||
{plugin.is_early_access && (
|
||
<div className="early-access-banner" style={{ marginBottom: '1.5rem' }}>
|
||
<div className="early-access-icon"><Clock size={18} /></div>
|
||
<div className="early-access-content">
|
||
<div className="early-access-title">Early Access Plugin</div>
|
||
<div className="early-access-text">
|
||
This plugin is currently in <strong>Early Access</strong>. 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!
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Payment success banner ── */}
|
||
{showPaymentSuccess && (
|
||
<div className="payment-success-banner">
|
||
<span className="success-icon"><CheckCircle2 size={24} color="#10b981" /></span>
|
||
<div className="banner-content">
|
||
<div className="banner-title">
|
||
Purchase successful!
|
||
</div>
|
||
<div className="banner-text">
|
||
You now own <strong>{plugin.name}</strong>. Click the download button to get your WASM file.
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() => setShowPaymentSuccess(false)}
|
||
className="banner-close"
|
||
>×</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* HEADER */}
|
||
<div className="plugin-detail-header">
|
||
<div
|
||
className="plugin-icon large"
|
||
style={{ backgroundImage: plugin.preview_path ? `url(${plugin.preview_path})` : 'none' }}
|
||
>
|
||
{!plugin.preview_path && plugin.name.charAt(0)}
|
||
</div>
|
||
<div className="plugin-header-info">
|
||
<h1 className="plugin-title">{plugin.name}</h1>
|
||
<p className="dev-name">by <Link to={`/profile/${plugin.dev_name}`} style={{ color: 'inherit', textDecoration: 'none' }} onMouseEnter={e => (e.currentTarget.style.textDecoration = 'underline')} onMouseLeave={e => (e.currentTarget.style.textDecoration = 'none')}>{plugin.dev_name}</Link></p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="plugin-detail-layout">
|
||
<div className="plugin-main-content">
|
||
|
||
{/* SCREENSHOTS / MEDIA GALLERY */}
|
||
{((screenshots.length > 0) || plugin.youtube_video_url) && (
|
||
<div className="screenshot-gallery">
|
||
{mainScreenshot === 'video' ? (
|
||
<PluginVideoPlayer url={plugin.youtube_video_url || undefined} />
|
||
) : (
|
||
<img
|
||
src={mainScreenshot || screenshots[0]?.path}
|
||
alt="Main"
|
||
className="main-screenshot"
|
||
style={{ cursor: 'pointer' }}
|
||
onClick={() => {
|
||
const idx = screenshots.findIndex((s: any) => s.path === (mainScreenshot || screenshots[0]?.path));
|
||
openLightbox(idx >= 0 ? idx : 0);
|
||
}}
|
||
/>
|
||
)}
|
||
<div className="thumbnail-strip">
|
||
{plugin.youtube_video_url && (
|
||
<div
|
||
className={`thumbnail ${mainScreenshot === 'video' ? 'active' : ''}`}
|
||
onClick={() => setMainScreenshot('video')}
|
||
style={{ position: 'relative', overflow: 'hidden' }}
|
||
>
|
||
{getYoutubeVideoId(plugin.youtube_video_url) ? (
|
||
<img
|
||
src={`https://img.youtube.com/vi/${getYoutubeVideoId(plugin.youtube_video_url)}/hqdefault.jpg`}
|
||
alt="Video Thumbnail"
|
||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||
/>
|
||
) : (
|
||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: '#000', color: '#fff', fontSize: '10px' }}>Video</div>
|
||
)}
|
||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0,0,0,0.4)' }}>
|
||
<svg viewBox="0 0 24 24" width="32" height="32" fill="var(--primary, #ff6b00)">
|
||
<path d="M8 5v14l11-7z" />
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{screenshots.map((shot: any) => (
|
||
<img
|
||
key={shot.id}
|
||
src={shot.path}
|
||
alt="Thumbnail"
|
||
className={`thumbnail ${mainScreenshot === shot.path ? 'active' : ''}`}
|
||
onClick={() => setMainScreenshot(shot.path)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{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 (
|
||
<div className="plugin-recent-update">
|
||
<div className="plugin-recent-update-header">
|
||
<div className="plugin-recent-update-title-group">
|
||
<h3 className="plugin-recent-update-title">
|
||
<Sparkles size={16} color="var(--primary)" />
|
||
What's New
|
||
</h3>
|
||
<span className="plugin-recent-update-badge">
|
||
v{latest.version}
|
||
</span>
|
||
{latest.created_at && (
|
||
<span className="plugin-recent-update-date">
|
||
{formatDate(latest.created_at)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{plugin.versions.length > 1 && (
|
||
<button
|
||
type="button"
|
||
className="plugin-recent-update-history-btn"
|
||
onClick={() => setChangelogOpen(true)}
|
||
>
|
||
<History size={13} />
|
||
<span>Version History ({plugin.versions.length})</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className={`plugin-recent-update-body markdown-content ${isLong ? 'is-clamped' : ''}`}>
|
||
{latest.release_notes ? (
|
||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={safeMarkdownComponents}>
|
||
{latest.release_notes}
|
||
</ReactMarkdown>
|
||
) : (
|
||
<p className="plugin-recent-update-empty">
|
||
Bug fixes and performance improvements.
|
||
</p>
|
||
)}
|
||
</div>
|
||
{isLong && (
|
||
<button
|
||
type="button"
|
||
className="plugin-recent-update-more-btn"
|
||
onClick={() => setChangelogOpen(true)}
|
||
>
|
||
<span>View More</span>
|
||
<ChevronRight size={14} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* DESCRIPTION */}
|
||
<div className="markdown-content">
|
||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={safeMarkdownComponents}>{currentDescription}</ReactMarkdown>
|
||
</div>
|
||
|
||
<hr className="divider" />
|
||
|
||
{/* REVIEWS */}
|
||
<section className="reviews-section">
|
||
<span className="reviews-section-label">Ratings & Reviews</span>
|
||
|
||
{stats ? (
|
||
<div className="review-summary-grid">
|
||
<div className="review-avg-block">
|
||
<div className="review-big-avg">{stats.avg}</div>
|
||
<div className="review-stars-row">
|
||
{[1, 2, 3, 4, 5].map(n => (
|
||
<span key={n} className={`review-star-icon ${n <= Math.round(Number(stats.avg)) ? 'filled' : 'empty'}`}>
|
||
<Star size={14} fill={n <= Math.round(Number(stats.avg)) ? 'currentColor' : 'none'} />
|
||
</span>
|
||
))}
|
||
</div>
|
||
<div className="review-total-label">{stats.total.toLocaleString()} Ratings</div>
|
||
</div>
|
||
<div className="review-bars-block">
|
||
{[5, 4, 3, 2, 1].map(num => (
|
||
<div key={num} className="review-bar-row">
|
||
<span className="review-bar-num">{num}</span>
|
||
<div className="review-bar-track">
|
||
<div
|
||
className="review-bar-fill"
|
||
style={{ width: `${(stats.distribution[num as keyof typeof stats.distribution] / stats.total) * 100}%` }}
|
||
/>
|
||
</div>
|
||
<span className="review-bar-count">
|
||
{stats.distribution[num as keyof typeof stats.distribution]}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="empty-reviews-prompt">No reviews yet. Be the first to leave one!</div>
|
||
)}
|
||
|
||
<div className="review-list-premium">
|
||
{Array.isArray(plugin.reviews) && plugin.reviews.map((r: any) => (
|
||
<div key={r.id} className="review-item-premium">
|
||
<div className="review-item-top">
|
||
<div className={`review-avatar ${getAvatarClass(r.username)}`}>
|
||
{getInitials(r.username)}
|
||
</div>
|
||
<div className="review-item-meta">
|
||
<span className="review-item-author">{r.username}</span>
|
||
<span className="review-item-date">{formatDate(r.created_at)}</span>
|
||
</div>
|
||
</div>
|
||
<div className="review-item-stars">
|
||
{[1, 2, 3, 4, 5].map(n => (
|
||
<span key={n} className={`review-star-sm ${n <= r.rating ? 'filled' : 'empty'}`}>
|
||
<Star size={12} fill={n <= r.rating ? 'currentColor' : 'none'} />
|
||
</span>
|
||
))}
|
||
</div>
|
||
<ExpandableText className="review-item-body" text={r.comment} limit={400} />
|
||
|
||
{/* Developer Reply */}
|
||
{r.developer_reply && (
|
||
<div className="developer-reply" style={{
|
||
marginTop: '1rem',
|
||
padding: '1rem',
|
||
background: 'rgba(255, 117, 24, 0.05)',
|
||
borderLeft: '2px solid var(--primary)',
|
||
borderRadius: '4px',
|
||
overflowWrap: 'break-word',
|
||
wordBreak: 'break-word'
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '0.5rem' }}>
|
||
<span style={{ fontSize: '0.75rem', fontWeight: 800, color: 'var(--primary)' }}>Developer Response</span>
|
||
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>{formatDate(r.replied_at)}</span>
|
||
</div>
|
||
<ExpandableText
|
||
text={r.developer_reply}
|
||
limit={300}
|
||
pStyle={{ fontSize: '0.9rem', color: 'var(--text)', lineHeight: 1.6 }}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Review Report & Reply Buttons */}
|
||
<div style={{ marginTop: '0.75rem', display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||
{reportedReviews.has(r.id) ? (
|
||
<p style={{ fontSize: '0.75rem', color: 'var(--success)', margin: 0, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||
<Check size={13} /> Report submitted
|
||
</p>
|
||
) : reviewReportOpen === r.id ? (
|
||
<form onSubmit={(e) => submitReviewReport(e, r.id)} className="report-form" style={{ maxWidth: '400px', width: '100%' }}>
|
||
<label className="report-form-label">Reason for reporting this review</label>
|
||
<div className="report-reasons" style={{ transform: 'scale(0.9)', transformOrigin: 'left' }}>
|
||
{REVIEW_REPORT_REASONS.map(reason => (
|
||
<button
|
||
key={reason}
|
||
type="button"
|
||
className={`report-reason-chip ${reviewReportSelected === reason ? 'selected' : ''}`}
|
||
onClick={() => { setReviewReportSelected(reason); setReviewReportCustom(''); }}
|
||
>
|
||
{reason}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{reviewReportSelected === 'Other' && (
|
||
<textarea
|
||
className="report-textarea"
|
||
placeholder="Describe the issue…"
|
||
value={reviewReportCustom}
|
||
onChange={e => setReviewReportCustom(e.target.value)}
|
||
rows={2}
|
||
autoFocus
|
||
style={{ fontSize: '0.8rem' }}
|
||
/>
|
||
)}
|
||
<div className="report-form-actions">
|
||
<button type="button" className="btn-report-cancel" onClick={resetReviewReport} style={{ padding: '4px 8px', fontSize: '0.7rem' }}>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="btn-report-submit"
|
||
disabled={
|
||
reviewReportSubmitting ||
|
||
!reviewReportSelected ||
|
||
(reviewReportSelected === 'Other' && !reviewReportCustom.trim())
|
||
}
|
||
style={{ padding: '4px 8px', fontSize: '0.7rem' }}
|
||
>
|
||
{reviewReportSubmitting ? 'Sending…' : 'Submit'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
) : replyOpen === r.id ? (
|
||
<form onSubmit={(e) => submitReply(e, r.id)} style={{ width: '100%', maxWidth: '500px' }}>
|
||
<textarea
|
||
className="report-textarea"
|
||
placeholder="Write your response to this review..."
|
||
value={replyText}
|
||
onChange={e => setReplyText(e.target.value)}
|
||
rows={3}
|
||
autoFocus
|
||
style={{ fontSize: '0.85rem', marginBottom: '0.5rem' }}
|
||
/>
|
||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||
<button
|
||
type="button"
|
||
className="btn-report-cancel"
|
||
onClick={() => { setReplyOpen(null); setReplyText(''); }}
|
||
style={{ padding: '4px 12px', fontSize: '0.75rem' }}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="btn btn-primary"
|
||
disabled={replySubmitting || !replyText.trim()}
|
||
style={{ padding: '4px 12px', fontSize: '0.75rem', height: 'auto' }}
|
||
>
|
||
{replySubmitting ? 'Replying...' : 'Post Reply'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
) : (
|
||
<>
|
||
{user && user.username === r.username ? (
|
||
<button className="btn-report" onClick={deleteReview} style={{ fontSize: '0.7rem', color: '#ff4d4f', display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||
<Trash2 size={12} /> Delete my review
|
||
</button>
|
||
) : user && (
|
||
<button className="btn-report" onClick={() => setReviewReportOpen(r.id)} style={{ fontSize: '0.7rem', display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||
<Flag size={12} /> Report review
|
||
</button>
|
||
)}
|
||
|
||
{isOwner && !r.developer_reply && !replyOpen && (
|
||
<button
|
||
className="btn-report"
|
||
onClick={() => setReplyOpen(r.id)}
|
||
style={{ fontSize: '0.7rem', color: 'var(--primary)', display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
||
>
|
||
<MessageSquare size={12} /> Reply as developer
|
||
</button>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{user ? (
|
||
hasReviewed ? (
|
||
<div className="review-already-posted">
|
||
<span className="review-already-icon"><Check size={14} /></span>
|
||
<div style={{ flex: 1 }}>
|
||
<div className="review-already-title">Review posted</div>
|
||
<div className="review-already-sub">You've already reviewed this plugin. Thanks for the feedback!</div>
|
||
</div>
|
||
<button
|
||
onClick={deleteReview}
|
||
style={{
|
||
background: 'rgba(255, 77, 79, 0.1)',
|
||
color: '#ff4d4f',
|
||
border: 'none',
|
||
padding: '8px 12px',
|
||
borderRadius: '6px',
|
||
fontSize: '0.8rem',
|
||
cursor: 'pointer',
|
||
fontWeight: '600'
|
||
}}
|
||
>
|
||
Delete Review
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<form onSubmit={submitReview} className="review-submit-form">
|
||
<h3 className="review-form-title">Leave a Review</h3>
|
||
<div className="form-group">
|
||
<label>Your Rating</label>
|
||
<StarRating rating={reviewForm.rating} onRate={val => setReviewForm({ ...reviewForm, rating: val })} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Your Experience</label>
|
||
<textarea
|
||
placeholder="What did you like? What could be better?"
|
||
value={reviewForm.comment}
|
||
maxLength={MAX_REVIEW_LENGTH}
|
||
onChange={e => setReviewForm({ ...reviewForm, comment: e.target.value })}
|
||
required
|
||
/>
|
||
</div>
|
||
<button className="btn btn-primary" type="submit">Post Review</button>
|
||
</form>
|
||
)
|
||
) : (
|
||
<div className="auth-notice">
|
||
Please <Link to="/login">login</Link> to rate this plugin.
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
|
||
{/* SIDEBAR */}
|
||
<aside className="plugin-sidebar">
|
||
<div className="sidebar-widget">
|
||
|
||
{/* Price / owned indicator */}
|
||
<div className="price-hero-section">
|
||
{plugin.type === 'paid' && ownsPlugin ? (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: plugin.is_preorder ? '#f97316' : 'var(--success, #4ade80)', fontWeight: 700, fontSize: '0.9rem' }}>
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" />
|
||
</svg>
|
||
{plugin.is_preorder ? 'Pre-Ordered (Awaiting Release)' : 'Purchased'}
|
||
</div>
|
||
) : plugin.type === 'paid' ? (
|
||
<div>
|
||
{plugin.is_preorder && (
|
||
<span style={{ display: 'inline-block', fontSize: '0.68rem', fontWeight: 800, background: 'rgba(249,115,22,0.15)', color: '#f97316', border: '1px solid rgba(249,115,22,0.3)', borderRadius: 4, padding: '1px 6px', marginBottom: '0.35rem' }}>
|
||
PRE-ORDER
|
||
</span>
|
||
)}
|
||
{(() => {
|
||
const base = plugin.price_cents ?? 0;
|
||
const discount = plugin.sale_discount_percent ?? 0;
|
||
const salePrice = plugin.sale_active && discount > 0
|
||
? Math.round(base * (1 - discount / 100))
|
||
: base;
|
||
|
||
const finalPrice = couponDiscount > 0
|
||
? Math.round(salePrice * (1 - couponDiscount / 100))
|
||
: salePrice;
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.2rem' }}>
|
||
<PriceDisplay cents={finalPrice} />
|
||
|
||
{(plugin.sale_active && discount > 0) || couponDiscount > 0 ? (
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '0.5rem' }}>
|
||
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)', textDecoration: 'line-through', fontFamily: 'var(--font-mono)' }}>
|
||
€{(base / 100).toFixed(2)}
|
||
</span>
|
||
|
||
{plugin.sale_active && discount > 0 && (
|
||
<span style={{ fontSize: '0.7rem', fontWeight: 800, background: 'rgba(255,107,107,0.1) ', border: '1px solid rgba(255,107,107,0.2) ', color: '#ff6b6b', borderRadius: 4, padding: '1px 6px' }}>
|
||
-{discount}% SALE
|
||
</span>
|
||
)}
|
||
|
||
{couponDiscount > 0 && (
|
||
<span style={{ fontSize: '0.7rem', fontWeight: 800, background: 'rgba(74,222,128,0.1) ', border: '1px solid rgba(74,222,128,0.2) ', color: '#4ade80', borderRadius: 4, padding: '1px 6px' }}>
|
||
-{couponDiscount}% COUPON
|
||
</span>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
) : (
|
||
<div className="price-free">{plugin.type === 'adwall' ? 'AD SUPPORTED' : 'FREE'}</div>
|
||
)}
|
||
</div>
|
||
|
||
{plugin.type === 'paid' && !ownsPlugin && (
|
||
<div style={{ marginBottom: '1.25rem', marginTop: '1rem', borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
|
||
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', marginBottom: '0.5rem', letterSpacing: '0.05em' }}>Have a promo code?</label>
|
||
<div style={{ display: 'flex', gap: '8px' }}>
|
||
<input
|
||
id="detailPromoCode"
|
||
name="promoCode"
|
||
type="text"
|
||
value={couponCode}
|
||
onChange={e => setCouponCode(e.target.value.toUpperCase())}
|
||
placeholder="PROMOCODE"
|
||
disabled={validatingCoupon || appliedCoupon !== null}
|
||
autoComplete="off"
|
||
autoCapitalize="characters"
|
||
spellCheck={false}
|
||
style={{
|
||
flex: 1,
|
||
padding: '8px 12px',
|
||
fontSize: '0.85rem',
|
||
fontFamily: 'var(--font-mono)',
|
||
textTransform: 'uppercase',
|
||
borderRadius: '6px',
|
||
border: '1px solid var(--border)',
|
||
backgroundColor: 'rgba(0,0,0,0.2)',
|
||
color: 'var(--text)',
|
||
outline: 'none'
|
||
}}
|
||
/>
|
||
{appliedCoupon ? (
|
||
<button
|
||
onClick={handleClearCoupon}
|
||
className="btn btn-secondary"
|
||
style={{ padding: '8px 16px', fontSize: '0.85rem', borderRadius: '6px', cursor: 'pointer', height: 'auto', display: 'flex', alignItems: 'center' }}
|
||
>
|
||
Clear
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={handleApplyCoupon}
|
||
disabled={validatingCoupon || !couponCode.trim()}
|
||
className="btn btn-download-main"
|
||
style={{ width: 'auto', padding: '8px 16px', fontSize: '0.85rem', borderRadius: '6px', cursor: 'pointer', display: 'flex', alignItems: 'center' }}
|
||
>
|
||
{validatingCoupon ? '…' : 'Apply'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
{couponMessage && (
|
||
<p style={{
|
||
fontSize: '0.78rem',
|
||
marginTop: '6px',
|
||
color: couponMessage.isError ? '#ef4444' : '#4ade80',
|
||
fontWeight: 500
|
||
}}>
|
||
{couponMessage.text}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
className="btn btn-download-main"
|
||
onClick={handleDownload}
|
||
disabled={plugin.type === 'paid' && !ownershipChecked}
|
||
>
|
||
{downloadLabel}
|
||
</button>
|
||
|
||
{plugin.source_link && (
|
||
<a
|
||
href={plugin.source_link}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="github-link-secondary"
|
||
>
|
||
<svg
|
||
viewBox="0 0 24 24"
|
||
width="18"
|
||
height="18"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
fill="none"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
>
|
||
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"></path>
|
||
</svg>
|
||
View Source Code
|
||
</a>
|
||
)}
|
||
|
||
<div className="sidebar-stats">
|
||
<div className="stat-item"><strong>{(plugin.downloads ?? 0).toLocaleString()}</strong> Downloads</div>
|
||
{plugin.created_at && (
|
||
<div className="stat-item">Released: <strong>{formatDate(plugin.created_at)}</strong></div>
|
||
)}
|
||
<div className="stat-item">Category: <strong>{plugin.category}</strong></div>
|
||
{plugin.version && (
|
||
<div className="stat-item" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||
<span>Version: <strong>{plugin.version}</strong></span>
|
||
{Array.isArray(plugin.versions) && plugin.versions.length > 0 && (
|
||
<button
|
||
onClick={() => setChangelogOpen(true)}
|
||
style={{
|
||
background: 'rgba(255,255,255,0.05)',
|
||
border: '1px solid rgba(255,255,255,0.1)',
|
||
color: 'var(--text)',
|
||
padding: '2px 8px',
|
||
borderRadius: '4px',
|
||
fontSize: '0.7rem',
|
||
cursor: 'pointer',
|
||
fontWeight: 600,
|
||
marginLeft: '8px'
|
||
}}
|
||
>
|
||
Update History
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="divider-sm" />
|
||
{user && !reportDone && (
|
||
<>
|
||
{!reportOpen ? (
|
||
<button className="btn-report" onClick={() => setReportOpen(true)} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||
<Flag size={12} /> Report plugin
|
||
</button>
|
||
) : (
|
||
<form onSubmit={submitReport} className="report-form">
|
||
<label className="report-form-label">Reason for report</label>
|
||
<div className="report-reasons">
|
||
{REPORT_REASONS.map(reason => (
|
||
<button
|
||
key={reason}
|
||
type="button"
|
||
className={`report-reason-chip ${reportSelected === reason ? 'selected' : ''}`}
|
||
onClick={() => { setReportSelected(reason); setReportCustom(''); }}
|
||
>
|
||
{reason}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{reportSelected === 'Other' && (
|
||
<textarea
|
||
className="report-textarea"
|
||
placeholder="Describe the issue…"
|
||
value={reportCustom}
|
||
onChange={e => setReportCustom(e.target.value)}
|
||
rows={3}
|
||
autoFocus
|
||
/>
|
||
)}
|
||
<div className="report-form-actions">
|
||
<button type="button" className="btn-report-cancel" onClick={resetReport}>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="btn-report-submit"
|
||
disabled={
|
||
reportSubmitting ||
|
||
!reportSelected ||
|
||
(reportSelected === 'Other' && !reportCustom.trim())
|
||
}
|
||
>
|
||
{reportSubmitting ? 'Sending…' : 'Submit'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
)}
|
||
</>
|
||
)}
|
||
{reportDone && (
|
||
<p className="report-done" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<Check size={14} /> Report submitted. Thanks for keeping the marketplace safe.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<PluginCommandsSidebarWidget commands={plugin.commands} />
|
||
</aside>
|
||
</div>
|
||
|
||
{/* LIGHTBOX MODAL */}
|
||
{lightboxImage && (
|
||
<div className="lightbox-overlay" onClick={closeLightbox}>
|
||
<div className="lightbox-content" onClick={(e) => e.stopPropagation()}>
|
||
<img src={lightboxImage} alt="Expanded view" className="lightbox-img" />
|
||
<button className="lightbox-close" onClick={closeLightbox}>
|
||
×
|
||
</button>
|
||
|
||
{/* Left arrow — only shown when multiple screenshots exist */}
|
||
{screenshots.length > 1 && (
|
||
<button
|
||
className="lightbox-arrow lightbox-arrow-left"
|
||
onClick={(e) => { e.stopPropagation(); lightboxPrev(); }}
|
||
aria-label="Previous screenshot"
|
||
>
|
||
‹
|
||
</button>
|
||
)}
|
||
|
||
{/* Right arrow */}
|
||
{screenshots.length > 1 && (
|
||
<button
|
||
className="lightbox-arrow lightbox-arrow-right"
|
||
onClick={(e) => { e.stopPropagation(); lightboxNext(); }}
|
||
aria-label="Next screenshot"
|
||
>
|
||
›
|
||
</button>
|
||
)}
|
||
|
||
{/* Dot indicators */}
|
||
{screenshots.length > 1 && (
|
||
<div className="lightbox-dots">
|
||
{screenshots.map((_: any, i: number) => (
|
||
<button
|
||
key={i}
|
||
className={`lightbox-dot ${i === lightboxIndex ? 'active' : ''}`}
|
||
onClick={(e) => { e.stopPropagation(); openLightbox(i); }}
|
||
aria-label={`Go to screenshot ${i + 1}`}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* CHANGELOG MODAL */}
|
||
{changelogOpen && (
|
||
<div className="lightbox-overlay" onClick={() => setChangelogOpen(false)} style={{ zIndex: 2000 }}>
|
||
<div className="lightbox-content" onClick={(e) => e.stopPropagation()} style={{
|
||
maxWidth: '800px',
|
||
width: '90%',
|
||
maxHeight: '80vh',
|
||
backgroundColor: 'var(--surface)',
|
||
borderRadius: '12px',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
padding: 0,
|
||
overflow: 'hidden',
|
||
border: '1px solid var(--border)',
|
||
boxShadow: '0 20px 50px rgba(0,0,0,0.8)'
|
||
}}>
|
||
<div style={{
|
||
padding: '20px 24px',
|
||
borderBottom: '1px solid var(--border)',
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
background: 'linear-gradient(to right, rgba(255, 117, 24, 0.05), transparent)'
|
||
}}>
|
||
<h2 style={{ margin: 0, fontSize: '1.25rem', color: 'var(--primary)', fontWeight: 800 }}>Update History</h2>
|
||
</div>
|
||
|
||
<div style={{ padding: '24px', overflowY: 'auto', flex: 1 }}>
|
||
{(plugin.versions ?? []).map((v: any, idx: number, arr: any[]) => (
|
||
<div key={v.id} style={{
|
||
marginBottom: idx === arr.length - 1 ? 0 : '32px',
|
||
paddingBottom: idx === arr.length - 1 ? 0 : '32px',
|
||
borderBottom: idx === arr.length - 1 ? 'none' : '1px solid var(--border)'
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||
<span style={{
|
||
color: 'var(--primary)',
|
||
fontSize: '1.1rem',
|
||
fontWeight: 800,
|
||
fontFamily: 'var(--font-mono)'
|
||
}}>
|
||
v{v.version}
|
||
</span>
|
||
<span style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>
|
||
Released {formatDate(v.created_at)}
|
||
</span>
|
||
</div>
|
||
<div className="markdown-content" style={{ color: 'var(--text)', fontSize: '0.95rem', lineHeight: 1.6 }}>
|
||
{v.release_notes ? (
|
||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={safeMarkdownComponents}>{v.release_notes}</ReactMarkdown>
|
||
) : (
|
||
<p style={{ fontStyle: 'italic', color: 'var(--text-muted)', opacity: 0.5 }}>No specific release notes for this update.</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div style={{ padding: '16px 24px', borderTop: '1px solid var(--border)', background: 'rgba(0,0,0,0.1)', textAlign: 'right' }}>
|
||
<button
|
||
onClick={() => setChangelogOpen(false)}
|
||
className="btn btn-secondary"
|
||
style={{ padding: '8px 24px' }}
|
||
>
|
||
Close
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default PluginDetail;
|