Initial commit of complete Beat Saber Pricing Website
This commit is contained in:
commit
ac0249ceb9
5 changed files with 1232 additions and 0 deletions
386
script.js
Normal file
386
script.js
Normal 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');
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue