Initial commit of complete Beat Saber Pricing Website
This commit is contained in:
commit
ac0249ceb9
5 changed files with 1232 additions and 0 deletions
68
README.md
Normal file
68
README.md
Normal 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
111
config.json
Normal 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
137
index.html
Normal 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
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');
|
||||
}
|
||||
}
|
||||
530
styles.css
Normal file
530
styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue