From ac0249ceb977fec136e81f5efa4b0a63b70d8ebf Mon Sep 17 00:00:00 2001 From: cosmo Date: Mon, 16 Mar 2026 03:15:20 +0100 Subject: [PATCH] Initial commit of complete Beat Saber Pricing Website --- README.md | 68 +++++++ config.json | 111 +++++++++++ index.html | 137 ++++++++++++++ script.js | 386 ++++++++++++++++++++++++++++++++++++++ styles.css | 530 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1232 insertions(+) create mode 100644 README.md create mode 100644 config.json create mode 100644 index.html create mode 100644 script.js create mode 100644 styles.css diff --git a/README.md b/README.md new file mode 100644 index 0000000..d81cdcc --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/config.json b/config.json new file mode 100644 index 0000000..30fdf8e --- /dev/null +++ b/config.json @@ -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 + } + ] + } +} \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..850b802 --- /dev/null +++ b/index.html @@ -0,0 +1,137 @@ + + + + + + Custom Mapping Pricing + + + + + + + + + + + + + + + +
+
+ +
+ + +
+

Loading...

+

Fetching configuration...

+ + +
+ + +
+ + +
+

Song Source

+
+ + +
+ + +
+ + +
+ + +
+

Included Difficulty

+

Select the difficulty included in your base price.

+
+ +
+
+ + +
+

Extra Difficulties

+

Add additional difficulties to your map.

+
+ +
+
+ + +
+

Lighting Options

+
+ +
+
+ + +
+

Extras / Addons

+
+ +
+
+ +
+ +
+ + +
+

Loading...

+
+ +
+ + +
+
+
+
Estimated Total
+
Rate: $0/min
+ +
+ +
+
+ + + + + + + + + diff --git a/script.js b/script.js new file mode 100644 index 0000000..97b2812 --- /dev/null +++ b/script.js @@ -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 = ``; + 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 '; +} + +function updateVideoInfo() { + document.getElementById('fetch-btn').innerHTML = ' 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'); + } +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..7f66e85 --- /dev/null +++ b/styles.css @@ -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; + } +}