feat: Add Coupons and Promo Codes tab to developer dashboard and purchase checkout
This commit is contained in:
parent
7ab986fb5a
commit
a0bcd266a3
@ -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,20 +569,55 @@ 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">
|
||||
{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}
|
||||
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));
|
||||
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}
|
||||
@ -789,29 +889,103 @@ const PluginDetail = () => {
|
||||
</div>
|
||||
) : plugin.type === 'paid' ? (
|
||||
<div>
|
||||
{plugin.sale_active && plugin.sale_discount_percent > 0 ? (
|
||||
{(() => {
|
||||
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' }}>
|
||||
{/* 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' }}>
|
||||
<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>
|
||||
<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_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>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<PriceDisplay cents={plugin.price_cents} />
|
||||
)}
|
||||
|
||||
{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 }}
|
||||
>
|
||||
×
|
||||
</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"
|
||||
|
||||
@ -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,7 +248,9 @@ 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]) => (
|
||||
{Object.entries(getCodeList())
|
||||
.sort((a, b) => a[1].localeCompare(b[1]))
|
||||
.map(([code, name]) => (
|
||||
<option key={code} value={code}>
|
||||
{name}
|
||||
</option>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@ -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 {
|
||||
await api.post('/auth/register', { ...form, captchaToken });
|
||||
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: 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}>
|
||||
|
||||
@ -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,8 +505,9 @@ const AddPlugin = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mp-form-row">
|
||||
{/* Keywords */}
|
||||
<div className="mp-form-group" style={{ marginBottom: '1.5rem' }}>
|
||||
<div className="mp-form-group">
|
||||
<label className="mp-label">
|
||||
Keywords{' '}
|
||||
<span style={{ color: 'var(--mp-text-3)', fontWeight: 400 }}>(comma-separated)</span>
|
||||
@ -516,6 +524,25 @@ const AddPlugin = () => {
|
||||
<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 */}
|
||||
<div className="mp-form-group">
|
||||
<label className="mp-checkbox-label" style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', userSelect: 'none' }}>
|
||||
|
||||
238
src/pages/dashboard/plugin/Coupons.tsx
Normal file
238
src/pages/dashboard/plugin/Coupons.tsx
Normal 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 & 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;
|
||||
@ -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} />
|
||||
)}
|
||||
|
||||
@ -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,6 +255,7 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
@ -261,6 +264,15 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
|
||||
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">
|
||||
<label className="mp-checkbox-label" style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', userSelect: 'none', marginTop: '0.5rem' }}>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user