fix: some bugs
This commit is contained in:
parent
b280372eb7
commit
b162833243
22
src/App.css
22
src/App.css
@ -216,7 +216,7 @@ h3 {
|
||||
|
||||
.nav-search-input-wrap input {
|
||||
width: 100%;
|
||||
padding: 8px 16px 8px 40px;
|
||||
padding: 8px 34px 8px 40px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
@ -226,6 +226,26 @@ h3 {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-search-clear {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3px;
|
||||
border-radius: 50%;
|
||||
transition: color 0.15s, background-color 0.15s;
|
||||
}
|
||||
|
||||
.nav-search-clear:hover {
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.nav-search-input-wrap input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
53
src/App.tsx
53
src/App.tsx
@ -1,7 +1,7 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Link, NavLink, useNavigate, Outlet, Navigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Search, Store } from 'lucide-react';
|
||||
import { Search, Store, X } from 'lucide-react';
|
||||
import Home from './pages/Home';
|
||||
import PluginDetail from './pages/PluginDetail';
|
||||
import LoginPage from './pages/auth/LoginPage';
|
||||
@ -272,18 +272,45 @@ const DashboardLayout = () => {
|
||||
// --- Components ---
|
||||
const Navbar = ({ user }: any) => {
|
||||
const { t } = useTranslation();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const location = useLocation();
|
||||
const [searchQuery, setSearchQuery] = useState(() => {
|
||||
if (typeof window !== 'undefined' && window.location.pathname === '/search') {
|
||||
return new URLSearchParams(window.location.search).get('q') || '';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSearch = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter' && searchQuery.trim()) {
|
||||
navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`);
|
||||
// Sync searchQuery with URL 'q' parameter when navigating
|
||||
useEffect(() => {
|
||||
if (location.pathname === '/search') {
|
||||
const urlQuery = new URLSearchParams(location.search).get('q') || '';
|
||||
setSearchQuery(urlQuery);
|
||||
} else {
|
||||
setSearchQuery('');
|
||||
}
|
||||
}, [location.pathname, location.search]);
|
||||
|
||||
const handleSearch = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
const query = searchQuery.trim();
|
||||
if (query) {
|
||||
navigate(`/search?q=${encodeURIComponent(query)}`);
|
||||
} else {
|
||||
navigate('/search');
|
||||
}
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setSearchQuery('');
|
||||
if (location.pathname === '/search') {
|
||||
navigate('/search');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMenu = () => setIsMenuOpen(!isMenuOpen);
|
||||
|
||||
return (
|
||||
@ -319,12 +346,26 @@ const Navbar = ({ user }: any) => {
|
||||
<div className="nav-search-input-wrap">
|
||||
<Search size={16} className="nav-search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
type="search"
|
||||
name="q"
|
||||
placeholder={t('nav.search_placeholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleSearch}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
className="nav-search-clear"
|
||||
onClick={handleClear}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -177,13 +177,16 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
|
||||
</div>
|
||||
|
||||
<div className="dev-form-group">
|
||||
<label>{t('developer.onboarding.display_name_label')}</label>
|
||||
<label htmlFor="onboardingDisplayName">{t('developer.onboarding.display_name_label')}</label>
|
||||
<input
|
||||
id="onboardingDisplayName"
|
||||
name="displayName"
|
||||
type="text"
|
||||
className="dev-input"
|
||||
placeholder={t('developer.onboarding.display_name_placeholder')}
|
||||
value={displayName}
|
||||
onChange={e => setDisplayName(e.target.value)}
|
||||
autoComplete="nickname"
|
||||
maxLength={60}
|
||||
autoFocus
|
||||
/>
|
||||
@ -234,51 +237,63 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
|
||||
{isSellingPaid && step === 2 && (
|
||||
<div className="dev-step-content">
|
||||
<div className="dev-form-group">
|
||||
<label className="dev-field-label">
|
||||
<label className="dev-field-label" htmlFor="onboardingLegalName">
|
||||
<ShieldCheck size={14} /> {t('developer.onboarding.legal_name_label')}
|
||||
</label>
|
||||
<input
|
||||
id="onboardingLegalName"
|
||||
name="legalName"
|
||||
type="text"
|
||||
className="dev-input"
|
||||
value={legalName}
|
||||
onChange={e => setLegalName(e.target.value)}
|
||||
placeholder={entityType === 'individual' ? 'First and Last Name' : 'Legal Company Name Inc.'}
|
||||
autoComplete="name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="dev-form-group">
|
||||
<label className="dev-field-label"><MapPin size={14} /> {t('developer.onboarding.street_label')}</label>
|
||||
<label className="dev-field-label" htmlFor="onboardingStreetAddress"><MapPin size={14} /> {t('developer.onboarding.street_label')}</label>
|
||||
<input
|
||||
id="onboardingStreetAddress"
|
||||
name="streetAddress"
|
||||
type="text"
|
||||
className="dev-input"
|
||||
value={streetAddress}
|
||||
onChange={e => setStreetAddress(e.target.value)}
|
||||
placeholder="123 Main St, Suite 400"
|
||||
autoComplete="street-address"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||
<div className="dev-form-group">
|
||||
<label className="dev-field-label">{t('developer.onboarding.city_label')}</label>
|
||||
<label className="dev-field-label" htmlFor="onboardingCity">{t('developer.onboarding.city_label')}</label>
|
||||
<input
|
||||
id="onboardingCity"
|
||||
name="city"
|
||||
type="text"
|
||||
className="dev-input"
|
||||
value={city}
|
||||
onChange={e => setCity(e.target.value)}
|
||||
placeholder="City"
|
||||
autoComplete="address-level2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="dev-form-group">
|
||||
<label className="dev-field-label">{t('developer.onboarding.postal_label')}</label>
|
||||
<label className="dev-field-label" htmlFor="onboardingPostalCode">{t('developer.onboarding.postal_label')}</label>
|
||||
<input
|
||||
id="onboardingPostalCode"
|
||||
name="postalCode"
|
||||
type="text"
|
||||
className="dev-input"
|
||||
value={postalCode}
|
||||
onChange={e => setPostalCode(e.target.value)}
|
||||
placeholder="10001"
|
||||
autoComplete="postal-code"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@ -286,11 +301,14 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||
<div className="dev-form-group">
|
||||
<label className="dev-field-label"><Globe size={14} /> {t('developer.onboarding.country_label')}</label>
|
||||
<label className="dev-field-label" htmlFor="onboardingCountry"><Globe size={14} /> {t('developer.onboarding.country_label')}</label>
|
||||
<select
|
||||
id="onboardingCountry"
|
||||
name="country"
|
||||
className="dev-input dev-select"
|
||||
value={country}
|
||||
onChange={e => setCountry(e.target.value)}
|
||||
autoComplete="country"
|
||||
required
|
||||
>
|
||||
<option value="">Select country...</option>
|
||||
@ -301,13 +319,16 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
|
||||
</div>
|
||||
|
||||
<div className="dev-form-group">
|
||||
<label className="dev-field-label">{t('developer.onboarding.vat_label')}</label>
|
||||
<label className="dev-field-label" htmlFor="onboardingVatId">{t('developer.onboarding.vat_label')}</label>
|
||||
<input
|
||||
id="onboardingVatId"
|
||||
name="vatId"
|
||||
type="text"
|
||||
className="dev-input"
|
||||
value={vatId}
|
||||
onChange={e => setVatId(e.target.value)}
|
||||
placeholder="EU123456789 or Tax ID"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -329,36 +350,45 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
|
||||
{((!isSellingPaid && step === 2) || (isSellingPaid && step === 3)) && (
|
||||
<div className="dev-step-content">
|
||||
<div className="dev-form-group">
|
||||
<label className="dev-field-label"><Mail size={14} /> {t('developer.onboarding.support_email_label')} *</label>
|
||||
<label className="dev-field-label" htmlFor="onboardingSupportEmail"><Mail size={14} /> {t('developer.onboarding.support_email_label')} *</label>
|
||||
<input
|
||||
id="onboardingSupportEmail"
|
||||
name="supportEmail"
|
||||
type="email"
|
||||
className="dev-input"
|
||||
value={supportEmail}
|
||||
onChange={e => setSupportEmail(e.target.value)}
|
||||
placeholder="support@yourdomain.com"
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="dev-form-group">
|
||||
<label className="dev-field-label"><Globe size={14} /> {t('developer.onboarding.website_label')}</label>
|
||||
<label className="dev-field-label" htmlFor="onboardingWebsiteUrl"><Globe size={14} /> {t('developer.onboarding.website_label')}</label>
|
||||
<input
|
||||
id="onboardingWebsiteUrl"
|
||||
name="websiteUrl"
|
||||
type="url"
|
||||
className="dev-input"
|
||||
value={websiteUrl}
|
||||
onChange={e => setWebsiteUrl(e.target.value)}
|
||||
placeholder="https://yourwebsite.com"
|
||||
autoComplete="url"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="dev-form-group">
|
||||
<label className="dev-field-label"><Code2 size={14} /> {t('developer.onboarding.github_label')}</label>
|
||||
<label className="dev-field-label" htmlFor="onboardingGithubUrl"><Code2 size={14} /> {t('developer.onboarding.github_label')}</label>
|
||||
<input
|
||||
id="onboardingGithubUrl"
|
||||
name="githubUrl"
|
||||
type="text"
|
||||
className="dev-input"
|
||||
value={githubUrl}
|
||||
onChange={e => setGithubUrl(e.target.value)}
|
||||
placeholder="https://github.com/yourname"
|
||||
autoComplete="url"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1157,11 +1157,16 @@ const PluginDetail = () => {
|
||||
<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',
|
||||
|
||||
@ -38,6 +38,110 @@ const NAV: { key: Tab; label: string; icon: React.FC<{ size?: number }> ; danger
|
||||
{ key: 'danger', label: 'Danger Zone', icon: AlertTriangle, danger: true },
|
||||
];
|
||||
|
||||
// ── Settings Subcomponents (defined at module level to prevent remounting on state changes) ──
|
||||
|
||||
interface SettingsCardProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: React.FC<{ size?: number }>;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const SettingsCard: React.FC<SettingsCardProps> = ({ title, description, icon: Icon, children }) => (
|
||||
<div className="settings-card">
|
||||
{(title || Icon) && (
|
||||
<div className="settings-card-header">
|
||||
{Icon && <div className="settings-card-icon"><Icon size={16} /></div>}
|
||||
<div>
|
||||
{title && <p className="settings-card-title">{title}</p>}
|
||||
{description && <p className="settings-card-desc">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="settings-card-body">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface FieldProps {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Field: React.FC<FieldProps> = ({ label, hint, children }) => (
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">{label}</label>
|
||||
{hint && <p className="settings-hint">{hint}</p>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface SaveBtnProps {
|
||||
isSaving?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const SaveBtn: React.FC<SaveBtnProps> = ({ isSaving = false, label = 'Save Changes' }) => (
|
||||
<button type="submit" className="settings-btn settings-btn-primary" disabled={isSaving}>
|
||||
{isSaving ? <><span className="spinner-sm" />{label.replace('Save', 'Saving')}…</> : label}
|
||||
</button>
|
||||
);
|
||||
|
||||
interface ToggleProps {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
label: string;
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
const Toggle: React.FC<ToggleProps> = ({ checked, onChange, label, desc }) => (
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<p className="settings-toggle-label">{label}</p>
|
||||
{desc && <p className="settings-toggle-desc">{desc}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
className={`settings-toggle ${checked ? 'on' : ''}`}
|
||||
onClick={() => onChange(!checked)}
|
||||
>
|
||||
<span className="settings-toggle-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface PwInputProps {
|
||||
placeholder: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
show: boolean;
|
||||
onToggle: () => void;
|
||||
autoComplete?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const PwInput: React.FC<PwInputProps> = ({ placeholder, value, onChange, show, onToggle, autoComplete, id, name }) => (
|
||||
<div className="settings-pw-wrap">
|
||||
<input
|
||||
id={id}
|
||||
name={name}
|
||||
type={show ? 'text' : 'password'}
|
||||
className="settings-input"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
autoComplete={autoComplete}
|
||||
required
|
||||
/>
|
||||
<button type="button" className="settings-pw-eye" onClick={onToggle} tabIndex={-1} aria-label={show ? 'Hide password' : 'Show password'}>
|
||||
{show ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ProfilePage = () => {
|
||||
const { user, login, logout, refreshUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
@ -316,75 +420,6 @@ const ProfilePage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const SettingsCard = ({ title, description, icon: Icon, children }: {
|
||||
title: string; description?: string; icon?: React.FC<{ size?: number }>; children: React.ReactNode;
|
||||
}) => (
|
||||
<div className="settings-card">
|
||||
{(title || Icon) && (
|
||||
<div className="settings-card-header">
|
||||
{Icon && <div className="settings-card-icon"><Icon size={16} /></div>}
|
||||
<div>
|
||||
<p className="settings-card-title">{title}</p>
|
||||
{description && <p className="settings-card-desc">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="settings-card-body">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Field = ({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) => (
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">{label}</label>
|
||||
{hint && <p className="settings-hint">{hint}</p>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const SaveBtn = ({ id, label = 'Save Changes' }: { id: string; label?: string }) => (
|
||||
<button type="submit" className="settings-btn settings-btn-primary" disabled={saving[id]}>
|
||||
{saving[id] ? <><span className="spinner-sm" />{label.replace('Save', 'Saving')}…</> : label}
|
||||
</button>
|
||||
);
|
||||
|
||||
const Toggle = ({ checked, onChange, label, desc }: { checked: boolean; onChange: (v: boolean) => void; label: string; desc?: string }) => (
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<p className="settings-toggle-label">{label}</p>
|
||||
{desc && <p className="settings-toggle-desc">{desc}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
className={`settings-toggle ${checked ? 'on' : ''}`}
|
||||
onClick={() => onChange(!checked)}
|
||||
>
|
||||
<span className="settings-toggle-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PwInput = ({ placeholder, value, onChange, show, onToggle }: {
|
||||
placeholder: string; value: string; onChange: (v: string) => void; show: boolean; onToggle: () => void;
|
||||
}) => (
|
||||
<div className="settings-pw-wrap">
|
||||
<input
|
||||
type={show ? 'text' : 'password'}
|
||||
className="settings-input"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button type="button" className="settings-pw-eye" onClick={onToggle} tabIndex={-1}>
|
||||
{show ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── render ────────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
@ -452,22 +487,28 @@ const ProfilePage = () => {
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: '1.25rem' }}>
|
||||
<Field label="Username">
|
||||
<input
|
||||
id="accountUsername"
|
||||
name="username"
|
||||
className="settings-input"
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={e => setFormData({ ...formData, username: e.target.value })}
|
||||
placeholder="Your username"
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Email Address">
|
||||
<input
|
||||
id="accountEmail"
|
||||
name="email"
|
||||
className="settings-input"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={e => setFormData({ ...formData, email: e.target.value })}
|
||||
placeholder="your@email.com"
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
@ -475,9 +516,12 @@ const ProfilePage = () => {
|
||||
|
||||
<Field label="Country">
|
||||
<select
|
||||
id="accountCountry"
|
||||
name="country"
|
||||
className="settings-input settings-select"
|
||||
value={formData.country}
|
||||
onChange={e => setFormData({ ...formData, country: e.target.value })}
|
||||
autoComplete="country"
|
||||
required
|
||||
>
|
||||
<option value="">Select a country…</option>
|
||||
@ -490,7 +534,7 @@ const ProfilePage = () => {
|
||||
</Field>
|
||||
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
<SaveBtn id="accountInfo" label="Save Changes" />
|
||||
<SaveBtn isSaving={saving['accountInfo']} label="Save Changes" />
|
||||
</div>
|
||||
</form>
|
||||
</SettingsCard>
|
||||
@ -522,35 +566,44 @@ const ProfilePage = () => {
|
||||
}}>
|
||||
<Field label="Current Password">
|
||||
<PwInput
|
||||
id="currentPassword"
|
||||
name="currentPassword"
|
||||
placeholder="Current password"
|
||||
value={formData.currentPassword}
|
||||
onChange={v => setFormData({ ...formData, currentPassword: v })}
|
||||
show={showCurrentPw}
|
||||
onToggle={() => setShowCurrentPw(s => !s)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="New Password">
|
||||
<PwInput
|
||||
id="newPassword"
|
||||
name="newPassword"
|
||||
placeholder="New password"
|
||||
value={formData.newPassword}
|
||||
onChange={v => setFormData({ ...formData, newPassword: v })}
|
||||
show={showNewPw}
|
||||
onToggle={() => setShowNewPw(s => !s)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Confirm New Password">
|
||||
<PwInput
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
placeholder="Repeat new password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={v => setFormData({ ...formData, confirmPassword: v })}
|
||||
show={showConfirmPw}
|
||||
onToggle={() => setShowConfirmPw(s => !s)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Field>
|
||||
{formData.newPassword && formData.confirmPassword && formData.newPassword !== formData.confirmPassword && (
|
||||
<p className="settings-inline-error"><AlertCircle size={13} /> Passwords do not match</p>
|
||||
)}
|
||||
<SaveBtn id="password" label="Update Password" />
|
||||
<SaveBtn isSaving={saving['password']} label="Update Password" />
|
||||
</form>
|
||||
</SettingsCard>
|
||||
|
||||
@ -566,11 +619,14 @@ const ProfilePage = () => {
|
||||
<form onSubmit={disable2fa} style={{ marginTop: '1.5rem' }}>
|
||||
<Field label="Confirm with your password to disable">
|
||||
<PwInput
|
||||
id="disable2faPassword"
|
||||
name="disable2faPassword"
|
||||
placeholder="Your current password"
|
||||
value={formData.disable2faPassword}
|
||||
onChange={v => setFormData({ ...formData, disable2faPassword: v })}
|
||||
show={show2faPw}
|
||||
onToggle={() => setShow2faPw(s => !s)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</Field>
|
||||
<button type="submit" className="settings-btn settings-btn-danger-outline">
|
||||
@ -602,12 +658,18 @@ const ProfilePage = () => {
|
||||
<form onSubmit={confirm2faSetup} style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', marginTop: '0.75rem' }}>
|
||||
<input
|
||||
type="text"
|
||||
id="verify2faCode"
|
||||
name="code"
|
||||
className="settings-input settings-code-input"
|
||||
placeholder="000 000"
|
||||
maxLength={6}
|
||||
maxLength={8}
|
||||
value={verify2faCode}
|
||||
onChange={e => setVerify2faCode(e.target.value.replace(/\D/g, ''))}
|
||||
onChange={e => setVerify2faCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<button type="submit" className="settings-btn settings-btn-primary">Verify & Enable</button>
|
||||
</form>
|
||||
@ -663,93 +725,120 @@ const ProfilePage = () => {
|
||||
|
||||
<Field label="Public Display Name">
|
||||
<input
|
||||
id="devDisplayName"
|
||||
name="displayName"
|
||||
className="settings-input"
|
||||
type="text"
|
||||
value={devProfileForm.displayName}
|
||||
onChange={e => setDevProfileForm({ ...devProfileForm, displayName: e.target.value })}
|
||||
autoComplete="nickname"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Legal Full Name / Business Name" hint="Used for internal verification & tax compliance">
|
||||
<input
|
||||
id="devLegalName"
|
||||
name="legalName"
|
||||
className="settings-input"
|
||||
type="text"
|
||||
value={devProfileForm.legalName}
|
||||
onChange={e => setDevProfileForm({ ...devProfileForm, legalName: e.target.value })}
|
||||
autoComplete="name"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Street Address">
|
||||
<input
|
||||
id="devStreetAddress"
|
||||
name="streetAddress"
|
||||
className="settings-input"
|
||||
type="text"
|
||||
value={devProfileForm.streetAddress}
|
||||
onChange={e => setDevProfileForm({ ...devProfileForm, streetAddress: e.target.value })}
|
||||
placeholder="123 Main St"
|
||||
autoComplete="street-address"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||
<Field label="City">
|
||||
<input
|
||||
id="devCity"
|
||||
name="city"
|
||||
className="settings-input"
|
||||
type="text"
|
||||
value={devProfileForm.city}
|
||||
onChange={e => setDevProfileForm({ ...devProfileForm, city: e.target.value })}
|
||||
autoComplete="address-level2"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Postal / ZIP Code">
|
||||
<input
|
||||
id="devPostalCode"
|
||||
name="postalCode"
|
||||
className="settings-input"
|
||||
type="text"
|
||||
value={devProfileForm.postalCode}
|
||||
onChange={e => setDevProfileForm({ ...devProfileForm, postalCode: e.target.value })}
|
||||
autoComplete="postal-code"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="VAT / Tax ID" hint="Optional for businesses">
|
||||
<input
|
||||
id="devVatId"
|
||||
name="vatId"
|
||||
className="settings-input"
|
||||
type="text"
|
||||
value={devProfileForm.vatId}
|
||||
onChange={e => setDevProfileForm({ ...devProfileForm, vatId: e.target.value })}
|
||||
placeholder="EU123456789"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Support Email" hint="Public email for plugin buyers to contact you">
|
||||
<input
|
||||
id="devSupportEmail"
|
||||
name="supportEmail"
|
||||
className="settings-input"
|
||||
type="email"
|
||||
value={devProfileForm.supportEmail}
|
||||
onChange={e => setDevProfileForm({ ...devProfileForm, supportEmail: e.target.value })}
|
||||
placeholder="support@domain.com"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Website URL">
|
||||
<input
|
||||
id="devWebsiteUrl"
|
||||
name="websiteUrl"
|
||||
className="settings-input"
|
||||
type="url"
|
||||
value={devProfileForm.websiteUrl}
|
||||
onChange={e => setDevProfileForm({ ...devProfileForm, websiteUrl: e.target.value })}
|
||||
placeholder="https://website.com"
|
||||
autoComplete="url"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="GitHub Profile">
|
||||
<input
|
||||
id="devGithubUrl"
|
||||
name="githubUrl"
|
||||
className="settings-input"
|
||||
type="text"
|
||||
value={devProfileForm.githubUrl}
|
||||
onChange={e => setDevProfileForm({ ...devProfileForm, githubUrl: e.target.value })}
|
||||
placeholder="https://github.com/username"
|
||||
autoComplete="url"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<SaveBtn id="devProfile" label="Save Developer Info" />
|
||||
<SaveBtn isSaving={saving['devProfile']} label="Save Developer Info" />
|
||||
</form>
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
@ -65,7 +65,7 @@ const SearchResultsPage = () => {
|
||||
return (
|
||||
<div className="container">
|
||||
<SEO
|
||||
title={searchQuery ? `Search results for "${searchQuery}"` : "All Plugins"}
|
||||
title={searchQuery ? `${searchQuery} - Plugins` : "All Plugins"}
|
||||
description={`Browse our collection of Minecraft plugins. ${searchQuery ? `Showing results for ${searchQuery}.` : ''}`}
|
||||
/>
|
||||
|
||||
@ -103,14 +103,22 @@ const SearchResultsPage = () => {
|
||||
<label>Price Range ($)</label>
|
||||
<div className="price-range-inputs">
|
||||
<input
|
||||
id="searchMinPrice"
|
||||
name="minPrice"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
autoComplete="off"
|
||||
placeholder="Min"
|
||||
value={minPrice}
|
||||
onChange={e => setMinPrice(e.target.value)}
|
||||
/>
|
||||
<span>-</span>
|
||||
<input
|
||||
id="searchMaxPrice"
|
||||
name="maxPrice"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
autoComplete="off"
|
||||
placeholder="Max"
|
||||
value={maxPrice}
|
||||
onChange={e => setMaxPrice(e.target.value)}
|
||||
@ -122,10 +130,7 @@ const SearchResultsPage = () => {
|
||||
<main className="search-main">
|
||||
<div className="search-header">
|
||||
<h1 className="search-title">
|
||||
{searchQuery
|
||||
? <>Results <span>for "{searchQuery}"</span></>
|
||||
: 'All Plugins'
|
||||
}
|
||||
<span>All</span> Plugins
|
||||
<small className="results-count">({plugins.length} found)</small>
|
||||
</h1>
|
||||
|
||||
@ -159,7 +164,7 @@ const SearchResultsPage = () => {
|
||||
<div className="empty-state">
|
||||
<p>
|
||||
{searchQuery
|
||||
? `No plugins found for "${searchQuery}" with these filters.`
|
||||
? `No plugins found matching your search and filters.`
|
||||
: 'No plugins available with these filters.'
|
||||
}
|
||||
</p>
|
||||
|
||||
@ -143,10 +143,14 @@ const AdminPanel = () => {
|
||||
<div className="admin-controls">
|
||||
<div className="admin-search">
|
||||
<input
|
||||
type="text"
|
||||
id="adminSearchInput"
|
||||
name="adminSearch"
|
||||
type="search"
|
||||
placeholder={`Search ${activeTab}...`}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
{activeTab === 'logs' && (
|
||||
|
||||
@ -273,12 +273,14 @@ const ForgotPasswordPage = () => {
|
||||
<div className="field-input-wrapper">
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
className={`auth-input${emailIsInvalid ? ' email-invalid' : ''}${emailIsValid ? ' email-valid' : ''}`}
|
||||
type="email"
|
||||
placeholder="you@company.com"
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
onBlur={handleEmailBlur}
|
||||
autoComplete="email"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
|
||||
@ -373,12 +373,16 @@ const LoginPage = () => {
|
||||
<div className="field-input-wrapper">
|
||||
<input
|
||||
id="totpCode"
|
||||
name="totpCode"
|
||||
className="login-input no-icon"
|
||||
type="text"
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
maxLength={8}
|
||||
value={totpCode}
|
||||
onChange={e => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||
onChange={e => setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
autoFocus
|
||||
@ -393,6 +397,7 @@ const LoginPage = () => {
|
||||
<div className="field-input-wrapper">
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
className={`login-input${emailIsInvalid ? ' email-invalid' : ''}${emailIsValid ? ' email-valid' : ''}`}
|
||||
type="email"
|
||||
placeholder="you@company.com"
|
||||
@ -436,6 +441,7 @@ const LoginPage = () => {
|
||||
<div className="field-input-wrapper">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
className="login-input no-icon"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
|
||||
@ -292,7 +292,7 @@ const RegisterPage = () => {
|
||||
<label className="field-label" htmlFor="username">{t('auth.username')}</label>
|
||||
<div className="field-input-wrapper">
|
||||
<input
|
||||
id="username" className="field-input" type="text" placeholder="johndoe"
|
||||
id="username" name="username" className="field-input" type="text" placeholder="johndoe"
|
||||
onChange={e => setForm({ ...form, username: e.target.value })}
|
||||
required disabled={isSubmitting} autoComplete="username"
|
||||
autoCapitalize="none" spellCheck={false}
|
||||
@ -306,6 +306,7 @@ const RegisterPage = () => {
|
||||
<div className="field-input-wrapper">
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
className={`field-input${emailIsInvalid ? ' email-invalid' : ''}${emailIsValid ? ' email-valid' : ''}`}
|
||||
type="email" placeholder="you@company.com"
|
||||
value={form.email}
|
||||
@ -351,6 +352,7 @@ const RegisterPage = () => {
|
||||
<div className="field-input-wrapper">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
className="field-input password-input"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Create a strong password"
|
||||
|
||||
@ -190,11 +190,13 @@ const ResetPasswordPage = () => {
|
||||
<label className="field-label" htmlFor="password">New Password</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
className="auth-input"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
@ -204,11 +206,13 @@ const ResetPasswordPage = () => {
|
||||
<label className="field-label" htmlFor="confirmPassword">Confirm New Password</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
className="auth-input"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
|
||||
@ -207,6 +207,348 @@
|
||||
border: 1px solid var(--dash-border);
|
||||
}
|
||||
|
||||
/* --- Developer Plugins List --- */
|
||||
.dev-plugins-container {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.dev-plugins-card {
|
||||
background: var(--dash-surface, #141414);
|
||||
border: 1px solid var(--dash-border, #2a2a2a);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.dev-plugins-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem 1.75rem;
|
||||
border-bottom: 1px solid var(--dash-border, #2a2a2a);
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dev-plugins-title-area {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.dev-plugins-title {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--dash-text, #eaeaea);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dev-plugins-count {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--dash-text-muted, #888);
|
||||
font-family: var(--font-mono, monospace);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.dev-plugins-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.85rem 1.75rem;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-bottom: 1px solid var(--dash-border, #2a2a2a);
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dev-plugins-filter-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--dash-border, #2a2a2a);
|
||||
border-radius: 8px;
|
||||
padding: 6px 12px;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dev-plugins-filter-box:focus-within {
|
||||
border-color: var(--dash-primary, #f97316);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
box-shadow: 0 0 10px rgba(249, 115, 22, 0.2);
|
||||
}
|
||||
|
||||
.dev-plugins-filter-box input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--dash-text, #eaeaea);
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dev-plugins-filter-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dev-plugins-filter-tab {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--dash-border, #2a2a2a);
|
||||
color: var(--dash-text-muted, #888);
|
||||
padding: 4px 12px;
|
||||
border-radius: 16px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.dev-plugins-filter-tab:hover {
|
||||
color: var(--dash-text, #eaeaea);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.dev-plugins-filter-tab.active {
|
||||
background: var(--dash-primary-light, rgba(249, 115, 22, 0.15));
|
||||
color: var(--dash-primary, #f97316);
|
||||
border-color: rgba(249, 115, 22, 0.4);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dev-plugins-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dev-plugins-table th {
|
||||
padding: 12px 18px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--dash-text-muted, #888);
|
||||
border-bottom: 1px solid var(--dash-border, #2a2a2a);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.dev-plugins-table td {
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--dash-border, #2a2a2a);
|
||||
vertical-align: middle;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.dev-plugins-row:hover td {
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
.dev-plugins-row:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Plugin Column - Wide & Prominent */
|
||||
.dev-plugins-col-main {
|
||||
width: 44%;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.dev-plugins-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dev-plugins-thumb {
|
||||
width: 116px;
|
||||
height: 65px;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 8px;
|
||||
background: #181818;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dev-plugins-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.dev-plugins-row:hover .dev-plugins-thumb img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.dev-plugins-thumb-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, rgba(249, 115, 22, 0.15), rgba(15, 23, 42, 0.9));
|
||||
color: var(--dash-primary, #f97316);
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.dev-plugins-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dev-plugins-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dev-plugins-item-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--dash-text, #eaeaea);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.dev-plugins-item-title:hover {
|
||||
color: var(--dash-primary, #f97316);
|
||||
}
|
||||
|
||||
.dev-plugins-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dev-plugins-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: var(--dash-text-muted, #94a3b8);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.dev-plugins-badge-mono {
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.dev-plugins-badge-early {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #f59e0b;
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.dev-plugins-badge-paid {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: #10b981;
|
||||
border-color: rgba(16, 185, 129, 0.25);
|
||||
}
|
||||
|
||||
.dev-plugins-hover-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
opacity: 0;
|
||||
transform: translateY(2px);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.dev-plugins-row:hover .dev-plugins-hover-actions {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.dev-plugins-action-icon-btn {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: var(--dash-text-secondary, #a3a3a3);
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.dev-plugins-action-icon-btn:hover {
|
||||
background: rgba(249, 115, 22, 0.15);
|
||||
color: var(--dash-primary, #f97316);
|
||||
border-color: rgba(249, 115, 22, 0.35);
|
||||
}
|
||||
|
||||
/* Visibility dot & badge */
|
||||
.dev-plugins-visibility-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dev-plugins-visibility-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--dash-text, #eaeaea);
|
||||
}
|
||||
|
||||
.dev-plugins-status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #10b981;
|
||||
box-shadow: 0 0 6px rgba(16, 185, 129, 0.5);
|
||||
}
|
||||
|
||||
.dev-plugins-type-pill {
|
||||
font-size: 0.72rem;
|
||||
color: var(--dash-text-muted, #888);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dev-plugins-date-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--dash-text-muted, #888);
|
||||
}
|
||||
|
||||
.dev-plugins-date-primary {
|
||||
color: var(--dash-text, #eaeaea);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-nav {
|
||||
display: flex;
|
||||
|
||||
@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAnalytics } from './useAnalytics';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Settings, Plus, ExternalLink, Sparkles } from 'lucide-react';
|
||||
import { Settings, Plus, ExternalLink, Sparkles, Search, BarChart2, Package } from 'lucide-react';
|
||||
import { useAuth } from '../../App';
|
||||
import DeveloperOnboardingModal from '../../components/DeveloperOnboardingModal';
|
||||
import { getPluginUrl } from '../../utils/url';
|
||||
@ -14,6 +14,8 @@ const DashboardPlugins = () => {
|
||||
const { timeSeries } = useAnalytics();
|
||||
const navigate = useNavigate();
|
||||
const [isDevModalOpen, setIsDevModalOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterType, setFilterType] = useState<string>('all');
|
||||
|
||||
// Grouping analytics data by plugin ID
|
||||
const plugins = useMemo(() => {
|
||||
@ -22,19 +24,56 @@ const DashboardPlugins = () => {
|
||||
if (!acc[id]) {
|
||||
acc[id] = {
|
||||
id: id,
|
||||
name: curr.name,
|
||||
name: curr.name,
|
||||
version: curr.version,
|
||||
pluginType: curr.pluginType || curr.type,
|
||||
category: curr.category,
|
||||
previewPath: curr.previewPath || curr.preview_path,
|
||||
priceCents: curr.priceCents || curr.price_cents || 0,
|
||||
isEarlyAccess: curr.isEarlyAccess || curr.is_early_access || false,
|
||||
downloads: 0,
|
||||
earnings: 0,
|
||||
views: 0,
|
||||
avgRating: curr.avgRating || 0,
|
||||
created_at: curr.created_at,
|
||||
updated_at: curr.updated_at
|
||||
};
|
||||
}
|
||||
acc[id].downloads += (curr.downloads || 0);
|
||||
acc[id].earnings += (curr.earnings || 0);
|
||||
acc[id].views += (curr.views || 0);
|
||||
if (curr.avgRating > 0) acc[id].avgRating = curr.avgRating;
|
||||
return acc;
|
||||
}, {} as Record<string, any>));
|
||||
}, [timeSeries]);
|
||||
|
||||
// Check if creator has any paid plugins or earnings
|
||||
const hasPaidPlugins = useMemo(() => {
|
||||
return plugins.some((p: any) =>
|
||||
(p.pluginType && p.pluginType.toLowerCase() === 'paid') ||
|
||||
(p.priceCents && p.priceCents > 0) ||
|
||||
(p.earnings && p.earnings > 0)
|
||||
);
|
||||
}, [plugins]);
|
||||
|
||||
const filteredPlugins = useMemo(() => {
|
||||
return plugins.filter((p: any) => {
|
||||
const term = searchTerm.trim().toLowerCase();
|
||||
const matchesSearch = !term ||
|
||||
(p.name && p.name.toLowerCase().includes(term)) ||
|
||||
(p.category && p.category.toLowerCase().includes(term)) ||
|
||||
String(p.id).includes(term);
|
||||
|
||||
const type = (p.pluginType || 'free').toLowerCase();
|
||||
const matchesType = filterType === 'all' ||
|
||||
(filterType === 'paid' && type === 'paid') ||
|
||||
(filterType === 'free' && type === 'free') ||
|
||||
(filterType === 'early_access' && p.isEarlyAccess);
|
||||
|
||||
return matchesSearch && matchesType;
|
||||
});
|
||||
}, [plugins, searchTerm, filterType]);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr || typeof dateStr !== 'string') return 'N/A';
|
||||
const normalized = dateStr.toLowerCase().trim();
|
||||
@ -47,8 +86,8 @@ const DashboardPlugins = () => {
|
||||
|
||||
if (!user?.is_developer) {
|
||||
return (
|
||||
<div className="dashboard-grid-layout">
|
||||
<div className="data-card" style={{ textAlign: 'center', padding: '3.5rem 2rem' }}>
|
||||
<div className="dev-plugins-container">
|
||||
<div className="dev-plugins-card" style={{ textAlign: 'center', padding: '3.5rem 2rem' }}>
|
||||
<div style={{
|
||||
width: 64, height: 64, borderRadius: '50%',
|
||||
background: 'rgba(249, 115, 22, 0.12)', border: '1px solid rgba(249, 115, 22, 0.3)',
|
||||
@ -80,82 +119,250 @@ const DashboardPlugins = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard-grid-layout">
|
||||
<div className="data-card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
||||
<h3>{t('developer.dashboard.plugins')}</h3>
|
||||
<div className="dev-plugins-container">
|
||||
<div className="dev-plugins-card">
|
||||
{/* Header */}
|
||||
<div className="dev-plugins-header">
|
||||
<div className="dev-plugins-title-area">
|
||||
<h3 className="dev-plugins-title">{t('developer.dashboard.plugins')}</h3>
|
||||
<span className="dev-plugins-count">{plugins.length}</span>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => navigate('/dashboard/add-plugin')}>
|
||||
<Plus size={18} style={{ marginRight: '8px' }} />
|
||||
{t('developer.dashboard.add_plugin')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('plugin.version')}</th>
|
||||
<th>{t('developer.dashboard.total_downloads')}</th>
|
||||
<th>{t('developer.dashboard.total_revenue')}</th>
|
||||
<th>Dates</th>
|
||||
<th style={{ textAlign: 'right' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plugins.map((p: any) => (
|
||||
<tr key={p.id}>
|
||||
<td data-label="Name">
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<strong>{p.name}</strong>
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--dash-text-muted)' }}>ID: {p.id}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="Downloads" className="mono">{p.downloads.toLocaleString()}</td>
|
||||
<td data-label="Earnings" className="mono" style={{ color: 'var(--success)' }}>
|
||||
+${p.earnings.toFixed(2)}
|
||||
</td>
|
||||
<td data-label="Dates">
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--dash-text-muted)' }}>
|
||||
<div>Created: {formatDate(p.created_at)}</div>
|
||||
<div>Updated: {formatDate(p.updated_at)}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="Actions" style={{ textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||
{/* View on Marketplace */}
|
||||
<button
|
||||
className="btn btn-icon-only"
|
||||
onClick={() => navigate(getPluginUrl(p))}
|
||||
title="View in Store"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
</button>
|
||||
|
||||
{/* Manage/Edit Plugin */}
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => navigate(`/dashboard/manage-plugin/${p.id}`)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '4px' }}
|
||||
>
|
||||
<Settings size={16} />
|
||||
{t('developer.dashboard.manage_plugin')}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{plugins.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ textAlign: 'center', padding: '3rem' }}>
|
||||
<div style={{ opacity: 0.6 }}>
|
||||
<p>No plugins published yet.</p>
|
||||
<small>Click "Add New Plugin" to get started</small>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="dev-plugins-toolbar">
|
||||
<div className="dev-plugins-filter-box">
|
||||
<Search size={15} color="var(--dash-text-muted)" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter your plugins..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="dev-plugins-filter-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`dev-plugins-filter-tab ${filterType === 'all' ? 'active' : ''}`}
|
||||
onClick={() => setFilterType('all')}
|
||||
>
|
||||
All ({plugins.length})
|
||||
</button>
|
||||
{hasPaidPlugins && (
|
||||
<button
|
||||
type="button"
|
||||
className={`dev-plugins-filter-tab ${filterType === 'paid' ? 'active' : ''}`}
|
||||
onClick={() => setFilterType('paid')}
|
||||
>
|
||||
Paid
|
||||
</button>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<button
|
||||
type="button"
|
||||
className={`dev-plugins-filter-tab ${filterType === 'free' ? 'active' : ''}`}
|
||||
onClick={() => setFilterType('free')}
|
||||
>
|
||||
Free
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`dev-plugins-filter-tab ${filterType === 'early_access' ? 'active' : ''}`}
|
||||
onClick={() => setFilterType('early_access')}
|
||||
>
|
||||
Early Access
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="dev-plugins-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="dev-plugins-col-main">Plugin</th>
|
||||
<th>Visibility</th>
|
||||
<th>{t('developer.dashboard.total_downloads')}</th>
|
||||
{hasPaidPlugins && <th>{t('developer.dashboard.total_revenue')}</th>}
|
||||
<th>Date</th>
|
||||
<th style={{ textAlign: 'right' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredPlugins.map((p: any) => {
|
||||
const pluginType = (p.pluginType || 'free').toLowerCase();
|
||||
const isPaid = pluginType === 'paid' || (p.priceCents && p.priceCents > 0);
|
||||
const priceFormatted = isPaid ? `€${((p.priceCents || 0) / 100).toFixed(2)}` : 'Free';
|
||||
const initial = (p.name || '?').charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
<tr key={p.id} className="dev-plugins-row">
|
||||
{/* Plugin Details (Wide Column) */}
|
||||
<td className="dev-plugins-col-main">
|
||||
<div className="dev-plugins-cell">
|
||||
{/* 16:9 Thumbnail */}
|
||||
<div
|
||||
className="dev-plugins-thumb"
|
||||
onClick={() => navigate(`/dashboard/manage-plugin/${p.id}`)}
|
||||
title="Manage Plugin"
|
||||
>
|
||||
{p.previewPath ? (
|
||||
<img src={p.previewPath} alt={p.name} loading="lazy" />
|
||||
) : (
|
||||
<div className="dev-plugins-thumb-fallback">
|
||||
<span>{initial}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info & Meta */}
|
||||
<div className="dev-plugins-info">
|
||||
<div className="dev-plugins-title-row">
|
||||
<span
|
||||
className="dev-plugins-item-title"
|
||||
onClick={() => navigate(`/dashboard/manage-plugin/${p.id}`)}
|
||||
title={p.name}
|
||||
>
|
||||
{p.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="dev-plugins-meta-row">
|
||||
<span className="dev-plugins-badge dev-plugins-badge-mono">
|
||||
{p.version ? `v${p.version}` : `ID: ${p.id}`}
|
||||
</span>
|
||||
{p.category && (
|
||||
<span className="dev-plugins-badge">
|
||||
{p.category}
|
||||
</span>
|
||||
)}
|
||||
{p.isEarlyAccess && (
|
||||
<span className="dev-plugins-badge dev-plugins-badge-early">
|
||||
Early Access
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hover Quick Actions */}
|
||||
<div className="dev-plugins-hover-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="dev-plugins-action-icon-btn"
|
||||
onClick={() => navigate(`/dashboard/manage-plugin/${p.id}`)}
|
||||
title="Edit plugin details & settings"
|
||||
>
|
||||
<Settings size={13} style={{ marginRight: 3 }} />
|
||||
<span style={{ fontSize: '0.72rem' }}>Edit</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="dev-plugins-action-icon-btn"
|
||||
onClick={() => navigate(getPluginUrl(p))}
|
||||
title="View in Marketplace"
|
||||
>
|
||||
<ExternalLink size={13} style={{ marginRight: 3 }} />
|
||||
<span style={{ fontSize: '0.72rem' }}>Store</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="dev-plugins-action-icon-btn"
|
||||
onClick={() => navigate(`/dashboard/manage-plugin/${p.id}?tab=analytics`)}
|
||||
title="Plugin Analytics"
|
||||
>
|
||||
<BarChart2 size={13} style={{ marginRight: 3 }} />
|
||||
<span style={{ fontSize: '0.72rem' }}>Analytics</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Visibility & Type */}
|
||||
<td>
|
||||
<div className="dev-plugins-visibility-cell">
|
||||
<div className="dev-plugins-visibility-status">
|
||||
<span className="dev-plugins-status-dot" />
|
||||
<span>Public</span>
|
||||
</div>
|
||||
<span className="dev-plugins-type-pill">
|
||||
{isPaid ? priceFormatted : 'Free'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Total Downloads */}
|
||||
<td className="mono">
|
||||
{p.downloads.toLocaleString()}
|
||||
</td>
|
||||
|
||||
{/* Total Revenue (Only shown if hasPaidPlugins is true) */}
|
||||
{hasPaidPlugins && (
|
||||
<td className="mono" style={{ color: p.earnings > 0 ? 'var(--success)' : 'var(--dash-text-muted)' }}>
|
||||
{isPaid || p.earnings > 0 ? (
|
||||
p.earnings > 0 ? `+€${p.earnings.toFixed(2)}` : '€0.00'
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
|
||||
{/* Dates */}
|
||||
<td>
|
||||
<div className="dev-plugins-date-cell">
|
||||
<div className="dev-plugins-date-primary">{formatDate(p.created_at)}</div>
|
||||
<div style={{ fontSize: '0.72rem' }}>Uploaded</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||
<button
|
||||
className="btn btn-icon-only"
|
||||
onClick={() => navigate(getPluginUrl(p))}
|
||||
title="View in Store"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => navigate(`/dashboard/manage-plugin/${p.id}`)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '4px', padding: '0.4rem 0.75rem', fontSize: '0.8rem' }}
|
||||
>
|
||||
<Settings size={14} />
|
||||
{t('developer.dashboard.manage_plugin')}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredPlugins.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={hasPaidPlugins ? 6 : 5} style={{ textAlign: 'center', padding: '3.5rem 1rem' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', opacity: 0.7 }}>
|
||||
<Package size={36} color="var(--dash-text-muted)" />
|
||||
<p style={{ margin: 0, fontWeight: 600, fontSize: '0.95rem' }}>
|
||||
{searchTerm ? 'No plugins matched your filter.' : 'No plugins published yet.'}
|
||||
</p>
|
||||
<small style={{ color: 'var(--dash-text-muted)' }}>
|
||||
{searchTerm ? 'Try adjusting your search or category filter.' : 'Click "Add New Plugin" to publish your first creation.'}
|
||||
</small>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -567,16 +567,19 @@ const AddPlugin = () => {
|
||||
|
||||
{/* Name */}
|
||||
<div className="mp-form-group">
|
||||
<label className="mp-label">
|
||||
<label className="mp-label" htmlFor="pluginName">
|
||||
Plugin Name <span className="ap-required">*</span>
|
||||
<CharCount current={name.length} max={MAX_NAME_LEN} />
|
||||
</label>
|
||||
<input
|
||||
id="pluginName"
|
||||
name="pluginName"
|
||||
className="mp-input"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="My Awesome Plugin"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
style={{ borderColor: fieldErrors.name ? 'var(--mp-error)' : undefined }}
|
||||
/>
|
||||
@ -586,8 +589,8 @@ const AddPlugin = () => {
|
||||
<div className="mp-form-row">
|
||||
{/* Category */}
|
||||
<div className="mp-form-group">
|
||||
<label className="mp-label">Category</label>
|
||||
<select className="mp-select" value={category}
|
||||
<label className="mp-label" htmlFor="pluginCategory">Category</label>
|
||||
<select id="pluginCategory" name="category" className="mp-select" value={category}
|
||||
onChange={e => setCategory(e.target.value)}>
|
||||
{PLUGIN_CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
@ -596,17 +599,20 @@ const AddPlugin = () => {
|
||||
|
||||
{/* Source link */}
|
||||
<div className="mp-form-group">
|
||||
<label className="mp-label">
|
||||
<label className="mp-label" htmlFor="pluginSourceLink">
|
||||
Source Link{' '}
|
||||
<span style={{ color: 'var(--mp-text-3)', fontWeight: 400 }}>(optional)</span>
|
||||
<CharCount current={sourceLink.length} max={MAX_SOURCE_LINK_LEN} />
|
||||
</label>
|
||||
<input
|
||||
id="pluginSourceLink"
|
||||
name="sourceLink"
|
||||
className="mp-input"
|
||||
type="url"
|
||||
value={sourceLink}
|
||||
onChange={e => setSourceLink(e.target.value)}
|
||||
placeholder="https://github.com/you/plugin"
|
||||
autoComplete="url"
|
||||
style={{ borderColor: fieldErrors.sourceLink ? 'var(--mp-error)' : undefined }}
|
||||
/>
|
||||
<FieldError msg={fieldErrors.sourceLink} />
|
||||
@ -616,17 +622,20 @@ const AddPlugin = () => {
|
||||
<div className="mp-form-row">
|
||||
{/* Keywords */}
|
||||
<div className="mp-form-group">
|
||||
<label className="mp-label">
|
||||
<label className="mp-label" htmlFor="pluginKeywords">
|
||||
Keywords{' '}
|
||||
<span style={{ color: 'var(--mp-text-3)', fontWeight: 400 }}>(comma-separated)</span>
|
||||
<CharCount current={keywords.length} max={MAX_KEYWORDS_LEN} />
|
||||
</label>
|
||||
<input
|
||||
id="pluginKeywords"
|
||||
name="keywords"
|
||||
className="mp-input"
|
||||
type="text"
|
||||
value={keywords}
|
||||
onChange={e => setKeywords(e.target.value)}
|
||||
placeholder="economy, shop, currency"
|
||||
autoComplete="off"
|
||||
style={{ borderColor: fieldErrors.keywords ? 'var(--mp-error)' : undefined }}
|
||||
/>
|
||||
<FieldError msg={fieldErrors.keywords} />
|
||||
@ -634,17 +643,20 @@ const AddPlugin = () => {
|
||||
|
||||
{/* YouTube Video URL */}
|
||||
<div className="mp-form-group">
|
||||
<label className="mp-label">
|
||||
<label className="mp-label" htmlFor="pluginYoutubeUrl">
|
||||
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
|
||||
id="pluginYoutubeUrl"
|
||||
name="youtubeVideoUrl"
|
||||
className="mp-input"
|
||||
type="url"
|
||||
value={youtubeVideoUrl}
|
||||
onChange={e => setYoutubeVideoUrl(e.target.value)}
|
||||
placeholder="https://www.youtube.com/watch?v=..."
|
||||
autoComplete="url"
|
||||
style={{ borderColor: fieldErrors.youtubeVideoUrl ? 'var(--mp-error)' : undefined }}
|
||||
/>
|
||||
<FieldError msg={fieldErrors.youtubeVideoUrl} />
|
||||
@ -655,6 +667,8 @@ const AddPlugin = () => {
|
||||
<div className="mp-form-group">
|
||||
<label className="mp-checkbox-label" style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', userSelect: 'none' }}>
|
||||
<input
|
||||
id="pluginEarlyAccess"
|
||||
name="earlyAccess"
|
||||
type="checkbox"
|
||||
checked={isEarlyAccess}
|
||||
onChange={e => setIsEarlyAccess(e.target.checked)}
|
||||
@ -672,13 +686,16 @@ const AddPlugin = () => {
|
||||
<div className="mp-card">
|
||||
<div className="mp-card-title"><User size={14} />Authorship</div>
|
||||
<div className="mp-form-group" style={{ marginBottom: 0 }}>
|
||||
<label className="mp-label">Developer Name</label>
|
||||
<label className="mp-label" htmlFor="pluginDeveloperName">Developer Name</label>
|
||||
<input
|
||||
id="pluginDeveloperName"
|
||||
name="developerName"
|
||||
className="mp-input"
|
||||
type="text"
|
||||
value={developerName}
|
||||
onChange={e => setDeveloperName(e.target.value)}
|
||||
placeholder={user?.username ?? 'Developer or studio name'}
|
||||
autoComplete="name"
|
||||
/>
|
||||
<span style={{ fontSize: '0.74rem', color: 'var(--mp-text-3)', marginTop: '0.3rem', display: 'block' }}>
|
||||
The person or team who built the plugin. Leave blank to use your account name.
|
||||
@ -998,7 +1015,7 @@ const AddPlugin = () => {
|
||||
|
||||
{licenseType === 'paid' && stripeConnected && (
|
||||
<div className="mp-form-group" style={{ marginTop: '1.5rem', animation: 'fadeIn 0.2s ease' }}>
|
||||
<label className="mp-label">Base Price (EUR)</label>
|
||||
<label className="mp-label" htmlFor="pluginBasePrice">Base Price (EUR)</label>
|
||||
{priceTooLow && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.5rem', padding: '0.45rem 0.65rem', background: 'rgba(255,77,79,0.1)', border: '1px solid rgba(255,77,79,0.35)', borderRadius: 6, fontSize: '0.75rem', color: 'var(--mp-error, #ff4d4f)' }}>
|
||||
<AlertTriangle size={13} style={{ flexShrink: 0 }} />
|
||||
@ -1007,9 +1024,19 @@ const AddPlugin = () => {
|
||||
)}
|
||||
<div style={{ position: 'relative', maxWidth: 160 }}>
|
||||
<span style={{ position: 'absolute', left: '0.85rem', top: '50%', transform: 'translateY(-50%)', color: priceTooLow ? 'var(--mp-error, #ff4d4f)' : 'var(--mp-text-3)', fontSize: '0.85rem' }}>€</span>
|
||||
<input className="mp-input" type="number" step="0.01" min="0.99" value={price}
|
||||
<input
|
||||
id="pluginBasePrice"
|
||||
name="price"
|
||||
className="mp-input"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.99"
|
||||
inputMode="decimal"
|
||||
autoComplete="off"
|
||||
value={price}
|
||||
onChange={e => setPrice(parseFloat(e.target.value) || 0)}
|
||||
style={{ paddingLeft: '1.6rem', borderColor: priceTooLow ? 'var(--mp-error, #ff4d4f)' : undefined }} />
|
||||
style={{ paddingLeft: '1.6rem', borderColor: priceTooLow ? 'var(--mp-error, #ff4d4f)' : undefined }}
|
||||
/>
|
||||
</div>
|
||||
{(() => {
|
||||
const priceCents = Math.round(price * 100);
|
||||
@ -1052,17 +1079,26 @@ const AddPlugin = () => {
|
||||
</div>
|
||||
</div>
|
||||
<label className="mp-toggle" style={{ marginLeft: '1rem', flexShrink: 0 }}>
|
||||
<input type="checkbox" checked={isPreorder} onChange={e => setIsPreorder(e.target.checked)} />
|
||||
<input
|
||||
id="pluginIsPreorder"
|
||||
name="isPreorder"
|
||||
type="checkbox"
|
||||
checked={isPreorder}
|
||||
onChange={e => setIsPreorder(e.target.checked)}
|
||||
/>
|
||||
<span className="mp-toggle-slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isPreorder && (
|
||||
<div style={{ marginTop: '0.85rem', padding: '0.85rem', background: 'var(--mp-bg-2)', borderRadius: 8, border: '1px solid var(--mp-border)' }}>
|
||||
<label className="mp-label" style={{ fontSize: '0.75rem', marginBottom: '0.35rem' }}>Expected Release Date (Optional)</label>
|
||||
<label className="mp-label" htmlFor="pluginPreorderReleaseDate" style={{ fontSize: '0.75rem', marginBottom: '0.35rem' }}>Expected Release Date (Optional)</label>
|
||||
<input
|
||||
id="pluginPreorderReleaseDate"
|
||||
name="preorderReleaseDate"
|
||||
type="date"
|
||||
className="mp-input"
|
||||
autoComplete="off"
|
||||
value={preorderReleaseDate}
|
||||
onChange={e => setPreorderReleaseDate(e.target.value)}
|
||||
style={{ maxWidth: 220 }}
|
||||
|
||||
@ -96,13 +96,18 @@ const Coupons = ({ plugin }: Props) => {
|
||||
</div>
|
||||
<form onSubmit={handleCreate} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||
<div className="mp-form-group">
|
||||
<label className="mp-label">Promo Code</label>
|
||||
<label className="mp-label" htmlFor="couponCode">Promo Code</label>
|
||||
<input
|
||||
id="couponCode"
|
||||
name="code"
|
||||
className="mp-input"
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={e => setCode(e.target.value.toUpperCase())}
|
||||
placeholder="E.g., SAVE50"
|
||||
autoComplete="off"
|
||||
autoCapitalize="characters"
|
||||
spellCheck={false}
|
||||
required
|
||||
style={{ textTransform: 'uppercase', fontFamily: 'var(--font-mono)' }}
|
||||
/>
|
||||
@ -110,11 +115,15 @@ const Coupons = ({ plugin }: Props) => {
|
||||
|
||||
<div className="mp-form-row">
|
||||
<div className="mp-form-group">
|
||||
<label className="mp-label">Discount %</label>
|
||||
<label className="mp-label" htmlFor="couponDiscount">Discount %</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<input
|
||||
id="couponDiscount"
|
||||
name="discountPercent"
|
||||
className="mp-input"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
min={1}
|
||||
max={99}
|
||||
value={discountPercent}
|
||||
@ -125,10 +134,14 @@ const Coupons = ({ plugin }: Props) => {
|
||||
</div>
|
||||
|
||||
<div className="mp-form-group">
|
||||
<label className="mp-label">Max Uses (Optional)</label>
|
||||
<label className="mp-label" htmlFor="couponMaxUses">Max Uses (Optional)</label>
|
||||
<input
|
||||
id="couponMaxUses"
|
||||
name="maxRedemptions"
|
||||
className="mp-input"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
min={1}
|
||||
value={maxRedemptions}
|
||||
onChange={e => setMaxRedemptions(e.target.value)}
|
||||
@ -138,10 +151,13 @@ const Coupons = ({ plugin }: Props) => {
|
||||
</div>
|
||||
|
||||
<div className="mp-form-group">
|
||||
<label className="mp-label">Expiration Date (Optional)</label>
|
||||
<label className="mp-label" htmlFor="couponExpiresAt">Expiration Date (Optional)</label>
|
||||
<input
|
||||
id="couponExpiresAt"
|
||||
name="expiresAt"
|
||||
className="mp-input"
|
||||
type="datetime-local"
|
||||
autoComplete="off"
|
||||
value={expiresAt}
|
||||
onChange={e => setExpiresAt(e.target.value)}
|
||||
/>
|
||||
|
||||
@ -75,11 +75,15 @@ const DangerZone = ({ plugin }: Props) => {
|
||||
<div className="mp-danger-input-row">
|
||||
<div className="mp-form-group">
|
||||
<input
|
||||
id="deleteConfirmInput"
|
||||
name="deleteConfirm"
|
||||
className="mp-input"
|
||||
type="text"
|
||||
value={deleteConfirm}
|
||||
onChange={e => setDeleteConfirm(e.target.value)}
|
||||
placeholder={plugin.name}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
style={{fontFamily:'var(--font-mono)'}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -109,13 +109,17 @@ const Licenses = ({ plugin }: Props) => {
|
||||
</p>
|
||||
<form onSubmit={handleGrantLicense} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||
<div className="mp-form-group">
|
||||
<label className="mp-label">User (Username, Email, or User ID)</label>
|
||||
<label className="mp-label" htmlFor="licenseUserIdentifier">User (Username, Email, or User ID)</label>
|
||||
<input
|
||||
id="licenseUserIdentifier"
|
||||
name="userIdentifier"
|
||||
type="text"
|
||||
className="mp-input"
|
||||
placeholder="e.g. alex or alex@example.com"
|
||||
value={userIdentifier}
|
||||
onChange={e => setUserIdentifier(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -163,14 +163,18 @@ const Pricing = ({ plugin, onSaved }: Props) => {
|
||||
)}
|
||||
|
||||
<div className="mp-form-group" style={{ marginBottom: 0 }}>
|
||||
<label className="mp-label">Price (EUR)</label>
|
||||
<label className="mp-label" htmlFor="pluginPriceInput">Price (EUR)</label>
|
||||
<div style={{ position: 'relative', maxWidth: 180 }}>
|
||||
<span style={{ position: 'absolute', left: '0.85rem', top: '50%', transform: 'translateY(-50%)', color: priceTooLow ? 'var(--mp-error, #ff4d4f)' : 'var(--mp-text-3)', fontSize: '0.85rem' }}>€</span>
|
||||
<input
|
||||
id="pluginPriceInput"
|
||||
name="price"
|
||||
className="mp-input"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.99"
|
||||
inputMode="decimal"
|
||||
autoComplete="off"
|
||||
value={price}
|
||||
onChange={e => handlePriceChange(e.target.value)}
|
||||
style={{ paddingLeft: '1.6rem', borderColor: priceTooLow ? 'var(--mp-error, #ff4d4f)' : undefined }}
|
||||
@ -201,13 +205,17 @@ const Pricing = ({ plugin, onSaved }: Props) => {
|
||||
|
||||
{/* Discount % — always visible when paid */}
|
||||
<div className="mp-form-group">
|
||||
<label className="mp-label">Discount</label>
|
||||
<label className="mp-label" htmlFor="pluginDiscountPercent">Discount</label>
|
||||
<div style={{ position: 'relative', maxWidth: 180 }}>
|
||||
<input
|
||||
id="pluginDiscountPercent"
|
||||
name="discountPercent"
|
||||
className="mp-input"
|
||||
type="number"
|
||||
min="1"
|
||||
max="99"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
value={discountPercent}
|
||||
onChange={e => setDiscountPercent(Math.min(99, Math.max(1, parseInt(e.target.value) || 0)))}
|
||||
style={{ paddingRight: '2.2rem' }}
|
||||
@ -284,17 +292,26 @@ const Pricing = ({ plugin, onSaved }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
<label className="mp-toggle" style={{ marginLeft: '1rem', flexShrink: 0 }}>
|
||||
<input type="checkbox" checked={isPreorder} onChange={e => setIsPreorder(e.target.checked)} />
|
||||
<input
|
||||
id="pricingIsPreorder"
|
||||
name="isPreorder"
|
||||
type="checkbox"
|
||||
checked={isPreorder}
|
||||
onChange={e => setIsPreorder(e.target.checked)}
|
||||
/>
|
||||
<span className="mp-toggle-slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isPreorder && (
|
||||
<div style={{ marginTop: '1rem', padding: '0.85rem', background: 'var(--mp-bg-2)', borderRadius: 8, border: '1px solid var(--mp-border)' }}>
|
||||
<label className="mp-label" style={{ fontSize: '0.78rem', marginBottom: '0.35rem' }}>Expected Release Date (Optional)</label>
|
||||
<label className="mp-label" htmlFor="pricingPreorderReleaseDate" style={{ fontSize: '0.78rem', marginBottom: '0.35rem' }}>Expected Release Date (Optional)</label>
|
||||
<input
|
||||
id="pricingPreorderReleaseDate"
|
||||
name="preorderReleaseDate"
|
||||
type="date"
|
||||
className="mp-input"
|
||||
autoComplete="off"
|
||||
value={preorderReleaseDate}
|
||||
onChange={e => setPreorderReleaseDate(e.target.value)}
|
||||
style={{ maxWidth: 220 }}
|
||||
|
||||
@ -166,15 +166,19 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
|
||||
</div>
|
||||
|
||||
<div className="mp-form-group">
|
||||
<label className="mp-label">
|
||||
<label className="mp-label" htmlFor="pluginVersionTag">
|
||||
Version Tag <span style={{color:'var(--mp-text-3)', fontWeight:400}}>(optional — e.g. 1.2.3)</span>
|
||||
</label>
|
||||
<input
|
||||
id="pluginVersionTag"
|
||||
name="version"
|
||||
className="mp-input"
|
||||
type="text"
|
||||
value={version}
|
||||
onChange={e => setVersion(e.target.value)}
|
||||
placeholder="1.0.0"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
style={{fontFamily:'var(--font-mono)'}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -336,48 +336,79 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
|
||||
</div>
|
||||
|
||||
<div className="mp-form-group">
|
||||
<label className="mp-label">Plugin Name</label>
|
||||
<input className="mp-input" type="text" value={name}
|
||||
onChange={e => setName(e.target.value)} required />
|
||||
<label className="mp-label" htmlFor="listingPluginName">Plugin Name</label>
|
||||
<input
|
||||
id="listingPluginName"
|
||||
name="pluginName"
|
||||
className="mp-input"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mp-form-row">
|
||||
<div className="mp-form-group">
|
||||
<label className="mp-label">Category</label>
|
||||
<select className="mp-select" value={category} onChange={e => setCategory(e.target.value)}>
|
||||
<label className="mp-label" htmlFor="listingPluginCategory">Category</label>
|
||||
<select id="listingPluginCategory" name="category" className="mp-select" value={category} onChange={e => setCategory(e.target.value)}>
|
||||
{PLUGIN_CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="mp-form-group">
|
||||
<label className="mp-label">Source Link</label>
|
||||
<input className="mp-input" type="url" value={sourceLink}
|
||||
<label className="mp-label" htmlFor="listingPluginSourceLink">Source Link</label>
|
||||
<input
|
||||
id="listingPluginSourceLink"
|
||||
name="sourceLink"
|
||||
className="mp-input"
|
||||
type="url"
|
||||
value={sourceLink}
|
||||
onChange={e => setSourceLink(e.target.value)}
|
||||
placeholder="https://github.com/you/plugin" />
|
||||
placeholder="https://github.com/you/plugin"
|
||||
autoComplete="url"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mp-form-row">
|
||||
<div className="mp-form-group">
|
||||
<label className="mp-label">
|
||||
<label className="mp-label" htmlFor="listingPluginKeywords">
|
||||
Keywords <span style={{color:'var(--mp-text-3)',fontWeight:400}}>(comma-separated)</span>
|
||||
</label>
|
||||
<input className="mp-input" type="text" value={keywords}
|
||||
<input
|
||||
id="listingPluginKeywords"
|
||||
name="keywords"
|
||||
className="mp-input"
|
||||
type="text"
|
||||
value={keywords}
|
||||
onChange={e => setKeywords(e.target.value)}
|
||||
placeholder="economy, shop, currency" />
|
||||
placeholder="economy, shop, currency"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="mp-form-group">
|
||||
<label className="mp-label">
|
||||
<label className="mp-label" htmlFor="listingPluginYoutubeUrl">
|
||||
YouTube Video URL <span style={{color:'var(--mp-text-3)',fontWeight:400}}>(optional)</span>
|
||||
</label>
|
||||
<input className="mp-input" type="url" value={youtubeVideoUrl}
|
||||
<input
|
||||
id="listingPluginYoutubeUrl"
|
||||
name="youtubeVideoUrl"
|
||||
className="mp-input"
|
||||
type="url"
|
||||
value={youtubeVideoUrl}
|
||||
onChange={e => setYoutubeVideoUrl(e.target.value)}
|
||||
placeholder="https://www.youtube.com/watch?v=..." />
|
||||
placeholder="https://www.youtube.com/watch?v=..."
|
||||
autoComplete="url"
|
||||
/>
|
||||
</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' }}>
|
||||
<input
|
||||
id="listingPluginEarlyAccess"
|
||||
name="earlyAccess"
|
||||
type="checkbox"
|
||||
checked={isEarlyAccess}
|
||||
onChange={e => setIsEarlyAccess(e.target.checked)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user