Initial commit of complete Beat Saber Pricing Website

This commit is contained in:
CPTN Cosmo 2026-03-16 03:15:20 +01:00
commit ac0249ceb9
5 changed files with 1232 additions and 0 deletions

68
README.md Normal file
View file

@ -0,0 +1,68 @@
# Beat Saber Custom Mapping Pricing
A modern, responsive, configuration-driven pricing calculator for Beat Saber custom mapping commissions. Simply paste a YouTube URL and the tool automatically grabs the actual duration to exactly calculate standard and fractional pricing dynamically.
It outputs a pre-filled PayPal.Me link with the final cost and an itemized order note tracking the requested map and selected options!
## Features
* **YouTube Ingestion:** Invisible IFrame API queries the video and extracts exact duration down to the second.
* **Exact Fractional Pricing:** `(Base Price Per Minute + Selected Extras Per Minute) / 60 * Total Seconds`
* **Included Difficulty Logic:** Your Base Price always accounts for exactly 1 included difficulty. When a user selects their "Base Difficulty", it is prevented from being added as an "Extra" paid difficulty.
* **Smart Discounts:** Support for Percentages, Flat Total, and Flat Base discounts using easy URL query parameters.
* **Glassmorphism Aesthetic:** Animated, glowing neon red and blue UI styling without external bloated CSS frameworks.
* **Fully Configured by JSON:** You do not need to rewrite HTML to change descriptions, names, social links, or complex pricing logic.
## How to Set Up
Due to browser CORS permissions, the system cannot fetch the localized `config.json` configuration file simply by double-clicking `index.html`. It must be run on a local HTTP server or hosted on an active website.
**To run locally for testing:**
```bash
python3 -m http.server 8000
```
Then visit `http://localhost:8000` in your browser.
## Configuration Options (`config.json`)
All primary values are declared inside `config.json`:
```json
{
"site": {
"headerTitle": "Your custom title",
"introText": "Your custom introduction rules/text",
"bottomText": "Your custom footer disclaimer text",
"socialLinks": [
{
"icon": "fa-brands fa-discord", // FontAwesome CSS classes
"url": "https://discord.com",
"label": "Discord"
}
],
"paypalUser": "YourPaypalMeUsername" // E.g., paypal.me/Joetastic
},
"pricing": {
"basePricePerMinute": 15, // The $ rate per minute of mapping
"difficulties": [...], // Available difficulties options
"lighting": [...], // Available lighting tiers
"addons": [...] // Misc options
}
}
```
*For Difficulties, Lighting, and Addons, each object accepts:*
* `id`: The programmatic reference ID
* `name`: The user-facing display name
* `description`: The subtext beneath the option in the UI
* `price`: The cost of the extra. Note: For Difficulties, this price is per *minute*. It is added to the base rate *before* the duration calculation happens.
## Applying Custom Discounts (`?discount=`)
You can generate special links to automatically apply discounts when users load the page. It will prominently display a green banner explaining the discount to the user and recalculate the final cost before creating the tracked PayPal link.
* `?discount_pct=20` Takes **20% Off** the final calculated total cost.
* `?discount_total=10` Takes a flat **$10.00 Off** the final calculated total cost.
* `?discount_base=5` Deducts **$5.00** from your standard `basePricePerMinute` configuration *before* any duration or extras logic is calculated.
**Example Link:**
```
http://localhost:8000/?discount_pct=15
```

111
config.json Normal file
View file

@ -0,0 +1,111 @@
{
"site": {
"headerTitle": "Beat Saber Custom Mapping",
"introText": "Get a custom map for your favorite song. Paste a YouTube URL to get started and see estimated pricing based on song length, difficulties, and custom lighting.",
"bottomText": "Pricing is an estimate. Final cost may vary based on song complexity. Contact me for details.",
"socialLinks": [
{
"icon": "fa-brands fa-discord",
"url": "https://discord.com",
"label": "Discord"
},
{
"icon": "fa-brands fa-twitter",
"url": "https://twitter.com",
"label": "Twitter"
},
{
"icon": "fa-brands fa-youtube",
"url": "https://youtube.com",
"label": "YouTube"
}
],
"paypalUser": "Joetastic"
},
"pricing": {
"basePricePerMinute": 40,
"difficulties": [
{
"id": "easy",
"name": "Easy",
"description": "Slow, relaxed mapping perfect for beginners.",
"price": 15,
"tier": "low"
},
{
"id": "normal",
"name": "Normal",
"description": "A step up in speed, introducing basic patterns.",
"price": 15,
"tier": "low"
},
{
"id": "hard",
"name": "Hard",
"description": "Faster pacing with more complex rhythmic patterns.",
"price": 20,
"tier": "high"
},
{
"id": "expert",
"name": "Expert",
"description": "Challenging patterns requiring good flow and stamina.",
"price": 20,
"tier": "high"
},
{
"id": "expertPlus",
"name": "Expert+",
"description": "Maximum difficulty. Fast, complex, and stamina-draining.",
"price": 20,
"tier": "expert"
}
],
"lighting": [
{
"id": "automated",
"name": "Automated (Free)",
"description": "Basic auto-generated lights using Beat Saber's default system.",
"price": 0
},
{
"id": "standard",
"name": "Standard Custom",
"description": "Hand-crafted lighting events synced nicely to the music.",
"price": 20
},
{
"id": "chromaBasic",
"name": "Chroma+ / Basic V3",
"description": "Custom colors and basic V3 environment enhancements.",
"price": 30
},
{
"id": "chromaAdvanced",
"name": "Chroma++ / Advanced V3",
"description": "Extensive use of Chroma features, lasers, and complex V3 environment manipulation.",
"price": 40
}
],
"addons": [
{
"id": "oneSaber",
"name": "One Saber",
"description": "Add a dedicated One Saber difficulty.",
"price": 10
},
{
"id": "noArrows",
"name": "No Arrows",
"description": "Add a No Arrows difficulty.",
"price": 5
},
{
"id": "customColors",
"name": "Custom Color Scheme",
"description": "Specific player and environment colors chosen for the map.",
"price": 5
}
]
}
}

137
index.html Normal file
View file

@ -0,0 +1,137 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Custom Mapping Pricing</title>
<!-- Google Fonts: Outfit for a modern, geometric look -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&display=swap" rel="stylesheet">
<!-- Font Awesome for Social Icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Custom CSS -->
<link rel="stylesheet" href="styles.css">
</head>
<body>
<!-- Background Animated Elements -->
<div class="bg-glow bg-blue"></div>
<div class="bg-glow bg-red"></div>
<div class="container">
<!-- Dynamic Header Section -->
<header class="glass-panel text-center">
<h1 id="site-title" class="glitch-text" data-text="Loading...">Loading...</h1>
<p id="site-intro" class="subtitle">Fetching configuration...</p>
<div id="social-links" class="social-container">
<!-- Social links injected here -->
</div>
</header>
<!-- Main Content Grid -->
<main class="grid-content">
<!-- Video Input / Duration Section -->
<section class="glass-panel main-section">
<h2><i class="fa-brands fa-youtube icon-red"></i> Song Source</h2>
<div class="input-group">
<input type="text" id="youtube-url" placeholder="Paste YouTube URL here..." autocomplete="off">
<button id="fetch-btn" class="neon-btn neon-blue">Fetch Info <i class="fa-solid fa-arrow-right"></i></button>
</div>
<div class="video-info hidden" id="video-info">
<div class="info-row">
<span class="label">Song:</span>
<span class="value yt-title" id="video-title-display">Loading...</span>
</div>
<div class="info-row">
<span class="label">Duration:</span>
<span class="value" id="duration-display">0:00</span>
</div>
<div class="info-row highlight">
<span class="label">Base Cost (<span id="base-price-display">$0</span>/min):</span>
<span class="value neon-text-blue" id="base-cost-display">$0.00</span>
</div>
<p class="help-text">The base cost covers the mapping time and includes exactly ONE difficulty of your choice.</p>
</div>
<div id="error-message" class="error-msg hidden"></div>
</section>
<!-- Options Container -->
<div class="options-grid disabled-until-fetch" id="options-container">
<!-- Base Difficulty Selection -->
<section class="glass-panel">
<h2><i class="fa-solid fa-cube icon-blue"></i> Included Difficulty</h2>
<p class="section-desc">Select the difficulty included in your base price.</p>
<div id="base-difficulty-options" class="option-list">
<!-- Radios injected here -->
</div>
</section>
<!-- Extra Difficulties Selection -->
<section class="glass-panel">
<h2><i class="fa-solid fa-cubes icon-red"></i> Extra Difficulties</h2>
<p class="section-desc">Add additional difficulties to your map.</p>
<div id="extra-difficulty-options" class="option-list">
<!-- Checkboxes injected here -->
</div>
</section>
<!-- Lighting Selection -->
<section class="glass-panel">
<h2><i class="fa-solid fa-lightbulb icon-blue"></i> Lighting Options</h2>
<div id="lighting-options" class="option-list">
<!-- Radios injected here -->
</div>
</section>
<!-- Addons Selection -->
<section class="glass-panel">
<h2><i class="fa-solid fa-puzzle-piece icon-red"></i> Extras / Addons</h2>
<div id="addons-options" class="option-list">
<!-- Checkboxes injected here -->
</div>
</section>
</div>
</main>
<!-- Bottom Dynamic Text -->
<footer class="footer-text text-center">
<p id="site-bottom">Loading...</p>
</footer>
</div>
<!-- Sticky Total Pricing Bar -->
<div class="total-bar glass-panel-heavy">
<div class="total-content">
<div class="total-info">
<div class="total-label">Estimated Total</div>
<div class="selected-summary" id="selected-summary">Rate: $0/min</div>
<div class="discount-summary hidden" id="discount-summary">10% Discount Applied!</div>
</div>
<div class="price-container">
<div class="total-price neon-text-red" id="total-price">$0.00</div>
<a href="#" id="paypal-btn" class="neon-btn checkout-btn hidden" target="_blank">
<i class="fa-brands fa-paypal"></i> Pay via PayPal
</a>
</div>
</div>
</div>
<!-- Hidden YouTube Player for API -->
<div id="yt-player-container" style="display: none;"></div>
<!-- Scripts -->
<script src="https://www.youtube.com/iframe_api"></script>
<script src="script.js"></script>
</body>
</html>

386
script.js Normal file
View file

@ -0,0 +1,386 @@
let config = {};
let player = null;
let currentDurationSec = 0;
// Wait for DOM to finish loading
document.addEventListener('DOMContentLoaded', () => {
fetchConfig();
});
// Setup YouTube API
function onYouTubeIframeAPIReady() {
// We don't initialize a player yet until the user provides a URL
}
// Format seconds into M:SS
function formatTime(seconds) {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
// Extract YouTube ID from various URL formats
function extractVideoID(url) {
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/;
const match = url.match(regExp);
return (match && match[2].length === 11) ? match[2] : null;
}
// Fetch and load configuration
async function fetchConfig() {
try {
const res = await fetch('config.json');
config = await res.json();
populateUI();
setupEventListeners();
} catch (error) {
console.error("Failed to load config.json", error);
document.getElementById('site-title').innerText = "Error loading config";
document.getElementById('site-title').classList.remove('glitch-text');
}
}
// Render dynamic elements based on config
function populateUI() {
// Populate Text
document.getElementById('site-title').innerText = config.site.headerTitle;
document.getElementById('site-title').setAttribute('data-text', config.site.headerTitle);
document.getElementById('site-intro').innerText = config.site.introText;
document.getElementById('site-bottom').innerText = config.site.bottomText;
document.getElementById('base-price-display').innerText = `$${config.pricing.basePricePerMinute}`;
// Populate Social Links
const socialContainer = document.getElementById('social-links');
socialContainer.innerHTML = '';
config.site.socialLinks.forEach(link => {
const a = document.createElement('a');
a.href = link.url;
a.className = 'social-link';
a.target = '_blank';
a.title = link.label;
a.innerHTML = `<i class="${link.icon}"></i>`;
socialContainer.appendChild(a);
});
// Populate Difficulties (Base and Extra)
const baseDiffDiv = document.getElementById('base-difficulty-options');
const extraDiffDiv = document.getElementById('extra-difficulty-options');
config.pricing.difficulties.forEach((diff, index) => {
// Base Check
const baseItem = createOptionItem('radio', 'base-diff', diff, index === 0);
// Extra Check
const extraItem = createOptionItem('checkbox', 'extra-diff', diff, false);
baseDiffDiv.appendChild(baseItem);
extraDiffDiv.appendChild(extraItem);
});
// Populate Lighting
const lightingDiv = document.getElementById('lighting-options');
config.pricing.lighting.forEach((light, index) => {
lightingDiv.appendChild(createOptionItem('radio', 'lighting', light, index === 0));
});
// Populate Addons
const addonsDiv = document.getElementById('addons-options');
if (config.pricing.addons) {
config.pricing.addons.forEach(addon => {
addonsDiv.appendChild(createOptionItem('checkbox', 'addon', addon, false));
});
}
updateSelectionState();
}
// Helper to create UI options
function createOptionItem(type, name, itemData, isChecked) {
const label = document.createElement('label');
label.className = `option-item ${isChecked ? 'active' : ''}`;
label.dataset.id = itemData.id;
label.dataset.price = itemData.price;
const input = document.createElement('input');
input.type = type;
input.name = name;
input.value = itemData.id;
input.checked = isChecked;
const indicator = document.createElement('div');
indicator.className = 'indicator';
const content = document.createElement('div');
content.className = 'option-content';
const header = document.createElement('div');
header.className = 'option-header';
const title = document.createElement('span');
title.className = 'option-name';
title.innerText = itemData.name;
const price = document.createElement('span');
price.className = 'option-price';
price.innerText = itemData.price === 0 ? 'FREE' : `+$${itemData.price}`;
// Base difficulties don't show a price tag in their block because they are included
if(name === 'base-diff') price.style.display = 'none';
header.appendChild(title);
header.appendChild(price);
const desc = document.createElement('span');
desc.className = 'option-description';
desc.innerText = itemData.description;
content.appendChild(header);
content.appendChild(desc);
label.appendChild(input);
label.appendChild(indicator);
label.appendChild(content);
return label;
}
// Add DOM event listeners
function setupEventListeners() {
document.getElementById('fetch-btn').addEventListener('click', handleFetchVideo);
document.getElementById('youtube-url').addEventListener('keypress', (e) => {
if(e.key === 'Enter') handleFetchVideo();
});
// Listen for changes in the form elements
const container = document.getElementById('options-container');
container.addEventListener('change', (e) => {
if (e.target.tagName === 'INPUT') {
updateSelectionState();
calculateTotal();
}
});
}
function updateSelectionState() {
// 1. Get currently selected base difficulty
const baseSelected = document.querySelector('input[name="base-diff"]:checked').value;
// 2. Loop through Extra Difficulties
const extraInputs = document.querySelectorAll('input[name="extra-diff"]');
extraInputs.forEach(input => {
const label = input.closest('.option-item');
if (input.value === baseSelected) {
// Uncheck and hide/disable
input.checked = false;
label.style.display = 'none';
} else {
label.style.display = 'flex';
}
});
// 3. Update active classes for styling
document.querySelectorAll('.option-item').forEach(label => {
const input = label.querySelector('input');
if (input.checked) {
label.classList.add('active');
} else {
label.classList.remove('active');
}
});
}
function handleFetchVideo() {
const url = document.getElementById('youtube-url').value;
const vid = extractVideoID(url);
const errorMsg = document.getElementById('error-message');
if (!vid) {
errorMsg.innerText = "Invalid YouTube URL.";
errorMsg.classList.remove('hidden');
return;
}
errorMsg.classList.add('hidden');
document.getElementById('fetch-btn').innerText = "Loading...";
// Create YouTube iframe to fetch duration
if (player) {
player.destroy();
}
player = new YT.Player('yt-player-container', {
height: '0',
width: '0',
videoId: vid,
events: {
'onReady': onPlayerReady,
'onError': onPlayerError
}
});
}
function onPlayerReady(event) {
// Player is ready, try to get duration
currentDurationSec = event.target.getDuration();
if (currentDurationSec === 0) {
// Sometimes it needs a slight delay or needs to buffer (cue)
event.target.playVideo();
setTimeout(() => {
currentDurationSec = event.target.getDuration();
event.target.stopVideo();
updateVideoInfo();
}, 1000);
} else {
updateVideoInfo();
}
}
function onPlayerError(event) {
const errorMsg = document.getElementById('error-message');
errorMsg.innerText = "Error loading video (Maybe region locked or unplayable embed).";
errorMsg.classList.remove('hidden');
document.getElementById('fetch-btn').innerHTML = 'Fetch Info <i class="fa-solid fa-arrow-right"></i>';
}
function updateVideoInfo() {
document.getElementById('fetch-btn').innerHTML = '<i class="fa-solid fa-check"></i> Loaded';
document.getElementById('video-info').classList.remove('hidden');
document.getElementById('options-container').classList.remove('disabled-until-fetch');
// Try to get title from player data
let videoTitle = "Unknown Song";
if (player && player.getVideoData) {
const data = player.getVideoData();
if (data && data.title) {
videoTitle = data.title;
}
}
document.getElementById('video-title-display').innerText = videoTitle;
document.getElementById('video-title-display').title = videoTitle; // Add tooltip for truncated titles
document.getElementById('duration-display').innerText = formatTime(currentDurationSec);
calculateTotal();
}
function calculateTotal() {
let extraRates = 0;
const summaryItems = [];
// Add Extra Difficulties
const extraInputs = document.querySelectorAll('input[name="extra-diff"]:checked');
extraInputs.forEach(input => {
const itemLabel = input.closest('.option-item').querySelector('.option-name').innerText;
extraRates += parseFloat(input.closest('.option-item').dataset.price);
summaryItems.push(itemLabel);
});
// Add Lighting
const lightingInput = document.querySelector('input[name="lighting"]:checked');
if (lightingInput) {
const itemLabel = lightingInput.closest('.option-item').querySelector('.option-name').innerText;
extraRates += parseFloat(lightingInput.closest('.option-item').dataset.price);
if (!itemLabel.includes('Free')) {
summaryItems.push(itemLabel);
}
}
// Add Addons
const addonInputs = document.querySelectorAll('input[name="addon"]:checked');
addonInputs.forEach(input => {
const itemLabel = input.closest('.option-item').querySelector('.option-name').innerText;
extraRates += parseFloat(input.closest('.option-item').dataset.price);
summaryItems.push(itemLabel);
});
// Calculate Total Cost
let totalRatePerMinute = config.pricing.basePricePerMinute + extraRates;
let totalCost = (totalRatePerMinute / 60) * currentDurationSec;
// Check for Discounts in URL
const urlParams = new URLSearchParams(window.location.search);
const discountPct = urlParams.get('discount_pct');
const discountTotal = urlParams.get('discount_total');
const discountBase = urlParams.get('discount_base');
let discountStr = "";
const discountDiv = document.getElementById('discount-summary');
// Apply discount_base directly to the basePricePerMinute rate
let currentBasePricePerMinute = config.pricing.basePricePerMinute;
if (discountBase && !isNaN(discountBase)) {
const amount = parseFloat(discountBase);
currentBasePricePerMinute -= amount;
if (currentBasePricePerMinute < 0) currentBasePricePerMinute = 0;
discountDiv.innerText = `$${amount.toFixed(2)} Off Base Rate Applied!`;
discountDiv.classList.remove('hidden');
discountStr = ` | Base Rate Discount: -$${amount.toFixed(2)}/min`;
}
// Calculate base cost and total cost with the potentially discounted base rate
let baseCost = (currentBasePricePerMinute / 60) * currentDurationSec;
totalRatePerMinute = currentBasePricePerMinute + extraRates;
totalCost = (totalRatePerMinute / 60) * currentDurationSec;
if (discountTotal && !isNaN(discountTotal)) {
const amount = parseFloat(discountTotal);
totalCost -= amount;
if (totalCost < 0) totalCost = 0;
// Only overwrite discount summary if base wasn't already set, or append it?
// Assuming mutually exclusive discounts for simplicity, but we can just overwrite
discountDiv.innerText = `$${amount.toFixed(2)} Off Total Applied!`;
discountDiv.classList.remove('hidden');
discountStr = ` | Total Discount: -$${amount.toFixed(2)}`;
}
else if (discountPct && !isNaN(discountPct)) {
const percent = parseFloat(discountPct);
if (percent > 0 && percent <= 100) {
const discountAmount = totalCost * (percent / 100);
totalCost -= discountAmount;
discountDiv.innerText = `${percent}% Discount Applied! (-$${discountAmount.toFixed(2)})`;
discountDiv.classList.remove('hidden');
discountStr = ` | Discount: ${percent}%`;
} else if (!discountBase) {
discountDiv.classList.add('hidden');
}
} else if (!discountBase) {
discountDiv.classList.add('hidden');
}
// Base Cost Display
document.getElementById('base-cost-display').innerText = `$${baseCost.toFixed(2)}`;
// Update Summary Display
const baseDiffInput = document.querySelector('input[name="base-diff"]:checked');
const baseDiffName = baseDiffInput ? baseDiffInput.closest('.option-item').querySelector('.option-name').innerText : 'None';
document.getElementById('selected-summary').innerText = `Included: ${baseDiffName} | Extras: ${summaryItems.length ? summaryItems.join(', ') : 'None'} | Rate: $${totalRatePerMinute}/min`;
// Update Total Bar
document.getElementById('total-price').innerText = `$${totalCost.toFixed(2)}`;
// Update PayPal Button
const paypalBtn = document.getElementById('paypal-btn');
if (config.site.paypalUser && totalCost > 0) {
const amountStr = totalCost.toFixed(2);
// Construct order note with video ID and selected options
const vid = extractVideoID(document.getElementById('youtube-url').value);
const selectedOptions = [];
if (baseDiffInput) selectedOptions.push(`Base: ${baseDiffInput.value}`);
extraInputs.forEach(input => selectedOptions.push(`Extra: ${input.value}`));
if (lightingInput) selectedOptions.push(`Lighting: ${lightingInput.value}`);
addonInputs.forEach(input => selectedOptions.push(`Addon: ${input.value}`));
const orderNote = `Video: ${vid} | Options: ${selectedOptions.join(', ')}${discountStr}`;
paypalBtn.href = `https://paypal.me/${config.site.paypalUser}/${amountStr}usd?item_name=${encodeURIComponent(orderNote)}`;
paypalBtn.classList.remove('hidden');
} else {
paypalBtn.classList.add('hidden');
}
}

530
styles.css Normal file
View file

@ -0,0 +1,530 @@
:root {
--bg-dark: #090B10;
--bg-panel: rgba(18, 22, 36, 0.7);
--bg-panel-heavy: rgba(18, 22, 36, 0.95);
--neon-blue: #00E5FF;
--neon-red: #FF0055;
--text-main: #FFFFFF;
--text-muted: #A0AABF;
--text-dark: #121A2F;
--border-radius: 12px;
--transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Outfit', sans-serif;
background-color: var(--bg-dark);
color: var(--text-main);
line-height: 1.6;
min-height: 100vh;
position: relative;
overflow-x: hidden;
padding-bottom: 100px; /* Space for total bar */
}
/* Background Glows */
.bg-glow {
position: fixed;
width: 50vw;
height: 50vw;
border-radius: 50%;
filter: blur(150px);
z-index: -1;
opacity: 0.15;
animation: float 10s infinite alternate ease-in-out;
}
.bg-blue {
background: var(--neon-blue);
top: -10%;
left: -10%;
}
.bg-red {
background: var(--neon-red);
bottom: -10%;
right: -10%;
animation-delay: -5s;
}
@keyframes float {
0% { transform: translateY(0) scale(1); }
100% { transform: translateY(50px) scale(1.1); }
}
/* Layout */
.container {
max-width: 900px;
margin: 0 auto;
padding: 2rem;
}
/* Glass Panels */
.glass-panel {
background: var(--bg-panel);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: var(--border-radius);
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
transition: var(--transition);
}
.glass-panel:hover {
border-color: rgba(255, 255, 255, 0.1);
transform: translateY(-2px);
}
.glass-panel-heavy {
background: var(--bg-panel-heavy);
backdrop-filter: blur(25px);
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
/* Typography */
h1.glitch-text {
font-size: 3rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 2px;
margin-bottom: 0.5rem;
background: linear-gradient(90deg, var(--neon-blue), var(--neon-red));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
position: relative;
}
h2 {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
.subtitle {
color: var(--text-muted);
font-size: 1.1rem;
max-width: 600px;
margin: 0 auto 1.5rem;
}
.section-desc {
color: var(--text-muted);
font-size: 0.95rem;
margin-bottom: 1.5rem;
}
.text-center { text-align: center; }
.hidden { display: none !important; }
/* Colors & Neons */
.icon-blue { color: var(--neon-blue); text-shadow: 0 0 10px var(--neon-blue); }
.icon-red { color: var(--neon-red); text-shadow: 0 0 10px var(--neon-red); }
.neon-text-blue { color: var(--neon-blue); text-shadow: 0 0 8px rgba(0,229,255,0.6); font-weight: 800; }
.neon-text-red { color: var(--neon-red); text-shadow: 0 0 8px rgba(255,0,85,0.6); font-weight: 800; }
/* Social Links */
.social-container {
display: flex;
justify-content: center;
gap: 1.5rem;
margin-top: 1rem;
}
.social-link {
color: var(--text-main);
font-size: 1.5rem;
transition: var(--transition);
text-decoration: none;
opacity: 0.8;
}
.social-link:hover {
opacity: 1;
color: var(--neon-blue);
transform: scale(1.2);
text-shadow: 0 0 10px var(--neon-blue);
}
/* Input Group */
.input-group {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
input[type="text"] {
flex: 1;
background: rgba(0,0,0,0.3);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
padding: 1rem 1.5rem;
color: white;
font-size: 1.1rem;
font-family: inherit;
transition: var(--transition);
}
input[type="text"]:focus {
outline: none;
border-color: var(--neon-blue);
box-shadow: 0 0 15px rgba(0,229,255,0.2);
background: rgba(0,0,0,0.5);
}
/* Buttons */
.neon-btn {
background: transparent;
border: 2px solid var(--neon-blue);
color: var(--neon-blue);
padding: 1rem 2rem;
font-family: inherit;
font-size: 1.1rem;
font-weight: 600;
border-radius: 8px;
cursor: pointer;
transition: var(--transition);
text-shadow: 0 0 5px var(--neon-blue);
box-shadow: inset 0 0 10px rgba(0,229,255,0.1), 0 0 10px rgba(0,229,255,0.1);
display: flex;
align-items: center;
gap: 0.5rem;
white-space: nowrap;
}
.neon-btn:hover {
background: var(--neon-blue);
color: var(--text-dark);
box-shadow: 0 0 20px var(--neon-blue);
text-shadow: none;
}
.neon-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
box-shadow: none;
border-color: var(--text-muted);
color: var(--text-muted);
}
/* Video Info */
.video-info {
background: rgba(0,0,0,0.3);
border-radius: 8px;
padding: 1.5rem;
border: 1px solid rgba(255,255,255,0.05);
animation: fadeIn 0.4s ease-out;
}
.info-row {
display: flex;
justify-content: space-between;
margin-bottom: 0.75rem;
font-size: 1.1rem;
}
.info-row.highlight {
font-size: 1.3rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px dashed rgba(255,255,255,0.1);
}
.yt-title {
color: var(--neon-blue);
font-weight: 600;
max-width: 60%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: right;
}
.help-text {
font-size: 0.85rem;
color: var(--text-muted);
margin-top: 1rem;
}
.error-msg {
color: var(--neon-red);
background: rgba(255,0,85,0.1);
padding: 1rem;
border-radius: 8px;
border: 1px solid rgba(255,0,85,0.3);
margin-top: 1rem;
font-size: 0.95rem;
animation: fadeIn 0.3s;
}
/* Options Grid */
.options-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
transition: opacity 0.3s;
}
.options-grid .glass-panel {
margin-bottom: 0;
}
.disabled-until-fetch {
opacity: 0.4;
pointer-events: none;
filter: grayscale(1);
}
/* Option Items (Radio / Checkbox) */
.option-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.option-item {
display: flex;
align-items: flex-start;
padding: 1rem;
background: rgba(0,0,0,0.2);
border: 1px solid rgba(255,255,255,0.05);
border-radius: 8px;
cursor: pointer;
transition: var(--transition);
position: relative;
overflow: hidden;
}
.option-item:hover {
background: rgba(255,255,255,0.05);
transform: translateX(4px);
}
.option-item input {
display: none;
}
.option-content {
flex: 1;
display: flex;
flex-direction: column;
}
.option-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.option-name {
font-weight: 600;
font-size: 1.05rem;
}
.option-price {
font-variant-numeric: tabular-nums;
color: var(--neon-blue);
font-weight: 800;
}
.option-description {
font-size: 0.85rem;
color: var(--text-muted);
line-height: 1.4;
}
/* Checkbox/Radio Indicator */
.indicator {
width: 20px;
height: 20px;
border: 2px solid var(--text-muted);
border-radius: 4px;
margin-right: 1rem;
margin-top: 2px;
flex-shrink: 0;
transition: var(--transition);
display: flex;
align-items: center;
justify-content: center;
}
input[type="radio"] + .indicator {
border-radius: 50%;
}
input:checked + .indicator {
border-color: var(--neon-blue);
background: rgba(0,229,255,0.2);
box-shadow: 0 0 10px rgba(0,229,255,0.4);
}
input[type="radio"]:checked + .indicator::after {
content: '';
width: 10px;
height: 10px;
background: var(--neon-blue);
border-radius: 50%;
}
input[type="checkbox"]:checked + .indicator::after {
content: '✓';
color: var(--neon-blue);
font-size: 14px;
font-weight: bold;
}
/* Glow Borders for Active Items */
.option-item.active {
border-color: rgba(0,229,255,0.5);
background: rgba(0,229,255,0.05);
}
/* Base vs Extra Highlights */
#base-difficulty-options .option-item.active {
border-color: rgba(0,229,255,0.5);
}
#extra-difficulty-options .option-item.active {
border-color: rgba(255,0,85,0.5);
}
#extra-difficulty-options input:checked + .indicator {
border-color: var(--neon-red);
background: rgba(255,0,85,0.2);
box-shadow: 0 0 10px rgba(255,0,85,0.4);
}
#extra-difficulty-options input[type="checkbox"]:checked + .indicator::after {
color: var(--neon-red);
}
/* Footer Section */
.footer-text {
color: var(--text-muted);
font-size: 0.9rem;
opacity: 0.6;
margin-top: 2rem;
margin-bottom: 2rem;
}
/* Total Sticky Bar */
.total-bar {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
padding: 1.5rem 2rem;
z-index: 100;
display: flex;
justify-content: center;
box-shadow: 0 -10px 40px rgba(0,0,0,0.8);
}
.total-content {
max-width: 900px;
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
}
.price-container {
display: flex;
align-items: center;
gap: 1.5rem;
}
.checkout-btn {
font-size: 1rem;
padding: 0.75rem 1.5rem;
text-decoration: none;
background: var(--neon-blue);
color: var(--text-dark);
border: none;
box-shadow: 0 0 15px var(--neon-blue);
}
.checkout-btn:hover {
background: white;
color: var(--text-dark);
box-shadow: 0 0 25px white;
}
.checkout-btn i {
margin-right: 0.25rem;
}
.total-info {
display: flex;
flex-direction: column;
}
.selected-summary {
font-size: 0.9rem;
color: var(--text-muted);
max-width: 500px;
line-height: 1.3;
margin-top: 0.25rem;
}
.discount-summary {
font-size: 0.95rem;
color: #00E676; /* Green for discount */
font-weight: 600;
margin-top: 0.2rem;
animation: fadeIn 0.3s ease-out;
}
.total-label {
font-size: 1.5rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
}
.total-price {
font-size: 2.5rem;
font-variant-numeric: tabular-nums;
}
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
/* Responsive */
@media (max-width: 768px) {
.options-grid {
grid-template-columns: 1fr;
}
.input-group {
flex-direction: column;
}
.neon-btn {
width: 100%;
justify-content: center;
}
h1.glitch-text {
font-size: 2rem;
}
.total-label {
font-size: 1.2rem;
}
.total-price {
font-size: 2rem;
}
}