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

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');
}
}