feat: Add Coupons and Promo Codes tab to developer dashboard and purchase checkout

This commit is contained in:
Alexander Medvedev 2026-07-13 11:09:41 +02:00
parent 7ab986fb5a
commit a0bcd266a3
8 changed files with 606 additions and 103 deletions

View File

@ -7,6 +7,30 @@ 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',
@ -125,6 +149,44 @@ const PluginDetail = () => {
const [selectedLanguage] = useState('en');
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);
@ -166,7 +228,9 @@ const PluginDetail = () => {
const fetchPlugin = () => {
api.get(`/plugins/${id}`).then(res => {
setPlugin(res.data);
if (res.data.screenshots?.length > 0) {
if (res.data.youtube_video_url) {
setMainScreenshot('video');
} else if (res.data.screenshots?.length > 0) {
setMainScreenshot(res.data.screenshots[0].path);
}
});
@ -283,7 +347,8 @@ const PluginDetail = () => {
// Does not own — go to Stripe checkout
try {
const res = await api.post(`/plugins/${id}/checkout`);
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) {
@ -504,30 +569,65 @@ const PluginDetail = () => {
<div className="plugin-detail-layout">
<div className="plugin-main-content">
{/* SCREENSHOTS */}
{Array.isArray(plugin.screenshots) && plugin.screenshots.length > 0 && (
{/* SCREENSHOTS / MEDIA GALLERY */}
{((Array.isArray(plugin.screenshots) && plugin.screenshots.length > 0) || plugin.youtube_video_url) && (
<div className="screenshot-gallery">
<img
src={mainScreenshot || plugin.screenshots[0].path}
alt="Main"
className="main-screenshot"
style={{ cursor: 'pointer' }}
onClick={() => {
const idx = plugin.screenshots.findIndex((s: any) => s.path === (mainScreenshot || plugin.screenshots[0].path));
openLightbox(idx >= 0 ? idx : 0);
}}
/>
<div className="thumbnail-strip">
{plugin.screenshots.map((shot: any) => (
<img
key={shot.id}
src={shot.path}
alt="Thumbnail"
className={`thumbnail ${mainScreenshot === shot.path ? 'active' : ''}`}
onClick={() => setMainScreenshot(shot.path)}
/>
))}
</div>
{mainScreenshot === 'video' ? (
<div style={{ position: 'relative', paddingBottom: '56.25%', height: 0, overflow: 'hidden', borderRadius: '8px', marginBottom: '15px', border: '1px solid rgba(255,255,255,0.1)' }}>
<iframe
src={getYoutubeEmbedUrl(plugin.youtube_video_url)}
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', border: 0 }}
/>
</div>
) : (
<img
src={mainScreenshot || plugin.screenshots[0]?.path}
alt="Main"
className="main-screenshot"
style={{ cursor: 'pointer' }}
onClick={() => {
const idx = plugin.screenshots.findIndex((s: any) => s.path === (mainScreenshot || plugin.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>
)}
{plugin.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>
)}
@ -789,29 +889,103 @@ const PluginDetail = () => {
</div>
) : plugin.type === 'paid' ? (
<div>
{plugin.sale_active && plugin.sale_discount_percent > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.2rem' }}>
{/* Sale price prominent */}
<PriceDisplay cents={Math.round(plugin.price_cents * (1 - plugin.sale_discount_percent / 100))} />
{/* Original price struck through */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)', textDecoration: 'line-through', fontFamily: 'var(--font-mono)' }}>
€{(plugin.price_cents / 100).toFixed(2)}
</span>
<span style={{ fontSize: '0.7rem', fontWeight: 800, background: 'rgba(255,107,107,0.15)', border: '1px solid rgba(255,107,107,0.35)', color: '#ff6b6b', borderRadius: 4, padding: '1px 6px', letterSpacing: '0.04em' }}>
-{plugin.sale_discount_percent}% SALE
</span>
</div>
</div>
) : (
<PriceDisplay cents={plugin.price_cents} />
)}
{(() => {
const base = plugin.price_cents;
const salePrice = plugin.sale_active && plugin.sale_discount_percent > 0
? Math.round(base * (1 - plugin.sale_discount_percent / 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 && plugin.sale_discount_percent > 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)' }}>
€{(plugin.price_cents / 100).toFixed(2)}
</span>
{plugin.sale_active && plugin.sale_discount_percent > 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' }}>
-{plugin.sale_discount_percent}% 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
type="text"
value={couponCode}
onChange={e => setCouponCode(e.target.value.toUpperCase())}
placeholder="PROMOCODE"
disabled={validatingCoupon || appliedCoupon !== null}
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}
@ -988,29 +1162,24 @@ const PluginDetail = () => {
maxWidth: '800px',
width: '90%',
maxHeight: '80vh',
backgroundColor: '#1b2838', // Steam-ish dark blue
borderRadius: '8px',
backgroundColor: 'var(--surface)',
borderRadius: '12px',
display: 'flex',
flexDirection: 'column',
padding: 0,
overflow: 'hidden',
boxShadow: '0 20px 50px rgba(0,0,0,0.5)'
border: '1px solid var(--border)',
boxShadow: '0 20px 50px rgba(0,0,0,0.8)'
}}>
<div style={{
padding: '20px 24px',
borderBottom: '1px solid rgba(255,255,255,0.1)',
borderBottom: '1px solid var(--border)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
background: 'linear-gradient(to right, #2a475e, #1b2838)'
background: 'linear-gradient(to right, rgba(255, 117, 24, 0.05), transparent)'
}}>
<h2 style={{ margin: 0, fontSize: '1.25rem', color: '#66c0f4', textTransform: 'uppercase', letterSpacing: '1px' }}>Update History</h2>
<button
onClick={() => setChangelogOpen(false)}
style={{ background: 'none', border: 'none', color: 'white', fontSize: '1.5rem', cursor: 'pointer', opacity: 0.6 }}
>
&times;
</button>
<h2 style={{ margin: 0, fontSize: '1.25rem', color: 'var(--primary)', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 800 }}>Update History</h2>
</div>
<div style={{ padding: '24px', overflowY: 'auto', flex: 1 }}>
@ -1018,33 +1187,33 @@ const PluginDetail = () => {
<div key={v.id} style={{
marginBottom: idx === plugin.versions.length - 1 ? 0 : '32px',
paddingBottom: idx === plugin.versions.length - 1 ? 0 : '32px',
borderBottom: idx === plugin.versions.length - 1 ? 'none' : '1px solid rgba(255,255,255,0.05)'
borderBottom: idx === plugin.versions.length - 1 ? 'none' : '1px solid var(--border)'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
<span style={{
color: '#66c0f4',
color: 'var(--primary)',
fontSize: '1.1rem',
fontWeight: 800,
fontFamily: 'var(--font-mono)'
}}>
v{v.version}
</span>
<span style={{ color: 'rgba(255,255,255,0.4)', fontSize: '0.85rem' }}>
<span style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>
Released {formatDate(v.created_at)}
</span>
</div>
<div className="markdown-content" style={{ color: 'rgba(255,255,255,0.8)', fontSize: '0.95rem', lineHeight: 1.6 }}>
<div className="markdown-content" style={{ color: 'var(--text)', fontSize: '0.95rem', lineHeight: 1.6 }}>
{v.release_notes ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>{v.release_notes}</ReactMarkdown>
) : (
<p style={{ fontStyle: 'italic', opacity: 0.5 }}>No specific release notes for this update.</p>
<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', background: 'rgba(0,0,0,0.2)', textAlign: 'right' }}>
<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"

View File

@ -3,7 +3,7 @@ import { useNavigate, useLocation, Link } from 'react-router-dom';
import { QRCodeSVG } from 'qrcode.react';
import api from '../api';
import { useAuth } from '../App';
import { getNames } from 'country-list';
import { getCodeList } from 'country-list';
interface LibraryEntry {
plugin_id: number;
@ -248,11 +248,13 @@ const ProfilePage = () => {
onFocus={(e) => e.target.style.borderColor = 'var(--primary)'}
onBlur={(e) => e.target.style.borderColor = 'var(--border)'}>
<option value="">Select a country</option>
{Object.entries(getNames()).map(([code, name]) => (
<option key={code} value={code}>
{name}
</option>
))}
{Object.entries(getCodeList())
.sort((a, b) => a[1].localeCompare(b[1]))
.map(([code, name]) => (
<option key={code} value={code}>
{name}
</option>
))}
</select>
</div>
<button type="submit" className="btn">Update Country</button>

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useRef } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Turnstile } from '@marsidev/react-turnstile';
@ -13,6 +13,7 @@ const LoginPage = () => {
const [emailTouched, setEmailTouched] = useState(false);
const [emailValid, setEmailValid] = useState<boolean | null>(null);
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const turnstileRef = useRef<any>(null);
// 2FA login state
const [requires2fa, setRequires2fa] = useState(false);
@ -69,18 +70,34 @@ const LoginPage = () => {
return;
}
if (!captchaToken) {
setIsSubmitting(true);
setError('');
let currentToken = captchaToken;
if (!currentToken && turnstileRef.current) {
try {
currentToken = await turnstileRef.current.getResponsePromise(2000);
} catch (err) {
try {
turnstileRef.current.reset();
currentToken = await turnstileRef.current.getResponsePromise(4000);
} catch (resetErr) {
console.error('Turnstile verification failed:', resetErr);
}
}
}
if (!currentToken) {
setError('Please complete the security check.');
setIsSubmitting(false);
return;
}
setIsSubmitting(true);
setError('');
try {
const res = await api.post('/auth/login', {
email: form.email,
password: form.password,
captchaToken,
captchaToken: currentToken,
});
if (res.data.requires_2fa) {
@ -436,8 +453,14 @@ const LoginPage = () => {
{!requires2fa && (
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: '20px', marginTop: '4px' }}>
<Turnstile siteKey="0x4AAAAAAClcSibyhKfR0H6o"
onSuccess={(token) => setCaptchaToken(token)} options={{ theme: 'light', appearance: 'interaction-only' } as any} />
<Turnstile
ref={turnstileRef}
siteKey="0x4AAAAAAClcSibyhKfR0H6o"
onSuccess={(token) => setCaptchaToken(token)}
onExpire={() => setCaptchaToken(null)}
onError={() => setCaptchaToken(null)}
options={{ theme: 'light', appearance: 'interaction-only' } as any}
/>
</div>
)}

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useRef } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Turnstile } from '@marsidev/react-turnstile';
import api from '../../api';
@ -29,6 +29,7 @@ function getStrength(pw: string): { score: number; label: string; color: string
const RegisterPage = () => {
const [form, setForm] = useState({ username: '', email: '', password: '', website: '' });
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const turnstileRef = useRef<any>(null);
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [emailTouched, setEmailTouched] = useState(false);
@ -70,13 +71,32 @@ const RegisterPage = () => {
if (!isEmailValid) { setError('Please enter a valid email address.'); return; }
if (!allRequirementsMet) { setError('Please meet all password requirements.'); return; }
if (!captchaToken) { setError('Please complete the security check.'); return; }
setIsSubmitting(true);
setError('');
let currentToken = captchaToken;
if (!currentToken && turnstileRef.current) {
try {
currentToken = await turnstileRef.current.getResponsePromise(2000);
} catch (err) {
try {
turnstileRef.current.reset();
currentToken = await turnstileRef.current.getResponsePromise(4000);
} catch (resetErr) {
console.error('Turnstile verification failed:', resetErr);
}
}
}
if (!currentToken) {
setError('Please complete the security check.');
setIsSubmitting(false);
return;
}
try {
await api.post('/auth/register', { ...form, captchaToken });
await api.post('/auth/register', { ...form, captchaToken: currentToken });
navigate('/check-email', { state: { email: form.email } });
} catch (err: any) {
setError(err.response?.data?.error || 'Registration failed. Please try again.');
@ -414,8 +434,14 @@ const RegisterPage = () => {
</div>
<div className="captcha-container">
<Turnstile siteKey="0x4AAAAAAClcSibyhKfR0H6o"
onSuccess={(token) => setCaptchaToken(token)} options={{ theme: 'light', appearance: 'interaction-only' } as any} />
<Turnstile
ref={turnstileRef}
siteKey="0x4AAAAAAClcSibyhKfR0H6o"
onSuccess={(token) => setCaptchaToken(token)}
onExpire={() => setCaptchaToken(null)}
onError={() => setCaptchaToken(null)}
options={{ theme: 'light', appearance: 'interaction-only' } as any}
/>
</div>
<button className="submit-btn" type="submit" disabled={isSubmitting}>

View File

@ -48,6 +48,7 @@ const MAX_TOTAL_IMAGE_BYTES = 5 * 1024 * 1024;
const MAX_NAME_LEN = 64;
const MAX_CATEGORY_LEN = 48;
const MAX_SOURCE_LINK_LEN = 512;
const MAX_YOUTUBE_URL_LEN = 512;
const MAX_KEYWORDS_LEN = 256;
const MAX_DESC_LEN = 64_000;
const MAX_SCREENSHOTS = 8;
@ -201,6 +202,7 @@ const AddPlugin = () => {
const [developerName, setDeveloperName] = useState('');
const [category, setCategory] = useState(PLUGIN_CATEGORIES[0]);
const [sourceLink, setSourceLink] = useState('');
const [youtubeVideoUrl, setYoutubeVideoUrl] = useState('');
const [keywords, setKeywords] = useState('');
// ── Step 1: Store Listing ──
@ -248,13 +250,17 @@ const AddPlugin = () => {
errs.sourceLink = `Source link exceeds ${MAX_SOURCE_LINK_LEN} characters.`;
if (sourceLink && !sourceLink.startsWith('http://') && !sourceLink.startsWith('https://'))
errs.sourceLink = 'Source link must start with http:// or https://';
if (youtubeVideoUrl.length > MAX_YOUTUBE_URL_LEN)
errs.youtubeVideoUrl = `YouTube video URL exceeds ${MAX_YOUTUBE_URL_LEN} characters.`;
if (youtubeVideoUrl && !youtubeVideoUrl.startsWith('http://') && !youtubeVideoUrl.startsWith('https://'))
errs.youtubeVideoUrl = 'YouTube video URL must start with http:// or https://';
if (keywords.length > MAX_KEYWORDS_LEN)
errs.keywords = `Keywords exceed ${MAX_KEYWORDS_LEN} characters.`;
const descLen = (descriptions[DEFAULT_LOCALE] ?? '').length;
if (descLen > MAX_DESC_LEN)
errs.description = `Description exceeds ${MAX_DESC_LEN} characters (${descLen}/${MAX_DESC_LEN}).`;
return errs;
}, [name, category, sourceLink, keywords, descriptions]);
}, [name, category, sourceLink, youtubeVideoUrl, keywords, descriptions]);
const hasFieldErrors = Object.keys(fieldErrors).length > 0;
@ -353,6 +359,7 @@ const AddPlugin = () => {
fd.append('name', name);
fd.append('category', category);
fd.append('source_link', sourceLink);
fd.append('youtube_video_url', youtubeVideoUrl);
fd.append('keywords', keywords);
fd.append('translated_descriptions', JSON.stringify(descriptions));
fd.append('type', licenseType);
@ -376,7 +383,7 @@ const AddPlugin = () => {
const resetAll = () => {
setDone(false); setStep(0);
setName(''); setDeveloperName(''); setCategory(PLUGIN_CATEGORIES[0]);
setSourceLink(''); setKeywords('');
setSourceLink(''); setYoutubeVideoUrl(''); setKeywords('');
setDescriptions({ [DEFAULT_LOCALE]: '' }); setActiveLocale(DEFAULT_LOCALE);
setPreviewImage(null); setPreviewImageUrl(null);
setScreenshots([]); setScreenshotPreviews([]);
@ -498,22 +505,42 @@ const AddPlugin = () => {
</div>
</div>
{/* Keywords */}
<div className="mp-form-group" style={{ marginBottom: '1.5rem' }}>
<label className="mp-label">
Keywords{' '}
<span style={{ color: 'var(--mp-text-3)', fontWeight: 400 }}>(comma-separated)</span>
<CharCount current={keywords.length} max={MAX_KEYWORDS_LEN} />
</label>
<input
className="mp-input"
type="text"
value={keywords}
onChange={e => setKeywords(e.target.value)}
placeholder="economy, shop, currency"
style={{ borderColor: fieldErrors.keywords ? 'var(--mp-error)' : undefined }}
/>
<FieldError msg={fieldErrors.keywords} />
<div className="mp-form-row">
{/* Keywords */}
<div className="mp-form-group">
<label className="mp-label">
Keywords{' '}
<span style={{ color: 'var(--mp-text-3)', fontWeight: 400 }}>(comma-separated)</span>
<CharCount current={keywords.length} max={MAX_KEYWORDS_LEN} />
</label>
<input
className="mp-input"
type="text"
value={keywords}
onChange={e => setKeywords(e.target.value)}
placeholder="economy, shop, currency"
style={{ borderColor: fieldErrors.keywords ? 'var(--mp-error)' : undefined }}
/>
<FieldError msg={fieldErrors.keywords} />
</div>
{/* YouTube Video URL */}
<div className="mp-form-group">
<label className="mp-label">
YouTube Video URL{' '}
<span style={{ color: 'var(--mp-text-3)', fontWeight: 400 }}>(optional)</span>
<CharCount current={youtubeVideoUrl.length} max={MAX_YOUTUBE_URL_LEN} />
</label>
<input
className="mp-input"
type="url"
value={youtubeVideoUrl}
onChange={e => setYoutubeVideoUrl(e.target.value)}
placeholder="https://www.youtube.com/watch?v=..."
style={{ borderColor: fieldErrors.youtubeVideoUrl ? 'var(--mp-error)' : undefined }}
/>
<FieldError msg={fieldErrors.youtubeVideoUrl} />
</div>
</div>
{/* Early Access */}

View File

@ -0,0 +1,238 @@
import { useState, useEffect } from 'react';
import { Tag, Plus, Trash2, Users, Clock } from 'lucide-react';
import api from '../../../api';
import type { PluginData } from './ManagePlugin';
interface Coupon {
id: number;
plugin_id: number;
code: string;
discount_percent: number;
max_redemptions: number | null;
used_redemptions: number;
created_at: string;
expires_at: string | null;
active: boolean;
}
type Props = { plugin: PluginData };
const Coupons = ({ plugin }: Props) => {
const [coupons, setCoupons] = useState<Coupon[]>([]);
const [loading, setLoading] = useState(true);
// Form state
const [code, setCode] = useState('');
const [discountPercent, setDiscountPercent] = useState<number>(20);
const [maxRedemptions, setMaxRedemptions] = useState<string>('');
const [expiresAt, setExpiresAt] = useState<string>('');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
fetchCoupons();
}, [plugin.id]);
const fetchCoupons = async () => {
try {
const res = await api.get(`/plugins/${plugin.id}/coupons`);
setCoupons(res.data);
} catch (err) {
console.error('Failed to fetch coupons', err);
} finally {
setLoading(false);
}
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!code.trim()) return;
setSubmitting(true);
const payload = {
code: code.trim().toUpperCase(),
discount_percent: discountPercent,
max_redemptions: maxRedemptions ? parseInt(maxRedemptions, 10) : null,
expires_at: expiresAt ? new Date(expiresAt).toISOString() : null,
};
try {
const res = await api.post(`/plugins/${plugin.id}/coupons`, payload);
setCoupons(prev => [res.data, ...prev]);
// Reset form
setCode('');
setDiscountPercent(20);
setMaxRedemptions('');
setExpiresAt('');
} catch (err: any) {
alert(err.response?.data || 'Failed to create coupon.');
} finally {
setSubmitting(false);
}
};
const handleDelete = async (couponId: number) => {
if (!window.confirm('Are you sure you want to delete this coupon?')) return;
try {
await api.delete(`/plugins/${plugin.id}/coupons/${couponId}`);
setCoupons(prev => prev.filter(c => c.id !== couponId));
} catch (err) {
alert('Failed to delete coupon.');
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
<div className="mp-section-header">
<h2 className="mp-title">Coupons &amp; Promo Codes</h2>
<p>Create discount codes that customers can enter on the plugin store listing page.</p>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: '2rem' }}>
{/* Create Coupon Card */}
<div className="mp-card">
<div className="mp-card-title">
<Plus size={14} /> Create Coupon
</div>
<form onSubmit={handleCreate} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<div className="mp-form-group">
<label className="mp-label">Promo Code</label>
<input
className="mp-input"
type="text"
value={code}
onChange={e => setCode(e.target.value.toUpperCase())}
placeholder="E.g., SAVE50"
required
style={{ textTransform: 'uppercase', fontFamily: 'var(--font-mono)' }}
/>
</div>
<div className="mp-form-row">
<div className="mp-form-group">
<label className="mp-label">Discount %</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<input
className="mp-input"
type="number"
min={1}
max={99}
value={discountPercent}
onChange={e => setDiscountPercent(Math.min(99, Math.max(1, parseInt(e.target.value, 10) || 0)))}
required
/>
</div>
</div>
<div className="mp-form-group">
<label className="mp-label">Max Uses (Optional)</label>
<input
className="mp-input"
type="number"
min={1}
value={maxRedemptions}
onChange={e => setMaxRedemptions(e.target.value)}
placeholder="Unlimited"
/>
</div>
</div>
<div className="mp-form-group">
<label className="mp-label">Expiration Date (Optional)</label>
<input
className="mp-input"
type="datetime-local"
value={expiresAt}
onChange={e => setExpiresAt(e.target.value)}
/>
</div>
<button type="submit" className="mp-btn mp-btn-primary" disabled={submitting || !code.trim()} style={{ marginTop: '0.5rem' }}>
<Plus size={14} /> {submitting ? 'Creating…' : 'Create Promo Code'}
</button>
</form>
</div>
{/* Active Coupons List */}
<div className="mp-card" style={{ display: 'flex', flexDirection: 'column' }}>
<div className="mp-card-title">
<Tag size={14} /> Active Promo Codes
</div>
{loading ? (
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--mp-text-3)' }}>Loading coupons…</div>
) : coupons.length === 0 ? (
<div style={{ padding: '3rem 2rem', textAlign: 'center', color: 'var(--mp-text-3)', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '10px' }}>
<Tag size={28} style={{ opacity: 0.3 }} />
<span>No active coupon codes for this plugin.</span>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', overflowY: 'auto', maxHeight: '420px', paddingRight: '4px' }}>
{coupons.map(c => {
const isExpired = c.expires_at ? new Date() > new Date(c.expires_at) : false;
const isMaxed = c.max_redemptions !== null && c.used_redemptions >= c.max_redemptions;
const isValid = !isExpired && !isMaxed && c.active;
return (
<div
key={c.id}
style={{
padding: '1rem',
borderRadius: '8px',
backgroundColor: 'rgba(255,255,255,0.02)',
border: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
opacity: isValid ? 1 : 0.6
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: 700, color: 'var(--primary)', fontSize: '1.05rem' }}>
{c.code}
</span>
<span
style={{
fontSize: '0.72rem',
padding: '2px 6px',
borderRadius: '4px',
fontWeight: 700,
backgroundColor: isValid ? 'rgba(74,222,128,0.1)' : 'rgba(239,68,68,0.1)',
color: isValid ? '#4ade80' : '#ef4444'
}}
>
{isValid ? `${c.discount_percent}% OFF` : 'INACTIVE'}
</span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px', fontSize: '0.78rem', color: 'var(--mp-text-3)', marginTop: '0.25rem' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<Users size={12} /> {c.used_redemptions} {c.max_redemptions !== null ? `/ ${c.max_redemptions} uses` : 'uses'}
</span>
{c.expires_at && (
<span style={{ display: 'flex', alignItems: 'center', gap: '4px', color: isExpired ? '#ef4444' : undefined }}>
<Clock size={12} /> Exp: {new Date(c.expires_at).toLocaleDateString()}
</span>
)}
</div>
</div>
<button
className="mp-btn mp-btn-danger mp-btn-outline"
onClick={() => handleDelete(c.id)}
style={{ padding: '6px', borderRadius: '6px' }}
title="Delete Coupon"
>
<Trash2 size={14} />
</button>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
};
export default Coupons;

View File

@ -9,6 +9,7 @@ import {
import StoreListing from './StoreListing';
import PublishUpdate from './PublishUpdate';
import Pricing from './Pricing';
import Coupons from './Coupons';
import DangerZone from './DangerZone';
import './ManagePlugin.css';
@ -27,12 +28,14 @@ export type PluginData = {
sale_active?: boolean;
sale_discount_percent?: number;
is_early_access?: boolean;
youtube_video_url?: string;
};
const NAV_ITEMS = [
{ key: 'listing', label: 'Store Listing', icon: LayoutGrid },
{ key: 'update', label: 'Publish Update', icon: Upload },
{ key: 'pricing', label: 'Pricing', icon: DollarSign },
{ key: 'coupons', label: 'Coupons', icon: Tag },
{ key: 'danger', label: 'Danger Zone', icon: Trash2 },
];
@ -130,6 +133,9 @@ const ManagePlugin = () => {
{activeTab === 'pricing' && (
<Pricing plugin={plugin} onSaved={refreshPlugin} />
)}
{activeTab === 'coupons' && (
<Coupons plugin={plugin} />
)}
{activeTab === 'danger' && (
<DangerZone plugin={plugin} />
)}

View File

@ -147,6 +147,7 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
const [activeLocale, setActiveLocale] = useState(DEFAULT_LOCALE);
const [category, setCategory] = useState(plugin.category);
const [sourceLink, setSourceLink] = useState(plugin.source_link || '');
const [youtubeVideoUrl, setYoutubeVideoUrl] = useState(plugin.youtube_video_url || '');
const [keywords, setKeywords] = useState(plugin.keywords || '');
const [isEarlyAccess, setIsEarlyAccess] = useState(plugin.is_early_access || false);
const [screenshots, setScreenshots] = useState(plugin.screenshots || []);
@ -181,6 +182,7 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
fd.append('translated_descriptions', JSON.stringify(descriptions));
fd.append('category', category);
fd.append('source_link', sourceLink);
fd.append('youtube_video_url', youtubeVideoUrl);
fd.append('keywords', keywords);
fd.append('is_early_access', String(isEarlyAccess));
try {
@ -253,13 +255,23 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
</div>
</div>
<div className="mp-form-group">
<label className="mp-label">
Keywords <span style={{color:'var(--mp-text-3)',fontWeight:400}}>(comma-separated)</span>
</label>
<input className="mp-input" type="text" value={keywords}
onChange={e => setKeywords(e.target.value)}
placeholder="economy, shop, currency" />
<div className="mp-form-row">
<div className="mp-form-group">
<label className="mp-label">
Keywords <span style={{color:'var(--mp-text-3)',fontWeight:400}}>(comma-separated)</span>
</label>
<input className="mp-input" type="text" value={keywords}
onChange={e => setKeywords(e.target.value)}
placeholder="economy, shop, currency" />
</div>
<div className="mp-form-group">
<label className="mp-label">
YouTube Video URL <span style={{color:'var(--mp-text-3)',fontWeight:400}}>(optional)</span>
</label>
<input className="mp-input" type="url" value={youtubeVideoUrl}
onChange={e => setYoutubeVideoUrl(e.target.value)}
placeholder="https://www.youtube.com/watch?v=..." />
</div>
</div>
<div className="mp-form-group">