chore: improve ui

This commit is contained in:
Alexander Medvedev 2026-03-31 18:46:51 +02:00
parent 2eabb9b7a5
commit 834ea2aa39
15 changed files with 436 additions and 72 deletions

6
public/robots.txt Normal file
View File

@ -0,0 +1,6 @@
User-agent: *
Allow: /
Disallow: /admin
Disallow: /dashboard
Sitemap: https://pumpkinmc.net/sitemap.xml

View File

@ -108,16 +108,24 @@ h1, h2, h3 { font-weight: 900; letter-spacing: -1px; }
.navbar {
background: rgba(10, 10, 10, 0.7);
backdrop-filter: blur(12px);
padding: 1.5rem 2.5rem;
padding: 1rem 2.5rem;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 1000;
}
.navbar a { text-decoration: none !important; }
.logo { font-size: 1.5rem; font-weight: 900; text-transform: uppercase; letter-spacing: -1px; text-decoration: none; color: var(--text); }
.logo { font-size: 1.5rem; font-weight: 900; text-transform: uppercase; letter-spacing: -1px; text-decoration: none; color: var(--text); z-index: 1001; }
.logo span { color: var(--primary); }
.nav-links { flex-grow: 1; margin-left: 3rem; }
.nav-content {
display: flex;
align-items: center;
flex-grow: 1;
}
.nav-links { margin-left: 3rem; }
.nav-links a { margin: 0 1rem; text-decoration: none; color: var(--text-muted); font-weight: 600; font-size: 0.9rem; text-transform: uppercase; transition: color 0.2s; }
.nav-links a.active, .nav-links a:hover { color: var(--text); text-decoration: none !important; }
.nav-search {
@ -142,10 +150,74 @@ h1, h2, h3 { font-weight: 900; letter-spacing: -1px; }
.nav-search input:focus {
border-color: var(--primary);
}
.nav-actions { display: flex; align-items: center; gap: 1.5rem; }
.nav-actions { display: flex; align-items: center; gap: 1rem; }
.nav-user-link { font-weight: 700; text-decoration: none; color: var(--text); }
.balance { font-family: var(--font-mono); color: var(--success); font-weight: 700; }
footer { text-align: center; padding: 4rem 2rem; border-top: 1px solid var(--border); color: var(--text-muted); }
.mobile-menu-toggle {
display: none;
background: none;
border: none;
color: var(--text);
cursor: pointer;
padding: 0.5rem;
z-index: 1001;
}
@media (max-width: 1024px) {
.navbar { padding: 1rem 1.5rem; }
.nav-links { margin-left: 1.5rem; }
.nav-links a { margin: 0 0.5rem; }
.nav-search { margin: 0 1rem; }
}
@media (max-width: 768px) {
.mobile-menu-toggle { display: block; }
.nav-content {
position: fixed;
top: 0;
right: -100%;
width: 80%;
max-width: 300px;
height: 100vh;
background: var(--surface);
flex-direction: column;
padding: 80px 2rem 2rem;
transition: right 0.3s ease;
box-shadow: -5px 0 15px rgba(0,0,0,0.5);
z-index: 1000;
align-items: flex-start;
}
.nav-content.open { right: 0; }
.nav-links { margin: 0 0 2rem 0; display: flex; flex-direction: column; gap: 1.5rem; }
.nav-links a { margin: 0; font-size: 1.1rem; }
.nav-search { margin: 0 0 2rem 0; max-width: none; width: 100%; }
.nav-actions { flex-direction: column; width: 100%; gap: 1rem; }
.nav-actions .btn { width: 100%; }
.plugin-detail-layout { grid-template-columns: 1fr; gap: 2rem; }
.sidebar-widget { position: static; }
.plugin-title { font-size: 2.5rem; }
.plugin-detail-header { flex-direction: column; align-items: flex-start; gap: 1rem; }
.container { padding: 2rem 1rem; }
.hero { padding: 4rem 1rem; }
.hero h1 { font-size: 2.5rem; }
.hero-stats { flex-direction: column; gap: 1.5rem; margin-top: 3rem; }
.review-summary-grid { grid-template-columns: 1fr; gap: 1.5rem; }
.review-avg-block { border-right: none; border-bottom: 1px solid var(--border); padding-right: 0; padding-bottom: 1.5rem; }
.profile-layout { flex-direction: column; }
.profile-nav { position: static; min-width: 0; width: 100%; margin-bottom: 1rem; }
}
@media (max-width: 480px) {
.plugin-grid { grid-template-columns: 1fr; }
.library-grid { grid-template-columns: 1fr; }
.plugin-title { font-size: 2rem; }
.auth-card, .settings-card { padding: 2rem 1.5rem; }
}
/* --- Hero Section --- */
.hero {

View File

@ -168,48 +168,65 @@ const DashboardLayout = () => {
const Navbar = ({ user }: any) => {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState('');
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())}`);
setSearchQuery('');
setIsMenuOpen(false);
}
};
const toggleMenu = () => setIsMenuOpen(!isMenuOpen);
return (
<nav className="navbar">
<div className="logo" style={{ display: 'flex', alignItems: 'center' }}>
<Link to="/">
<Link to="/" onClick={() => setIsMenuOpen(false)}>
<img src="/icon.png" alt="Market Logo" style={{ height: '50px', marginRight: '0px', verticalAlign: 'middle' }} />
<span>MARKET</span>
</Link>
</div>
<div className="nav-links">
<NavLink to="/">{t('home')}</NavLink>
{user && <NavLink to="/dashboard">{t('dashboard')}</NavLink>}
{user && user.role === 'admin' && <NavLink to="/admin">Admin</NavLink>}
</div>
<div className="nav-search">
<input
type="text"
placeholder={t('search')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleSearch}
/>
</div>
<div className="nav-actions">
{user ? (
<>
<NavLink to="/settings" className="nav-user-link">{user.username}</NavLink>
</>
) : (
<>
<Link to="/login" className="btn btn-secondary">{t('login')}</Link>
<Link to="/register" className="btn">{t('register')}</Link>
</>
)}
<button className="mobile-menu-toggle" onClick={toggleMenu} aria-label="Toggle menu">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{isMenuOpen ? (
<path d="M18 6L6 18M6 6l12 12" />
) : (
<path d="M3 12h18M3 6h18M3 18h18" />
)}
</svg>
</button>
<div className={`nav-content ${isMenuOpen ? 'open' : ''}`}>
<div className="nav-links">
<NavLink to="/" onClick={() => setIsMenuOpen(false)}>{t('home')}</NavLink>
{user && <NavLink to="/dashboard" onClick={() => setIsMenuOpen(false)}>{t('dashboard')}</NavLink>}
{user && user.role === 'admin' && <NavLink to="/admin" onClick={() => setIsMenuOpen(false)}>Admin</NavLink>}
</div>
<div className="nav-search">
<input
type="text"
placeholder={t('search')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleSearch}
/>
</div>
<div className="nav-actions">
{user ? (
<>
<NavLink to="/settings" className="nav-user-link" onClick={() => setIsMenuOpen(false)}>{user.username}</NavLink>
</>
) : (
<>
<Link to="/login" className="btn btn-secondary" onClick={() => setIsMenuOpen(false)}>{t('login')}</Link>
<Link to="/register" className="btn" onClick={() => setIsMenuOpen(false)}>{t('register')}</Link>
</>
)}
</div>
</div>
</nav>
);

96
src/components/SEO.tsx Normal file
View File

@ -0,0 +1,96 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
interface SEOProps {
title?: string;
description?: string;
keywords?: string;
canonical?: string;
ogType?: string;
ogImage?: string;
}
const SEO = ({
title,
description = "Explore powerful, performance-optimized Minecraft plugins at Pumpkin Market. The one-stop marketplace for WASM-powered MC plugins.",
keywords = "minecraft, plugins, wasm, marketplace, mc plugins, development",
canonical,
ogType = "website",
ogImage = "/icon.png",
}: SEOProps) => {
const location = useLocation();
const siteName = "Pumpkin Market";
const fullTitle = title ? `${title} | ${siteName}` : `${siteName} - WASM Minecraft Plugin Marketplace`;
const url = canonical || `https://pumpkinmc.net${location.pathname}`;
useEffect(() => {
// Update Title
document.title = fullTitle;
// Update Meta Description
let metaDescription = document.querySelector('meta[name="description"]');
if (!metaDescription) {
metaDescription = document.createElement('meta');
metaDescription.setAttribute('name', 'description');
document.head.appendChild(metaDescription);
}
metaDescription.setAttribute('content', description);
// Update Meta Keywords
let metaKeywords = document.querySelector('meta[name="keywords"]');
if (!metaKeywords) {
metaKeywords = document.createElement('meta');
metaKeywords.setAttribute('name', 'keywords');
document.head.appendChild(metaKeywords);
}
metaKeywords.setAttribute('content', keywords);
// Update Canonical
let linkCanonical = document.querySelector('link[rel="canonical"]');
if (!linkCanonical) {
linkCanonical = document.createElement('link');
linkCanonical.setAttribute('rel', 'canonical');
document.head.appendChild(linkCanonical);
}
linkCanonical.setAttribute('href', url);
// Update Open Graph tags
const updateOG = (property: string, content: string) => {
let tag = document.querySelector(`meta[property="${property}"]`);
if (!tag) {
tag = document.createElement('meta');
tag.setAttribute('property', property);
document.head.appendChild(tag);
}
tag.setAttribute('content', content);
};
updateOG('og:title', fullTitle);
updateOG('og:description', description);
updateOG('og:url', url);
updateOG('og:type', ogType);
updateOG('og:image', ogImage);
updateOG('og:site_name', siteName);
// Update Twitter Card tags
const updateTwitter = (name: string, content: string) => {
let tag = document.querySelector(`meta[name="${name}"]`);
if (!tag) {
tag = document.createElement('meta');
tag.setAttribute('name', name);
document.head.appendChild(tag);
}
tag.setAttribute('content', content);
};
updateTwitter('twitter:card', 'summary_large_image');
updateTwitter('twitter:title', fullTitle);
updateTwitter('twitter:description', description);
updateTwitter('twitter:image', ogImage);
}, [fullTitle, description, keywords, url, ogType, ogImage]);
return null; // This component doesn't render anything
};
export default SEO;

View File

@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import api from '../api';
import SEO from '../components/SEO';
const Home = () => {
const { t, i18n } = useTranslation();
@ -12,10 +13,13 @@ const Home = () => {
if (hasFetched.current) return;
api.get('/plugins', { params: { q: '', sort: 'newest' } })
.then(res => {
setPlugins(res.data);
setPlugins(Array.isArray(res.data) ? res.data : []);
hasFetched.current = true;
})
.catch(err => console.error('Fetch error:', err));
.catch(err => {
console.error('Fetch error:', err);
setPlugins([]);
});
}, []);
const getDescription = (translatedStr: string) => {
@ -59,6 +63,10 @@ const Home = () => {
return (
<>
<SEO
title="Home"
description="Discover the best WASM-powered Minecraft plugins at Pumpkin Market. Performance, security, and variety in one place."
/>
<style>{`
.home-plugin-grid {
display: grid;

View File

@ -5,6 +5,7 @@ import { useAuth } from '../App';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useNavigate } from 'react-router-dom';
import SEO from '../components/SEO';
const REPORT_REASONS = [
'Malware or malicious code',
@ -138,12 +139,12 @@ const PluginDetail = () => {
}, [plugin, selectedLanguage]);
const hasReviewed = useMemo(() => {
if (!user || !plugin?.reviews) return false;
if (!user || !plugin?.reviews || !Array.isArray(plugin.reviews)) return false;
return plugin.reviews.some((r: any) => r.username === user.username);
}, [plugin?.reviews, user]);
const stats = useMemo(() => {
if (!plugin?.reviews || plugin.reviews.length === 0) return null;
if (!plugin?.reviews || !Array.isArray(plugin.reviews) || plugin.reviews.length === 0) return null;
const total = plugin.reviews.length;
const distribution = { 5: 0, 4: 0, 3: 0, 2: 0, 1: 0 };
let sum = 0;
@ -257,8 +258,39 @@ const PluginDetail = () => {
return 'Download Latest WASM';
})();
const productSchema = {
"@context": "https://schema.org/",
"@type": "SoftwareApplication",
"name": plugin.name,
"operatingSystem": "Minecraft",
"applicationCategory": "GameApplication",
"offers": {
"@type": "Offer",
"price": plugin.type === 'paid' ? (plugin.price_cents / 100).toFixed(2) : "0.00",
"priceCurrency": "EUR"
},
"aggregateRating": stats ? {
"@type": "AggregateRating",
"ratingValue": stats.avg,
"reviewCount": stats.total
} : undefined,
"author": {
"@type": "Person",
"name": plugin.dev_name
}
};
return (
<div className="container">
<SEO
title={plugin.name}
description={currentDescription.substring(0, 160)}
ogType="product"
ogImage={plugin.preview_path || "/icon.png"}
/>
<script type="application/ld+json">
{JSON.stringify(productSchema)}
</script>
{/* ── Payment success banner ── */}
{showPaymentSuccess && (
@ -301,7 +333,7 @@ const PluginDetail = () => {
<div className="plugin-main-content">
{/* SCREENSHOTS */}
{plugin.screenshots && plugin.screenshots.length > 0 && (
{Array.isArray(plugin.screenshots) && plugin.screenshots.length > 0 && (
<div className="screenshot-gallery">
<img
src={mainScreenshot || plugin.screenshots[0].path}
@ -366,7 +398,7 @@ const PluginDetail = () => {
)}
<div className="review-list-premium">
{plugin.reviews?.map((r: any) => (
{Array.isArray(plugin.reviews) && plugin.reviews.map((r: any) => (
<div key={r.id} className="review-item-premium">
<div className="review-item-top">
<div className={`review-avatar ${getAvatarClass(r.username)}`}>

View File

@ -53,7 +53,7 @@ const ProfilePage = () => {
setLibraryLoading(true);
setLibraryError(null);
api.get('/user/library')
.then(res => setLibrary(res.data))
.then(res => setLibrary(Array.isArray(res.data) ? res.data : []))
.catch(() => setLibraryError('Failed to load your library. Please try again.'))
.finally(() => setLibraryLoading(false));
}, [activeTab]);

View File

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useSearchParams, Link } from 'react-router-dom';
import api from '../api';
import SEO from '../components/SEO';
const SearchResultsPage = () => {
const [searchParams] = useSearchParams();
@ -19,7 +20,7 @@ const SearchResultsPage = () => {
const response = await api.get('/plugins', {
params: { sort: sort || undefined },
});
setPlugins(response.data);
setPlugins(Array.isArray(response.data) ? response.data : []);
} catch (err: any) {
console.error('Error fetching plugins:', err);
setError('Failed to load plugins. Please try again.');
@ -57,6 +58,10 @@ const SearchResultsPage = () => {
return (
<div className="container">
<SEO
title={searchQuery ? `Search results for "${searchQuery}"` : "All Plugins"}
description={`Browse our collection of Minecraft plugins. ${searchQuery ? `Showing results for ${searchQuery}.` : ''}`}
/>
<h1 className="section-title">
{searchQuery
? <>Search Results <span>for "{searchQuery}"</span></>

View File

@ -121,3 +121,55 @@
padding: 4rem;
opacity: 0.6;
}
@media (max-width: 768px) {
.admin-header {
flex-direction: column;
align-items: center;
text-align: center;
}
.admin-tabs {
width: 100%;
justify-content: center;
}
.admin-tabs button {
flex: 1;
padding: 8px 10px;
font-size: 0.85rem;
}
.admin-table thead {
display: none;
}
.admin-table, .admin-table tbody, .admin-table tr, .admin-table td {
display: block;
width: 100%;
}
.admin-table tr {
margin-bottom: 1.5rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 1rem;
}
.admin-table td {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
text-align: right;
}
.admin-table td:last-child {
border-bottom: none;
padding-bottom: 0;
}
.admin-table td::before {
content: attr(data-label);
font-weight: 700;
font-size: 0.75rem;
text-transform: uppercase;
color: var(--text-muted, #888);
margin-right: 1rem;
text-align: left;
}
}

View File

@ -19,13 +19,15 @@ const AdminPanel = () => {
try {
if (activeTab === 'users') {
const res = await api.get('/admin/users');
setUsers(res.data);
setUsers(Array.isArray(res.data) ? res.data : []);
} else {
const res = await api.get('/admin/plugins');
setPlugins(res.data);
setPlugins(Array.isArray(res.data) ? res.data : []);
}
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to fetch data');
setUsers([]);
setPlugins([]);
} finally {
setLoading(false);
}
@ -92,13 +94,13 @@ const AdminPanel = () => {
<tbody>
{users.map(u => (
<tr key={u.id}>
<td>{u.id}</td>
<td>{u.username}</td>
<td>{u.email}</td>
<td><span className={`role-badge ${u.role}`}>{u.role}</span></td>
<td>{u.stripe_ready ? '✅' : '❌'}</td>
<td>{new Date(u.created_at).toLocaleDateString()}</td>
<td>
<td data-label="ID">{u.id}</td>
<td data-label="Username">{u.username}</td>
<td data-label="Email">{u.email}</td>
<td data-label="Role"><span className={`role-badge ${u.role}`}>{u.role}</span></td>
<td data-label="Stripe">{u.stripe_ready ? '✅' : '❌'}</td>
<td data-label="Created">{new Date(u.created_at).toLocaleDateString()}</td>
<td data-label="Actions">
<button className="btn-delete" onClick={() => deleteUser(u.id)}>Delete</button>
</td>
</tr>
@ -122,14 +124,14 @@ const AdminPanel = () => {
<tbody>
{plugins.map(p => (
<tr key={p.id}>
<td>{p.id}</td>
<td>{p.name}</td>
<td><span className={`type-badge ${p.type}`}>{p.type}</span></td>
<td>€{(p.price_cents / 100).toFixed(2)}</td>
<td>{p.dev_name}</td>
<td>{p.downloads}</td>
<td>{p.views}</td>
<td>
<td data-label="ID">{p.id}</td>
<td data-label="Name">{p.name}</td>
<td data-label="Type"><span className={`type-badge ${p.type}`}>{p.type}</span></td>
<td data-label="Price">€{(p.price_cents / 100).toFixed(2)}</td>
<td data-label="Developer">{p.dev_name}</td>
<td data-label="Downloads">{p.downloads}</td>
<td data-label="Views">{p.views}</td>
<td data-label="Actions">
<button className="btn-delete" onClick={() => deletePlugin(p.id)}>Delete</button>
</td>
</tr>

View File

@ -173,9 +173,82 @@
border-radius: 6px;
border: 1px solid var(--dash-border);
}
.switch-label {
font-weight: 500;
color: var(--dash-text);
@media (max-width: 768px) {
.dashboard-nav {
display: flex;
overflow-x: auto;
white-space: nowrap;
margin-bottom: 2rem;
-webkit-overflow-scrolling: touch;
padding-bottom: 5px;
}
.dashboard-nav a {
margin-right: 1.5rem;
padding: 0.75rem 0;
font-size: 0.85rem;
}
.dashboard-grid-layout {
grid-template-columns: 1fr;
gap: 1.5rem;
}
.kpi-row {
grid-template-columns: 1fr;
}
.dashboard-grid-v2 {
grid-template-columns: 1fr;
}
.dashboard-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.dashboard-top-nav {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.kpi-grid {
grid-template-columns: 1fr;
}
/* Responsive Tables */
.data-table thead {
display: none; /* Hide headers on mobile */
}
.data-table, .data-table tbody, .data-table tr, .data-table td {
display: block;
width: 100%;
}
.data-table tr {
margin-bottom: 1.5rem;
background: var(--dash-surface);
border: 1px solid var(--dash-border);
border-radius: 12px;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.02);
}
.data-table td {
text-align: left !important;
padding: 0.75rem 0;
border: none;
border-bottom: 1px solid var(--dash-surface-soft);
display: flex;
justify-content: space-between;
align-items: center;
}
.data-table td:last-child {
border-bottom: none;
padding-bottom: 0;
margin-top: 0.5rem;
}
.data-table td::before {
content: attr(data-label);
font-weight: 700;
font-size: 0.75rem;
text-transform: uppercase;
color: var(--dash-text-muted);
}
}
.switch {
position: relative;

View File

@ -59,23 +59,23 @@ const DashboardPlugins = () => {
<tbody>
{plugins.map((p: any) => (
<tr key={p.id}>
<td>
<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 className="mono">{p.downloads.toLocaleString()}</td>
<td className="mono" style={{ color: 'var(--success)' }}>
<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>
<td data-label="Dates">
<div style={{ fontSize: '0.8rem', color: 'var(--dash-text-muted)' }}>
<div>Created: {formatDate(p.createdAt)}</div>
<div>Updated: {formatDate(p.updatedAt)}</div>
</div>
</td>
<td style={{ textAlign: 'right' }}>
<td data-label="Actions" style={{ textAlign: 'right' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
{/* View on Marketplace */}
<button

View File

@ -18,14 +18,15 @@ export const useAnalytics = () => {
});
// Ensure we are accessing the correct keys from your Express res.json
setTimeSeries(res.data.timeSeries || []);
setMapData(res.data.mapData || []);
setBreakdowns(res.data.breakdowns || []);
setTimeSeries(Array.isArray(res.data.timeSeries) ? res.data.timeSeries : []);
setMapData(Array.isArray(res.data.mapData) ? res.data.mapData : []);
setBreakdowns(Array.isArray(res.data.breakdowns) ? res.data.breakdowns : []);
} catch (error) {
console.error("Analytics Fetch Error:", error);
// Reset data on error so the UI doesn't show stale/broken stats
setTimeSeries([]);
setMapData([]);
setBreakdowns([]);
} finally {
setLoading(false);
}

View File

@ -337,7 +337,7 @@ const PrivacyPolicyPage = () => {
/* ── Sidebar ── */
.pp-sidebar {
position: sticky;
top: 76px;
top: 100px;
}
.sidebar-label {
@ -386,7 +386,7 @@ const PrivacyPolicyPage = () => {
padding: 28px 32px;
margin-bottom: 16px;
transition: border-color 0.2s;
scroll-margin-top: 80px;
scroll-margin-top: 100px;
cursor: default;
}

View File

@ -288,7 +288,7 @@ const TermsOfServicePage = () => {
/* ── Sidebar ── */
.tos-sidebar {
position: sticky;
top: 76px;
top: 100px;
}
.sidebar-label {
@ -337,9 +337,9 @@ const TermsOfServicePage = () => {
padding: 28px 32px;
margin-bottom: 16px;
transition: border-color 0.2s;
scroll-margin-top: 80px;
scroll-margin-top: 100px;
cursor: default;
}
.tos-section:hover { border-color: rgba(255, 107, 0, 0.2); }
.tos-section.active { border-color: rgba(255, 107, 0, 0.35); }